feat(notes): add AI-enhanced Notes system with semantic search (#3277)
Adds a first-class **Notes** feature to the research library — a continuation of #1464, rebuilt on current `main`. A note is stored as a `documents` row (`source_type='note'`) and reuses the existing library / RAG / embedding stack, so notes get collections, chunking, and semantic search for free.
This commit is contained in:
@@ -31,6 +31,8 @@ on:
|
||||
- 'tests/database/test_alembic_migrations.py'
|
||||
- 'tests/database/test_migration_0003_indexes.py'
|
||||
- 'tests/database/test_migration_0004_app_settings.py'
|
||||
- 'tests/database/test_migration_0021_note_tables.py'
|
||||
- 'tests/database/test_migration_0022_note_references.py'
|
||||
- 'tests/performance/database/scripts/create_compat_db.py'
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -253,7 +255,7 @@ jobs:
|
||||
|
||||
- name: Run migration version tests
|
||||
run: |
|
||||
pdm run pytest tests/database/test_migration_0003_indexes.py tests/database/test_migration_0004_app_settings.py -v --tb=short
|
||||
pdm run pytest tests/database/test_migration_0003_indexes.py tests/database/test_migration_0004_app_settings.py tests/database/test_migration_0021_note_tables.py tests/database/test_migration_0022_note_references.py -v --tb=short
|
||||
|
||||
- name: Run database initialization tests
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
name: Playwright Notes Tests
|
||||
|
||||
# Runs when a PR carries the test:notes / test:ui label. `synchronize` is
|
||||
# in the trigger list so a labeled PR RE-RUNS on every push — with
|
||||
# `labeled` alone the suite ran exactly once at label time, and later
|
||||
# commits merged with no notes check at all (the label-time result went
|
||||
# stale silently). Unlabeled PRs skip via the job-level `if`.
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
notes-ui:
|
||||
# Gate on the label being PRESENT (not on the labeled-event payload),
|
||||
# so pushes to an already-labeled PR re-run the suite.
|
||||
if: |
|
||||
github.event_name != 'pull_request' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'test:notes') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'test:ui')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
env:
|
||||
TEST_RUN_ID: t-gh-${{ github.run_id }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up PDM
|
||||
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
version: 2.26.2
|
||||
cache: true
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pdm install
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
# Both lockfiles: the Vite build step runs a ROOT `npm ci` in
|
||||
# addition to the Playwright one — hashing only the Playwright
|
||||
# lockfile left the root dependencies uncached on every run.
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
tests/ui_tests/playwright/package-lock.json
|
||||
|
||||
# Build the Vite frontend bundle. The /notes/ page templates pull
|
||||
# `bootstrap` from `window.bootstrap`, which is exposed by the bundle
|
||||
# produced by `npm run build` (vite_helper.py reads
|
||||
# static/dist/.vite/manifest.json). Without the bundle, base.html
|
||||
# falls back to a build-not-found comment, the bundle script tag is
|
||||
# absent, `bootstrap.Modal` is undefined, and every test that opens
|
||||
# a modal hangs waiting for the `.show` class. The release-gate
|
||||
# workflow does the same step.
|
||||
- name: Build Vite frontend assets
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# Cache Playwright browser binaries so chromium isn't re-downloaded every
|
||||
# run. Keyed on package-lock.json so a `^`-semver Playwright bump (which
|
||||
# leaves package.json unchanged) still busts the cache.
|
||||
- name: Cache Playwright browsers
|
||||
id: pw-cache
|
||||
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: pw-${{ runner.os }}-${{ hashFiles('tests/ui_tests/playwright/package-lock.json') }}
|
||||
restore-keys: |
|
||||
pw-${{ runner.os }}-
|
||||
|
||||
- name: Install Playwright (browser + system deps)
|
||||
if: steps.pw-cache.outputs.cache-hit != 'true'
|
||||
working-directory: tests/ui_tests/playwright
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install chromium --with-deps
|
||||
|
||||
- name: Install Playwright (system deps only on cache hit)
|
||||
if: steps.pw-cache.outputs.cache-hit == 'true'
|
||||
working-directory: tests/ui_tests/playwright
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install-deps chromium
|
||||
|
||||
- name: Setup test directories
|
||||
run: mkdir -p data/encrypted_databases
|
||||
|
||||
# init_test_database.py is not idempotent — it crashes if test_admin
|
||||
# already exists. Fresh CI runners are clean, but workflow re-runs on
|
||||
# the same runner-cache hit can encounter pre-existing DB state. Wipe
|
||||
# any leftovers before init.
|
||||
# Encrypted DB filename: ldr_user_<sha256(username)[:16]>.db (paths.py),
|
||||
# NOT test_admin*. Each DB has a sibling .salt file.
|
||||
- name: Clean prior test DB (idempotency safety)
|
||||
run: |
|
||||
rm -f data/ldr_auth.db
|
||||
rm -f data/encrypted_databases/ldr_user_*.db data/encrypted_databases/ldr_user_*.db.salt
|
||||
|
||||
- name: Start LDR server
|
||||
env:
|
||||
CI: true
|
||||
TEST_ENV: true
|
||||
FLASK_ENV: testing
|
||||
LDR_DATA_DIR: ${{ github.workspace }}/data
|
||||
DISABLE_RATE_LIMITING: true
|
||||
SECRET_KEY: test-secret-key-for-ci
|
||||
# Must MATCH the Initialize-test-database step's KDF: the server
|
||||
# opens each per-user encrypted DB (on login via auth.setup.js) with
|
||||
# its own env, and SQLCipher can only open a DB with the same
|
||||
# kdf_iter it was created with. LDR_TEST_MODE is required for the
|
||||
# 1000 to survive the production-floor clamp (see the init step).
|
||||
LDR_TEST_MODE: '1'
|
||||
LDR_DB_KDF_ITERATIONS: '1000'
|
||||
run: |
|
||||
export PYTHONPATH="$PWD/src:$PYTHONPATH"
|
||||
echo "Starting server with LDR_DATA_DIR=$LDR_DATA_DIR"
|
||||
pdm run python -m local_deep_research.web.app &
|
||||
SERVER_PID=$!
|
||||
echo "SERVER_PID=$SERVER_PID" >> "$GITHUB_ENV"
|
||||
|
||||
# Probe /auth/login (not /) so we wait for the auth blueprint to
|
||||
# be wired up before tests start, not just for the bare port to bind.
|
||||
for i in {1..30}; do
|
||||
if curl -sf http://127.0.0.1:5000/auth/login > /dev/null; then
|
||||
echo "Server is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for server... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if ! curl -sf http://127.0.0.1:5000/auth/login > /dev/null; then
|
||||
echo "Server failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Initialize test database
|
||||
env:
|
||||
TEST_ENV: true
|
||||
LDR_DATA_DIR: ${{ github.workspace }}/data
|
||||
# LDR_TEST_MODE is REQUIRED for the KDF override to take effect:
|
||||
# _get_min_kdf_iterations() only relaxes the production floor
|
||||
# (100_000) when LDR_TEST_MODE / PYTEST_CURRENT_TEST is set — TEST_ENV
|
||||
# and CI are deliberately NOT honored. Without it the 1000 below is
|
||||
# silently clamped up to 256000, making SQLCipher ~256x slower for
|
||||
# DB creation + the 500-setting import (see #4430 / puppeteer-e2e).
|
||||
LDR_TEST_MODE: '1'
|
||||
LDR_DB_KDF_ITERATIONS: '1000'
|
||||
run: |
|
||||
export PYTHONPATH="$PWD/src:$PYTHONPATH"
|
||||
pdm run python scripts/ci/init_test_database.py
|
||||
|
||||
# NOTE: register_ci_user.js is intentionally OMITTED. After cleanup +
|
||||
# init the user is fresh; auth.setup.js (the Playwright `setup` project)
|
||||
# performs the actual login and persists storageState. Running the
|
||||
# puppeteer registration step would always hit its "user exists → fall
|
||||
# back to login" branch (5–15s, extra flake surface, no value).
|
||||
|
||||
- name: Run notes UI tests
|
||||
working-directory: tests/ui_tests/playwright
|
||||
env:
|
||||
TEST_BASE_URL: http://127.0.0.1:5000
|
||||
TEST_USERNAME: test_admin
|
||||
TEST_PASSWORD: testpass123
|
||||
CI: true
|
||||
run: npm run test:notes
|
||||
|
||||
# The mobile-nav Notes-entry regression guard lives OUTSIDE tests/notes/
|
||||
# (so the Desktop-only notes-paths project doesn't pick it up).
|
||||
# `npm run test:notes` therefore never executes it — run it explicitly
|
||||
# here so a deletion of the Notes entry from mobile-navigation.js can't
|
||||
# ship with a green CI. Pixel 5 is the chromium-based mobile project:
|
||||
# this job only installs chromium, so webkit device profiles (iPhone*)
|
||||
# can't launch here.
|
||||
- name: Run mobile nav Notes-entry regression guard
|
||||
working-directory: tests/ui_tests/playwright
|
||||
env:
|
||||
TEST_BASE_URL: http://127.0.0.1:5000
|
||||
TEST_USERNAME: test_admin
|
||||
TEST_PASSWORD: testpass123
|
||||
CI: true
|
||||
run: |
|
||||
npx playwright test --project='Pixel 5' mobile-notes-nav --reporter=line 2>&1 | tee /tmp/mobile-nav-guard.log
|
||||
# The spec self-skips on project mismatch, and Playwright exits 0
|
||||
# on an all-skipped run — assert the guard actually executed.
|
||||
grep -qE '[1-9][0-9]* passed' /tmp/mobile-nav-guard.log
|
||||
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
kill "$SERVER_PID" || true
|
||||
fi
|
||||
pkill -f "python -m local_deep_research.web.app" || true
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-notes-report
|
||||
path: |
|
||||
tests/ui_tests/playwright/playwright-report/
|
||||
tests/ui_tests/playwright/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Publish Test Results
|
||||
if: always()
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2.23.0
|
||||
with:
|
||||
files: tests/ui_tests/playwright/test-results/results.xml
|
||||
check_name: Notes UI Test Results
|
||||
comment_mode: off
|
||||
@@ -154,6 +154,31 @@ jobs:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }}
|
||||
|
||||
# ============================================================================
|
||||
# NOTES TEST GATE (Required) - Playwright Notes UI functional tests
|
||||
# ============================================================================
|
||||
# Verifies the notes feature (create/edit/delete, mobile-nav Notes-entry
|
||||
# regression guard) via a real server + Chromium. playwright-notes-tests.yml
|
||||
# only runs its job on `pull_request` when labeled test:notes/test:ui — but
|
||||
# its `if:` gates on `github.event_name != 'pull_request'`, which is true
|
||||
# for this workflow_call (release.yml's own trigger is push/workflow_dispatch,
|
||||
# never pull_request; workflow_call does not overwrite github.event_name —
|
||||
# see the identical note in puppeteer-e2e-tests.yml), so the job always
|
||||
# runs here regardless of PR labels. Uses a hardcoded test SECRET_KEY and
|
||||
# its own KDF env — no secrets required, so none are inherited/passed.
|
||||
# Functional (not cosmetic) coverage for a core feature — must pass before
|
||||
# any release proceeds, same severity as e2e-test-gate.
|
||||
# ============================================================================
|
||||
notes-test-gate:
|
||||
needs: [version-check]
|
||||
if: needs.version-check.outputs.should_release == 'true'
|
||||
# Runs in parallel with release-gate (no dependency besides version-check)
|
||||
uses: ./.github/workflows/playwright-notes-tests.yml
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write # Required: publishes Playwright test result checks
|
||||
pull-requests: write # Required: matches playwright-notes-tests.yml job permissions (no-op in release context)
|
||||
|
||||
# ============================================================================
|
||||
# RESPONSIVE TEST GATE (Advisory) - Responsive UI tests across viewports
|
||||
# ============================================================================
|
||||
@@ -212,10 +237,10 @@ jobs:
|
||||
contents: read
|
||||
|
||||
build:
|
||||
needs: [version-check, release-gate, ci-gate, test-gate, e2e-test-gate, responsive-test-gate, compat-test-gate, compose-integration-gate, vulture-gate]
|
||||
needs: [version-check, release-gate, ci-gate, test-gate, e2e-test-gate, notes-test-gate, responsive-test-gate, compat-test-gate, compose-integration-gate, vulture-gate]
|
||||
# test-gate (Playwright WebKit), responsive-test-gate, and vulture-gate are advisory
|
||||
# release-gate, ci-gate, e2e-test-gate, compat-test-gate, and compose-integration-gate are required
|
||||
if: ${{ !cancelled() && needs.release-gate.result == 'success' && needs.ci-gate.result == 'success' && needs.e2e-test-gate.result == 'success' && needs.compat-test-gate.result == 'success' && needs.compose-integration-gate.result == 'success' }}
|
||||
# release-gate, ci-gate, e2e-test-gate, notes-test-gate, compat-test-gate, and compose-integration-gate are required
|
||||
if: ${{ !cancelled() && needs.release-gate.result == 'success' && needs.ci-gate.result == 'success' && needs.e2e-test-gate.result == 'success' && needs.notes-test-gate.result == 'success' && needs.compat-test-gate.result == 'success' && needs.compose-integration-gate.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
@@ -98,8 +98,13 @@ def classify_file(path):
|
||||
|
||||
# JavaScript files
|
||||
if p.suffix == ".js":
|
||||
# Vitest test files: tests/js/**/*.test.js
|
||||
if "tests" in parts_set and basename.endswith(".test.js"):
|
||||
# Vitest test files (tests/js/**/*.test.js) and Playwright specs
|
||||
# (tests/ui_tests/playwright/**/*.spec.js) — both are this repo's
|
||||
# JS test layers; a commit shipping UI code with .spec.js coverage
|
||||
# must not be flagged as untested.
|
||||
if "tests" in parts_set and (
|
||||
basename.endswith(".test.js") or basename.endswith(".spec.js")
|
||||
):
|
||||
return "test"
|
||||
# Source: under src/.../static/js/
|
||||
if "static" in parts_set and "js" in parts_set and parts_set & {"src"}:
|
||||
|
||||
@@ -355,8 +355,16 @@ def _is_raw_sql_exempt(filename: str) -> bool:
|
||||
if "migration" in lower or "migrate" in lower or "alembic" in lower:
|
||||
return True
|
||||
base = Path(fn).name
|
||||
# Test files: /tests/ directory or test_* prefix (strict, avoids "attest"/"contest" matches)
|
||||
if "/tests/" in fn or base.startswith("test_") or base.endswith("_test.py"):
|
||||
# Test files: tests/ path component, test_* prefix, *_test.py suffix,
|
||||
# or conftest.py (pytest shared fixtures). Leading-dir check covers
|
||||
# both "tests/..." (repo-root relative) and ".../tests/..." (absolute).
|
||||
if (
|
||||
"/tests/" in fn
|
||||
or fn.startswith("tests/")
|
||||
or base.startswith("test_")
|
||||
or base.endswith("_test.py")
|
||||
or base == "conftest.py"
|
||||
):
|
||||
return True
|
||||
if base in DB_UTIL_FILES:
|
||||
return True
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
**Collections: system collections no longer offer a Delete button that always fails** ([#3277](https://github.com/LearningCircuit/local-deep-research/pull/3277)). The collection-details page rendered its Delete button unconditionally, but deleting the Library, Research History, or Notes system collections is refused server-side with a 409 — clicking it could only ever produce an error. The collection payload now carries a server-computed `is_protected` flag (single source of truth: the deletion service's PROTECTED_COLLECTION_TYPES) and the page hides the button for protected collections. The button's tooltip and confirm dialog were also corrected: they promised "documents remain in the library", but the delete removes documents that belong to no other collection.
|
||||
@@ -0,0 +1 @@
|
||||
**Library documents: Notes panel and Word-review-style inline comments** ([#3277](https://github.com/LearningCircuit/local-deep-research/pull/3277)). The research-page notes features now generalize to library documents. The document details page gains a Notes panel (notes referencing the document, plus **Add note**); the full-text view (`/library/document/<id>/txt`) gains the same select-to-comment surface as research reports — select a passage, write a remark, and it anchors as a persistent highlight whose popover offers *Open note* and *Delete*. Comments are full notes, like everywhere else. Under the hood this introduces the notes feature's reference layer: a `note_references` table (migration 0022) recording note → (document | research) references with optional quote anchors (Hypothesis-style TextQuoteSelector; targets' rendered text is immutable, so anchors cannot drift). Research-comment anchors moved out of `research_meta` onto the same table — one mechanism for both target kinds (the pre-release meta-based anchors retire; the comment notes themselves are untouched). Annotating a NOTE remains deliberately unsupported: note content is mutable, so anchors would drift on every edit. The anchor engine and popovers were extracted into a shared `annotation_surface.js` component used by both the research and document surfaces.
|
||||
@@ -0,0 +1 @@
|
||||
**Research results: Notes panel, save-as-note, and clip-to-note** ([#3277](https://github.com/LearningCircuit/local-deep-research/pull/3277)). The notes feature could link research runs to notes, but only in one direction — from a research run's results page there was no way to see or create the notes commenting on it. The results page now has a Notes panel that lists every note linked to the run (newest first, with plain-text previews), plus three ways to write: **Add note** starts a linked note pre-titled after the research query and drops you into the editor; **Save report as note** copies the completed report into a new editable note (a derivation, not a conversion — the report stays the immutable record for provenance and fact-checking, and the note carries a provenance header plus the research link); and selecting text in the report shows a **Clip to note** button that captures the selection as a quote note with attribution, without leaving the page. Backed by three new endpoints (`GET/POST /notes/api/research/<id>/notes`, `POST .../save-as-note`) with all-or-nothing create+link semantics.
|
||||
@@ -0,0 +1 @@
|
||||
**Rate limiter: a broken `RATELIMIT_STORAGE_URL` fails fast with an actionable error** ([#3277](https://github.com/LearningCircuit/local-deep-research/pull/3277)). This branch makes the rate limiter honor `RATELIMIT_STORAGE_URL` (previously documented but silently ignored in favor of hardcoded `memory://`). Operators who exported the variable per the old docs — without the matching client package installed (`redis` is not a declared dependency) — would have hit a bare `ConfigurationError` naming neither the variable nor the remedy at first request. The storage URI is now validated at startup and a failure raises a clear message naming the variable, the value, and the fix (install the client package or unset the variable). An empty-string value reads as unset, matching how the codebase treats other empty env vars. Deliberately no silent fallback to `memory://` — per-worker limits would quietly multiply the login bucket, a credential-throttle bypass.
|
||||
@@ -0,0 +1 @@
|
||||
**Research results: Word-review-style inline comments** ([#3277](https://github.com/LearningCircuit/local-deep-research/pull/3277)). Select a passage in a research report and choose **Comment** to attach a remark to that exact text: the passage gets a persistent highlight, and clicking it shows the comment in a popover with *Open note* and *Delete*. Each comment IS a full note — comment-first content with the quoted passage and a provenance link, linked to the run via the existing research↔note mechanism — so comments are searchable, taggable, and editable like any other note and also appear in the results page's Notes panel. Only the anchor (the quoted text plus short before/after context, a Hypothesis-style TextQuoteSelector) is stored on the research run; since reports are immutable, anchors can never drift. The anchor engine bridges the renderer's `<br>`/block boundaries (which contribute no characters to the DOM text) against selection text (which renders them as newlines), so highlights re-apply reliably across reloads, including passages spanning line breaks. Deleting a comment removes both the highlight anchor and its note; deleting the note from the notes UI simply retires the highlight.
|
||||
@@ -0,0 +1 @@
|
||||
**Search everything** ([#3277](https://github.com/LearningCircuit/local-deep-research/pull/3277)). New unified Search page (`/library/search/`, linked from the Knowledge Base sidebar) that searches notes, library documents, and research reports from one box, with the same Text / AI Hybrid / AI Only mode selector as the notes/library/history pages. The keyword leg runs a wildcard-escaped, column-projected ILIKE over every document's title and content; the AI leg searches the FAISS indexes of the system collections (Library, Research History, Notes) plus any collection with a current RAG index — missing/unindexed ones are skipped, and infrastructure failures surface as errors rather than empty results. Hybrid mode merges both legs with the shared tiered ranking and degrades per leg with an inline notice. Every result links to its canonical page (note editor, research results, or library document) via a server-computed URL, and the page JS is built around a search-mode registry so future modes are one registry entry.
|
||||
@@ -0,0 +1 @@
|
||||
**AI-enhanced Notes feature** ([#3277](https://github.com/LearningCircuit/local-deep-research/pull/3277)). New `/notes` page with markdown editor, version history (FIFO-capped at 100, atomic restore), wiki-style `[[Title]]` linking with backlinks, research integration (pin research runs to notes), and AI assist via the floating-action button (Summarize, Suggest Tags, Key Concepts, Similar Notes, Research Questions, plus multi-note Synthesize). The FAB also offers **Suggest Links** (one-click accept of semantically-related `[[connections]]`) and **Verify with Research** (extracts a note's factual claims, runs a deep-research, and grades each claim supported / contradicted / partially-supported / unverified with reasoning + sources). It also offers **Unlinked Mentions** (link notes that mention this one's title but don't link it) and **Similar Passages** (chunk-level: find semantically-similar passages across notes). The notes list has an **Ask your notes** box that runs a research pinned to the Notes collection (warning first if there are no notes or none are indexed). Notes search offers a Text / AI Hybrid / AI-only mode selector (defaulting to AI Hybrid), matching the library/history/news pages and sharing their tiered text+semantic merge. Notes are stored as `Document` rows with `source_type='note'` and reuse the existing library / RAG / embedding stack; per-user rate-limit buckets (`notes_write`, `notes_ai`, `notes_search`, `notes_synthesize`, `notes_factcheck`) prevent abusive generation.
|
||||
@@ -11,6 +11,7 @@ This document describes the database models and their relationships in Local Dee
|
||||
- [Authentication](#authentication)
|
||||
- [Settings](#settings)
|
||||
- [Library & Documents](#library--documents)
|
||||
- [Notes](#notes)
|
||||
- [Queue Management](#queue-management)
|
||||
- [Metrics & Analytics](#metrics--analytics)
|
||||
- [News System](#news-system)
|
||||
@@ -335,6 +336,27 @@ Vector index metadata.
|
||||
|
||||
---
|
||||
|
||||
### Notes
|
||||
|
||||
Notes are **not a dedicated table** — a note is a `documents` row whose source
|
||||
type is `note`, with satellite tables for versioning, links, research pins,
|
||||
references/annotations, and synthesis. This lets notes reuse the Library / RAG
|
||||
/ embedding stack. Full data model, ER diagram, and behavior:
|
||||
**[Notes — Data Model](./NOTES.md)**.
|
||||
|
||||
| Table | Purpose | Key FKs (ondelete) |
|
||||
|---|---|---|
|
||||
| `note_versions` | Version-history snapshots (bookended atomic restore, FIFO-capped at 100) | `document_id → documents` (CASCADE) |
|
||||
| `note_links` | Wiki-style `[[link]]` edges between notes | `source_document_id`, `target_document_id → documents` (CASCADE) |
|
||||
| `note_research` | Research runs pinned to a note | `document_id → documents`, `research_id → research_history` (CASCADE) |
|
||||
| `note_references` | Generic reference / inline-annotation layer (document XOR research target) | `note_id`, `target_document_id → documents`; `target_research_id → research_history` (all CASCADE) |
|
||||
| `note_syntheses` | AI synthesis (merge / summarize / compare) audit record | `result_document_id → documents` (SET NULL) |
|
||||
| `note_synthesis_sources` | Source notes of a synthesis | `synthesis_id → note_syntheses`, `source_document_id → documents` (CASCADE) |
|
||||
|
||||
Created by migrations `0021_add_note_tables.py` and `0022_add_note_references.py`.
|
||||
|
||||
---
|
||||
|
||||
### Queue Management
|
||||
|
||||
Background task processing.
|
||||
@@ -604,5 +626,6 @@ Failed verification attempts.
|
||||
|
||||
- [Architecture Overview](./OVERVIEW.md) - System architecture
|
||||
- [Semantic Search](./SEMANTIC_SEARCH.md) - How Document/Collection models enable semantic search
|
||||
- [Notes — Data Model](./NOTES.md) - How notes reuse `documents` plus the `note_*` satellite tables
|
||||
- [Extension Guide](../developing/EXTENDING.md) - Adding custom components
|
||||
- [Troubleshooting](../troubleshooting.md) - Common issues
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# Notes — Data Model
|
||||
|
||||
How the Notes feature stores its data. The short version: **a note is not its
|
||||
own table** — it is a row in the existing `documents` table, and a handful of
|
||||
satellite `note_*` tables hang off it for versioning, links, research pins,
|
||||
references/annotations, and synthesis. This lets notes reuse the whole
|
||||
Library / RAG / embedding stack (collections, chunks, semantic search) for
|
||||
free.
|
||||
|
||||
See also: [`DATABASE_SCHEMA.md`](DATABASE_SCHEMA.md) (whole-database schema),
|
||||
[`SEMANTIC_SEARCH.md`](SEMANTIC_SEARCH.md), [`../library-and-rag.md`](../library-and-rag.md).
|
||||
|
||||
## The core idea: a note is a `Document`
|
||||
|
||||
A note is a row in `documents` whose `source_type_id` resolves to the
|
||||
`source_types` row named `note` (`NoteService` lazily creates that
|
||||
`SourceType` — `display_name="Note"`, `icon="sticky-note"` — the first time a
|
||||
note is made). There is **no** boolean/enum column on `documents` marking
|
||||
"is a note"; a Document is a note iff its source type is `note`.
|
||||
|
||||
The note's own content lives in Document columns:
|
||||
|
||||
| Column | Type | Note usage |
|
||||
|---|---|---|
|
||||
| `id` | `String(36)` UUID, PK | the note id |
|
||||
| `source_type_id` | FK → `source_types.id` | points at the `note` source type |
|
||||
| `title` | `Text` | note title |
|
||||
| `text_content` | `Text` | note body (markdown) |
|
||||
| `tags` | `JSON` | user tags |
|
||||
| `favorite` | `Boolean` | **DB column stays `favorite`; the API/UI call it "pinned"** — same column, two names by design |
|
||||
| `document_hash` | `String(64)`, unique | `sha256(f"{note_id}:{content}")` |
|
||||
|
||||
Every note is also placed in a per-user **system collection**
|
||||
(`collections.collection_type = "notes"`, name "Notes") via a
|
||||
`document_collections` join row. That collection is the note's permanent home
|
||||
— `remove_from_collection` refuses to unlink a note from it — and it is what
|
||||
puts notes on the same collection / RAG / semantic-search rails as uploaded
|
||||
documents and research reports. A note may additionally belong to any number
|
||||
of ordinary user collections.
|
||||
|
||||
## Entity-relationship diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
source_types ||--o{ documents : "source_type_id"
|
||||
documents {
|
||||
string id PK "UUID"
|
||||
string source_type_id FK "note-type = a note"
|
||||
text title
|
||||
text text_content
|
||||
json tags
|
||||
bool favorite "aka pinned"
|
||||
}
|
||||
research_history {
|
||||
string id PK "UUID"
|
||||
}
|
||||
|
||||
documents ||--o{ note_versions : "document_id (CASCADE)"
|
||||
note_versions {
|
||||
string id PK "UUID"
|
||||
string document_id FK
|
||||
string content_hash "sha256, dedup"
|
||||
enum change_type "initial|auto_save|manual_save|pre_restore|restore"
|
||||
text change_summary "LLM, async, nullable"
|
||||
datetime created_at
|
||||
}
|
||||
|
||||
documents ||--o{ note_links : "source_document_id (CASCADE)"
|
||||
documents ||--o{ note_links : "target_document_id (CASCADE)"
|
||||
note_links {
|
||||
int id PK
|
||||
string source_document_id FK
|
||||
string target_document_id FK
|
||||
string link_text
|
||||
bool auto_suggested
|
||||
}
|
||||
|
||||
documents ||--o{ note_research : "document_id (CASCADE)"
|
||||
research_history ||--o{ note_research : "research_id (CASCADE)"
|
||||
note_research {
|
||||
int id PK
|
||||
string document_id FK
|
||||
string research_id FK
|
||||
int display_order
|
||||
bool is_collapsed
|
||||
}
|
||||
|
||||
documents ||--o{ note_references : "note_id (CASCADE)"
|
||||
documents |o--o{ note_references : "target_document_id (CASCADE, nullable)"
|
||||
research_history |o--o{ note_references : "target_research_id (CASCADE, nullable)"
|
||||
note_references {
|
||||
int id PK
|
||||
string note_id FK
|
||||
string target_document_id FK "XOR target_research_id"
|
||||
string target_research_id FK "XOR target_document_id"
|
||||
text quote "null = plain ref; set = inline annotation"
|
||||
text prefix
|
||||
text suffix
|
||||
}
|
||||
|
||||
documents |o--o{ note_syntheses : "result_document_id (SET NULL, nullable)"
|
||||
note_syntheses {
|
||||
int id PK
|
||||
string result_document_id FK "nullable"
|
||||
enum synthesis_type "merge|summarize|compare"
|
||||
}
|
||||
note_syntheses ||--o{ note_synthesis_sources : "synthesis_id (CASCADE)"
|
||||
documents ||--o{ note_synthesis_sources : "source_document_id (CASCADE)"
|
||||
note_synthesis_sources {
|
||||
int id PK
|
||||
int synthesis_id FK
|
||||
string source_document_id FK
|
||||
}
|
||||
|
||||
documents ||--o{ document_collections : "document_id (CASCADE)"
|
||||
collections ||--o{ document_collections : "collection_id (CASCADE)"
|
||||
documents ||--o{ document_chunks : "source_id (loose, no FK)"
|
||||
```
|
||||
|
||||
Reading the diagram:
|
||||
|
||||
- **`documents` is the hub.** Every note-facing edge points at `documents`,
|
||||
not at a dedicated notes table.
|
||||
- **`note_links` is a document-to-document self relation** expressed as two FK
|
||||
columns on one row (`source_document_id`, `target_document_id`) — hence the
|
||||
two edges from `documents`.
|
||||
- **`note_references` is polymorphic**: each row targets *either* a document
|
||||
*or* a research run, never both/neither (enforced by a CHECK, below). Both
|
||||
target edges are drawn optional.
|
||||
- **`note_syntheses.result_document_id` is `SET NULL`, not CASCADE** — the one
|
||||
note FK that is not cascade-deleted, so the synthesis audit record survives
|
||||
its result note being deleted.
|
||||
- `document_chunks.source_id` is a **loose reference** (a plain indexed
|
||||
`String(36)`, not a foreign key) — the RAG chunk rows are cleaned up in
|
||||
application code, not by the relational cascade.
|
||||
|
||||
## The `note_*` tables
|
||||
|
||||
All defined in `database/models/note.py`; created by migrations
|
||||
`0021_add_note_tables.py` and `0022_add_note_references.py`.
|
||||
|
||||
### `note_versions` — version-history snapshots
|
||||
- PK `id` `String(36)` UUID (app-generated, not autoincrement — versions can be URL-shared).
|
||||
- FK `document_id → documents.id` **CASCADE**, not null.
|
||||
- `title`, `content`, `tags` (the snapshotted state), `content_hash` (SHA-256, for dedup), `change_type` (enum), `change_summary` (LLM-generated, filled async, nullable), `created_at`.
|
||||
- UNIQUE `(document_id, content_hash)`; INDEX `(document_id, created_at)`.
|
||||
- CHECK `change_type IN ('initial','auto_save','manual_save','pre_restore','restore')`.
|
||||
|
||||
### `note_links` — wiki-style `[[link]]` edges
|
||||
- PK `id` integer autoincrement.
|
||||
- FK `source_document_id → documents.id` **CASCADE**; FK `target_document_id → documents.id` **CASCADE**.
|
||||
- `link_text`, `auto_suggested`, `created_at`.
|
||||
- UNIQUE `(source_document_id, target_document_id)` (one edge per pair); INDEX on `target_document_id`.
|
||||
- CHECK `source_document_id != target_document_id` (no self-links).
|
||||
|
||||
### `note_research` — pinned research runs
|
||||
- PK `id` integer autoincrement.
|
||||
- FK `document_id → documents.id` **CASCADE**; FK `research_id → research_history.id` **CASCADE**.
|
||||
- `display_order`, `is_collapsed`, `created_at`.
|
||||
- UNIQUE `(document_id, research_id)`; UNIQUE `(document_id, display_order)` (prevents a concurrent-insert race producing duplicate orders — the service retries on `IntegrityError`).
|
||||
|
||||
### `note_references` — generic reference / inline-annotation layer (migration 0022)
|
||||
- PK `id` integer autoincrement.
|
||||
- FK `note_id → documents.id` **CASCADE**.
|
||||
- FK `target_document_id → documents.id` **CASCADE, nullable**; FK `target_research_id → research_history.id` **CASCADE, nullable**.
|
||||
- `quote`, `prefix`, `suffix` (a Hypothesis-style `TextQuoteSelector` — `quote IS NULL` = a plain reference feeding a target's "Notes" panel; non-null = a highlighted inline comment).
|
||||
- CHECK `ck_note_reference_single_target`: `(target_document_id IS NOT NULL) + (target_research_id IS NOT NULL) = 1` — exactly one target.
|
||||
|
||||
### `note_syntheses` / `note_synthesis_sources` — AI synthesis audit
|
||||
- `note_syntheses`: PK `id`; FK `result_document_id → documents.id` **SET NULL, nullable**; `synthesis_type` enum; CHECK `synthesis_type IN ('merge','summarize','compare')`.
|
||||
- `note_synthesis_sources`: PK `id`; FK `synthesis_id → note_syntheses.id` **CASCADE**; FK `source_document_id → documents.id` **CASCADE**; UNIQUE `(synthesis_id, source_document_id)`. One synthesis has 2–5 source rows (count enforced in the service, not the DB).
|
||||
|
||||
> A `note_templates` table existed in an earlier draft of migration 0021 but
|
||||
> was removed before merge (no ORM model ever shipped). Migration 0021's
|
||||
> `downgrade()` still defensively drops it if a pre-release dev DB has it.
|
||||
|
||||
## Key behaviors
|
||||
|
||||
### Version history — bookends, FIFO cap, atomic restore
|
||||
- A save inserts a `note_versions` row; identical-state saves dedup on
|
||||
`(document_id, content_hash)` (the hash is `sha256(title \x00 content \x00 json(sorted(tags)))`).
|
||||
- **Restore is bookended and atomic.** `restore_with_bookends` wraps three
|
||||
writes in one transaction: a `PRE_RESTORE` snapshot of the current state →
|
||||
apply the chosen version's title/content/tags to the Document → a `RESTORE`
|
||||
snapshot of the new state. Both bookends salt their hash with a throwaway
|
||||
random UUID so they always insert (their content by definition duplicates an
|
||||
existing row and would otherwise be dedup-absorbed).
|
||||
- **FIFO cap** `MAX_VERSIONS_PER_NOTE = 100`: the oldest **non-bookend**
|
||||
versions are pruned past 100. PRE_RESTORE/RESTORE rows are exempt from that
|
||||
prune (so the "restored here" trail survives heavy editing) but are
|
||||
separately capped at `MAX_BOOKEND_VERSIONS = 40`.
|
||||
- `change_summary` is generated by the LLM **asynchronously** after commit (a
|
||||
bounded thread pool) — the row lands with `change_summary = NULL` and is
|
||||
updated later.
|
||||
|
||||
### Wiki-links
|
||||
Parsed from note content with `\[\[([^\]\n]{1,500})\]\]`, capped at 1000 links
|
||||
per note. Link text resolves to a target Document by title (ASCII-folded to
|
||||
mirror SQLite's `LOWER()`). Re-linking with different text updates the existing
|
||||
`(source, target)` row rather than duplicating it.
|
||||
|
||||
### Reference layer & inline annotations
|
||||
`note_references` is the single mechanism behind both the "Notes panel" on a
|
||||
document/research page (plain references, `quote IS NULL`) and Word-style
|
||||
inline comments (anchored references, `quote` set). Because a reference target
|
||||
is *either* a document *or* a research run, one row can't accidentally point at
|
||||
both. Anchors use immutable target text (rendered reports / extracted document
|
||||
text), so highlights can't drift — which is also why **notes themselves cannot
|
||||
be annotated** (their content is mutable).
|
||||
|
||||
### Synthesis
|
||||
`NoteAIService.synthesize_notes` runs the LLM over 2–5 notes and returns
|
||||
content + a suggested title; it does **not** write the audit tables. The route
|
||||
then creates the result note via `create_note` and, in one transaction,
|
||||
inserts a `note_syntheses` row plus a `note_synthesis_sources` row per source
|
||||
note (deleting the orphaned note if that fails).
|
||||
|
||||
### RAG / semantic-search integration
|
||||
When a note is indexed into a collection it is chunked and embedded like any
|
||||
other Document: `document_chunks.source_id` holds the note's `documents.id`,
|
||||
and `document_collections`/`rag_document_status` track per-collection index
|
||||
state. Editing a note deletes its `rag_document_status` row so the auto-index
|
||||
worker re-embeds it. This is what powers "Ask your notes" and AI/hybrid notes
|
||||
search. Chunk/vector cleanup is done in application code (not DB cascade)
|
||||
because vectors live in FAISS, outside the relational graph.
|
||||
|
||||
### Cascade / deletion behavior
|
||||
- **Delete a note** (`documents` row): CASCADE removes its `note_versions`,
|
||||
both directions of `note_links`, its `note_research`, its `note_references`
|
||||
(as `note_id` *and* as `target_document_id`), and `note_synthesis_sources`
|
||||
where it was a source. Its `note_syntheses.result_document_id` is **SET
|
||||
NULL** (audit record survives). Separately, `delete_note` purges the note's
|
||||
FAISS vectors and `document_chunks` from every collection it was indexed in.
|
||||
- **Delete a collection**: CASCADE removes `document_collections` rows (drops
|
||||
notes from that collection) but does **not** delete the note Documents — a
|
||||
note still lives in the system "Notes" collection.
|
||||
- **Delete a research run** (`research_history`): CASCADE removes `note_research`
|
||||
pins and `note_references` anchored to that run (its inline annotations).
|
||||
The notes themselves are untouched — no note content is deleted.
|
||||
|
||||
## Migrations
|
||||
- `0021_add_note_tables.py` — `revision = "0021"`, `down_revision = "0020"`
|
||||
(creates `note_versions`, `note_links`, `note_research`, `note_syntheses`,
|
||||
`note_synthesis_sources`).
|
||||
- `0022_add_note_references.py` — `revision = "0022"`, `down_revision = "0021"`
|
||||
(creates `note_references`; current migration head).
|
||||
|
||||
Both guard `create_table` with `inspector.has_table(...)` for idempotency, and
|
||||
both `downgrade()` functions log row counts before dropping, so an accidental
|
||||
downgrade is loud rather than silent.
|
||||
@@ -120,6 +120,7 @@ _None._
|
||||
| `e2e-research-test.yml` | last 30 days | PR | [](https://github.com/LearningCircuit/local-deep-research/actions/workflows/e2e-research-test.yml) |
|
||||
| `labels-sync.yml` | last 30 days | push:main, manual | [](https://github.com/LearningCircuit/local-deep-research/actions/workflows/labels-sync.yml) |
|
||||
| `mcp-tests.yml` | last 30 days | push:main, PR, manual | [](https://github.com/LearningCircuit/local-deep-research/actions/workflows/mcp-tests.yml) |
|
||||
| `playwright-notes-tests.yml` | last 30 days | PR, workflow_call, manual | [](https://github.com/LearningCircuit/local-deep-research/actions/workflows/playwright-notes-tests.yml) |
|
||||
| `pr-triage.yml` | last 30 days | PR, pull_request_review | [](https://github.com/LearningCircuit/local-deep-research/actions/workflows/pr-triage.yml) |
|
||||
| `release.yml` | last 30 days | push:main, manual | [](https://github.com/LearningCircuit/local-deep-research/actions/workflows/release.yml) |
|
||||
| `ui-full-shards.yml` | last 30 days | PR, manual | [](https://github.com/LearningCircuit/local-deep-research/actions/workflows/ui-full-shards.yml) |
|
||||
|
||||
@@ -292,6 +292,7 @@ export default [
|
||||
MutationObserver: "readonly",
|
||||
ResizeObserver: "readonly",
|
||||
IntersectionObserver: "readonly",
|
||||
NodeFilter: "readonly",
|
||||
requestAnimationFrame: "readonly",
|
||||
cancelAnimationFrame: "readonly",
|
||||
requestIdleCallback: "readonly",
|
||||
|
||||
@@ -67,7 +67,7 @@ You can configure the LDR service endpoints using environment variables:
|
||||
export LDR_LLM_OLLAMA_URL=http://localhost:11434
|
||||
|
||||
# For remote Ollama server
|
||||
export LDR_LLM_OLLAMA_URL=http://192.168.178.66:11434
|
||||
export LDR_LLM_OLLAMA_URL=http://192.0.2.10:11434
|
||||
|
||||
# For Docker compose service names
|
||||
export LDR_LLM_OLLAMA_URL=http://ollama:11434
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
- LDR_LLM_OLLAMA_URL=http://ollama:11434
|
||||
|
||||
# For remote Ollama instance
|
||||
# - LDR_LLM_OLLAMA_URL=http://192.168.178.66:11434
|
||||
# - LDR_LLM_OLLAMA_URL=http://192.0.2.10:11434
|
||||
|
||||
# For host machine Ollama
|
||||
# - LDR_LLM_OLLAMA_URL=http://host.docker.internal:11434
|
||||
|
||||
@@ -95,6 +95,12 @@ def seed_source_types(username: str, password: str = None) -> None:
|
||||
"description": "Sources discovered during research with content for semantic search",
|
||||
"icon": "link",
|
||||
},
|
||||
{
|
||||
"name": "note",
|
||||
"display_name": "Note",
|
||||
"description": "User-created notes with AI-enhanced features",
|
||||
"icon": "sticky-note",
|
||||
},
|
||||
{
|
||||
"name": "zotero",
|
||||
"display_name": "Zotero",
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Add notes feature tables
|
||||
|
||||
Creates the tables needed for the AI-enhanced notes feature. Notes are
|
||||
stored as Documents with source_type='note'; these tables provide
|
||||
auxiliary note-specific data (versioning, linking, research integration,
|
||||
and synthesis).
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-03-29 (originally authored as 0007 against an older
|
||||
head; renumbered repeatedly — 0007 → 0009 → 0010 → 0011 → 0013 → 0014 →
|
||||
0015 → 0019 → 0020 → 0021 — on successive merges as main's migration chain grew
|
||||
while feat/notes-v2 was in flight (most recently main's
|
||||
0019_retire_both_egress_scope landed at 0019, so this moved from 0019 to
|
||||
0020 and note_references from 0020 to 0021). The migration body is
|
||||
unchanged — it still creates the same five note tables — only the
|
||||
revision/down_revision header was updated to chain after 0019.)
|
||||
|
||||
Tables Added:
|
||||
=============
|
||||
- note_versions: version history for notes
|
||||
- note_links: wiki-style [[linking]] between notes
|
||||
- note_research: links between notes and research runs
|
||||
- note_syntheses: tracks AI synthesis operations across notes
|
||||
- note_synthesis_sources: junction table linking syntheses to their source docs
|
||||
|
||||
Downgrade Behavior:
|
||||
==================
|
||||
Drops all note tables in reverse dependency order. Any note data will be
|
||||
permanently lost on downgrade. Also drops legacy `note_templates` if
|
||||
present — an earlier revision of this migration (commit 30f12c326) created
|
||||
it; commit d852dcaf1 removed it from this migration, leaving the orphan in
|
||||
dev DBs that had run the pre-cleanup version.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy_utc import UtcDateTime, utcnow
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0021"
|
||||
down_revision = "0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create the notes feature tables."""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
|
||||
# Table 1: note_versions
|
||||
if not inspector.has_table("note_versions"):
|
||||
op.create_table(
|
||||
"note_versions",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.String(36),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"document_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"tags",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'"),
|
||||
),
|
||||
sa.Column("change_type", sa.String(50), nullable=False),
|
||||
sa.Column("change_summary", sa.Text(), nullable=True),
|
||||
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
UtcDateTime(),
|
||||
nullable=False,
|
||||
server_default=utcnow(),
|
||||
),
|
||||
sa.Index(
|
||||
"idx_note_version_created",
|
||||
"document_id",
|
||||
"created_at",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"document_id",
|
||||
"content_hash",
|
||||
name="uix_note_version_content",
|
||||
),
|
||||
# Mirror NoteChangeType in database/models/note.py:32-39.
|
||||
# Kept hardcoded rather than importing `NoteChangeType` from
|
||||
# the models package: alembic migrations run as isolated
|
||||
# scripts, no prior migration (0001–0005) imports from
|
||||
# database.models.*, and `values_callable` on the ORM Enum
|
||||
# suppresses SQLAlchemy's auto-CHECK emission — so this
|
||||
# explicit constraint is the sole DB-level enforcement. When
|
||||
# NoteChangeType grows, sync both literals; the test
|
||||
# TestEnumCheckParity in tests/database/test_migration_0021_note_tables.py
|
||||
# guards against drift.
|
||||
sa.CheckConstraint(
|
||||
"change_type IN ('initial', 'auto_save', 'manual_save', "
|
||||
"'pre_restore', 'restore')",
|
||||
name="ck_note_change_type",
|
||||
),
|
||||
)
|
||||
|
||||
# Table 2: note_links
|
||||
if not inspector.has_table("note_links"):
|
||||
op.create_table(
|
||||
"note_links",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.Integer(),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
),
|
||||
sa.Column(
|
||||
"source_document_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"target_document_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("link_text", sa.String(500), nullable=False),
|
||||
sa.Column(
|
||||
"auto_suggested",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
UtcDateTime(),
|
||||
nullable=False,
|
||||
server_default=utcnow(),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"source_document_id",
|
||||
"target_document_id",
|
||||
name="uix_note_link",
|
||||
),
|
||||
sa.Index("idx_note_link_target", "target_document_id"),
|
||||
sa.CheckConstraint(
|
||||
"source_document_id != target_document_id",
|
||||
name="ck_note_link_no_self",
|
||||
),
|
||||
)
|
||||
|
||||
# Table 3: note_research
|
||||
if not inspector.has_table("note_research"):
|
||||
op.create_table(
|
||||
"note_research",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.Integer(),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
),
|
||||
sa.Column(
|
||||
"document_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"research_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("research_history.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"display_order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"is_collapsed",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
UtcDateTime(),
|
||||
nullable=False,
|
||||
server_default=utcnow(),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"document_id",
|
||||
"research_id",
|
||||
name="uix_note_research",
|
||||
),
|
||||
# UNIQUE(document_id, display_order): link_research_to_note
|
||||
# computes MAX(display_order)+1 then INSERTs; without this,
|
||||
# two concurrent same-user calls (double-click, two tabs)
|
||||
# both observe the same MAX and insert duplicate orders. The
|
||||
# service-layer IntegrityError retry recomputes MAX+1 on
|
||||
# collision. Created inline with the table (was a separate
|
||||
# follow-up migration before this feature's branch was
|
||||
# consolidated to a single migration) — a fresh table has no
|
||||
# rows, so no duplicate-renumbering data migration is needed.
|
||||
# uix_note_research_display_order's backing btree (leads with
|
||||
# document_id) already serves the document-scoped ORDER BY /
|
||||
# MAX(display_order) reads, so no separate idx_note_research_order
|
||||
# index is created — it would be a redundant second index over
|
||||
# the identical columns. Mirrors the model __table_args__.
|
||||
sa.UniqueConstraint(
|
||||
"document_id",
|
||||
"display_order",
|
||||
name="uix_note_research_display_order",
|
||||
),
|
||||
)
|
||||
|
||||
# Table 4: note_syntheses
|
||||
if not inspector.has_table("note_syntheses"):
|
||||
op.create_table(
|
||||
"note_syntheses",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.Integer(),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
),
|
||||
sa.Column(
|
||||
"result_document_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("documents.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("synthesis_type", sa.String(50), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
UtcDateTime(),
|
||||
nullable=False,
|
||||
server_default=utcnow(),
|
||||
),
|
||||
# Mirror NoteSynthesisType in database/models/note.py. Same
|
||||
# rationale as the change_type CHECK above: the ORM Enum uses
|
||||
# values_callable which suppresses SQLAlchemy's auto-CHECK, so
|
||||
# this explicit constraint is the sole DB-level enforcement.
|
||||
# TestEnumCheckParity guards the three-way parity
|
||||
# (Python enum, migration CHECK, model CHECK).
|
||||
sa.CheckConstraint(
|
||||
"synthesis_type IN ('merge', 'summarize', 'compare')",
|
||||
name="ck_note_synthesis_type",
|
||||
),
|
||||
)
|
||||
|
||||
# Table 5: note_synthesis_sources
|
||||
# Junction table for NoteSynthesis → source documents. Replaces an
|
||||
# earlier JSON-list column (source_document_ids) that lacked FK
|
||||
# integrity. CASCADE on both sides ensures stale refs don't linger.
|
||||
if not inspector.has_table("note_synthesis_sources"):
|
||||
op.create_table(
|
||||
"note_synthesis_sources",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.Integer(),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
),
|
||||
sa.Column(
|
||||
"synthesis_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("note_syntheses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"source_document_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
UtcDateTime(),
|
||||
nullable=False,
|
||||
server_default=utcnow(),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"synthesis_id",
|
||||
"source_document_id",
|
||||
name="uix_note_synthesis_source",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Drop the notes tables in reverse dependency order.
|
||||
|
||||
Logs a warning with row counts before dropping so an operator who
|
||||
runs the downgrade by accident sees what they're about to lose. Note
|
||||
Documents themselves (with ``source_type='note'``) survive in
|
||||
``documents`` but become orphans without versioning, links, or
|
||||
research integration; re-running upgrade does NOT regenerate any of
|
||||
that auxiliary state.
|
||||
"""
|
||||
from loguru import logger
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
|
||||
# Drop junction first (CASCADE-dependent on note_syntheses).
|
||||
# Include legacy note_templates from the pre-cleanup version of this migration.
|
||||
table_names = [
|
||||
"note_synthesis_sources",
|
||||
"note_syntheses",
|
||||
"note_research",
|
||||
"note_links",
|
||||
"note_versions",
|
||||
"note_templates",
|
||||
]
|
||||
|
||||
# Pre-flight: surface row counts so downgrade-by-accident is loud.
|
||||
# The table name is interpolated from a hardcoded list above, not
|
||||
# user input — Ruff's S608 is a false positive here.
|
||||
for table_name in table_names:
|
||||
if inspector.has_table(table_name):
|
||||
try:
|
||||
count = conn.execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM {table_name}") # noqa: S608
|
||||
).scalar()
|
||||
except Exception:
|
||||
# Don't collapse the failure into the 'unknown' sentinel
|
||||
# silently — a COUNT that cannot run is itself worth surfacing
|
||||
# before we drop the table.
|
||||
logger.exception(
|
||||
"downgrade 0021: could not count rows in {} before drop",
|
||||
table_name,
|
||||
)
|
||||
count = "unknown"
|
||||
if count:
|
||||
logger.warning(
|
||||
"downgrade 0021: dropping {} ({} rows will be lost)",
|
||||
table_name,
|
||||
count,
|
||||
)
|
||||
|
||||
for table_name in table_names:
|
||||
if inspector.has_table(table_name):
|
||||
op.drop_table(table_name)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Add note_references table (generic note → target references with
|
||||
optional quote anchors)
|
||||
|
||||
The reference layer for the notes feature: a row records that a note
|
||||
refers to a TARGET — a library document or a research run — optionally
|
||||
anchored to a quoted passage of the target's (immutable) rendered text
|
||||
(quote + short prefix/suffix disambiguation context, a Hypothesis-style
|
||||
TextQuoteSelector). Anchored rows power the Word-review-style inline
|
||||
comments; anchor-less rows are plain "this note is about that document"
|
||||
links (the document-page Notes panel).
|
||||
|
||||
Research-run inline comments previously kept their anchors inside
|
||||
ResearchHistory.research_meta["annotations"] (a pre-release iteration on
|
||||
this same feature branch); they now live here so documents and research
|
||||
share one mechanism. No data migration: the meta-based format never
|
||||
shipped in a release, so only dev environments carry any, and those
|
||||
anchors simply retire (the comment NOTES themselves are untouched).
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
|
||||
Tables Added:
|
||||
=============
|
||||
- note_references: note → (document | research) references with optional
|
||||
quote anchors
|
||||
|
||||
Downgrade Behavior:
|
||||
==================
|
||||
Drops note_references. Anchors (highlight positions) are permanently
|
||||
lost on downgrade; the referenced notes and targets are untouched.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy_utc import UtcDateTime, utcnow
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0022"
|
||||
down_revision = "0021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create the note_references table."""
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
|
||||
if not inspector.has_table("note_references"):
|
||||
op.create_table(
|
||||
"note_references",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.Integer(),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
),
|
||||
sa.Column(
|
||||
"note_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"target_document_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"target_research_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("research_history.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
# Anchor fields. NULL quote = anchor-less reference (a plain
|
||||
# "this note is about that target" link); non-NULL quote = an
|
||||
# inline annotation highlighted in the target's rendered text.
|
||||
sa.Column("quote", sa.Text(), nullable=True),
|
||||
sa.Column("prefix", sa.Text(), nullable=True),
|
||||
sa.Column("suffix", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
UtcDateTime(),
|
||||
nullable=False,
|
||||
server_default=utcnow(),
|
||||
),
|
||||
# Exactly one target. Mirrors the model-level CheckConstraint
|
||||
# (three-way parity guarded by tests, same house style as the
|
||||
# note_syntheses enum CHECK).
|
||||
sa.CheckConstraint(
|
||||
"(target_document_id IS NOT NULL) + "
|
||||
"(target_research_id IS NOT NULL) = 1",
|
||||
name="ck_note_reference_single_target",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Drop the note_references table.
|
||||
|
||||
note_references holds inline-annotation quote anchors (and plain
|
||||
note→target references), so log the row count before dropping — an
|
||||
operator running the downgrade by accident should see what they're
|
||||
about to lose, matching migration 0021's downgrade.
|
||||
"""
|
||||
from loguru import logger
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
if inspector.has_table("note_references"):
|
||||
try:
|
||||
count = conn.execute(
|
||||
sa.text("SELECT COUNT(*) FROM note_references") # noqa: S608
|
||||
).scalar()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"downgrade 0022: could not count note_references before drop"
|
||||
)
|
||||
count = "unknown"
|
||||
if count:
|
||||
logger.warning(
|
||||
"downgrade 0022: dropping note_references "
|
||||
"({} annotation/reference rows will be lost)",
|
||||
count,
|
||||
)
|
||||
op.drop_table("note_references")
|
||||
@@ -99,6 +99,18 @@ from .zotero import (
|
||||
# Import Domain Classification model
|
||||
from ...domain_classifier.models import DomainClassification
|
||||
|
||||
# Note Models (notes are Documents with source_type='note')
|
||||
from .note import (
|
||||
NoteChangeType,
|
||||
NoteLink,
|
||||
NoteReference,
|
||||
NoteResearch,
|
||||
NoteSynthesis,
|
||||
NoteSynthesisSource,
|
||||
NoteSynthesisType,
|
||||
NoteVersion,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
"Base",
|
||||
@@ -198,4 +210,13 @@ __all__ = [
|
||||
"ZoteroItemMap",
|
||||
# Domain Classification
|
||||
"DomainClassification",
|
||||
# Note Models
|
||||
"NoteChangeType",
|
||||
"NoteSynthesisType",
|
||||
"NoteVersion",
|
||||
"NoteLink",
|
||||
"NoteReference",
|
||||
"NoteResearch",
|
||||
"NoteSynthesis",
|
||||
"NoteSynthesisSource",
|
||||
]
|
||||
|
||||
@@ -361,12 +361,12 @@ class Document(Base):
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
title_str = (
|
||||
self.title[:50]
|
||||
if self.title
|
||||
else (self.filename[:50] if self.filename else "Untitled")
|
||||
)
|
||||
return f"<Document(title='{title_str}', type={self.file_type}, size={self.file_size})>"
|
||||
# ID-only repr — earlier versions interpolated the title slice
|
||||
# which leaks user note content into log lines / SQLAlchemy
|
||||
# error messages on a multi-tenant deployment. The id is enough
|
||||
# for debugging; full row data is one query away.
|
||||
doc_id = self.id[:8] if self.id else "None"
|
||||
return f"<Document(id={doc_id}, type={self.file_type})>"
|
||||
|
||||
|
||||
class DocumentBlob(Base):
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
Note-specific models for AI-connected notes feature.
|
||||
|
||||
Notes are stored as Documents with source_type='note'. This module contains
|
||||
auxiliary tables for note-specific features:
|
||||
- Version history
|
||||
- Wiki-style linking
|
||||
- Research integration
|
||||
- Synthesis tracking
|
||||
|
||||
Naming note: the `note_*` table names are a service-layer scoping
|
||||
convention — every FK actually points at `documents.id`, not at a dedicated
|
||||
notes table. Renaming to `document_*` was considered during review since the
|
||||
shapes (versions, links, research-pins) could apply to any Document, but
|
||||
`change_type` enum values like `auto_save` / `pre_restore` are note-specific
|
||||
semantics and would be meaningless for research-downloaded PDFs. Revisit the
|
||||
naming only when there's a real second use case for versioning/linking on
|
||||
other Document subtypes.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import backref, relationship
|
||||
from sqlalchemy_utc import UtcDateTime, utcnow
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class NoteChangeType(str, enum.Enum):
|
||||
"""Valid values for NoteVersion.change_type."""
|
||||
|
||||
INITIAL = "initial"
|
||||
AUTO_SAVE = "auto_save"
|
||||
MANUAL_SAVE = "manual_save"
|
||||
PRE_RESTORE = "pre_restore"
|
||||
RESTORE = "restore"
|
||||
|
||||
|
||||
class NoteSynthesisType(str, enum.Enum):
|
||||
"""Valid values for NoteSynthesis.synthesis_type."""
|
||||
|
||||
MERGE = "merge"
|
||||
SUMMARIZE = "summarize"
|
||||
COMPARE = "compare"
|
||||
|
||||
|
||||
class NoteVersion(Base):
|
||||
"""
|
||||
Stores version history for notes (documents with source_type='note').
|
||||
Enables undo, restore, and semantic change tracking.
|
||||
"""
|
||||
|
||||
__tablename__ = "note_versions"
|
||||
|
||||
# UUID PK (String(36)) rather than autoincrement Integer — versions may
|
||||
# be URL-shared / bookmarked, so IDs are externally visible. The other
|
||||
# note junction tables use Integer autoincrement because they're
|
||||
# internal-only; the asymmetry is intentional.
|
||||
id = Column(String(36), primary_key=True)
|
||||
|
||||
# The document (note) this version belongs to
|
||||
# Indexed via the composite idx_note_version_created below.
|
||||
document_id = Column(
|
||||
String(36),
|
||||
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Snapshot of the note at this version
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
tags = Column(
|
||||
JSON, nullable=False, default=list, server_default=text("'[]'")
|
||||
)
|
||||
|
||||
# Change metadata (see NoteChangeType)
|
||||
change_type = Column(
|
||||
Enum(
|
||||
NoteChangeType,
|
||||
values_callable=lambda obj: [e.value for e in obj],
|
||||
name="note_change_type",
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
change_summary = Column(
|
||||
Text, nullable=True
|
||||
) # LLM-generated description of changes
|
||||
content_hash = Column(
|
||||
String(64), nullable=False
|
||||
) # SHA-256 for deduplication
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(
|
||||
UtcDateTime,
|
||||
default=utcnow(),
|
||||
server_default=utcnow(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
document = relationship(
|
||||
"Document",
|
||||
backref=backref("note_versions", passive_deletes=True),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_note_version_created", "document_id", "created_at"),
|
||||
UniqueConstraint(
|
||||
"document_id", "content_hash", name="uix_note_version_content"
|
||||
),
|
||||
# Mirror NoteChangeType members above. The ORM Enum() with
|
||||
# values_callable suppresses SQLAlchemy's auto-CHECK, so declare
|
||||
# it explicitly here — parity with the migration.
|
||||
CheckConstraint(
|
||||
"change_type IN ('initial', 'auto_save', 'manual_save', "
|
||||
"'pre_restore', 'restore')",
|
||||
name="ck_note_change_type",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
doc_id = self.document_id[:8] if self.document_id else "None"
|
||||
return f"<NoteVersion(document_id={doc_id}, id={self.id[:8] if self.id else 'None'})>"
|
||||
|
||||
|
||||
class NoteLink(Base):
|
||||
"""
|
||||
Tracks wiki-style [[links]] between notes (documents with source_type='note').
|
||||
Enables backlinks feature and knowledge graph visualization.
|
||||
"""
|
||||
|
||||
__tablename__ = "note_links"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Source document (the note containing the link).
|
||||
# Not individually indexed — the UNIQUE(source, target) constraint
|
||||
# below produces a usable index for single-column lookups on source.
|
||||
source_document_id = Column(
|
||||
String(36),
|
||||
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Target document (the note being linked to).
|
||||
# NOT individually indexed here — the explicit `idx_note_link_target`
|
||||
# below in __table_args__ is the source of truth for this index.
|
||||
# Pre-fix the column also carried `index=True`, which made
|
||||
# SQLAlchemy create a second (auto-named) index over the same
|
||||
# column. Migration 0021 already only creates `idx_note_link_target`,
|
||||
# so production DBs are fine; this aligns the ORM declaration with
|
||||
# what actually lives on disk.
|
||||
target_document_id = Column(
|
||||
String(36),
|
||||
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# The text used in the link (e.g., [[My Note Title]])
|
||||
link_text = Column(String(500), nullable=False)
|
||||
|
||||
# Whether this link was auto-suggested by AI
|
||||
auto_suggested = Column(
|
||||
Boolean, default=False, server_default=text("0"), nullable=False
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(
|
||||
UtcDateTime,
|
||||
default=utcnow(),
|
||||
server_default=utcnow(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
source_document = relationship(
|
||||
"Document",
|
||||
foreign_keys=[source_document_id],
|
||||
backref=backref("outgoing_note_links", passive_deletes=True),
|
||||
)
|
||||
target_document = relationship(
|
||||
"Document",
|
||||
foreign_keys=[target_document_id],
|
||||
backref=backref("incoming_note_links", passive_deletes=True),
|
||||
)
|
||||
|
||||
# Ensure unique links (one link per source-target pair) and prevent self-links
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"source_document_id", "target_document_id", name="uix_note_link"
|
||||
),
|
||||
Index("idx_note_link_target", "target_document_id"),
|
||||
CheckConstraint(
|
||||
"source_document_id != target_document_id",
|
||||
name="ck_note_link_no_self",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
src_id = (
|
||||
self.source_document_id[:8] if self.source_document_id else "None"
|
||||
)
|
||||
tgt_id = (
|
||||
self.target_document_id[:8] if self.target_document_id else "None"
|
||||
)
|
||||
return f"<NoteLink(source={src_id}, target={tgt_id})>"
|
||||
|
||||
|
||||
class NoteResearch(Base):
|
||||
"""
|
||||
Junction table tracking research runs triggered from notes.
|
||||
Links notes (documents) to their research results.
|
||||
"""
|
||||
|
||||
__tablename__ = "note_research"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Foreign keys
|
||||
# document_id is indexed by the uix_note_research_display_order UNIQUE
|
||||
# (it leads with document_id), so no separate index is needed.
|
||||
document_id = Column(
|
||||
String(36),
|
||||
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
research_id = Column(
|
||||
String(36),
|
||||
ForeignKey("research_history.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Display settings
|
||||
display_order = Column(
|
||||
Integer, default=0, server_default=text("0"), nullable=False
|
||||
)
|
||||
is_collapsed = Column(
|
||||
Boolean, default=False, server_default=text("0"), nullable=False
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(
|
||||
UtcDateTime,
|
||||
default=utcnow(),
|
||||
server_default=utcnow(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
document = relationship(
|
||||
"Document",
|
||||
backref=backref("note_research_runs", passive_deletes=True),
|
||||
)
|
||||
research = relationship(
|
||||
"ResearchHistory",
|
||||
backref=backref("note_context", passive_deletes=True),
|
||||
)
|
||||
|
||||
# Ensure one entry per document-research pair, and prevent
|
||||
# duplicate display_order from a concurrent-insert race.
|
||||
# `link_research_to_note` computes MAX(display_order)+1 then INSERTs;
|
||||
# without the second UniqueConstraint two concurrent calls would
|
||||
# both observe the same MAX and insert with the same display_order.
|
||||
# The service-layer fix retries on IntegrityError; the constraint
|
||||
# is what makes the retry necessary (and safe).
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"document_id", "research_id", name="uix_note_research"
|
||||
),
|
||||
# uix_note_research_display_order leads with document_id, so its
|
||||
# backing btree already serves every (document_id [, display_order])
|
||||
# read — get_note_research's ORDER BY and link_research's
|
||||
# MAX(display_order) WHERE document_id. A separate
|
||||
# idx_note_research_order over the identical columns would be a
|
||||
# redundant second physical index (dead weight on the reorder
|
||||
# write path), so it is intentionally omitted — same reasoning the
|
||||
# note_links table applies to its source column.
|
||||
UniqueConstraint(
|
||||
"document_id",
|
||||
"display_order",
|
||||
name="uix_note_research_display_order",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
doc_id = self.document_id[:8] if self.document_id else "None"
|
||||
res_id = self.research_id[:8] if self.research_id else "None"
|
||||
return f"<NoteResearch(document_id={doc_id}, research_id={res_id})>"
|
||||
|
||||
|
||||
class NoteReference(Base):
|
||||
"""Generic note → target reference with an optional quote anchor.
|
||||
|
||||
A row records that a note refers to a TARGET — a library document or
|
||||
a research run (exactly one, CHECK-enforced). With a ``quote`` it is
|
||||
an inline annotation: the quoted passage of the target's immutable
|
||||
rendered text is highlighted, with short ``prefix``/``suffix``
|
||||
context disambiguating repeated phrases (Hypothesis-style
|
||||
TextQuoteSelector). Without a quote it is a plain "this note is
|
||||
about that target" link, powering the target page's Notes panel.
|
||||
|
||||
NoteResearch remains the note-side pinning of research runs (with
|
||||
per-note display ordering); this table is the target-side reference
|
||||
layer both documents and research share.
|
||||
"""
|
||||
|
||||
__tablename__ = "note_references"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
note_id = Column(
|
||||
String(36),
|
||||
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
target_document_id = Column(
|
||||
String(36),
|
||||
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
target_research_id = Column(
|
||||
String(36),
|
||||
ForeignKey("research_history.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Anchor (NULL quote = anchor-less reference).
|
||||
quote = Column(Text, nullable=True)
|
||||
prefix = Column(Text, nullable=True)
|
||||
suffix = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(
|
||||
UtcDateTime,
|
||||
default=utcnow(),
|
||||
server_default=utcnow(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
note = relationship(
|
||||
"Document",
|
||||
foreign_keys=[note_id],
|
||||
backref=backref("outgoing_references", passive_deletes=True),
|
||||
)
|
||||
target_document = relationship(
|
||||
"Document",
|
||||
foreign_keys=[target_document_id],
|
||||
backref=backref("note_annotations", passive_deletes=True),
|
||||
)
|
||||
target_research = relationship(
|
||||
"ResearchHistory",
|
||||
backref=backref("note_annotations", passive_deletes=True),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Exactly one target — mirrors ck_note_reference_single_target in
|
||||
# migration 0022 (three-way parity guarded by tests, same house
|
||||
# style as the note_syntheses enum CHECK).
|
||||
CheckConstraint(
|
||||
"(target_document_id IS NOT NULL) + "
|
||||
"(target_research_id IS NOT NULL) = 1",
|
||||
name="ck_note_reference_single_target",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
note_id = self.note_id[:8] if self.note_id else "None"
|
||||
target = self.target_document_id or self.target_research_id or "None"
|
||||
return (
|
||||
f"<NoteReference(note={note_id}, target={target[:8]}, "
|
||||
f"anchored={self.quote is not None})>"
|
||||
)
|
||||
|
||||
|
||||
class NoteSynthesis(Base):
|
||||
"""
|
||||
Tracks note synthesis operations (merge, compare, summarize).
|
||||
Links synthesized notes to their source notes via NoteSynthesisSource.
|
||||
"""
|
||||
|
||||
__tablename__ = "note_syntheses"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# The resulting synthesized note (document).
|
||||
# `ondelete='SET NULL'` (not CASCADE) preserves the audit record even
|
||||
# after the result note is deleted. CASCADE was considered because no
|
||||
# route currently reads syntheses, but destroying the record — and its
|
||||
# junction rows tying each source note to this operation — loses more
|
||||
# than it's worth: a future "what AI operations have I run?" UI can
|
||||
# read NULL-result rows meaningfully.
|
||||
result_document_id = Column(
|
||||
String(36),
|
||||
ForeignKey("documents.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Type of synthesis performed. Enum with `values_callable` mirrors the
|
||||
# house style used by NoteChangeType (and DocumentStatus / EmbeddingProvider
|
||||
# elsewhere): suppresses SQLAlchemy's auto-CHECK so the sole DB-level
|
||||
# enforcement is the explicit CheckConstraint in __table_args__. When
|
||||
# NoteSynthesisType grows, update both literals; TestEnumCheckParity
|
||||
# in tests/database/test_migration_0021_note_tables.py guards the parity.
|
||||
synthesis_type = Column(
|
||||
Enum(
|
||||
NoteSynthesisType,
|
||||
values_callable=lambda obj: [e.value for e in obj],
|
||||
name="note_synthesis_type",
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(
|
||||
UtcDateTime,
|
||||
default=utcnow(),
|
||||
server_default=utcnow(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
result_document = relationship(
|
||||
"Document", foreign_keys=[result_document_id]
|
||||
)
|
||||
sources = relationship(
|
||||
"NoteSynthesisSource",
|
||||
cascade="all, delete-orphan",
|
||||
backref=backref("synthesis"),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Mirror NoteSynthesisType members above. The ORM Enum() with
|
||||
# values_callable suppresses SQLAlchemy's auto-CHECK, so declare
|
||||
# it explicitly here — parity with the migration.
|
||||
CheckConstraint(
|
||||
"synthesis_type IN ('merge', 'summarize', 'compare')",
|
||||
name="ck_note_synthesis_type",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NoteSynthesis(type={self.synthesis_type}, sources={len(self.sources)})>"
|
||||
|
||||
|
||||
class NoteSynthesisSource(Base):
|
||||
"""
|
||||
Junction table linking a NoteSynthesis to its source Documents.
|
||||
|
||||
Replaces an earlier JSON-list column on NoteSynthesis (source_document_ids)
|
||||
that lacked FK integrity — the author's original TODO on migration 0006.
|
||||
Do NOT revert to a JSON list "for simplicity": it re-opens the stale-ID
|
||||
class of bug, where deleting a source note leaves dangling references in
|
||||
the synthesis record forever.
|
||||
"""
|
||||
|
||||
__tablename__ = "note_synthesis_sources"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
synthesis_id = Column(
|
||||
Integer,
|
||||
ForeignKey("note_syntheses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_document_id = Column(
|
||||
String(36),
|
||||
ForeignKey("documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
created_at = Column(
|
||||
UtcDateTime,
|
||||
default=utcnow(),
|
||||
server_default=utcnow(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
source_document = relationship(
|
||||
"Document", foreign_keys=[source_document_id]
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"synthesis_id",
|
||||
"source_document_id",
|
||||
name="uix_note_synthesis_source",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
syn_id = self.synthesis_id if self.synthesis_id is not None else "None"
|
||||
src_id = (
|
||||
self.source_document_id[:8] if self.source_document_id else "None"
|
||||
)
|
||||
return f"<NoteSynthesisSource(synthesis={syn_id}, source={src_id})>"
|
||||
@@ -17,7 +17,10 @@ from flask import Blueprint, jsonify, request, session
|
||||
from ....web.auth.decorators import login_required
|
||||
from ...utils import handle_api_error
|
||||
from ..services.document_deletion import DocumentDeletionService
|
||||
from ..services.collection_deletion import CollectionDeletionService
|
||||
from ..services.collection_deletion import (
|
||||
CollectionDeletionService,
|
||||
PROTECTED_COLLECTION_TYPES,
|
||||
)
|
||||
from ..services.bulk_deletion import BulkDeletionService
|
||||
|
||||
|
||||
@@ -79,6 +82,11 @@ def delete_document(document_id):
|
||||
|
||||
if result.get("deleted"):
|
||||
return jsonify({"success": True, **result})
|
||||
# Note refusal (bypass guard): caller targeted a note via the
|
||||
# document API. Return 403 so the frontend can route them to
|
||||
# DELETE /api/notes/<id>, distinct from 404 (not found).
|
||||
if result.get("is_note"):
|
||||
return jsonify({"success": False, **result}), 403
|
||||
return jsonify({"success": False, **result}), 404
|
||||
|
||||
except Exception as e:
|
||||
@@ -104,6 +112,14 @@ def delete_document_blob(document_id):
|
||||
|
||||
if result.get("deleted"):
|
||||
return jsonify({"success": True, **result})
|
||||
# Note refusal: mirror the sibling DELETE /document/<id> route
|
||||
# which returns 403 when delete_document declines on a note.
|
||||
# Pre-fix this branch only returned 400 (bad request) because
|
||||
# the error string lacked "not found", which confuses clients
|
||||
# routing 403 → "use the notes API instead" vs 400 → "bad
|
||||
# request payload".
|
||||
if result.get("is_note"):
|
||||
return jsonify({"success": False, **result}), 403
|
||||
error_code = (
|
||||
404 if "not found" in result.get("error", "").lower() else 400
|
||||
)
|
||||
@@ -163,6 +179,11 @@ def remove_document_from_collection(collection_id, document_id):
|
||||
|
||||
if result.get("unlinked"):
|
||||
return jsonify({"success": True, **result})
|
||||
# Protected-home refusal (note in its Notes collection): 403 so
|
||||
# the frontend can distinguish it from 404 (not found / not in
|
||||
# this collection), mirroring the is_note guard on delete_document.
|
||||
if result.get("protected"):
|
||||
return jsonify({"success": False, **result}), 403
|
||||
return jsonify({"success": False, **result}), 404
|
||||
|
||||
except Exception as e:
|
||||
@@ -196,7 +217,12 @@ def delete_collection(collection_id):
|
||||
if result.get("deleted"):
|
||||
return jsonify({"success": True, **result})
|
||||
error = result.get("error", "Unknown error")
|
||||
status_code = 404 if "not found" in error.lower() else 400
|
||||
if "not found" in error.lower():
|
||||
status_code = 404
|
||||
elif result.get("collection_type") in PROTECTED_COLLECTION_TYPES:
|
||||
status_code = 409
|
||||
else:
|
||||
status_code = 400
|
||||
return jsonify({"success": False, **result}), status_code
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -13,15 +13,32 @@ from loguru import logger
|
||||
|
||||
from ....database.models.library import (
|
||||
Collection,
|
||||
Document,
|
||||
DocumentCollection,
|
||||
DocumentChunk,
|
||||
CollectionFolder,
|
||||
RAGIndex,
|
||||
RagDocumentStatus,
|
||||
SourceType,
|
||||
)
|
||||
from ....database.session_context import get_user_db_session
|
||||
from ..utils.cascade_helper import CascadeHelper
|
||||
|
||||
# System collection types that must never be deleted via the public API.
|
||||
# These collections hold first-class user data whose lifecycle is owned by
|
||||
# other subsystems (Library, Research History, Notes); allowing them to be
|
||||
# deleted via the generic collection-delete path triggers cascade orphan
|
||||
# deletion of every document inside, which for Notes means total data loss
|
||||
# of notes, versions, links, and synthesis sources.
|
||||
# NOTE: 'zotero' is deliberately NOT protected — unlike the singleton system
|
||||
# collections above, users create/remove Zotero-synced collections and the
|
||||
# generic delete path is their only UI for it. Whether deleting a Zotero
|
||||
# collection should also purge its synced documents is a separate question
|
||||
# for the Zotero sync owner to decide, not something to block wholesale here.
|
||||
PROTECTED_COLLECTION_TYPES = frozenset(
|
||||
{"default_library", "research_history", "notes"}
|
||||
)
|
||||
|
||||
|
||||
class CollectionDeletionService:
|
||||
"""Service for collection deletion operations."""
|
||||
@@ -83,6 +100,23 @@ class CollectionDeletionService:
|
||||
"error": "Collection not found",
|
||||
}
|
||||
|
||||
if collection.collection_type in PROTECTED_COLLECTION_TYPES:
|
||||
logger.warning(
|
||||
"Refused to delete protected system collection "
|
||||
f"{collection_id[:8]}... (type={collection.collection_type})"
|
||||
)
|
||||
return {
|
||||
"deleted": False,
|
||||
"collection_id": collection_id,
|
||||
"collection_name": collection.name,
|
||||
"collection_type": collection.collection_type,
|
||||
"error": (
|
||||
"Cannot delete system collection "
|
||||
f"'{collection.name}' (type={collection.collection_type}). "
|
||||
"This collection holds first-class user data."
|
||||
),
|
||||
}
|
||||
|
||||
collection_name = f"collection_{collection_id}"
|
||||
result = {
|
||||
"deleted": False,
|
||||
@@ -139,6 +173,19 @@ class CollectionDeletionService:
|
||||
|
||||
# 8. Delete orphaned documents if requested
|
||||
if delete_orphaned_documents:
|
||||
# Notes must never be hard-deleted via the orphan
|
||||
# cascade: they live behind their own deletion API
|
||||
# (DELETE /api/notes/<id>) and remain discoverable
|
||||
# via list_notes(), which filters by source_type_id
|
||||
# rather than collection membership. This mirrors the
|
||||
# note-skip in document_deletion.py's orphan path. The
|
||||
# check is kept local (a single SourceType lookup) to
|
||||
# avoid importing the notes-services package into the
|
||||
# deletion package, matching document_deletion's own
|
||||
# local _is_note_document.
|
||||
note_source = (
|
||||
session.query(SourceType).filter_by(name="note").first()
|
||||
)
|
||||
for doc_id in doc_ids_in_collection:
|
||||
# Check if document is in any other collection
|
||||
remaining = (
|
||||
@@ -147,6 +194,20 @@ class CollectionDeletionService:
|
||||
.count()
|
||||
)
|
||||
if remaining == 0:
|
||||
if note_source is not None:
|
||||
document = session.query(Document).get(doc_id)
|
||||
if (
|
||||
document is not None
|
||||
and document.source_type_id
|
||||
== note_source.id
|
||||
):
|
||||
logger.info(
|
||||
f"Note {doc_id[:8]}... orphaned by "
|
||||
"collection deletion — skipping orphan "
|
||||
"delete; use DELETE /api/notes/<id> "
|
||||
"to remove."
|
||||
)
|
||||
continue
|
||||
# Document is orphaned - delete it
|
||||
CascadeHelper.delete_document_completely(
|
||||
session, doc_id
|
||||
|
||||
@@ -13,14 +13,31 @@ from loguru import logger
|
||||
|
||||
from ....constants import FILE_PATH_BLOB_DELETED
|
||||
from ....database.models.library import (
|
||||
Collection,
|
||||
Document,
|
||||
DocumentChunk,
|
||||
DocumentCollection,
|
||||
SourceType,
|
||||
)
|
||||
from ....database.session_context import get_user_db_session
|
||||
from ..utils.cascade_helper import CascadeHelper
|
||||
|
||||
|
||||
def _is_note_document(session, document) -> bool:
|
||||
"""Return True if the document represents a note (source_type='note').
|
||||
|
||||
Notes have their own deletion API at /api/notes/<id>. Letting them be
|
||||
deleted via the generic document-deletion endpoints would bypass the
|
||||
B1 protection that was specifically built to prevent note data loss
|
||||
(see PROTECTED_COLLECTION_TYPES in collection_deletion.py).
|
||||
|
||||
The check is local-by-design — keep this module free of imports from
|
||||
the notes package to avoid an import cycle.
|
||||
"""
|
||||
note_source = session.query(SourceType).filter_by(name="note").first()
|
||||
return bool(note_source) and document.source_type_id == note_source.id
|
||||
|
||||
|
||||
class DocumentDeletionService:
|
||||
"""Service for document deletion operations."""
|
||||
|
||||
@@ -73,6 +90,25 @@ class DocumentDeletionService:
|
||||
"error": "Document not found",
|
||||
}
|
||||
|
||||
# Notes are not deletable via the generic document API.
|
||||
# The Notes-collection deletion guard only blocks the
|
||||
# cascade path; without this check a user could wipe an
|
||||
# individual note by passing its UUID here, and the bulk
|
||||
# endpoint amplifies the same gap. Route the caller to
|
||||
# DELETE /api/notes/<id> which goes through NoteService
|
||||
# with the proper version/link cleanup.
|
||||
if _is_note_document(session, document):
|
||||
return {
|
||||
"deleted": False,
|
||||
"document_id": document_id,
|
||||
"title": document.title or "Untitled",
|
||||
"error": (
|
||||
"Notes cannot be deleted via the document API. "
|
||||
"Use DELETE /api/notes/<id> instead."
|
||||
),
|
||||
"is_note": True,
|
||||
}
|
||||
|
||||
title = document.title or document.filename or "Untitled"
|
||||
result: Dict[str, Any] = {
|
||||
"deleted": False,
|
||||
@@ -180,6 +216,24 @@ class DocumentDeletionService:
|
||||
"error": "Document not found",
|
||||
}
|
||||
|
||||
# Notes are not mutable via the generic blob-delete API.
|
||||
# Without this guard, a note's storage_mode/file_path can
|
||||
# be overwritten with the PDF-blob-deletion sentinel via
|
||||
# either DELETE /library/api/document/<id>/blob OR the
|
||||
# bulk DELETE /library/api/documents/blobs path (which
|
||||
# loops over this method per-id).
|
||||
if _is_note_document(session, document):
|
||||
return {
|
||||
"deleted": False,
|
||||
"document_id": document_id,
|
||||
"bytes_freed": 0,
|
||||
"error": (
|
||||
"Notes cannot be modified via the document API. "
|
||||
"Use the notes API instead."
|
||||
),
|
||||
"is_note": True,
|
||||
}
|
||||
|
||||
result = {
|
||||
"deleted": False,
|
||||
"document_id": document_id,
|
||||
@@ -304,6 +358,34 @@ class DocumentDeletionService:
|
||||
"error": "Document not in this collection",
|
||||
}
|
||||
|
||||
# Permanent-home guard: the Notes collection is every
|
||||
# note's home (_get_or_create_notes_collection); nothing
|
||||
# re-links a note after an unlink, so removal here would
|
||||
# silently drop the note from semantic search, the
|
||||
# collection view, and the auto-reindex worker — while
|
||||
# list_notes still shows it. NoteService.remove_from_collection
|
||||
# enforces this for the notes API; this mirrors it for the
|
||||
# generic collection-document API (single + bulk).
|
||||
collection = session.query(Collection).get(collection_id)
|
||||
if (
|
||||
collection is not None
|
||||
and collection.collection_type == "notes"
|
||||
and _is_note_document(session, document)
|
||||
):
|
||||
return {
|
||||
"unlinked": False,
|
||||
"document_deleted": False,
|
||||
"document_id": document_id,
|
||||
"collection_id": collection_id,
|
||||
"protected": True,
|
||||
"error": (
|
||||
"Cannot remove a note from its notes "
|
||||
"collection — it is the note's permanent "
|
||||
"home. Use DELETE /api/notes/<id> to delete "
|
||||
"the note itself."
|
||||
),
|
||||
}
|
||||
|
||||
result = {
|
||||
"unlinked": False,
|
||||
"document_deleted": False,
|
||||
@@ -327,7 +409,21 @@ class DocumentDeletionService:
|
||||
session, document_id
|
||||
)
|
||||
|
||||
if remaining_count == 0:
|
||||
if remaining_count == 0 and _is_note_document(
|
||||
session, document
|
||||
):
|
||||
# Note that was orphaned by this removal: skip the
|
||||
# destructive delete_document_completely path. Notes
|
||||
# live behind their own deletion API (DELETE /api/notes/<id>)
|
||||
# and are still discoverable via list_notes() which
|
||||
# filters by source_type_id, not collection membership.
|
||||
# The Notes collection itself acts as the persistent
|
||||
# home for every note via _get_or_create_notes_collection.
|
||||
logger.info(
|
||||
f"Note {document_id[:8]}... orphaned from collection — "
|
||||
"skipping orphan delete; use DELETE /api/notes/<id> to remove."
|
||||
)
|
||||
elif remaining_count == 0:
|
||||
# Document is orphaned - delete it completely
|
||||
# Note: We're already in a session, so we need to do this
|
||||
# directly rather than calling delete_document()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Notes module - AI-enhanced note-taking within the research library.
|
||||
|
||||
Notes are stored as Documents with source_type='note', sharing the same
|
||||
infrastructure as research documents while providing note-specific features:
|
||||
- Wiki-style [[linking]] between notes
|
||||
- Version history
|
||||
- AI-powered summarization, tagging, and concept extraction
|
||||
- Research integration
|
||||
|
||||
Note: Routes remain in web/routes/notes_routes.py since they need Flask templates.
|
||||
This module provides the services layer.
|
||||
"""
|
||||
|
||||
from .services.note_service import NoteService
|
||||
from .services.note_ai_service import NoteAIService
|
||||
|
||||
__all__ = ["NoteService", "NoteAIService"]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Notes services."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from ....database.models import SourceType
|
||||
from .note_ai_service import NoteAIService
|
||||
from .note_service import NoteService
|
||||
|
||||
|
||||
def get_note_source_type_id(session) -> Optional[str]:
|
||||
"""Get the source type ID for notes. Returns None if not found."""
|
||||
source_type = session.query(SourceType).filter_by(name="note").first()
|
||||
return source_type.id if source_type else None
|
||||
|
||||
|
||||
__all__ = ["NoteService", "NoteAIService", "get_note_source_type_id"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -373,14 +373,18 @@ def toggle_favorite(document_id):
|
||||
return jsonify({"favorite": is_favorite})
|
||||
|
||||
|
||||
@library_bp.route("/api/document/<string:document_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_document(document_id):
|
||||
"""Delete a document from library."""
|
||||
username = session["username"]
|
||||
service = LibraryService(username)
|
||||
success = service.delete_document(document_id)
|
||||
return jsonify({"success": success})
|
||||
# NOTE: DELETE /library/api/document/<id> is intentionally NOT defined here.
|
||||
# The canonical handler lives in deletion/routes/delete_routes.py
|
||||
# (delete_bp, url_prefix="/library/api") which adds the note-protection
|
||||
# guard (returns 403 for a note id, routing the caller to DELETE
|
||||
# /api/notes/<id>) and the richer cascade (filesystem + FAISS + chunk
|
||||
# stats). This blueprint previously also declared that exact path; since
|
||||
# library_bp is registered before delete_bp (app_factory.py), Werkzeug
|
||||
# resolved to THIS unguarded handler first, making the guard dead code and
|
||||
# letting a note Document be hard-deleted via the document API. The
|
||||
# duplicate route was removed so the guarded endpoint is reachable. The
|
||||
# route-resolution invariant is locked by a test in
|
||||
# tests/research_library/deletion (test_delete_document_route_is_guarded).
|
||||
|
||||
|
||||
@library_bp.route("/api/document/<string:document_id>/pdf-url")
|
||||
|
||||
@@ -88,6 +88,22 @@ def _agent_enabled_default_on(collection) -> bool:
|
||||
return getattr(collection, "agent_enabled", True) is not False
|
||||
|
||||
|
||||
def _is_protected_collection(collection) -> bool:
|
||||
"""True when the collection's type is deletion-protected.
|
||||
|
||||
Single source of truth is PROTECTED_COLLECTION_TYPES in the deletion
|
||||
service (imported lazily, matching update_collection's usage); the
|
||||
serializers expose the result as ``is_protected`` so UI surfaces can
|
||||
hide destructive affordances instead of offering an action the server
|
||||
categorically refuses with a 409.
|
||||
"""
|
||||
from ..deletion.services.collection_deletion import (
|
||||
PROTECTED_COLLECTION_TYPES,
|
||||
)
|
||||
|
||||
return collection.collection_type in PROTECTED_COLLECTION_TYPES
|
||||
|
||||
|
||||
# Global ThreadPoolExecutor for auto-indexing to prevent thread proliferation.
|
||||
#
|
||||
# ThreadPoolExecutor's internal _work_queue is unbounded. Without
|
||||
@@ -1304,6 +1320,23 @@ def create_collection():
|
||||
name = data.get("name", "").strip()
|
||||
description = data.get("description", "").strip()
|
||||
collection_type = data.get("type", "user_uploads")
|
||||
# Allowlist user-creatable types. System types (notes,
|
||||
# default_library, research_history) are lazy-created by their
|
||||
# owning subsystems; a user-crafted impostor (e.g. type="notes")
|
||||
# would be undeletable under PROTECTED_COLLECTION_TYPES and could
|
||||
# nondeterministically win _get_or_create_notes_collection's
|
||||
# .first() lookup, splitting the notes corpus.
|
||||
allowed_types = {"user_uploads", "user_collection"}
|
||||
if collection_type not in allowed_types:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Invalid collection type '{collection_type}'. "
|
||||
f"Allowed: {sorted(allowed_types)}"
|
||||
),
|
||||
}
|
||||
), 400
|
||||
# Egress classification, default private (the safe choice). A public
|
||||
# collection counts as a public engine and may use cloud inference.
|
||||
is_public = bool(data.get("is_public", False))
|
||||
@@ -1388,6 +1421,33 @@ def update_collection(collection_id):
|
||||
{"success": False, "error": "Collection not found"}
|
||||
), 404
|
||||
|
||||
# System collections (Notes, Library, Research History) own
|
||||
# first-class user data whose lifecycle is managed by other
|
||||
# subsystems. Renaming them via this generic endpoint
|
||||
# bypasses their intended invariants and confuses every UI
|
||||
# surface that lists collections. Mirrors PROTECTED_COLLECTION_TYPES
|
||||
# in the deletion service. Only identity fields (name,
|
||||
# description) are locked — the is_public / agent_enabled
|
||||
# toggles below are deliberate per-collection settings that
|
||||
# must keep working for system collections too.
|
||||
from ..deletion.services.collection_deletion import (
|
||||
PROTECTED_COLLECTION_TYPES,
|
||||
)
|
||||
|
||||
if collection.collection_type in PROTECTED_COLLECTION_TYPES and (
|
||||
name or "description" in data
|
||||
):
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Cannot rename or redescribe system "
|
||||
f"collection '{collection.name}' "
|
||||
f"(type={collection.collection_type})."
|
||||
),
|
||||
}
|
||||
), 409
|
||||
|
||||
if name:
|
||||
# Check if new name conflicts with existing collection
|
||||
existing = (
|
||||
@@ -1409,7 +1469,9 @@ def update_collection(collection_id):
|
||||
|
||||
collection.name = name
|
||||
|
||||
if description is not None: # Allow empty description
|
||||
# Only write when the caller sends the key (sending "" still
|
||||
# clears) — otherwise toggle-only PUTs wipe the description.
|
||||
if "description" in data:
|
||||
collection.description = description
|
||||
|
||||
# Egress classification toggle (only when the caller sends it).
|
||||
@@ -1871,6 +1933,14 @@ def get_collection_documents(collection_id):
|
||||
{"success": False, "error": "Collection not found"}
|
||||
), 404
|
||||
|
||||
# Look up note source type to filter notes into separate array
|
||||
note_source_type = (
|
||||
db_session.query(SourceType).filter_by(name="note").first()
|
||||
)
|
||||
note_source_type_id = (
|
||||
note_source_type.id if note_source_type else None
|
||||
)
|
||||
|
||||
# Get documents through junction table. Compute "has text in DB"
|
||||
# at the SQL level and defer the text_content column, so listing a
|
||||
# collection doesn't pull every document's full text body into
|
||||
@@ -1889,6 +1959,12 @@ def get_collection_documents(collection_id):
|
||||
|
||||
documents = []
|
||||
for link, doc, has_text_db in doc_links:
|
||||
# Skip notes — they go in the separate notes array
|
||||
if (
|
||||
note_source_type_id
|
||||
and doc.source_type_id == note_source_type_id
|
||||
):
|
||||
continue
|
||||
# Check if PDF file is stored
|
||||
has_pdf = bool(
|
||||
doc.file_path and doc.file_path not in FILE_PATH_SENTINELS
|
||||
@@ -1936,6 +2012,41 @@ def get_collection_documents(collection_id):
|
||||
}
|
||||
)
|
||||
|
||||
# Get notes in this collection (note_source_type resolved above)
|
||||
notes = []
|
||||
if note_source_type:
|
||||
note_links = (
|
||||
db_session.query(DocumentCollection, Document)
|
||||
.join(Document)
|
||||
.filter(
|
||||
DocumentCollection.collection_id == collection_id,
|
||||
Document.source_type_id == note_source_type.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for link, note in note_links:
|
||||
content = note.text_content or ""
|
||||
notes.append(
|
||||
{
|
||||
"id": note.id,
|
||||
"title": note.title or "Untitled",
|
||||
"content_preview": content[:200] + "..."
|
||||
if len(content) > 200
|
||||
else content,
|
||||
"tags": note.tags or [],
|
||||
"pinned": note.favorite,
|
||||
"created_at": note.created_at.isoformat()
|
||||
if note.created_at
|
||||
else None,
|
||||
"updated_at": note.updated_at.isoformat()
|
||||
if note.updated_at
|
||||
else None,
|
||||
"indexed": link.indexed,
|
||||
"chunk_count": link.chunk_count,
|
||||
"source_type": "note",
|
||||
}
|
||||
)
|
||||
|
||||
# Get index file size if available
|
||||
index_file_size = None
|
||||
index_file_size_bytes = None
|
||||
@@ -1987,8 +2098,15 @@ def get_collection_documents(collection_id):
|
||||
"index_file_size": index_file_size,
|
||||
"index_file_size_bytes": index_file_size_bytes,
|
||||
"collection_type": collection.collection_type,
|
||||
# Deletion policy comes from the server so the UI
|
||||
# can't drift from PROTECTED_COLLECTION_TYPES: the
|
||||
# details page hides its Delete button for system
|
||||
# collections, whose deletion the service refuses
|
||||
# with a 409 anyway.
|
||||
"is_protected": _is_protected_collection(collection),
|
||||
},
|
||||
"documents": documents,
|
||||
"notes": notes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -336,12 +336,146 @@ class LibraryRAGService:
|
||||
return hashlib.sha256(hash_input.encode()).hexdigest()
|
||||
|
||||
def _get_index_path(self, index_hash: str) -> Path:
|
||||
"""Get path for FAISS index file."""
|
||||
"""Get path for FAISS index file.
|
||||
|
||||
Files are scoped to a per-user subdirectory derived from a hash
|
||||
of the username so two users with identical collection name +
|
||||
embedding model don't share the same .faiss/.pkl files on disk.
|
||||
The previous layout used a shared `cache/rag_indices/` for all
|
||||
users, which would let one user's vectors land in another
|
||||
user's load path in any deployment running more than one
|
||||
account against the same data dir.
|
||||
"""
|
||||
# Store in centralized cache directory (respects LDR_DATA_DIR)
|
||||
cache_dir = get_cache_directory() / "rag_indices"
|
||||
user_scope = hashlib.sha256(self.username.encode("utf-8")).hexdigest()[
|
||||
:16
|
||||
]
|
||||
cache_dir = get_cache_directory() / "rag_indices" / user_scope
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 0o700: filesystem-level defense-in-depth so other local
|
||||
# accounts can't browse another LDR user's vector caches.
|
||||
try:
|
||||
cache_dir.chmod(0o700)
|
||||
except OSError:
|
||||
# Best-effort: some filesystems (e.g. Windows shares) don't
|
||||
# honor chmod; the per-user path scope is still applied.
|
||||
pass
|
||||
return cache_dir / f"{index_hash}.faiss"
|
||||
|
||||
def _migrate_legacy_index_files(
|
||||
self, legacy_path: Path, index_path: Path, rag_index: RAGIndex
|
||||
) -> None:
|
||||
"""Relocate a pre-per-user-scoping index into the per-user directory.
|
||||
|
||||
Before the per-user path scoping, indexes lived directly in the
|
||||
shared ``cache/rag_indices/`` directory. ``load_or_create_faiss_index``
|
||||
recomputes the path and ignores the stored one, so without this
|
||||
one-time migration an upgrading user's existing index is never
|
||||
found again: semantic search silently returns nothing while
|
||||
``DocumentCollection.indexed`` still claims everything is indexed,
|
||||
so a non-force re-index no-ops too. Only files directly inside the
|
||||
known legacy shared directory with the expected hash filename are
|
||||
moved — arbitrary stored paths are not followed.
|
||||
"""
|
||||
legacy_root = get_cache_directory() / "rag_indices"
|
||||
if (
|
||||
legacy_path == index_path
|
||||
or legacy_path.parent != legacy_root
|
||||
or legacy_path.name != index_path.name
|
||||
or not legacy_path.exists()
|
||||
):
|
||||
return
|
||||
lock = _get_faiss_write_lock(self.username, str(index_path))
|
||||
with lock:
|
||||
if index_path.exists():
|
||||
return
|
||||
try:
|
||||
legacy_pkl = legacy_path.with_suffix(".pkl")
|
||||
legacy_path.rename(index_path)
|
||||
if legacy_pkl.exists():
|
||||
legacy_pkl.rename(index_path.with_suffix(".pkl"))
|
||||
except OSError:
|
||||
logger.exception(
|
||||
f"Failed to migrate legacy FAISS index "
|
||||
f"{legacy_path} -> {index_path}"
|
||||
)
|
||||
return
|
||||
# Persist the relocated path so the row stops pointing at the
|
||||
# now-empty legacy location.
|
||||
with get_user_db_session(self.username, self.db_password) as session:
|
||||
idx = session.query(RAGIndex).filter_by(id=rag_index.id).first()
|
||||
if idx:
|
||||
idx.index_path = str(index_path)
|
||||
session.commit()
|
||||
try:
|
||||
# Refresh the integrity record under the new path (the old
|
||||
# record is keyed by the legacy path and no longer applies).
|
||||
self.integrity_manager.record_file(
|
||||
index_path,
|
||||
related_entity_type="rag_index",
|
||||
related_entity_id=rag_index.id,
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal: verify_file() creates a record on demand for
|
||||
# unknown paths, so the subsequent load still succeeds.
|
||||
logger.exception(
|
||||
"Failed to refresh integrity record after index migration"
|
||||
)
|
||||
logger.info(
|
||||
f"Migrated legacy shared FAISS index to per-user location: "
|
||||
f"{index_path}"
|
||||
)
|
||||
|
||||
def _reset_index_state_for_rebuild(
|
||||
self, collection_id: str, rag_index: RAGIndex
|
||||
) -> None:
|
||||
"""Reset per-document indexed state when the index file is gone.
|
||||
|
||||
Reaching the fresh-build branch with DB rows that still claim
|
||||
indexed content means the on-disk index was lost (missing after an
|
||||
upgrade, quarantined, or deleted on dimension mismatch). Without
|
||||
this reset, ``index_document``/``index_collection`` skip every
|
||||
document (``DocumentCollection.indexed`` is still True), so the
|
||||
only repair is an undocumented force re-index while search
|
||||
silently returns nothing. Clearing the flags makes the regular
|
||||
re-index path rebuild for real and makes the UI show the honest
|
||||
unindexed state. Worst case of the narrow race with an in-flight
|
||||
indexer is re-submitting chunks the merge step dedups by id.
|
||||
"""
|
||||
with get_user_db_session(self.username, self.db_password) as session:
|
||||
idx = session.query(RAGIndex).filter_by(id=rag_index.id).first()
|
||||
claims_content = bool(
|
||||
idx and (idx.chunk_count or idx.total_documents)
|
||||
)
|
||||
stale_status = (
|
||||
session.query(RagDocumentStatus)
|
||||
.filter_by(rag_index_id=rag_index.id)
|
||||
.count()
|
||||
)
|
||||
stale_memberships = (
|
||||
session.query(DocumentCollection)
|
||||
.filter_by(collection_id=collection_id, indexed=True)
|
||||
.count()
|
||||
)
|
||||
if not (claims_content or stale_status or stale_memberships):
|
||||
# Genuinely new index — nothing to reset.
|
||||
return
|
||||
if idx:
|
||||
idx.chunk_count = 0
|
||||
idx.total_documents = 0
|
||||
session.query(RagDocumentStatus).filter_by(
|
||||
rag_index_id=rag_index.id
|
||||
).delete()
|
||||
session.query(DocumentCollection).filter_by(
|
||||
collection_id=collection_id, indexed=True
|
||||
).update({"indexed": False, "chunk_count": 0})
|
||||
session.commit()
|
||||
logger.warning(
|
||||
f"FAISS index file for collection {collection_id} is "
|
||||
f"missing; reset indexed state for {stale_memberships} "
|
||||
f"document(s) so the next index run rebuilds the index."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _deduplicate_chunks(
|
||||
chunks: List[LangchainDocument],
|
||||
@@ -573,12 +707,90 @@ class LibraryRAGService:
|
||||
f"Failed to prune quarantined file {stale}; continuing"
|
||||
)
|
||||
|
||||
# Cap on candidates run through the shared-text check. A document with
|
||||
# more distinct edited-away/deleted chunks than this is pathologically
|
||||
# large; rather than scan the collection for thousands of needles under
|
||||
# the FAISS write lock, keep them all (don't purge) — safe (never drops
|
||||
# a live document's vector), at worst leaving the same ghost vectors the
|
||||
# notes semantic-search ghost-hit filter already suppresses.
|
||||
_MAX_SHARED_TEXT_CANDIDATES = 512
|
||||
|
||||
def _shared_text_survivors(
|
||||
self,
|
||||
candidates: list,
|
||||
collection_id: Optional[str],
|
||||
exclude_document_id: str,
|
||||
) -> set:
|
||||
"""Purge candidates whose chunk text another collection member
|
||||
still contains.
|
||||
|
||||
``candidates`` is a list of ``(embedding_id, chunk_text)``. Chunk
|
||||
rows are deduped per (chunk_hash, collection) with one mutable
|
||||
``source_id`` owner, so ownership alone cannot prove a vector is
|
||||
unused: a self-owned row may be serving other documents' identical
|
||||
text (boilerplate, license blocks). Chunks are contiguous slices
|
||||
of ``Document.text_content`` (the splitters segment, they don't
|
||||
rewrite), so a substring containment test against the collection's
|
||||
other current members is a sound "someone still needs this" check.
|
||||
|
||||
Each OTHER member's ``text_content`` is read exactly once (one
|
||||
SQLCipher decrypt per member) and the still-pending candidates are
|
||||
checked against it in memory, resolved ones dropped as we go. The
|
||||
previous implementation issued one ``instr()`` full-scan query PER
|
||||
candidate — O(candidates × collection text) of decryption under the
|
||||
FAISS write lock, which on the deletion path (every chunk of the
|
||||
document is a candidate) turned deleting a large note into a
|
||||
minutes-long, lock-holding stall.
|
||||
"""
|
||||
survivors: set = set()
|
||||
pending = [
|
||||
(fid, chunk_text) for fid, chunk_text in candidates if chunk_text
|
||||
]
|
||||
if not pending or collection_id is None:
|
||||
return survivors
|
||||
if len(pending) > self._MAX_SHARED_TEXT_CANDIDATES:
|
||||
logger.warning(
|
||||
"Shared-text purge check skipped for {} candidate chunks "
|
||||
"(> cap {}); keeping them to avoid an unbounded scan — the "
|
||||
"ghost-hit filter covers any residual vectors",
|
||||
len(pending),
|
||||
self._MAX_SHARED_TEXT_CANDIDATES,
|
||||
)
|
||||
return {fid for fid, _ in pending}
|
||||
with get_user_db_session(self.username, self.db_password) as session:
|
||||
member_texts = (
|
||||
session.query(Document.text_content)
|
||||
.join(
|
||||
DocumentCollection,
|
||||
DocumentCollection.document_id == Document.id,
|
||||
)
|
||||
.filter(
|
||||
DocumentCollection.collection_id == collection_id,
|
||||
Document.id != exclude_document_id,
|
||||
Document.text_content.isnot(None),
|
||||
)
|
||||
.yield_per(1)
|
||||
)
|
||||
for (member_text,) in member_texts:
|
||||
if not pending:
|
||||
break
|
||||
still = []
|
||||
for fid, chunk_text in pending:
|
||||
if chunk_text in member_text:
|
||||
survivors.add(fid)
|
||||
else:
|
||||
still.append((fid, chunk_text))
|
||||
pending = still
|
||||
return survivors
|
||||
|
||||
def _merge_and_persist_locked(
|
||||
self,
|
||||
index_path: Path,
|
||||
chunks_to_add: list,
|
||||
embedding_ids: list,
|
||||
force_reindex: bool = False,
|
||||
replace_document_id: Optional[str] = None,
|
||||
replace_collection_id: Optional[str] = None,
|
||||
) -> Dict[str, int]:
|
||||
"""Read-modify-write the on-disk FAISS index under the
|
||||
``(username, index_path)`` write lock so concurrent workers
|
||||
@@ -607,6 +819,19 @@ class LibraryRAGService:
|
||||
``embedding_ids`` that already exist in the index
|
||||
(so the caller's metadata wins). If False, skip IDs
|
||||
that already exist (idempotent re-indexing).
|
||||
replace_document_id: Replace-on-reindex — purge the document's
|
||||
PRIOR chunk vectors from the index before adding the new
|
||||
ones. The set is derived from the freshly-reloaded FAISS
|
||||
docstore by ``metadata['document_id']`` (and collection),
|
||||
so it is authoritative and self-healing: a crash or a
|
||||
concurrent indexer that left orphan vectors is cleaned by
|
||||
the next re-index regardless of what any ephemeral DB
|
||||
snapshot said, and the last writer under the lock removes
|
||||
ALL prior versions' vectors. Ids also being re-added are
|
||||
kept.
|
||||
replace_collection_id: Scope the replace purge to this
|
||||
collection's chunks for the document (a document can be
|
||||
indexed into multiple collections).
|
||||
|
||||
Returns:
|
||||
``{"added": n, "skipped": m}`` for caller logging.
|
||||
@@ -630,7 +855,12 @@ class LibraryRAGService:
|
||||
str(index_path.parent),
|
||||
self.embedding_manager.embeddings,
|
||||
index_name=index_path.stem,
|
||||
normalize_L2=True,
|
||||
# Must match the creation-time setting at build
|
||||
# time (normalize_L2=self.normalize_vectors below)
|
||||
# — reloading an unnormalized index with
|
||||
# normalize_L2=True silently mixes normalized and
|
||||
# raw vectors in the same index.
|
||||
normalize_L2=self.normalize_vectors,
|
||||
)
|
||||
except Exception:
|
||||
# Reload failed (torn write, etc.). Keep
|
||||
@@ -641,6 +871,118 @@ class LibraryRAGService:
|
||||
"proceeding with in-memory state."
|
||||
)
|
||||
|
||||
# Replace-on-reindex: purge the document's prior chunk vectors
|
||||
# (different chunk hashes from an earlier content version) that
|
||||
# would otherwise be orphaned in the index forever — unbounded
|
||||
# growth + stale duplicate hits on every note edit. The set is
|
||||
# derived HERE, from the freshly-reloaded docstore, by matching
|
||||
# each vector's metadata['document_id'] (and collection) — NOT
|
||||
# from a caller-supplied snapshot. This makes it authoritative
|
||||
# and self-healing: it catches vectors a crashed or concurrent
|
||||
# earlier indexer left behind, and the last writer under the
|
||||
# lock removes every prior version's vectors. Ids we're about
|
||||
# to re-add are kept.
|
||||
purged_count = 0
|
||||
if replace_document_id and hasattr(self.faiss_index, "docstore"):
|
||||
keep = set(embedding_ids)
|
||||
purge = []
|
||||
for fid, fdoc in self.faiss_index.docstore._dict.items():
|
||||
if fid in keep:
|
||||
continue
|
||||
meta = getattr(fdoc, "metadata", None) or {}
|
||||
if meta.get("document_id") != replace_document_id:
|
||||
continue
|
||||
fdoc_coll = meta.get("collection_id")
|
||||
if (
|
||||
replace_collection_id is not None
|
||||
and fdoc_coll is not None
|
||||
and fdoc_coll != replace_collection_id
|
||||
):
|
||||
continue
|
||||
purge.append(fid)
|
||||
purge_texts = {
|
||||
fid: getattr(
|
||||
self.faiss_index.docstore._dict.get(fid),
|
||||
"page_content",
|
||||
None,
|
||||
)
|
||||
for fid in purge
|
||||
}
|
||||
if purge:
|
||||
# Exclusive-ownership check: within a collection the
|
||||
# schema dedups chunk rows by (chunk_hash,
|
||||
# collection_name), so one vector can serve several
|
||||
# documents while its metadata still names whichever
|
||||
# document indexed the text first. The DocumentChunk
|
||||
# row is the authoritative owner record
|
||||
# (_store_chunks_to_db re-points it to the latest
|
||||
# claimant); a candidate whose row belongs to a
|
||||
# DIFFERENT document is still in use — deleting it
|
||||
# would silently drop that document's content from
|
||||
# search while it still shows as indexed. Skip those
|
||||
# (they become the same benign orphan the pre-purge
|
||||
# code always left behind). Rowless candidates are
|
||||
# true orphans and stay purgeable.
|
||||
with get_user_db_session(
|
||||
self.username, self.db_password
|
||||
) as session:
|
||||
foreign_owned = set()
|
||||
for i in range(0, len(purge), 500):
|
||||
batch = purge[i : i + 500]
|
||||
foreign_owned.update(
|
||||
row[0]
|
||||
for row in session.query(
|
||||
DocumentChunk.embedding_id
|
||||
).filter(
|
||||
DocumentChunk.embedding_id.in_(batch),
|
||||
DocumentChunk.source_id
|
||||
!= replace_document_id,
|
||||
)
|
||||
)
|
||||
if foreign_owned:
|
||||
logger.info(
|
||||
f"Replace-on-reindex: keeping "
|
||||
f"{len(foreign_owned)} shared chunk vector(s) "
|
||||
f"now owned by other documents"
|
||||
)
|
||||
purge = [
|
||||
fid for fid in purge if fid not in foreign_owned
|
||||
]
|
||||
if purge:
|
||||
# Shared-text check: the single mutable source_id owner
|
||||
# can't represent multi-document sharing — a SELF-owned
|
||||
# row can still be serving another document's identical
|
||||
# text (ownership is last-claimer-wins, and rows created
|
||||
# before the repoint logic kept their first claimant).
|
||||
# Keep any candidate whose chunk text still appears in
|
||||
# another current member of this collection; deleting it
|
||||
# would silently drop that document's content from
|
||||
# search while it still shows as indexed. False keeps
|
||||
# are the same benign orphans the pre-purge code always
|
||||
# left behind.
|
||||
still_shared = self._shared_text_survivors(
|
||||
[(fid, purge_texts.get(fid)) for fid in purge],
|
||||
replace_collection_id,
|
||||
replace_document_id,
|
||||
)
|
||||
if still_shared:
|
||||
logger.info(
|
||||
f"Replace-on-reindex: keeping "
|
||||
f"{len(still_shared)} chunk vector(s) whose "
|
||||
f"text other collection documents still contain"
|
||||
)
|
||||
purge = [
|
||||
fid for fid in purge if fid not in still_shared
|
||||
]
|
||||
if purge:
|
||||
logger.info(
|
||||
f"Replace-on-reindex: removing {len(purge)} prior "
|
||||
f"chunk vector(s) for document "
|
||||
f"{str(replace_document_id)[:8]}... from FAISS"
|
||||
)
|
||||
self.faiss_index.delete(purge)
|
||||
purged_count = len(purge)
|
||||
|
||||
# Force-reindex: remove old copies of IDs we're about to
|
||||
# re-add, so updated metadata replaces stale. Dedup via
|
||||
# set→list because ``embedding_ids`` can contain repeats
|
||||
@@ -684,14 +1026,26 @@ class LibraryRAGService:
|
||||
"added": len(new_chunks),
|
||||
"skipped": len(chunks_to_add) - len(new_chunks),
|
||||
"added_ids": new_ids,
|
||||
"purged": purged_count,
|
||||
}
|
||||
|
||||
def load_or_create_faiss_index(self, collection_id: str) -> FAISS:
|
||||
def load_or_create_faiss_index(
|
||||
self, collection_id: str, *, reset_stale_state: bool = False
|
||||
) -> FAISS:
|
||||
"""
|
||||
Load existing FAISS index or create new one.
|
||||
|
||||
Args:
|
||||
collection_id: UUID of the collection
|
||||
reset_stale_state: When the on-disk index is gone (missing,
|
||||
quarantined, or deleted on dimension mismatch) but DB rows
|
||||
still claim indexed content, reset those rows so a regular
|
||||
re-index rebuilds instead of no-opping. Only write paths
|
||||
(index/delete operations) may pass True: the default False
|
||||
keeps read paths — this method is also how searches load
|
||||
the index — free of destructive DB writes, so a transient
|
||||
integrity failure during a mere search cannot wipe the
|
||||
collection's indexed state and trigger a full re-embed.
|
||||
|
||||
Returns:
|
||||
FAISS vector store instance
|
||||
@@ -699,7 +1053,23 @@ class LibraryRAGService:
|
||||
rag_index = self._get_or_create_rag_index(collection_id)
|
||||
self.rag_index_record = rag_index
|
||||
|
||||
index_path = Path(rag_index.index_path)
|
||||
# Always recompute the index path from username + index_hash so
|
||||
# the per-user scoping (F2 fix) applies retroactively to
|
||||
# RAGIndex rows created before the path layout changed. The
|
||||
# stored `rag_index.index_path` may point to a pre-fix shared
|
||||
# location; if the file still lives there we relocate it into
|
||||
# the per-user directory (one-time upgrade migration) instead
|
||||
# of stranding it.
|
||||
# We also stamp the recomputed path back onto the in-memory
|
||||
# record so the downstream save_local() sites that read
|
||||
# `self.rag_index_record.index_path` pick up the new location.
|
||||
index_path = self._get_index_path(rag_index.index_hash)
|
||||
stored_path = (
|
||||
Path(rag_index.index_path) if rag_index.index_path else None
|
||||
)
|
||||
rag_index.index_path = str(index_path)
|
||||
if not index_path.exists() and stored_path is not None:
|
||||
self._migrate_legacy_index_files(stored_path, index_path, rag_index)
|
||||
|
||||
# Hold the per-(username, index_path) write lock across the
|
||||
# entire verify → quarantine → load sequence. A narrower scope
|
||||
@@ -801,7 +1171,9 @@ class LibraryRAGService:
|
||||
str(index_path.parent),
|
||||
self.embedding_manager.embeddings,
|
||||
index_name=index_path.stem,
|
||||
normalize_L2=True,
|
||||
# Match the creation-time flag; see the
|
||||
# reload in _merge_and_persist_locked.
|
||||
normalize_L2=self.normalize_vectors,
|
||||
)
|
||||
logger.info(
|
||||
f"Loaded existing FAISS index from {index_path}"
|
||||
@@ -822,6 +1194,15 @@ class LibraryRAGService:
|
||||
index_path, "load_local_raised"
|
||||
)
|
||||
|
||||
# Falling through to a fresh build means the on-disk index is gone
|
||||
# (missing, quarantined, or deleted on dimension mismatch). If DB
|
||||
# rows still claim indexed content, reset them so a regular
|
||||
# (non-force) re-index actually rebuilds instead of no-opping.
|
||||
# Write callers only (see the reset_stale_state doc above) — a
|
||||
# read/search caller must not mutate index state.
|
||||
if reset_stale_state:
|
||||
self._reset_index_state_for_rebuild(collection_id, rag_index)
|
||||
|
||||
# Create new FAISS index with configurable type and distance metric
|
||||
logger.info(
|
||||
f"Creating new FAISS index: type={self.index_type}, metric={self.distance_metric}, dimension={rag_index.embedding_dimension}"
|
||||
@@ -981,6 +1362,22 @@ class LibraryRAGService:
|
||||
"error": "Document has no text content",
|
||||
}
|
||||
|
||||
# Replace-on-reindex context: a document being re-indexed (e.g.
|
||||
# a note whose content changed) has prior chunks with DIFFERENT
|
||||
# content hashes from the new text. Left alone they accumulate
|
||||
# in both the DB and FAISS forever — unbounded per-edit growth
|
||||
# plus stale duplicate search hits. The cleanup is handled
|
||||
# durably and concurrency-safely below: the FAISS purge derives
|
||||
# the document's prior vectors from the index docstore by
|
||||
# metadata under the write lock (authoritative + self-healing,
|
||||
# not from an ephemeral pre-committed snapshot), and the DB
|
||||
# chunk rows are pruned inside the main transaction after the
|
||||
# new chunks are stored (so a failure rolls back together with
|
||||
# everything else, never orphaning rows or poisoning the
|
||||
# shared session). The prune and the chunk-store below both use
|
||||
# the single ``collection_name`` value so they can never filter
|
||||
# on a different name than was stored.
|
||||
|
||||
try:
|
||||
# Create LangChain Document from text
|
||||
doc = LangchainDocument(
|
||||
@@ -1059,7 +1456,7 @@ class LibraryRAGService:
|
||||
# below will reload from disk under the lock anyway).
|
||||
if self.faiss_index is None:
|
||||
self.faiss_index = self.load_or_create_faiss_index(
|
||||
collection_id
|
||||
collection_id, reset_stale_state=True
|
||||
)
|
||||
|
||||
# Read-modify-write the on-disk FAISS index under
|
||||
@@ -1076,6 +1473,8 @@ class LibraryRAGService:
|
||||
chunks,
|
||||
embedding_ids,
|
||||
force_reindex=force_reindex,
|
||||
replace_document_id=document_id,
|
||||
replace_collection_id=collection_id,
|
||||
)
|
||||
if merge_stats["added"]:
|
||||
if force_reindex:
|
||||
@@ -1157,6 +1556,31 @@ class LibraryRAGService:
|
||||
f"Updated RAGIndex stats: chunk_count +{len(chunks)}, total_documents +1"
|
||||
)
|
||||
|
||||
# Replace-on-reindex DB prune: remove this document's prior
|
||||
# chunk rows that the new content no longer uses (embedding
|
||||
# ids not in the freshly-stored set). Runs INSIDE this
|
||||
# transaction (after _store_chunks_to_db, before the final
|
||||
# commit) so a failure rolls back with everything else —
|
||||
# no separate pre-commit, no session poisoning, no orphaned
|
||||
# rows. Unchanged chunks reuse their existing row (their id
|
||||
# is in embedding_ids) and are kept.
|
||||
fresh_ids = [e for e in embedding_ids if e]
|
||||
stale_q = session.query(DocumentChunk).filter(
|
||||
DocumentChunk.source_id == document_id,
|
||||
DocumentChunk.source_type == "document",
|
||||
DocumentChunk.collection_name == collection_name,
|
||||
)
|
||||
if fresh_ids:
|
||||
stale_q = stale_q.filter(
|
||||
DocumentChunk.embedding_id.notin_(fresh_ids)
|
||||
)
|
||||
pruned = stale_q.delete(synchronize_session=False)
|
||||
if pruned:
|
||||
logger.info(
|
||||
f"Re-index of {document_id[:8]}...: pruned {pruned} "
|
||||
f"stale chunk row(s) the new content no longer uses"
|
||||
)
|
||||
|
||||
# Flush ORM changes to database before commit
|
||||
session.flush()
|
||||
logger.info(f"Flushed ORM changes for document {document_id}")
|
||||
@@ -1363,6 +1787,98 @@ class LibraryRAGService:
|
||||
"error": f"Operation failed: {type(e).__name__}",
|
||||
}
|
||||
|
||||
def purge_document_chunks(
|
||||
self, document_id: str, collection_id: str
|
||||
) -> int:
|
||||
"""Delete a document's chunk rows from a collection's RAG store
|
||||
WITHOUT requiring the ``DocumentCollection`` join row to still exist.
|
||||
|
||||
``remove_document_from_rag`` short-circuits (returns "Document not
|
||||
found in collection") when the join row is gone — which is exactly
|
||||
the state after a ``Document`` is cascade-deleted. Callers that
|
||||
delete the Document first (note deletion) must use this to actually
|
||||
purge the now-orphaned ``DocumentChunk`` rows; otherwise the chunks
|
||||
(and their embedding ids) linger and semantic search keeps
|
||||
resolving hits to a Document row that no longer exists.
|
||||
|
||||
Mirrors the chunk-delete half of ``remove_document_from_rag``.
|
||||
FAISS-vector pruning is NOT done here: pair this with
|
||||
``purge_document_vectors`` when the document is being deleted
|
||||
outright — replace-on-reindex can never fire again for a deleted
|
||||
document id. Returns the number of chunk rows deleted.
|
||||
"""
|
||||
collection_name = f"collection_{collection_id}"
|
||||
return self.embedding_manager._delete_chunks_from_db(
|
||||
collection_name=collection_name,
|
||||
source_id=document_id,
|
||||
)
|
||||
|
||||
def purge_document_vectors(
|
||||
self, document_id: str, collection_id: str
|
||||
) -> int:
|
||||
"""Remove a deleted document's chunk vectors from the collection's
|
||||
FAISS index.
|
||||
|
||||
Companion to ``purge_document_chunks`` for callers that delete the
|
||||
``Document`` row itself (note deletion). Without this the vectors
|
||||
lingered until a replace-on-reindex of the SAME document id — which
|
||||
can never fire again once the document is deleted — so collection
|
||||
search (which builds snippets straight from the FAISS docstore,
|
||||
with no Document-existence check) kept serving the deleted content
|
||||
indefinitely.
|
||||
|
||||
Runs the same reload → purge → save sequence as replace-on-reindex
|
||||
(``_merge_and_persist_locked`` with an empty add set), including
|
||||
its shared-chunk ownership check: a vector whose ``DocumentChunk``
|
||||
row is owned by a different document survives, because that
|
||||
document still contains the identical text.
|
||||
|
||||
Call BEFORE ``purge_document_chunks`` if pairing both — the
|
||||
ownership rows are the evidence the purge consults. (Rowless
|
||||
vectors are treated as orphans and purged, so the reverse order
|
||||
still converges; this order just keeps the check authoritative.)
|
||||
|
||||
Returns the number of vectors removed.
|
||||
"""
|
||||
collection_name = f"collection_{collection_id}"
|
||||
index_hash = self._get_index_hash(
|
||||
collection_name, self.embedding_model, self.embedding_provider
|
||||
)
|
||||
with get_user_db_session(self.username, self.db_password) as session:
|
||||
rag_index = (
|
||||
session.query(RAGIndex).filter_by(index_hash=index_hash).first()
|
||||
)
|
||||
if rag_index is None:
|
||||
# Collection was never indexed with this configuration —
|
||||
# no vectors to purge. Deliberately NOT
|
||||
# _get_or_create_rag_index: a deletion path must not
|
||||
# create index records (or probe the embedding provider).
|
||||
return 0
|
||||
self.rag_index_record = rag_index
|
||||
|
||||
index_path = self._get_index_path(rag_index.index_hash)
|
||||
if not index_path.exists():
|
||||
return 0
|
||||
verified, reason = self.integrity_manager.verify_file(index_path)
|
||||
if not verified:
|
||||
# Leave quarantine/rebuild decisions to the indexing paths;
|
||||
# an unreadable index has nothing purgeable right now.
|
||||
logger.warning(
|
||||
f"Skipping vector purge for document {document_id}: index "
|
||||
f"{index_path} fails verification ({reason})"
|
||||
)
|
||||
return 0
|
||||
|
||||
stats = self._merge_and_persist_locked(
|
||||
index_path,
|
||||
[],
|
||||
[],
|
||||
force_reindex=False,
|
||||
replace_document_id=document_id,
|
||||
replace_collection_id=collection_id,
|
||||
)
|
||||
return stats.get("purged", 0)
|
||||
|
||||
def index_documents_batch(
|
||||
self,
|
||||
doc_info: List[tuple],
|
||||
@@ -1577,7 +2093,7 @@ class LibraryRAGService:
|
||||
# Extract collection_id from collection_name (format: "collection_<uuid>")
|
||||
collection_id = collection_name.removeprefix("collection_")
|
||||
self.faiss_index = self.load_or_create_faiss_index(
|
||||
collection_id
|
||||
collection_id, reset_stale_state=True
|
||||
)
|
||||
|
||||
# Remove from FAISS index. delete + save + record must all
|
||||
|
||||
@@ -999,12 +999,31 @@ class LibraryService:
|
||||
return False
|
||||
|
||||
def delete_document(self, document_id: str) -> bool:
|
||||
"""Delete a document from library (file and database entry)."""
|
||||
"""Delete a document from library (file and database entry).
|
||||
|
||||
Refuses notes: they have their own deletion API
|
||||
(DELETE /api/notes/<id>) that performs version/link/synthesis
|
||||
cleanup. Hard-deleting a note here would bypass that and destroy
|
||||
the note's history. Defense-in-depth — the routed handler is the
|
||||
guarded DocumentDeletionService, but this is a public method.
|
||||
"""
|
||||
from ...database.models.library import SourceType
|
||||
|
||||
with get_user_db_session(self.username) as session:
|
||||
doc = session.query(Document).get(document_id)
|
||||
if not doc:
|
||||
return False
|
||||
|
||||
note_source = (
|
||||
session.query(SourceType).filter_by(name="note").first()
|
||||
)
|
||||
if note_source and doc.source_type_id == note_source.id:
|
||||
logger.warning(
|
||||
f"Refused to delete note {document_id[:8]}... via the "
|
||||
"library document API; use DELETE /api/notes/<id>."
|
||||
)
|
||||
return False
|
||||
|
||||
# Get file path from tracker (only if document has original_url)
|
||||
tracker = None
|
||||
if doc.original_url:
|
||||
|
||||
@@ -16,6 +16,26 @@ from ...database.models.library import Document, DocumentCollection
|
||||
from ...security.path_validator import PathValidator
|
||||
|
||||
|
||||
def escape_like(text: str) -> str:
|
||||
"""Escape SQL LIKE/ILIKE wildcards so user input matches literally.
|
||||
|
||||
``%`` and ``_`` are LIKE wildcards and ``\\`` is the escape character;
|
||||
without escaping, a query like ``my_note`` would treat ``_`` as "any
|
||||
character" and ``%`` would match anything. Use the result with
|
||||
``.like(pattern, escape="\\\\")`` / ``.ilike(pattern, escape="\\\\")``.
|
||||
|
||||
This is the single source of truth for LIKE-escaping across the library
|
||||
and notes query paths (previously copy-pasted in NoteService,
|
||||
LibraryService and unified_search_routes).
|
||||
"""
|
||||
return (
|
||||
(text or "")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
|
||||
|
||||
def is_downloadable_domain(url: str) -> bool:
|
||||
"""Check if URL is from a downloadable academic domain using proper URL parsing."""
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,8 @@ Note: This is designed for single-instance local deployments. For multi-worker
|
||||
production deployments, configure Redis storage via RATELIMIT_STORAGE_URL.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from flask import g, request, session as flask_session
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
@@ -54,18 +56,92 @@ def get_client_ip():
|
||||
return get_remote_address()
|
||||
|
||||
|
||||
# Global limiter instance - will be initialized in app_factory
|
||||
# Rate limiting is disabled in CI unless ENABLE_RATE_LIMITING=true
|
||||
# This allows the rate limiting test to run with rate limiting enabled
|
||||
# Global limiter instance - will be initialized in app_factory.
|
||||
# Rate limiting is disabled in CI unless ENABLE_RATE_LIMITING=true so the
|
||||
# dedicated rate-limit test can run with limits on.
|
||||
#
|
||||
# Note: In-memory storage is used by default, which is suitable for single-instance
|
||||
# deployments. For multi-instance production deployments behind a load balancer,
|
||||
# configure Redis storage via RATELIMIT_STORAGE_URL environment variable:
|
||||
# export RATELIMIT_STORAGE_URL="redis://localhost:6379"
|
||||
# Storage backend is honoured from `RATELIMIT_STORAGE_URL` so operators
|
||||
# running behind a multi-worker WSGI (e.g. `gunicorn -w N`) can point at
|
||||
# Redis. Falling back to `memory://` per-worker silently turns
|
||||
# "5 requests / 15 min" into "5N requests / 15 min" — for the login
|
||||
# bucket that is effectively a credential-throttle bypass.
|
||||
def _validated_storage_uri() -> str:
|
||||
"""Read RATELIMIT_STORAGE_URL and fail fast if its backend is unusable.
|
||||
|
||||
``or`` (not a default arg) so an empty-string value — e.g. a blank
|
||||
``RATELIMIT_STORAGE_URL=`` line in docker-compose — reads as unset,
|
||||
matching how the codebase treats empty env vars elsewhere (LDR_DATA_DIR).
|
||||
|
||||
Historically this variable was documented but ignored (the URI was
|
||||
hardcoded to memory://), so deployments may have it exported without
|
||||
the backend client installed; without this check the first
|
||||
init_app/request dies with a bare ConfigurationError that names
|
||||
neither the variable nor the remedy. Deliberately no silent fallback
|
||||
to memory:// — that would re-split the limits per worker, which for
|
||||
the login bucket is a credential-throttle bypass.
|
||||
|
||||
Called from ``validate_rate_limit_storage`` (app_factory, before
|
||||
``limiter.init_app``) rather than at module import: this module is
|
||||
pulled in by every blueprint, the CLI tools, migrations and tests,
|
||||
and an import-time RuntimeError for what is fundamentally a *server*
|
||||
misconfiguration made all of those unimportable.
|
||||
"""
|
||||
uri = _storage_uri_from_env()
|
||||
if not uri.startswith("memory:"):
|
||||
try:
|
||||
from limits.storage import storage_from_string
|
||||
|
||||
storage_from_string(uri)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"RATELIMIT_STORAGE_URL is set to {uri!r} but the "
|
||||
f"rate-limit storage backend could not be initialised: "
|
||||
f"{exc}. Install the required client package (e.g. "
|
||||
"`pip install redis` for redis:// URLs) or unset "
|
||||
"RATELIMIT_STORAGE_URL to use per-process in-memory limits."
|
||||
) from exc
|
||||
return uri
|
||||
|
||||
|
||||
def _storage_uri_from_env() -> str:
|
||||
"""The configured storage URI, empty-string-tolerant (see above)."""
|
||||
return os.environ.get("RATELIMIT_STORAGE_URL") or "memory://"
|
||||
|
||||
|
||||
def validate_rate_limit_storage() -> None:
|
||||
"""Fail fast on an unusable RATELIMIT_STORAGE_URL backend.
|
||||
|
||||
app_factory calls this right before ``limiter.init_app`` so a broken
|
||||
configuration aborts server startup with an actionable message
|
||||
instead of surfacing as a bare ConfigurationError on the first
|
||||
request — while plain imports of this module stay side-effect free.
|
||||
"""
|
||||
_validated_storage_uri()
|
||||
|
||||
|
||||
_RATELIMIT_STORAGE_URI = _storage_uri_from_env()
|
||||
if _RATELIMIT_STORAGE_URI.startswith("memory:"):
|
||||
_worker_count_env = os.environ.get("GUNICORN_WORKERS") or os.environ.get(
|
||||
"WEB_CONCURRENCY"
|
||||
)
|
||||
try:
|
||||
_worker_count = int(_worker_count_env) if _worker_count_env else 1
|
||||
except (TypeError, ValueError):
|
||||
_worker_count = 1
|
||||
if _worker_count > 1:
|
||||
logger.warning(
|
||||
"RATELIMIT_STORAGE_URL is unset (using memory://) with "
|
||||
"{} workers — limits are per-worker, so the effective cap "
|
||||
"is {}× the configured value. Set RATELIMIT_STORAGE_URL to a "
|
||||
"shared backend (e.g. redis://...) to get a global cap.",
|
||||
_worker_count,
|
||||
_worker_count,
|
||||
)
|
||||
|
||||
limiter = Limiter(
|
||||
key_func=get_client_ip,
|
||||
default_limits=[DEFAULT_RATE_LIMIT],
|
||||
storage_uri="memory://",
|
||||
storage_uri=_RATELIMIT_STORAGE_URI,
|
||||
headers_enabled=True,
|
||||
enabled=is_rate_limiting_enabled(),
|
||||
)
|
||||
|
||||
@@ -196,12 +196,28 @@ def create_app():
|
||||
# PREFERRED_URL_SCHEME affects URL generation (url_for), not request.is_secure
|
||||
app.config["PREFERRED_URL_SCHEME"] = "https"
|
||||
|
||||
# File upload security limits - calculated from FileUploadValidator constants
|
||||
# File upload security limits - calculated from FileUploadValidator constants.
|
||||
# Cap the request body at the largest *legitimate* upload (max files ×
|
||||
# max per-file size). This admits every upload the validator would accept —
|
||||
# a single max-size file AND a full multi-file batch — while still rejecting
|
||||
# bodies larger than any legitimate request. (Per-file size and per-request
|
||||
# file count are re-checked in the upload route / validator.)
|
||||
app.config["MAX_CONTENT_LENGTH"] = (
|
||||
FileUploadValidator.MAX_FILES_PER_REQUEST
|
||||
* FileUploadValidator.MAX_FILE_SIZE
|
||||
)
|
||||
|
||||
# Arm the notes JSON body cap BEFORE CSRF protection. Flask-WTF's CSRF
|
||||
# check reads request.form on mutating requests, caching request.stream
|
||||
# with the max_content_length then in effect; app-level before_request
|
||||
# hooks run in registration order, so this must precede CSRFProtect so a
|
||||
# chunked (no Content-Length) notes body is capped before its stream is
|
||||
# cached under the app-wide multi-file-upload MAX_CONTENT_LENGTH. See
|
||||
# notes_routes.arm_notes_body_cap.
|
||||
from .routes.notes_routes import arm_notes_body_cap
|
||||
|
||||
app.before_request(arm_notes_body_cap)
|
||||
|
||||
# Initialize CSRF protection
|
||||
# Explicitly enable CSRF protection (don't rely on implicit Flask-WTF behavior)
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
@@ -217,8 +233,21 @@ def create_app():
|
||||
# Rate limiting is disabled in CI via enabled callable in rate_limiter.py
|
||||
# Also set app config to ensure Flask-Limiter respects our settings
|
||||
from ..settings.env_registry import is_rate_limiting_enabled
|
||||
from ..security.rate_limiter import validate_rate_limit_storage
|
||||
|
||||
app.config["RATELIMIT_ENABLED"] = is_rate_limiting_enabled()
|
||||
rate_limiting_enabled = is_rate_limiting_enabled()
|
||||
# Fail fast HERE (server startup) on an unusable RATELIMIT_STORAGE_URL
|
||||
# backend, with an actionable message — not at module import (which
|
||||
# would break CLI tools/migrations/tests) and not on the first request
|
||||
# (a bare ConfigurationError naming neither variable nor remedy).
|
||||
# Only when rate limiting is actually enabled: with it disabled the
|
||||
# limiter never touches the backend (enabled=False short-circuits it),
|
||||
# so a stale/broken RATELIMIT_STORAGE_URL left over from a prior
|
||||
# deployment must not abort startup for an operator who has explicitly
|
||||
# turned rate limiting off.
|
||||
if rate_limiting_enabled:
|
||||
validate_rate_limit_storage()
|
||||
app.config["RATELIMIT_ENABLED"] = rate_limiting_enabled
|
||||
app.config["RATELIMIT_STRATEGY"] = "moving-window"
|
||||
limiter.init_app(app)
|
||||
|
||||
@@ -764,6 +793,18 @@ def register_blueprints(app):
|
||||
app.register_blueprint(scheduler_bp)
|
||||
logger.info("Document Scheduler routes registered successfully")
|
||||
|
||||
# Register Notes blueprint
|
||||
from .routes.notes_routes import notes_bp
|
||||
|
||||
app.register_blueprint(notes_bp)
|
||||
logger.info("Notes blueprint registered")
|
||||
|
||||
# Register Unified Search blueprint ("Search everything")
|
||||
from .routes.unified_search_routes import unified_search_bp
|
||||
|
||||
app.register_blueprint(unified_search_bp)
|
||||
logger.info("Unified Search blueprint registered")
|
||||
|
||||
# CSRF exemptions — Flask-WTF requires Blueprint objects (not strings)
|
||||
# to populate _exempt_blueprints. Passing strings only populates
|
||||
# _exempt_views, which compares against module-qualified names and
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Shared search-query limits for the notes and unified-search routes.
|
||||
|
||||
Both route modules cap the user-supplied free-text ``q`` / ``search``
|
||||
param that flows into ``ILIKE %...%`` over unbounded TEXT columns (and the
|
||||
embedding call). The cap lived as an independent ``MAX_SEARCH_LEN = 512``
|
||||
in each module, linked only by a "mirrors the other file" comment that
|
||||
would rot the moment one side changed. One definition here removes the
|
||||
manual-sync hazard.
|
||||
"""
|
||||
|
||||
# 512 chars — an order of magnitude above any realistic UI query input.
|
||||
MAX_SEARCH_LEN = 512
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Unified Search Routes — "Search everything".
|
||||
|
||||
One search surface across every entity stored as a ``Document`` row
|
||||
(notes, research reports, library downloads, user uploads):
|
||||
|
||||
* GET /library/search/ — the Search page
|
||||
* GET /library/search/api/keyword — ILIKE over title + text_content
|
||||
* GET /library/search/api/semantic — FAISS search over the system
|
||||
collections (default_library, research_history, notes)
|
||||
|
||||
The two API legs return the SAME result-row contract so the page JS can
|
||||
merge them mode-agnostically::
|
||||
|
||||
{id, title, content_preview, source_type, url, similarity?}
|
||||
|
||||
``url`` is computed server-side (see ``_document_url``) so every future
|
||||
consumer links results consistently.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from flask import Blueprint, jsonify, request, session
|
||||
|
||||
from ...research_library.utils import escape_like, handle_api_error
|
||||
from ...security.rate_limiter import limiter, _get_api_user_key
|
||||
from ..auth.decorators import login_required
|
||||
from ..utils.templates import render_template_with_defaults
|
||||
|
||||
# MAX_SEARCH_LEN (the user-query cap into ILIKE + the embedding call) is
|
||||
# shared with notes_routes via _search_constants so the two route modules
|
||||
# can't diverge.
|
||||
from ._search_constants import MAX_SEARCH_LEN
|
||||
|
||||
# Keyword-leg preview length: column-projected ``substr`` so the full
|
||||
# text_content (which can be megabytes for extracted PDFs) is never
|
||||
# hydrated into the response.
|
||||
PREVIEW_LEN = 300
|
||||
# Lead-in shown before the first content match when the preview window is
|
||||
# anchored on it (see keyword_search).
|
||||
PREVIEW_CONTEXT = 60
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 50
|
||||
DEFAULT_MIN_SIMILARITY = 0.25
|
||||
|
||||
# Minimum query length, matching the page JS's UNIFIED_SEARCH_MIN_QUERY:
|
||||
# below 2 chars the keyword leg is an expensive full-content scan for a
|
||||
# near-meaningless match set, and the client never sends one anyway.
|
||||
MIN_SEARCH_LEN = 2
|
||||
|
||||
# System collections always searched by the semantic leg, keyed by
|
||||
# ``Collection.collection_type``. These are lazily created elsewhere
|
||||
# (library init / first note), so a missing one is SKIPPED rather than
|
||||
# created — a read endpoint must not have write side effects. User
|
||||
# collections join them whenever they have a current RAG index (see
|
||||
# semantic_search) so the two legs cover the same corpus for anything
|
||||
# the user actually indexed.
|
||||
SEMANTIC_COLLECTION_TYPES = ("default_library", "research_history", "notes")
|
||||
|
||||
# Per-user search budget — same shape as the notes_search bucket (cheap
|
||||
# DB/FAISS lookups typed through a debounced search box).
|
||||
_unified_search_limit = limiter.shared_limit(
|
||||
"60 per minute", scope="unified_search", key_func=_get_api_user_key
|
||||
)
|
||||
|
||||
unified_search_bp = Blueprint(
|
||||
"unified_search", __name__, url_prefix="/library/search"
|
||||
)
|
||||
|
||||
|
||||
def _document_url(source_type_name, document_id, research_id):
|
||||
"""Canonical link for a search hit, by source type.
|
||||
|
||||
* ``note`` → the note editor.
|
||||
* ``research_report`` with a ``research_id`` → the research results
|
||||
page (the report Document is a search-index copy of that run).
|
||||
* everything else (uploads, downloads, report copies whose run is
|
||||
gone) → the library document details page.
|
||||
"""
|
||||
if source_type_name == "note":
|
||||
return f"/notes/{document_id}"
|
||||
if source_type_name == "research_report" and research_id:
|
||||
return f"/results/{research_id}"
|
||||
return f"/library/document/{document_id}"
|
||||
|
||||
|
||||
def _validated_query_and_limit():
|
||||
"""Parse/validate the shared ``q`` and ``limit`` query params.
|
||||
|
||||
Returns ``(query, limit, error_response)`` where ``error_response``
|
||||
is a ready ``(json, status)`` tuple when validation failed (and the
|
||||
other fields are None).
|
||||
"""
|
||||
q = request.args.get("q", "")
|
||||
if len(q) > MAX_SEARCH_LEN:
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"q exceeds maximum length ({MAX_SEARCH_LEN} chars)",
|
||||
}
|
||||
),
|
||||
400,
|
||||
),
|
||||
)
|
||||
q = q.strip()
|
||||
if not q:
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
(jsonify({"success": False, "error": "Query is required"}), 400),
|
||||
)
|
||||
if len(q) < MIN_SEARCH_LEN:
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Query must be at least {MIN_SEARCH_LEN} "
|
||||
"characters"
|
||||
),
|
||||
}
|
||||
),
|
||||
400,
|
||||
),
|
||||
)
|
||||
try:
|
||||
limit = max(
|
||||
1, min(int(request.args.get("limit", DEFAULT_LIMIT)), MAX_LIMIT)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
(jsonify({"success": False, "error": "Invalid limit"}), 400),
|
||||
)
|
||||
return q, limit, None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Page Route
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@unified_search_bp.route("/")
|
||||
@login_required
|
||||
def unified_search_page():
|
||||
"""Render the unified Search page."""
|
||||
return render_template_with_defaults(
|
||||
"pages/unified_search.html", active_page="unified-search"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API Routes
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@unified_search_bp.route("/api/keyword", methods=["GET"])
|
||||
@login_required
|
||||
@_unified_search_limit
|
||||
def keyword_search():
|
||||
"""Keyword leg: ILIKE over Document.title + text_content, ALL source types.
|
||||
|
||||
Query params:
|
||||
q: Search query (required, capped at MAX_SEARCH_LEN)
|
||||
limit: Maximum results (default 20, clamped 1..50)
|
||||
|
||||
Column-projected (id, title, 300-char preview, source-type name,
|
||||
updated_at, research_id) — never hydrates full text_content.
|
||||
"""
|
||||
username = session.get("username")
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Not authenticated"}), 401
|
||||
|
||||
try:
|
||||
q, limit, error = _validated_query_and_limit()
|
||||
if error:
|
||||
return error
|
||||
|
||||
from sqlalchemy import case, func, or_
|
||||
|
||||
from ...database.models.library import Document, SourceType
|
||||
from ...database.session_context import get_user_db_session
|
||||
|
||||
pattern = f"%{escape_like(q)}%"
|
||||
# Center the preview on the first content match instead of always
|
||||
# slicing the head: for a content hit deep in a long document, the
|
||||
# first 300 chars usually don't contain the matched text at all,
|
||||
# so the card gave no clue why the document matched. instr() takes
|
||||
# the raw query (wildcard escaping only applies to LIKE), folded
|
||||
# with lower() on both sides exactly like ilike; 0 = title-only
|
||||
# match → head preview. PREVIEW_CONTEXT chars of lead-in keep the
|
||||
# match readable in situ.
|
||||
match_pos = func.instr(func.lower(Document.text_content), func.lower(q))
|
||||
preview_start = case(
|
||||
(match_pos > PREVIEW_CONTEXT, match_pos - PREVIEW_CONTEXT),
|
||||
else_=1,
|
||||
)
|
||||
with get_user_db_session(username) as db:
|
||||
rows = (
|
||||
db.query(
|
||||
Document.id,
|
||||
Document.title,
|
||||
func.substr(
|
||||
Document.text_content, preview_start, PREVIEW_LEN
|
||||
),
|
||||
SourceType.name,
|
||||
Document.updated_at,
|
||||
Document.research_id,
|
||||
match_pos,
|
||||
)
|
||||
.join(SourceType, Document.source_type_id == SourceType.id)
|
||||
.filter(
|
||||
# Only surface completed documents, matching every
|
||||
# library listing/search path (library_service filters
|
||||
# status == 'completed' too). Without this, in-progress
|
||||
# or failed downloads — partial/empty text, or a stale
|
||||
# title — appear as hits that link to a document page
|
||||
# with no usable content.
|
||||
Document.status == "completed",
|
||||
or_(
|
||||
Document.title.ilike(pattern, escape="\\"),
|
||||
Document.text_content.ilike(pattern, escape="\\"),
|
||||
),
|
||||
)
|
||||
.order_by(Document.updated_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
results = []
|
||||
for (
|
||||
doc_id,
|
||||
title,
|
||||
preview,
|
||||
source_type_name,
|
||||
updated_at,
|
||||
research_id,
|
||||
pos,
|
||||
) in rows:
|
||||
preview = preview or ""
|
||||
# Mark the cut ONLY when the window truly opens mid-document, i.e.
|
||||
# preview_start = pos - PREVIEW_CONTEXT > 1, which is pos >
|
||||
# PREVIEW_CONTEXT + 1. At exactly pos == PREVIEW_CONTEXT + 1 the SQL
|
||||
# clamps preview_start to 1 (the document's first char), so the
|
||||
# preview isn't truncated and a leading "…" would be spurious.
|
||||
if pos and pos > PREVIEW_CONTEXT + 1:
|
||||
preview = "…" + preview
|
||||
results.append(
|
||||
{
|
||||
"id": doc_id,
|
||||
"title": title or "Untitled",
|
||||
"content_preview": preview,
|
||||
"source_type": source_type_name,
|
||||
"updated_at": updated_at.isoformat()
|
||||
if updated_at
|
||||
else None,
|
||||
"research_id": research_id,
|
||||
"url": _document_url(source_type_name, doc_id, research_id),
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"query": q,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return handle_api_error("unified keyword search", e)
|
||||
|
||||
|
||||
@unified_search_bp.route("/api/semantic", methods=["GET"])
|
||||
@login_required
|
||||
@_unified_search_limit
|
||||
def semantic_search():
|
||||
"""Semantic leg: FAISS search over the system collections.
|
||||
|
||||
Query params:
|
||||
q: Search query (required, capped at MAX_SEARCH_LEN)
|
||||
limit: Maximum results (default 20, clamped 1..50)
|
||||
min_similarity: Minimum similarity 0.0-1.0 (default 0.25)
|
||||
|
||||
Searches the RAG index of every existing system collection
|
||||
(default_library, research_history, notes — missing ones are
|
||||
skipped, not created) and merges the hits across collections by
|
||||
similarity desc, one result per document (its best-scoring chunk).
|
||||
|
||||
Infrastructure failures PROPAGATE as a 500 via ``handle_api_error``
|
||||
— an outage must not masquerade as "no matches" (repo rule: no
|
||||
silent fallbacks). The page's hybrid mode degrades per leg
|
||||
client-side, where the degradation is surfaced as a notice.
|
||||
"""
|
||||
username = session.get("username")
|
||||
if not username:
|
||||
return jsonify({"success": False, "error": "Not authenticated"}), 401
|
||||
|
||||
try:
|
||||
q, limit, error = _validated_query_and_limit()
|
||||
if error:
|
||||
return error
|
||||
try:
|
||||
min_similarity_raw = float(
|
||||
request.args.get("min_similarity", DEFAULT_MIN_SIMILARITY)
|
||||
)
|
||||
# Reject nan/inf explicitly: float("nan") does not raise, and
|
||||
# max(0.0, min(nan, 1.0)) silently collapses to 0.0 (the most
|
||||
# permissive threshold) instead of surfacing the bad input.
|
||||
if not math.isfinite(min_similarity_raw):
|
||||
raise ValueError( # noqa: TRY301 — caught just below and mapped to a 400
|
||||
"min_similarity must be a finite number"
|
||||
)
|
||||
min_similarity = max(0.0, min(min_similarity_raw, 1.0))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify(
|
||||
{"success": False, "error": "Invalid min_similarity"}
|
||||
), 400
|
||||
|
||||
from ...database.models.library import (
|
||||
Collection,
|
||||
Document,
|
||||
RAGIndex,
|
||||
SourceType,
|
||||
)
|
||||
from ...database.session_context import get_user_db_session
|
||||
|
||||
# Resolve the searchable collections: the system collections that
|
||||
# exist, plus ANY other collection with a current RAG index. The
|
||||
# keyword leg covers every document, so scoping the semantic leg
|
||||
# to system collections only made the two modes cover different
|
||||
# corpora — documents living solely in an indexed user collection
|
||||
# were keyword-searchable but never AI-searchable. Unindexed
|
||||
# collections are harmless to include (the engine returns [] when
|
||||
# no current index exists), but filtering here avoids paying its
|
||||
# per-collection setup for collections that can't match.
|
||||
with get_user_db_session(username) as db:
|
||||
all_collections = db.query(
|
||||
Collection.id,
|
||||
Collection.name,
|
||||
Collection.collection_type,
|
||||
).all()
|
||||
indexed_names = {
|
||||
name
|
||||
for (name,) in db.query(RAGIndex.collection_name)
|
||||
.filter(RAGIndex.is_current.is_(True))
|
||||
.all()
|
||||
}
|
||||
collections = [
|
||||
(collection_id, collection_name)
|
||||
for collection_id, collection_name, collection_type in all_collections
|
||||
if collection_type in SEMANTIC_COLLECTION_TYPES
|
||||
or f"collection_{collection_id}" in indexed_names
|
||||
]
|
||||
|
||||
if not collections:
|
||||
return jsonify(
|
||||
{"success": True, "query": q, "results": [], "count": 0}
|
||||
)
|
||||
|
||||
from ...web_search_engines.engines.search_engine_collection import (
|
||||
CollectionSearchEngine,
|
||||
)
|
||||
|
||||
# Over-fetch chunk-level hits per collection: documents are
|
||||
# chunk-indexed, so one long document can occupy several top-k
|
||||
# slots before the per-document dedup below.
|
||||
fetch_k = limit * 3
|
||||
hits = []
|
||||
for collection_id, collection_name in collections:
|
||||
engine = CollectionSearchEngine(
|
||||
collection_id=collection_id,
|
||||
collection_name=collection_name,
|
||||
max_results=fetch_k,
|
||||
settings_snapshot={"_username": username},
|
||||
)
|
||||
# Engine failures propagate to handle_api_error below.
|
||||
raw_results = engine.search(q, limit=fetch_k)
|
||||
for r in raw_results or []:
|
||||
score = r.get("relevance_score", 0)
|
||||
if score < min_similarity:
|
||||
continue
|
||||
meta = r.get("metadata", {}) or {}
|
||||
doc_id = meta.get("document_id") or meta.get("source_id")
|
||||
if not doc_id:
|
||||
continue
|
||||
hits.append(
|
||||
{
|
||||
"id": doc_id,
|
||||
"title": r.get("title", "Untitled"),
|
||||
"content_preview": (r.get("snippet", "") or "")[
|
||||
:PREVIEW_LEN
|
||||
],
|
||||
"similarity": round(float(score), 3),
|
||||
}
|
||||
)
|
||||
|
||||
# Merge across collections by similarity desc; keep each
|
||||
# document's best-scoring chunk only. Dedup the WHOLE pool (not
|
||||
# just the first `limit`) so the existence check below can backfill
|
||||
# from lower-ranked valid hits when a top hit turns out to be a
|
||||
# FAISS orphan — otherwise a handful of orphans in the top `limit`
|
||||
# silently shrink the result set even though enough valid matches
|
||||
# were fetched.
|
||||
hits.sort(key=lambda h: h["similarity"], reverse=True)
|
||||
candidates = []
|
||||
seen_doc_ids = set()
|
||||
for hit in hits:
|
||||
if hit["id"] in seen_doc_ids:
|
||||
continue
|
||||
seen_doc_ids.add(hit["id"])
|
||||
candidates.append(hit)
|
||||
|
||||
# Enrich with the Document row (source type + research_id → url).
|
||||
# A candidate absent from the lookup is a FAISS orphan — the
|
||||
# document was deleted but its vectors linger until reindex — so
|
||||
# drop it rather than render a link that 404s, and only cut to
|
||||
# `limit` AFTER dropping orphans.
|
||||
results = []
|
||||
if candidates:
|
||||
with get_user_db_session(username) as db:
|
||||
rows = (
|
||||
db.query(Document.id, SourceType.name, Document.research_id)
|
||||
.join(SourceType, Document.source_type_id == SourceType.id)
|
||||
.filter(
|
||||
Document.id.in_([h["id"] for h in candidates]),
|
||||
# Match the keyword leg: don't surface in-progress
|
||||
# or failed documents even if their vectors exist.
|
||||
Document.status == "completed",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
doc_info = {
|
||||
doc_id: (source_type_name, research_id)
|
||||
for doc_id, source_type_name, research_id in rows
|
||||
}
|
||||
for hit in candidates:
|
||||
info = doc_info.get(hit["id"])
|
||||
if info is None:
|
||||
continue
|
||||
source_type_name, research_id = info
|
||||
hit["source_type"] = source_type_name
|
||||
hit["url"] = _document_url(
|
||||
source_type_name, hit["id"], research_id
|
||||
)
|
||||
results.append(hit)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"query": q,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return handle_api_error("unified semantic search", e)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3281,3 +3281,221 @@ input[type="number"].ldr-form-control::-webkit-inner-spin-button {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Research results — Notes panel (components/research_notes.js)
|
||||
========================================================================== */
|
||||
|
||||
.ldr-research-notes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ldr-research-notes-heading {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ldr-research-notes-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ldr-research-notes-hint {
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.ldr-research-notes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ldr-research-note-row {
|
||||
display: block;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--bg-tertiary, transparent);
|
||||
}
|
||||
|
||||
.ldr-research-note-row:hover {
|
||||
border-color: var(--accent-primary, #888);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ldr-research-note-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ldr-research-note-preview {
|
||||
display: block;
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.15rem;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.ldr-research-notes-empty {
|
||||
color: var(--text-muted, #888);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ldr-clip-to-note-btn {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Research results — inline annotations (Word-review-style comments)
|
||||
========================================================================== */
|
||||
|
||||
.ldr-selection-toolbar {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #fff);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
mark.ldr-research-annotation {
|
||||
background: color-mix(in srgb, var(--accent-primary, #e8a33d) 30%, transparent);
|
||||
border-bottom: 2px solid var(--accent-primary, #e8a33d);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
mark.ldr-research-annotation:hover {
|
||||
background: color-mix(in srgb, var(--accent-primary, #e8a33d) 45%, transparent);
|
||||
}
|
||||
|
||||
.ldr-annotation-popover {
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 16px);
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary, #fff);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ldr-annotation-popover-quote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #888);
|
||||
border-left: 3px solid var(--accent-primary, #e8a33d);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ldr-annotation-popover-input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary, #fff);
|
||||
color: inherit;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ldr-annotation-popover-comment {
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ldr-annotation-popover-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Unified Search page ("Search everything" — /library/search/)
|
||||
Result cards linking notes, library documents and research reports.
|
||||
The search chrome (input, mode dropdown, notice, empty state) reuses
|
||||
the generic ldr-notes-* search styles from notes.css.
|
||||
============================================================================ */
|
||||
|
||||
.ldr-unified-search-results {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ldr-unified-result-card {
|
||||
display: block;
|
||||
background: var(--bg-secondary, #fff);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.ldr-unified-result-card:hover,
|
||||
.ldr-unified-result-card:focus-visible {
|
||||
border-color: var(--accent-primary, #e8a33d);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ldr-unified-result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.ldr-unified-result-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ldr-unified-source-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-tertiary, #f0f0f0);
|
||||
color: var(--text-secondary, #666);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ldr-unified-result-preview {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #666);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
let collectionData = null;
|
||||
let documentsData = [];
|
||||
let notesData = [];
|
||||
let currentFilter = 'all';
|
||||
let indexingPollInterval = null;
|
||||
|
||||
@@ -106,6 +107,7 @@ async function loadCollectionDetails() {
|
||||
if (data.success) {
|
||||
collectionData = data.collection;
|
||||
documentsData = data.documents || [];
|
||||
notesData = data.notes || [];
|
||||
|
||||
// Update header
|
||||
document.getElementById('collection-name').textContent = collectionData.name;
|
||||
@@ -123,14 +125,25 @@ async function loadCollectionDetails() {
|
||||
agentToggle.checked = collectionData.agent_enabled !== false;
|
||||
}
|
||||
|
||||
// System collections (Notes, Library, Research History) can't be
|
||||
// deleted — the service refuses with a 409 — so don't offer a
|
||||
// button whose only outcome is an error. The server decides
|
||||
// (is_protected mirrors PROTECTED_COLLECTION_TYPES); to clear a
|
||||
// system collection's embeddings, Re-Index remains available.
|
||||
if (collectionData.is_protected) {
|
||||
const deleteBtn = document.getElementById('delete-collection-btn');
|
||||
if (deleteBtn) deleteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
updateStatistics();
|
||||
|
||||
// Display collection's embedding settings
|
||||
displayCollectionEmbeddingSettings();
|
||||
|
||||
// Render documents
|
||||
// Render documents and notes
|
||||
renderDocuments();
|
||||
renderNotes();
|
||||
|
||||
// Show search section if any documents are indexed
|
||||
initCollectionSearch();
|
||||
@@ -147,10 +160,13 @@ async function loadCollectionDetails() {
|
||||
* Update statistics
|
||||
*/
|
||||
function updateStatistics() {
|
||||
const totalDocs = documentsData.length;
|
||||
const indexedDocs = documentsData.filter(doc => doc.indexed).length;
|
||||
// Notes are collection members too: indexing processes them, so the
|
||||
// displayed totals must include them to match the indexing counts.
|
||||
const allItems = documentsData.concat(notesData);
|
||||
const totalDocs = allItems.length;
|
||||
const indexedDocs = allItems.filter(item => item.indexed).length;
|
||||
const unindexedDocs = totalDocs - indexedDocs;
|
||||
const totalChunks = documentsData.reduce((sum, doc) => sum + (doc.chunk_count || 0), 0);
|
||||
const totalChunks = allItems.reduce((sum, item) => sum + (item.chunk_count || 0), 0);
|
||||
|
||||
document.getElementById('stat-total-docs').textContent = totalDocs;
|
||||
document.getElementById('stat-indexed-docs').textContent = indexedDocs;
|
||||
@@ -274,7 +290,12 @@ function renderDocuments() {
|
||||
|
||||
if (filteredDocs.length === 0) {
|
||||
container.style.display = 'none';
|
||||
noDocsMessage.style.display = 'flex';
|
||||
// Don't show the misleading "No documents… Upload Files" empty state
|
||||
// when the collection still has (visible) notes below — that would
|
||||
// claim the collection is empty while the Notes section lists its
|
||||
// contents and would steer the user to the wrong action. Only show it
|
||||
// when nothing matches the current filter in either section.
|
||||
noDocsMessage.style.display = getFilteredNotes().length === 0 ? 'flex' : 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,6 +338,63 @@ function renderDocuments() {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the active indexed/unindexed filter to the notes list. Notes use an
|
||||
* `indexed` flag like documents, and updateStatistics() counts them in the
|
||||
* Indexed/Not-Indexed totals, so the filter must scope the notes too or the
|
||||
* visible set would contradict the counts.
|
||||
*/
|
||||
function getFilteredNotes() {
|
||||
if (currentFilter === 'indexed') {
|
||||
return notesData.filter(note => note.indexed);
|
||||
} else if (currentFilter === 'unindexed') {
|
||||
return notesData.filter(note => !note.indexed);
|
||||
}
|
||||
return notesData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render notes list (notes are collection members served in the separate
|
||||
* `notes` array of the collection-documents payload)
|
||||
*/
|
||||
function renderNotes() {
|
||||
const section = document.getElementById('notes-section');
|
||||
const container = document.getElementById('notes-list');
|
||||
if (!section || !container) return;
|
||||
|
||||
const filteredNotes = getFilteredNotes();
|
||||
if (filteredNotes.length === 0) {
|
||||
section.style.display = 'none';
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
|
||||
// eslint-disable-next-line no-unsanitized/property -- audited 2026-06-06: all interpolations use escapeHtml, numeric coercion, or hardcoded strings
|
||||
container.innerHTML = filteredNotes.map(note => `
|
||||
<div class="ldr-document-item ${note.indexed ? 'ldr-indexed' : 'ldr-unindexed'}">
|
||||
<a href="/notes/${encodeURIComponent(note.id)}" class="ldr-document-link" style="text-decoration: none; color: inherit; display: block; flex: 1;">
|
||||
<div class="ldr-document-info">
|
||||
<div class="ldr-document-title">
|
||||
<i class="fas fa-sticky-note" style="color: var(--accent-primary); margin-right: 8px;" title="Note"></i>
|
||||
${escapeHtml(note.title || 'Untitled')}
|
||||
${note.pinned ? '<i class="fas fa-thumbtack" style="color: var(--accent-primary); margin-left: 8px;" title="Pinned"></i>' : ''}
|
||||
</div>
|
||||
<div class="ldr-document-meta">
|
||||
<span class="ldr-badge ldr-badge-info">note</span> •
|
||||
${note.indexed ?
|
||||
`<span class="ldr-badge ldr-badge-success">Indexed (${escapeHtml(String(note.chunk_count))} chunks)</span>` :
|
||||
'<span class="ldr-badge ldr-badge-warning">Not indexed</span>'
|
||||
}
|
||||
${note.updated_at ? ` • Updated: ${escapeHtml(new Date(note.updated_at).toLocaleString())}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter documents
|
||||
*/
|
||||
@@ -330,6 +408,9 @@ function filterDocuments(filter) {
|
||||
event.target.classList.add('ldr-active');
|
||||
|
||||
renderDocuments();
|
||||
// Notes are counted in the indexed/unindexed stats, so they must honor the
|
||||
// same filter — otherwise the visible notes contradict the counts.
|
||||
renderNotes();
|
||||
}
|
||||
|
||||
|
||||
@@ -543,7 +624,7 @@ function addLogEntry(message, type = 'info') {
|
||||
* Delete collection
|
||||
*/
|
||||
async function deleteCollection() {
|
||||
if (!confirm(`Are you sure you want to delete "${collectionData.name}"? This action cannot be undone.`)) return;
|
||||
if (!confirm(`Are you sure you want to delete "${collectionData.name}"? Documents that belong only to this collection will be deleted with it. This action cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const csrfToken = window.api ? window.api.getCsrfToken() : '';
|
||||
@@ -687,7 +768,8 @@ let collectionSearchId = 0;
|
||||
* Initialize search section if collection has indexed documents.
|
||||
*/
|
||||
function initCollectionSearch() {
|
||||
const hasIndexed = documentsData.some(function(d) { return d.indexed; });
|
||||
const hasIndexed = documentsData.some(function(d) { return d.indexed; })
|
||||
|| notesData.some(function(n) { return n.indexed; });
|
||||
const section = document.getElementById('collection-search-section');
|
||||
if (!section) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* Annotation Surface (shared component)
|
||||
*
|
||||
* Word-review-style inline comments over a container of IMMUTABLE
|
||||
* rendered text (research reports, library-document extracted text —
|
||||
* immutability is what makes quote anchors safe). Provides:
|
||||
* - a selection toolbar (Comment + host-supplied extra actions),
|
||||
* - the comment-entry popover (each comment is saved as a NOTE by the
|
||||
* host's endpoint; this component only ships quote/prefix/suffix +
|
||||
* comment),
|
||||
* - the anchor engine: whitespace-normalized quote matching with
|
||||
* virtual spaces at <br>/block boundaries, per-text-node <mark>
|
||||
* wrapping, prefix/suffix disambiguation for repeated phrases,
|
||||
* - highlight popovers with Open note / Delete.
|
||||
*
|
||||
* Hosts (components/research_notes.js, components/document_notes.js)
|
||||
* call LDRAnnotationSurface.init(config) with their endpoints. The ONE
|
||||
* deliberate global is the namespaced window.LDRAnnotationSurface —
|
||||
* unique enough not to collide with the deferred shared scripts.
|
||||
*
|
||||
* URL security: all URLs used here come from the host config and are
|
||||
* same-origin relative paths; no external URLs are handled. External URL
|
||||
* validation is available via URLValidator.isSafeUrl if that changes.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Anchor engine (pure — exposed on the test hook below)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Elements whose boundary reads as a break in the rendered text even
|
||||
// though they contribute NO character to textContent: <br> most of
|
||||
// all (markdown renderers turn intra-paragraph newlines into <br>, so
|
||||
// textContent says "butshared" where the user sees — and selects —
|
||||
// "but\nshared"), plus block-level containers.
|
||||
const BLOCK_BOUNDARY_RE = /^(?:P|DIV|LI|UL|OL|H[1-6]|BLOCKQUOTE|PRE|TABLE|THEAD|TBODY|TR|TD|TH|SECTION|ARTICLE|BR|HR)$/;
|
||||
|
||||
/**
|
||||
* Build a whitespace-normalized string over the container's rendered
|
||||
* text plus a per-character map back to (node, offset). Whitespace
|
||||
* runs collapse to one space, and <br>/block boundaries emit a
|
||||
* VIRTUAL space (map entry null — no DOM position), so a quote
|
||||
* captured from selection.toString() (which inserts newlines at those
|
||||
* boundaries) matches the same passage in the haystack.
|
||||
*/
|
||||
function buildNormalizedText(root) {
|
||||
const map = [];
|
||||
let text = '';
|
||||
let lastWasSpace = true; // drop leading whitespace
|
||||
|
||||
const pushVirtualSpace = () => {
|
||||
if (!lastWasSpace) {
|
||||
text += ' ';
|
||||
map.push(null);
|
||||
lastWasSpace = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Recursive DFS (document order, same as a TreeWalker) so a block/<br>
|
||||
// boundary can emit a virtual space on BOTH sides. Emitting only on
|
||||
// ENTER left bare text that immediately follows a closed block (e.g. a
|
||||
// caption right after </blockquote>) glued to the block's text
|
||||
// ("insightas noted"), so a quote spanning that boundary — where a
|
||||
// selection inserts a newline — never anchored.
|
||||
const walk = (parent) => {
|
||||
for (let node = parent.firstChild; node; node = node.nextSibling) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const isBoundary = BLOCK_BOUNDARY_RE.test(node.tagName);
|
||||
if (isBoundary) pushVirtualSpace();
|
||||
walk(node);
|
||||
if (isBoundary) pushVirtualSpace();
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const s = node.textContent;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (/\s/.test(s[i])) {
|
||||
if (!lastWasSpace) {
|
||||
text += ' ';
|
||||
map.push({ node, offset: i });
|
||||
lastWasSpace = true;
|
||||
}
|
||||
} else {
|
||||
text += s[i];
|
||||
map.push({ node, offset: i });
|
||||
lastWasSpace = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
|
||||
// Drop trailing boundary/whitespace: the outermost element's exit adds
|
||||
// a trailing virtual space, but a quote is always trimmed, so the
|
||||
// haystack should end at its last real character (keeps the text stable
|
||||
// and matchable).
|
||||
while (text.endsWith(' ')) {
|
||||
text = text.slice(0, -1);
|
||||
map.pop();
|
||||
}
|
||||
return { text, map };
|
||||
}
|
||||
|
||||
function normalizeWhitespace(s) {
|
||||
return String(s || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate `quote` in the normalized haystack; when the phrase repeats,
|
||||
* prefer the occurrence whose surroundings best match prefix/suffix.
|
||||
*/
|
||||
function findQuoteIndex(haystack, quote, prefix, suffix) {
|
||||
// Empty quote: indexOf('', n) never returns -1, so the hit-collection
|
||||
// loop below would spin forever. Callers already guard, but this is
|
||||
// the exposed pure engine — fail closed.
|
||||
if (!quote) return -1;
|
||||
const hits = [];
|
||||
let i = haystack.indexOf(quote);
|
||||
while (i !== -1) {
|
||||
hits.push(i);
|
||||
i = haystack.indexOf(quote, i + 1);
|
||||
}
|
||||
if (!hits.length) return -1;
|
||||
if (hits.length === 1 || (!prefix && !suffix)) return hits[0];
|
||||
|
||||
const sharedEndLen = (a, b) => {
|
||||
let n = 0;
|
||||
while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n++;
|
||||
return n;
|
||||
};
|
||||
const sharedStartLen = (a, b) => {
|
||||
let n = 0;
|
||||
while (n < a.length && n < b.length && a[n] === b[n]) n++;
|
||||
return n;
|
||||
};
|
||||
|
||||
let best = hits[0];
|
||||
let bestScore = -1;
|
||||
for (const h of hits) {
|
||||
let score = 0;
|
||||
if (prefix) score += sharedEndLen(haystack.slice(Math.max(0, h - prefix.length), h), prefix);
|
||||
if (suffix) score += sharedStartLen(haystack.slice(h + quote.length, h + quote.length + suffix.length), suffix);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = h;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the normalized-index range [start, end) in <mark> elements —
|
||||
* one per text-node segment (a Range spanning element boundaries
|
||||
* can't be surrounded in one piece). Null map entries are virtual
|
||||
* boundary spaces with no DOM position.
|
||||
*/
|
||||
function wrapNormalizedRange(map, start, end, noteId) {
|
||||
const segments = [];
|
||||
let current = null;
|
||||
for (let i = start; i < end; i++) {
|
||||
const m = map[i];
|
||||
if (!m) {
|
||||
if (current) {
|
||||
segments.push(current);
|
||||
current = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current && current.node === m.node && m.offset === current.endOffset) {
|
||||
current.endOffset = m.offset + 1;
|
||||
} else {
|
||||
if (current) segments.push(current);
|
||||
current = { node: m.node, startOffset: m.offset, endOffset: m.offset + 1 };
|
||||
}
|
||||
}
|
||||
if (current) segments.push(current);
|
||||
|
||||
// Wrap segments HIGHEST-offset-first. A quote spanning a collapsed
|
||||
// whitespace run (e.g. a double space) maps to two non-contiguous
|
||||
// segments on the SAME text node; wrapping the earlier one first calls
|
||||
// surroundContents, which splits that node and shortens it, so the
|
||||
// later segment's offsets become out of bounds -> IndexSizeError.
|
||||
// Going back-to-front keeps every not-yet-wrapped segment's offsets
|
||||
// valid. The whole range op is guarded so one bad segment (e.g. an
|
||||
// overlap with a prior mark) is skipped, not fatal to the other marks.
|
||||
for (const seg of segments.slice().reverse()) {
|
||||
const mark = document.createElement('mark');
|
||||
mark.className = 'ldr-research-annotation';
|
||||
mark.dataset.noteId = noteId;
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.setStart(seg.node, seg.startOffset);
|
||||
range.setEnd(seg.node, seg.endOffset);
|
||||
range.surroundContents(mark);
|
||||
} catch (e) {
|
||||
SafeLogger.error('Annotation wrap failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one annotation's highlight; returns whether it anchored. */
|
||||
function applyAnnotation(container, annotation) {
|
||||
const quote = normalizeWhitespace(annotation.quote);
|
||||
if (!quote) return false;
|
||||
// Rebuilt per annotation: wrapping mutates/splits text nodes, so
|
||||
// earlier maps go stale. Targets are a few hundred KB at most and
|
||||
// annotations number in the dozens — fine.
|
||||
const { text, map } = buildNormalizedText(container);
|
||||
const idx = findQuoteIndex(
|
||||
text,
|
||||
quote,
|
||||
normalizeWhitespace(annotation.prefix),
|
||||
normalizeWhitespace(annotation.suffix)
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
wrapNormalizedRange(map, idx, idx + quote.length, annotation.note_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Surface factory
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Component-local wrappers over the shared trio (window.NotesShared) —
|
||||
// local names kept for shadowing-safety; the bodies live in one place.
|
||||
const csrfToken = () => window.NotesShared.csrfToken();
|
||||
const toast = (message, type) => window.NotesShared.toast(message, type);
|
||||
|
||||
/**
|
||||
* Wire an annotation surface over a container.
|
||||
*
|
||||
* @param {Object} config
|
||||
* @param {string} config.containerId Element holding the immutable
|
||||
* rendered text.
|
||||
* @param {Object} config.endpoints { list, create, deleteFor(noteId) }
|
||||
* — list GETs {annotations}, create POSTs {quote, prefix, suffix,
|
||||
* comment}, deleteFor(noteId) returns the DELETE url. All
|
||||
* same-origin relative paths.
|
||||
* @param {Array} [config.extraActions] Additional selection-toolbar
|
||||
* actions: {icon, label, onActivate(selectionHit)} where
|
||||
* selectionHit = {text, range}.
|
||||
* @param {Function} [config.onChanged] Called after a comment is
|
||||
* created or deleted (hosts refresh their notes panels).
|
||||
* @returns {{reload: Function}}
|
||||
*/
|
||||
function init(config) {
|
||||
const containerId = config.containerId;
|
||||
const endpoints = config.endpoints;
|
||||
const onChanged = config.onChanged || (() => {});
|
||||
let toolbar = null;
|
||||
let popover = null;
|
||||
let clickBound = false;
|
||||
let annotationsByNoteId = {};
|
||||
// Re-apply-across-render machinery (see applyWhenReady).
|
||||
let readyObserver = null;
|
||||
let applyingHighlights = false;
|
||||
// Monotonic token so an out-of-order reload() response can't
|
||||
// overwrite a newer annotation list (see reload).
|
||||
let reloadToken = 0;
|
||||
|
||||
const container = () => document.getElementById(containerId);
|
||||
|
||||
const postJson = (url, body) => window.NotesShared.postJson(url, body);
|
||||
|
||||
// ---- selection toolbar ----
|
||||
|
||||
function makeToolbarButton(iconClass, label, onActivate) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-primary btn-sm';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = iconClass;
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.append(icon, document.createTextNode(` ${label}`));
|
||||
// mousedown, not click: a click first collapses the selection
|
||||
// (mousedown clears it), hiding the toolbar before its click
|
||||
// handler ever ran.
|
||||
btn.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
onActivate(selectionWithinContainer());
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function setupToolbar() {
|
||||
toolbar = document.createElement('div');
|
||||
toolbar.className = 'ldr-selection-toolbar';
|
||||
toolbar.style.display = 'none';
|
||||
toolbar.appendChild(
|
||||
makeToolbarButton('fas fa-comment', 'Comment', openCommentPopover)
|
||||
);
|
||||
for (const action of config.extraActions || []) {
|
||||
toolbar.appendChild(
|
||||
makeToolbarButton(action.icon, action.label, (hit) => {
|
||||
hideToolbar();
|
||||
action.onActivate(hit);
|
||||
})
|
||||
);
|
||||
}
|
||||
document.body.appendChild(toolbar);
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
// Defer: the selection object updates after mouseup.
|
||||
setTimeout(maybeShowToolbar, 0);
|
||||
});
|
||||
document.addEventListener('scroll', hideToolbar, { passive: true });
|
||||
}
|
||||
|
||||
function selectionWithinContainer() {
|
||||
const root = container();
|
||||
const sel = window.getSelection();
|
||||
if (!root || !sel || sel.isCollapsed || sel.rangeCount === 0) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!root.contains(range.commonAncestorContainer)) return null;
|
||||
const text = sel.toString().trim();
|
||||
return text.length >= 10 ? { text, range } : null;
|
||||
}
|
||||
|
||||
function maybeShowToolbar() {
|
||||
const hit = selectionWithinContainer();
|
||||
if (!hit) {
|
||||
hideToolbar();
|
||||
return;
|
||||
}
|
||||
const rect = hit.range.getBoundingClientRect();
|
||||
toolbar.style.position = 'fixed';
|
||||
toolbar.style.top = `${Math.max(8, rect.top - 36)}px`;
|
||||
toolbar.style.left = `${Math.min(window.innerWidth - 220, Math.max(8, rect.left + rect.width / 2 - 80))}px`;
|
||||
toolbar.style.zIndex = '10001';
|
||||
toolbar.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
function hideToolbar() {
|
||||
if (toolbar) toolbar.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- popovers ----
|
||||
|
||||
// The current popover's outside-click listener, so EVERY close path
|
||||
// (Cancel, save, delete, Escape, opening the next popover) can remove
|
||||
// it. Previously it was only removed from inside its own handler on a
|
||||
// genuine outside click, so closing any other way orphaned the
|
||||
// document-level 'mousedown' listener permanently — unbounded
|
||||
// accumulation over a session of opening/closing comments.
|
||||
let popoverOutsideClick = null;
|
||||
|
||||
function closePopover() {
|
||||
if (popoverOutsideClick) {
|
||||
document.removeEventListener('mousedown', popoverOutsideClick);
|
||||
popoverOutsideClick = null;
|
||||
}
|
||||
if (popover) {
|
||||
popover.remove();
|
||||
popover = null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(pop, anchorRect) {
|
||||
pop.style.position = 'fixed';
|
||||
pop.style.top = `${Math.min(window.innerHeight - 60, anchorRect.bottom + 6)}px`;
|
||||
pop.style.left = `${Math.min(window.innerWidth - 340, Math.max(8, anchorRect.left))}px`;
|
||||
pop.style.zIndex = '10002';
|
||||
}
|
||||
|
||||
function basePopover() {
|
||||
closePopover();
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'ldr-annotation-popover';
|
||||
popover = pop;
|
||||
document.body.appendChild(pop);
|
||||
setTimeout(() => {
|
||||
if (popover !== pop) return; // already closed before we armed
|
||||
popoverOutsideClick = (e) => {
|
||||
if (popover === pop && !pop.contains(e.target)) {
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', popoverOutsideClick);
|
||||
}, 0);
|
||||
pop.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closePopover();
|
||||
});
|
||||
return pop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture short normalized prefix/suffix context around the
|
||||
* selection to disambiguate repeated phrases. Range.comparePoint
|
||||
* against the normalized index map finds the exact occurrence the
|
||||
* user selected rather than guessing among duplicates.
|
||||
*/
|
||||
function captureContext(range, quote) {
|
||||
const root = container();
|
||||
const empty = { prefix: '', suffix: '' };
|
||||
if (!root) return empty;
|
||||
try {
|
||||
const { text, map } = buildNormalizedText(root);
|
||||
let start = -1;
|
||||
for (let i = 0; i < map.length; i++) {
|
||||
if (!map[i]) continue; // virtual boundary space
|
||||
if (range.comparePoint(map[i].node, map[i].offset) >= 0) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1) return empty;
|
||||
const end = Math.min(start + quote.length, text.length);
|
||||
return {
|
||||
prefix: text.slice(Math.max(0, start - 30), start),
|
||||
suffix: text.slice(end, end + 30)
|
||||
};
|
||||
} catch (e) {
|
||||
SafeLogger.error('Annotation context capture failed:', e);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection → comment entry popover. */
|
||||
function openCommentPopover(hit) {
|
||||
hideToolbar();
|
||||
if (!hit) return;
|
||||
|
||||
let quote = normalizeWhitespace(hit.text);
|
||||
if (quote.length > 1000) quote = quote.slice(0, 1000);
|
||||
const context = captureContext(hit.range, quote);
|
||||
|
||||
const rect = hit.range.getBoundingClientRect();
|
||||
const pop = basePopover();
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'ldr-annotation-popover-quote';
|
||||
label.textContent = quote.length > 120 ? `${quote.slice(0, 120)}…` : quote;
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'ldr-annotation-popover-input';
|
||||
textarea.rows = 3;
|
||||
textarea.placeholder = 'Your comment… (saved as a note linked to this page)';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ldr-annotation-popover-actions';
|
||||
const save = document.createElement('button');
|
||||
save.type = 'button';
|
||||
save.className = 'btn btn-primary btn-sm';
|
||||
save.textContent = 'Comment';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'btn ldr-btn-outline btn-sm';
|
||||
cancel.textContent = 'Cancel';
|
||||
actions.append(save, cancel);
|
||||
pop.append(label, textarea, actions);
|
||||
positionPopover(pop, rect);
|
||||
textarea.focus();
|
||||
|
||||
cancel.addEventListener('click', closePopover);
|
||||
save.addEventListener('click', async () => {
|
||||
const comment = textarea.value.trim();
|
||||
if (!comment) {
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
save.disabled = true;
|
||||
try {
|
||||
await postJson(endpoints.create, {
|
||||
quote,
|
||||
prefix: context.prefix,
|
||||
suffix: context.suffix,
|
||||
comment
|
||||
});
|
||||
closePopover();
|
||||
toast('Comment saved as a note', 'success');
|
||||
onChanged();
|
||||
reload();
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error saving annotation:', error);
|
||||
toast(error.message || 'Failed to save comment', 'error');
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Existing-highlight popover: comment preview + open/delete. */
|
||||
function showAnnotationPopover(mark, annotation) {
|
||||
const pop = basePopover();
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'ldr-annotation-popover-comment';
|
||||
// Note content is comment-first, then the quoted passage as a
|
||||
// "> " blockquote; the reader is already looking at the
|
||||
// passage, so show only the comment part.
|
||||
const preview = annotation.comment_preview || annotation.note_title || '';
|
||||
body.textContent = preview.split(/\n\s*>/)[0].trim();
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ldr-annotation-popover-actions';
|
||||
const open = document.createElement('a');
|
||||
open.className = 'btn btn-primary btn-sm';
|
||||
open.href = `/notes/${encodeURIComponent(annotation.note_id)}`;
|
||||
open.textContent = 'Open note';
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn ldr-btn-outline btn-sm';
|
||||
del.textContent = 'Delete note';
|
||||
actions.append(open, del);
|
||||
pop.append(body, actions);
|
||||
positionPopover(pop, mark.getBoundingClientRect());
|
||||
|
||||
del.addEventListener('click', async () => {
|
||||
// The comment IS a note that the user may have opened and
|
||||
// expanded with unrelated content — deleting the annotation
|
||||
// deletes that whole note. Confirm so it's never silent.
|
||||
if (!window.confirm(
|
||||
'Delete this comment? The linked note (and anything you '
|
||||
+ 'added to it) will be permanently removed.'
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
del.disabled = true;
|
||||
try {
|
||||
const response = await safeFetchWithAuth(endpoints.deleteFor(annotation.note_id), {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFToken': csrfToken() },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || `Server returned ${response.status}`);
|
||||
}
|
||||
closePopover();
|
||||
toast('Comment deleted', 'success');
|
||||
onChanged();
|
||||
// Unwrap ALL segments of this annotation, not just the
|
||||
// clicked one: a quote spanning a <br>/whitespace run
|
||||
// renders as several <mark data-note-id=X> siblings (see
|
||||
// wrapNormalizedRange / anchoredCount), so unwrapping only
|
||||
// the clicked mark leaves the others as clickable ghosts
|
||||
// bound to a now-deleted note. Also drop the map entry
|
||||
// synchronously so a ghost click before the fire-and-forget
|
||||
// reload() completes (or if reload() fails) can't reopen a
|
||||
// popover for the deleted note. reload() re-applies the rest.
|
||||
const root = container();
|
||||
if (root) {
|
||||
root.querySelectorAll('mark.ldr-research-annotation').forEach((m) => {
|
||||
if (m.dataset.noteId === String(annotation.note_id)) {
|
||||
m.replaceWith(...m.childNodes);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
mark.replaceWith(...mark.childNodes);
|
||||
}
|
||||
delete annotationsByNoteId[annotation.note_id];
|
||||
reload();
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error deleting annotation:', error);
|
||||
toast(error.message || 'Failed to delete comment', 'error');
|
||||
del.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- highlight application ----
|
||||
|
||||
function applyAllAnnotations(annotations) {
|
||||
const root = container();
|
||||
if (!root) return;
|
||||
for (const mark of [...root.querySelectorAll('mark.ldr-research-annotation')]) {
|
||||
mark.replaceWith(...mark.childNodes);
|
||||
}
|
||||
root.normalize();
|
||||
let missed = 0;
|
||||
for (const a of annotations) {
|
||||
if (!applyAnnotation(root, a)) missed++;
|
||||
}
|
||||
if (missed) {
|
||||
SafeLogger.error(`${missed} annotation(s) could not be anchored to the rendered text`);
|
||||
}
|
||||
annotationsByNoteId = {};
|
||||
for (const a of annotations) annotationsByNoteId[a.note_id] = a;
|
||||
if (!clickBound) {
|
||||
clickBound = true;
|
||||
root.addEventListener('click', (e) => {
|
||||
const mark = e.target.closest('mark.ldr-research-annotation');
|
||||
if (!mark) return;
|
||||
const annotation = annotationsByNoteId[mark.dataset.noteId];
|
||||
if (annotation) showAnnotationPopover(mark, annotation);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A loading placeholder is present. Must be robust to more than the
|
||||
// original page markup: the results page (results.js) first replaces
|
||||
// results.html's `.ldr-loading-spinner` with its OWN placeholder
|
||||
// (`<i class="fas fa-spinner fa-pulse"> Loading research results…`)
|
||||
// and only THEN, after the report fetch, swaps in the real content —
|
||||
// so a heuristic that only knew `.ldr-loading-spinner` would treat
|
||||
// the placeholder as ready, anchor nothing, and never re-run.
|
||||
function looksLikeLoading(root) {
|
||||
if (root.querySelector('.ldr-loading-spinner, .ldr-spinner, .fa-spinner, .fa-pulse, .spinner-border')) {
|
||||
return true;
|
||||
}
|
||||
const text = root.textContent.trim();
|
||||
if (!text) return true;
|
||||
// Only a SHORT body that opens with a loading word is a
|
||||
// placeholder ("Loading research results…"). A real report that
|
||||
// merely begins with the word "Loading"/"Searching" is long, and
|
||||
// treating it as a placeholder would suppress every highlight on
|
||||
// that page permanently.
|
||||
return text.length < 120 && /^(?:loading|searching)\b/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's text often renders asynchronously (the research report
|
||||
* is fetched + rendered by results.js, possibly through more than
|
||||
* one placeholder). Apply once real content is present, and RE-APPLY
|
||||
* whenever the container's content is replaced — results.js swaps the
|
||||
* whole report in via innerHTML, which would blow away marks applied
|
||||
* to an earlier placeholder. Stop once every annotation has anchored
|
||||
* (or after a timeout / on the surface being reloaded).
|
||||
*/
|
||||
function applyWhenReady(annotations) {
|
||||
const root = container();
|
||||
if (!root) return;
|
||||
if (readyObserver) {
|
||||
readyObserver.disconnect();
|
||||
readyObserver = null;
|
||||
}
|
||||
const total = annotations.length;
|
||||
|
||||
// Count DISTINCT anchored annotations, not <mark> elements: a
|
||||
// multi-segment quote yields several marks for ONE annotation,
|
||||
// so an element count would over-report and stop the observer
|
||||
// while other annotations are still unanchored.
|
||||
const anchoredCount = () => {
|
||||
const ids = new Set();
|
||||
root.querySelectorAll('mark.ldr-research-annotation').forEach((m) => {
|
||||
if (m.dataset.noteId) ids.add(m.dataset.noteId);
|
||||
});
|
||||
return ids.size;
|
||||
};
|
||||
|
||||
const tryApply = () => {
|
||||
if (looksLikeLoading(root)) return false;
|
||||
// applyAllAnnotations mutates the DOM (wraps marks). Those
|
||||
// mutations fire the observer on a LATER microtask — after a
|
||||
// synchronous `applyingHighlights=false` reset would already
|
||||
// have cleared — so a partially-anchored set would re-trigger
|
||||
// this callback forever (a browser-hanging microtask loop).
|
||||
// Disconnect around the mutation and discard our own records
|
||||
// before reconnecting, so only genuine host changes re-run us.
|
||||
applyingHighlights = true;
|
||||
if (readyObserver) readyObserver.disconnect();
|
||||
try {
|
||||
applyAllAnnotations(annotations);
|
||||
} finally {
|
||||
if (readyObserver) {
|
||||
readyObserver.takeRecords();
|
||||
readyObserver.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
applyingHighlights = false;
|
||||
}
|
||||
return anchoredCount() >= total;
|
||||
};
|
||||
|
||||
if (tryApply()) return;
|
||||
|
||||
const thisObserver = new MutationObserver(() => {
|
||||
if (applyingHighlights) return;
|
||||
if (tryApply() && readyObserver === thisObserver) {
|
||||
readyObserver.disconnect();
|
||||
readyObserver = null;
|
||||
}
|
||||
});
|
||||
readyObserver = thisObserver;
|
||||
readyObserver.observe(root, { childList: true, subtree: true });
|
||||
// Content that never fully anchors (a quote genuinely not present
|
||||
// in the final render) must not observe forever. Guard on identity:
|
||||
// a newer applyWhenReady() may have replaced readyObserver with its
|
||||
// own observer (and its own fresh 60s window); this stale timeout
|
||||
// must only tear down ITS OWN observer, never the newer one.
|
||||
setTimeout(() => {
|
||||
if (readyObserver === thisObserver) {
|
||||
readyObserver.disconnect();
|
||||
readyObserver = null;
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
// Sequence concurrent reloads: create-comment and delete both
|
||||
// fire their own reload(), so responses can arrive out of order.
|
||||
// Only the newest reload is allowed to apply — otherwise a stale
|
||||
// earlier list can overwrite a newer one (a just-added highlight
|
||||
// vanishing, or a just-deleted mark reappearing).
|
||||
const myToken = ++reloadToken;
|
||||
try {
|
||||
const response = await safeFetchWithAuth(endpoints.list, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.error || 'load failed');
|
||||
if (myToken !== reloadToken) return; // superseded
|
||||
const annotations = data.annotations || [];
|
||||
if (!annotations.length && !Object.keys(annotationsByNoteId).length) return;
|
||||
applyWhenReady(annotations);
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error loading annotations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupToolbar();
|
||||
reload();
|
||||
return { reload };
|
||||
}
|
||||
|
||||
window.LDRAnnotationSurface = { init };
|
||||
|
||||
// Test hook (production-inert): the pure anchor engine.
|
||||
if (window.__VITEST_TEST__) {
|
||||
window.__annotationSurfaceTest = {
|
||||
buildNormalizedText,
|
||||
normalizeWhitespace,
|
||||
findQuoteIndex,
|
||||
applyAnnotation
|
||||
};
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Document Notes Component (library document pages)
|
||||
*
|
||||
* The document-side twin of components/research_notes.js: on a library
|
||||
* document's pages, list the notes referencing it, start a new linked
|
||||
* note, and — on the full-text view, whose extracted text is immutable —
|
||||
* attach Word-review-style inline comments to passages via the shared
|
||||
* LDRAnnotationSurface (components/annotation_surface.js).
|
||||
*
|
||||
* Reads its target from data-document-id on #document-notes-section
|
||||
* (details page) or #ldr-document-text-content (text view). Rendering
|
||||
* builds DOM nodes with textContent; names are IIFE-scoped.
|
||||
*
|
||||
* URL security: every URL here is a same-origin relative path built with
|
||||
* encodeURIComponent — no external URLs. URLValidator.isSafeUrl is
|
||||
* available if that ever changes.
|
||||
*/
|
||||
(function() {
|
||||
let documentId = null;
|
||||
|
||||
// Component-local wrappers over the shared trio (window.NotesShared) —
|
||||
// local names kept for shadowing-safety; the bodies live in one place.
|
||||
const toast = (message, type) => window.NotesShared.toast(message, type);
|
||||
const postJson = (url, body) => window.NotesShared.postJson(url, body);
|
||||
|
||||
function initDocumentNotes() {
|
||||
const section = document.getElementById('document-notes-section');
|
||||
const textContainer = document.getElementById('ldr-document-text-content');
|
||||
documentId =
|
||||
(section && section.dataset.documentId) ||
|
||||
(textContainer && textContainer.dataset.documentId) ||
|
||||
null;
|
||||
if (!documentId) return;
|
||||
|
||||
const addBtn = document.getElementById('document-add-note-btn');
|
||||
if (addBtn) addBtn.addEventListener('click', addNoteForDocument);
|
||||
|
||||
if (textContainer) {
|
||||
// The extracted text is immutable — safe annotation surface.
|
||||
const base = `/notes/api/documents/${encodeURIComponent(documentId)}`;
|
||||
window.LDRAnnotationSurface.init({
|
||||
containerId: 'ldr-document-text-content',
|
||||
endpoints: {
|
||||
list: `${base}/annotations`,
|
||||
create: `${base}/annotations`,
|
||||
deleteFor: (noteId) => `${base}/annotations/${encodeURIComponent(noteId)}`
|
||||
},
|
||||
onChanged: loadDocumentNotes
|
||||
});
|
||||
}
|
||||
|
||||
loadDocumentNotes();
|
||||
}
|
||||
|
||||
async function loadDocumentNotes() {
|
||||
const list = document.getElementById('document-notes-list');
|
||||
const empty = document.getElementById('document-notes-empty');
|
||||
if (!list) return;
|
||||
try {
|
||||
const response = await safeFetchWithAuth(`/notes/api/documents/${encodeURIComponent(documentId)}/notes`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.error || 'load failed');
|
||||
|
||||
list.replaceChildren();
|
||||
const notes = data.notes || [];
|
||||
if (empty) empty.style.display = notes.length ? 'none' : 'block';
|
||||
for (const note of notes) {
|
||||
list.appendChild(window.NotesShared.renderNoteRow(note));
|
||||
}
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error loading document notes:', error);
|
||||
if (empty) {
|
||||
empty.textContent = "Couldn't load notes for this document.";
|
||||
empty.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function addNoteForDocument() {
|
||||
const btn = document.getElementById('document-add-note-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const data = await postJson(`/notes/api/documents/${encodeURIComponent(documentId)}/notes`, {});
|
||||
window.location.href = `/notes/${encodeURIComponent(data.note_id)}`;
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error creating note for document:', error);
|
||||
toast(error.message || 'Failed to create note', 'error');
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initDocumentNotes);
|
||||
} else {
|
||||
initDocumentNotes();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Shared helpers for the notes feature's page scripts and components.
|
||||
*
|
||||
* The research/document notes components and the notes/note-detail pages
|
||||
* each used to define their own byte-identical toast / CSRF-token /
|
||||
* POST-and-parse helpers (three copies of the trio in the components,
|
||||
* plus a weaker CSRF helper in the pages). This is the single home for
|
||||
* that glue.
|
||||
*
|
||||
* Exposed as window.NotesShared METHODS, not bare globals: every consumer
|
||||
* keeps its own page/component-local wrapper NAME (getCSRFToken /
|
||||
* csrfToken / postJson / toast) and delegates here, so no shared global
|
||||
* function name is introduced — that would reintroduce the deferred
|
||||
* shared-script global-shadowing bug this codebase deliberately avoids.
|
||||
* Loaded via its own <script defer> tag ahead of every consumer, the same
|
||||
* way components/annotation_surface.js and services/formatting.js are.
|
||||
*/
|
||||
(function () {
|
||||
/** Surface a transient message via the shared ui service (no-op if absent). */
|
||||
function toast(message, type) {
|
||||
if (window.ui && window.ui.showMessage) {
|
||||
window.ui.showMessage(message, type || 'info');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the CSRF token: prefer the api service, fall back to the
|
||||
* meta tag so a POST still carries a token if window.api hasn't loaded
|
||||
* (the page-file helpers previously skipped this fallback and sent an
|
||||
* empty token in that case).
|
||||
*/
|
||||
function csrfToken() {
|
||||
return (window.api && window.api.getCsrfToken)
|
||||
? window.api.getCsrfToken()
|
||||
: (document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON and return the parsed body, throwing on a non-OK response
|
||||
* or a {success:false} envelope. Sends the CSRF header and same-origin
|
||||
* credentials. Callers that need the raw Response (status branching,
|
||||
* streaming) should not use this.
|
||||
*/
|
||||
async function postJson(url, body) {
|
||||
const response = await safeFetchWithAuth(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken()
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body || {})
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || `Server returned ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one linked-notes row: an <a> to the note with its title and
|
||||
* (markdown-stripped) preview. Shared by the research and document
|
||||
* notes panels, which rendered byte-identical rows. Uses textContent
|
||||
* throughout, so no user data reaches innerHTML.
|
||||
*/
|
||||
function renderNoteRow(note) {
|
||||
const row = document.createElement('a');
|
||||
row.className = 'ldr-research-note-row';
|
||||
row.href = `/notes/${encodeURIComponent(note.id)}`;
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'ldr-research-note-title';
|
||||
title.textContent = note.title || 'Untitled note';
|
||||
row.appendChild(title);
|
||||
|
||||
if (note.content_preview) {
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'ldr-research-note-preview';
|
||||
preview.textContent = window.formatting.stripMarkdownToText(note.content_preview);
|
||||
row.appendChild(preview);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
window.NotesShared = { toast, csrfToken, postJson, renderNoteRow };
|
||||
})();
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Research Notes Component (results page)
|
||||
*
|
||||
* The reverse direction of the notes feature's research linking: from a
|
||||
* research run's results page, see the notes that comment on it, start a
|
||||
* new linked note, save the report itself as an editable note copy, clip
|
||||
* a selected passage into a quote note, or attach a Word-review-style
|
||||
* inline comment to a passage (via the shared LDRAnnotationSurface —
|
||||
* see components/annotation_surface.js).
|
||||
*
|
||||
* All rendering builds DOM nodes with textContent (never innerHTML with
|
||||
* user data). Function names are component-scoped inside this IIFE — no
|
||||
* globals to collide with the deferred shared scripts.
|
||||
*/
|
||||
(function() {
|
||||
// URL security: every URL this component fetches or assigns is a
|
||||
// same-origin relative path (/notes/..., built with encodeURIComponent)
|
||||
// — no external URLs are handled. External URL validation is available
|
||||
// via URLValidator.isSafeUrl if that ever changes.
|
||||
let researchId = null;
|
||||
|
||||
// Cap clipped selections: a note exists to hold a *quote*, not a
|
||||
// second copy of the report (that's what Save-as-note is for).
|
||||
const MAX_CLIP_CHARS = 4000;
|
||||
|
||||
// Component-local wrappers over the shared trio (window.NotesShared) —
|
||||
// local names kept for shadowing-safety; the bodies live in one place.
|
||||
const toast = (message, type) => window.NotesShared.toast(message, type);
|
||||
const postJson = (url, body) => window.NotesShared.postJson(url, body);
|
||||
|
||||
function initResearchNotes() {
|
||||
const section = document.getElementById('research-notes-section');
|
||||
if (!section) return;
|
||||
|
||||
researchId = URLBuilder.extractResearchIdFromPattern('results');
|
||||
if (!researchId) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const addBtn = document.getElementById('research-add-note-btn');
|
||||
if (addBtn) addBtn.addEventListener('click', addNoteForResearch);
|
||||
// Same "save the whole report as an editable, linked note" action is
|
||||
// offered in two places: the contextual button in the Notes card and a
|
||||
// prominent one in the results top action bar — both call the one
|
||||
// saveReportAsNote handler.
|
||||
const saveBtn = document.getElementById('research-save-as-note-btn');
|
||||
if (saveBtn) saveBtn.addEventListener('click', saveReportAsNote);
|
||||
const topSaveBtn = document.getElementById(
|
||||
'research-save-as-note-top-btn'
|
||||
);
|
||||
if (topSaveBtn) topSaveBtn.addEventListener('click', saveReportAsNote);
|
||||
|
||||
// Selection toolbar + inline comments over the rendered report.
|
||||
const base = `/notes/api/research/${encodeURIComponent(researchId)}`;
|
||||
window.LDRAnnotationSurface.init({
|
||||
containerId: 'results-content',
|
||||
endpoints: {
|
||||
list: `${base}/annotations`,
|
||||
create: `${base}/annotations`,
|
||||
deleteFor: (noteId) => `${base}/annotations/${encodeURIComponent(noteId)}`
|
||||
},
|
||||
extraActions: [
|
||||
{
|
||||
icon: 'fas fa-quote-right',
|
||||
label: 'Clip to note',
|
||||
onActivate: clipSelectionToNote
|
||||
}
|
||||
],
|
||||
// Comments are notes — the panel below lists them too.
|
||||
onChanged: loadResearchNotes
|
||||
});
|
||||
|
||||
loadResearchNotes();
|
||||
}
|
||||
|
||||
async function loadResearchNotes() {
|
||||
const list = document.getElementById('research-notes-list');
|
||||
const empty = document.getElementById('research-notes-empty');
|
||||
if (!list) return;
|
||||
try {
|
||||
const response = await safeFetchWithAuth(`/notes/api/research/${encodeURIComponent(researchId)}/notes`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.error || 'load failed');
|
||||
|
||||
list.replaceChildren();
|
||||
const notes = data.notes || [];
|
||||
if (empty) empty.style.display = notes.length ? 'none' : 'block';
|
||||
for (const note of notes) {
|
||||
list.appendChild(window.NotesShared.renderNoteRow(note));
|
||||
}
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error loading research notes:', error);
|
||||
// Leave the section usable: the buttons still work, and a
|
||||
// failed list load shouldn't block reading the report.
|
||||
if (empty) {
|
||||
empty.textContent = "Couldn't load notes for this research.";
|
||||
empty.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function addNoteForResearch() {
|
||||
const btn = document.getElementById('research-add-note-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const data = await postJson(`/notes/api/research/${encodeURIComponent(researchId)}/notes`, {});
|
||||
// Land the user in the editor — the starter note is theirs to write.
|
||||
window.location.href = `/notes/${encodeURIComponent(data.note_id)}`;
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error creating note for research:', error);
|
||||
toast(error.message || 'Failed to create note', 'error');
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The one handler is wired to TWO buttons (the Notes-card button and
|
||||
// the top action bar's). Disabling only the clicked button can't stop
|
||||
// the other — and a rapid double-click on the never-disabled top
|
||||
// button fired two POSTs, each creating its own note copy of the
|
||||
// report. Module-level in-flight flag + disable both.
|
||||
let saveAsNoteInProgress = false;
|
||||
|
||||
async function saveReportAsNote() {
|
||||
if (saveAsNoteInProgress) return;
|
||||
saveAsNoteInProgress = true;
|
||||
const buttons = [
|
||||
document.getElementById('research-save-as-note-btn'),
|
||||
document.getElementById('research-save-as-note-top-btn'),
|
||||
].filter(Boolean);
|
||||
buttons.forEach((b) => { b.disabled = true; });
|
||||
try {
|
||||
const data = await postJson(`/notes/api/research/${encodeURIComponent(researchId)}/save-as-note`, {});
|
||||
window.location.href = `/notes/${encodeURIComponent(data.note_id)}`;
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error saving research as note:', error);
|
||||
toast(error.message || 'Failed to save report as note', 'error');
|
||||
buttons.forEach((b) => { b.disabled = false; });
|
||||
// eslint-disable-next-line require-atomic-updates -- single-writer guard; re-armed only on failure (success navigates away)
|
||||
saveAsNoteInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape markdown link-label metacharacters (backslash first) plus
|
||||
// newlines. Twin of the server's escape_markdown_link_label — keep in
|
||||
// sync. Component-local name so it can't collide with deferred globals.
|
||||
function escapeMarkdownLinkLabel(label) {
|
||||
return String(label == null ? '' : label)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\[/g, '\\[')
|
||||
.replace(/\]/g, '\\]')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)')
|
||||
.replace(/[\r\n]/g, ' ');
|
||||
}
|
||||
|
||||
async function clipSelectionToNote(hit) {
|
||||
if (!hit) return;
|
||||
|
||||
let quote = hit.text;
|
||||
if (quote.length > MAX_CLIP_CHARS) {
|
||||
quote = quote.slice(0, MAX_CLIP_CHARS) + '…';
|
||||
}
|
||||
|
||||
const queryEl = document.getElementById('result-query');
|
||||
const queryText = queryEl ? queryEl.textContent.trim() : '';
|
||||
const titleSeed = quote.replace(/\s+/g, ' ').slice(0, 60);
|
||||
const blockquote = quote.split('\n').map((line) => `> ${line}`).join('\n');
|
||||
// Escape the query before embedding it as a markdown link label, so
|
||||
// a crafted query can't break out of [label](url) — matches the
|
||||
// server-side escape_markdown_link_label used by the note routes.
|
||||
const label = escapeMarkdownLinkLabel(queryText || researchId);
|
||||
const content =
|
||||
`${blockquote}\n\n— clipped from the research run ` +
|
||||
`[${label}](/results/${researchId})\n\n`;
|
||||
|
||||
try {
|
||||
await postJson(`/notes/api/research/${encodeURIComponent(researchId)}/notes`, {
|
||||
title: `Clip: ${titleSeed}`,
|
||||
content
|
||||
});
|
||||
// Stay on the page — clipping is part of the reading flow.
|
||||
// The new note appears in the Notes section instead.
|
||||
toast('Clipped to note', 'success');
|
||||
loadResearchNotes();
|
||||
} catch (error) {
|
||||
SafeLogger.error('Error clipping selection to note:', error);
|
||||
toast(error.message || 'Failed to clip selection', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initResearchNotes);
|
||||
} else {
|
||||
initResearchNotes();
|
||||
}
|
||||
})();
|
||||
@@ -129,6 +129,34 @@ function buildTieredResults(textResults, semanticResults, options) {
|
||||
return { tier1, tier2, tier3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten buildTieredResults output into one ordered array of the
|
||||
* caller's item shape: tier-1 text items enriched with their semantic
|
||||
* similarity + snippet (as content_preview), then tier-2 keyword-only
|
||||
* items, then tier-3 semantic-only results verbatim.
|
||||
*
|
||||
* Shared by the notes and unified-search pages, whose page-local copies
|
||||
* of this flatten had already started drifting apart.
|
||||
*/
|
||||
function flattenTieredResults(tiered) {
|
||||
const out = [];
|
||||
for (const entry of tiered.tier1) {
|
||||
const item = { ...entry.historyItem };
|
||||
item.similarity = entry.semanticMatch.similarity;
|
||||
if (entry.semanticMatch.snippet) {
|
||||
item.content_preview = entry.semanticMatch.snippet;
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
for (const entry of tiered.tier2) {
|
||||
out.push(entry.historyItem);
|
||||
}
|
||||
for (const entry of tiered.tier3) {
|
||||
out.push(entry.semanticResult);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a self-contained semantic result card element.
|
||||
*
|
||||
@@ -246,8 +274,15 @@ window.SemanticSearch = {
|
||||
renderSnippet,
|
||||
highlightTerms,
|
||||
buildTieredResults,
|
||||
flattenTieredResults,
|
||||
createSemanticResultCard,
|
||||
isSafeExternalUrl,
|
||||
// Shared semantic-search tuning, previously inlined as bare literals in
|
||||
// both the notes page and the unified-search page (they could silently
|
||||
// diverge). MIN_SIMILARITY = FAISS score cutoff; MIN_QUERY_LENGTH = the
|
||||
// shortest query the AI modes will embed.
|
||||
MIN_SIMILARITY: 0.25,
|
||||
MIN_QUERY_LENGTH: 2,
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -45,6 +45,7 @@ if (typeof URLS !== 'undefined') {
|
||||
METRICS_CONTEXT_OVERFLOW: '/metrics/context-overflow', // Context overflow diagnostics
|
||||
JOURNAL_QUALITY: '/metrics/journals',
|
||||
LIBRARY: '/library/',
|
||||
NOTES: '/notes/',
|
||||
COLLECTIONS: '/library/collections',
|
||||
COLLECTION_DETAILS: '/library/collections/{id}',
|
||||
NEWS: '/news/',
|
||||
|
||||
@@ -159,6 +159,10 @@
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span class="ldr-mobile-sheet-label">Collections</span>
|
||||
</button>
|
||||
<button class="ldr-mobile-sheet-item" data-item-id="notes" data-action="${URLS.PAGES.NOTES}">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<span class="ldr-mobile-sheet-label">Notes</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Unified Search Page ("Search everything")
|
||||
*
|
||||
* One search box across notes, library documents, and research reports,
|
||||
* with the same Text / AI Hybrid / AI Only modes as the notes page —
|
||||
* built around a MODE REGISTRY so future modes are one registry entry.
|
||||
*
|
||||
* URL security: every URL this page fetches is a same-origin relative
|
||||
* path (/library/search/api/...), and result links come from the
|
||||
* server-computed `url` field, additionally constrained to relative
|
||||
* paths by unifiedSearchSafeHref below. External URL validation is
|
||||
* available via URLValidator.isSafeUrl if that ever changes.
|
||||
*
|
||||
* All function names are page-unique (unifiedSearch~ prefix) — deferred
|
||||
* shared scripts clobber same-named globals (see the notes
|
||||
* global-shadowing bug).
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH MODE REGISTRY
|
||||
// ============================================================================
|
||||
// Adding a search mode = adding ONE entry here. The controller (input
|
||||
// debounce, mode dropdown, render loop) consumes only this interface:
|
||||
//
|
||||
// label: string shown on the selector button and dropdown item.
|
||||
// icon: Font Awesome class for the button/dropdown icon.
|
||||
// placeholder: search-input placeholder while the mode is active.
|
||||
// hint: short dropdown-item description (optional).
|
||||
// run(query, {signal}) → Promise<{results, notice?}>
|
||||
// - `query` is the trimmed search text (>= 2 chars).
|
||||
// - `signal` is an AbortSignal; pass it to fetches so a newer search
|
||||
// cancels this one. Let AbortError propagate (re-throw it).
|
||||
// - Resolve with `results`: an array of
|
||||
// {id, title, content_preview, url, source_type, similarity?}
|
||||
// and an optional `notice` string the controller shows inline
|
||||
// (e.g. a degraded-but-usable search).
|
||||
// - Reject (throw) when the mode cannot produce meaningful results;
|
||||
// the controller renders the "Couldn't search" error state with a
|
||||
// retry action.
|
||||
const SEARCH_MODE_REGISTRY = {
|
||||
text: { label: 'Text Only', icon: 'fa-font', placeholder: 'Search everything by keyword...', hint: 'keyword match', run: runKeywordMode },
|
||||
hybrid: { label: 'AI Hybrid', icon: 'fa-brain', placeholder: 'AI Hybrid: keywords + meaning...', hint: 'keywords + meaning', run: runHybridMode },
|
||||
semantic: { label: 'AI Only', icon: 'fa-brain', placeholder: 'Search everything by meaning...', hint: 'search by meaning', run: runSemanticMode },
|
||||
};
|
||||
|
||||
const UNIFIED_SEARCH_DEFAULT_MODE = 'hybrid';
|
||||
// Minimum query length lives in the shared window.SemanticSearch
|
||||
// (MIN_QUERY_LENGTH) so this page and the notes page can't diverge; read
|
||||
// at call time in runUnifiedSearch (the component is loaded before this).
|
||||
|
||||
// Source-type badge map. Unknown source types (user_upload,
|
||||
// research_download, research_source, manual_entry, future ones) fall
|
||||
// back to the generic Document badge.
|
||||
const UNIFIED_SOURCE_BADGES = Object.freeze({
|
||||
note: { label: 'Note', icon: 'fa-sticky-note' },
|
||||
research_report: { label: 'Report', icon: 'fa-file-alt' },
|
||||
});
|
||||
const UNIFIED_SOURCE_BADGE_DEFAULT = Object.freeze({ label: 'Document', icon: 'fa-file' });
|
||||
|
||||
// Page state
|
||||
let unifiedSearchMode = UNIFIED_SEARCH_DEFAULT_MODE;
|
||||
let unifiedSearchAbortController = null;
|
||||
// Monotonic id so a slow response can't overwrite a newer search.
|
||||
let unifiedSearchRunId = 0;
|
||||
// Debounce timer for the search input. Module-level so a mode change can
|
||||
// cancel a pending re-run — it re-runs immediately itself, and the stale
|
||||
// timer would otherwise fire a duplicate identical search.
|
||||
let unifiedSearchDebounceTimer = null;
|
||||
|
||||
// DOM refs
|
||||
let unifiedSearchInput;
|
||||
let unifiedSearchResults;
|
||||
let unifiedSearchEmpty;
|
||||
let unifiedSearchNotice;
|
||||
let unifiedSearchModeBtn;
|
||||
let unifiedSearchModeMenu;
|
||||
let unifiedSearchModeLabel;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
unifiedSearchInput = document.getElementById('ldr-unified-search-input');
|
||||
unifiedSearchResults = document.getElementById('ldr-unified-search-results');
|
||||
unifiedSearchEmpty = document.getElementById('ldr-unified-search-empty');
|
||||
unifiedSearchNotice = document.getElementById('ldr-unified-search-notice');
|
||||
unifiedSearchModeBtn = document.getElementById('unified-search-mode-btn');
|
||||
unifiedSearchModeMenu = document.getElementById('unified-search-mode-menu');
|
||||
unifiedSearchModeLabel = document.getElementById('unified-search-mode-label');
|
||||
|
||||
renderUnifiedSearchModeMenu();
|
||||
setUnifiedSearchMode(UNIFIED_SEARCH_DEFAULT_MODE);
|
||||
setupUnifiedSearchListeners();
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the mode dropdown from the registry — a future mode appears in
|
||||
* the menu without a template change.
|
||||
*/
|
||||
function renderUnifiedSearchModeMenu() {
|
||||
if (!unifiedSearchModeMenu) return;
|
||||
unifiedSearchModeMenu.replaceChildren();
|
||||
for (const [mode, def] of Object.entries(SEARCH_MODE_REGISTRY)) {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.className = 'dropdown-item' + (mode === unifiedSearchMode ? ' active' : '');
|
||||
a.href = '#';
|
||||
a.dataset.mode = mode;
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas ' + def.icon;
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
a.appendChild(icon);
|
||||
a.appendChild(document.createTextNode(' ' + def.label + ' '));
|
||||
if (def.hint) {
|
||||
const small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = '— ' + def.hint;
|
||||
a.appendChild(small);
|
||||
}
|
||||
li.appendChild(a);
|
||||
unifiedSearchModeMenu.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Delegated data-action handlers (CSP-friendly — no inline onclick).
|
||||
const UNIFIED_SEARCH_ACTIONS = {
|
||||
'retry-unified-search': () => runUnifiedSearch(),
|
||||
};
|
||||
|
||||
function setupUnifiedSearchListeners() {
|
||||
// Search input with debounce. Text mode is a single server call; the
|
||||
// AI modes add an embedding round-trip, so debounce a little longer.
|
||||
if (unifiedSearchInput) {
|
||||
unifiedSearchInput.addEventListener('input', function() {
|
||||
clearTimeout(unifiedSearchDebounceTimer);
|
||||
unifiedSearchDebounceTimer = setTimeout(
|
||||
() => runUnifiedSearch(),
|
||||
unifiedSearchMode === 'text' ? 300 : 500
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Mode selector
|
||||
if (unifiedSearchModeMenu) {
|
||||
unifiedSearchModeMenu.addEventListener('click', function(e) {
|
||||
const item = e.target.closest('[data-mode]');
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const mode = item.dataset.mode;
|
||||
if (!mode || mode === unifiedSearchMode) return;
|
||||
setUnifiedSearchMode(mode);
|
||||
});
|
||||
}
|
||||
|
||||
// Delegated data-action dispatcher
|
||||
document.addEventListener('click', (e) => {
|
||||
const el = e.target.closest('[data-action]');
|
||||
if (!el) return;
|
||||
const fn = UNIFIED_SEARCH_ACTIONS[el.dataset.action];
|
||||
if (fn) {
|
||||
e.preventDefault();
|
||||
fn(el, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active search mode, update the selector chrome from the
|
||||
* registry, and re-run the current query in the new mode.
|
||||
*/
|
||||
function setUnifiedSearchMode(mode) {
|
||||
const def = SEARCH_MODE_REGISTRY[mode];
|
||||
if (!def) return;
|
||||
unifiedSearchMode = mode;
|
||||
|
||||
if (unifiedSearchModeMenu) {
|
||||
unifiedSearchModeMenu.querySelectorAll('.dropdown-item').forEach((i) => {
|
||||
i.classList.toggle('active', i.dataset.mode === mode);
|
||||
});
|
||||
}
|
||||
if (unifiedSearchModeLabel) unifiedSearchModeLabel.textContent = def.label;
|
||||
if (unifiedSearchModeBtn) {
|
||||
const icon = unifiedSearchModeBtn.querySelector('i');
|
||||
if (icon) icon.className = 'fas ' + def.icon;
|
||||
}
|
||||
if (unifiedSearchInput) unifiedSearchInput.placeholder = def.placeholder;
|
||||
|
||||
clearUnifiedSearchNotice();
|
||||
// Cancel any pending debounced re-run — the immediate run below covers
|
||||
// it, and the stale timer would fire a duplicate identical search.
|
||||
clearTimeout(unifiedSearchDebounceTimer);
|
||||
// Re-run so the visible results reflect the new mode immediately.
|
||||
runUnifiedSearch();
|
||||
}
|
||||
|
||||
/** Show a small inline notice under the search box (e.g. AI unavailable). */
|
||||
function showUnifiedSearchNotice(message) {
|
||||
if (!unifiedSearchNotice) return;
|
||||
unifiedSearchNotice.textContent = message;
|
||||
unifiedSearchNotice.style.display = 'block';
|
||||
}
|
||||
|
||||
/** Hide the inline search notice. */
|
||||
function clearUnifiedSearchNotice() {
|
||||
if (!unifiedSearchNotice) return;
|
||||
unifiedSearchNotice.textContent = '';
|
||||
unifiedSearchNotice.style.display = 'none';
|
||||
}
|
||||
|
||||
/** Render the centered "searching…" spinner inside the results area. */
|
||||
function showUnifiedSearchLoading(message) {
|
||||
if (!unifiedSearchResults) return;
|
||||
unifiedSearchResults.replaceChildren();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ldr-semantic-search-loading';
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'ldr-spinner';
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message || 'Searching...';
|
||||
wrap.append(spinner, text);
|
||||
unifiedSearchResults.appendChild(wrap);
|
||||
unifiedSearchResults.style.display = 'block';
|
||||
if (unifiedSearchEmpty) unifiedSearchEmpty.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the "Couldn't search" error state with a retry button.
|
||||
* Replaces the results area so a failed search can't leave a spinner
|
||||
* running or masquerade as the "No matches" empty state.
|
||||
*/
|
||||
function showUnifiedSearchError(message) {
|
||||
if (!unifiedSearchResults || !unifiedSearchEmpty) return;
|
||||
unifiedSearchResults.replaceChildren();
|
||||
unifiedSearchResults.style.display = 'none';
|
||||
// message is one of this file's hardcoded strings — escaped anyway.
|
||||
unifiedSearchEmpty.innerHTML = `
|
||||
<i class="fas fa-exclamation-triangle ldr-notes-empty-icon"></i>
|
||||
<h3>Couldn't search</h3>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
<button class="ldr-create-note-btn" data-action="retry-unified-search">
|
||||
<i class="fas fa-rotate-right"></i> Try again
|
||||
</button>
|
||||
`;
|
||||
unifiedSearchEmpty.style.display = 'block';
|
||||
}
|
||||
|
||||
/** Render the idle state shown before the user has typed a query. */
|
||||
function showUnifiedSearchIdleState() {
|
||||
if (!unifiedSearchResults || !unifiedSearchEmpty) return;
|
||||
unifiedSearchResults.replaceChildren();
|
||||
unifiedSearchResults.style.display = 'none';
|
||||
unifiedSearchEmpty.innerHTML = `
|
||||
<i class="fas fa-search ldr-notes-empty-icon"></i>
|
||||
<h3>Search everything</h3>
|
||||
<p>Find notes, library documents, and research reports in one place</p>
|
||||
`;
|
||||
unifiedSearchEmpty.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the active mode's search for the current input value. This is the
|
||||
* ONLY place a mode's `run` is invoked — everything mode-specific lives
|
||||
* behind the registry contract documented above.
|
||||
*/
|
||||
async function runUnifiedSearch() {
|
||||
const query = ((unifiedSearchInput && unifiedSearchInput.value) || '').trim();
|
||||
|
||||
if (unifiedSearchAbortController) {
|
||||
unifiedSearchAbortController.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
unifiedSearchAbortController = controller;
|
||||
const myId = ++unifiedSearchRunId;
|
||||
|
||||
if (query.length < window.SemanticSearch.MIN_QUERY_LENGTH) {
|
||||
clearUnifiedSearchNotice();
|
||||
showUnifiedSearchIdleState();
|
||||
return;
|
||||
}
|
||||
|
||||
const def = SEARCH_MODE_REGISTRY[unifiedSearchMode];
|
||||
if (!def) return;
|
||||
|
||||
clearUnifiedSearchNotice();
|
||||
showUnifiedSearchLoading('Searching...');
|
||||
|
||||
try {
|
||||
const { results, notice } = await def.run(query, { signal: controller.signal });
|
||||
// A newer search (typing or a mode change) superseded this one.
|
||||
if (myId !== unifiedSearchRunId) return;
|
||||
if (notice) showUnifiedSearchNotice(notice);
|
||||
renderUnifiedSearchResults(results || []);
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (myId !== unifiedSearchRunId) return;
|
||||
SafeLogger.error('Unified search failed:', error);
|
||||
// Surface the server's own error message when a mode flagged it
|
||||
// (e.g. a 400 validation detail) — blaming "your connection" for
|
||||
// a server-reported problem sends the user debugging the wrong
|
||||
// thing. Network/parse failures keep the generic copy.
|
||||
showUnifiedSearchError(
|
||||
error && error.isServerError && error.message
|
||||
? error.message
|
||||
: 'Search failed — check your connection and try again.'
|
||||
);
|
||||
} finally {
|
||||
// Only clear the module-level controller if it is still ours —
|
||||
// a newer run already installed its own.
|
||||
if (unifiedSearchAbortController === controller) {
|
||||
unifiedSearchAbortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mode implementations (registry `run` functions)
|
||||
// ============================================================================
|
||||
|
||||
/** Text Only: the keyword endpoint (ILIKE over title + content). */
|
||||
async function runKeywordMode(query, { signal } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('q', query);
|
||||
params.append('limit', '20');
|
||||
const response = await safeFetchWithAuth(`/library/search/api/keyword?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
const err = new Error(data.error || 'Keyword search failed');
|
||||
// Server-reported failure — the controller may show this message.
|
||||
err.isServerError = true;
|
||||
throw err;
|
||||
}
|
||||
return { results: data.results || [] };
|
||||
}
|
||||
|
||||
/** AI Only: the semantic endpoint (FAISS over the system collections). */
|
||||
async function runSemanticMode(query, { signal } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('q', query);
|
||||
params.append('limit', '20');
|
||||
params.append('min_similarity', String(window.SemanticSearch.MIN_SIMILARITY));
|
||||
const response = await safeFetchWithAuth(`/library/search/api/semantic?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
const err = new Error(data.error || 'Semantic search failed');
|
||||
// Server-reported failure — the controller may show this message.
|
||||
err.isServerError = true;
|
||||
throw err;
|
||||
}
|
||||
return { results: data.results || [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Hybrid: run both legs in parallel and merge them via the shared
|
||||
* window.SemanticSearch.buildTieredResults — the exact tiering the
|
||||
* notes/library/history/news pages use. Tier 1 = matched by both (top,
|
||||
* ranked by similarity), Tier 2 = keyword-only (recency), Tier 3 =
|
||||
* semantic-only (ranked by similarity). Each leg is best-effort: a
|
||||
* single-leg failure degrades to the other leg plus an inline notice
|
||||
* (by design, like the notes page — not a bug-masking fallback); both
|
||||
* legs failing rejects, which the controller renders as the error
|
||||
* state with retry.
|
||||
*/
|
||||
async function runHybridMode(query, { signal } = {}) {
|
||||
let kwUnavailable = false;
|
||||
let aiUnavailable = false;
|
||||
|
||||
const kwPromise = runKeywordMode(query, { signal })
|
||||
.then((r) => r.results)
|
||||
.catch((err) => {
|
||||
if (err && err.name === 'AbortError') throw err;
|
||||
SafeLogger.error('Hybrid keyword leg failed:', err);
|
||||
kwUnavailable = true;
|
||||
return [];
|
||||
});
|
||||
|
||||
const semPromise = runSemanticMode(query, { signal })
|
||||
.then((r) => r.results)
|
||||
.catch((err) => {
|
||||
if (err && err.name === 'AbortError') throw err;
|
||||
SafeLogger.error('Hybrid semantic leg failed:', err);
|
||||
aiUnavailable = true;
|
||||
return [];
|
||||
});
|
||||
|
||||
const [textResults, semanticResults] = await Promise.all([kwPromise, semPromise]);
|
||||
|
||||
// Both legs down: nothing meaningful to render — reject so the
|
||||
// controller shows the explicit error state (with retry) instead of
|
||||
// a fake empty result.
|
||||
if (kwUnavailable && aiUnavailable) {
|
||||
throw new Error('Both search legs failed');
|
||||
}
|
||||
|
||||
const merge = window.SemanticSearch && window.SemanticSearch.buildTieredResults;
|
||||
if (!merge) {
|
||||
// Shared component missing (load-order/dependency issue) — show
|
||||
// the keyword base rather than nothing, and log it loudly. Keep
|
||||
// the degradation notice: without it, a failed AI leg on this
|
||||
// path was indistinguishable from healthy keyword-only results.
|
||||
SafeLogger.error('window.SemanticSearch.buildTieredResults unavailable; showing keyword results only');
|
||||
return {
|
||||
results: textResults,
|
||||
notice: aiUnavailable
|
||||
? (textResults.length > 0
|
||||
? 'AI search unavailable — showing keyword matches only.'
|
||||
: 'AI search unavailable — no keyword matches found.')
|
||||
: 'AI ranking unavailable — showing keyword matches only.',
|
||||
};
|
||||
}
|
||||
|
||||
// The semantic endpoint returns its snippet as `content_preview`;
|
||||
// buildTieredResults reads `.snippet`, so alias it for Tier-1 enrichment.
|
||||
const normalizedSemantic = semanticResults.map(
|
||||
(r) => ({ ...r, snippet: r.content_preview || '' }),
|
||||
);
|
||||
|
||||
const tiered = merge(textResults, normalizedSemantic, {
|
||||
textIdKey: 'id',
|
||||
semanticIdKey: 'id',
|
||||
});
|
||||
const results = mergeUnifiedSearchTiers(tiered);
|
||||
|
||||
let notice = null;
|
||||
if (aiUnavailable) {
|
||||
notice = textResults.length > 0
|
||||
? 'AI search unavailable — showing keyword matches only.'
|
||||
: 'AI search unavailable — no keyword matches found.';
|
||||
} else if (kwUnavailable) {
|
||||
notice = results.length > 0
|
||||
? 'Text search unavailable — showing AI matches only.'
|
||||
: 'Text search unavailable — no AI matches found.';
|
||||
}
|
||||
return { results, notice };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten tiered hybrid results into a single ordered results array.
|
||||
* Thin page-local wrapper over the shared
|
||||
* window.SemanticSearch.flattenTieredResults (the notes and
|
||||
* unified-search copies of this logic had drifted apart); the caller
|
||||
* already sits behind the buildTieredResults availability guard, which
|
||||
* ships in the same component file.
|
||||
*/
|
||||
function mergeUnifiedSearchTiers(tiered) {
|
||||
return window.SemanticSearch.flattenTieredResults(tiered);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rendering
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Constrain a result link to a same-origin relative path. The server
|
||||
* only ever emits /notes/…, /results/… or /library/document/… here, so
|
||||
* anything else indicates tampering/corruption — link to '#' instead.
|
||||
*/
|
||||
function unifiedSearchSafeHref(url) {
|
||||
// Reject '//' (protocol-relative) AND '/\' — browsers normalize the
|
||||
// backslash to '/', so '/\evil.com' navigates cross-origin too.
|
||||
// Reject ASCII control chars first: the URL parser strips embedded
|
||||
// tab/newline/CR, so '/\t/evil.com' would slip past the '//' check yet
|
||||
// resolve to a protocol-relative cross-origin URL once parsed.
|
||||
return typeof url === 'string'
|
||||
// eslint-disable-next-line no-control-regex -- intentionally detecting embedded control chars the URL parser would strip
|
||||
&& !/[\u0000-\u001f\u007f]/.test(url)
|
||||
&& url.startsWith('/')
|
||||
&& !url.startsWith('//')
|
||||
&& !url.startsWith('/\\')
|
||||
? url
|
||||
: '#';
|
||||
}
|
||||
|
||||
/** Render one result row as an <a> card (badge, title, preview). */
|
||||
function createUnifiedSearchResultCard(result) {
|
||||
const badge = UNIFIED_SOURCE_BADGES[result.source_type] || UNIFIED_SOURCE_BADGE_DEFAULT;
|
||||
const similarityHtml = window.formatting.similarityBadge(result.similarity);
|
||||
const preview = result.content_preview || '';
|
||||
|
||||
return `
|
||||
<a class="ldr-unified-result-card" href="${escapeHtml(unifiedSearchSafeHref(result.url))}">
|
||||
<div class="ldr-unified-result-header">
|
||||
<span class="ldr-unified-source-badge">
|
||||
<i class="fas ${escapeHtml(badge.icon)}" aria-hidden="true"></i> ${escapeHtml(badge.label)}
|
||||
</span>
|
||||
<h3 class="ldr-unified-result-title">${escapeHtml(result.title || 'Untitled')}</h3>
|
||||
${similarityHtml}
|
||||
</div>
|
||||
<p class="ldr-unified-result-preview">${escapeHtml(preview)}</p>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Render the result list (or the "No matches" empty state). */
|
||||
function renderUnifiedSearchResults(results) {
|
||||
if (!unifiedSearchResults || !unifiedSearchEmpty) return;
|
||||
|
||||
if (!results.length) {
|
||||
unifiedSearchResults.replaceChildren();
|
||||
unifiedSearchResults.style.display = 'none';
|
||||
const hint = unifiedSearchMode === 'text'
|
||||
? 'Try a different search or switch to AI Hybrid mode'
|
||||
: 'Try a different search or switch to Text mode';
|
||||
// eslint-disable-next-line no-unsanitized/property -- audited 2026-07-04: hint is one of two hardcoded strings, no user data
|
||||
unifiedSearchEmpty.innerHTML = `
|
||||
<i class="fas fa-search ldr-notes-empty-icon"></i>
|
||||
<h3>No matches found</h3>
|
||||
<p>${hint}</p>
|
||||
`;
|
||||
unifiedSearchEmpty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
unifiedSearchEmpty.style.display = 'none';
|
||||
unifiedSearchResults.style.display = 'block';
|
||||
const html = results.map((r) => createUnifiedSearchResultCard(r)).join('');
|
||||
// eslint-disable-next-line no-unsanitized/property -- audited 2026-07-04: all interpolations use escapeHtml/numeric
|
||||
unifiedSearchResults.innerHTML = html;
|
||||
}
|
||||
|
||||
// Production-inert test hook (mirrors notes.js's __notesTest). Exposed
|
||||
// only when a vitest harness sets window.__VITEST_TEST__ before import,
|
||||
// so it never ships to the browser.
|
||||
if (typeof window !== 'undefined' && window.__VITEST_TEST__) {
|
||||
window.__unifiedSearchTest = {
|
||||
SEARCH_MODE_REGISTRY,
|
||||
runUnifiedSearch,
|
||||
runKeywordMode,
|
||||
runSemanticMode,
|
||||
runHybridMode,
|
||||
mergeUnifiedSearchTiers,
|
||||
renderUnifiedSearchResults,
|
||||
renderUnifiedSearchModeMenu,
|
||||
setUnifiedSearchMode,
|
||||
unifiedSearchSafeHref,
|
||||
getMode: () => unifiedSearchMode,
|
||||
setDomRefs: ({ input, results, empty, notice, modeBtn, modeMenu, modeLabel } = {}) => {
|
||||
unifiedSearchInput = input;
|
||||
unifiedSearchResults = results;
|
||||
unifiedSearchEmpty = empty;
|
||||
unifiedSearchNotice = notice;
|
||||
unifiedSearchModeBtn = modeBtn;
|
||||
unifiedSearchModeMenu = modeMenu;
|
||||
unifiedSearchModeLabel = modeLabel;
|
||||
unifiedSearchRunId = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,7 @@ async function safeFetch(url, options = {}) {
|
||||
*
|
||||
* The "which 401s redirect" decision and the login-URL construction are reused
|
||||
* from api.js (window.api) so there is a single definition shared with
|
||||
* fetchWithErrorHandling — no duplicated/drifting logic.
|
||||
* fetchWithErrorHandling — no duplicated/drifting logic.
|
||||
*
|
||||
* Do NOT use on /auth/* endpoints — those handle their own 401 states.
|
||||
*/
|
||||
@@ -37,13 +37,90 @@ async function safeFetchWithAuth(url, options = {}) {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured HTTP error raised by safeFetchJson. Carries status,
|
||||
* Retry-After (in seconds where the server provides one), and the
|
||||
* parsed body when JSON, so callers can branch on 429 vs 500 vs 4xx
|
||||
* and surface a user-meaningful message ("rate limit reached, try
|
||||
* again in 30s") instead of a generic "Failed to ...".
|
||||
*/
|
||||
class HTTPError extends Error {
|
||||
constructor({ status, statusText, retryAfter, body, url }) {
|
||||
const detail = body && body.error ? body.error : statusText || `HTTP ${status}`;
|
||||
super(detail);
|
||||
this.name = 'HTTPError';
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.retryAfter = retryAfter;
|
||||
this.body = body;
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
function _parseRetryAfter(headerValue) {
|
||||
if (!headerValue) {
|
||||
return null;
|
||||
}
|
||||
const secs = Number(headerValue);
|
||||
if (Number.isFinite(secs) && secs >= 0) {
|
||||
return secs;
|
||||
}
|
||||
// RFC 7231 also allows an HTTP-date; convert to relative seconds.
|
||||
const when = Date.parse(headerValue);
|
||||
if (!Number.isNaN(when)) {
|
||||
const delta = Math.max(0, Math.round((when - Date.now()) / 1000));
|
||||
return delta;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around safeFetch that:
|
||||
* - throws an HTTPError on any non-2xx response (so callers can stop
|
||||
* blindly calling `.json()` on error pages that don't return JSON),
|
||||
* - exposes Retry-After on the thrown error for 429 backoff handling,
|
||||
* - parses the response as JSON for 2xx.
|
||||
*
|
||||
* Migration path: existing call sites that wrap `await safeFetch(...)`
|
||||
* + `await resp.json()` + manual `data.success` checks can switch to
|
||||
* `await safeFetchJson(...)` and catch HTTPError instead.
|
||||
*
|
||||
* For endpoints that should redirect to login on an expired session,
|
||||
* compose with the auth-aware variant by passing safeFetchWithAuth's
|
||||
* result semantics — or call safeFetchWithAuth directly when a JSON
|
||||
* envelope isn't needed.
|
||||
*/
|
||||
async function safeFetchJson(url, options = {}) {
|
||||
const response = await safeFetch(url, options);
|
||||
if (!response.ok) {
|
||||
let body;
|
||||
try {
|
||||
// Some error responses are JSON, some are HTML/text.
|
||||
const ct = response.headers.get('content-type') || '';
|
||||
body = ct.includes('application/json') ? await response.json() : null;
|
||||
} catch (_e) {
|
||||
body = null;
|
||||
}
|
||||
throw new HTTPError({
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
retryAfter: _parseRetryAfter(response.headers.get('Retry-After')),
|
||||
body,
|
||||
url,
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Export for Node.js testing
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { safeFetch, safeFetchWithAuth };
|
||||
module.exports = { safeFetch, safeFetchWithAuth, safeFetchJson, HTTPError };
|
||||
}
|
||||
|
||||
// Make the helpers available globally for browser usage
|
||||
if (typeof window !== 'undefined') {
|
||||
window.safeFetch = safeFetch;
|
||||
window.safeFetchWithAuth = safeFetchWithAuth;
|
||||
window.safeFetchJson = safeFetchJson;
|
||||
window.HTTPError = HTTPError;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
* Utility functions for formatting data
|
||||
*/
|
||||
|
||||
// Canonical inner-target pattern for a [[wiki-link]] — the text between the
|
||||
// brackets. A wiki-link target must NOT span a newline (it mirrors how the
|
||||
// backend link parser resolves [[Title]]), so the class excludes both `]`
|
||||
// and `\n`. This is the single source of truth: note-detail.js
|
||||
// (processWikiLinks and renderNoteMarkdown) reuses it via
|
||||
// window.formatting.WIKILINK_TARGET so parse and render agree on newline
|
||||
// handling. Exported as a source string (not a RegExp) so each call site can
|
||||
// build its own stateful global regex without sharing lastIndex.
|
||||
const WIKILINK_TARGET = '[^\\]\\n]+';
|
||||
|
||||
/**
|
||||
* Format a status string to be more user-friendly
|
||||
* @param {string} status - The status string
|
||||
@@ -147,6 +157,254 @@ function generateChartColors(count) {
|
||||
return { background, border };
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic markdown rendering (shared between notes pages).
|
||||
* Does NOT include [[wiki-link]] rendering — callers that need it
|
||||
* should post-process the returned HTML.
|
||||
* @param {string} text - Raw markdown text
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
// escapeHtml is only guaranteed as window.escapeHtml (see xss-protection.js / ui.js).
|
||||
// Resolve it at CALL time, not module-load time: formatting.js is loaded (defer)
|
||||
// before ui.js/xss-protection.js, so a module-load capture would always bind the
|
||||
// fallback. The local fallback covers the same characters and is used only if the
|
||||
// global isn't present.
|
||||
function _escapeHtml(str) {
|
||||
if (typeof window !== 'undefined' && typeof window.escapeHtml === 'function') {
|
||||
return window.escapeHtml(str);
|
||||
}
|
||||
return String(str == null ? '' : str).replace(/[&<>"']/g, (m) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[m]));
|
||||
}
|
||||
|
||||
// Validate a markdown-link URL for safe rendering. A literal scheme blocklist is
|
||||
// bypassable via embedded tab/newline/control chars (browsers strip them before
|
||||
// resolving the scheme), so route through the URL parser which normalizes them.
|
||||
function _isSafeLinkUrl(url) {
|
||||
if (typeof URLValidator !== 'undefined' && URLValidator.isSafeUrl) {
|
||||
return URLValidator.isSafeUrl(url, { allowMailto: true });
|
||||
}
|
||||
// Fallback: strip C0 controls + space, then let the URL parser decide the scheme.
|
||||
// eslint-disable-next-line no-control-regex -- stripping control chars is the point
|
||||
const cleaned = String(url == null ? '' : url).replace(/[\u0000-\u0020]/g, '');
|
||||
if (cleaned.startsWith('#')) return true;
|
||||
try {
|
||||
const parsed = new URL(cleaned, window.location.href);
|
||||
return ['http:', 'https:', 'ftp:', 'ftps:', 'mailto:'].includes(parsed.protocol);
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Bold / italic / strikethrough passes, shared between the main body and
|
||||
// markdown-link labels (labels are emphasized separately because the link
|
||||
// construct as a whole is tokenized before the body-wide emphasis pass).
|
||||
// Input MUST already be HTML-escaped.
|
||||
function _applyEmphasis(escaped) {
|
||||
return escaped
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||
}
|
||||
|
||||
function basicMarkdownRender(text) {
|
||||
if (!text) return '<p style="color: var(--text-secondary); font-style: italic;">Nothing to preview</p>';
|
||||
|
||||
// Escape HTML first
|
||||
let html = _escapeHtml(text);
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||
|
||||
// Link constructs are extracted into placeholder tokens BEFORE the
|
||||
// emphasis passes, so underscores/asterisks inside URLs or [[wiki-link]]
|
||||
// targets are not rewritten into <em>/<strong> (which would corrupt
|
||||
// hrefs and break the callers' wiki-link text-node post-processing).
|
||||
// The token delimiters contain '<', which cannot appear literally in the
|
||||
// escaped input, so note content cannot forge a token.
|
||||
const tokens = [];
|
||||
const stash = (s) => `<!--LDRTOK${tokens.push(s) - 1}-->`;
|
||||
|
||||
// Inline code has the highest inline precedence: its content is literal.
|
||||
// Stash it FIRST — before links, wiki-links and the emphasis pass — so a
|
||||
// `snake_case` / `a*b*c` span is not chopped into <em>/<strong> and a
|
||||
// `[label](url)` inside backticks does not become a live anchor. The
|
||||
// captured text is already HTML-escaped by the escape-first pass above.
|
||||
html = html.replace(/`([^`]+)`/g, (_m, code) =>
|
||||
stash(`<code style="background: var(--bg-tertiary); padding: 0.15rem 0.3rem; border-radius: 4px;">${code}</code>`));
|
||||
|
||||
// [[wiki-links]] — kept verbatim for callers to post-process
|
||||
// (same target pattern as note-detail.js processWikiLinks).
|
||||
html = html.replace(new RegExp('\\[\\[' + WIKILINK_TARGET + '\\]\\]', 'g'), (match) => stash(match));
|
||||
|
||||
// Links — only emit an anchor for URLs the URL parser confirms are safe.
|
||||
// (A literal scheme blocklist is bypassable via embedded control chars.)
|
||||
// Unsafe links stay as literal text; `match` is already entity-escaped
|
||||
// from the escape-first pass, so it renders inertly — re-escaping it
|
||||
// would display double-escaped entities (&amp;) to the user.
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
|
||||
const trimmed = url.trim();
|
||||
if (!_isSafeLinkUrl(trimmed)) {
|
||||
return stash(match);
|
||||
}
|
||||
return stash(`<a href="${trimmed}" target="_blank" rel="noopener noreferrer" style="color: var(--accent-primary);">${_applyEmphasis(label)}</a>`);
|
||||
});
|
||||
|
||||
// Bold, italic and strikethrough (code spans are already stashed above,
|
||||
// so their literal contents are protected from this pass).
|
||||
html = _applyEmphasis(html);
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.+)$/gm, '<blockquote style="border-left: 3px solid var(--accent-primary); padding-left: 1rem; margin: 0.5rem 0; color: var(--text-secondary);">$1</blockquote>');
|
||||
|
||||
// Lists (simple)
|
||||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
|
||||
|
||||
// Paragraphs (double newlines)
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = '<p>' + html + '</p>';
|
||||
|
||||
// Single newlines to <br>
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
// Restore the stashed link constructs. Tokens are unforgeable (a literal
|
||||
// '<' in note content is escaped above), so every index is valid. A stashed
|
||||
// value may itself contain an earlier token (a [[wiki-link]] inside a link
|
||||
// label), and String.replace does not rescan replacements — so repeat until
|
||||
// stable. Values only reference earlier-created tokens, never themselves,
|
||||
// so this terminates.
|
||||
let beforeRestore;
|
||||
do {
|
||||
beforeRestore = html;
|
||||
html = html.replace(/<!--LDRTOK(\d+)-->/g, (_m, i) => tokens[i]);
|
||||
} while (html !== beforeRestore);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply markdown formatting to selected text in a textarea (shared between notes pages).
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
* @param {string} format - format key (bold, italic, etc.)
|
||||
* @param {Object} [extraFormats] - additional format entries to merge
|
||||
*/
|
||||
function applyMarkdownFormat(textarea, format, extraFormats) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const selected = text.substring(start, end);
|
||||
|
||||
const formats = {
|
||||
bold: { before: '**', after: '**', placeholder: 'bold text' },
|
||||
italic: { before: '_', after: '_', placeholder: 'italic text' },
|
||||
strikethrough: { before: '~~', after: '~~', placeholder: 'strikethrough' },
|
||||
heading: { before: '## ', after: '', placeholder: 'Heading', lineStart: true },
|
||||
quote: { before: '> ', after: '', placeholder: 'quote', lineStart: true },
|
||||
code: { before: '`', after: '`', placeholder: 'code' },
|
||||
bullet: { before: '- ', after: '', placeholder: 'list item', lineStart: true },
|
||||
numbered: { before: '1. ', after: '', placeholder: 'list item', lineStart: true },
|
||||
link: { before: '[', after: '](url)', placeholder: 'link text' },
|
||||
// Wiki-style note link. Lives in the base map (not just the
|
||||
// note-detail extraFormats) because the notes editor's toolbar
|
||||
// dispatches formats straight through window.formatting without
|
||||
// passing extraFormats — so a "notelink" button only resolves if
|
||||
// the format is known here. Mirrors note-detail.js's extraFormats.
|
||||
notelink: { before: '[[', after: ']]', placeholder: 'Note Title' },
|
||||
};
|
||||
|
||||
// Merge any extra formats (e.g. notelink for the detail page)
|
||||
if (extraFormats) {
|
||||
Object.assign(formats, extraFormats);
|
||||
}
|
||||
|
||||
const fmt = formats[format];
|
||||
if (!fmt) return;
|
||||
|
||||
let newText, newCursorStart, newCursorEnd;
|
||||
const content = selected || fmt.placeholder;
|
||||
|
||||
if (fmt.lineStart) {
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||
newText = text.slice(0, lineStart) + fmt.before + text.slice(lineStart);
|
||||
newCursorStart = start + fmt.before.length;
|
||||
newCursorEnd = end + fmt.before.length;
|
||||
} else {
|
||||
newText = text.slice(0, start) + fmt.before + content + fmt.after + text.slice(end);
|
||||
if (selected) {
|
||||
newCursorStart = start + fmt.before.length;
|
||||
newCursorEnd = start + fmt.before.length + selected.length;
|
||||
} else {
|
||||
newCursorStart = start + fmt.before.length;
|
||||
newCursorEnd = start + fmt.before.length + fmt.placeholder.length;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.value = newText;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(newCursorStart, newCursorEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce markdown source to plain text for list/card previews.
|
||||
*
|
||||
* Single home for the note-preview strip shared by pages/notes.js and the
|
||||
* research/document notes components (previously three near-identical
|
||||
* copies that drifted — notes.js carried the superset, the components a
|
||||
* subset missing fenced-code / horizontal-rule / strikethrough handling).
|
||||
* The output is always assigned via textContent / escapeHtml'd and
|
||||
* rendered as TEXT, never HTML, so an imperfect strip degrades to stray
|
||||
* punctuation, never markup — the regex is intentionally lightweight.
|
||||
*
|
||||
* Exposed as a window.formatting METHOD, not a bare global function: the
|
||||
* callers keep their page-local wrapper names to avoid the deferred
|
||||
* shared-script global-shadowing bug; only the duplicated body is shared.
|
||||
*/
|
||||
function stripMarkdownToText(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
// Fenced-code markers (content inside is kept as plain text).
|
||||
.replace(/```[^\n]*/g, ' ')
|
||||
// Images before links:  → alt
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
// Links: [text](url) → text
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
// Wiki links: [[Target|alias]] → alias, [[Target]] → Target
|
||||
.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (m, target, alias) => alias || target)
|
||||
// ATX headings + blockquote markers at line starts.
|
||||
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
||||
.replace(/^\s{0,3}>\s?/gm, '')
|
||||
// List bullets / ordered-list markers at line starts.
|
||||
.replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
|
||||
// Horizontal rules on their own line.
|
||||
.replace(/^\s*(?:[-*_]\s*){3,}$/gm, ' ')
|
||||
// Emphasis / strikethrough / inline-code markers (keep the text).
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/`([^`]*)`/g, '$1')
|
||||
// Collapse the leftover whitespace/newlines into single spaces.
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the "% match" semantic-similarity badge shared by the notes list,
|
||||
* unified search, and the note-detail similar-notes/passages panels.
|
||||
* Returns '' for a null/undefined similarity so callers can drop it in
|
||||
* unconditionally. Previously inlined at 5 sites that had already drifted
|
||||
* (3 were missing the title tooltip the other 2 carried).
|
||||
*/
|
||||
function similarityBadge(similarity) {
|
||||
if (similarity === undefined || similarity === null) return '';
|
||||
return `<span class="ldr-similarity-badge" title="Semantic similarity">${Math.round(similarity * 100)}% match</span>`;
|
||||
}
|
||||
|
||||
// Export the functions to make them available to other modules
|
||||
window.formatting = {
|
||||
formatStatus,
|
||||
@@ -156,5 +414,10 @@ window.formatting = {
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
generateChartColors,
|
||||
capitalizeFirstLetter
|
||||
capitalizeFirstLetter,
|
||||
basicMarkdownRender,
|
||||
applyMarkdownFormat,
|
||||
stripMarkdownToText,
|
||||
similarityBadge,
|
||||
WIKILINK_TARGET
|
||||
};
|
||||
|
||||
@@ -284,7 +284,11 @@ function renderMarkdown(markdown) {
|
||||
gfm: true,
|
||||
headerIds: true,
|
||||
smartLists: true,
|
||||
smartypants: true,
|
||||
// smartypants:false — converting `--` to en/em-dashes
|
||||
// mangles code blocks that contain CLI flags (e.g.
|
||||
// `--verbose`) or Python `**kwargs`. Match app.js so notes
|
||||
// rendering preserves verbatim code for copy-paste.
|
||||
smartypants: false,
|
||||
highlight(code, language) {
|
||||
// Use Prism for syntax highlighting if available
|
||||
if (typeof Prism !== 'undefined' && Prism.languages[language]) {
|
||||
|
||||
@@ -265,6 +265,15 @@
|
||||
|
||||
<!-- Security Utilities -->
|
||||
<script defer src="/static/js/security/xss-protection.js"></script>
|
||||
<!-- url-validator + safe-fetch are core shared deps used BY page/component
|
||||
scripts (e.g. safeFetchWithAuth in the annotation components), so they
|
||||
must load before the component_scripts/page_scripts blocks. Deferred
|
||||
scripts run in document order at readyState 'interactive' (never
|
||||
'loading'), and the annotation components call init() synchronously —
|
||||
if safe-fetch.js loaded after them, safeFetchWithAuth would be
|
||||
undefined and annotations/notes would fail to load on first render. -->
|
||||
<script defer src="/static/js/security/url-validator.js"></script>
|
||||
<script defer src="/static/js/security/safe-fetch.js"></script>
|
||||
|
||||
<!-- Form Utilities -->
|
||||
<script defer src="/static/js/utils/form-validation.js"></script>
|
||||
@@ -302,9 +311,8 @@
|
||||
|
||||
<script defer src="/static/js/components/settings_sync.js"></script>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<script defer src="/static/js/security/url-validator.js"></script>
|
||||
<script defer src="/static/js/security/safe-fetch.js"></script>
|
||||
<!-- Mobile Navigation (url-validator + safe-fetch moved up to load before
|
||||
the page/component script blocks) -->
|
||||
<script defer src="/static/js/mobile-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -50,6 +50,18 @@
|
||||
<span class="ldr-nav-text">Downloads</span>
|
||||
</a>
|
||||
</li>
|
||||
<li {% if active_page == 'notes' %}class="active ldr-sidebar-item--nested"{% else %}class="ldr-sidebar-item--nested"{% endif %} data-page="notes">
|
||||
<a href="{{ url_for('notes.notes_page') }}" title="Notes" aria-label="Notes">
|
||||
<i aria-hidden="true" class="fas fa-sticky-note"></i>
|
||||
<span class="ldr-nav-text">Notes</span>
|
||||
</a>
|
||||
</li>
|
||||
<li {% if active_page == 'unified-search' %}class="active ldr-sidebar-item--nested"{% else %}class="ldr-sidebar-item--nested"{% endif %} data-page="unified-search">
|
||||
<a href="{{ url_for('unified_search.unified_search_page') }}" title="Search" aria-label="Search">
|
||||
<i aria-hidden="true" class="fas fa-search"></i>
|
||||
<span class="ldr-nav-text">Search</span>
|
||||
</a>
|
||||
</li>
|
||||
<li {% if active_page == 'zotero' %}class="active ldr-sidebar-item--nested"{% else %}class="ldr-sidebar-item--nested"{% endif %} data-page="zotero">
|
||||
<a href="{{ url_for('zotero.zotero_page') }}" title="Zotero" aria-label="Zotero">
|
||||
<i aria-hidden="true" class="fas fa-book"></i>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<button id="reindex-collection-btn" class="ldr-btn-collections ldr-btn-collections-warning ldr-btn-lg" title="Re-process all documents. Use after changing embedding settings.">
|
||||
<i aria-hidden="true" class="fas fa-redo"></i> Re-Index All Documents
|
||||
</button>
|
||||
<button id="delete-collection-btn" class="ldr-btn-collections ldr-btn-collections-danger ldr-btn-lg" title="Delete this collection. Documents remain in library but embeddings are removed.">
|
||||
<button id="delete-collection-btn" class="ldr-btn-collections ldr-btn-collections-danger ldr-btn-lg" title="Delete this collection and its embeddings. Documents also in other collections are kept; documents only in this collection are deleted with it.">
|
||||
<i aria-hidden="true" class="fas fa-trash"></i> Delete Collection
|
||||
</button>
|
||||
</div>
|
||||
@@ -196,6 +196,15 @@
|
||||
<p>No documents in this collection yet. Use the Upload Files button above to add documents.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes List (hidden unless the collection contains notes) -->
|
||||
<div class="ldr-rag-section" id="notes-section" style="display: none;">
|
||||
<div class="ldr-section-header">
|
||||
<h2><i aria-hidden="true" class="fas fa-sticky-note"></i> Notes</h2>
|
||||
</div>
|
||||
|
||||
<div id="notes-list" class="ldr-documents-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
|
||||
@@ -270,6 +270,29 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Notes referencing this document (components/document_notes.js) -->
|
||||
<div class="ldr-metadata-section" id="document-notes-section" data-document-id="{{ document.id }}">
|
||||
<div class="ldr-research-notes-header">
|
||||
<h2 class="ldr-research-notes-heading"><i aria-hidden="true" class="fas fa-sticky-note"></i> Notes</h2>
|
||||
<div class="ldr-research-notes-actions">
|
||||
<button class="btn ldr-btn-outline btn-sm" id="document-add-note-btn" title="Start a note linked to this document — your comments, caveats, and follow-up ideas">
|
||||
<i aria-hidden="true" class="fas fa-plus"></i> Add note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="ldr-research-notes-hint">Tip: open the full text view to comment on specific passages.</p>
|
||||
<div id="document-notes-list" class="ldr-research-notes-list"></div>
|
||||
<p class="ldr-research-notes-empty" id="document-notes-empty" style="display:none">No notes on this document yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_scripts %}
|
||||
<!-- Must load after base.html's shared deferred scripts (safe-fetch.js):
|
||||
deferred scripts execute in document order, and document_notes.js
|
||||
calls safeFetchWithAuth during its synchronous init. The annotation
|
||||
surface is not loaded here — this page has no #ldr-document-text-content. -->
|
||||
<script defer src="/static/js/components/notes_shared.js"></script>
|
||||
<script defer src="/static/js/components/document_notes.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,6 +33,16 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="ldr-text-content">{{ text_content }}</div>
|
||||
<p class="ldr-research-notes-hint">Tip: select text to comment on it (highlighted, Word-review style).</p>
|
||||
<div class="ldr-text-content" id="ldr-document-text-content" data-document-id="{{ document_id }}">{{ text_content }}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_scripts %}
|
||||
<!-- Must load after base.html's shared deferred scripts (safe-fetch.js):
|
||||
deferred scripts execute in document order, and both components call
|
||||
safeFetchWithAuth during their synchronous init. -->
|
||||
<script defer src="/static/js/components/notes_shared.js"></script>
|
||||
<script defer src="/static/js/components/annotation_surface.js"></script>
|
||||
<script defer src="/static/js/components/document_notes.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% set active_page = 'notes' %}
|
||||
|
||||
{% block title %}Notes - Deep Research System{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/notes.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="ldr-page active" id="notes">
|
||||
<div class="ldr-notes-page-header">
|
||||
<h1><i class="fas fa-sticky-note"></i> Notes</h1>
|
||||
<div class="ldr-notes-page-actions">
|
||||
<button class="ldr-select-mode-btn" id="ldr-select-mode-btn" data-action="toggle-select-mode">
|
||||
<i class="fas fa-check-square"></i> <span id="ldr-select-mode-label">Select</span>
|
||||
</button>
|
||||
<button class="ldr-create-note-btn" data-action="create-new-note">
|
||||
<i class="fas fa-plus"></i> New Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ask your notes — starts a research run pinned to the Notes collection -->
|
||||
<div class="ldr-notes-ask">
|
||||
<i class="fas fa-comments" aria-hidden="true"></i>
|
||||
<input type="text" id="ldr-notes-ask-input" placeholder="Ask your notes a question..."
|
||||
aria-label="Ask your notes a question">
|
||||
<button class="ldr-notes-ask-btn" id="ldr-notes-ask-btn" data-action="ask-notes">
|
||||
<i class="fas fa-paper-plane" aria-hidden="true"></i> Ask
|
||||
</button>
|
||||
</div>
|
||||
<div id="ldr-notes-ask-notice" class="ldr-notes-search-notice" role="status" aria-live="polite" style="display: none;"></div>
|
||||
|
||||
<!-- Select Mode Action Bar -->
|
||||
<div id="ldr-select-actions" class="ldr-select-actions" style="display: none;">
|
||||
<span id="ldr-select-count">0 selected</span>
|
||||
<div class="ldr-select-actions-buttons">
|
||||
<button class="btn btn-secondary btn-sm" data-action="clear-selection">Clear</button>
|
||||
<button class="btn btn-primary btn-sm" id="ldr-synthesize-btn" data-action="open-synthesize-modal" disabled>
|
||||
<i class="fas fa-wand-magic-sparkles"></i> Synthesize
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="ldr-notes-filters">
|
||||
<div class="ldr-notes-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="ldr-notes-search" placeholder="AI Hybrid: keywords + meaning..."
|
||||
aria-label="Search notes">
|
||||
</div>
|
||||
<!-- Search mode selector — mirrors the library/history/news pages
|
||||
(Text / AI Hybrid / AI only) and shares the SemanticSearch merge. -->
|
||||
<div class="dropdown ldr-notes-search-mode">
|
||||
<button class="btn btn-sm ldr-btn-outline dropdown-toggle" type="button"
|
||||
id="notes-search-mode-btn" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
aria-label="Search mode">
|
||||
<i class="fas fa-brain" aria-hidden="true"></i>
|
||||
<span id="notes-search-mode-label">AI Hybrid</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-label="Search mode" id="notes-search-mode-menu">
|
||||
<li><a class="dropdown-item active" href="#" data-mode="hybrid">
|
||||
<i class="fas fa-brain" aria-hidden="true"></i> AI Hybrid
|
||||
<small class="text-muted">— keywords + meaning</small>
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-mode="text">
|
||||
<i class="fas fa-font" aria-hidden="true"></i> Text Only
|
||||
<small class="text-muted">— keyword match</small>
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-mode="semantic">
|
||||
<i class="fas fa-brain" aria-hidden="true"></i> AI Only
|
||||
<small class="text-muted">— search by meaning</small>
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<select id="ldr-collection-filter" class="ldr-collection-filter-select" aria-label="Filter by collection">
|
||||
<option value="">All Collections</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search notice (e.g. AI search unavailable → keyword-only) -->
|
||||
<div id="ldr-notes-search-notice" class="ldr-notes-search-notice" role="status" aria-live="polite" style="display: none;"></div>
|
||||
|
||||
<!-- Notes Grid -->
|
||||
<div class="ldr-notes-grid" id="ldr-notes-grid" role="status" aria-live="polite">
|
||||
<div class="ldr-loading-spinner ldr-centered">
|
||||
<div class="ldr-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load-more pager for the keyword listing (the list endpoint pages
|
||||
at 100 notes; without this, notes past the first page were
|
||||
unreachable from the UI) -->
|
||||
<div id="ldr-notes-load-more" class="ldr-notes-load-more" style="display: none;">
|
||||
<button class="btn ldr-btn-outline" data-action="load-more-notes">
|
||||
<i aria-hidden="true" class="fas fa-chevron-down"></i>
|
||||
<span id="ldr-notes-load-more-label">Load more</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="ldr-notes-empty" class="ldr-notes-empty" role="status" aria-live="polite" style="display: none;">
|
||||
<i class="fas fa-sticky-note ldr-notes-empty-icon"></i>
|
||||
<h3>No notes yet</h3>
|
||||
<p>Start capturing your ideas and research notes</p>
|
||||
<button class="ldr-create-note-btn" data-action="create-new-note">
|
||||
<i class="fas fa-plus"></i> Create your first note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Note Modal -->
|
||||
<div class="modal fade" id="noteModal" tabindex="-1" aria-labelledby="noteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content" style="background: var(--bg-secondary); border: 1px solid var(--border-color);">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--border-color);">
|
||||
<h5 class="modal-title" id="noteModalLabel">
|
||||
<i class="fas fa-plus-circle" style="color: var(--accent-primary); margin-right: 0.5rem;"></i>
|
||||
New Note
|
||||
</h5>
|
||||
<button type="button" class="btn-close ldr-btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{# div, not <form>: the save button lives in the modal footer
|
||||
(outside this element) and saves via fetch. A real <form>
|
||||
makes Enter in the title input trigger implicit GET
|
||||
submission — a page navigation that discards the draft. #}
|
||||
<div id="note-form">
|
||||
<div class="mb-3">
|
||||
<label for="note-title" class="form-label">Title</label>
|
||||
<input type="text" class="form-control" id="note-title" placeholder="Give your note a title..." required
|
||||
style="background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-primary);">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="note-content" class="form-label">Content</label>
|
||||
<div class="ldr-markdown-editor-container" style="border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden;">
|
||||
<!-- Mode Toggle -->
|
||||
<div class="ldr-editor-tabs" style="display: flex; background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color);">
|
||||
<button type="button" class="ldr-editor-tab active" data-mode="write" style="padding: 0.5rem 1rem; background: transparent; border: none; border-bottom: 2px solid var(--accent-primary); color: var(--accent-primary); cursor: pointer; font-weight: 500; font-size: 0.85rem;">
|
||||
<i class="fas fa-pencil-alt"></i> Write
|
||||
</button>
|
||||
<button type="button" class="ldr-editor-tab" data-mode="preview" style="padding: 0.5rem 1rem; background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; font-weight: 500; font-size: 0.85rem;">
|
||||
<i class="fas fa-eye"></i> Preview
|
||||
</button>
|
||||
</div>
|
||||
<!-- Formatting Toolbar -->
|
||||
<div class="ldr-markdown-toolbar" id="modal-markdown-toolbar" style="display: flex; align-items: center; gap: 0.25rem; padding: 0.5rem 0.75rem; background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); flex-wrap: wrap;">
|
||||
<div class="ldr-toolbar-group" style="display: flex; gap: 0.125rem;">
|
||||
<button type="button" class="ldr-toolbar-btn" title="Bold (Ctrl+B)" data-format="bold" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-bold"></i></button>
|
||||
<button type="button" class="ldr-toolbar-btn" title="Italic (Ctrl+I)" data-format="italic" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-italic"></i></button>
|
||||
<button type="button" class="ldr-toolbar-btn" title="Strikethrough" data-format="strikethrough" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-strikethrough"></i></button>
|
||||
</div>
|
||||
<div style="width: 1px; height: 16px; background: var(--border-color); margin: 0 0.4rem;"></div>
|
||||
<div class="ldr-toolbar-group" style="display: flex; gap: 0.125rem;">
|
||||
<button type="button" class="ldr-toolbar-btn" title="Heading" data-format="heading" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-heading"></i></button>
|
||||
<button type="button" class="ldr-toolbar-btn" title="Quote" data-format="quote" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-quote-left"></i></button>
|
||||
<button type="button" class="ldr-toolbar-btn" title="Code" data-format="code" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-code"></i></button>
|
||||
</div>
|
||||
<div style="width: 1px; height: 16px; background: var(--border-color); margin: 0 0.4rem;"></div>
|
||||
<div class="ldr-toolbar-group" style="display: flex; gap: 0.125rem;">
|
||||
<button type="button" class="ldr-toolbar-btn" title="Bullet List" data-format="bullet" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-list"></i></button>
|
||||
<button type="button" class="ldr-toolbar-btn" title="Numbered List" data-format="numbered" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-list-ol"></i></button>
|
||||
</div>
|
||||
<div style="width: 1px; height: 16px; background: var(--border-color); margin: 0 0.4rem;"></div>
|
||||
<div class="ldr-toolbar-group" style="display: flex; gap: 0.125rem;">
|
||||
<button type="button" class="ldr-toolbar-btn" title="Link" data-format="link" style="width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: 4px; color: var(--text-secondary); cursor: pointer;"><i class="fas fa-link"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Textarea -->
|
||||
<textarea class="form-control" id="note-content" rows="10" placeholder="Write your note here... Markdown is supported!" required
|
||||
style="background: var(--bg-tertiary); border: none; border-radius: 0; color: var(--text-primary); font-family: 'Monaco', 'Menlo', monospace; line-height: 1.6;"></textarea>
|
||||
<!-- Preview -->
|
||||
<div id="modal-note-preview" class="ldr-markdown-content" style="display: none; min-height: 240px; padding: 1rem; background: var(--bg-tertiary); color: var(--text-primary); line-height: 1.6;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ldr-note-tags" class="form-label">Tags <span class="text-muted">(comma separated)</span></label>
|
||||
<input type="text" class="form-control" id="ldr-note-tags" placeholder="research, ideas, todo"
|
||||
style="background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-primary);">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid var(--border-color);">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="save-note-btn" data-action="save-note">
|
||||
<i class="fas fa-save"></i> Save Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Synthesize Notes Modal -->
|
||||
<div class="modal fade" id="synthesizeModal" tabindex="-1" aria-labelledby="synthesizeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content" style="background: var(--bg-secondary); border: 1px solid var(--border-color);">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--border-color);">
|
||||
<h5 class="modal-title" id="synthesizeModalLabel">
|
||||
<i class="fas fa-wand-magic-sparkles" style="color: var(--accent-primary); margin-right: 0.5rem;"></i>
|
||||
Synthesize Notes
|
||||
</h5>
|
||||
<button type="button" class="btn-close ldr-btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="synthesis-type" class="form-label">Type</label>
|
||||
<select class="form-select" id="synthesis-type"
|
||||
style="background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-primary);">
|
||||
<option value="merge">Merge (combine into one coherent note)</option>
|
||||
<option value="summarize">Summarize (condensed overview)</option>
|
||||
<option value="compare">Compare (similarities and differences)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Selected Notes</label>
|
||||
<div id="synthesis-selected-list" class="ldr-synthesis-selected-list"></div>
|
||||
</div>
|
||||
<div id="synthesis-preview" class="ldr-synthesis-preview" style="display: none;">
|
||||
<h6 id="synthesis-preview-title">Preview</h6>
|
||||
<div id="synthesis-preview-content" class="ldr-markdown-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid var(--border-color);">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="synthesis-preview-btn" data-action="preview-synthesis">
|
||||
<i class="fas fa-eye"></i> Preview
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="synthesis-create-btn" data-action="create-synthesized-note">
|
||||
<i class="fas fa-plus"></i> Create Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_scripts %}
|
||||
<!-- Note: url-validator.js, ui.js, urls.js, formatting.js, api.js are already loaded in base.html -->
|
||||
<!-- Shared semantic-search merge/render helpers (window.SemanticSearch),
|
||||
reused by the library, history and news pages for hybrid search. -->
|
||||
<script defer src="/static/js/components/notes_shared.js"></script>
|
||||
<script defer src="/static/js/components/semantic_search.js"></script>
|
||||
<script defer src="/static/js/pages/notes.js"></script>
|
||||
{% endblock %}
|
||||
@@ -28,6 +28,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn ldr-btn-outline" id="save-to-collection-btn" title="Save this research to a collection for future reference"><i aria-hidden="true" class="fas fa-folder-plus"></i> Save to Collection</button>
|
||||
<button class="btn ldr-btn-outline" id="research-save-as-note-top-btn" title="Copy this report into a new editable note, linked to this research (the report itself stays unchanged)"><i aria-hidden="true" class="fas fa-sticky-note"></i> Save as Note</button>
|
||||
<button class="btn ldr-btn-outline" id="report-issue-btn" title="Report bugs, suggest features, or get help from the community"><i aria-hidden="true" class="fas fa-hands-helping"></i> Help Improve</button>
|
||||
<button class="btn ldr-btn-outline" id="back-to-history"><i aria-hidden="true" class="fas fa-arrow-left"></i> Back to History</button>
|
||||
</div>
|
||||
@@ -107,6 +108,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes on this research (reverse direction of the notes feature's
|
||||
research linking; populated by components/research_notes.js) -->
|
||||
<div class="ldr-card" id="research-notes-section">
|
||||
<div class="ldr-card-content">
|
||||
<div class="ldr-research-notes-header">
|
||||
<h3 class="ldr-research-notes-heading"><i aria-hidden="true" class="fas fa-sticky-note"></i> Notes</h3>
|
||||
<div class="ldr-research-notes-actions">
|
||||
<button class="btn ldr-btn-outline btn-sm" id="research-add-note-btn" title="Start a note linked to this research — your comments, caveats, and follow-up ideas">
|
||||
<i aria-hidden="true" class="fas fa-plus"></i> Add note
|
||||
</button>
|
||||
<button class="btn ldr-btn-outline btn-sm" id="research-save-as-note-btn" title="Copy this report into a new editable note (the report itself stays unchanged)">
|
||||
<i aria-hidden="true" class="fas fa-copy"></i> Save report as note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="ldr-research-notes-hint">Tip: select text in the report to comment on it (highlighted, Word-review style) or clip it into a quote note.</p>
|
||||
<div id="research-notes-list" class="ldr-research-notes-list"></div>
|
||||
<p class="ldr-research-notes-empty" id="research-notes-empty" style="display:none">No notes on this research yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "components/log_panel.html" %}
|
||||
|
||||
<!-- Help Improve Modal -->
|
||||
@@ -196,4 +218,8 @@
|
||||
<script defer src="/static/js/components/report_issue.js"></script>
|
||||
<!-- Save to Collection component -->
|
||||
<script defer src="/static/js/components/save_to_collection.js"></script>
|
||||
|
||||
<script defer src="/static/js/components/notes_shared.js"></script>
|
||||
<script defer src="/static/js/components/annotation_surface.js"></script>
|
||||
<script defer src="/static/js/components/research_notes.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% set active_page = 'unified-search' %}
|
||||
|
||||
{% block title %}Search - Deep Research System{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{# Shared search chrome (input, mode selector, notice, empty state) —
|
||||
the notes page defines these generic ldr-notes-* search styles. #}
|
||||
<link rel="stylesheet" href="/static/css/notes.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="ldr-page active" id="unified-search">
|
||||
<div class="ldr-notes-page-header">
|
||||
<h1><i class="fas fa-search"></i> Search</h1>
|
||||
</div>
|
||||
|
||||
<!-- Search input + mode selector (same chrome as the notes page) -->
|
||||
<div class="ldr-notes-filters">
|
||||
<div class="ldr-notes-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<!-- maxlength mirrors the server's MAX_SEARCH_LEN (512): the
|
||||
endpoints 400 past it, so stop the overflow at the input -->
|
||||
<input type="text" id="ldr-unified-search-input" placeholder="AI Hybrid: keywords + meaning..."
|
||||
maxlength="512" aria-label="Search everything">
|
||||
</div>
|
||||
<!-- Search mode selector — mirrors the notes/library/history/news
|
||||
pages (Text / AI Hybrid / AI only). Items are rendered by the
|
||||
page JS from its SEARCH_MODE_REGISTRY, so a future mode is one
|
||||
registry entry with no template change. -->
|
||||
<div class="dropdown ldr-notes-search-mode">
|
||||
<button class="btn btn-sm ldr-btn-outline dropdown-toggle" type="button"
|
||||
id="unified-search-mode-btn" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
aria-label="Search mode">
|
||||
<i class="fas fa-brain" aria-hidden="true"></i>
|
||||
<span id="unified-search-mode-label">AI Hybrid</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-label="Search mode" id="unified-search-mode-menu">
|
||||
<!-- Populated from SEARCH_MODE_REGISTRY by unified_search.js -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline search notice (e.g. one hybrid leg unavailable) -->
|
||||
<div id="ldr-unified-search-notice" class="ldr-notes-search-notice" role="status" aria-live="polite" style="display: none;"></div>
|
||||
|
||||
<!-- Results. Deliberately NOT aria-live: announcing the whole 20-40
|
||||
card list on every debounced re-render buries screen-reader users;
|
||||
the notice/empty regions above/below carry the status updates. -->
|
||||
<div class="ldr-unified-search-results" id="ldr-unified-search-results"></div>
|
||||
|
||||
<!-- Empty / idle / error state -->
|
||||
<div id="ldr-unified-search-empty" class="ldr-notes-empty" role="status" aria-live="polite">
|
||||
<i class="fas fa-search ldr-notes-empty-icon"></i>
|
||||
<h3>Search everything</h3>
|
||||
<p>Find notes, library documents, and research reports in one place</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_scripts %}
|
||||
<!-- Note: url-validator.js, ui.js, urls.js, formatting.js, api.js are already loaded in base.html -->
|
||||
<!-- Shared semantic-search merge helpers (window.SemanticSearch),
|
||||
reused by the notes/library/history/news pages for hybrid search. -->
|
||||
<script defer src="/static/js/components/semantic_search.js"></script>
|
||||
<script defer src="/static/js/pages/unified_search.js"></script>
|
||||
{% endblock %}
|
||||
@@ -216,16 +216,49 @@ class LocalEmbeddingManager:
|
||||
start_char = metadata.get("start_char", 0)
|
||||
end_char = metadata.get("end_char", len(chunk_text))
|
||||
|
||||
# Check if chunk already exists
|
||||
# Check if chunk already exists. Scoped to the collection
|
||||
# to mirror the schema's uix_chunk_collection UNIQUE —
|
||||
# the previous hash-only lookup reused a row (and its
|
||||
# embedding id) across collections, mis-attributing the
|
||||
# chunk's collection_name and sharing one embedding id
|
||||
# between different collections' FAISS indexes.
|
||||
existing_chunk = (
|
||||
session.query(DocumentChunk)
|
||||
.filter_by(chunk_hash=chunk_hash)
|
||||
.filter_by(
|
||||
chunk_hash=chunk_hash,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_chunk:
|
||||
# Update existing chunk
|
||||
existing_chunk.last_accessed = datetime.now(UTC)
|
||||
if (
|
||||
existing_chunk.source_id != source_id
|
||||
or existing_chunk.source_type != source_type
|
||||
):
|
||||
# Within a collection the schema dedups chunk
|
||||
# rows by hash, so identical text in two
|
||||
# documents shares one row + one vector. Track
|
||||
# ownership last-claimer-wins: the re-index
|
||||
# purge in library_rag_service treats the row's
|
||||
# source_id as the authoritative owner and only
|
||||
# deletes a shared vector when the re-indexed
|
||||
# document still owns it — re-pointing here is
|
||||
# what keeps another document's copy alive when
|
||||
# the original indexer edits its chunk away.
|
||||
existing_chunk.source_type = source_type
|
||||
existing_chunk.source_id = source_id
|
||||
existing_chunk.source_path = (
|
||||
str(source_path) if source_path else None
|
||||
)
|
||||
existing_chunk.document_title = document_title
|
||||
existing_chunk.document_metadata = metadata
|
||||
existing_chunk.chunk_index = idx
|
||||
existing_chunk.start_char = start_char
|
||||
existing_chunk.end_char = end_char
|
||||
existing_chunk.word_count = word_count
|
||||
chunk_ids.append(existing_chunk.embedding_id)
|
||||
logger.debug(
|
||||
f"Chunk already exists, reusing: {existing_chunk.embedding_id}"
|
||||
|
||||
@@ -18,6 +18,7 @@ from ...research_library.services.pdf_storage_manager import PDFStorageManager
|
||||
from ...database.session_context import get_user_db_session
|
||||
from ...config.thread_settings import get_setting_from_snapshot
|
||||
from ...config.paths import get_library_directory
|
||||
from ...utilities.type_utils import to_bool
|
||||
|
||||
|
||||
def _relevance_from_faiss_score(vector_store: Any, score: float) -> float:
|
||||
@@ -205,6 +206,15 @@ class CollectionSearchEngine(LibraryRAGSearchEngine):
|
||||
embedding_provider = rag_index.embedding_model_type.value
|
||||
chunk_size = rag_index.chunk_size or self.chunk_size
|
||||
chunk_overlap = rag_index.chunk_overlap or self.chunk_overlap
|
||||
# Thread the stored normalization flag through so the index is
|
||||
# queried with the same L2 normalization it was BUILT with.
|
||||
# Otherwise LibraryRAGService defaults normalize_vectors=True,
|
||||
# corrupting similarity rankings for collections indexed with
|
||||
# local_search_normalize_vectors=False. NULL → True mirrors the
|
||||
# to_bool(..., default=True) convention used elsewhere.
|
||||
normalize_vectors = to_bool(
|
||||
rag_index.normalize_vectors, default=True
|
||||
)
|
||||
|
||||
# Create RAG service with collection's embedding settings
|
||||
with LibraryRAGService(
|
||||
@@ -213,6 +223,7 @@ class CollectionSearchEngine(LibraryRAGSearchEngine):
|
||||
embedding_provider=embedding_provider,
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
normalize_vectors=normalize_vectors,
|
||||
) as rag_service:
|
||||
# Check if there are indexed documents
|
||||
stats = rag_service.get_rag_stats(self.collection_id)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Tests for note SQLAlchemy models.
|
||||
|
||||
Focuses on __repr__ safety when attributes are None (unsaved/pre-flush
|
||||
instances) and basic structural checks.
|
||||
"""
|
||||
|
||||
from local_deep_research.database.models.note import (
|
||||
NoteLink,
|
||||
NoteResearch,
|
||||
NoteSynthesis,
|
||||
NoteSynthesisSource,
|
||||
NoteVersion,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NoteVersion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoteVersionRepr:
|
||||
def test_repr_with_full_document_id(self):
|
||||
obj = NoteVersion(
|
||||
id="11111111-2222-3333-4444-555555555555",
|
||||
document_id="abcdef01-1234-5678-9abc-def012345678",
|
||||
title="t",
|
||||
content="c",
|
||||
change_type="manual_save",
|
||||
content_hash="a" * 64,
|
||||
)
|
||||
result = repr(obj)
|
||||
assert "abcdef01" in result
|
||||
assert "11111111" in result
|
||||
|
||||
def test_repr_with_none_document_id(self):
|
||||
"""Must not raise TypeError when document_id is None (unsaved instance)."""
|
||||
obj = NoteVersion(
|
||||
id="11111111-2222-3333-4444-555555555555",
|
||||
document_id=None,
|
||||
title="t",
|
||||
content="c",
|
||||
change_type="auto_save",
|
||||
content_hash="b" * 64,
|
||||
)
|
||||
result = repr(obj)
|
||||
assert "None" in result
|
||||
|
||||
def test_repr_with_short_document_id(self):
|
||||
"""document_id shorter than 8 chars should not raise."""
|
||||
obj = NoteVersion(
|
||||
id="11111111-2222-3333-4444-555555555555",
|
||||
document_id="abc",
|
||||
title="t",
|
||||
content="c",
|
||||
change_type="auto_save",
|
||||
content_hash="c" * 64,
|
||||
)
|
||||
result = repr(obj)
|
||||
assert "abc" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NoteLink
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoteLinkRepr:
|
||||
def test_repr_with_full_ids(self):
|
||||
obj = NoteLink(
|
||||
source_document_id="src-uuid-1234-5678-9abc-def012345678",
|
||||
target_document_id="tgt-uuid-abcd-ef01-2345-678901234567",
|
||||
link_text="[[My Note]]",
|
||||
)
|
||||
result = repr(obj)
|
||||
assert "src-uuid" in result
|
||||
assert "tgt-uuid" in result
|
||||
|
||||
def test_repr_with_none_source(self):
|
||||
"""Must not raise TypeError when source_document_id is None."""
|
||||
obj = NoteLink(
|
||||
source_document_id=None,
|
||||
target_document_id="tgt-uuid-abcd-ef01-2345-678901234567",
|
||||
link_text="[[My Note]]",
|
||||
)
|
||||
result = repr(obj)
|
||||
assert "None" in result
|
||||
|
||||
def test_repr_with_none_target(self):
|
||||
"""Must not raise TypeError when target_document_id is None."""
|
||||
obj = NoteLink(
|
||||
source_document_id="src-uuid-1234-5678-9abc-def012345678",
|
||||
target_document_id=None,
|
||||
link_text="[[My Note]]",
|
||||
)
|
||||
result = repr(obj)
|
||||
assert "None" in result
|
||||
|
||||
def test_repr_with_both_none(self):
|
||||
"""Must not raise TypeError when both IDs are None."""
|
||||
obj = NoteLink(
|
||||
source_document_id=None,
|
||||
target_document_id=None,
|
||||
link_text="[[My Note]]",
|
||||
)
|
||||
result = repr(obj)
|
||||
assert result.count("None") == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Other models — smoke tests for __repr__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOtherNoteModelReprs:
|
||||
def test_note_research_repr(self):
|
||||
obj = NoteResearch(document_id="doc-id", research_id="res-id")
|
||||
result = repr(obj)
|
||||
assert "NoteResearch" in result
|
||||
|
||||
def test_note_synthesis_repr(self):
|
||||
obj = NoteSynthesis(
|
||||
synthesis_type="merge",
|
||||
)
|
||||
obj.sources = [
|
||||
NoteSynthesisSource(source_document_id="a"),
|
||||
NoteSynthesisSource(source_document_id="b"),
|
||||
]
|
||||
result = repr(obj)
|
||||
assert "merge" in result
|
||||
assert "2" in result
|
||||
|
||||
def test_note_synthesis_repr_no_sources(self):
|
||||
"""Empty sources list must render without raising."""
|
||||
obj = NoteSynthesis(
|
||||
synthesis_type="compare",
|
||||
)
|
||||
result = repr(obj)
|
||||
assert "compare" in result
|
||||
@@ -685,6 +685,12 @@ class TestMigrationChain:
|
||||
"0014_benchmark_run_version_and_snapshot.py",
|
||||
"0015_drop_document_notes.py",
|
||||
"0016_drop_orphaned_cache_tables.py",
|
||||
"0017_download_queue_research_status_index.py",
|
||||
"0018_remove_mcp_strategy.py",
|
||||
"0019_retire_both_egress_scope.py",
|
||||
"0020_add_zotero_tables.py",
|
||||
"0021_add_note_tables.py",
|
||||
"0022_add_note_references.py",
|
||||
]
|
||||
|
||||
for filename in expected_files:
|
||||
@@ -711,7 +717,22 @@ class TestMigrationChain:
|
||||
# Head should be the latest migration
|
||||
assert script.get_current_head() == get_head_revision()
|
||||
|
||||
# Verify chain link 0010 -> 0009 -> 0008 (the rest of the chain is checked below).
|
||||
# Verify chain links 0015 -> 0014 -> 0013 -> 0012 -> 0011 -> 0010 -> ... (rest below).
|
||||
rev_0015 = script.get_revision("0015")
|
||||
assert rev_0015.down_revision == "0014"
|
||||
|
||||
rev_0014 = script.get_revision("0014")
|
||||
assert rev_0014.down_revision == "0013"
|
||||
|
||||
rev_0013 = script.get_revision("0013")
|
||||
assert rev_0013.down_revision == "0012"
|
||||
|
||||
rev_0012 = script.get_revision("0012")
|
||||
assert rev_0012.down_revision == "0011"
|
||||
|
||||
rev_0011 = script.get_revision("0011")
|
||||
assert rev_0011.down_revision == "0010"
|
||||
|
||||
rev_0010 = script.get_revision("0010")
|
||||
assert rev_0010.down_revision == "0009"
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ class TestSeedSourceTypes:
|
||||
|
||||
seed_source_types("testuser", "testpass")
|
||||
|
||||
# Should have queried for each type and added 6 new ones
|
||||
# Should have queried for each type and added 7 new ones
|
||||
# (research_download, user_upload, manual_entry, research_report,
|
||||
# research_source, zotero)
|
||||
assert mock_session.add.call_count == 6
|
||||
# research_source, note, zotero)
|
||||
assert mock_session.add.call_count == 7
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@patch("local_deep_research.database.library_init.get_user_db_session")
|
||||
|
||||
@@ -368,7 +368,7 @@ class TestMigration0018HeadAlignment:
|
||||
rev_0018 = script.get_revision("0018")
|
||||
assert rev_0018.down_revision == "0017"
|
||||
|
||||
# NOTE: the head-alignment guard (assert head == latest) always lives in
|
||||
# the newest migration's test file — currently
|
||||
# test_migration_0020_add_zotero_tables.py. 0018 is no longer the head,
|
||||
# so asserting it here would break every future migration.
|
||||
# NOTE: the head-alignment guard (assert head == latest) lives in the
|
||||
# newest migration's test file — see TestMigration0022HeadAlignment in
|
||||
# test_migration_0022_note_references.py. 0018 is no longer the head, so
|
||||
# asserting it here would break every future migration.
|
||||
|
||||
@@ -125,10 +125,13 @@ class TestMigration0019RetireBoth:
|
||||
assert _read_setting(engine, EGRESS_SCOPE_KEY) is None
|
||||
|
||||
|
||||
class TestMigration0019HeadAlignment:
|
||||
"""Head-alignment guard. By convention this lives in the newest
|
||||
migration's test file (moved here from the 0018 test file when 0019 was
|
||||
added). Catches a new migration landing without the chain being wired up."""
|
||||
class TestMigration0019ChainGuard:
|
||||
"""Chain guard for 0019. The head-alignment guard (assert head == the
|
||||
newest revision) moved to the notes branch's newest migration test —
|
||||
TestMigration0022HeadAlignment in test_migration_0022_note_references.py
|
||||
— when feat/notes-v2 re-chained note_tables (0021) and note_references
|
||||
(0022) on top of this egress migration. 0019 is no longer the head, so
|
||||
only its chaining onto 0018 is asserted here."""
|
||||
|
||||
def test_0019_chains_correctly_to_0018(self):
|
||||
from alembic.config import Config
|
||||
@@ -143,8 +146,3 @@ class TestMigration0019HeadAlignment:
|
||||
script = ScriptDirectory.from_config(config)
|
||||
rev_0019 = script.get_revision("0019")
|
||||
assert rev_0019.down_revision == "0018"
|
||||
|
||||
# NOTE: the head-alignment guard (assert head == latest) moved to the
|
||||
# newest migration's test file — test_migration_0020_add_zotero_tables.py.
|
||||
# 0019 is no longer the head, so asserting it here would break every
|
||||
# future migration.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Tests for migration 0020: add the Zotero integration tables.
|
||||
|
||||
Verifies the migration chains correctly after 0019 and is the current head,
|
||||
and that the upgrade creates the two Zotero tables. Also holds the
|
||||
head-alignment guard (0020 is the newest migration); when a later migration
|
||||
is added, move this guard to that migration's test file.
|
||||
Verifies the migration chains correctly after 0019 and that the upgrade
|
||||
creates the two Zotero tables. The head-alignment guard is NOT here: when
|
||||
feat/notes-v2 was merged it re-chained note_tables (0021) and
|
||||
note_references (0022) on top of this migration, so 0020 is no longer the
|
||||
head — the guard lives in TestMigration0022HeadAlignment in
|
||||
test_migration_0022_note_references.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -15,7 +17,6 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from local_deep_research.database.alembic_runner import (
|
||||
get_alembic_config,
|
||||
get_head_revision,
|
||||
get_migrations_dir,
|
||||
run_migrations,
|
||||
)
|
||||
@@ -29,12 +30,6 @@ def test_0020_chains_after_0019():
|
||||
assert rev.down_revision == "0019"
|
||||
|
||||
|
||||
def test_head_revision_is_0020():
|
||||
# 0020 (add zotero tables) is the newest migration. If you add a later
|
||||
# migration, move this guard to its test file.
|
||||
assert get_head_revision() == "0020"
|
||||
|
||||
|
||||
def test_upgrade_creates_zotero_tables(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'test_0020.db'}")
|
||||
run_migrations(engine) # up to head (includes 0020)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
"""Tests for migration 0022_add_note_references + the NoteReference model.
|
||||
|
||||
The reference layer: note → (document | research) rows with optional
|
||||
quote anchors, powering the document/research Notes panels and the
|
||||
Word-review-style inline comments. The three-way parity between the
|
||||
model CHECK, the migration CHECK, and behavior follows the house style
|
||||
established by the note_syntheses enum CHECK in 0021.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from local_deep_research.database.models import (
|
||||
Document,
|
||||
NoteReference,
|
||||
ResearchHistory,
|
||||
SourceType,
|
||||
)
|
||||
from local_deep_research.database.models.base import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine():
|
||||
eng = create_engine("sqlite:///:memory:")
|
||||
|
||||
# FK enforcement matches the app (PRAGMA foreign_keys=ON everywhere).
|
||||
from sqlalchemy import event
|
||||
|
||||
@event.listens_for(eng, "connect")
|
||||
def _fk_on(dbapi_conn, _):
|
||||
dbapi_conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
Base.metadata.create_all(eng)
|
||||
return eng
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(engine):
|
||||
s = sessionmaker(bind=engine)()
|
||||
yield s
|
||||
s.close()
|
||||
|
||||
|
||||
def _source_type(session):
|
||||
st = session.query(SourceType).filter_by(name="note").first()
|
||||
if st is None:
|
||||
st = SourceType(id=str(uuid.uuid4()), name="note", display_name="Note")
|
||||
session.add(st)
|
||||
session.commit()
|
||||
return st
|
||||
|
||||
|
||||
def _note(session, title="A note"):
|
||||
doc = Document(
|
||||
id=str(uuid.uuid4()),
|
||||
title=title,
|
||||
text_content="body",
|
||||
file_type="note",
|
||||
file_size=4,
|
||||
document_hash=uuid.uuid4().hex,
|
||||
source_type_id=_source_type(session).id,
|
||||
)
|
||||
session.add(doc)
|
||||
session.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def _research(session):
|
||||
rh = ResearchHistory(
|
||||
id=str(uuid.uuid4()),
|
||||
query="q",
|
||||
mode="quick",
|
||||
status="completed",
|
||||
created_at="2026-07-04T00:00:00+00:00",
|
||||
)
|
||||
session.add(rh)
|
||||
session.commit()
|
||||
return rh
|
||||
|
||||
|
||||
class TestSingleTargetCheck:
|
||||
"""Model CHECK: exactly one of the two targets."""
|
||||
|
||||
def test_document_target_ok(self, session):
|
||||
note, target = _note(session), _note(session, "target doc")
|
||||
session.add(
|
||||
NoteReference(note_id=note.id, target_document_id=target.id)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_research_target_ok(self, session):
|
||||
note, rh = _note(session), _research(session)
|
||||
session.add(
|
||||
NoteReference(
|
||||
note_id=note.id,
|
||||
target_research_id=rh.id,
|
||||
quote="a passage",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_no_target_rejected(self, session):
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
session.add(NoteReference(note_id=_note(session).id))
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
session.rollback()
|
||||
|
||||
def test_both_targets_rejected(self, session):
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
note, target, rh = (
|
||||
_note(session),
|
||||
_note(session, "t"),
|
||||
_research(session),
|
||||
)
|
||||
session.add(
|
||||
NoteReference(
|
||||
note_id=note.id,
|
||||
target_document_id=target.id,
|
||||
target_research_id=rh.id,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
session.rollback()
|
||||
|
||||
|
||||
class TestCascades:
|
||||
def test_deleting_the_note_cascades_the_reference(self, session):
|
||||
note, target = _note(session), _note(session, "target")
|
||||
session.add(
|
||||
NoteReference(
|
||||
note_id=note.id, target_document_id=target.id, quote="q"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.delete(note)
|
||||
session.commit()
|
||||
assert session.query(NoteReference).count() == 0
|
||||
|
||||
def test_deleting_the_target_cascades_the_reference(self, session):
|
||||
note, rh = _note(session), _research(session)
|
||||
session.add(NoteReference(note_id=note.id, target_research_id=rh.id))
|
||||
session.commit()
|
||||
session.delete(rh)
|
||||
session.commit()
|
||||
assert session.query(NoteReference).count() == 0
|
||||
|
||||
def test_deleting_the_target_document_cascades_the_reference(self, session):
|
||||
note, target = _note(session), _note(session, "target doc")
|
||||
session.add(
|
||||
NoteReference(note_id=note.id, target_document_id=target.id)
|
||||
)
|
||||
session.commit()
|
||||
session.delete(target)
|
||||
session.commit()
|
||||
assert session.query(NoteReference).count() == 0
|
||||
|
||||
|
||||
class TestModelMigrationParity:
|
||||
"""The migration's CHECK expression must match the model's — drift
|
||||
means fresh installs (migration) and tests (metadata.create_all)
|
||||
enforce different rules."""
|
||||
|
||||
def test_check_constraint_parity(self):
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import local_deep_research.database.migrations.versions as versions
|
||||
|
||||
migration = (
|
||||
Path(versions.__file__).parent / "0022_add_note_references.py"
|
||||
).read_text()
|
||||
model_checks = [
|
||||
str(c.sqltext)
|
||||
for c in NoteReference.__table__.constraints
|
||||
if c.name == "ck_note_reference_single_target"
|
||||
]
|
||||
assert model_checks, "model CHECK missing"
|
||||
normalized_model = re.sub(r"\s+", " ", model_checks[0]).strip()
|
||||
# The migration carries the same expression. Its source splits the
|
||||
# SQL across adjacent string literals, so drop the quote characters
|
||||
# before collapsing whitespace.
|
||||
migration_normalized = re.sub(r"\s+", " ", migration.replace('"', ""))
|
||||
assert (
|
||||
"(target_document_id IS NOT NULL) + (target_research_id IS NOT NULL) = 1"
|
||||
in migration_normalized
|
||||
)
|
||||
assert (
|
||||
normalized_model
|
||||
== "(target_document_id IS NOT NULL) + (target_research_id IS NOT NULL) = 1"
|
||||
)
|
||||
|
||||
|
||||
class TestMigration0022HeadAlignment:
|
||||
"""Head-alignment guard (moved from the 0019 test file per the repo
|
||||
convention of keeping this in the newest migration's test)."""
|
||||
|
||||
def test_head_revision_is_0022(self):
|
||||
from local_deep_research.database.alembic_runner import (
|
||||
get_head_revision,
|
||||
)
|
||||
|
||||
assert get_head_revision() == "0022"
|
||||
|
||||
def test_0022_chains_correctly_to_0021(self):
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
from local_deep_research.database.alembic_runner import (
|
||||
get_migrations_dir,
|
||||
)
|
||||
|
||||
config = Config()
|
||||
config.set_main_option("script_location", str(get_migrations_dir()))
|
||||
script = ScriptDirectory.from_config(config)
|
||||
assert script.get_revision("0022").down_revision == "0021"
|
||||
@@ -86,6 +86,13 @@ EXPECTED_TABLES = {
|
||||
"benchmark_progress",
|
||||
"benchmark_results",
|
||||
"benchmark_runs",
|
||||
# Notes
|
||||
"note_links",
|
||||
"note_references",
|
||||
"note_research",
|
||||
"note_syntheses",
|
||||
"note_synthesis_sources",
|
||||
"note_versions",
|
||||
# Chat
|
||||
"chat_sessions",
|
||||
"chat_messages",
|
||||
@@ -220,6 +227,128 @@ class TestCriticalColumns:
|
||||
"This will break API key storage."
|
||||
)
|
||||
|
||||
def test_note_versions_has_exact_column_set(self):
|
||||
"""NoteVersion stores the per-edit snapshot used by restore and
|
||||
audit history. The (document_id, content_hash) UNIQUE
|
||||
constraint is part of the dedup contract; any rename or removal
|
||||
of these columns breaks the version-history page silently.
|
||||
"""
|
||||
from local_deep_research.database.models.note import NoteVersion
|
||||
|
||||
expected = {
|
||||
"id",
|
||||
"document_id",
|
||||
"title",
|
||||
"content",
|
||||
"tags",
|
||||
"change_type",
|
||||
"change_summary",
|
||||
"content_hash",
|
||||
"created_at",
|
||||
}
|
||||
actual = set(NoteVersion.__table__.columns.keys())
|
||||
missing = expected - actual
|
||||
extra = actual - expected
|
||||
assert not missing, (
|
||||
f"NoteVersion is missing required columns: {sorted(missing)}"
|
||||
)
|
||||
assert not extra, (
|
||||
f"NoteVersion has unexpected columns: {sorted(extra)}. "
|
||||
"If intentional, also update the 0020 migration (and its downgrade) "
|
||||
"and this test."
|
||||
)
|
||||
|
||||
def test_note_links_has_exact_column_set(self):
|
||||
"""NoteLink encodes the wiki-link graph. auto_suggested is the
|
||||
distinction between user-typed and AI-suggested links — losing
|
||||
it collapses two distinct UX flows."""
|
||||
from local_deep_research.database.models.note import NoteLink
|
||||
|
||||
expected = {
|
||||
"id",
|
||||
"source_document_id",
|
||||
"target_document_id",
|
||||
"link_text",
|
||||
"auto_suggested",
|
||||
"created_at",
|
||||
}
|
||||
actual = set(NoteLink.__table__.columns.keys())
|
||||
missing = expected - actual
|
||||
extra = actual - expected
|
||||
assert not missing, (
|
||||
f"NoteLink is missing required columns: {sorted(missing)}"
|
||||
)
|
||||
assert not extra, f"NoteLink has unexpected columns: {sorted(extra)}."
|
||||
|
||||
def test_note_research_has_exact_column_set(self):
|
||||
"""NoteResearch ties a note to its research runs; display_order
|
||||
and is_collapsed drive the sidebar's ordered/collapsed UI."""
|
||||
from local_deep_research.database.models.note import NoteResearch
|
||||
|
||||
expected = {
|
||||
"id",
|
||||
"document_id",
|
||||
"research_id",
|
||||
"display_order",
|
||||
"is_collapsed",
|
||||
"created_at",
|
||||
}
|
||||
actual = set(NoteResearch.__table__.columns.keys())
|
||||
missing = expected - actual
|
||||
extra = actual - expected
|
||||
assert not missing, (
|
||||
f"NoteResearch is missing required columns: {sorted(missing)}"
|
||||
)
|
||||
assert not extra, (
|
||||
f"NoteResearch has unexpected columns: {sorted(extra)}."
|
||||
)
|
||||
|
||||
def test_note_syntheses_has_exact_column_set(self):
|
||||
"""NoteSynthesis is the audit row for an AI synthesis run.
|
||||
result_document_id is SET NULL on result-doc delete; the
|
||||
column must stay nullable and present for the audit trail."""
|
||||
from local_deep_research.database.models.note import NoteSynthesis
|
||||
|
||||
expected = {
|
||||
"id",
|
||||
"result_document_id",
|
||||
"synthesis_type",
|
||||
"created_at",
|
||||
}
|
||||
actual = set(NoteSynthesis.__table__.columns.keys())
|
||||
missing = expected - actual
|
||||
extra = actual - expected
|
||||
assert not missing, (
|
||||
f"NoteSynthesis is missing required columns: {sorted(missing)}"
|
||||
)
|
||||
assert not extra, (
|
||||
f"NoteSynthesis has unexpected columns: {sorted(extra)}."
|
||||
)
|
||||
|
||||
def test_note_synthesis_sources_has_exact_column_set(self):
|
||||
"""NoteSynthesisSource is the synthesis ↔ source-note junction.
|
||||
The (synthesis_id, source_document_id) UNIQUE prevents the
|
||||
dedup bug fixed in PR #3277; both columns must stay present."""
|
||||
from local_deep_research.database.models.note import (
|
||||
NoteSynthesisSource,
|
||||
)
|
||||
|
||||
expected = {
|
||||
"id",
|
||||
"synthesis_id",
|
||||
"source_document_id",
|
||||
"created_at",
|
||||
}
|
||||
actual = set(NoteSynthesisSource.__table__.columns.keys())
|
||||
missing = expected - actual
|
||||
extra = actual - expected
|
||||
assert not missing, (
|
||||
f"NoteSynthesisSource is missing required columns: {sorted(missing)}"
|
||||
)
|
||||
assert not extra, (
|
||||
f"NoteSynthesisSource has unexpected columns: {sorted(extra)}."
|
||||
)
|
||||
|
||||
def test_journal_has_exact_column_set(self):
|
||||
"""Journal is an LLM-only cache; the column set is deliberately
|
||||
minimal. Lock it down so an accidental add/drop in the model
|
||||
|
||||
@@ -483,3 +483,93 @@ class TestOrphanedDocumentDeletion:
|
||||
assert shared_doc is not None, "Shared document should be preserved"
|
||||
assert preserved_count == 1, "One document should be preserved"
|
||||
assert deleted_count == 2, "Two orphaned documents should be deleted"
|
||||
|
||||
|
||||
class TestProtectedCollectionTypes:
|
||||
"""Guard tests for protected system collections (notes, research_history,
|
||||
default_library). Deleting these via the public service must be refused so
|
||||
a user can't wipe every note in a single click from the collections UI."""
|
||||
|
||||
def test_protected_collection_types_constant(self):
|
||||
from local_deep_research.research_library.deletion.services.collection_deletion import (
|
||||
PROTECTED_COLLECTION_TYPES,
|
||||
)
|
||||
|
||||
assert "notes" in PROTECTED_COLLECTION_TYPES
|
||||
assert "research_history" in PROTECTED_COLLECTION_TYPES
|
||||
assert "default_library" in PROTECTED_COLLECTION_TYPES
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"collection_type",
|
||||
["notes", "research_history", "default_library"],
|
||||
)
|
||||
def test_delete_collection_refuses_protected_type(
|
||||
self, monkeypatch, test_engine, collection_type
|
||||
):
|
||||
"""delete_collection() must refuse system collections and leave the
|
||||
underlying rows in place."""
|
||||
from contextlib import contextmanager
|
||||
from local_deep_research.research_library.deletion.services.collection_deletion import (
|
||||
CollectionDeletionService,
|
||||
)
|
||||
|
||||
# Seed a collection of the protected type.
|
||||
Session = sessionmaker(bind=test_engine)
|
||||
with Session() as session:
|
||||
coll = Collection(
|
||||
id=str(uuid.uuid4()),
|
||||
name=f"System {collection_type}",
|
||||
description="seeded by test",
|
||||
collection_type=collection_type,
|
||||
)
|
||||
session.add(coll)
|
||||
session.commit()
|
||||
coll_id = coll.id
|
||||
|
||||
# Patch get_user_db_session to hand the service the same engine.
|
||||
@contextmanager
|
||||
def _fake_get_user_db_session(username, password=None):
|
||||
with Session() as s:
|
||||
yield s
|
||||
|
||||
monkeypatch.setattr(
|
||||
"local_deep_research.research_library.deletion.services.collection_deletion.get_user_db_session",
|
||||
_fake_get_user_db_session,
|
||||
)
|
||||
|
||||
service = CollectionDeletionService("test_user")
|
||||
result = service.delete_collection(coll_id)
|
||||
|
||||
assert result["deleted"] is False
|
||||
assert result["collection_type"] == collection_type
|
||||
assert "system collection" in result["error"].lower()
|
||||
|
||||
# And the collection row is still present.
|
||||
with Session() as session:
|
||||
assert session.query(Collection).get(coll_id) is not None
|
||||
|
||||
def test_delete_collection_allows_user_collection_type(
|
||||
self, monkeypatch, test_engine, test_collection
|
||||
):
|
||||
"""Sanity check: a non-protected collection still deletes normally."""
|
||||
from contextlib import contextmanager
|
||||
from local_deep_research.research_library.deletion.services.collection_deletion import (
|
||||
CollectionDeletionService,
|
||||
)
|
||||
|
||||
Session = sessionmaker(bind=test_engine)
|
||||
|
||||
@contextmanager
|
||||
def _fake_get_user_db_session(username, password=None):
|
||||
with Session() as s:
|
||||
yield s
|
||||
|
||||
monkeypatch.setattr(
|
||||
"local_deep_research.research_library.deletion.services.collection_deletion.get_user_db_session",
|
||||
_fake_get_user_db_session,
|
||||
)
|
||||
|
||||
service = CollectionDeletionService("test_user")
|
||||
result = service.delete_collection(test_collection.id)
|
||||
|
||||
assert result["deleted"] is True, result.get("error")
|
||||
|
||||
@@ -138,6 +138,29 @@ class TestClassifyFile:
|
||||
== "other"
|
||||
)
|
||||
|
||||
# --- JS test layers ---
|
||||
|
||||
def test_vitest_test_file(self):
|
||||
assert classify_file("tests/js/services/formatting.test.js") == "test"
|
||||
|
||||
def test_playwright_spec_file(self):
|
||||
# Playwright specs are tests too — a UI commit shipping .spec.js
|
||||
# coverage must not be flagged as untested by require-tests.
|
||||
assert (
|
||||
classify_file(
|
||||
"tests/ui_tests/playwright/tests/notes/notes-crud.spec.js"
|
||||
)
|
||||
== "test"
|
||||
)
|
||||
|
||||
def test_static_js_page_is_source(self):
|
||||
assert (
|
||||
classify_file(
|
||||
"src/local_deep_research/web/static/js/pages/notes.js"
|
||||
)
|
||||
== "source"
|
||||
)
|
||||
|
||||
# --- Edge cases ---
|
||||
|
||||
def test_top_level_python_file(self):
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Tests for components/annotation_surface.js — applyWhenReady re-applies
|
||||
* highlights across the results page's asynchronous render.
|
||||
*
|
||||
* The results page (results.js) first swaps results.html's
|
||||
* `.ldr-loading-spinner` for its OWN placeholder
|
||||
* (`<i class="fas fa-spinner fa-pulse"> Loading research results…`) and
|
||||
* only THEN, after the report fetch, replaces the container's innerHTML
|
||||
* with the real report. A one-shot readiness check keyed on
|
||||
* `.ldr-loading-spinner` would see the placeholder as "ready", anchor
|
||||
* nothing, and never re-run — so highlights would silently never appear
|
||||
* on the first view of a research report. This drives the real
|
||||
* LDRAnnotationSurface.init() through that exact sequence.
|
||||
*/
|
||||
|
||||
let SafeLoggerErrors;
|
||||
|
||||
beforeAll(() => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.SafeLogger = {
|
||||
error: (...a) => SafeLoggerErrors.push(a),
|
||||
warn: () => {},
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
// The surface now calls safeFetchWithAuth (auth-aware wrapper added on
|
||||
// main); delegate to the fetch mock each test installs.
|
||||
globalThis.safeFetchWithAuth = (...args) =>
|
||||
(globalThis.safeFetch || globalThis.fetch)(...args);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
SafeLoggerErrors = [];
|
||||
document.body.innerHTML = '';
|
||||
// Fresh module each test so init()'s document-level listeners don't stack.
|
||||
vi.resetModules();
|
||||
await import('@js/components/annotation_surface.js');
|
||||
});
|
||||
|
||||
function mockAnnotationsFetch(annotations) {
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true, annotations }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
describe('applyWhenReady across the placeholder → content swap', () => {
|
||||
it('anchors the highlight only after the real report replaces the placeholder', async () => {
|
||||
// Container starts with results.js's loading placeholder (NOT the
|
||||
// .ldr-loading-spinner the naive heuristic looked for).
|
||||
const container = document.createElement('div');
|
||||
container.id = 'results-content';
|
||||
// eslint-disable-next-line no-unsanitized/property -- test fixture
|
||||
container.innerHTML =
|
||||
'<div class="text-center my-5"><i class="fas fa-spinner fa-pulse"></i>' +
|
||||
'<p class="mt-3">Loading research results...</p></div>';
|
||||
document.body.appendChild(container);
|
||||
|
||||
mockAnnotationsFetch([
|
||||
{ note_id: 'n1', quote: 'attention mechanism', prefix: '', suffix: '' },
|
||||
]);
|
||||
|
||||
const base = '/notes/api/research/res-1';
|
||||
window.LDRAnnotationSurface.init({
|
||||
containerId: 'results-content',
|
||||
endpoints: {
|
||||
list: `${base}/annotations`,
|
||||
create: `${base}/annotations`,
|
||||
deleteFor: (id) => `${base}/annotations/${id}`,
|
||||
},
|
||||
});
|
||||
|
||||
await flush(); // annotations fetch resolves
|
||||
|
||||
// Placeholder is still showing → nothing anchored yet, and we must
|
||||
// NOT have given up (no "could not be anchored" error logged).
|
||||
expect(container.querySelectorAll('mark.ldr-research-annotation').length).toBe(0);
|
||||
expect(
|
||||
SafeLoggerErrors.some((e) => String(e[0]).includes('could not be anchored'))
|
||||
).toBe(false);
|
||||
|
||||
// results.js now renders the real report (wholesale innerHTML swap).
|
||||
// eslint-disable-next-line no-unsanitized/property -- test fixture
|
||||
container.innerHTML =
|
||||
'<h2>Findings</h2><p>We propose a new attention mechanism for this.</p>';
|
||||
await flush(); // MutationObserver fires → re-apply
|
||||
|
||||
const marks = container.querySelectorAll('mark.ldr-research-annotation');
|
||||
expect(marks.length).toBe(1);
|
||||
expect(marks[0].textContent).toBe('attention mechanism');
|
||||
});
|
||||
|
||||
it('applies highlights to a real report that merely begins with the word "Loading"', async () => {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'results-content';
|
||||
// A genuine (long) report whose text happens to START with "Loading".
|
||||
// Pre-fix, looksLikeLoading() treated ANY leading "Loading"/"Searching"
|
||||
// as a placeholder and suppressed every highlight on the page forever;
|
||||
// the fix only treats a SHORT (<120 char) such body as a placeholder.
|
||||
// eslint-disable-next-line no-unsanitized/property -- test fixture
|
||||
container.innerHTML =
|
||||
'<p>Loading historical context first: the paper introduces a novel ' +
|
||||
'attention mechanism that substantially reduces the compute cost of ' +
|
||||
'long-context inference across a wide range of downstream tasks.</p>';
|
||||
document.body.appendChild(container);
|
||||
|
||||
mockAnnotationsFetch([
|
||||
{ note_id: 'n1', quote: 'attention mechanism', prefix: '', suffix: '' },
|
||||
]);
|
||||
|
||||
const base = '/notes/api/research/res-1';
|
||||
window.LDRAnnotationSurface.init({
|
||||
containerId: 'results-content',
|
||||
endpoints: {
|
||||
list: `${base}/annotations`,
|
||||
create: `${base}/annotations`,
|
||||
deleteFor: (id) => `${base}/annotations/${id}`,
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const marks = container.querySelectorAll('mark.ldr-research-annotation');
|
||||
expect(marks.length).toBe(1);
|
||||
expect(marks[0].textContent).toBe('attention mechanism');
|
||||
});
|
||||
|
||||
it('keeps re-applying until every DISTINCT annotation anchors, not just until the mark count is reached', async () => {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'results-content';
|
||||
// Annotation A's quote spans a <br> → TWO marks for ONE annotation.
|
||||
// Annotation B's quote is absent from this first render.
|
||||
// eslint-disable-next-line no-unsanitized/property -- test fixture
|
||||
container.innerHTML =
|
||||
'<p>reduces cost substantially, but<br>shared ownership must be tracked</p>';
|
||||
document.body.appendChild(container);
|
||||
|
||||
mockAnnotationsFetch([
|
||||
{ note_id: 'A', quote: 'substantially, but shared ownership', prefix: '', suffix: '' },
|
||||
{ note_id: 'B', quote: 'governance model', prefix: '', suffix: '' },
|
||||
]);
|
||||
|
||||
const base = '/notes/api/research/res-1';
|
||||
window.LDRAnnotationSurface.init({
|
||||
containerId: 'results-content',
|
||||
endpoints: {
|
||||
list: `${base}/annotations`,
|
||||
create: `${base}/annotations`,
|
||||
deleteFor: (id) => `${base}/annotations/${id}`,
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
// A anchored as 2 marks. Pre-fix, a mark COUNT of 2 >= total 2 declared
|
||||
// the set complete and tore down the observer — even though B had not
|
||||
// anchored — so B's later content would never be picked up.
|
||||
|
||||
// B's content now arrives (wholesale re-render, as results.js does).
|
||||
// eslint-disable-next-line no-unsanitized/property -- test fixture
|
||||
container.innerHTML =
|
||||
'<p>reduces cost substantially, but<br>shared ownership must be tracked</p>' +
|
||||
'<p>The governance model is shared.</p>';
|
||||
await flush();
|
||||
|
||||
// With the distinct-annotation completion metric the observer was still
|
||||
// active and re-anchored BOTH. Pre-fix it had already stopped, so
|
||||
// neither the swapped-away A nor the newly-present B is anchored.
|
||||
const ids = new Set(
|
||||
[...container.querySelectorAll('mark.ldr-research-annotation')].map(
|
||||
(m) => m.dataset.noteId
|
||||
)
|
||||
);
|
||||
expect(ids.has('B')).toBe(true);
|
||||
expect(ids.has('A')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -265,8 +265,16 @@ describe('LibrarySearch', () => {
|
||||
|
||||
it('renders fallback message when SemanticSearch module not loaded', () => {
|
||||
const container = document.createElement('div');
|
||||
// SemanticSearch not loaded in tests
|
||||
LS.renderSemanticResults([{ document_id: '1' }], container, 'q');
|
||||
// The shared setup loads window.SemanticSearch for every test;
|
||||
// this case deliberately exercises its ABSENCE, so remove it
|
||||
// just for this assertion and restore it after.
|
||||
const saved = window.SemanticSearch;
|
||||
delete window.SemanticSearch;
|
||||
try {
|
||||
LS.renderSemanticResults([{ document_id: '1' }], container, 'q');
|
||||
} finally {
|
||||
window.SemanticSearch = saved;
|
||||
}
|
||||
expect(container.textContent).toContain('Search module not loaded');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Tests for components/notes_shared.js — the shared notes helper trio.
|
||||
*
|
||||
* postJson / csrfToken / toast were byte-identical across three note
|
||||
* components (and a weaker CSRF helper across two pages); they now live
|
||||
* here once. These pin the behavior the delegates rely on, notably the
|
||||
* meta-tag CSRF fallback the page helpers previously lacked.
|
||||
*
|
||||
* The shared setup (tests/js/setup.js) already loads notes_shared.js, so
|
||||
* window.NotesShared is present.
|
||||
*/
|
||||
|
||||
let NotesShared;
|
||||
|
||||
beforeAll(() => {
|
||||
NotesShared = window.NotesShared;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = '';
|
||||
delete window.api;
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
});
|
||||
|
||||
describe('NotesShared.csrfToken', () => {
|
||||
it('prefers window.api.getCsrfToken when available', () => {
|
||||
window.api = { getCsrfToken: () => 'from-api' };
|
||||
document.head.innerHTML = '<meta name="csrf-token" content="from-meta">';
|
||||
expect(NotesShared.csrfToken()).toBe('from-api');
|
||||
});
|
||||
|
||||
it('falls back to the meta tag when window.api is absent', () => {
|
||||
document.head.innerHTML = '<meta name="csrf-token" content="from-meta">';
|
||||
expect(NotesShared.csrfToken()).toBe('from-meta');
|
||||
});
|
||||
|
||||
it('returns empty string when neither source is present', () => {
|
||||
expect(NotesShared.csrfToken()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotesShared.postJson', () => {
|
||||
it('POSTs with the CSRF header and returns the parsed body on success', async () => {
|
||||
window.api = { getCsrfToken: () => 'tok' };
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, id: 'n1' }) })
|
||||
);
|
||||
|
||||
const data = await NotesShared.postJson('/notes/api/x', { a: 1 });
|
||||
|
||||
expect(data).toEqual({ success: true, id: 'n1' });
|
||||
const [url, opts] = globalThis.safeFetch.mock.calls[0];
|
||||
expect(url).toBe('/notes/api/x');
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.headers['X-CSRFToken']).toBe('tok');
|
||||
expect(JSON.parse(opts.body)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('throws the server error message on {success:false}', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: false, error: 'nope' }) })
|
||||
);
|
||||
await expect(NotesShared.postJson('/notes/api/x')).rejects.toThrow('nope');
|
||||
});
|
||||
|
||||
it('throws a status-based message when the body has no error', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500, json: () => Promise.reject(new Error('not json')) })
|
||||
);
|
||||
await expect(NotesShared.postJson('/notes/api/x')).rejects.toThrow('Server returned 500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotesShared.toast', () => {
|
||||
it('routes through window.ui.showMessage with the given type', () => {
|
||||
NotesShared.toast('hi', 'success');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('hi', 'success');
|
||||
});
|
||||
|
||||
it('defaults to info and no-ops when ui is absent', () => {
|
||||
NotesShared.toast('hi');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('hi', 'info');
|
||||
delete window.ui;
|
||||
expect(() => NotesShared.toast('x')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tests for components/annotation_surface.js — the annotation anchor engine
|
||||
* (shared by research_notes.js and document_notes.js).
|
||||
*
|
||||
* Quotes are captured from selection.toString(), which inserts newlines at
|
||||
* <br> and block boundaries; the haystack is built from text nodes, where
|
||||
* those boundaries contribute NO characters (textContent of "but<br>shared"
|
||||
* is "butshared"). buildNormalizedText bridges the two with virtual spaces,
|
||||
* so anchors match the passage the user actually selected. Reports are
|
||||
* immutable, so a matching anchor can never drift.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn();
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, notes: [], annotations: [] }) })
|
||||
);
|
||||
await import('@js/components/annotation_surface.js');
|
||||
hook = window.__annotationSurfaceTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
function container(html) {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'results-content';
|
||||
// eslint-disable-next-line no-unsanitized/property -- test fixture, hardcoded HTML
|
||||
el.innerHTML = html;
|
||||
document.body.replaceChildren(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('buildNormalizedText', () => {
|
||||
it('emits a virtual space at <br> boundaries (renderer newline → <br>)', () => {
|
||||
const el = container('<p>cost substantially, but<br>shared ownership</p>');
|
||||
const { text } = hook.buildNormalizedText(el);
|
||||
expect(text).toBe('cost substantially, but shared ownership');
|
||||
});
|
||||
|
||||
it('emits a virtual space between block elements', () => {
|
||||
const el = container('<h2>Findings</h2><p>Dedup reduces cost.</p>');
|
||||
const { text } = hook.buildNormalizedText(el);
|
||||
expect(text).toBe('Findings Dedup reduces cost.');
|
||||
});
|
||||
|
||||
it('collapses whitespace runs like a selection does', () => {
|
||||
const el = container('<p>a b\n\n c</p>');
|
||||
const { text } = hook.buildNormalizedText(el);
|
||||
expect(text).toBe('a b c');
|
||||
});
|
||||
|
||||
it('emits a virtual space when EXITING a block (bare text after a close tag)', () => {
|
||||
// Regression: only emitting on block ENTER glued the caption to the
|
||||
// quote ("insightas noted"), so a quote spanning the boundary (where a
|
||||
// selection inserts a newline) never anchored.
|
||||
const el = container('<div><blockquote>Key insight</blockquote>as noted below.</div>');
|
||||
const { text } = hook.buildNormalizedText(el);
|
||||
expect(text).toBe('Key insight as noted below.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findQuoteIndex', () => {
|
||||
it('finds a unique quote', () => {
|
||||
expect(hook.findQuoteIndex('alpha beta gamma', 'beta', '', '')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns -1 for a missing quote', () => {
|
||||
expect(hook.findQuoteIndex('alpha beta', 'delta', '', '')).toBe(-1);
|
||||
});
|
||||
|
||||
it('disambiguates repeated phrases via prefix/suffix context', () => {
|
||||
const hay = 'the cost is high. later the cost is low.';
|
||||
// Selecting the SECOND "the cost" (prefix "later ").
|
||||
const idx = hook.findQuoteIndex(hay, 'the cost', 'later ', ' is low');
|
||||
expect(idx).toBe(hay.indexOf('later ') + 'later '.length);
|
||||
});
|
||||
|
||||
it('returns -1 for an empty quote instead of looping forever', () => {
|
||||
// indexOf('', n) never returns -1; without the guard the hit loop hangs.
|
||||
expect(hook.findQuoteIndex('alpha beta', '', '', '')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAnnotation', () => {
|
||||
it('wraps a quote spanning a <br> in per-segment marks', () => {
|
||||
const el = container('<p>reduces cost substantially, but<br>shared ownership must be tracked</p>');
|
||||
const ok = hook.applyAnnotation(el, {
|
||||
note_id: 'n1',
|
||||
quote: 'substantially, but shared ownership',
|
||||
prefix: '',
|
||||
suffix: ''
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
const marks = el.querySelectorAll('mark.ldr-research-annotation');
|
||||
expect(marks.length).toBe(2); // one segment per side of the <br>
|
||||
const highlighted = [...marks].map((m) => m.textContent).join(' ');
|
||||
expect(highlighted).toContain('substantially, but');
|
||||
expect(highlighted).toContain('shared ownership');
|
||||
expect(marks[0].dataset.noteId).toBe('n1');
|
||||
});
|
||||
|
||||
it('reports false (and leaves the DOM untouched) for an unmatchable quote', () => {
|
||||
const el = container('<p>completely different content</p>');
|
||||
const ok = hook.applyAnnotation(el, {
|
||||
note_id: 'n2', quote: 'not in the report', prefix: '', suffix: ''
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(el.querySelectorAll('mark').length).toBe(0);
|
||||
});
|
||||
|
||||
it('anchors a quote spanning a collapsed double-space without throwing', () => {
|
||||
// Regression: the quote maps to two non-contiguous segments on ONE text
|
||||
// node (the double space collapses to one); wrapping front-to-first
|
||||
// threw IndexSizeError and aborted every annotation on the page.
|
||||
const el = container('<p>The result is significant for this study.</p>');
|
||||
const ok = hook.applyAnnotation(el, {
|
||||
note_id: 'n3', quote: 'result is significant', prefix: '', suffix: ''
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
const marks = el.querySelectorAll('mark.ldr-research-annotation');
|
||||
expect(marks.length).toBeGreaterThan(0);
|
||||
const highlighted = [...marks].map((m) => m.textContent).join('');
|
||||
expect(highlighted.replace(/\s+/g, ' ')).toBe('result is significant');
|
||||
});
|
||||
|
||||
it('does not abort a second annotation when the first is unwrappable', () => {
|
||||
// With per-annotation application, one bad/overlapping wrap must not
|
||||
// prevent a later, valid annotation from anchoring.
|
||||
const el = container('<p>The result is significant for this study today.</p>');
|
||||
hook.applyAnnotation(el, { note_id: 'a', quote: 'result is significant', prefix: '', suffix: '' });
|
||||
const ok2 = hook.applyAnnotation(el, { note_id: 'b', quote: 'this study today', prefix: '', suffix: '' });
|
||||
expect(ok2).toBe(true);
|
||||
expect(el.querySelector('mark[data-note-id="b"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — acceptSuggestedLink flow.
|
||||
*
|
||||
* Clicking Accept on a suggested link POSTs accept-link, then: on success marks
|
||||
* the button "Linked", syncs note.content from the server response (it now
|
||||
* contains the new [[link]]), and runs a BEST-EFFORT panel refresh. The success
|
||||
* is marked BEFORE that refresh, so a refresh error can't fall through and
|
||||
* falsely revert the button / report failure while the link actually persisted.
|
||||
* On a real failure the button reverts to idle and an error is shown.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
function setup() {
|
||||
document.body.innerHTML =
|
||||
'<div id="note-content-rendered"></div>' +
|
||||
'<div class="ldr-ai-result-card">' +
|
||||
'<button class="ldr-suggest-link-accept" data-target-id="t1" data-target-title="Target Note">Link</button>' +
|
||||
'</div>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'old body', tags: [], outgoing_links: [] });
|
||||
return document.querySelector('.ldr-suggest-link-accept');
|
||||
}
|
||||
|
||||
describe('acceptSuggestedLink', () => {
|
||||
it('marks the button Linked and syncs note.content on success', async () => {
|
||||
const btn = setup();
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
if (url.includes('/accept-link')) {
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, note: { content: 'old body\n[[Target Note]]' } }) });
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, outgoing_links: [] }) });
|
||||
});
|
||||
|
||||
await hook.acceptSuggestedLink(btn);
|
||||
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.innerHTML).toContain('Linked');
|
||||
expect(hook.getNote().content).toBe('old body\n[[Target Note]]');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('Linked to "Target Note"', 'success');
|
||||
});
|
||||
|
||||
it('refreshes the version-history pager after a read-mode accept', async () => {
|
||||
const btn = setup();
|
||||
const calledUrls = [];
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
calledUrls.push(url);
|
||||
if (url.includes('/accept-link')) {
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, note: { content: 'old body [[Target Note]]' } }) });
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, outgoing_links: [], versions: [], total: 0 }) });
|
||||
});
|
||||
|
||||
await hook.acceptSuggestedLink(btn);
|
||||
|
||||
// accept-link appends [[Target]] via update_note — a content-changing
|
||||
// save that snapshots a new NoteVersion server-side — so the version
|
||||
// pager MUST be refreshed (like saveNote does). Otherwise its offset
|
||||
// goes stale and the next "show older versions" duplicates a row.
|
||||
expect(calledUrls.some((u) => u.includes('/versions?'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the button Linked even if the post-accept refresh throws (link persisted)', async () => {
|
||||
const btn = setup();
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
if (url.includes('/accept-link')) {
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, note: { content: 'x [[Target Note]]' } }) });
|
||||
}
|
||||
// The best-effort outgoing-links refresh blows up.
|
||||
return Promise.reject(new Error('refresh failed'));
|
||||
});
|
||||
|
||||
await hook.acceptSuggestedLink(btn);
|
||||
|
||||
// The link IS persisted server-side; the button must not revert.
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.innerHTML).toContain('Linked');
|
||||
expect(window.ui.showMessage).not.toHaveBeenCalledWith('Failed to accept link', 'error');
|
||||
});
|
||||
|
||||
it('reverts the button to idle and shows an error on failure', async () => {
|
||||
const btn = setup();
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false, error: 'nope' }) })
|
||||
);
|
||||
|
||||
await hook.acceptSuggestedLink(btn);
|
||||
|
||||
expect(btn.disabled).toBe(false); // reverted to idle
|
||||
expect(btn.innerHTML).toContain('Link');
|
||||
expect(btn.innerHTML).not.toContain('Linked');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('nope', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptSuggestedLink — edit mode entered mid-flight', () => {
|
||||
// The entry guard refuses an accept STARTED in edit mode, but the panel
|
||||
// survives enterEditMode, so the user can enter edit mode WHILE the POST
|
||||
// is in flight. The re-check after the await must not (a) re-render the
|
||||
// read view mid-edit, nor (b) leave the pre-link editor buffer to
|
||||
// overwrite the just-persisted link on the user's next Save.
|
||||
|
||||
function setupWithEditor() {
|
||||
const btn = setup();
|
||||
document.body.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<input id="ldr-note-title" value="T"><textarea id="note-content"></textarea>'
|
||||
);
|
||||
hook.bindEditorRefs();
|
||||
hook.setEditState({ inEdit: false, unsaved: false }); // read mode at entry
|
||||
return btn;
|
||||
}
|
||||
|
||||
it('folds the link into a PRISTINE editor buffer so a later Save keeps it', async () => {
|
||||
const btn = setupWithEditor();
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
if (url.includes('/accept-link')) {
|
||||
// User enters edit mode mid-POST; the editor is seeded with the
|
||||
// current (pre-link) note content and NOT yet typed into.
|
||||
hook.setEditState({ inEdit: true, unsaved: false });
|
||||
document.getElementById('note-content').value = 'old body';
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, note: { content: 'old body\n[[Target Note]]' } }) });
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, outgoing_links: [] }) });
|
||||
});
|
||||
|
||||
await hook.acceptSuggestedLink(btn);
|
||||
|
||||
// The link is persisted, the chip shows Linked, and — crucially — the
|
||||
// editor buffer now CONTAINS the link, so the user's next Save keeps it
|
||||
// instead of overwriting it with the stale pre-link content.
|
||||
expect(btn.innerHTML).toContain('Linked');
|
||||
expect(document.getElementById('note-content').value).toBe('old body\n[[Target Note]]');
|
||||
expect(hook.getNote().content).toBe('old body\n[[Target Note]]');
|
||||
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
});
|
||||
|
||||
it('does NOT stomp a typed editor buffer; warns and keeps the singleton current', async () => {
|
||||
const btn = setupWithEditor();
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
if (url.includes('/accept-link')) {
|
||||
// User enters edit mode mid-POST AND types unsaved WIP.
|
||||
hook.setEditState({ inEdit: true, unsaved: true });
|
||||
document.getElementById('note-content').value = 'old body + my unsaved WIP';
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, note: { content: 'old body\n[[Target Note]]' } }) });
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, outgoing_links: [] }) });
|
||||
});
|
||||
|
||||
await hook.acceptSuggestedLink(btn);
|
||||
|
||||
// The user's WIP is untouched (not stomped by the server content)...
|
||||
expect(document.getElementById('note-content').value).toBe('old body + my unsaved WIP');
|
||||
// ...the singleton is kept current so Cancel reveals the link...
|
||||
expect(hook.getNote().content).toBe('old body\n[[Target Note]]');
|
||||
// ...the chip shows Linked (link persisted)...
|
||||
expect(btn.innerHTML).toContain('Linked');
|
||||
// ...and the user is told their current edit won't include the link.
|
||||
const infoCalls = window.ui.showMessage.mock.calls.filter(c => c[1] === 'info');
|
||||
expect(infoCalls.length).toBe(1);
|
||||
expect(infoCalls[0][0]).toContain('reload');
|
||||
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptSuggestedLink — mid-edit refusal', () => {
|
||||
it('refuses without POSTing and leaves the chip actionable', async () => {
|
||||
const btn = setup();
|
||||
document.body.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<input id="ldr-note-title" value="T"><textarea id="note-content"></textarea>'
|
||||
);
|
||||
document.getElementById('note-content').value = 'draft text';
|
||||
hook.bindEditorRefs();
|
||||
hook.setEditState({ inEdit: true, unsaved: false });
|
||||
globalThis.safeFetch = vi.fn();
|
||||
|
||||
await hook.acceptSuggestedLink(btn);
|
||||
|
||||
// No server call: /accept-link would mutate the SAVED content, which
|
||||
// the user's eventual Save (stale editor buffer) would overwrite.
|
||||
// No local staging either: it loses the route's exact-target-id
|
||||
// binding and breaks on titles containing ']]' or '|'.
|
||||
expect(globalThis.safeFetch).not.toHaveBeenCalled();
|
||||
expect(document.getElementById('note-content').value).toBe('draft text');
|
||||
// The chip must stay idle/actionable — not claim "Linked".
|
||||
expect(btn.disabled).toBe(false);
|
||||
expect(btn.innerHTML).not.toContain('Linked');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith(
|
||||
'Finish editing (Save or Cancel) before accepting links.', 'info');
|
||||
expect(hook.getEditState().unsaved).toBe(false);
|
||||
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Characterization tests for pages/note-detail.js AI handlers.
|
||||
*
|
||||
* The read-only AI handlers (summarizeNote, suggestTags, ...) had no vitest
|
||||
* coverage — only Playwright. These lock their happy/empty/failure behaviour
|
||||
* so the shared-scaffold refactor can be done later under green. Each
|
||||
* handler: opens the AI panel, POSTs, renders the result on success, and on
|
||||
* failure routes through failAIAction (hide panel + error toast).
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook; output is read
|
||||
* off the #ldr-ai-results-content panel.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
globalThis.escapeHtml = (s) =>
|
||||
String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML =
|
||||
'<div id="ldr-ai-results-panel" style="display:none"><span id="ldr-ai-results-title"></span>' +
|
||||
'<div id="ldr-ai-results-content"></div></div>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'body', tags: ['x'] });
|
||||
hook.resetAiActionGuards(); // clear any in-flight guard left by a held request
|
||||
});
|
||||
|
||||
function panel() {
|
||||
return {
|
||||
title: document.getElementById('ldr-ai-results-title').textContent,
|
||||
content: document.getElementById('ldr-ai-results-content').innerHTML,
|
||||
display: document.getElementById('ldr-ai-results-panel').style.display,
|
||||
};
|
||||
}
|
||||
|
||||
describe('summarizeNote', () => {
|
||||
it('renders the summary on success', async () => {
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
expect(url).toContain('/notes/api/notes/note-1/summarize');
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, summary: 'Line one\nLine two' }) });
|
||||
});
|
||||
|
||||
await hook.summarizeNote();
|
||||
|
||||
const p = panel();
|
||||
expect(p.title).toBe('Summary');
|
||||
expect(p.content).toContain('Line one');
|
||||
expect(p.content).toContain('<br>'); // newline -> <br>
|
||||
expect(p.display).toBe('block');
|
||||
});
|
||||
|
||||
it('routes a failure through failAIAction (hides panel + error toast)', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false, error: 'no summary' }) })
|
||||
);
|
||||
|
||||
await hook.summarizeNote();
|
||||
|
||||
expect(panel().display).toBe('none'); // closeAIResults
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('no summary', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestTags', () => {
|
||||
it('renders clickable suggested tags on success', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: true, tags: ['alpha', 'beta'] }) })
|
||||
);
|
||||
|
||||
await hook.suggestTags();
|
||||
|
||||
const p = panel();
|
||||
expect(p.title).toBe('Suggested Tags');
|
||||
expect(p.content).toContain('data-tag="alpha"');
|
||||
expect(p.content).toContain('data-tag="beta"');
|
||||
});
|
||||
|
||||
it('shows the empty state when no tags are suggested', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: true, tags: [] }) })
|
||||
);
|
||||
|
||||
await hook.suggestTags();
|
||||
|
||||
expect(panel().content.toLowerCase()).toContain('no additional tags');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractResearchQuestions', () => {
|
||||
it('renders the questions on success', async () => {
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
expect(url).toContain('/notes/api/notes/note-1/research-questions');
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, questions: ['What is X?', 'Why Y?'] }) });
|
||||
});
|
||||
|
||||
await hook.extractResearchQuestions();
|
||||
|
||||
const p = panel();
|
||||
expect(p.title).toBe('Research Questions');
|
||||
expect(p.content).toContain('What is X?');
|
||||
expect(p.content).toContain('Why Y?');
|
||||
});
|
||||
|
||||
it('shows the empty state when no questions are extracted', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: true, questions: [] }) })
|
||||
);
|
||||
|
||||
await hook.extractResearchQuestions();
|
||||
|
||||
expect(panel().content.toLowerCase()).toContain('no research questions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnlinkedMentions', () => {
|
||||
it('renders mention cards with a Link button on success', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({
|
||||
success: true,
|
||||
mentions: [{ id: 'm1', title: 'Mentioner', content_preview: '...mentions this...' }],
|
||||
}) })
|
||||
);
|
||||
|
||||
await hook.findUnlinkedMentions();
|
||||
|
||||
const p = panel();
|
||||
expect(p.title).toBe('Unlinked Mentions');
|
||||
expect(p.content).toContain('data-mention-id="m1"');
|
||||
expect(p.content).toContain('Mentioner');
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no unlinked mentions', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: true, mentions: [] }) })
|
||||
);
|
||||
|
||||
await hook.findUnlinkedMentions();
|
||||
|
||||
expect(panel().content.toLowerCase()).toContain('no unlinked mentions');
|
||||
});
|
||||
});
|
||||
|
||||
// The remaining list-style handlers share the same scaffold; lock each one's
|
||||
// dispatch URL, panel title, empty state, and the shared failure path.
|
||||
describe.each([
|
||||
['findSimilarNotes', 'similar?limit=5', 'Similar Notes', 'similar_notes', 'no similar notes',
|
||||
[{ id: 'sn1', title: 'SimNote', content_preview: 'preview' }], 'SimNote'],
|
||||
['findSimilarPassages', 'similar-passages', 'Similar Passages', 'passages', 'no similar passages',
|
||||
[{ note_id: 'np1', note_title: 'PassNote', snippet: 'a snippet' }], 'PassNote'],
|
||||
['suggestLinks', 'suggested-links?limit=5', 'Suggested Links', 'suggestions', 'no link suggestions',
|
||||
[{ id: 'sl1', title: 'LinkTarget', content_preview: 'preview' }], 'LinkTarget'],
|
||||
])('%s', (fnName, urlPart, title, dataKey, emptyText, sampleData, sampleText) => {
|
||||
it('hits the right endpoint and shows the empty state', async () => {
|
||||
let calledUrl = '';
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
calledUrl = url;
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, [dataKey]: [] }) });
|
||||
});
|
||||
|
||||
await hook[fnName]();
|
||||
|
||||
expect(calledUrl).toContain(urlPart);
|
||||
const p = panel();
|
||||
expect(p.title).toBe(title);
|
||||
expect(p.content.toLowerCase()).toContain(emptyText);
|
||||
});
|
||||
|
||||
it('renders results on success', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: true, [dataKey]: sampleData }) })
|
||||
);
|
||||
|
||||
await hook[fnName]();
|
||||
|
||||
const p = panel();
|
||||
expect(p.title).toBe(title);
|
||||
expect(p.content).toContain(sampleText);
|
||||
});
|
||||
|
||||
it('routes a failure through failAIAction', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false, error: 'boom' }) })
|
||||
);
|
||||
|
||||
await hook[fnName]();
|
||||
|
||||
expect(panel().display).toBe('none');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('boom', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-entrancy guard', () => {
|
||||
it('drops a re-dispatch of the SAME action while it is in flight', async () => {
|
||||
let resolveFirst;
|
||||
const fetchMock = vi.fn(() => new Promise((r) => { resolveFirst = r; }));
|
||||
globalThis.safeFetch = fetchMock;
|
||||
|
||||
hook.summarizeNote(); // in flight — request held open
|
||||
await hook.summarizeNote(); // guarded → dropped immediately
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Release so the guard clears.
|
||||
resolveFirst({ json: () => Promise.resolve({ success: true, summary: 's' }) });
|
||||
});
|
||||
|
||||
it('still allows a DIFFERENT action to run concurrently', async () => {
|
||||
const fetchMock = vi.fn(() => new Promise(() => {})); // never resolves
|
||||
globalThis.safeFetch = fetchMock;
|
||||
|
||||
hook.summarizeNote(); // in flight
|
||||
hook.suggestTags(); // different action key → NOT blocked
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractKeyConcepts', () => {
|
||||
it('renders concept categories on success', async () => {
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
expect(url).toContain('/notes/api/notes/note-1/key-concepts');
|
||||
return Promise.resolve({ json: () => Promise.resolve({
|
||||
success: true,
|
||||
concepts: { entities: ['Marie Curie'], concepts: [], themes: ['radioactivity'] },
|
||||
}) });
|
||||
});
|
||||
|
||||
await hook.extractKeyConcepts();
|
||||
|
||||
const p = panel();
|
||||
expect(p.title).toBe('Key Concepts');
|
||||
expect(p.content).toContain('Marie Curie');
|
||||
expect(p.content).toContain('radioactivity');
|
||||
});
|
||||
|
||||
it('routes a failure through failAIAction', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false, error: 'no concepts' }) })
|
||||
);
|
||||
|
||||
await hook.extractKeyConcepts();
|
||||
|
||||
expect(panel().display).toBe('none');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('no concepts', 'error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — AI panel reopen-after-dismiss guard.
|
||||
*
|
||||
* Every AI action opens the shared results panel in a "Working…" state and
|
||||
* renders into it when the response lands. Pre-fix, only the fact-check
|
||||
* flow guarded dismissal (_factCheckGeneration): for every other action
|
||||
* (Summarize, Key Concepts, Similar Notes, Version Details, …), closing
|
||||
* the panel while the multi-second request was in flight was undone the
|
||||
* moment the response arrived — showAIResults unconditionally set
|
||||
* display:block, popping the dismissed panel back open over the page.
|
||||
*
|
||||
* Post-fix, closeAIResults bumps _aiPanelGeneration and the action's
|
||||
* success path drops the render when the generation moved.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
globalThis.escapeHtml = (s) =>
|
||||
String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML =
|
||||
'<div id="ldr-ai-results-panel" style="display:none"><span id="ldr-ai-results-title"></span>' +
|
||||
'<div id="ldr-ai-results-content"></div></div>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'body', tags: [] });
|
||||
hook.resetAiActionGuards();
|
||||
});
|
||||
|
||||
function panelDisplay() {
|
||||
return document.getElementById('ldr-ai-results-panel').style.display;
|
||||
}
|
||||
|
||||
describe('AI panel dismissed while a request is in flight', () => {
|
||||
it('does not reopen when the response lands after the user closed it', async () => {
|
||||
let resolveRequest;
|
||||
globalThis.safeFetch = vi.fn(() => new Promise((r) => { resolveRequest = r; }));
|
||||
|
||||
const action = hook.summarizeNote();
|
||||
|
||||
// The action opened the panel in its "Working…" state.
|
||||
expect(panelDisplay()).toBe('block');
|
||||
|
||||
// The user dismisses it while the LLM call is still running.
|
||||
hook.closeAIResults();
|
||||
expect(panelDisplay()).toBe('none');
|
||||
|
||||
// The response lands — pre-fix this popped the panel back open.
|
||||
resolveRequest({ json: () => Promise.resolve({ success: true, summary: 'late result' }) });
|
||||
await action;
|
||||
|
||||
expect(panelDisplay()).toBe('none');
|
||||
expect(document.getElementById('ldr-ai-results-content').innerHTML)
|
||||
.not.toContain('late result');
|
||||
});
|
||||
|
||||
it('still renders normally when the panel was not dismissed', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: true, summary: 'fresh result' }) })
|
||||
);
|
||||
|
||||
await hook.summarizeNote();
|
||||
|
||||
expect(panelDisplay()).toBe('block');
|
||||
expect(document.getElementById('ldr-ai-results-content').innerHTML)
|
||||
.toContain('fresh result');
|
||||
});
|
||||
|
||||
it('a dismissed panel can be reopened by a NEW action afterwards', async () => {
|
||||
// First action dismissed mid-flight...
|
||||
let resolveFirst;
|
||||
globalThis.safeFetch = vi.fn(() => new Promise((r) => { resolveFirst = r; }));
|
||||
const first = hook.summarizeNote();
|
||||
hook.closeAIResults();
|
||||
resolveFirst({ json: () => Promise.resolve({ success: true, summary: 'stale' }) });
|
||||
await first;
|
||||
expect(panelDisplay()).toBe('none');
|
||||
|
||||
// ...must not poison the next explicit action.
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: true, summary: 'second run' }) })
|
||||
);
|
||||
await hook.summarizeNote();
|
||||
|
||||
expect(panelDisplay()).toBe('block');
|
||||
expect(document.getElementById('ldr-ai-results-content').innerHTML)
|
||||
.toContain('second run');
|
||||
});
|
||||
});
|
||||
|
||||
describe("one AI action's failure must not eat a concurrent action's result", () => {
|
||||
it('Key Concepts still renders when a concurrent Summarize fails', async () => {
|
||||
// Summarize: held, then fails. Key Concepts: starts while Summarize
|
||||
// is in flight (different buttonId → allowed), then succeeds.
|
||||
let rejectSummary;
|
||||
let resolveConcepts;
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
if (url.includes('/summarize')) {
|
||||
return new Promise((_, rej) => { rejectSummary = rej; });
|
||||
}
|
||||
if (url.includes('/key-concepts')) {
|
||||
return new Promise((res) => { resolveConcepts = res; });
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true }) });
|
||||
});
|
||||
|
||||
const summary = hook.summarizeNote();
|
||||
expect(panelDisplay()).toBe('block'); // Summarize claimed the panel
|
||||
|
||||
const concepts = hook.extractKeyConcepts();
|
||||
// Key Concepts now owns the panel (its "Working…" is showing).
|
||||
expect(panelDisplay()).toBe('block');
|
||||
|
||||
// Summarize fails: pre-fix this closed the panel and bumped the
|
||||
// generation, dropping Key Concepts' result.
|
||||
rejectSummary(new Error('summary boom'));
|
||||
await summary;
|
||||
// Panel must still be open (Key Concepts owns it).
|
||||
expect(panelDisplay()).toBe('block');
|
||||
|
||||
resolveConcepts({
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
concepts: { entities: [], concepts: ['transformers'], themes: [] },
|
||||
}),
|
||||
});
|
||||
await concepts;
|
||||
|
||||
expect(panelDisplay()).toBe('block');
|
||||
expect(document.getElementById('ldr-ai-results-content').innerHTML)
|
||||
.toContain('transformers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyNote claims the shared panel from an in-flight action', () => {
|
||||
it("drops the slower action's late render instead of clobbering the fact-check panel", async () => {
|
||||
hook.resetVerifyState();
|
||||
let resolveSummarize;
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
if (url.includes('/summarize')) {
|
||||
return new Promise((r) => { resolveSummarize = r; });
|
||||
}
|
||||
// fact-check POST: left pending — only the first paint matters.
|
||||
return new Promise(() => {});
|
||||
});
|
||||
|
||||
const summarize = hook.summarizeNote();
|
||||
expect(panelDisplay()).toBe('block');
|
||||
|
||||
// Fact-check takes over the shared panel while Summarize is in flight.
|
||||
hook.verifyNote();
|
||||
expect(document.getElementById('ldr-ai-results-title').textContent)
|
||||
.toBe('Fact-check');
|
||||
|
||||
// Summarize lands late — pre-fix it repainted the panel mid-fact-check.
|
||||
resolveSummarize({ json: () => Promise.resolve({ success: true, summary: 'late summary' }) });
|
||||
await summarize;
|
||||
|
||||
expect(document.getElementById('ldr-ai-results-title').textContent)
|
||||
.toBe('Fact-check');
|
||||
expect(document.getElementById('ldr-ai-results-content').innerHTML)
|
||||
.toContain('Extracting checkable claims');
|
||||
expect(document.getElementById('ldr-ai-results-content').innerHTML)
|
||||
.not.toContain('late summary');
|
||||
|
||||
hook.resetVerifyState();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — renderBacklinkCard shared renderer.
|
||||
*
|
||||
* renderBacklinkCard() is the file-local helper that backs the three sidebar
|
||||
* card lists (related notes, backlinks, outgoing links). renderBacklinks() and
|
||||
* renderOutgoingLinks() were de-duplicated onto it; this test locks the
|
||||
* contract that:
|
||||
* - every dynamic field (id/title/preview) is HTML-escaped via escapeHtml,
|
||||
* - badgeHtml is appended verbatim inside the title div (NOT escaped — it is
|
||||
* trusted static markup such as the AI-suggested badge),
|
||||
* - the produced markup is byte-identical to the pre-refactor template.
|
||||
*
|
||||
* Reached through the production-inert window.__noteDetailTest hook; auto-init
|
||||
* is skipped because the harness sets window.__VITEST_TEST__ before import.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
// Production escapeHtml (security/xss-protection.js) is a global the module
|
||||
// reads at call time. Reproduce its exact semantics — note it also escapes "/".
|
||||
const HTML_ESCAPE_MAP = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
};
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof text !== 'string') {
|
||||
text = String(text);
|
||||
}
|
||||
return text.replace(/[&<>"'/]/g, (m) => HTML_ESCAPE_MAP[m]);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.escapeHtml = escapeHtml;
|
||||
window.escapeHtml = escapeHtml;
|
||||
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: true, notes: [] }) })
|
||||
);
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
delete globalThis.escapeHtml;
|
||||
delete window.escapeHtml;
|
||||
});
|
||||
|
||||
// Original 8-space-indented templates, copied verbatim from the pre-refactor
|
||||
// renderBacklinks() / renderOutgoingLinks() so we diff against the real thing.
|
||||
function origBacklinkTemplate(row) {
|
||||
return `
|
||||
<a class="ldr-sidebar-backlink" href="/notes/${escapeHtml(row.id)}">
|
||||
<div class="ldr-sidebar-backlink-title">${escapeHtml(row.title)}</div>
|
||||
<div class="ldr-sidebar-backlink-preview">${escapeHtml(row.content_preview || '')}</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
function origOutgoingTemplate(row) {
|
||||
return `
|
||||
<a class="ldr-sidebar-backlink" href="/notes/${escapeHtml(row.id)}">
|
||||
<div class="ldr-sidebar-backlink-title">${escapeHtml(row.title)}${row.auto_suggested ? ' <i class="fas fa-magic ldr-ai-link-badge" title="AI-suggested link" aria-label="AI-suggested link"></i>' : ''}</div>
|
||||
<div class="ldr-sidebar-backlink-preview">${escapeHtml(row.content_preview || '')}</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
const AI_BADGE =
|
||||
' <i class="fas fa-magic ldr-ai-link-badge" title="AI-suggested link" aria-label="AI-suggested link"></i>';
|
||||
|
||||
const SAMPLE_ROWS = [
|
||||
{ id: 'a"b<c', title: 'T & <x>', content_preview: 'preview "quoted"', auto_suggested: false },
|
||||
{ id: 'plain-id', title: 'Title', content_preview: '', auto_suggested: true },
|
||||
{ id: 'n3', title: '', content_preview: null, auto_suggested: false },
|
||||
{ id: 'n4', title: 'X', content_preview: undefined, auto_suggested: true },
|
||||
{ id: 'a/b/c', title: 'has/slash', content_preview: 'p/q', auto_suggested: false },
|
||||
];
|
||||
|
||||
describe('renderBacklinkCard — escaping contract', () => {
|
||||
it('HTML-escapes id, title and preview', () => {
|
||||
const out = hook.renderBacklinkCard({
|
||||
id: '<script>',
|
||||
title: '<b>t</b>',
|
||||
preview: 'a & b',
|
||||
});
|
||||
expect(out).not.toContain('<script>');
|
||||
expect(out).toContain('href="/notes/<script>"');
|
||||
expect(out).toContain('<b>t</b>');
|
||||
expect(out).toContain('a & b');
|
||||
});
|
||||
|
||||
it('treats null/undefined preview as empty string', () => {
|
||||
expect(hook.renderBacklinkCard({ id: 'i', title: 't', preview: null })).toContain(
|
||||
'<div class="ldr-sidebar-backlink-preview"></div>'
|
||||
);
|
||||
expect(hook.renderBacklinkCard({ id: 'i', title: 't' })).toContain(
|
||||
'<div class="ldr-sidebar-backlink-preview"></div>'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends badgeHtml verbatim inside the title div (not escaped)', () => {
|
||||
const out = hook.renderBacklinkCard({
|
||||
id: 'i',
|
||||
title: 't',
|
||||
preview: 'p',
|
||||
badgeHtml: AI_BADGE,
|
||||
});
|
||||
expect(out).toContain(`<div class="ldr-sidebar-backlink-title">t${AI_BADGE}</div>`);
|
||||
});
|
||||
|
||||
it('omits the badge when badgeHtml is empty (default)', () => {
|
||||
const out = hook.renderBacklinkCard({ id: 'i', title: 't', preview: 'p' });
|
||||
expect(out).toContain('<div class="ldr-sidebar-backlink-title">t</div>');
|
||||
expect(out).not.toContain('ldr-ai-link-badge');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderBacklinkCard — byte-identical to pre-refactor templates', () => {
|
||||
it.each(SAMPLE_ROWS)('matches renderBacklinks markup for %o', (row) => {
|
||||
const refactored = hook.renderBacklinkCard({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
preview: row.content_preview,
|
||||
});
|
||||
expect(refactored).toBe(origBacklinkTemplate(row));
|
||||
});
|
||||
|
||||
it.each(SAMPLE_ROWS)('matches renderOutgoingLinks markup for %o', (row) => {
|
||||
const refactored = hook.renderBacklinkCard({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
preview: row.content_preview,
|
||||
badgeHtml: row.auto_suggested ? AI_BADGE : '',
|
||||
});
|
||||
expect(refactored).toBe(origOutgoingTemplate(row));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — compareVersion (semantic-diff modal).
|
||||
*
|
||||
* compareVersion opens the diff modal with a loading placeholder, fetches the
|
||||
* semantic diff of a version against current, and renders the added/removed/
|
||||
* modified sections; a failure shows the error in the modal body + a toast. A
|
||||
* single-flight guard drops a re-trigger while one diff is in flight.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
window.bootstrap = { Modal: { getOrCreateInstance: () => ({ show: () => {} }) } };
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
delete window.bootstrap;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="diffModal"></div><div id="diff-modal-body"></div>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'c', tags: [] });
|
||||
});
|
||||
|
||||
const body = () => document.getElementById('diff-modal-body').innerHTML;
|
||||
|
||||
describe('compareVersion', () => {
|
||||
it('renders the diff sections on success', async () => {
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
expect(url).toContain('/versions/semantic-diff');
|
||||
expect(url).toContain('version2=current');
|
||||
return Promise.resolve({ json: () => Promise.resolve({
|
||||
success: true,
|
||||
diff: { summary: 'Some changes', added: ['a new line'], removed: ['an old line'] },
|
||||
}) });
|
||||
});
|
||||
|
||||
await hook.compareVersion('ver12345678');
|
||||
|
||||
expect(body()).toContain('Some changes');
|
||||
expect(body()).toContain('a new line');
|
||||
expect(body()).toContain('an old line');
|
||||
});
|
||||
|
||||
it('shows the error in the modal body on failure', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false, error: 'diff boom' }) })
|
||||
);
|
||||
|
||||
await hook.compareVersion('ver12345678');
|
||||
|
||||
expect(body()).toContain('diff boom');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('diff boom', 'error');
|
||||
});
|
||||
|
||||
it('single-flight guard: a re-trigger while in flight fetches once', async () => {
|
||||
let resolveFirst;
|
||||
const fetchMock = vi.fn(() => new Promise((r) => { resolveFirst = r; }));
|
||||
globalThis.safeFetch = fetchMock;
|
||||
|
||||
hook.compareVersion('ver12345678'); // in flight
|
||||
await hook.compareVersion('ver12345678'); // guarded
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
resolveFirst({ json: () => Promise.resolve({ success: false }) });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — countWords + generateTableOfContents.
|
||||
*
|
||||
* countWords counts whitespace-separated words (0 for empty/falsy).
|
||||
* generateTableOfContents scans the rendered note for h1/h2/h3, assigns each
|
||||
* an id, and builds a leveled TOC list with anchor links — or a "No headings
|
||||
* found" placeholder when there are none.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
describe('countWords', () => {
|
||||
it('counts whitespace-separated words', () => {
|
||||
expect(hook.countWords('one two three')).toBe(3);
|
||||
expect(hook.countWords(' spaced out ')).toBe(2);
|
||||
expect(hook.countWords('single')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 for empty or falsy input', () => {
|
||||
expect(hook.countWords('')).toBe(0);
|
||||
expect(hook.countWords(null)).toBe(0);
|
||||
expect(hook.countWords(' ')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTableOfContents', () => {
|
||||
it('builds a leveled TOC from the rendered headings and assigns ids', () => {
|
||||
document.body.innerHTML =
|
||||
'<div id="note-content-rendered"><h1>Intro</h1><p>x</p><h2>Details</h2><h3>Sub</h3></div>' +
|
||||
'<ul id="toc-list"></ul>';
|
||||
|
||||
hook.generateTableOfContents();
|
||||
|
||||
const items = document.querySelectorAll('#toc-list .ldr-toc-item');
|
||||
expect(items.length).toBe(3);
|
||||
expect(items[0].className).toContain('ldr-level-1');
|
||||
expect(items[1].className).toContain('ldr-level-2');
|
||||
expect(items[2].className).toContain('ldr-level-3');
|
||||
expect(document.querySelector('#toc-list a').textContent).toBe('Intro');
|
||||
// Each heading got an id so the anchor links resolve.
|
||||
const headings = document.querySelectorAll('#note-content-rendered h1, #note-content-rendered h2, #note-content-rendered h3');
|
||||
headings.forEach((h) => expect(h.id).toMatch(/^heading-\d+$/));
|
||||
});
|
||||
|
||||
it('shows a placeholder when there are no headings', () => {
|
||||
document.body.innerHTML =
|
||||
'<div id="note-content-rendered"><p>no headings here</p></div><ul id="toc-list"></ul>';
|
||||
|
||||
hook.generateTableOfContents();
|
||||
|
||||
expect(document.getElementById('toc-list').innerHTML.toLowerCase()).toContain('no headings found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — edit-mode lifecycle tag revert.
|
||||
*
|
||||
* enterEditMode snapshots note.tags; handleTagKeypress/removeTag mutate
|
||||
* note.tags in place during the edit. exitEditMode (Cancel) restores the
|
||||
* snapshot, so a tag added then cancelled does NOT silently persist on a later
|
||||
* save. If the user has unsaved changes, Cancel confirms first — and declining
|
||||
* keeps both edit mode and the in-edit tags.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML =
|
||||
'<input id="ldr-note-title" /><textarea id="note-content"></textarea>' +
|
||||
'<div id="ldr-note-tags"><input id="add-tag-input" /></div>';
|
||||
hook.bindEditorRefs();
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
});
|
||||
|
||||
describe('edit-mode lifecycle — tag revert on cancel', () => {
|
||||
it('reverts in-edit tag mutations when Cancel is taken', () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'c', tags: ['original'] });
|
||||
|
||||
hook.enterEditMode();
|
||||
// User adds a tag mid-edit (mutates note.tags in place).
|
||||
hook.getNote().tags.push('added-during-edit');
|
||||
expect(hook.getNote().tags).toEqual(['original', 'added-during-edit']);
|
||||
|
||||
hook.exitEditMode(); // Cancel
|
||||
|
||||
// Snapshot restored — the added tag does NOT persist.
|
||||
expect(hook.getNote().tags).toEqual(['original']);
|
||||
});
|
||||
|
||||
it('keeps edit mode and the in-edit tags when discard is declined', () => {
|
||||
window.confirm = vi.fn(() => false); // user declines "discard changes?"
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'c', tags: ['original'] });
|
||||
|
||||
hook.enterEditMode();
|
||||
hook.getNote().tags.push('added-during-edit');
|
||||
hook.setEditState({ inEdit: true, unsaved: true }); // there ARE unsaved edits
|
||||
|
||||
hook.exitEditMode(); // Cancel → confirm declined → no-op
|
||||
|
||||
// Declined → tags are NOT reverted (still mid-edit).
|
||||
expect(hook.getNote().tags).toEqual(['original', 'added-during-edit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual tag add/remove', () => {
|
||||
const enter = () => ({ key: 'Enter', preventDefault: () => {} });
|
||||
|
||||
function typeTag(value) {
|
||||
document.getElementById('add-tag-input').value = value;
|
||||
hook.handleTagKeypress(enter());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
});
|
||||
|
||||
it('adds a new tag on Enter and clears the input', () => {
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'c', tags: ['a'] });
|
||||
typeTag(' beta '); // trimmed
|
||||
expect(hook.getNote().tags).toEqual(['a', 'beta']);
|
||||
expect(document.getElementById('add-tag-input').value).toBe('');
|
||||
});
|
||||
|
||||
it('does not add a duplicate tag', () => {
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'c', tags: ['a'] });
|
||||
typeTag('a');
|
||||
expect(hook.getNote().tags).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('rejects an over-long tag with an error and no add', () => {
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'c', tags: [] });
|
||||
typeTag('x'.repeat(200));
|
||||
expect(hook.getNote().tags).toEqual([]);
|
||||
expect(window.ui.showMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initializes null tags without crashing', () => {
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'c', tags: null });
|
||||
typeTag('first');
|
||||
expect(hook.getNote().tags).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('removeTag removes the given tag', () => {
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'c', tags: ['keep', 'drop'] });
|
||||
hook.removeTag('drop');
|
||||
expect(hook.getNote().tags).toEqual(['keep']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cancel during an in-flight save is refused (edit not resurrected)', () => {
|
||||
it('exitEditMode is a no-op while a save is in flight', async () => {
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'original', tags: [] });
|
||||
hook.setEditState({ inEdit: true, unsaved: true });
|
||||
document.getElementById('ldr-note-title').value = 'T';
|
||||
document.getElementById('note-content').value = 'edited body';
|
||||
|
||||
// Hold the PUT open so _saveInProgress stays true.
|
||||
let resolvePut;
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (opts && opts.method === 'PUT') {
|
||||
return new Promise((r) => { resolvePut = r; });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, links: [], outgoing_links: [], versions: [], total: 0 }) });
|
||||
});
|
||||
|
||||
const saving = hook.saveNote();
|
||||
|
||||
// User clicks Cancel while the PUT is in flight — must be refused,
|
||||
// NOT discard-then-get-clobbered by the resolving save.
|
||||
window.confirm = vi.fn(() => true);
|
||||
hook.exitEditMode();
|
||||
|
||||
expect(hook.getEditState().inEdit).toBe(true); // still in edit mode
|
||||
expect(window.confirm).not.toHaveBeenCalled(); // never reached discard
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Still saving'), 'info'
|
||||
);
|
||||
|
||||
// Let the save finish cleanly.
|
||||
resolvePut({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
await saving;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — fact-check status poll -> grade -> verdicts.
|
||||
*
|
||||
* _startFactCheckPoll polls /api/research/<id>/status on a 3s setInterval;
|
||||
* once the run reaches a terminal-completed state it stops polling and calls
|
||||
* gradeFactCheck, which POSTs /grade and renders the verdicts into the AI
|
||||
* panel. A non-completed terminal state surfaces a "did not complete" message
|
||||
* instead. Driven with fake timers via the production-inert hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
window.ResearchStates = {
|
||||
isTerminal: (s) => ['completed', 'failed', 'cancelled'].includes(s),
|
||||
isCompleted: (s) => s === 'completed',
|
||||
};
|
||||
window.SemanticSearch = { isSafeExternalUrl: () => true };
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
delete window.ResearchStates;
|
||||
delete window.SemanticSearch;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML =
|
||||
'<div id="ldr-ai-results-panel" style="display:none"><span id="ldr-ai-results-title"></span>' +
|
||||
'<div id="ldr-ai-results-content"></div></div>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'c', tags: [] });
|
||||
hook.resetVerifyState();
|
||||
});
|
||||
|
||||
const content = () => document.getElementById('ldr-ai-results-content').innerHTML;
|
||||
|
||||
describe('fact-check poll', () => {
|
||||
it('polls until completed, then grades and renders verdicts', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let statusCalls = 0;
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
if (url.includes('/status')) {
|
||||
statusCalls += 1;
|
||||
// First tick: still running; second tick: completed.
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: statusCalls === 1 ? 'in_progress' : 'completed' }) });
|
||||
}
|
||||
if (url.includes('/grade')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({
|
||||
success: true,
|
||||
verdicts: [{ verdict: 'supported', claim: 'The sky is blue', confidence: 90, sources: [] }],
|
||||
}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
});
|
||||
|
||||
hook._startFactCheckPoll('r1', ['The sky is blue']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000); // tick 1 -> in_progress
|
||||
expect(content()).not.toContain('The sky is blue');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000); // tick 2 -> completed -> grade
|
||||
|
||||
expect(content()).toContain('The sky is blue');
|
||||
expect(content()).toContain('Supported');
|
||||
|
||||
// Poll stopped — no further /status calls after completion.
|
||||
const before = globalThis.safeFetch.mock.calls.filter(([u]) => u.includes('/status')).length;
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
const after = globalThis.safeFetch.mock.calls.filter(([u]) => u.includes('/status')).length;
|
||||
expect(after).toBe(before);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces a "did not complete" message for a terminal non-completed status', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
globalThis.safeFetch = vi.fn((url) => {
|
||||
if (url.includes('/status')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'failed' }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
});
|
||||
|
||||
hook._startFactCheckPoll('r1', ['claim']);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(content().toLowerCase()).toContain('did not complete');
|
||||
// No grading was attempted.
|
||||
const graded = globalThis.safeFetch.mock.calls.some(([u]) => u.includes('/grade'));
|
||||
expect(graded).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — renderGradeFailure.
|
||||
*
|
||||
* When the research COMPLETED but grading failed, renderGradeFailure keeps the
|
||||
* completed research and offers a "Retry grading" affordance (re-grade without
|
||||
* re-running the research). A 502 (report temporarily unavailable) is softened;
|
||||
* other errors show the server message (or a generic fallback).
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML =
|
||||
'<div id="ldr-ai-results-panel"><span id="ldr-ai-results-title"></span>' +
|
||||
'<div id="ldr-ai-results-content"></div></div>';
|
||||
});
|
||||
|
||||
const content = () => document.getElementById('ldr-ai-results-content').innerHTML;
|
||||
|
||||
describe('renderGradeFailure', () => {
|
||||
it('offers a retry affordance carrying the research id, plus a report link', () => {
|
||||
hook.renderGradeFailure('res-1', ['c'], 'hard error', 500);
|
||||
|
||||
const html = content();
|
||||
expect(html).toContain('hard error');
|
||||
expect(html).toContain('Retry grading');
|
||||
expect(html).toContain('data-research-id="res-1"');
|
||||
expect(html).toContain('/results/res-1'); // view full report link
|
||||
});
|
||||
|
||||
it('softens the 502 (report temporarily unavailable) copy', () => {
|
||||
hook.renderGradeFailure('res-1', ['c'], 'Bad Gateway', 502);
|
||||
|
||||
const html = content().toLowerCase();
|
||||
expect(html).toContain('temporarily unavailable');
|
||||
expect(html).not.toContain('bad gateway');
|
||||
});
|
||||
|
||||
it('falls back to a generic message when none is given', () => {
|
||||
hook.renderGradeFailure('res-1', ['c'], undefined, 500);
|
||||
expect(content()).toContain('Grading failed.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — indexNoteToCollection ("Index for Search").
|
||||
*
|
||||
* POSTs /index for the note + collection; on success reports the chunk count
|
||||
* and refreshes the note's collections, otherwise surfaces the error.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, collections: [] }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'c', tags: [] });
|
||||
});
|
||||
|
||||
describe('indexNoteToCollection', () => {
|
||||
it('POSTs the index request and reports the chunk count on success', async () => {
|
||||
let body = null;
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (url.includes('/index')) {
|
||||
body = JSON.parse(opts.body);
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, result: { chunk_count: 7 } }) });
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, collections: [] }) });
|
||||
});
|
||||
|
||||
await hook.indexNoteToCollection('coll-1');
|
||||
|
||||
expect(body).toMatchObject({ collection_id: 'coll-1', force_reindex: false });
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('7 chunks'), 'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the error on a non-success response', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false, error: 'no embedding model' }) })
|
||||
);
|
||||
|
||||
await hook.indexNoteToCollection('coll-1');
|
||||
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('no embedding model', 'error');
|
||||
});
|
||||
|
||||
it('surfaces a generic error on a thrown request', async () => {
|
||||
globalThis.safeFetch = vi.fn(() => Promise.reject(new Error('net down')));
|
||||
|
||||
await hook.indexNoteToCollection('coll-1');
|
||||
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('Failed to index note', 'error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — deleteNote + collection-membership mutations.
|
||||
*
|
||||
* deleteNote confirms, DELETEs the note, and navigates to the list on success.
|
||||
* addToCollection validates a selection, POSTs, and is re-entrancy guarded.
|
||||
* removeFromCollection DELETEs the membership. All surface server errors.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, collections: [] }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<select id="collection-select"><option value="">--</option><option value="c1">C1</option></select>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
window.URLValidator = { safeAssign: vi.fn() };
|
||||
window.confirm = vi.fn(() => true);
|
||||
hook.setCollectionModal({ hide: vi.fn() });
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'c', tags: [] });
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('DELETEs and navigates to the list on success', async () => {
|
||||
let method = '';
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
method = opts.method;
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true }) });
|
||||
});
|
||||
|
||||
await hook.deleteNote();
|
||||
|
||||
expect(method).toBe('DELETE');
|
||||
expect(window.URLValidator.safeAssign).toHaveBeenCalledWith(window.location, 'href', '/notes/');
|
||||
});
|
||||
|
||||
it('does nothing when the confirm is declined', async () => {
|
||||
window.confirm = vi.fn(() => false);
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.safeFetch = fetchMock;
|
||||
|
||||
await hook.deleteNote();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces an error without navigating on failure', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false, error: 'cannot delete' }) })
|
||||
);
|
||||
|
||||
await hook.deleteNote();
|
||||
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('cannot delete', 'error');
|
||||
expect(window.URLValidator.safeAssign).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToCollection', () => {
|
||||
it('rejects an empty selection with no POST', async () => {
|
||||
document.getElementById('collection-select').value = '';
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.safeFetch = fetchMock;
|
||||
|
||||
await hook.addToCollection(null);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('Please select a collection', 'error');
|
||||
});
|
||||
|
||||
it('POSTs and reports success', async () => {
|
||||
document.getElementById('collection-select').value = 'c1';
|
||||
let body = null;
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (opts && opts.method === 'POST') {
|
||||
body = JSON.parse(opts.body);
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true }) });
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, collections: [] }) });
|
||||
});
|
||||
|
||||
await hook.addToCollection(null);
|
||||
|
||||
expect(body).toMatchObject({ collection_id: 'c1' });
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('Added to collection', 'success');
|
||||
});
|
||||
|
||||
it('re-entrancy guard: a double-activation POSTs once', async () => {
|
||||
document.getElementById('collection-select').value = 'c1';
|
||||
let resolveFirst;
|
||||
const fetchMock = vi.fn(() => new Promise((r) => { resolveFirst = r; }));
|
||||
globalThis.safeFetch = fetchMock;
|
||||
|
||||
hook.addToCollection(null); // in flight
|
||||
await hook.addToCollection(null); // guarded
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
resolveFirst({ json: () => Promise.resolve({ success: false }) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromCollection', () => {
|
||||
it('DELETEs the membership and reports success', async () => {
|
||||
let url = '';
|
||||
globalThis.safeFetch = vi.fn((u, opts) => {
|
||||
if (opts && opts.method === 'DELETE') {
|
||||
url = u;
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true }) });
|
||||
}
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: true, collections: [] }) });
|
||||
});
|
||||
|
||||
await hook.removeFromCollection('c1', null);
|
||||
|
||||
expect(url).toContain('/collections/c1');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('Removed from collection', 'success');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — loadNoteCollections + showFactCheckStatus.
|
||||
*
|
||||
* loadNoteCollections loads the note's collections and derives note.is_indexed
|
||||
* from membership (indexed iff at least one collection has it indexed), keeping
|
||||
* the badge in sync after both index and un-index. showFactCheckStatus renders
|
||||
* the status message + the claims list into the AI panel.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, collections: [] }) })
|
||||
);
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
describe('loadNoteCollections — is_indexed derivation', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML =
|
||||
'<div id="ldr-note-collections"><button class="ldr-add-collection-btn"></button></div>' +
|
||||
'<span id="note-indexed"></span>';
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'c', tags: [], is_indexed: false });
|
||||
});
|
||||
|
||||
it('marks the note indexed when any collection has it indexed', async () => {
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({
|
||||
success: true,
|
||||
collections: [{ id: 'c1', name: 'A', indexed: false }, { id: 'c2', name: 'B', indexed: true }],
|
||||
}) })
|
||||
);
|
||||
|
||||
await hook.loadNoteCollections();
|
||||
|
||||
expect(hook.getNote().is_indexed).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the note not-indexed when no collection has it indexed', async () => {
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'c', tags: [], is_indexed: true });
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ json: () => Promise.resolve({
|
||||
success: true,
|
||||
collections: [{ id: 'c1', name: 'A', indexed: false }],
|
||||
}) })
|
||||
);
|
||||
|
||||
await hook.loadNoteCollections();
|
||||
|
||||
expect(hook.getNote().is_indexed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFactCheckStatus', () => {
|
||||
it('renders the status message and the claims list', () => {
|
||||
document.body.innerHTML =
|
||||
'<div id="ldr-ai-results-panel"><span id="ldr-ai-results-title"></span>' +
|
||||
'<div id="ldr-ai-results-content"></div></div>';
|
||||
|
||||
hook.showFactCheckStatus('Researching…', ['The sky is blue', 'Water is wet']);
|
||||
|
||||
const html = document.getElementById('ldr-ai-results-content').innerHTML;
|
||||
expect(html).toContain('Researching');
|
||||
expect(html).toContain('The sky is blue');
|
||||
expect(html).toContain('Water is wet');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — partial-save payloads and the
|
||||
* unsaved-changes guard across read-mode saves.
|
||||
*
|
||||
* saveNote diffs against the last server-acked state and sends only the
|
||||
* changed fields: a pin toggle must not re-upload the whole note body
|
||||
* (content/title in the payload is also what schedules the server's
|
||||
* auto-index worker), and a no-change save must not PUT at all.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
function setupDom() {
|
||||
document.body.innerHTML =
|
||||
'<span id="note-title-display"></span><span id="note-updated"></span>' +
|
||||
'<span id="note-word-count"></span><div id="ldr-versions-list"></div>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
}
|
||||
|
||||
function mockFetch(puts) {
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (opts && opts.method === 'PUT') {
|
||||
puts.push(JSON.parse(opts.body));
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true, links: [], outgoing_links: [], versions: [], total: 0 }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('partial-save payloads', () => {
|
||||
it('first save (no baseline) sends the full payload', async () => {
|
||||
setupDom();
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'body', tags: ['a'], pinned: false });
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
|
||||
const puts = [];
|
||||
mockFetch(puts);
|
||||
|
||||
expect(await hook.saveNote()).toBe('saved');
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(Object.keys(puts[0]).sort()).toEqual(['content', 'pinned', 'tags', 'title']);
|
||||
});
|
||||
|
||||
it('a pin-only change sends only {pinned} — no content re-upload', async () => {
|
||||
setupDom();
|
||||
const note = { id: 'n1', title: 'T', content: 'a large body', tags: ['a'], pinned: false };
|
||||
hook.setNote(note);
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
|
||||
const puts = [];
|
||||
mockFetch(puts);
|
||||
|
||||
// Establish the server-acked baseline.
|
||||
await hook.saveNote();
|
||||
expect(puts).toHaveLength(1);
|
||||
|
||||
// Pin flip: only the changed field goes over the wire. The absent
|
||||
// content/title keys are also what keeps the server from queueing
|
||||
// its (no-op) auto-index job for a pin toggle.
|
||||
note.pinned = true;
|
||||
expect(await hook.saveNote()).toBe('saved');
|
||||
expect(puts).toHaveLength(2);
|
||||
expect(puts[1]).toEqual({ pinned: true });
|
||||
});
|
||||
|
||||
it('a no-change save skips the PUT entirely and still reports saved', async () => {
|
||||
setupDom();
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'body', tags: [], pinned: false });
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
|
||||
const puts = [];
|
||||
mockFetch(puts);
|
||||
|
||||
await hook.saveNote(); // baseline
|
||||
expect(puts).toHaveLength(1);
|
||||
|
||||
expect(await hook.saveNote()).toBe('saved');
|
||||
expect(puts).toHaveLength(1); // no second PUT
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsaved-changes guard across read-mode saves', () => {
|
||||
it('a read-mode save completing after the user entered edit mode keeps the guard armed', async () => {
|
||||
setupDom();
|
||||
const note = { id: 'n1', title: 'T', content: 'body', tags: [], pinned: false };
|
||||
hook.setNote(note);
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
|
||||
// Defer the PUT so we can interleave the user's edit-mode entry.
|
||||
let resolvePut;
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (opts && opts.method === 'PUT') {
|
||||
return new Promise((resolve) => {
|
||||
resolvePut = () => resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true, links: [], outgoing_links: [], versions: [], total: 0 }),
|
||||
});
|
||||
});
|
||||
|
||||
note.pinned = true; // read-mode mutation prompting the save
|
||||
const savePromise = hook.saveNote();
|
||||
|
||||
// While the PUT is in flight the user opens the editor and types.
|
||||
hook.setEditState({ inEdit: true, unsaved: true });
|
||||
|
||||
resolvePut();
|
||||
expect(await savePromise).toBe('saved');
|
||||
|
||||
// Pre-fix the success path overwrote hasUnsavedChanges=false even
|
||||
// though this save never looked at the editor — the beforeunload
|
||||
// guard silently disarmed and the typed edits could be lost.
|
||||
expect(hook.getEditState()).toEqual({ inEdit: true, unsaved: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — loadRelatedNotesForSidebar error-vs-empty.
|
||||
*
|
||||
* Locks the failure-vs-empty distinction in note-detail.js
|
||||
* (loadRelatedNotesForSidebar):
|
||||
*
|
||||
* - A genuine success with an empty similar_notes list renders the
|
||||
* "No related notes found" empty state and marks the panel 'loaded'.
|
||||
* - A non-2xx response, a `{ success: false }` body, or a thrown fetch is a
|
||||
* LOAD FAILURE: it must render a distinct, retryable error state (NOT the
|
||||
* "No related notes found" empty state) and reset the load-state flag to
|
||||
* 'idle' so a collapse/re-expand (and the Retry button) re-fetches.
|
||||
*
|
||||
* Revert guard: if the fix is reverted (error path collapsed back into the
|
||||
* empty branch with _relatedNotesLoadState = 'loaded'), the failure tests fail
|
||||
* because the panel shows "No related notes found", has no Retry button, and
|
||||
* stays 'loaded'.
|
||||
*
|
||||
* These exercise module-private state via the production-inert
|
||||
* window.__noteDetailTest hook. The auto-init is skipped because the harness
|
||||
* sets window.__VITEST_TEST__ before import.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Skip the page bootstrap (DOMContentLoaded -> loadNote/fetch/Bootstrap).
|
||||
window.__VITEST_TEST__ = true;
|
||||
|
||||
// The loader calls a bare global safeFetch; renderSidebarLoadError uses a
|
||||
// bare global escapeHtml. Provide stubs so import + render paths never throw.
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, similar_notes: [] }) })
|
||||
);
|
||||
globalThis.escapeHtml = (s) => String(s);
|
||||
globalThis.formatDateTime = (s) => String(s);
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
hook.setNote({ id: 'n1' });
|
||||
// The loader writes into #sidebar-related.
|
||||
document.body.innerHTML = '<div id="sidebar-related"></div>';
|
||||
globalThis.safeFetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
hook.resetNote();
|
||||
});
|
||||
|
||||
function relatedContainer() {
|
||||
return document.getElementById('sidebar-related');
|
||||
}
|
||||
|
||||
describe('loadRelatedNotesForSidebar — error vs empty', () => {
|
||||
it('renders the empty state (not an error) on a genuine success with no results', async () => {
|
||||
globalThis.safeFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true, similar_notes: [] }),
|
||||
});
|
||||
|
||||
await hook.loadRelatedNotesForSidebar();
|
||||
|
||||
const html = relatedContainer().innerHTML;
|
||||
expect(html).toContain('No related notes found');
|
||||
// The empty state is NOT a retryable error state.
|
||||
expect(html).not.toContain('ldr-sidebar-retry');
|
||||
// Success path is terminal: it stays 'loaded', no re-fetch on re-expand.
|
||||
expect(hook.getRelatedNotesLoadState()).toBe('loaded');
|
||||
});
|
||||
|
||||
it('renders a retryable error (not the empty state) on a 500 / success:false body', async () => {
|
||||
globalThis.safeFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ success: false, error: 'boom' }),
|
||||
});
|
||||
|
||||
await hook.loadRelatedNotesForSidebar();
|
||||
|
||||
const html = relatedContainer().innerHTML;
|
||||
// Must NOT masquerade as an empty result.
|
||||
expect(html).not.toContain('No related notes found');
|
||||
// Distinct, retryable error state with a Retry button.
|
||||
expect(html).toContain('ldr-sidebar-retry');
|
||||
expect(html).toContain("Couldn't load related notes");
|
||||
// Reset to 'idle' so re-expand (and Retry) re-fetches.
|
||||
expect(hook.getRelatedNotesLoadState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('renders a retryable error on a 200 body with success:false (server-signalled error)', async () => {
|
||||
globalThis.safeFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ success: false, error: 'embedding service down' }),
|
||||
});
|
||||
|
||||
await hook.loadRelatedNotesForSidebar();
|
||||
|
||||
const html = relatedContainer().innerHTML;
|
||||
expect(html).not.toContain('No related notes found');
|
||||
expect(html).toContain('ldr-sidebar-retry');
|
||||
expect(hook.getRelatedNotesLoadState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('renders a retryable error when the fetch itself throws', async () => {
|
||||
globalThis.safeFetch.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await hook.loadRelatedNotesForSidebar();
|
||||
|
||||
const html = relatedContainer().innerHTML;
|
||||
expect(html).not.toContain('No related notes found');
|
||||
expect(html).toContain('ldr-sidebar-retry');
|
||||
expect(hook.getRelatedNotesLoadState()).toBe('idle');
|
||||
});
|
||||
|
||||
it('Retry button re-invokes the loader (recovers to a result on the retry)', async () => {
|
||||
// First load fails (500), then the Retry click succeeds with one note.
|
||||
globalThis.safeFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ success: false }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
similar_notes: [{ id: 'r1', title: 'Related', content_preview: 'preview' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await hook.loadRelatedNotesForSidebar();
|
||||
expect(globalThis.safeFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const retryBtn = relatedContainer().querySelector('.ldr-sidebar-retry');
|
||||
expect(retryBtn).not.toBeNull();
|
||||
|
||||
retryBtn.click();
|
||||
// Let the async retry settle.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(globalThis.safeFetch).toHaveBeenCalledTimes(2);
|
||||
const html = relatedContainer().innerHTML;
|
||||
expect(html).toContain('Related');
|
||||
expect(html).not.toContain('ldr-sidebar-retry');
|
||||
expect(hook.getRelatedNotesLoadState()).toBe('loaded');
|
||||
});
|
||||
|
||||
it('renders the result cards on a genuine success with results', async () => {
|
||||
globalThis.safeFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
similar_notes: [{ id: 'r1', title: 'My Note', content_preview: 'snippet' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await hook.loadRelatedNotesForSidebar();
|
||||
|
||||
const html = relatedContainer().innerHTML;
|
||||
expect(html).toContain('My Note');
|
||||
expect(html).not.toContain('No related notes found');
|
||||
expect(html).not.toContain('ldr-sidebar-retry');
|
||||
expect(hook.getRelatedNotesLoadState()).toBe('loaded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — toggleResearchCollapsed's per-item
|
||||
* in-flight guard.
|
||||
*
|
||||
* Two rapid clicks on the same research item's collapse chevron would
|
||||
* otherwise fire two independent, unordered PATCHes and could persist the
|
||||
* OPPOSITE of the user's final choice. A per-item dataset guard refuses the
|
||||
* second click until the first PATCH settles.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
function researchItem() {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'ldr-research-item';
|
||||
item.setAttribute('data-research-id', 'r1');
|
||||
item.innerHTML =
|
||||
'<button class="ldr-research-item-collapse-btn" aria-expanded="true">' +
|
||||
'<i class="fas fa-chevron-down"></i></button>';
|
||||
document.body.appendChild(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'c', tags: [] });
|
||||
});
|
||||
|
||||
describe('toggleResearchCollapsed in-flight guard', () => {
|
||||
it('refuses a second collapse click while the first PATCH is in flight', async () => {
|
||||
const item = researchItem();
|
||||
const fetchMock = vi.fn(() => new Promise(() => {})); // held forever
|
||||
globalThis.safeFetch = fetchMock;
|
||||
|
||||
hook.toggleResearchCollapsed(item); // first click — PATCH in flight
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(item.dataset.collapsePending).toBe('1');
|
||||
|
||||
await hook.toggleResearchCollapsed(item); // second click — refused
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // no second PATCH raced
|
||||
});
|
||||
|
||||
it('clears the guard after the PATCH settles so a later click works', async () => {
|
||||
const item = researchItem();
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: false, json: () => Promise.resolve({ success: false }) })
|
||||
);
|
||||
|
||||
await hook.toggleResearchCollapsed(item);
|
||||
|
||||
// finally{} clears the per-item guard even on failure.
|
||||
expect(item.dataset.collapsePending).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — restoreVersion unsaved-edits confirm.
|
||||
*
|
||||
* Restoring a version reloads the note from the server's snapshot of the
|
||||
* chosen version; it does NOT save the live editor buffer first. The old
|
||||
* confirm copy falsely promised "your current content will be saved as a new
|
||||
* version", so a mid-edit restore silently discarded unsaved edits. The fix
|
||||
* tells the truth and explicitly warns when there are unsaved edits to lose.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook; confirm is
|
||||
* declined so the network/reload path is never reached.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
hook.setNote({ id: 'note-1' });
|
||||
window.confirm = vi.fn(() => false); // decline → no restore fetch/reload
|
||||
});
|
||||
|
||||
describe('restoreVersion — unsaved-edits confirm copy', () => {
|
||||
it('warns that unsaved edits will be discarded when editing with unsaved changes', async () => {
|
||||
hook.setEditState({ inEdit: true, unsaved: true });
|
||||
|
||||
await hook.restoreVersion('v1');
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledTimes(1);
|
||||
const msg = window.confirm.mock.calls[0][0].toLowerCase();
|
||||
expect(msg).toContain('unsaved edits will be discarded');
|
||||
// The old, false promise must be gone.
|
||||
expect(msg).not.toContain('saved as a new version');
|
||||
});
|
||||
|
||||
it('uses an accurate copy (no false "will be saved") when there are no unsaved edits', async () => {
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
|
||||
await hook.restoreVersion('v1');
|
||||
|
||||
const msg = window.confirm.mock.calls[0][0].toLowerCase();
|
||||
expect(msg).toContain('replaces the current note content');
|
||||
expect(msg).not.toContain('saved as a new version');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreVersion — blocked while a save is in flight', () => {
|
||||
it('refuses (no confirm, no restore POST) while the save PUT is pending', async () => {
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'body', tags: [], pinned: false });
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
// Even an accepting user must not reach the restore POST.
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
let resolvePut;
|
||||
globalThis.safeFetch = vi.fn(() => new Promise((resolve) => { resolvePut = resolve; }));
|
||||
|
||||
// Read-mode save: PUT dispatched with note's content, left hanging.
|
||||
const savePromise = hook.saveNote();
|
||||
|
||||
await hook.restoreVersion('v1');
|
||||
|
||||
expect(window.confirm).not.toHaveBeenCalled();
|
||||
// Only the save PUT hit the network — no /restore POST raced it.
|
||||
expect(globalThis.safeFetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.safeFetch.mock.calls[0][0]).not.toContain('/restore');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('Still saving — please wait.', 'info');
|
||||
|
||||
// Settle the hanging PUT (failure branch keeps DOM needs minimal).
|
||||
resolvePut({ json: () => Promise.resolve({ success: false, error: 'nope' }) });
|
||||
await savePromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveNote — blocked while a restore is in flight (mirror guard)', () => {
|
||||
it("refuses (no PUT) during the restore's round-trip", async () => {
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'body', tags: [], pinned: false });
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
let resolveRestore;
|
||||
globalThis.safeFetch = vi.fn(() => new Promise((resolve) => { resolveRestore = resolve; }));
|
||||
|
||||
// Restore POST dispatched, left hanging.
|
||||
const restorePromise = hook.restoreVersion('v1');
|
||||
|
||||
const outcome = await hook.saveNote();
|
||||
|
||||
// The save must refuse outright — not queue: a queued body would
|
||||
// still be pre-restore state and would overwrite the restore.
|
||||
expect(outcome).toBe('failed');
|
||||
expect(globalThis.safeFetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.safeFetch.mock.calls[0][0]).toContain('/restore');
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('Restore in progress — please wait.', 'info');
|
||||
|
||||
// Settle the hanging restore (failure branch keeps DOM needs minimal).
|
||||
resolveRestore({ json: () => Promise.resolve({ success: false, error: 'nope' }) });
|
||||
await restorePromise;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — keystrokes during an in-flight save
|
||||
* (editor drift).
|
||||
*
|
||||
* saveNote snapshots the editor when the save starts, then awaits the PUT
|
||||
* plus two follow-up fetches (outgoing links, version history) — a window
|
||||
* of one-to-several seconds in which the textarea stays editable. Pre-fix,
|
||||
* the success path force-exited edit mode and cleared hasUnsavedChanges
|
||||
* unconditionally: read mode rendered the pre-drift content and re-entering
|
||||
* edit repopulated the textarea from note.content, permanently discarding
|
||||
* everything typed after Save was clicked.
|
||||
*
|
||||
* Post-fix the success path compares the live editor against the saved
|
||||
* snapshot: on drift it stays in edit mode, keeps hasUnsavedChanges armed,
|
||||
* and chains a follow-up save that persists the trailing keystrokes.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
function setupDom() {
|
||||
document.body.innerHTML =
|
||||
'<input id="ldr-note-title" /><textarea id="note-content"></textarea>' +
|
||||
'<span id="note-title-display"></span><span id="note-updated"></span>' +
|
||||
'<span id="note-word-count"></span><div id="ldr-versions-list"></div>';
|
||||
hook.bindEditorRefs();
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
}
|
||||
|
||||
describe('keystrokes during an in-flight save (editor drift)', () => {
|
||||
it('stays in edit mode and chains a save carrying the trailing keystrokes', async () => {
|
||||
setupDom();
|
||||
hook.setNote({ id: 'n1', title: 'T', content: 'original', tags: [], pinned: false });
|
||||
hook.setEditState({ inEdit: true, unsaved: true });
|
||||
document.getElementById('ldr-note-title').value = 'T';
|
||||
document.getElementById('note-content').value = 'original';
|
||||
|
||||
let resolveFirstPut;
|
||||
const putBodies = [];
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (opts && opts.method === 'PUT') {
|
||||
putBodies.push(JSON.parse(opts.body));
|
||||
if (putBodies.length === 1) {
|
||||
// Hold PUT 1 open so we can type "during" it.
|
||||
return new Promise((r) => { resolveFirstPut = r; });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, links: [], outgoing_links: [], versions: [], total: 0 }) });
|
||||
});
|
||||
|
||||
const firstSave = hook.saveNote();
|
||||
expect(putBodies).toHaveLength(1);
|
||||
expect(putBodies[0].content).toBe('original');
|
||||
|
||||
// The user keeps typing while the PUT is in flight.
|
||||
document.getElementById('note-content').value = 'original plus trailing keystrokes';
|
||||
|
||||
resolveFirstPut({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
const outcome = await firstSave; // resolves after the chained save
|
||||
|
||||
// Edit mode must survive — exiting would render pre-drift content
|
||||
// and a later edit re-entry would clobber the textarea.
|
||||
expect(hook.getEditState().inEdit).toBe(true);
|
||||
// The chained follow-up save persisted the trailing keystrokes.
|
||||
expect(putBodies).toHaveLength(2);
|
||||
expect(putBodies[1].content).toBe('original plus trailing keystrokes');
|
||||
expect(outcome).toBe('saved');
|
||||
// Nothing is pending anymore: the flag is truthfully clear.
|
||||
expect(hook.getEditState().unsaved).toBe(false);
|
||||
});
|
||||
|
||||
it('clean save (no drift) still exits edit mode and clears the flag', async () => {
|
||||
setupDom();
|
||||
hook.setNote({ id: 'n2', title: 'T', content: 'stable', tags: [], pinned: false });
|
||||
hook.setEditState({ inEdit: true, unsaved: true });
|
||||
document.getElementById('ldr-note-title').value = 'T';
|
||||
document.getElementById('note-content').value = 'stable';
|
||||
|
||||
let putCount = 0;
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (opts && opts.method === 'PUT') {
|
||||
putCount += 1;
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, links: [], outgoing_links: [], versions: [], total: 0 }) });
|
||||
});
|
||||
|
||||
const outcome = await hook.saveNote();
|
||||
|
||||
expect(outcome).toBe('saved');
|
||||
expect(putCount).toBe(1); // no spurious chained save
|
||||
expect(hook.getEditState().inEdit).toBe(false);
|
||||
expect(hook.getEditState().unsaved).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Tests for pages/note-detail.js — post-save refresh failure.
|
||||
*
|
||||
* After a successful PUT, saveNote refreshes the outgoing-links and
|
||||
* version-history panels. Those refreshes are non-fatal (each wrapped in its
|
||||
* own try/catch). A failure there must NOT turn a successful save into a
|
||||
* failure: the user still sees "Note saved" and the save outcome is 'saved'.
|
||||
*
|
||||
* Driven via the production-inert window.__noteDetailTest hook.
|
||||
*/
|
||||
|
||||
let hook;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.__VITEST_TEST__ = true;
|
||||
globalThis.safeFetch = vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })
|
||||
);
|
||||
globalThis.getCSRFToken = () => 'csrf';
|
||||
globalThis.escapeHtml = (s) => String(s ?? '');
|
||||
|
||||
await import('@js/pages/note-detail.js');
|
||||
hook = window.__noteDetailTest;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete window.__VITEST_TEST__;
|
||||
});
|
||||
|
||||
describe('post-save refresh failure', () => {
|
||||
it('a failing version-history refresh does not abort the save', async () => {
|
||||
document.body.innerHTML =
|
||||
'<span id="note-title-display"></span><span id="note-updated"></span>' +
|
||||
'<span id="note-word-count"></span><div id="ldr-versions-list"></div>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'c', tags: [], pinned: false });
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
|
||||
let putCount = 0;
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (opts && opts.method === 'PUT') {
|
||||
putCount += 1;
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
}
|
||||
if (url.includes('/versions')) {
|
||||
// Post-save refresh blows up.
|
||||
return Promise.reject(new Error('versions refresh failed'));
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, links: [], outgoing_links: [] }) });
|
||||
});
|
||||
|
||||
const outcome = await hook.saveNote();
|
||||
|
||||
expect(putCount).toBe(1); // the save itself happened
|
||||
expect(outcome).toBe('saved'); // and is reported as saved...
|
||||
expect(window.ui.showMessage).toHaveBeenCalledWith('Note saved', 'success');
|
||||
});
|
||||
|
||||
it('refreshes the version-history panel in place after a save', async () => {
|
||||
document.body.innerHTML =
|
||||
'<span id="note-title-display"></span><span id="note-updated"></span>' +
|
||||
'<span id="note-word-count"></span><div id="ldr-versions-list"></div>';
|
||||
window.ui = { showMessage: vi.fn() };
|
||||
hook.setNote({ id: 'note-1', title: 'T', content: 'c', tags: [], pinned: false });
|
||||
hook.setEditState({ inEdit: false, unsaved: false });
|
||||
|
||||
globalThis.safeFetch = vi.fn((url, opts) => {
|
||||
if (opts && opts.method === 'PUT') {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
}
|
||||
if (url.includes('/versions')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({
|
||||
success: true,
|
||||
total: 2,
|
||||
versions: [
|
||||
{ id: 'v2', version_number: 2, change_type: 'manual_save', created_at: '2024-01-02T00:00:00Z' },
|
||||
{ id: 'v1', version_number: 1, change_type: 'initial', created_at: '2024-01-01T00:00:00Z' },
|
||||
],
|
||||
}) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, links: [], outgoing_links: [] }) });
|
||||
});
|
||||
|
||||
await hook.saveNote();
|
||||
|
||||
// The version panel refreshed in place (no navigation) with the new rows.
|
||||
const list = document.getElementById('ldr-versions-list').innerHTML;
|
||||
expect(list).toContain('Version 2');
|
||||
expect(list).toContain('Version 1');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user