diff --git a/.github/workflows/graph-studio-release.yml b/.github/workflows/graph-studio-release.yml new file mode 100644 index 0000000..caffefb --- /dev/null +++ b/.github/workflows/graph-studio-release.yml @@ -0,0 +1,139 @@ +name: GraphPaper 0.6 graph studio release +on: + push: + branches: [main, feat/graph-studio-v0.6] + paths: + - 'graphpaper/**' + - 'ui/**' + - 'scripts/**' + - 'tests/**' + - 'docs/RELEASE-0.6.0.md' + - '.github/workflows/graph-studio-release.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: graph-studio-${{ github.ref }} + cancel-in-progress: true +jobs: + source: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + outputs: + revision: ${{ steps.source.outputs.revision }} + steps: + - uses: actions/checkout@v4 + - name: Refresh the source manifest without changing application code or workflows + id: source + env: + TARGET_BRANCH: ${{ github.ref_name }} + run: | + python scripts/prepare_assets.py --manifest + git add publish-manifest.json + if ! git diff --cached --quiet; then + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'Refresh graph studio source publishing manifest' + git push origin "HEAD:$TARGET_BRANCH" + fi + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + browser: + needs: source + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.source.outputs.revision }} + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - run: python -m pip install -q -r requirements-dev.txt + - run: python -m pytest -q --junitxml=test-results/linux.xml + - run: node --test tests/graph_math.test.cjs + - run: python scripts/docs_tool.py check + - run: python -m playwright install chromium + - run: python scripts/ui_smoke.py + - run: python scripts/ui_studio_smoke.py + - run: python scripts/ui_science_smoke.py + - run: python scripts/ui_graphify_smoke.py + - run: python scripts/ui_polemic_smoke.py + - run: python scripts/ui_voices_smoke.py + - run: python scripts/ui_graph_studio_smoke.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: graph-studio-browser-validation + path: test-results/ + windows: + needs: [source, browser] + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.source.outputs.revision }} + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - run: python -m pip install -q -r requirements-dev.txt -r requirements-desktop.txt + - run: python -m pytest -q --junitxml=test-results/windows.xml + - run: node --test tests/graph_math.test.cjs + - name: Verify hosted WebView2 prerequisite + shell: pwsh + run: ./scripts/prepare_ci_webview.ps1 + - name: Build Windows package with public Graphify and official Codex + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: ./scripts/build_release.ps1 + - name: Exercise the actual compiled spatial graph editor and existing workspaces + run: python scripts/native_smoke.py --science --graphify --graph-studio --polemic --voices --executable dist/GraphPaper/GraphPaper.exe --out test-results/native-package + - uses: actions/upload-artifact@v4 + with: + name: GraphPaper-0.6-tested-Windows + path: | + dist/GraphPaper-v0.6.0-Windows-x64.zip + dist/SHA256SUMS.txt + - uses: actions/upload-artifact@v4 + if: always() + with: + name: graph-studio-native-validation + path: test-results/ + publish: + needs: [source, browser, windows] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 8 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.source.outputs.revision }} + - uses: actions/download-artifact@v4 + with: + name: GraphPaper-0.6-tested-Windows + path: release-package + - name: Publish the same tested archive, never overwrite an existing release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + cd release-package + sha256sum --check SHA256SUMS.txt + cd .. + if gh release view v0.6.0 >/dev/null 2>&1; then echo 'Release already exists; refusing overwrite.'; exit 1; fi + cp docs/RELEASE-0.6.0.md release-package/notes.md + revision=$(git rev-parse HEAD) + digest=$(sha256sum release-package/GraphPaper-v0.6.0-Windows-x64.zip | cut -d' ' -f1) + printf '\n\nCompiled Windows graph editing and native save-on-close checks passed. Validation: https://github.com/%s/actions/runs/%s . Source: %s . Archive SHA-256: %s .\n' "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" "$revision" "$digest" >> release-package/notes.md + gh release create v0.6.0 release-package/GraphPaper-v0.6.0-Windows-x64.zip release-package/SHA256SUMS.txt --target "$revision" --title 'GraphPaper 0.6.0 — Ink, paper & spatial graph editing' --notes-file release-package/notes.md --draft + release_id=$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq '.[]|select(.tag_name=="v0.6.0")|.id') + uploaded=$(gh api "repos/$GITHUB_REPOSITORY/releases/$release_id/assets" --jq '.[]|select(.name=="GraphPaper-v0.6.0-Windows-x64.zip")|.digest') + test "$uploaded" = "sha256:$digest" + gh release edit v0.6.0 --draft=false --latest diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e3854db..8e970c7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -21,6 +21,7 @@ jobs: cache: pip - run: python -m pip install -q -r requirements-dev.txt - run: python -m pytest -q --junitxml=test-results/results.xml + - run: node --test tests/graph_math.test.cjs - uses: actions/upload-artifact@v4 if: always() with: @@ -36,13 +37,14 @@ jobs: with: python-version: '3.12' - run: python -m pip install -q -r requirements-dev.txt - - run: python -m playwright install --with-deps chromium + - run: python -m playwright install chromium - run: python scripts/ui_smoke.py - run: python scripts/ui_studio_smoke.py - run: python scripts/ui_science_smoke.py - run: python scripts/ui_graphify_smoke.py - run: python scripts/ui_polemic_smoke.py - run: python scripts/ui_voices_smoke.py + - run: python scripts/ui_graph_studio_smoke.py - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/voices-release.yml b/.github/workflows/voices-release.yml deleted file mode 100644 index 9bc3628..0000000 --- a/.github/workflows/voices-release.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: GraphPaper 0.5 hosted Windows release -on: - push: - branches: [main] - paths: ['graphpaper/__init__.py', '.github/workflows/voices-release.yml'] - workflow_dispatch: -permissions: - contents: read -concurrency: - group: graphpaper-0.5-release - cancel-in-progress: true -jobs: - source: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - outputs: - sha: ${{ steps.revision.outputs.sha }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - id: revision - name: Record a source-only manifest without changing code or workflows - run: | - python scripts/prepare_assets.py --manifest - git add publish-manifest.json - git diff --cached --check - if ! git diff --cached --quiet; then - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m 'Refresh verified v0.5 source publishing manifest' - git push origin HEAD:main - fi - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - validate: - needs: source - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.source.outputs.sha }} - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: pip - - run: test "$(python -c 'from graphpaper import __version__; print(__version__)')" = '0.5.0' - - run: python -m pip install -q -r requirements-dev.txt - - run: python -m pytest -q --junitxml=test-results/linux.xml - - run: python scripts/docs_tool.py check - - name: Install the test browser on the hosted Ubuntu image - run: python -m playwright install chromium - - run: python scripts/ui_smoke.py - - run: python scripts/ui_studio_smoke.py - - run: python scripts/ui_science_smoke.py - - run: python scripts/ui_graphify_smoke.py - - run: python scripts/ui_polemic_smoke.py - - run: python scripts/ui_voices_smoke.py - - uses: actions/upload-artifact@v4 - if: always() - with: - name: voices-linux-validation - path: test-results/ - windows: - needs: [source, validate] - runs-on: windows-latest - timeout-minutes: 30 - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.source.outputs.sha }} - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: pip - - run: python -m pip install -q -r requirements-dev.txt -r requirements-desktop.txt - - run: python -m pytest -q --junitxml=test-results/windows.xml - - name: Verify the hosted Windows WebView2 prerequisite - shell: pwsh - run: ./scripts/prepare_ci_webview.ps1 - - name: Exercise the isolated native source workflow before spending a package build - run: python scripts/native_smoke.py --science --polemic --voices --out test-results/native-source - - name: Build with public Graphify and official Codex - shell: pwsh - env: - GITHUB_TOKEN: ${{ github.token }} - run: ./scripts/build_release.ps1 - - uses: actions/upload-artifact@v4 - with: - name: GraphPaper-v0.5.0-Windows-candidate - path: | - dist/GraphPaper-v0.5.0-Windows-x64.zip - dist/SHA256SUMS.txt - - name: Exercise the compiled native window, voice graphs, Graphify and save-on-close - run: python scripts/native_smoke.py --science --graphify --polemic --voices --executable dist/GraphPaper/GraphPaper.exe --out test-results/native-package - - name: Publish only the successfully tested artifact - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Stop' - $tag = 'v0.5.0' - $package = 'GraphPaper-v0.5.0-Windows-x64.zip' - $revision = (git rev-parse HEAD).Trim() - $expected = ((Get-Content dist/SHA256SUMS.txt).Split(' ')[0]).Trim().ToLower() - $actual = (Get-FileHash "dist/$package" -Algorithm SHA256).Hash.ToLower() - if ($actual -ne $expected) { throw 'Archive checksum mismatch.' } - gh release view $tag *> $null - if ($LASTEXITCODE -eq 0) { throw 'Release already exists; refusing to overwrite it.' } - $notes = Get-Content docs/RELEASE-0.5.0.md -Raw - $notes += "`n`nBuilt and tested entirely on GitHub-hosted Linux and Windows runners. The compiled Windows application passed its native interaction and save-on-close checks. Validation run: https://github.com/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID . Source revision: $revision . Archive SHA-256: $actual .`n" - $notes | Set-Content test-results/published-release-notes.md -Encoding utf8 - gh release create $tag "dist/$package" dist/SHA256SUMS.txt --target $revision --title 'GraphPaper 0.5.0 - Reusable voice graphs' --notes-file test-results/published-release-notes.md --draft - if ($LASTEXITCODE -ne 0) { throw 'Release upload failed.' } - $releases = gh api "repos/$env:GITHUB_REPOSITORY/releases?per_page=100" | ConvertFrom-Json - $release = $releases | Where-Object { $_.tag_name -eq $tag } - if (-not $release) { throw 'The uploaded draft release could not be found.' } - $asset = $release.assets | Where-Object { $_.name -eq $package } - if ($asset.digest -ne "sha256:$actual") { throw 'Uploaded artifact does not match the tested ZIP.' } - gh release edit $tag --draft=false --latest - if ($LASTEXITCODE -ne 0) { throw 'Release publication failed.' } - - name: Retain native diagnostics from this runner only - if: always() - shell: pwsh - run: | - foreach ($folder in @('native-source','native-package')) { - foreach ($name in @('native.log','desktop.log','report.json')) { - $path = "test-results/$folder/$name" - if (Test-Path $path) { Write-Host "=== $path ==="; Get-Content $path -Tail 80 } - } - } - - uses: actions/upload-artifact@v4 - if: always() - with: - name: voices-windows-validation - path: test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae5a52..c52d3da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.6.0 + +- Replace the green wash with ink-blue/charcoal surfaces and a licensed scan of real graph paper in the sidebar. +- Add spatial XYZ/SVG graph navigation, 2D switching, focus/filter/display budgets, adaptive labels and distinct directed relationship styles. +- Add persistent manual node/edge editing, creation, connection, confirmed deletion and graph-only undo without rewriting manuscripts or losing provenance. +- Add synthetic dense-graph browser checks, geometry tests and native compiled graph-editor release checks. + + ## 0.5.0 - Bound and automatically batch JEV requests by actual serialized UTF-8 size; remove voice-training prose from decision payloads. diff --git a/README.md b/README.md index 26d977b..11e053e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Use **Codex / ChatGPT sign-in** or your own model API. Keep control of the argum **External Graphify now supports Codex sign-in and per-model reasoning.** The Windows release includes the public runtime; there is no extra API-key requirement or duplicate Native pass. [Graphify setup and distribution](docs/UPDATE-0.3.1.md) +**New in 0.6: the spatial graph studio.** Orbit or flatten your graph, focus its neighborhoods, and edit nodes and connections directly. Ink-blue surfaces and a real scanned graph-paper sidebar. [Explore the graph controls](docs/GRAPH-STUDIO.md). + ## Four ways to write | Mode | Start with | Build toward | diff --git a/docs/GRAPH-STUDIO.md b/docs/GRAPH-STUDIO.md new file mode 100644 index 0000000..1d25c8b --- /dev/null +++ b/docs/GRAPH-STUDIO.md @@ -0,0 +1,52 @@ +# The graph studio: ink, paper and space + +GraphPaper 0.6 replaces the green-on-green interface with charcoal/ink-blue surfaces, colored graph semantics and a **real scanned sheet of graph paper** in the sidebar. The sidebar asset is bundled locally; viewing it does not call an image service or require an internet connection. Attribution appears at the bottom of the sidebar and in the package's `ASSET-CREDITS.md`. + +## Navigate without losing the thread + +Open any existing project's **Graph** tab. You do not need to rebuild the graph or upload its documents again. + +**3D** is a spatial XYZ layout rendered with perspective, depth sorting and scalable SVG—not a prerecorded animation or a picture. Drag the background to orbit. Hold **Shift** and drag to pan. Use the wheel or **+ / −** to zoom. Drag an individual node to arrange it. **Fit graph** brings the displayed nodes back into view; **Reset camera** restores the initial orientation; **Spread nodes apart** increases spacing. The minimap recenters the displayed view when clicked. **Expand graph** provides more room; Escape leaves the expanded view. Switch to **2D** for a flat, pannable diagram at any time. + +This implementation does not require WebGL, an external graph service, or an extra model call. It runs within the existing Windows WebView2 and local browser interfaces. It is a genuinely rotatable spatial view using an SVG renderer, not a GPU/WebGL scene. There is no perpetual force simulation, auto-orbit or animation competing with reading. + +The camera and manually arranged positions are stored on this device per project. They are view settings, not factual graph changes. They are not presently included in portable project exports. + +## Reduce density, without deleting the graph + +The initial view shows at most **60 nodes**. Choose 40, 60, 100 or 180 in **Show**. At most **360 links** are drawn at once. The footer reports how much of the stored graph is being displayed. These are display budgets, not extraction or storage limits: the undisplayed data remains in the project. + +Search examines the **whole graph**, including titles, notes and relationship names, so a low-degree idea outside the initial overview is still reachable. Type filters select node kinds; connection filters select relationship families. Click a node and choose **Focus here**, or select **1 hop / 2 hops**, to isolate its neighborhood. **Reset filters** returns to the overview. Adaptive labels reduce collisions at distant zoom levels; select or hover an item for its full name. + +## Read the connections, not just the dots + +Node kinds use distinct colors and shapes: sources are blue squares, claims rose circles, themes cyan diamonds, evidence violet hexagons, and tensions/questions amber triangles. Imported type labels are retained; unrecognized types use the generic idea style. + +Relationships use color **and** line pattern **and** arrow direction. The legend identifies support, opposition, citation, consequence, style, question and association families. Other relationship names receive a stable accent/pattern, with their exact text available on hover or selection. Classification is deliberately lexical; a colored line does not prove causation or validate the relationship. Existing source/target direction is preserved. + +Click a connection itself or choose it in the selected node's connection list to inspect its endpoints and wording. On a crowded overview only active/nearby labels are emphasized; unrelated connections fade after selection. + +## Edit the graph itself + +Click a node to edit its **Title, Type and Notes** in the inspector, then choose **Save node**. Click a line to edit **From, To, Relationship and Notes**, then **Save connection**. Names and wording are yours; the editor does not filter power language or call a writing model. + +**Add node** creates a new idea. **Connect nodes** opens an endpoint-and-relationship dialog. Alternatively select a node, choose **Connect…**, and click the destination node. Deleting asks for confirmation. A node deletion removes that node and its incident edges, not the original documents, voice profile or manuscript. + +Manual changes retain original source anchors and mark changed material as **proposed**. An old quote remains available to inspect; it does not automatically prove an edited claim. Existing generated angles and outlines remain stored and become outdated where applicable. No prose is silently regenerated. + +**Undo edit** reverses up to 25 recent manual graph edits in this local project database. Graph and history commit atomically with version checks. Undo refuses to overwrite a graph that has since been rebuilt or replaced. It does not restore or overwrite the manuscript. Independent pin choices made after an edit are retained. + +Unsaved inspector edits are guarded when changing selection or leaving the graph. Native close asks you to save/discard/stay rather than losing the inspector text; **Ctrl+S** saves the active graph edit. Browser close uses its unsaved-change warning. Editing is blocked while an extraction/writing job is active. + +## The real paper + +The image is **“Graph paper notepad (4562203394)” by Calsidyrose**, a scan of actual vintage graph paper, licensed **CC BY 2.0**. It was resized and converted to WebP; no generated picture is substituted for the paper. Original and bundled checksums are in `ui/assets/graph-paper.json`. + +- [Original scan and attribution](https://commons.wikimedia.org/wiki/File:Graph_paper_notepad_(4562203394).jpg) +- [Creative Commons Attribution 2.0](https://creativecommons.org/licenses/by/2.0/) + +## Verification scope + +Automated checks exercise persistent node/edge edits, stale-write rejection, provenance preservation, deletion/undo, spatial transforms, rendering limits and full-graph search. Browser checks use an isolated synthetic 600-node/1,500-edge graph as well as the small illustrative demo. The Windows release workflow also exercises the compiled executable's graph controls, existing Graphify action, voice reuse, native dialogs and save-on-close. The release workflow results—not this feature description—record which runs actually passed. No private manuscript or user computer is part of these tests. + +[Graph and JEV](GRAPH-AND-JEV.md) · [Reusable voices](VOICE-GRAPHS.md) · [Windows guide](WINDOWS.md) diff --git a/docs/README.md b/docs/README.md index c94bb22..ef46125 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,3 +50,7 @@ These Markdown files are the maintained source. [The documentation tooling](DEVE ## Reusable voices and bounded JEV decisions [Save a voice graph once and reuse it across projects](VOICE-GRAPHS.md). Training pieces are separate from article evidence; current author instructions override saved preferences. + +## Spatial graph studio + +[3D/2D navigation, decluttering, node/connection editing and undo](GRAPH-STUDIO.md) · [0.6.0 release](RELEASE-0.6.0.md) diff --git a/docs/RELEASE-0.6.0.md b/docs/RELEASE-0.6.0.md new file mode 100644 index 0000000..c3e66c7 --- /dev/null +++ b/docs/RELEASE-0.6.0.md @@ -0,0 +1,22 @@ +# GraphPaper 0.6.0 — Ink, paper & the spatial graph studio + +## Actual interface changes + +- **No more green wash:** charcoal/ink-blue surfaces, clearer contrast and spectral accents throughout the studio. +- **Real graph paper in the sidebar:** a bundled, credited scan by Calsidyrose (CC BY 2.0), not a generated mockup or a green CSS grid. Works offline. +- **Spatial 3D graph:** perspective XYZ layout, orbit, pan, zoom, node dragging, a minimap, expanded view and a one-click 2D view. Dependency-free SVG rendering; no WebGL requirement or model calls. +- **Less visual congestion:** display budgets, full-graph search, node/connection filters, one-/two-hop focus, spacing controls and adaptive labels. Hidden nodes are not deleted. +- **Readable connections:** colored/patterned directed links, a relationship legend and selectable connections; distinct node shapes as well as colors. +- **Edit what you click:** node title/type/notes, connection endpoints/relationship/notes, add/connect/delete dialogs, confirmation, unsaved-edit guards and persistent graph-only undo. Original sources, manuscript and voice stay intact. No tone filter. + +## Use it + +Download `GraphPaper-v0.6.0-Windows-x64.zip`, extract it completely into a new folder and open **GraphPaper.exe** with `_internal` beside it. This Windows package is unsigned. Keep your existing project data directory; do not copy an empty test database over it. + +Open your existing project's **Graph** tab. **No extraction rerun is required.** Drag the canvas to orbit, Shift-drag to pan, wheel to zoom; click a node or a connection to edit it in the inspector. Use **Focus** and **Show** to control density. The author remains in charge of the wording. + +[Graph studio controls and limitations](https://github.com/AronAxe/GraphPaper/blob/main/docs/GRAPH-STUDIO.md) + +The public Graphify runtime, official Codex connection, reasoning controls, Science, Polemic, reusable voice graphs and bounded JEV requests are retained. This is not a claim that editing or displaying a graph establishes the truth of its claims. + +Builds and native Windows release checks run on GitHub-hosted machines, not the user's computer. Synthetic fixtures exercise UI, provider transport and storage; no private accounts or manuscripts are used. Final validation and archive checksum are attached to the published release. diff --git a/graphpaper/__init__.py b/graphpaper/__init__.py index f56785a..69c4530 100644 --- a/graphpaper/__init__.py +++ b/graphpaper/__init__.py @@ -1,2 +1,2 @@ """GraphPaper: an evidence-aware, graph-native writing studio.""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/graphpaper/graph_editing.py b/graphpaper/graph_editing.py new file mode 100644 index 0000000..94bf307 --- /dev/null +++ b/graphpaper/graph_editing.py @@ -0,0 +1,122 @@ +"""Explicit local graph edits, optimistic saves and reversible graph-only history. + +This never calls a model or changes writing voice/position. Quote anchors survive +manual edits as provenance, not automatic validation of a changed claim. +""" +from __future__ import annotations +import hashlib +import json +from typing import Literal +from fastapi import Request +from pydantic import Field +from .models import Model, Node, Edge, Graph, now, uid +from .storage import Conflict + + +class Edit(Model): + version: int = Field(ge=0) + operation: Literal['node','edge','delete-node','delete-edge'] + id: str | None = Field(None,max_length=1000) + values: dict = Field(default_factory=dict) + + +def graph_hash(graph): + return hashlib.sha256(json.dumps(graph.model_dump(),sort_keys=True,ensure_ascii=False).encode()).hexdigest() + + +def install(app,store,runner): + with store.connect() as c: + c.execute('CREATE TABLE IF NOT EXISTS graph_history (id TEXT PRIMARY KEY, project_id TEXT, created TEXT, before_graph TEXT, before_pins TEXT, after_hash TEXT)') + c.execute('CREATE INDEX IF NOT EXISTS graph_history_project ON graph_history(project_id,created)') + + def get(pid,version): + if runner.active(pid):raise ValueError('Finish or cancel the active job before editing its graph.') + p=store.get(pid) + if version!=p.version:raise Conflict('The project has changed. Your graph edit has not been overwritten; refresh the project before applying it.') + return p + + def result(p,selected_id=None): + with store.connect() as c: + row=c.execute('SELECT after_hash FROM graph_history WHERE project_id=? ORDER BY created DESC,rowid DESC LIMIT 1',(p.id,)).fetchone() + return {'project':p,'selected_id':selected_id,'can_undo':bool(row and row[0]==graph_hash(p.graph))} + + def commit(p,expected,before,undo_id=None): + # One transaction commits the graph and its undo record; a stale writer + # cannot leave half an edit or restore somebody else's graph. + with store.lock,store.connect() as c: + p.version=expected+1;p.updated=now() + saved=c.execute('UPDATE projects SET version=?,updated=?,data=? WHERE id=? AND version=?',(p.version,p.updated,p.model_dump_json(),p.id,expected)) + if not saved.rowcount:raise Conflict('Project changed during graph editing. Your newer work was not overwritten.') + if undo_id: + c.execute('DELETE FROM graph_history WHERE id=? AND project_id=?',(undo_id,p.id)) + else: + c.execute('INSERT INTO graph_history VALUES(?,?,?,?,?,?)',(uid('g_'),p.id,now(),before[0],before[1],graph_hash(p.graph))) + c.execute('DELETE FROM graph_history WHERE project_id=? AND id NOT IN (SELECT id FROM graph_history WHERE project_id=? ORDER BY created DESC,rowid DESC LIMIT 25)',(p.id,p.id)) + + @app.get('/api/projects/{pid}/graph/edit-status') + def status(pid:str): + return {k:v for k,v in result(store.get(pid)).items() if k!='project'} + + @app.post('/api/projects/{pid}/graph/edit') + async def graph_edit(pid:str,request:Request): + raw=Edit.model_validate(await request.json());p=get(pid,raw.version) + before=(p.graph.model_dump_json(),json.dumps({'pinned':p.brief.pinned_nodes,'excluded':p.brief.excluded_nodes})) + nodes={n.id:n for n in p.graph.nodes};edges={e.id:e for e in p.graph.edges} + selected=raw.id;v=raw.values + if raw.operation in {'node','edge'}: + node=raw.operation=='node' + fields={'label','kind','description'} if node else {'source','target','relation','description'} + if set(v)-fields:raise ValueError('Only the visible graph fields can be edited; source anchors are retained separately.') + for key,value in v.items(): + if not isinstance(value,str):raise ValueError('Graph fields must contain text.') + if key!='description' and not value.strip():raise ValueError('A graph label, type or connection cannot be empty.') + if key in {'kind','relation'} and len(value)>80:raise ValueError('Graph types and relationship names must be at most 80 characters.') + collection=nodes if node else edges + if raw.id and raw.id not in collection:raise KeyError(raw.id) + if raw.id: + previous=collection[raw.id];data=previous.model_dump()|v + if any(getattr(previous,k)!=val for k,val in v.items()):data['status']='proposed' + item=(Node if node else Edge).model_validate(data) + else: + item=(Node if node else Edge).model_validate({'id':uid('n_' if node else 'e_'),'status':'proposed'}|v) + if not node and (item.source not in nodes or item.target not in nodes):raise ValueError('Both connection endpoints must exist in this project.') + if node:nodes[item.id]=item + else:edges[item.id]=item + selected=item.id + else: + if v:raise ValueError('Deletion does not accept replacement fields.') + if raw.operation=='delete-node': + if raw.id not in nodes:raise KeyError(raw.id) + del nodes[raw.id] + edges={key:e for key,e in edges.items() if e.source!=raw.id and e.target!=raw.id} + p.brief.pinned_nodes=[n for n in p.brief.pinned_nodes if n!=raw.id] + p.brief.excluded_nodes=[n for n in p.brief.excluded_nodes if n!=raw.id] + else: + if raw.id not in edges:raise KeyError(raw.id) + del edges[raw.id] + selected=None + if len(nodes)>5000 or len(edges)>20000:raise ValueError('Graph exceeds 5,000 nodes or 20,000 connections.') + p.graph.nodes=list(nodes.values());p.graph.edges=list(edges.values()) + p.graph.coverage['manually_edited_at']=now() + p.angles_context_hash='';p.outline_context_hash='' + pin_history=json.loads(before[1]);pin_history['after_pinned']=p.brief.pinned_nodes;pin_history['after_excluded']=p.brief.excluded_nodes + before=(before[0],json.dumps(pin_history)) + commit(p,raw.version,before) + return result(p,selected) + + @app.post('/api/projects/{pid}/graph/undo') + async def undo(pid:str,request:Request): + raw=await request.json() + if set(raw)!={'version'} or not isinstance(raw['version'],int):raise ValueError('Supply the current project version to undo.') + p=get(pid,raw['version']) + with store.connect() as c: + row=c.execute('SELECT * FROM graph_history WHERE project_id=? ORDER BY created DESC,rowid DESC LIMIT 1',(pid,)).fetchone() + if not row:raise ValueError('No manual graph edits to undo.') + if row['after_hash']!=graph_hash(p.graph):raise Conflict('The graph was rebuilt or replaced after this edit. Undo will not overwrite that graph.') + p.graph=Graph.model_validate_json(row['before_graph']);pins=json.loads(row['before_pins']) + # Restore edit-induced pin removal, not independent choices made later. + if p.brief.pinned_nodes==pins.get('after_pinned',pins['pinned']):p.brief.pinned_nodes=pins['pinned'] + if p.brief.excluded_nodes==pins.get('after_excluded',pins['excluded']):p.brief.excluded_nodes=pins['excluded'] + p.angles_context_hash='';p.outline_context_hash='' + commit(p,raw['version'],None,undo_id=row['id']) + return result(p) diff --git a/graphpaper/server.py b/graphpaper/server.py index 82565d7..52fea0d 100644 --- a/graphpaper/server.py +++ b/graphpaper/server.py @@ -358,5 +358,7 @@ async def import_project(request: Request): install_editorial(app,store,runner) from .science_routes import install as install_science install_science(app, store, vault, runner, settings) + from .graph_editing import install as install_graph_editing + install_graph_editing(app, store, runner) app.mount("/static", StaticFiles(directory=asset_directory()), name="static") return app diff --git a/graphpaper/storage.py b/graphpaper/storage.py index fb77998..f5aa6bb 100644 --- a/graphpaper/storage.py +++ b/graphpaper/storage.py @@ -91,6 +91,8 @@ def delete(self, project_id: str): with self.lock, self.connect() as c: c.execute("DELETE FROM projects WHERE id=?", (project_id,)) c.execute("DELETE FROM revisions WHERE project_id=?", (project_id,)) + if c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='graph_history'").fetchone(): + c.execute("DELETE FROM graph_history WHERE project_id=?", (project_id,)) def snapshot(self, project: Project, label: str): if not project.draft.strip(): diff --git a/publish-manifest.json b/publish-manifest.json index a41f96d..76c402c 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -4,22 +4,22 @@ ".gitattributes": "acdb1038616f95b335ceb851c161a6139bf23561b2a74894a9ccb33ded5e71f4", ".github/topics.json": "341aa983b1829624dec7ab7cd2e83eed08d45bc78305033c1e860631c1227e41", ".github/workflows/docs.yml": "19c4d6229a804adf09358171b19a139b45f1c2630b751213af1e9e62baed2c40", + ".github/workflows/graph-studio-release.yml": "743b70fb67cc451c7c3ad9b28c422383d0d4e8eca9c26c58c5b029f707408eef", ".github/workflows/import-build.yml": "c56a990b97242f154907f76059f5b859448ca8de7b582928d63bb3717ac0d60a", ".github/workflows/native-diagnostics.yml": "818584858c3f0dce8ce17ff0ccff9b5984fab6c6d2a1481baa280f841c8cbf53", ".github/workflows/native-probe.yml": "b7e6f3ea3f8fef9e3725ac2a242b7c4be2545bc4491be5ce2e68f723c302bc56", - ".github/workflows/quality.yml": "2ffc5f11447f1cfb5a0f45798df1d217f5d5c4eaf5808270523a69f468f7d9b6", + ".github/workflows/quality.yml": "6253cd001844b75fc1b748749afb3b2d7c3872fb5426c2a29bf9e346e2a4300f", ".github/workflows/voice-manifest.yml": "19647138870a09bbe7835df34ddab4017a91da9c1937444a32f3a35b9ef1fa88", - ".github/workflows/voices-release.yml": "174a8f98f2fda23f938299e347d09a3615ac7a3b07a962bb952d164a5ba2be28", ".github/workflows/windows.yml": "65512954b719620063384ce93950b89c9b0aaac2ad6c7c620d505f8787b0444a", ".gitignore": "229a84e2b07c91b31c30f06df236ea17fe211eb75b217adc2eecb3f9d377f9bf", - "CHANGELOG.md": "1f8159e524f9e0138c8d706b897108ea8ebdd9a764fb5a1e2b74d04bfc297e98", + "CHANGELOG.md": "e288d09d51cac8ee952e63958b8ca8e23e2d77f1b5d51def57b2ed4e7f3dbd1a", "CONTRIBUTING.md": "170e7cd71886a6cd3aa82c3ab5a478cde152faa4f243900e047bca565e658001", "GraphPaper.pyw": "8fd19e9ba8caa25b11b0b458e00aa3ea4067ca5d90161f7ba2781f1d514fa094", "LICENSE": "5849844cbc9e9e199cf576224279d63d5fa56ca913f7a13285e4aa4fcb99bf80", "Open GraphPaper.vbs": "6ddf70eba29c281f9373c11b6aa7f69862c8ea7b03050b974c98d817ca11974e", "Publish GraphPaper.pyw": "a55984668dd0040d9bb5fc8c4c18ea5a201eede7e8a51ab79f8f0f095162ec8b", "Publish GraphPaper.vbs": "f9c595bcd13da5df60dcc2cfd906665243628f6e6c10a8b6a03ad1392a9698ad", - "README.md": "dac0e710729e57ee730a867495db842e4fc14b597009c18f0e994e1cf8b89cb8", + "README.md": "c9083ffc46bd7ccff1a86f8a3bd85ebe3ae32dfc732500dc51518189ca86a11b", "SECURITY.md": "e70e5b87ea06cdc075c3111a1eb0bb6ac5f5b6f8b55f5e735ad69d84ae63cda6", "docs/ARCHITECTURE.md": "86a28806513cf8b533d6a4767322c7dd339a6a2ed112081ea7ce5fcdf4ffd89d", "docs/CONNECTIONS.md": "eec1286ddc356bbb2cd7d63692e73abae6d0f7e9159bbb2edee6377dd5b3fd72", @@ -28,19 +28,21 @@ "docs/FICTION.md": "6ac2f136e848db8b9fd2bd329d4228894ad765e95d6a9cec317bb9372a847df8", "docs/GETTING-STARTED.md": "d663e4b1ea461de7e87ba8bb2fa15949db7ba8d89de69cbf1c0d1a265d3e2abd", "docs/GRAPH-AND-JEV.md": "6fc0ea4437d5691d50c02899f9a5377f2e42b97b9190cebc1c12d34819259c6e", + "docs/GRAPH-STUDIO.md": "cf5a765cbd08f0caf23da868da5b05ba3ab1a15c9d84fc7d61950c8baf0ca697", "docs/INTEGRATIONS.md": "3fe70965f7597f7746335278ae954fed891264e6d767fd5a046cf85eb1baf3d2", "docs/NONFICTION.md": "09ad36f0c4d1e51053feeb4e53a939b772cde1d1c75321c43cf9434822d70367", "docs/POLEMIC.md": "5992aece0504869add46268a741642675c77912ffedec7c59a1ef08220291161", "docs/PROJECTS-AND-SOURCES.md": "828179218c31bb9f50698e2d047a04b4a056b979800795a37cd790d29060e2dd", "docs/PUBLISHING.md": "324b83d5138b22bb29ca1737cbc0563595cc14f03177d478c478bde9109f975f", "docs/QUALITY.md": "75b998fe5bd366c955516e1671c19a43ad6f6d5449e7356218980f0073bd3250", - "docs/README.md": "393335da0650e4a657ce6deb694a8182cb8429c042551e722cbd82fa5a90d101", + "docs/README.md": "c3bf1d01e5f45bc72c653216a94d8686938f5c8ad3f35498dfdf409e150b437e", "docs/RELEASE-0.2.1.md": "42642432ff5c6017630d33a64726c5dca37abbbb36a476a901a59ec8887aa870", "docs/RELEASE-0.2.md": "c03a41903088590845d0da0707a562b9e6cbf4c5e1ff57d8794763f349093e44", "docs/RELEASE-0.3.0.md": "e32f30a3a9716c310be21dcf540cf9060384b0c7fdf69c8fc1c45d36c105cf29", "docs/RELEASE-0.3.1.md": "3879831312c10f64c9faceaac28066ef557825416d1ba75560729c514c16d8f8", "docs/RELEASE-0.4.0.md": "ed7d83f07cf10c447efa2e1ba9fa2634af691cad792928548871eccbee96c751", "docs/RELEASE-0.5.0.md": "960b36e74d2044bdc05c8b26d54113ea6580c259103d6ef6017809822032b0bf", + "docs/RELEASE-0.6.0.md": "02e6d3ef374c2b1309e416b2c11e93260bd83f00fa8c577471a7a9c7aab95900", "docs/SCIENCE.md": "9357a2f4ac714b6330aff4ccfe45ca0457d18e7816a73646314024823fd0ea00", "docs/SECURITY.md": "0f818eb0cf4e8f94ffaaeb6da3cc2c2bc474674b673d185c61b86b2eb5fd3864", "docs/TROUBLESHOOTING.md": "0834f56c656c97e721226a335b24a7aa8afe9cd99b4e28e950412bd86ae3d2d6", @@ -128,7 +130,7 @@ "examples/README.md": "0b7b83630202501a3ba6ad070708419aa23e32c8060ef9447491c6434043bbf6", "examples/fiction.json": "e3f90c0eed71102640c8a65ae86510e0258e9c90c3cc18730ed05329f632e88d", "examples/nonfiction.json": "af9196cf15dbe1d431f02c7d8ec11a720f25e1c3e30a756c1f9711b4c65a08cc", - "graphpaper/__init__.py": "a3146b399d581208e7ae734d18409e209de2cbe2eb9e115ccf1a0bdcd6aa955a", + "graphpaper/__init__.py": "c4c1db5dc55f7a5f7a09e49b859275a82ece49d31b5e19d36052cb39b1184d8e", "graphpaper/apa.py": "e8c5b844c0f02f14c5a73d6128e4d174d4ad655e380b4d675215beb125cd717f", "graphpaper/argument_draft.py": "a2b5ff0e2a100688a6c0e3be684b68107542a3341a307da7682e0b24e0f5ea3d", "graphpaper/author_web.py": "16ad1218ce0bf76bdd067e731176357586a28dfb55d297a393a3e6a596e126ff", @@ -140,6 +142,7 @@ "graphpaper/export.py": "fd76bec7858c53700dfe068035c187277ed85fa7e90a46cb1f1c47b57eb1e744", "graphpaper/folders.py": "37f9b255406e3ff93b6f6886294722c14561a4c1195d5c2107fe67c8de6fbca8", "graphpaper/graph.py": "eb9226bb03fc71b1d5eb309b833fa415efe5117b070500cef647704b334cdefe", + "graphpaper/graph_editing.py": "8f8b768c0b1843260d4925b7f6c8d2ae0935b7db30ce8d684946f25620622eac", "graphpaper/graphify_adapter.py": "5c4ef54058240b3e8f82daed1d7d7180ee839c43811d7cbb824f35d040a3d1af", "graphpaper/graphify_gateway.py": "258f8fe1b41682f3a952fe62d4ecb55824f82eea30f3997997565a3a45ff0164", "graphpaper/graphify_process.py": "a499fb74738bee070b1557c388312cce3448057d5be725f75bb5c926ac1e2237", @@ -159,8 +162,8 @@ "graphpaper/science_models.py": "986446c848927d85aa57bc2d2ba9f248f656fac9f6acc67d056e8f55776258e8", "graphpaper/science_routes.py": "cd2e5c76035dcd7a9384c74b4264306da8429072359a476b9d4e060aee86bb69", "graphpaper/secrets.py": "2d90bfed7124875d50cd977c0c4f17c4cb19e79af307445bc85af99596c3766e", - "graphpaper/server.py": "49cbcb61cb2ade46f69ca13aa7f1100c37ef239c9958b0461ff52ba6280224c0", - "graphpaper/storage.py": "38b0df820a0c4dae0199bf38656d529e726a4475fe7a47b66e41903e96d14eae", + "graphpaper/server.py": "afad82265041c6ad84de1c2e461381d0997b888bae1e98ceed28b0c9df562e5f", + "graphpaper/storage.py": "3a905690b5d737477d7de6b3fe0e216aa38831dd28f08b62a6b35791c56f50af", "graphpaper/studio_routes.py": "5597db3ee3cba50e7eb4bd8c4e36d3a74be5768bb01b75749f16ac28e10ee84f", "graphpaper/style_graph.py": "01adedab019d8108fd8643be8b3863ddac53936e368b3f6ef6ec97e5af8b9568", "graphpaper/support.py": "2f4a3f5f7f6a725b15719a775155bdc73c32ce0df022d43b4fb53dbf7e862d8e", @@ -172,7 +175,7 @@ "requirements-dev.txt": "40b197f9671e3c6db4d47ca73d9679eabafe662a56d4824c6f9e64255e47a5ce", "requirements-graphify.txt": "9eea3e6ea7fa3ceb1247518901d41d55a0813dc5133475172541c68c122454f1", "requirements.txt": "4e73f195c8a6d32b841b671200bf0dcaa91e5a042c585e935e3132ee5e4a02a3", - "scripts/build_release.ps1": "d56a421d287431c42e7971381ebd5234fb561cb67c117f6b321b86717a751c6a", + "scripts/build_release.ps1": "49fd93f3216141c9b1abb58fc0f76f24228cb7c6049a238b416de0841521c0bb", "scripts/build_windows.ps1": "44e0008ff8221097ead7355aa184a60e824d2fcbf0c77e54c74ddfc8f3a54858", "scripts/bundle_codex.py": "651d08e26949dbda47bca14dbc368d97b82e11a28759792cb6096adcea174efa", "scripts/check_codex.py": "2fff4a6cc80188d54914086ab3f3d7d3cf1ec02001541bad6bc8a426fc1bbc81", @@ -182,15 +185,17 @@ "scripts/check_scholarly.py": "a2169565bd8fc7f19f1f8ef6a0e4d79008267e327670cd615595b1045577a409", "scripts/desktop_entry.py": "94392ff111a9f3c736e5bda9037fec0e6fe2fd8fc8f7866c4aabf1a2d3afc135", "scripts/docs_tool.py": "7fd2a681bae5fd4a49ae394c74639dee8e833ad08e8a156406c292af6db46572", + "scripts/graph_studio_ui_checks.py": "7f3e05bd9649666854521eabfccb650e6c3d15578633ef36a027877828c6d267", "scripts/graphify_ui_checks.py": "6dc1e45478a10fa8e3d17190864b2145cd973a5a6e23c7500f0d99ddfb824dc6", "scripts/hooks/hook-graphify.py": "5659b57f3b3e11f9327b2da6e00c4bd82a523f7f978e3b9506811c2caf007ff3", - "scripts/native_smoke.py": "fad1f11330719923017a7b7b8813831bba4cd8c05104dea372cfc5f30a5c9c2c", + "scripts/native_smoke.py": "24fd497d5d7d68fd6be588b904d9ec8aac4ac5bbe637d4ae6a68401ca7144dae", "scripts/package_windows.py": "8b56c4235ca2bbac39657905fa4633d78273bc4df6c9e67558a7e893176af8b3", "scripts/prepare_assets.py": "179b65a0662d329fadecd9a41c134a249ed1f3cc3123a814095ee231636d819d", "scripts/prepare_ci_webview.ps1": "2572906263473c7ea94bd9517e76b2e1470ffec1719b6244088ac2a9346e6195", "scripts/prepare_graphify.py": "c81d4146c3b4a137b93bb61d16308c483a3e3a453501426569242d227bb58e03", "scripts/probe_ci_cdp.py": "91b76aa4aa813792088b435d35144b32f94dd49e8c7248a90b56c3e247dc81c9", "scripts/publish_gui.py": "46bf7dff734e16b31c322e45bbf80abcfa29d7fbbc93f4c8162a119ff9c79ef4", + "scripts/ui_graph_studio_smoke.py": "9c398f0e14a899768e89b9212c0fa07b02e77e533c496b31b2e8d0735dde19f8", "scripts/ui_graphify_smoke.py": "0f29a01d972ad63006aba345952bb9e092575c452eb9475c505c21d9267a3de3", "scripts/ui_polemic_smoke.py": "3a0304b586d10f6fd6b15df6d31b4895a075f6e93b69483d82cebae3bb926236", "scripts/ui_science_smoke.py": "fa744ee223db49c0e2325e94fe7deec62470f1d6186b28f2b2dacccb1264f2fb", @@ -200,6 +205,7 @@ "scripts/voice_ui_checks.py": "120c54529db6205d883f3011e6e50549468f35f4dc91a3b185de8b788c46e4bf", "tests/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tests/conftest.py": "fb734bdc191b96cc60a20d83a4fbeea7b032a35e1b882a09d1cb80167e9bb1a4", + "tests/graph_math.test.cjs": "96388ec98cd144e6b650a536ffa04250f58606b5787452dabfe736f79bee28f8", "tests/graphify_fixtures.py": "6961e0ceca4a78fca57baa9d9ab9f748e587e8d73adb7fc2d25b791716202e00", "tests/graphify_http_fixture.py": "86b51983fcb1d91b0138cb72cda7ba7941e30d4e9caf8f21c64b3d49a2e2265b", "tests/polemic_fixtures.py": "32429610e320e75fb4476be65597827276100b3ee0b556b77d558dace8f5cc9c", @@ -211,6 +217,7 @@ "tests/test_desktop_and_publish.py": "71fae513bac9965c146b20035b84d006cfb80045fb521523cbff38a11ef37edc", "tests/test_documentation_tools.py": "37c0f4df10a6f2919bb01e1c06813de4efae4a941908a20b3489967f36043c6b", "tests/test_editorial_intent.py": "e14b9943ea285e4f8b9f9c3fcf4fbcf9b8a6ef318c9b35bbaeb54f30d03b15d8", + "tests/test_graph_editing.py": "c5cbf6a1de1a993e6c47a7ec63d5882b04d22777792f0cc205d683fcfb01b88d", "tests/test_graphify_extraction.py": "d7da1549de893fda3606da1deea3aa1c53d28e92d210261fbe65418cda9090c4", "tests/test_graphify_gateway.py": "f64249c44525b8337f7aa517c5c149b6cddc8b76f42f67a2b5e1a4930a88ebe5", "tests/test_graphify_provenance.py": "ce91f39b3a60bd88c52bd57f70361c92daca7e5e261f9f30868dddfde00384d3", @@ -229,11 +236,17 @@ "tests/test_voice_integration.py": "63c9e4d4dadba187d36169593868af44a4df94896ae1dc06b8b4f451ac0f8eab", "tests/test_voice_library.py": "12071c049d7ccca7d10d531879ed67c19eee97178c0a5c3bc34559aed77417e7", "tests/test_windowless.py": "421620004c87cf24dec771966602b0666e75ecca67fec05071cf40e4b6887f44", - "ui/app.js": "09e96444fe3dc7b25db957616f8b60655e6625ff878e9cf8049fa2eecc1c10e4", + "ui/app.js": "4f5681c432b8fa5923fa68d57b9ace55cd493ee43be5971316a632f73ec2917d", + "ui/assets/CREDITS.md": "e9672e1c683f7db3e5265c52ea98fda9e8eec260e0a251ffa3065e85ebcffd77", + "ui/assets/graph-paper.json": "0b37ff27703f8f6adfcd4d05037a594d526749d9641cb6cd226efe1dcc09f687", + "ui/assets/graph-paper.webp": "d6b0df4e72784fce9cb72693c6aa4156d39efcdb4859c686a0f643ea5d066701", + "ui/graph_math.js": "bcdae1e2f4c15390bfc5c671a72b46bc3215b5997dc328bda1c8a0b39df59315", + "ui/graph_studio.css": "4de0fc25d74ccfa57e8c8d48cc4881cd9de075f3b60f2e106328fccec29b61cb", + "ui/graph_studio.js": "baa995d76d57a7887ad928cde3179bb6658afc1b1c7dab86a0c13f743b1025fb", "ui/graphify.js": "1bad42f86764de73c5e9411cca64d0c86d3bdfecaa8682be3e0728663b85d18c", "ui/graphpaper.ico": "f94c4061c53e8e1a1f2be96ef1a8c18b20c19a984bc35f6337f1037c1025a493", "ui/icon.svg": "9f97b80a90020b682f96756d1245dfa3cc9f19ed989b0751f802220a3934be0f", - "ui/index.html": "b157036ea077e65718e3b4e641051b1b959814077f078fad5618c2854b67fbb3", + "ui/index.html": "fc31c0977f44da6ddd3ce1abf7c7ce0a86b56fe2bd61e7cf2393c6e8f6b45084", "ui/polemic.css": "08e5b07f2b8cda29c79794714456502c058f5baa65ad7e47ec758e2a524cc80b", "ui/polemic.js": "169695b1caffb6a6b40c144f421a0cac846b52bb5be90e205861c75e7599574a", "ui/science.css": "0234fa0a9542afc269ea5b3914a80e0d574843d45f7033394d9e8edaa40ed91c", diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1 index ef8a7a9..a2ca405 100644 --- a/scripts/build_release.ps1 +++ b/scripts/build_release.ps1 @@ -11,7 +11,8 @@ if ($LASTEXITCODE -ne 0) { throw 'Public Graphify runtime verification failed.' python -m PyInstaller --noconfirm --clean --windowed --onedir --name GraphPaper --paths . --additional-hooks-dir scripts/hooks --icon ui/graphpaper.ico --add-data 'ui;ui' --add-data 'vendor;vendor' --collect-all webview scripts/desktop_entry.py if ($LASTEXITCODE -ne 0) { throw 'Windows desktop build failed.' } Copy-Item README.md, LICENSE -Destination dist/GraphPaper/ -Copy-Item docs/RELEASE-0.5.0.md -Destination dist/GraphPaper/WHATS-NEW.md +Copy-Item ui/assets/CREDITS.md -Destination dist/GraphPaper/ASSET-CREDITS.md +Copy-Item docs/RELEASE-0.6.0.md -Destination dist/GraphPaper/WHATS-NEW.md python scripts/check_package.py dist/GraphPaper if ($LASTEXITCODE -ne 0) { throw 'Compiled application self-test failed.' } python scripts/check_graphify.py --executable dist/GraphPaper/GraphPaper.exe --out test-results/graphify-package.json diff --git a/scripts/graph_studio_ui_checks.py b/scripts/graph_studio_ui_checks.py new file mode 100644 index 0000000..ca9a451 --- /dev/null +++ b/scripts/graph_studio_ui_checks.py @@ -0,0 +1,100 @@ +"""Actual graph UI interactions; synthetic data, no AI or user installation.""" +from playwright.sync_api import expect + + +def exercise(page,check,out,dense=True): + # The caller owns the isolated project. No direct backend save bypasses the + # node/edge editing controls exercised below. + if page.locator('.step[data-tab="graph"]').count():page.locator('.step[data-tab="graph"]').click() + expect(page.get_by_role('button',name='3D',exact=True)).to_be_visible() + expect(page.locator('#graph-svg')).to_be_visible() + page.wait_for_function('() => document.querySelectorAll("#graph-svg [data-node]").length > 0') + assert page.evaluate('() => getComputedStyle(document.querySelector(".sidebar")).backgroundImage.includes("graph-paper.webp")') + assert page.evaluate('''async () => {const r=await fetch('/static/assets/graph-paper.webp');return r.ok && (await r.blob()).size>20000;}''') + check('Real scanned graph paper loads locally in the light sidebar, not a generated grid') + before=page.evaluate('() => JSON.stringify(state.p.graph)') + pose=page.evaluate('() => ({yaw:GS.view.yaw, x:document.querySelector("[data-node]").getAttribute("transform")})') + box=page.locator('#graph-svg').bounding_box() + page.mouse.move(box['x']+box['width']*.7,box['y']+14);page.mouse.down() + page.mouse.move(box['x']+box['width']*.7+70,box['y']+48,steps=8);page.mouse.up() + page.wait_for_timeout(160) + assert page.evaluate('() => GS.view.yaw')!=pose['yaw'] + assert before==page.evaluate('() => JSON.stringify(state.p.graph)') + check('Dragging orbits true XYZ positions without changing graph content') + page.get_by_role('button',name='2D',exact=True).click();assert page.evaluate('() => GS.view.mode')=='2d' + page.get_by_role('button',name='3D',exact=True).click() + page.get_by_role('button',name='Reset camera',exact=True).click();page.wait_for_timeout(100) + old=page.evaluate('() => GS.view.zoom') + page.get_by_role('button',name='Zoom in',exact=True).click();page.wait_for_timeout(100) + assert page.evaluate('() => GS.view.zoom')>old + page.get_by_role('button',name='Fit graph',exact=True).click() + check('Flat/spatial toggle, zoom and fit are functioning controls') + node=page.locator('#graph-svg [data-node]').first;nodeid=node.get_attribute('data-node') + node.click();expect(page.locator('#gs-label')).to_be_visible() + old_sources=page.evaluate('() => JSON.stringify(state.p.sources)');old_draft=page.evaluate('() => state.p.draft') + page.locator('#gs-label').fill('The argument keeps its teeth') + page.locator('#gs-description').fill('Keep the fucking point. This is a deliberate graph edit.') + page.get_by_role('button',name='Save node',exact=True).click() + expect(page.locator('.gs-selected-title h3')).to_have_text('The argument keeps its teeth') + assert page.evaluate('() => state.p.draft')==old_draft and page.evaluate('() => JSON.stringify(state.p.sources)')==old_sources + saved=page.evaluate('async () => await api("/projects/"+state.p.id)') + assert any(n['id']==nodeid and 'fucking' in n['description'] for n in saved['graph']['nodes']) + check('Click/edit/save changes the stored node, preserves language, sources and manuscript') + page.get_by_role('button',name='Focus here',exact=True).click();page.wait_for_timeout(100) + assert page.evaluate('() => GS.view.hops')==1 + page.get_by_role('button',name='Reset filters',exact=True).click() + page.get_by_role('button',name='Clear graph selection',exact=True).click() + # Add a node and connect it through the actual dialogs. + n_before=page.evaluate('() => state.p.graph.nodes.length') + page.get_by_role('button',name='Add node',exact=True).first.click() + page.locator('#gs-new-label').fill('A new counterpoint');page.locator('#gs-new-kind').select_option('claim') + page.get_by_role('button',name='Add node',exact=True).last.click() + expect(page.locator('.modal')).to_have_count(0) + assert page.evaluate('() => state.p.graph.nodes.length')==n_before+1 + newid=page.evaluate('() => state.node') + page.get_by_role('button',name='Connect nodes',exact=True).click() + page.locator('#gs-new-source').select_option(newid);page.locator('#gs-new-target').select_option(nodeid) + page.locator('#gs-new-relation').fill('contradicts') + page.get_by_role('button',name='Create connection',exact=True).click() + expect(page.locator('#gs-edge-relation')).to_have_value('contradicts') + page.wait_for_function("""() => document.querySelectorAll('.gs-link[data-relationship="contradiction"]').length > 0""") + page.locator('#gs-edge-relation').fill('questions');page.get_by_role('button',name='Save connection',exact=True).click() + expect(page.locator('#gs-edge-relation')).to_have_value('questions') + page.wait_for_function('() => state.p.graph.edges.find(e=>e.id===state.edge)?.relation === \"questions\"') + check('Connections have direction, distinct line patterns, editable endpoints and saved relationship types') + page.get_by_role('button',name='Delete',exact=True).click();page.get_by_role('button',name='Delete from graph',exact=True).click() + expect(page.locator('.modal')).to_have_count(0) + page.get_by_role('button',name='Undo edit',exact=True).click() + page.wait_for_function('() => state.p.graph.edges.some(e=>e.relation==="questions")') + check('Confirmed deletion and persistent graph-only undo work without rewriting the document') + # Dirty inspector navigation is guarded. + page.locator('#graph-svg [data-node]').first.click();page.locator('#gs-label').fill('Unsaved title') + page.evaluate('() => window.graphpaperRequestClose()') + expect(page.get_by_role('heading',name='Keep your graph edit?',exact=True)).to_be_visible() + page.get_by_role('button',name='Stay here',exact=True).click() + page.get_by_role('button',name='2D',exact=True).click() + expect(page.locator('#gs-label')).to_have_value('Unsaved title') + page.locator('#graph-svg [data-node]').last.click() + expect(page.get_by_role('heading',name='Keep your graph edit?',exact=True)).to_be_visible() + page.get_by_role('button',name='Discard edit',exact=True).click() + check('Selecting another idea cannot silently discard an unsaved inspector edit') + page.get_by_role('button',name='Clear graph selection',exact=True).click() + page.get_by_role('button',name='3D',exact=True).click() + page.evaluate('window.scrollTo(0,0)');page.wait_for_timeout(150) + page.locator('#toasts').evaluate('el => el.replaceChildren()') + page.screenshot(path=str(out/'graph-studio.png'),full_page=True) + if dense: + graph={'nodes':[{'id':f'd{i}','label':('Needle in the archive' if i==599 else f'Concept {i}'),'kind':['claim','theme','evidence','concept'][i%4],'community':i%8} for i in range(600)],'edges':[{'id':f'l{i}','source':f'd{i%599}','target':f'd{(i*7+1)%599}','relation':['supports','contradicts','cites','leads_to','questions','related_to'][i%6]} for i in range(1500)]} + page.evaluate('''async graph => {state.p=await api('/projects/'+state.p.id+'/graph/import',graph);GS.positions={};GS.view.fit=true;render();}''',graph) + page.wait_for_timeout(250) + assert page.locator('#graph-svg [data-node]').count()<=60 + assert page.locator('#graph-svg .gs-link').count()<=360 + assert page.evaluate('() => state.p.graph.nodes.length')==600 + page.screenshot(path=str(out/'dense-overview.png'),full_page=True) + page.locator('#graph-search').fill('Needle in the archive') + expect(page.get_by_role('button',name='Needle in the archive',exact=True)).to_be_visible() + page.get_by_role('button',name='Needle in the archive',exact=True).click() + expect(page.locator('#gs-label')).to_have_value('Needle in the archive') + check('A 600-node graph is decluttered without deletion; full-graph search reaches a hidden low-degree idea') + page.get_by_role('button',name='Reset filters',exact=True).click() + return {'real_spatial_projection':True,'manual_editing':True,'synthetic_data':True,'live_models':False} diff --git a/scripts/native_smoke.py b/scripts/native_smoke.py index e1453f1..e2bece9 100644 --- a/scripts/native_smoke.py +++ b/scripts/native_smoke.py @@ -23,6 +23,7 @@ def main(): parser.add_argument('--graphify',action='store_true',help='Verify the actual external Graphify action using a synthetic loopback model') parser.add_argument('--polemic',action='store_true',help='Verify mode conversion and authorial controls in the native shell') parser.add_argument('--voices',action='store_true',help='Exercise saved voice graphs without model calls') + parser.add_argument('--graph-studio',action='store_true',help='Exercise the spatial graph and real node/edge editing controls') parser.add_argument('--out',type=Path,default=ROOT/'test-results/native') args=parser.parse_args() if sys.platform!='win32':raise SystemExit('This regression requires a native Windows desktop.') @@ -132,6 +133,10 @@ def cdp_ready(): from graphify_ui_checks import exercise report['external_graphify']=exercise(page,check,out) responsive(hwnd) + if args.graph_studio: + from graph_studio_ui_checks import exercise + report['graph_studio']=exercise(page,check,out,dense=False) + responsive(hwnd) if args.polemic: before=page.evaluate('() => ({sources:state.p.sources, graph:state.p.graph, draft:state.p.draft, voice:state.p.voice_profile})') page.locator('[data-action="writing-mode"]').click() diff --git a/scripts/ui_graph_studio_smoke.py b/scripts/ui_graph_studio_smoke.py new file mode 100644 index 0000000..e85ba48 --- /dev/null +++ b/scripts/ui_graph_studio_smoke.py @@ -0,0 +1,40 @@ +"""Real HTTP browser acceptance checks for the graph redesign.""" +from pathlib import Path +import argparse,json,socket,sys,tempfile,threading,time,traceback +ROOT=Path(__file__).resolve().parents[1];sys.path.insert(0,str(ROOT)) +from graphpaper.server import create_app +from playwright.sync_api import sync_playwright +from graph_studio_ui_checks import exercise +import uvicorn + +def main(): + parser=argparse.ArgumentParser();parser.add_argument('--chromium');parser.add_argument('--out',type=Path,default=ROOT/'test-results/graph-studio') + args=parser.parse_args();out=args.out;out.mkdir(parents=True,exist_ok=True) + report={'checks':[],'page_errors':[],'ok':False,'live_models':False} + def check(s):report['checks'].append(s);print('PASS',s,flush=True) + with tempfile.TemporaryDirectory(prefix='GraphPaper-graph-design-') as temp: + app=create_app(Path(temp));sock=socket.socket();sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] + server=uvicorn.Server(uvicorn.Config(app,log_level='error'));thread=threading.Thread(target=lambda:server.run(sockets=[sock]),daemon=True);thread.start() + for _ in range(200): + if server.started:break + time.sleep(.05) + assert server.started + with sync_playwright() as pw: + browser=pw.chromium.launch(headless=True,**({'executable_path':args.chromium} if args.chromium else {})) + page=browser.new_page(viewport={'width':1580,'height':1120});page.set_default_timeout(8000) + page.on('pageerror',lambda e:report['page_errors'].append(str(e))) + try: + page.goto(f'http://127.0.0.1:{port}/');page.wait_for_selector('.welcome') + page.get_by_role('button',name='The city after dark',exact=True).click();page.wait_for_selector('#graph-svg') + report['graph']=exercise(page,check,out) + for width in [1000,720,390]: + page.set_viewport_size({'width':width,'height':900});page.wait_for_timeout(200) + assert page.evaluate('() => document.documentElement.scrollWidth')<=width,(width,page.evaluate('() => document.documentElement.scrollWidth')) + check('Responsive graph and controls stay within 1000, 720 and 390 pixel viewports') + assert not report['page_errors'],report['page_errors'];report['ok']=True + except Exception: + report['error']=traceback.format_exc();page.screenshot(path=str(out/'failure.png'),full_page=True);raise + finally: + (out/'report.json').write_text(json.dumps(report,indent=2)+'\n');browser.close();server.should_exit=True;thread.join(timeout=5);sock.close();app.state.runner.pool.shutdown(wait=True,cancel_futures=True) + print(json.dumps(report,indent=2)) +if __name__=='__main__':main() diff --git a/tests/graph_math.test.cjs b/tests/graph_math.test.cjs new file mode 100644 index 0000000..b47c83f --- /dev/null +++ b/tests/graph_math.test.cjs @@ -0,0 +1,8 @@ +const test=require('node:test');const assert=require('node:assert/strict'); +let M;try{M=require('../ui/graph_math.js');}catch{} +test('spatial renderer math is present',()=>assert.ok(M,'The spatial graph implementation is missing')); +test('actual XYZ depth rotates into screen position, 2D ignores z',()=>{if(!M)return;const p={x:100,y:30,z:180};const c={yaw:0,pitch:0,mode:'3d',zoom:1,panX:0,panY:0};assert.notEqual(M.project(p,c).x,M.project(p,{...c,yaw:1}).x);assert.equal(M.project(p,{...c,mode:'2d'}).x,100);assert.ok(M.project({...p,z:1e6},c).scale>0);}); +test('large graphs are bounded and search can find a hidden low-degree node',()=>{if(!M)return;const nodes=Array.from({length:1000},(_,i)=>({id:'n'+i,label:i===999?'Hidden target':'Node '+i,kind:'concept'}));const edges=nodes.slice(1).map(n=>({source:'n0',target:n.id,relation:'supports'}));let v=M.visible({nodes,edges},{limit:60});assert.equal(v.nodes.length,60);assert.ok(v.edges.length<=M.MAX_EDGES);v=M.visible({nodes,edges},{limit:60,search:'Hidden target'});assert.ok(v.nodes.some(n=>n.id==='n999'));}); +test('focus includes exact neighborhood and matching relation filters',()=>{if(!M)return;const g={nodes:['a','b','c','d'].map(id=>({id,label:id})),edges:[{id:'1',source:'a',target:'b',relation:'supports'},{id:'2',source:'b',target:'c',relation:'contradicts'}]};assert.deepEqual(M.visible(g,{focus:'a',hops:1}).nodes.map(n=>n.id).sort(),['a','b']);assert.equal(M.visible(g,{relation:'contradiction'}).edges.length,1);assert.equal(M.relation('is adjacent to'),'association');assert.equal(M.relation('does not support'),'association');}); +test('layout is deterministic, finite and does not modify source graph',()=>{if(!M)return;const nodes=Array.from({length:60},(_,i)=>({id:'n'+i,label:'Node '+i,kind:'concept',community:i%4}));const edges=nodes.slice(1).map((n,i)=>({source:n.id,target:'n'+i}));const copy=JSON.stringify({nodes,edges});const a=M.layout(nodes,edges);assert.deepEqual(a,M.layout(nodes,edges));assert.equal(copy,JSON.stringify({nodes,edges}));assert.ok(Object.values(a).every(p=>['x','y','z'].every(k=>Number.isFinite(p[k]))));assert.ok(Object.values(a).some(p=>p.z!==0));}); +test('many communities do not stretch depth until the overview collapses',()=>{const nodes=Array.from({length:60},(_,i)=>({id:'island'+i,label:'Idea '+i,kind:'concept',community:i}));const positions=M.layout(nodes,[]);assert.ok(Object.values(positions).every(p=>Math.abs(p.z)<=180),'Depth must stay bounded independently of the community count');}); diff --git a/tests/test_graph_editing.py b/tests/test_graph_editing.py new file mode 100644 index 0000000..ebb8e63 --- /dev/null +++ b/tests/test_graph_editing.py @@ -0,0 +1,93 @@ +"""Real API edits; no inference, no installed user projects.""" +import copy +import pytest +from graphpaper.models import Project, Graph, Node, Edge, Source, Evidence, Angle + + +def seeded(app): + p=Project(title='Graph editing fixture',sources=[Source(id='S1',title='Source',text='Original quotation.')], + graph=Graph(nodes=[Node(id='n1',label='First',status='sourced',evidence=[Evidence(source_id='S1',quote='Original quotation.',verified=True)],source_ids=['S1']),Node(id='n2',label='Second')],edges=[Edge(id='e1',source='n1',target='n2',relation='supports')],engine='Graphify'),draft='Keep my fucking argument.',angles=[Angle(title='Kept angle',thesis='My argument.')]) + p.voice_profile.instructions='Power language stays.' + app.state.store.create(p) + return p + + +def edit(client,p,op,id=None,**values): + return client.post(f'/api/projects/{p.id}/graph/edit',json={'version':p.version,'operation':op,'id':id,'values':values}) + + +def test_edit_node_keeps_manuscript_voice_evidence_and_marks_angles_stale(client,app): + p=seeded(app) + response=edit(client,p,'node','n1',label='An uncompromising claim',kind='claim',description='Not a polite question.') + assert response.status_code==200,response.text + saved=response.json()['project'];node=saved['graph']['nodes'][0] + assert node['label']=='An uncompromising claim' and node['status']=='proposed' + assert node['evidence']==p.graph.nodes[0].model_dump()['evidence'] + assert saved['sources']==p.model_dump()['sources'] and saved['draft']==p.draft + assert saved['voice_profile']==p.voice_profile.model_dump() and len(saved['angles'])==1 + assert saved['angles_context_hash']=='' and saved['usage']['calls']==0 + + +def test_node_and_connection_creation_and_reloading(client,app): + p=seeded(app) + response=edit(client,p,'node',label='New idea',kind='theme',description='New thought') + assert response.status_code==200,response.text + value=response.json();p=Project.model_validate(value['project']);nid=value['selected_id'] + response=edit(client,p,'edge',source=nid,target='n1',relation='contradicts',description='Explains the disagreement.') + assert response.status_code==200,response.text + value=response.json();p=Project.model_validate(value['project']) + assert len(p.graph.nodes)==3 and len(p.graph.edges)==2 + assert client.get(f'/api/projects/{p.id}').json()['graph']==p.graph.model_dump() + + +def test_deleting_node_removes_only_incident_edges_and_undo_restores_it(client,app): + p=seeded(app);p.brief.pinned_nodes=['n1'];app.state.store.save(p,p.version) + before=p.graph.model_dump() + result=edit(client,p,'delete-node','n1');assert result.status_code==200,result.text + value=result.json();saved=value['project'] + assert len(saved['graph']['nodes'])==1 and saved['graph']['edges']==[] + assert saved['brief']['pinned_nodes']==[] + result=client.post(f'/api/projects/{p.id}/graph/undo',json={'version':saved['version']}) + assert result.status_code==200,result.text + restored=result.json()['project'] + assert restored['graph']==before and restored['brief']['pinned_nodes']==['n1'] + assert restored['draft']==p.draft + + +@pytest.mark.parametrize('values',[{'label':''},{'label':'x'*201},{'status':'sourced'},{'source_ids':['S99']},{'kind':'x'*81}]) +def test_invalid_or_provenance_spoofing_edits_are_atomic(client,app,values): + p=seeded(app);before=p.model_dump() + result=edit(client,p,'node','n1',**values) + assert result.status_code==400,result.text + assert app.state.store.get(p.id).model_dump()==before + + +def test_invalid_edge_target_and_stale_edit_do_not_overwrite(client,app): + p=seeded(app) + assert edit(client,p,'edge','e1',target='absent').status_code==400 + assert edit(client,p,'edge','e1',relation='questions').status_code==200 + assert edit(client,p,'node','n1',label='Stale').status_code==409 + assert app.state.store.get(p.id).graph.nodes[0].label=='First' + + +def test_edit_is_blocked_while_extraction_runs(client,app,monkeypatch): + p=seeded(app);monkeypatch.setattr(app.state.runner,'active',lambda pid:True) + assert edit(client,p,'node','n1',label='Do not apply').status_code==400 + assert app.state.store.get(p.id).version==p.version + + +def test_undo_never_overwrites_a_subsequent_graph_rebuild(client,app): + p=seeded(app);result=edit(client,p,'node','n1',label='Edited').json() + p=Project.model_validate(result['project']);p.graph=Graph(nodes=[Node(id='new',label='Fresh extraction')]);app.state.store.save(p,p.version) + response=client.post(f'/api/projects/{p.id}/graph/undo',json={'version':p.version}) + assert response.status_code==409 + assert app.state.store.get(p.id).graph.nodes[0].id=='new' + + +def test_undo_keeps_pin_choices_made_after_the_graph_edit(client,app): + p=seeded(app);p.brief.pinned_nodes=['n1'];app.state.store.save(p,p.version) + value=edit(client,p,'node','n1',label='Edited').json() + p=Project.model_validate(value['project']);p.brief.pinned_nodes=['n2'];app.state.store.save(p,p.version) + response=client.post(f'/api/projects/{p.id}/graph/undo',json={'version':p.version}) + assert response.status_code==200,response.text + assert response.json()['project']['brief']['pinned_nodes']==['n2'] diff --git a/ui/app.js b/ui/app.js index 1f6dbb4..1f1a75a 100644 --- a/ui/app.js +++ b/ui/app.js @@ -65,7 +65,7 @@ function progressDone(t){const p=state.p;return t==='sources'?p.sources.length>0 function render(){ const p=state.p,s=state.settings; document.documentElement.dataset.theme=s.theme||'dark'; - $('#app').innerHTML=`
${''}
${icon('check')}${esc(state.save)}${p?ibtn('Project options','project-options','settings'):''}${btn(p?'Export':'Import project',p?'export':'import-project',p?'download':'upload','small')}
${p?projectHTML():welcomeHTML()}
`; + $('#app').innerHTML=`
${''}
${icon('check')}${esc(state.save)}${p?ibtn('Project options','project-options','settings'):''}${btn(p?'Export':'Import project',p?'export':'import-project',p?'download':'upload','small')}
${p?projectHTML():welcomeHTML()}
`; if(p&&state.tab==='graph'&&p.graph.nodes.length)requestAnimationFrame(drawGraph); setupDrops();paintJob(); } diff --git a/ui/assets/CREDITS.md b/ui/assets/CREDITS.md new file mode 100644 index 0000000..59a0595 --- /dev/null +++ b/ui/assets/CREDITS.md @@ -0,0 +1,3 @@ +# Sidebar photograph / scan + +Graph paper notepad (4562203394), by **Calsidyrose**, is a direct scan of real vintage graph paper. Source: https://commons.wikimedia.org/wiki/File:Graph_paper_notepad_(4562203394).jpg . Licensed under **Creative Commons Attribution 2.0**: https://creativecommons.org/licenses/by/2.0/ . Resized and converted to WebP for offline display. No endorsement is implied. The application includes a visible attribution link. diff --git a/ui/assets/graph-paper.json b/ui/assets/graph-paper.json new file mode 100644 index 0000000..6c6e409 --- /dev/null +++ b/ui/assets/graph-paper.json @@ -0,0 +1,10 @@ +{ + "title": "Graph paper notepad (4562203394)", + "author": "Calsidyrose", + "source": "https://commons.wikimedia.org/wiki/File:Graph_paper_notepad_(4562203394).jpg", + "license": "CC BY 2.0", + "license_url": "https://creativecommons.org/licenses/by/2.0/", + "changes": "Resized and converted to WebP; the image is a scan of real vintage graph paper, not AI-generated.", + "original_sha256": "317936ffeeaa0214b9e03da063de092fc69688ab046d0c5c1997d2e776386da6", + "bundled_sha256": "d6b0df4e72784fce9cb72693c6aa4156d39efcdb4859c686a0f643ea5d066701" +} diff --git a/ui/assets/graph-paper.webp b/ui/assets/graph-paper.webp new file mode 100644 index 0000000..d077fa1 Binary files /dev/null and b/ui/assets/graph-paper.webp differ diff --git a/ui/graph_math.js b/ui/graph_math.js new file mode 100644 index 0000000..85ab7c8 --- /dev/null +++ b/ui/graph_math.js @@ -0,0 +1,48 @@ +/* Pure, dependency-free spatial graph geometry. Shared by the UI and Node tests. */ +(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.GraphMath=api;})(typeof globalThis!=='undefined'?globalThis:this,function(){ + 'use strict'; + const MAX_NODES=180,MAX_EDGES=360; + const clamp=(v,a,b)=>Math.min(b,Math.max(a,v)); + const family={support:['supports','support','reinforces','corroborates','backs','supported_by','can_support','seeks_to_support'],contradiction:['contradicts','contradiction','opposes','conflicts_with','challenges','refutes'],citation:['cites','citation','references','quotes','sourced_from','draws_from'],causal:['causes','leads_to','enables','produces','results_in','depends_on','can_produce'],style:['influences_style','style_influence','expresses','uses_style','style'],question:['questions','raises_question','open_question','asks','uncertain','can_question']}; + function relation(value){const r=String(value||'').trim().toLowerCase().replace(/[\s-]+/g,'_');for(const [k,list] of Object.entries(family))if(list.includes(r))return k;return 'association';} + function kind(value){const k=String(value||'concept').toLowerCase();if(['source','document','paper','article','reference'].includes(k))return 'source';if(['claim','argument','hypothesis','finding'].includes(k))return 'claim';if(['evidence','quote','quotation','data','fact'].includes(k))return 'evidence';if(['theme','topic','concept','idea'].includes(k))return k==='theme'||k==='topic'?'theme':'concept';if(['angle','question','risk','tension','conflict'].includes(k))return 'angle';if(['person','character','organization'].includes(k))return 'character';if(['section','draft','draft_section','scene','event'].includes(k))return 'section';if(['voice','style','habit','rhythm','diction','rule','canon'].includes(k))return 'style';return 'concept';} + function visible(g,o={}){ + const limit=clamp(Number(o.limit)||60,10,MAX_NODES),search=String(o.search||'').trim().toLowerCase(); + const all=new Map(g.nodes.map(n=>[n.id,n])),degree=new Map(),adj=new Map(); + for(const e of g.edges){degree.set(e.source,(degree.get(e.source)||0)+1);degree.set(e.target,(degree.get(e.target)||0)+1);if(!adj.has(e.source))adj.set(e.source,new Set());if(!adj.has(e.target))adj.set(e.target,new Set());adj.get(e.source).add(e.target);adj.get(e.target).add(e.source);} + const neighborhood=new Set();if(o.focus&&all.has(o.focus)){neighborhood.add(o.focus);let front=[o.focus];for(let i=0;i<(Number(o.hops)||1);i++){const next=[];for(const id of front)for(const n of adj.get(id)||[])if(!neighborhood.has(n)){neighborhood.add(n);next.push(n);}front=next;}} + const matched=new Set();if(search){for(const n of g.nodes)if([n.label,n.kind,n.description].join(' ').toLowerCase().includes(search))matched.add(n.id);for(const e of g.edges)if([e.relation,e.description].join(' ').toLowerCase().includes(search)){matched.add(e.source);matched.add(e.target);}} + const path=new Set(o.path||[]),pins=new Set(o.pins||[]); + let candidates=g.nodes.filter(n=>(!o.kind||kind(n.kind)===o.kind)&&(!o.focus||neighborhood.has(n.id))&&(!search||matched.has(n.id))); + const rank=n=>(n.id===o.selected?1e7:0)+(path.has(n.id)?1e6:0)+(pins.has(n.id)?1e5:0)+(degree.get(n.id)||0); + candidates.sort((a,b)=>rank(b)-rank(a)||String(a.id).localeCompare(String(b.id))); + const nodes=candidates.slice(0,limit),ids=new Set(nodes.map(n=>n.id)); + const links=g.edges.filter(e=>ids.has(e.source)&&ids.has(e.target)&&(!o.relation||relation(e.relation)===o.relation)); + links.sort((a,b)=>((b.source===o.selected||b.target===o.selected)?1:0)-((a.source===o.selected||a.target===o.selected)?1:0)); + return {nodes,edges:links.slice(0,MAX_EDGES),degree,matching:candidates.length,availableEdges:links.length,total:g.nodes.length}; + } + function layout(nodes,edges){ + const sorted=nodes.slice().sort((a,b)=>String(a.id).localeCompare(String(b.id))),positions=Object.create(null); + const communities=new Set(sorted.map(n=>n.community||0)),useCommunity=communities.size>1; + const groups=new Map();for(const n of sorted){const key=useCommunity?'c'+(n.community||0):kind(n.kind);if(!groups.has(key))groups.set(key,[]);groups.get(key).push(n);} + const keys=[...groups.keys()].sort(),centers={},ring=keys.length>1?Math.max(180,Math.sqrt(nodes.length)*36):0; + keys.forEach((key,j)=>{const many=keys.length>8,theta=many?j*2.399963:j*2*Math.PI/keys.length-.5,radius=many?ring*Math.sqrt((j+.5)/keys.length):ring;centers[key]={x:Math.cos(theta)*radius*1.35,y:Math.sin(theta)*radius*.9,z:Math.sin(theta)*90};groups.get(key).forEach((n,i)=>{const t=i*2.399963,r=55*Math.sqrt(i+1),c=centers[key];positions[n.id]={x:c.x+Math.cos(t)*r,y:c.y+Math.sin(t)*r,z:c.z+Math.sin(t*1.31)*70};});}); + for(let step=0;step<64;step++){ + const forces=Object.create(null);for(const n of sorted)forces[n.id]={x:-positions[n.id].x*.002,y:-positions[n.id].y*.002}; + for(let i=0;ispan{background:var(--accent);color:var(--accentInk)}.step-rule{background:var(--border)} +.button{border-radius:9px;font-size:12px}.button.primary{background:linear-gradient(135deg,var(--accent),#aabaff);color:var(--accentInk);box-shadow:0 3px 13px #647fff14}.button.primary:hover{filter:brightness(1.09)}[data-theme=light] .button.primary{background:#3565b1;color:#fff}.button:focus-visible,.icon-btn:focus-visible{outline:2px solid var(--accent);outline-offset:3px} +.panel{border-color:var(--border);border-radius:13px}.modal{background:var(--panel);border-color:var(--border);box-shadow:0 30px 100px #0009}.modal-head{background:linear-gradient(100deg,#526b9a14,transparent);border-bottom-color:var(--border)}.modal h2{font-size:23px;font-weight:620}.modal-body label{color:var(--muted)}input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.chip.green{background:#699bda18;border-color:#6793c744;color:var(--accent)}.chip.gold{color:var(--gold);background:#c68b2511}.dropzone{background:#688abc07;border-color:#637fa254}.upload-circle{background:#6596f319;color:var(--accent)}.source-card{background:linear-gradient(135deg,#638de909,transparent 65%),var(--panel);border-color:var(--border)}.source-card .doc-icon{color:var(--accent);background:#69a1ff17}.source-card:nth-child(3n+2) .doc-icon{color:#c5a5fa;background:#ae80e716}.source-card:nth-child(3n) .doc-icon{color:var(--gold);background:#c2964f16}.angle-card{border-top:2px solid #7594bd80}.angle-card:nth-child(3n+2){border-top-color:#ad83d380}.angle-card:nth-child(3n){border-top-color:#d4a45b80}.welcome h1{letter-spacing:-.065em}.welcome-card{background:linear-gradient(140deg,#618ce70e,transparent),var(--panel);transition:transform .15s,border-color .15s}.welcome-card:hover{transform:translateY(-3px);border-color:var(--accent)}.voice-graph{background:#151c28}.voice-graph-node circle{fill:#b7a0ef!important;stroke:#e0d1ff!important}.voice-graph-node.evidence-node circle{fill:#72a7ed!important;stroke:#a5c8fa!important}.voice-graph line{stroke:#919ebc}.voice-graph .overlay-link{stroke:#dfaa70} +.gs-workbench{grid-template-columns:minmax(0,1fr) 292px;gap:18px;align-items:start}.gs-visual{min-width:0}.gs-panel{min-height:0;background:#141b27;border-color:#3a4b65;overflow:hidden;box-shadow:0 15px 45px #0002;background-image:none}.gs-toolbar{display:flex;gap:9px;align-items:center;padding:13px 14px;border-bottom:1px solid #36445b;background:#1b2433}.gs-toolbar .graph-search{max-width:none;flex:1;min-width:80px;background:#101722;border:1px solid #405574;border-radius:8px;padding:0 10px}.gs-toolbar .graph-search input{background:transparent;border:0;font-size:12px;height:35px;min-width:0;width:100%;padding:5px 6px;color:#e4ecfa}.gs-toolbar .graph-search .icon{width:15px;color:#8faed8}.gs-mode{display:flex;background:#111824;border:1px solid #3a4b64;border-radius:8px;padding:3px;flex-shrink:0}.gs-mode button{font-size:11px;font-weight:700;background:none;color:#9dacbf;border-radius:5px;padding:5px 9px;min-height:27px}.gs-mode button[aria-pressed=true]{background:#436797;color:#fff;box-shadow:0 1px 3px #0003}.gs-toolbar>.icon-btn{color:#adbed5;flex-shrink:0}.gs-filterbar{display:flex;align-items:center;gap:9px;padding:9px 14px;background:#172030;border-bottom:1px solid #2d3b52;flex-wrap:wrap}.gs-filter{display:flex;align-items:center;gap:6px;font-size:10px;color:#8fa4c3;margin:0;min-width:0}.gs-filter span{font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.04em}.gs-filter select{max-width:143px;font-size:10px;padding:5px 6px;min-height:29px;border-color:#384961;color:#d2def1;background:#202b3c;border-radius:6px}.gs-limit{margin-left:auto}.gs-limit select{width:54px}.gs-stage{position:relative;background:radial-gradient(ellipse at 35% 30%,#293d5f40,transparent 66%),radial-gradient(ellipse at 80% 75%,#5b316319,transparent 50%),#121a26;background-image:radial-gradient(ellipse at 35% 30%,#293d5f40,transparent 66%),radial-gradient(#647ba02a .75px,transparent .75px);background-size:auto,24px 24px;overflow:hidden;isolation:isolate}.gs-stage .graph-canvas{width:100%;height:610px;display:block;touch-action:none;cursor:grab;user-select:none}.gs-stage .graph-canvas:active{cursor:grabbing}.gs-instructions{position:absolute;pointer-events:none;top:17px;left:18px;padding:9px 12px;background:#101923b0;border:1px solid #4d6a933b;border-radius:8px;box-shadow:0 4px 15px #0001;color:#9aafcd;backdrop-filter:blur(5px);font-size:10px;line-height:1.65}.gs-instructions b{display:block;font-size:9px;letter-spacing:.17em;color:#aec8ec;margin-bottom:2px}.gs-instructions span{opacity:.85}.gs-viewtools{position:absolute;bottom:16px;right:15px;display:flex;flex-direction:column;padding:4px;background:#121c2cef;border:1px solid #4a6080;border-radius:10px;box-shadow:0 5px 20px #0003}.gs-viewtools .icon-btn{color:#bdd3f1;min-width:30px;min-height:32px}.gs-viewtools .icon-btn:hover{background:#384c6a;color:#fff}.gs-axis{position:absolute;bottom:22px;left:19px;pointer-events:none;display:flex;gap:2px;align-items:center}.gs-axis span{font-size:9px;color:#90a5c5;letter-spacing:.15em}.gs-minimap{position:absolute;bottom:17px;right:66px;width:136px;height:89px;background:#131d2beb;border-radius:11px;cursor:crosshair;box-shadow:0 5px 14px #0003}.gs-minimap>rect{fill:transparent;stroke:#476181}.gs-minimap-viewport{fill:#6fa9ff13;stroke:#729ed7;stroke-width:1}.gs-empty{position:absolute;inset:42% 14% auto;text-align:center;color:#d0ddef;pointer-events:none}.gs-empty strong{font-size:20px;font-weight:500}.gs-empty p{font-size:12px;color:#95a8c4;margin-top:7px}.gs-summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;background:#1a2332;color:#9cb0cd;font-size:10px;border-top:1px solid #34435b;line-height:1.6}.gs-summary .text-link{font-size:10px;flex-shrink:0;color:#9dc4ff}.gs-legend{padding:11px 15px 12px;background:#151e2c;border-top:1px solid #303e54;color:#9eafc8}.gs-legend summary{cursor:pointer;font-size:11px;color:#d7e2f3;font-weight:600}.gs-legend summary span{font-size:9px;font-weight:400;color:#859ab8;margin-left:10px}.gs-legend>div{display:flex;gap:9px 17px;flex-wrap:wrap;padding-top:10px}.gs-legend>div>span{display:inline-flex;align-items:center;gap:6px;font-size:10px}.gs-legend p{font-size:9px;color:#8499b7;margin-top:8px}.gs-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding-top:13px}.gs-actions .button{min-height:32px;font-size:10px;padding:6px 9px}.gs-link .graph-edge{stroke:var(--link-color);stroke-width:1.35;stroke-opacity:.56;cursor:pointer;transition:none}.gs-link.active .graph-edge{stroke-width:2.2;stroke-opacity:1}.gs-link.muted-link{opacity:.24}.gs-link:hover .graph-edge,.gs-link:focus-within .graph-edge{stroke-width:3;stroke-opacity:1}.gs-link-hit{cursor:pointer;outline:none}.gs-link:focus-within .graph-edge{filter:drop-shadow(0 0 3px var(--link-color))}.gs-link-label{font:10px var(--font);paint-order:stroke;stroke:#121a26;stroke-width:5px;stroke-linejoin:round;pointer-events:none}.gs-node{cursor:grab;opacity:1;transition:opacity .15s}.gs-node:active{cursor:grabbing}.gs-node.gs-dim{opacity:.34}.gs-node:hover,.gs-node:focus-visible{opacity:1}.gs-node:hover .gs-halo,.gs-node:focus-visible .gs-halo{opacity:.28}.gs-node:focus-visible .gs-halo{stroke:currentColor;stroke-width:2}.gs-node-caption rect{fill:#1c293ded;stroke:#49608088;stroke-width:.7}.gs-node-caption text{font:11.5px var(--font);fill:#e8f0fc;stroke:none;paint-order:normal;font-weight:500}.gs-node.active .gs-node-caption rect{stroke:currentColor;fill:#263950}.gs-node.active .gs-node-caption text{fill:#fff;font-weight:650}.gs-node-caption{pointer-events:none}.gs-selection-ring{filter:drop-shadow(0 0 3px currentColor)} +.gs-inspector{border-top:2px solid #7b9fcf;overflow:hidden;position:sticky;top:16px;background:linear-gradient(150deg,#6c8fb509,transparent 200px),var(--panel);max-height:900px;overflow-y:auto}.gs-inspector .panel-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:15px 16px;border-color:var(--border);flex-wrap:wrap}.gs-inspector .panel-header h2{font-size:14px;font-weight:650;letter-spacing:-.02em}.gs-kicker{font-size:8px;letter-spacing:.14em;color:var(--soft)}.gs-inspector .panel-body{padding:17px 16px}.gs-selection-empty>.icon{width:34px;height:34px;color:var(--accent);margin-bottom:12px;stroke-width:1.1}.gs-selection-empty h3{font-size:18px;color:var(--text);line-height:1.4}.gs-selection-empty p{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.75}.gs-type-legend{display:grid;grid-template-columns:1fr 1fr;gap:11px 10px;margin-top:22px;font-size:10px;color:var(--muted)}.gs-type-legend span{display:flex;align-items:center;gap:8px}.gs-type-legend i,.gs-connections i{display:inline-block;flex-shrink:0;width:8px;height:8px;background:var(--type-color);border-radius:50%;box-shadow:0 0 8px color-mix(in srgb,var(--type-color) 30%,transparent)}.gs-divider{height:1px;background:var(--border);margin:21px 0}.gs-tip{border-left:2px solid #827aaa;color:var(--muted);padding-left:12px;margin-top:24px}.gs-tip strong{font-size:11px;color:var(--text);font-weight:550}.gs-tip p{font-size:11px;line-height:1.8;margin-top:4px}.gs-badge{display:inline-flex;align-items:center;gap:7px;color:var(--type-color);background:color-mix(in srgb,var(--type-color) 12%,transparent);border:1px solid color-mix(in srgb,var(--type-color) 30%,transparent);padding:4px 9px;border-radius:7px;font-size:11px;font-weight:600}.gs-badge:before{content:'';width:7px;height:7px;background:var(--type-color);border-radius:50%}.gs-inspector .gs-selected-title{background:none;border:0;padding:0;margin:0 0 18px}.gs-selected-title h3{font-size:23px;font-weight:600;line-height:1.3;margin-bottom:10px;overflow-wrap:anywhere}.gs-selected-title .chip{font-size:9px;color:var(--muted)}.gs-edit-fields label{font-size:11px;margin-top:13px;color:var(--muted)}.gs-edit-fields input,.gs-edit-fields textarea,.gs-edit-fields select{width:100%;font-size:12px;padding:9px 10px;border-radius:7px;line-height:1.65}.gs-edit-fields textarea{min-height:96px}.gs-edit-fields .button{font-size:11px}.gs-edit-fields #gs-edit-status{margin-top:8px;font-size:10px;line-height:1.6;color:var(--gold)}.gs-inspector .evidence-card{background:#6385b608;border-color:var(--border)}.gs-inspector .evidence-card blockquote{font-size:11px;line-height:1.7}.gs-connections summary{font-size:11px;color:var(--muted);cursor:pointer}.gs-connections summary b{font-size:10px;background:var(--panel2);padding:2px 6px;margin-left:6px;border-radius:4px;font-weight:500}.gs-connections button{display:flex;align-items:center;text-align:left;gap:9px;padding:9px 0;background:none;width:100%;border-bottom:1px solid #58688125;color:var(--muted)}.gs-connections button:hover{color:var(--text);background:var(--hover)}.gs-connections strong{font-size:10px;display:block;color:var(--text);font-weight:500}.gs-connections small{font-size:10px;display:block;line-height:1.7;overflow-wrap:anywhere}.graph-expanded .gs-workbench{position:fixed;inset:10px;z-index:35;padding:15px;background:var(--bg);border:1px solid var(--border);border-radius:16px;overflow:auto;box-shadow:0 0 0 40px #000a}.graph-expanded .gs-stage .graph-canvas{height:calc(100vh - 263px);min-height:300px}.graph-expanded .gs-inspector{max-height:calc(100vh - 55px)}.graph-expanded .gs-actions{padding-top:8px} +[data-theme=light] .gs-inspector .evidence-card{background:#f4f6fb}[data-theme=light] .gs-node-caption text{fill:#e8f0fc}.gs-panel .text-link:hover{color:#fff} +@media(min-width:1700px){.gs-workbench{grid-template-columns:minmax(0,1fr) 320px}.gs-stage .graph-canvas{height:690px}} +@media(max-width:1250px){.shell{grid-template-columns:205px minmax(0,1fr)}.workspace-body{padding:0 20px 24px}.workspace-head{padding:23px 20px 18px}.gs-workbench{grid-template-columns:minmax(0,1fr) 258px;gap:13px}.gs-toolbar{padding:10px}.gs-filterbar{gap:7px;padding:8px 10px}.gs-filter select{max-width:124px}.gs-filter span{display:none}.gs-stage .graph-canvas{height:560px}.gs-inspector .panel-body{padding:14px}.gs-selected-title h3{font-size:21px}.gs-limit{margin-left:0}} +@media(max-width:1040px){.gs-workbench{grid-template-columns:minmax(0,1fr)}.gs-inspector{position:static;max-height:none}.gs-stage .graph-canvas{height:560px}.gs-inspector .gs-edit-fields{max-width:640px}.gs-connections{max-width:640px}.gs-selection-empty{max-width:560px}.gs-type-legend{grid-template-columns:repeat(3,1fr);max-width:570px}.graph-expanded .gs-workbench{grid-template-columns:minmax(0,1fr)}.graph-expanded .gs-inspector{max-height:none}.graph-expanded .gs-stage .graph-canvas{height:60vh}} +@media(max-width:720px){.shell{grid-template-columns:1fr}.sidebar.open{width:230px}.topbar{padding:0 16px}.workspace-head{padding:21px 16px 18px}.workspace-head h1{font-size:25px}.workspace-body{padding:0 12px 20px}.steps{padding-left:16px;padding-right:16px}.gs-stage .graph-canvas{height:490px}.gs-filterbar{gap:7px}.gs-filter select{max-width:123px}.gs-toolbar .graph-search input{font-size:11px}.gs-mode button{padding:4px 7px}.gs-instructions{font-size:9px;top:10px;left:10px;max-width:205px}.gs-summary{font-size:9px;padding:9px 10px;align-items:flex-start}.gs-legend>div{gap:8px 12px}.gs-legend summary span{display:none}.gs-minimap{width:109px;height:72px}.gs-axis{left:12px;bottom:20px}.gs-actions .spacer{display:none}.gs-inspector .panel-header{padding:13px 16px}} +@media(prefers-reduced-motion:reduce){.gs-node,.welcome-card{transition:none}.welcome-card:hover{transform:none}} +@media print{.gs-workbench{display:none}.sidebar{display:none!important}} + +.gs-link-label{display:none}.gs-link-label.shown,.gs-link:hover .gs-link-label,.gs-link:focus-within .gs-link-label{display:block} diff --git a/ui/graph_studio.js b/ui/graph_studio.js new file mode 100644 index 0000000..9398802 --- /dev/null +++ b/ui/graph_studio.js @@ -0,0 +1,102 @@ +/* GraphPaper spatial studio. Real XYZ projection; no CDN, WebGL or model calls. */ +'use strict'; +const GS={pid:'',view:null,positions:{},dirty:false,drag:null,svg:null,frame:0,saveTimer:0,canUndo:false,pending:null,linkFrom:null,screen:[],bound:null}; +const GS_TYPES={source:{name:'Source',color:'#64a8ff',shape:'square'},claim:{name:'Claim',color:'#f38ab7',shape:'circle'},evidence:{name:'Evidence',color:'#bc9cff',shape:'hex'},theme:{name:'Theme',color:'#53cbd5',shape:'diamond'},angle:{name:'Angle / tension',color:'#ffc16c',shape:'triangle'},character:{name:'Person / character',color:'#f4977c',shape:'square'},section:{name:'Scene / section',color:'#d7a7ff',shape:'hex'},style:{name:'Style / canon',color:'#a3a4ff',shape:'diamond'},concept:{name:'Idea',color:'#97b5ed',shape:'circle'}}; +const GS_REL={support:{name:'Support',color:'#5cdbbf',dash:''},contradiction:{name:'Opposition',color:'#ff967d',dash:'7 4'},citation:{name:'Citation',color:'#75b5ff',dash:'2 4'},causal:{name:'Consequence',color:'#f8ca7d',dash:''},style:{name:'Style',color:'#c6a0ff',dash:'8 3 2 3'},question:{name:'Question',color:'#ed99cf',dash:'3 5'},association:{name:'Association',color:'#a0b0ca',dash:'2 5'}}; +const GS_OTHER=['#82b9ce','#b4a0d7','#c3ba91','#99b7de','#c69dab','#9fc1b0']; +function gsEdgeStyle(value){const family=GraphMath.relation(value);if(family!=='association')return {...GS_REL[family],key:family};let h=0;for(const c of String(value||''))h=(h*31+c.charCodeAt(0))>>>0;const index=h%GS_OTHER.length;return {name:String(value),color:GS_OTHER[index],dash:['2 5','7 4','1 3','8 3 2 3','4 4',''][index],key:'other-'+index};} +function gsViewRender(){const inspector=$('.gs-inspector');render();if(GS.dirty&&inspector&&$('.gs-inspector'))$('.gs-inspector').replaceWith(inspector);} +function gsDefaults(){return {mode:'3d',yaw:.28,pitch:-.15,zoom:1,panX:0,panY:0,limit:60,kind:'',relation:'',hops:0,spread:1,fit:true};} +function gsEnsure(){if(!state.p)return;if(GS.pid===state.p.id)return;GS.pid=state.p.id;GS.view=gsDefaults();GS.positions=Object.create(null);GS.dirty=false;GS.linkFrom=null;GS.canUndo=false;GS.drag=null;try{const saved=JSON.parse(storage.getItem('graphpaper.spatial.'+GS.pid)||'null');if(saved){for(const key of ['yaw','pitch','zoom','panX','panY','spread'])if(Number.isFinite(saved.view?.[key]))GS.view[key]=GraphMath.clamp(saved.view[key],key==='zoom'?.12:-1e5,key==='zoom'?5:1e5);GS.view.spread=GraphMath.clamp(GS.view.spread,.5,3);GS.view.mode=saved.view?.mode==='2d'?'2d':'3d';GS.view.fit=false;for(const [id,p] of Object.entries(saved.positions||{}).slice(0,5000))if(['x','y','z'].every(k=>Number.isFinite(p[k])&&Math.abs(p[k])<1e6))GS.positions[id]=p;}}catch{}const pid=GS.pid;api('/projects/'+pid+'/graph/edit-status').then(s=>{if(GS.pid===pid){GS.canUndo=s.can_undo;const b=$('[data-action="gs-undo"]');if(b)b.disabled=!GS.canUndo||busy();}}).catch(()=>{});} +function gsRemember(){clearTimeout(GS.saveTimer);const pid=GS.pid;GS.saveTimer=setTimeout(()=>{if(GS.pid!==pid)return;try{storage.setItem('graphpaper.spatial.'+pid,JSON.stringify({view:GS.view,positions:GS.positions}));}catch{}},250);} +function gsRequestDraw(){if(GS.frame)return;GS.frame=requestAnimationFrame(()=>{GS.frame=0;drawGraph();});} +function gsOptions(items,selected){return Object.entries(items).map(([k,v])=>``).join('');} +const gsPreviousGraphHTML=graphHTML; +graphHTML=function(){gsEnsure();const p=state.p,v=GS.view;return `
${ibtn('Expand graph','gs-expand','expand')}
${ibtn('Zoom in','zoom-in','plus')}${ibtn('Zoom out','zoom-out','minus')}${ibtn('Fit graph','fit-graph','expand')}${ibtn('Spread nodes apart','gs-spread','branch')}${ibtn('Reset camera','gs-reset','refresh')}
Read the connections Color + pattern + direction
${Object.entries(GS_REL).map(([k,t])=>`${t.name}`).join('')}

Known relationships share a style. Other names have their own accent; hover or select a link to read its exact wording.

${btn('Add node','gs-add-node','plus','small')}${btn('Connect nodes','gs-add-edge','branch','small',p.graph.nodes.length<2?'disabled':'')}${btn('Undo edit','gs-undo','refresh','small ghost',!GS.canUndo||busy()?'disabled':'')}${btn('Find a path','find-path','branch','small ghost')}${btn('Rebuild','run-graph','refresh','small ghost',busy()?'disabled':'')}${btn('Import','import-graph','upload','small ghost')}
${p.graph.warnings.slice(0,2).map(w=>`
${esc(w)}
`).join('')}${state.path.length?`
Conceptual path: ${state.path.map(id=>esc(p.graph.nodes.find(n=>n.id===id)?.label||id)).join(' → ')}
`:''}
${directionHTML()}
`;}; +const gsPreviousDirection=directionHTML; +directionHTML=function(){if(!state.p||state.tab!=='graph')return gsPreviousDirection();const p=state.p;const n=p.graph.nodes.find(n=>n.id===state.node),e=p.graph.edges.find(e=>e.id===state.edge),item=n||e;if(!item)return ``; + const type=n?GS_TYPES[GraphMath.kind(n.kind)]:GS_REL[GraphMath.relation(e.relation)];const links=n?p.graph.edges.filter(l=>l.source===n.id||l.target===n.id):[]; + const sourceIds=[...new Set([...(item.source_ids||[]),...(item.evidence||[]).map(a=>a.source_id)])]; + return ``; +}; +function gsEndpointOptions(id){return state.p.graph.nodes.map(n=>``).join('');} +function gsRelationList(){return ''+['supports','contradicts','cites','leads_to','influences_style','questions','related_to'].map(t=>`';} +function gsGuard(fn){if(!GS.dirty){fn();return;}GS.pending=fn;showModal('Keep your graph edit?','There are unsaved changes in the graph inspector.','

Save them before continuing, or discard this edit. Your last saved graph is unchanged.

',btn('Stay here','close-modal','','small ghost')+btn('Discard edit','gs-discard','','small')+btn('Save and continue','gs-save-continue','check','primary'));} +function gsSelect(id,edge=false){gsGuard(()=>{GS.dirty=false;state.node=edge?null:id;state.edge=edge?id:null;state.path=[];gsRequestDraw();refreshDirection();});} +selectNode=function(id){gsSelect(id);}; +function gsPoint(ev,svg){const p=svg.createSVGPoint();p.x=ev.clientX;p.y=ev.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());} +function gsFit(points,width,height){if(!points.length)return;const c={...GS.view,zoom:1,panX:0,panY:0};const ps=points.map(p=>GraphMath.project(p,c));const xs=ps.map(p=>p.x),ys=ps.map(p=>p.y),minX=Math.min(...xs),maxX=Math.max(...xs),minY=Math.min(...ys),maxY=Math.max(...ys);const zoom=GraphMath.clamp(Math.min((width-180)/Math.max(160,maxX-minX),(height-170)/Math.max(120,maxY-minY)),.12,2);GS.view.zoom=zoom;GS.view.panX=-(minX+maxX)/2*zoom;GS.view.panY=-(minY+maxY)/2*zoom;GS.view.fit=false;} +function gsShape(shape,r,fill){const common=`fill="${fill}" stroke="currentColor" stroke-width="1.2"`;if(shape==='diamond')return ``;if(shape==='square')return ``;if(shape==='triangle')return ``;if(shape==='hex')return ``;return ``;} +drawGraph=function(){gsEnsure();const svg=$('#graph-svg');if(!svg||!state.p)return;const v=GS.view,g=state.p.graph; + const selection=state.node||(state.edge?g.edges.find(e=>e.id===state.edge)?.source:null); + const view=GraphMath.visible(g,{...v,search:state.search,selected:selection,focus:v.hops?selection:null,pins:state.p.brief.pinned_nodes,path:state.path}); + const nodes=view.nodes,edges=view.edges;if(nodes.some(n=>!GS.positions[n.id])){const fresh=GraphMath.layout(nodes,edges);for(const n of nodes)GS.positions[n.id]||=fresh[n.id];} + const bounds=svg.getBoundingClientRect(),width=Math.max(300,bounds.width||900),height=Math.max(300,bounds.height||650);svg.setAttribute('viewBox',`0 0 ${width} ${height}`); + const world=n=>{const p=GS.positions[n.id];return {x:p.x*v.spread,y:p.y*v.spread,z:p.z*v.spread};};if(v.fit)gsFit(nodes.map(world),width,height); + const projected=new Map(nodes.map(n=>{const q=GraphMath.project(world(n),v);return [n.id,{...q,x:q.x+width/2,y:q.y+height/2}];})); + const neighbors=new Set(state.path||[]);if(selection){neighbors.add(selection);g.edges.forEach(e=>{if(e.source===selection)neighbors.add(e.target);if(e.target===selection)neighbors.add(e.source);});} + const labels=new Map(),boxes=[];const labelCandidates=nodes.slice().sort((a,b)=>(b.id===state.node?1e5:neighbors.has(b.id)?1000:0)+(view.degree.get(b.id)||0)-(a.id===state.node?1e5:neighbors.has(a.id)?1000:0)-(view.degree.get(a.id)||0)); + const maxLabels=nodes.length<=30?30:Math.min(nodes.length,Math.round(24+v.zoom*12)); + for(const n of labelCandidates){const p=projected.get(n.id);if(labels.size>=maxLabels&&!neighbors.has(n.id))continue;const text=n.label.length>30?n.label.slice(0,28)+'…':n.label,w=Math.min(196,text.length*6.5+18),h=25;if(p.x<-80||p.y<-40||p.x>width+80||p.y>height+40)continue;const possibilities=[{x:p.x-w/2,y:p.y+21},{x:p.x-w/2,y:p.y-47},{x:p.x+23,y:p.y-12}];let box=possibilities.find(b=>!boxes.some(a=>b.xa.x&&b.ya.y));if(!box&&n.id===state.node)box=possibilities[0];if(!box)continue;boxes.push({...box,w,h});labels.set(n.id,{...box,w,h,text});} + const defs=Object.entries(GS_TYPES).map(([k,t])=>``).join('')+Object.entries(GS_REL).map(([k,t])=>``).join(''); + const otherDefs=GS_OTHER.map((color,i)=>``).join(''); + const pairs=new Map();const edgeHtml=edges.map(e=>{const a=projected.get(e.source),b=projected.get(e.target),key=[e.source,e.target].sort().join('\0'),index=pairs.get(key)||0;pairs.set(key,index+1);const route=GraphMath.curve(a,b,index),type=GraphMath.relation(e.relation),t=gsEdgeStyle(e.relation),active=e.id===state.edge||state.path.length&&state.path.includes(e.source)&&state.path.includes(e.target)||selection&&(e.source===selection||e.target===selection);const dim=selection&&!active; + return `${esc(e.relation)} · ${esc(e.status)} · click to edit${`${esc(e.relation.replaceAll('_',' '))}`}`;}).join(''); + const nodesHtml=nodes.slice().sort((a,b)=>projected.get(b.id).z-projected.get(a.id).z).map(n=>{const p=projected.get(n.id),k=GraphMath.kind(n.kind),t=GS_TYPES[k],r=GraphMath.clamp((12+Math.min(6,(view.degree.get(n.id)||0)*.45))*Math.sqrt(p.scale),9,25),active=n.id===state.node,dim=selection&&!neighbors.has(n.id),label=labels.get(n.id);return `${gsShape(t.shape,r,`url(#gs-grad-${k})`)}${active?``:''}${state.p.brief.pinned_nodes.includes(n.id)?``:''}${label?`${esc(label.text)}`:''}${esc(n.label)} · ${esc(n.kind)}\n${esc(n.description||'Click to inspect and edit')}`;}).join(''); + svg.innerHTML=`${defs}${otherDefs}${edgeHtml}${nodesHtml}`;GS.screen=[...projected.entries()]; + const count=$('#graph-visibility');if(count)count.textContent=`${nodes.length} of ${g.nodes.length} nodes · ${edges.length} of ${g.edges.length} connections${nodes.length${v.mode==='3d'?'SPATIAL VIEW':'FLAT VIEW'}${GS.linkFrom?'Choose another node to connect.':v.mode==='3d'?'Drag canvas to orbit · Shift-drag to pan':'Drag canvas to pan'}
Scroll to zoom · drag nodes to arrange
`; + const empty=$('#gs-empty');empty.classList.toggle('hidden',nodes.length>0);if(!nodes.length)empty.innerHTML=g.nodes.length?'No matching ideas.

Reset filters or try another search.

':'A blank sheet. An open question.

Add a node yourself or build a graph from your sources.

'; + $('#gs-axis').innerHTML=`${v.mode==='3d'?'XYZ':'XY'}${[['x','#f4977c'],['y','#64a8ff'],['z','#bc9cff']].filter(([a])=>v.mode==='3d'||a!=='z').map(([a,c])=>{const p=GraphMath.project({x:a==='x'?19:0,y:a==='y'?-19:0,z:a==='z'?19:0},{...v,zoom:1,panX:0,panY:0});return `${a.toUpperCase()}`;}).join('')}`; + gsMini(nodes,world,width,height);gsBind(svg);gsRemember(); +}; +function gsMini(nodes,world,width,height){const mini=$('#gs-minimap');if(!mini)return;const pts=nodes.map(n=>({id:n.id,...GraphMath.project(world(n),{...GS.view,zoom:1,panX:0,panY:0})}));if(!pts.length){mini.innerHTML='';return;}const xs=pts.map(p=>p.x),ys=pts.map(p=>p.y),x0=Math.min(...xs)-80,y0=Math.min(...ys)-80,w=Math.max(...xs)-x0+80,h=Math.max(...ys)-y0+80,s=Math.min(130/w,78/h);GS.bound={x0,y0,s};const z=GS.view.zoom;mini.innerHTML=`${pts.map(p=>``).join('')}`;mini.onclick=ev=>{const p=gsPoint(ev,mini),b=GS.bound;GS.view.panX=-((p.x-10)/b.s+b.x0)*GS.view.zoom;GS.view.panY=-((p.y-10)/b.s+b.y0)*GS.view.zoom;gsRequestDraw();};} +function gsBind(svg){if(GS.svg===svg)return;GS.svg=svg; + svg.onpointerdown=ev=>{if(ev.button!==0&&ev.button!==1)return;const p=gsPoint(ev,svg),node=ev.target.closest('[data-node]'),edge=ev.target.closest('[data-edge]');GS.drag={id:node?.dataset.node,edge:edge?.dataset.edge,start:p,view:{...GS.view},pos:node?{...GS.positions[node.dataset.node]}:null,pan:ev.shiftKey||ev.button===1,moved:false};svg.setPointerCapture(ev.pointerId);}; + svg.onpointermove=ev=>{const d=GS.drag;if(!d)return;const p=gsPoint(ev,svg),dx=p.x-d.start.x,dy=p.y-d.start.y;if(Math.hypot(dx,dy)>4)d.moved=true;if(!d.moved)return;if(d.id){GS.positions[d.id]=GraphMath.move(d.pos,dx/GS.view.spread,dy/GS.view.spread,d.view);}else if(d.pan||GS.view.mode==='2d'){GS.view.panX=d.view.panX+dx;GS.view.panY=d.view.panY+dy;}else{GS.view.yaw=d.view.yaw+dx*.006;GS.view.pitch=GraphMath.clamp(d.view.pitch+dy*.006,-1.4,1.4);}gsRequestDraw();}; + svg.onpointerup=ev=>{const d=GS.drag;GS.drag=null;if(svg.hasPointerCapture(ev.pointerId))svg.releasePointerCapture(ev.pointerId);if(!d)return;if(!d.moved){if(d.id&&GS.linkFrom){const from=GS.linkFrom;GS.linkFrom=null;gsConnectDialog(from,d.id);}else if(d.id)gsSelect(d.id);else if(d.edge)gsSelect(d.edge,true);}gsRequestDraw();};svg.onpointercancel=()=>{GS.drag=null;}; + svg.onwheel=ev=>{ev.preventDefault();const p=gsPoint(ev,svg),r=svg.viewBox.baseVal,old=GS.view.zoom,z=GraphMath.clamp(old*Math.exp(-ev.deltaY*.0014),.12,5),x=p.x-r.width/2,y=p.y-r.height/2;GS.view.panX=x-(x-GS.view.panX)*z/old;GS.view.panY=y-(y-GS.view.panY)*z/old;GS.view.zoom=z;gsRequestDraw();}; + svg.onkeydown=ev=>{if(['Enter',' '].includes(ev.key)){const n=ev.target.closest('[data-node]'),e=ev.target.closest('[data-edge]');if(n||e){ev.preventDefault();gsSelect(n?n.dataset.node:e.dataset.edge,!n);}}}; + svg.ondblclick=ev=>{const n=ev.target.closest('[data-node]');if(n){gsSelect(n.dataset.node);$('#gs-label')?.focus();}}; +} +async function gsApply(operation,id,values){await flush();const pid=state.p.id;const response=await api('/projects/'+pid+'/graph/edit',{version:state.p.version,operation,id,values});if(state.p?.id!==pid)return;state.p=response.project;GS.canUndo=response.can_undo;GS.dirty=false;state.node=operation==='node'?response.selected_id:null;state.edge=operation==='edge'?response.selected_id:null;syncSummary();editorialUI.statusKey='';render();toast('Graph saved. Your manuscript and sources are unchanged.');} +async function gsSave(){const f=$('.gs-edit-fields');if(!f)return;const type=f.dataset.editKind;const values=type==='node'?{label:$('#gs-label').value,kind:$('#gs-node-kind').value,description:$('#gs-description').value}:{source:$('#gs-source').value,target:$('#gs-target').value,relation:$('#gs-edge-relation').value,description:$('#gs-description').value};await gsApply(type,f.dataset.editId,values);} +function gsAddNode(){gsGuard(()=>showModal('Add an idea','A manual graph node. No extraction or writing is rerun.',``,btn('Add node','gs-create-node','plus','primary')));} +function gsConnectDialog(from,to){gsGuard(()=>showModal('Connect two ideas','Direction and relationship are yours to specify.',`${gsRelationList()}`,btn('Create connection','gs-create-edge','branch','primary')));} +const gsPreviousAction=handleAction; +handleAction=async function(el){const a=el.dataset.action; + if(GS.dirty&&['tab','home','new','open-project','run-graph','run-angles','pin','unpin','exclude','clear-exclusions','brief'].includes(a)){gsGuard(()=>gsPreviousAction(el));return;} + if(!a.startsWith('gs-')&&!['zoom-in','zoom-out','fit-graph'].includes(a))return gsPreviousAction(el); + gsEnsure();if(!GS.view)return; + if(['gs-save','gs-delete','gs-add-node','gs-add-edge','gs-create-node','gs-create-edge','gs-undo','gs-connect-from','gs-confirm-delete','gs-save-continue'].includes(a)&&!editable())return; + if(a==='gs-save'){await gsSave();return;}if(a==='gs-save-continue'){const fn=GS.pending;await gsSave();closeModal();GS.pending=null;fn?.();return;}if(a==='gs-discard'){GS.dirty=false;const fn=GS.pending;GS.pending=null;closeModal();fn?.();return;} + if(a==='gs-add-node'){gsAddNode();return;}if(a==='gs-add-edge'){gsConnectDialog();return;} + if(a==='gs-create-node'){const values={label:$('#gs-new-label').value,kind:$('#gs-new-kind').value,description:$('#gs-new-notes').value};await gsApply('node',null,values);closeModal();GS.view.fit=true;drawGraph();return;} + if(a==='gs-create-edge'){const values={source:$('#gs-new-source').value,target:$('#gs-new-target').value,relation:$('#gs-new-relation').value,description:$('#gs-new-notes').value};await gsApply('edge',null,values);closeModal();return;} + if(a==='gs-delete'){const n=state.p.graph.nodes.find(n=>n.id===state.node);const count=n?state.p.graph.edges.filter(e=>e.source===n.id||e.target===n.id).length:0;showModal(n?'Delete this node?':'Delete this connection?','This can be reversed with Undo edit.',`

${n?`Remove “${esc(n.label)}” and its ${count} incident connection(s)?`:'Remove only the selected connection?'}

Your source documents, voice and manuscript will not be deleted.

`,btn('Cancel','close-modal','','small')+btn('Delete from graph','gs-confirm-delete','x','primary'));return;} + if(a==='gs-confirm-delete'){await gsApply(state.node?'delete-node':'delete-edge',state.node||state.edge,{});closeModal();return;} + if(a==='gs-undo'){await flush();const result=await api('/projects/'+state.p.id+'/graph/undo',{version:state.p.version});state.p=result.project;GS.canUndo=result.can_undo;GS.dirty=false;state.node=null;state.edge=null;syncSummary();render();toast('Manual graph edit undone.');return;} + if(a==='gs-select-edge'){gsSelect(el.dataset.id,true);return;}if(a==='gs-deselect'){gsGuard(()=>{state.node=null;state.edge=null;GS.view.hops=0;GS.dirty=false;render();});return;} + if(a==='gs-connect-from'){gsGuard(()=>{GS.linkFrom=state.node;gsRequestDraw();toast('Click the destination node, then name the connection.');});return;} + if(a==='gs-focus-here'){GS.view.hops=1;GS.view.fit=true;gsViewRender();return;} + if(a==='gs-expand'){document.body.classList.toggle('graph-expanded');gsRequestDraw();return;} + if(a==='gs-clear'){GS.view.kind='';GS.view.relation='';GS.view.hops=0;state.search='';GS.view.fit=true;gsViewRender();return;} + if(a==='gs-2d'||a==='gs-3d'){GS.view.mode=a==='gs-2d'?'2d':'3d';GS.view.fit=true;gsViewRender();return;} + if(a==='gs-reset'){GS.view.yaw=.28;GS.view.pitch=-.15;GS.view.panX=0;GS.view.panY=0;GS.view.spread=1;GS.view.fit=true;} + if(a==='gs-spread')GS.view.spread=Math.min(3,GS.view.spread*1.18); + if(a==='fit-graph')GS.view.fit=true; + if(a==='zoom-in'||a==='zoom-out')GS.view.zoom=GraphMath.clamp(GS.view.zoom*(a==='zoom-in'?1.2:1/1.2),.12,5); + gsRequestDraw(); +}; +document.addEventListener('change',event=>{const e=event.target;if(!GS.view)return;if(['gs-kind','gs-relation','gs-focus','gs-limit'].includes(e.id)){const key={'gs-kind':'kind','gs-relation':'relation','gs-focus':'hops','gs-limit':'limit'}[e.id];GS.view[key]=['hops','limit'].includes(key)?Number(e.value):e.value;GS.view.fit=true;if(key==='hops'&&e.value!=='0'&&!state.node&&!state.edge)toast('Select an idea to focus its neighborhood.');gsRequestDraw();}}); +document.addEventListener('input',event=>{if(event.target.closest('.gs-edit-fields')){GS.dirty=true;const note=$('#gs-edit-status');if(note)note.textContent='Unsaved graph edit — press Save to apply.';}}); +document.addEventListener('keydown',event=>{if(event.key==='Escape'){GS.linkFrom=null;if(document.body.classList.contains('graph-expanded')){document.body.classList.remove('graph-expanded');gsRequestDraw();}}}); +window.addEventListener('resize',()=>{if(state.tab==='graph')gsRequestDraw();}); +const gsPreviousRender=render; +render=function(){gsPreviousRender();document.body.classList.toggle('graph-workspace',!!state.p&&state.tab==='graph');const side=$('.sidebar');if(side&&!$('.gs-paper-credit',side)){const credit=document.createElement('a');credit.className='gs-paper-credit';credit.href='https://commons.wikimedia.org/wiki/File:Graph_paper_notepad_(4562203394).jpg';credit.target='_blank';credit.rel='noopener noreferrer';credit.textContent='Paper scan · Calsidyrose / CC BY 2.0';side.append(credit);}if(state.tab!=='graph')document.body.classList.remove('graph-expanded');else if(state.p)gsRequestDraw();}; +if(state.p||state.projects.length)render(); + +const gsPreviousClose=window.graphpaperRequestClose; +window.graphpaperRequestClose=async()=>{if(!GS.dirty)return gsPreviousClose();await window.pywebview?.api?.cancel_close?.().catch(()=>{});gsGuard(()=>gsPreviousClose());}; +window.addEventListener('beforeunload',event=>{if(GS.dirty){event.preventDefault();event.returnValue='';}}); +document.addEventListener('keydown',event=>{if(GS.dirty&&(event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'){event.preventDefault();event.stopImmediatePropagation();gsSave().catch(err=>toast(err.message,true));}},true); diff --git a/ui/index.html b/ui/index.html index 6e13acb..63c14a1 100644 --- a/ui/index.html +++ b/ui/index.html @@ -13,6 +13,9 @@ + + +

Opening your writing studio…