diff --git a/.github/workflows/atlas-assembly.yml b/.github/workflows/atlas-assembly.yml new file mode 100644 index 0000000..4f43937 --- /dev/null +++ b/.github/workflows/atlas-assembly.yml @@ -0,0 +1,54 @@ +name: Integrate and validate Graph Atlas +on: + push: + branches: [design/graph-atlas-v0.6] + paths: ['.atlas/**', '.github/workflows/atlas-assembly.yml'] +permissions: + contents: write +jobs: + integrate: + if: contains(github.event.head_commit.message, '[atlas assemble]') + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - run: python -m pip install -q -r requirements-dev.txt pillow + - name: Integrate reviewed source and the licensed paper scan + run: | + python .atlas/apply.py + rm -rf .atlas + node --check ui/graph-atlas.js + node scripts/test_graph_scene.cjs + python -m pytest -q --junitxml=test-results/atlas-unit.xml + python scripts/docs_tool.py check + - run: python -m playwright install chromium + - name: Test existing writing and the new graph editor + run: | + python scripts/ui_smoke.py + python scripts/ui_studio_smoke.py + python scripts/ui_science_smoke.py + python scripts/ui_graphify_smoke.py + python scripts/ui_polemic_smoke.py + python scripts/ui_voices_smoke.py + python scripts/ui_atlas_smoke.py + - name: Commit only ordinary verified source and assets + run: | + git add -A -- .atlas graphpaper ui scripts tests docs README.md CHANGELOG.md pyproject.toml + python scripts/prepare_assets.py --manifest + git add publish-manifest.json + git diff --cached --check + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'Integrate Graph Atlas and verified squared-paper asset' + git push origin HEAD:design/graph-atlas-v0.6 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: atlas-integration-validation + path: | + test-results/ + ui/graph-paper.jpg diff --git a/.github/workflows/atlas-release.yml b/.github/workflows/atlas-release.yml new file mode 100644 index 0000000..1bf2bd6 --- /dev/null +++ b/.github/workflows/atlas-release.yml @@ -0,0 +1,107 @@ +name: Graph Atlas hosted Windows release +on: + push: + branches: [main] + paths: ['graphpaper/__init__.py', '.github/workflows/atlas-release.yml'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: graphpaper-atlas-release + cancel-in-progress: false +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - run: test "$(python -c 'from graphpaper import __version__; print(__version__)')" = '0.6.0' + - run: python -m pip install -q -r requirements-dev.txt + - run: python -m pytest -q --junitxml=test-results/linux.xml + - run: node scripts/test_graph_scene.cjs + - run: python scripts/docs_tool.py check + - run: python -m playwright install chromium + - name: Seven browser suites against the real local server + run: | + python scripts/ui_smoke.py + python scripts/ui_studio_smoke.py + python scripts/ui_science_smoke.py + python scripts/ui_graphify_smoke.py + python scripts/ui_polemic_smoke.py + python scripts/ui_voices_smoke.py + python scripts/ui_atlas_smoke.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: atlas-linux-validation + path: test-results/ + windows: + needs: validate + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - 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 scripts/test_graph_scene.cjs + - name: Verify the hosted WebView2 prerequisite + shell: pwsh + run: ./scripts/prepare_ci_webview.ps1 + - name: Build with public Graphify and official Codex + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: ./scripts/build_release.ps1 + - name: Exercise the compiled native window and graph editing + run: python scripts/native_smoke.py --science --graphify --polemic --voices --atlas --executable dist/GraphPaper/GraphPaper.exe --out test-results/native-package + - uses: actions/upload-artifact@v4 + with: + name: GraphPaper-v0.6.0-Windows-x64 + path: | + dist/GraphPaper-v0.6.0-Windows-x64.zip + dist/SHA256SUMS.txt + - name: Publish only the tested archive + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $tag = 'v0.6.0' + $package = 'GraphPaper-v0.6.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.6.0.md -Raw + $notes += "`n`nValidation: https://github.com/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID . Source: $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.6.0 - Graph Atlas' --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 } + $asset = $release.assets | Where-Object { $_.name -eq $package } + if ($asset.digest -ne "sha256:$actual") { throw 'Uploaded archive differs from the tested package.' } + gh release edit $tag --draft=false --latest + if ($LASTEXITCODE -ne 0) { throw 'Release publication failed.' } + - name: Print native report + if: always() + shell: pwsh + run: | + if (Test-Path test-results/native-package/report.json) { Get-Content test-results/native-package/report.json } + - uses: actions/upload-artifact@v4 + if: always() + with: + name: atlas-windows-validation + path: test-results/ 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..4b8ee76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.6.0 — Graph Atlas + +Real squared-paper sidebar, ink palette, orbitable graph with typed lines, dense-graph clusters, neighborhood focus, persistent node/edge editing and atomic one-step undo. Existing writing, sources and voices are preserved. + # Changelog ## 0.5.0 diff --git a/README.md b/README.md index 26d977b..76a4e3f 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,8 @@ A graph suggests connections; it does not prove them. Science keeps capped searc For implementation details, tests and builds, see the [developer guide](docs/DEVELOPMENT.md) and [architecture](docs/ARCHITECTURE.md). Recorded release validation is separate from the live CI badge: [test reports and scope](docs/QUALITY.md). **[MIT licensed](LICENSE) · Created by Aron Bijl.** External runtimes and services retain their own licenses and terms. + + +## Graph Atlas + +The new [editable spatial graph](docs/GRAPH-ATLAS.md) adds orbit, focus, clusters and colored relationships. The light sidebar uses [real scanned graph paper](docs/ASSET-CREDITS.md), not a generated mockup. diff --git a/docs/ASSET-CREDITS.md b/docs/ASSET-CREDITS.md new file mode 100644 index 0000000..88c9305 --- /dev/null +++ b/docs/ASSET-CREDITS.md @@ -0,0 +1,15 @@ +# Interface asset credits + +## Graph-paper sidebar + +**Graph paper notepad (4562203394)**, a direct scan by **Calsidyrose**, 25 April 2010. + +Source: https://commons.wikimedia.org/wiki/File:Graph_paper_notepad_(4562203394).jpg + +Original source: https://www.flickr.com/photos/calsidyrose/4562203394/ + +License: **Creative Commons Attribution 2.0 Generic (CC BY 2.0)**, https://creativecommons.org/licenses/by/2.0/ . + +GraphPaper bundles a resized JPEG of this scan as `ui/graph-paper.jpg`. The sidebar crops it through CSS and applies a translucent light overlay for legibility. No endorsement is implied. The image retains its own CC BY 2.0 license, separate from the MIT application code. + +The graph canvas, interface icons and atlas mark are vector interface elements, not an AI-generated screenshot. No generated mockup is shipped as the interface. diff --git a/docs/GRAPH-ATLAS.md b/docs/GRAPH-ATLAS.md new file mode 100644 index 0000000..2eeb276 --- /dev/null +++ b/docs/GRAPH-ATLAS.md @@ -0,0 +1,29 @@ +# Graph Atlas + +GraphPaper 0.6 replaces the cramped graph canvas with an editable spatial view and an ink/indigo workspace. The sidebar uses an actual scanned sheet of squared graph paper, bundled locally. It is not the earlier generated mockup. + +## Explore without a wall of labels + +Open an existing project and choose **Graph**. There is no extraction, model call or graph rebuild required for the new view. Drag the background to orbit; Shift-drag or middle-drag to pan; scroll to zoom. Drag individual nodes to arrange them for the current session. **2D** returns to a flat view. **Fit graph** or the overview inset recenters the scene. The expand button gives the graph most of the window. + +The scene uses real x/y/z positions and perspective projection into accessible SVG, not a raster picture and not a WebGL dependency. It does not continuously simulate or rotate when idle. Camera and spacing preferences are remembered per project; manually dragged node positions are session-local. + +Node colors and shapes identify ideas, claims, sources, evidence, people, events and other types. Connections have their own colors, line patterns and arrowheads. The legend distinguishes support, challenge, citation, sequence/cause, analogy/style and other relationships. These are visual groupings of existing relation labels, not newly inferred evidence. Select a line to see its exact label and direction. + +Large graphs initially open as expandable **Clusters**, grouped using existing community/type information. The overview counts all represented nodes. Click a cluster to enter it. Select an idea and choose **Focus neighborhood** for its direct connections, or choose a two-hop view. Type and relationship filters narrow the scene. Search runs over the full stored graph, not only the initial visible nodes. When an individual view exceeds 500 nodes or 2,000 connections, the counts make that visible; use clustering, search or focus to inspect the rest. + +**Layout & readability** contains spacing and smart/all/hidden label controls. Smart labels avoid overlapping labels while keeping the selected neighborhood readable. Keyboard users can Tab through nodes and lines, press Enter to select, use arrow keys to orbit, +/- to zoom, F to fit and 2/3 to switch views. The inspector also provides a text-based node browser. + +## Click and edit + +Select a node to edit its title, type and notes. Select a relationship to edit its label and notes, reverse its direction, or delete it. The toolbar adds nodes and connects existing nodes. Press **Save changes** to persist the edit. Unsaved form changes are identified; switching the selected item asks before discarding them. + +Manual edits use a version-checked API. They preserve source files, exact quotation anchors, manuscripts, voices, outlines and existing angles. An edited relationship or claim is labeled **proposed**, rather than falsely retaining a sourced verification stamp. This is provenance bookkeeping, not a language filter. The graph editor does not weaken an author's wording or call a model. + +**Undo last graph edit** restores the previous graph in one atomic operation, including the affected pins/exclusions. This one-step undo is stored locally and survives reopening the app. It refuses to overwrite an intervening rebuilt/imported graph. Deleting a node also deletes its incident connections; original sources and writing remain intact. + +## What stays the same + +Codex OAuth, model/reasoning controls, public Graphify extraction, reusable voice graphs, bounded JEV decisions, Polemic and Science remain available. This update changes the interface and adds explicit manual graph editing, not the authorial instructions or evidence pipeline. Tests use disposable projects, not a user's installed database. + +[Graph and JEV](GRAPH-AND-JEV.md) · [Reusable voices](VOICE-GRAPHS.md) · [Asset credits](ASSET-CREDITS.md) diff --git a/docs/README.md b/docs/README.md index c94bb22..8317f90 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,3 +50,6 @@ 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. + + +[Graph Atlas: spatial navigation and click-to-edit](GRAPH-ATLAS.md) · [Interface asset credits](ASSET-CREDITS.md) diff --git a/docs/RELEASE-0.6.0.md b/docs/RELEASE-0.6.0.md new file mode 100644 index 0000000..68f404f --- /dev/null +++ b/docs/RELEASE-0.6.0.md @@ -0,0 +1,18 @@ +# GraphPaper 0.6.0 — Graph Atlas + +The actual application UI has been redesigned, not replaced with a mockup. + +- Charcoal/indigo workspace, clearer panels and a light sidebar using a real licensed scan of squared graph paper, bundled for offline use. +- Orbitable spatial graph, pan/zoom, depth cues, 2D/3D switch, expanded view and overview inset. +- Colored and shaped node types; separately colored, patterned, directional relationship lines with a legend. +- Dense-graph clusters, one/two-hop neighborhood focus, filters, full-graph search and collision-aware labels. +- Click-to-edit nodes and relationships, add/connect/delete/reverse controls and persistent one-step graph undo. No model calls for editing or viewing. +- Existing sources, manuscripts, reusable voices and authorial settings remain intact. No new tone filters. + +Download the Windows ZIP, extract the whole folder and run `GraphPaper.exe` with `_internal` alongside it. Existing projects use the new view without rebuilding their graphs. The package is unsigned. + +The spatial view projects x/y/z coordinates into SVG and works without a dedicated GPU or a remote CDN. The bundled paper asset is credited in the application files and documentation. + +Guide: https://github.com/AronAxe/GraphPaper/blob/main/docs/GRAPH-ATLAS.md + +Development and Windows packaging use GitHub-hosted runners, not the user's computer. Provider-dependent regression tests use synthetic fixtures; this UI release does not claim a new live-model writing-quality evaluation. 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_editor.py b/graphpaper/graph_editor.py new file mode 100644 index 0000000..093b42c --- /dev/null +++ b/graphpaper/graph_editor.py @@ -0,0 +1,100 @@ +"""Explicit manual graph editing; no inference, no source rewrite, atomic one-step undo.""" +from __future__ import annotations + +import hashlib +import json +from fastapi import Request +from starlette.concurrency import run_in_threadpool +from .models import Project, Node, Edge, Graph, now, uid +from .storage import Conflict + + +def graph_digest(graph): + return hashlib.sha256(graph.model_dump_json().encode('utf-8')).hexdigest() + + +def apply_edit(p: Project, raw: dict) -> Project: + if not isinstance(raw, dict) or set(raw)-{'version','operation','id','values'}: + raise ValueError('Unsupported graph edit.') + operation=raw.get('operation','') + if not isinstance(operation,str) or operation not in {'node.add','node.update','node.delete','edge.add','edge.update','edge.delete'}: + raise ValueError('Choose a node or relationship edit.') + p=p.model_copy(deep=True) + entity,action=operation.split('.') + values=raw.get('values',{}) + allowed={'label','kind','description'} if entity=='node' else {'source','target','relation','description'} + if not isinstance(values,dict) or set(values)-allowed: + raise ValueError('Only editable graph fields are accepted; source anchors are preserved.') + for k,v in values.items(): + if not isinstance(v,str):raise ValueError('Graph fields must be text.') + maximum={'label':200,'kind':80,'relation':80,'description':4000 if entity=='node' else 2000,'source':300,'target':300}[k] + if len(v)>maximum or (k!='description' and not v.strip()): + raise ValueError(f'{k} must contain text, up to {maximum} characters.') + collection=p.graph.nodes if entity=='node' else p.graph.edges + cls=Node if entity=='node' else Edge + item=next((x for x in collection if x.id==raw.get('id')),None) + if action!='add' and item is None:raise KeyError(raw.get('id')) + if action=='delete': + collection.remove(item) + if entity=='node': + p.graph.edges=[e for e in p.graph.edges if e.source!=item.id and e.target!=item.id] + p.brief.pinned_nodes=[i for i in p.brief.pinned_nodes if i!=item.id] + p.brief.excluded_nodes=[i for i in p.brief.excluded_nodes if i!=item.id] + else: + if action=='add': + required={'label'} if entity=='node' else {'source','target','relation'} + if not required<=values.keys():raise ValueError('Required graph fields are missing.') + changed=cls.model_validate({'id':uid('n_' if entity=='node' else 'e_'),'status':'proposed',**values}) + else: + changed=cls.model_validate(item.model_dump()|values|{'status':'proposed'}) + if entity=='edge': + ids={n.id for n in p.graph.nodes} + if changed.source not in ids or changed.target not in ids: + raise ValueError('Both ends of the relationship must exist in this project.') + if action=='add':collection.append(changed) + else:collection[collection.index(item)]=changed + if len(p.graph.nodes)>5000 or len(p.graph.edges)>20000:raise ValueError('Graph size limit reached.') + if hasattr(p,'angles_context_hash'):p.angles_context_hash='' + if hasattr(p,'outline_context_hash'):p.outline_context_hash='' + return p + + +def register(app, store, runner): + def transact(pid, raw, undo=False): + if not isinstance(raw,dict):raise ValueError('Expected a graph edit object.') + with store.lock, store.connect() as c: + if runner.active(pid):raise ValueError('Finish or cancel the active job before editing its graph.') + row=c.execute('SELECT data FROM projects WHERE id=?',(pid,)).fetchone() + if not row:raise KeyError(pid) + p=Project.model_validate_json(row[0]);expected=raw.get('version') + if isinstance(expected,bool) or not isinstance(expected,int) or expected!=p.version: + raise Conflict('The project changed. Refresh before saving; newer work was not overwritten.') + key='graph-undo:'+pid + if undo: + previous=c.execute('SELECT data FROM cache WHERE key=?',(key,)).fetchone() + if not previous:raise ValueError('There is no manual graph edit to undo.') + previous=json.loads(previous[0]) + if previous['after']!=graph_digest(p.graph): + raise Conflict('The graph has changed since that edit. Undo will not replace a newer graph.') + p.graph=Graph.model_validate(previous['graph']) + p.brief.pinned_nodes=previous['pinned'];p.brief.excluded_nodes=previous['excluded'] + if hasattr(p,'angles_context_hash'):p.angles_context_hash='' + if hasattr(p,'outline_context_hash'):p.outline_context_hash='' + c.execute('DELETE FROM cache WHERE key=?',(key,)) + else: + old=p + p=apply_edit(p,raw) + previous={'graph':old.graph.model_dump(),'pinned':old.brief.pinned_nodes,'excluded':old.brief.excluded_nodes,'after':graph_digest(p.graph)} + c.execute('INSERT OR REPLACE INTO cache VALUES(?,?)',(key,json.dumps(previous))) + p.version=expected+1;p.updated=now() + result=c.execute('UPDATE projects SET version=?,updated=?,data=? WHERE id=? AND version=?',(p.version,p.updated,p.model_dump_json(),pid,expected)) + if not result.rowcount:raise Conflict('The project changed before the graph could be saved.') + return p + + @app.post('/api/projects/{pid}/graph/edit') + async def graph_edit(pid: str, request: Request): + return await run_in_threadpool(transact,pid,await request.json()) + + @app.post('/api/projects/{pid}/graph/undo') + async def graph_undo(pid: str, request: Request): + return await run_in_threadpool(transact,pid,await request.json(),True) diff --git a/graphpaper/server.py b/graphpaper/server.py index 82565d7..7ab6f4b 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_editor import register as register_graph_editor + register_graph_editor(app, store, runner) app.mount("/static", StaticFiles(directory=asset_directory()), name="static") return app diff --git a/publish-manifest.json b/publish-manifest.json index a41f96d..007c74a 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -3,44 +3,48 @@ "files": { ".gitattributes": "acdb1038616f95b335ceb851c161a6139bf23561b2a74894a9ccb33ded5e71f4", ".github/topics.json": "341aa983b1829624dec7ab7cd2e83eed08d45bc78305033c1e860631c1227e41", + ".github/workflows/atlas-assembly.yml": "9872d211b9b5338732ee77023ed299d7140183aec0341df309202fa9cb7d8b23", + ".github/workflows/atlas-release.yml": "b5962263175605dd8ec9156f6018943826f5a0dc928640f9c1c5d9080310ed47", ".github/workflows/docs.yml": "19c4d6229a804adf09358171b19a139b45f1c2630b751213af1e9e62baed2c40", ".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/voice-manifest.yml": "19647138870a09bbe7835df34ddab4017a91da9c1937444a32f3a35b9ef1fa88", - ".github/workflows/voices-release.yml": "174a8f98f2fda23f938299e347d09a3615ac7a3b07a962bb952d164a5ba2be28", ".github/workflows/windows.yml": "65512954b719620063384ce93950b89c9b0aaac2ad6c7c620d505f8787b0444a", ".gitignore": "229a84e2b07c91b31c30f06df236ea17fe211eb75b217adc2eecb3f9d377f9bf", - "CHANGELOG.md": "1f8159e524f9e0138c8d706b897108ea8ebdd9a764fb5a1e2b74d04bfc297e98", + "CHANGELOG.md": "320a100fdf3fbf2b77e2b51c3c80bc38fb51c6429d1d551e34729c0b898cd5dc", "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": "eb76562f8c35af037195bf59a7b8dafefebf42a2a4d06449703125d979f6fbc3", "SECURITY.md": "e70e5b87ea06cdc075c3111a1eb0bb6ac5f5b6f8b55f5e735ad69d84ae63cda6", "docs/ARCHITECTURE.md": "86a28806513cf8b533d6a4767322c7dd339a6a2ed112081ea7ce5fcdf4ffd89d", + "docs/ASSET-CREDITS.md": "5987a558aa4595e5b7c4bd1ed17aaac44757f1c0341df2613ce38082a051cf2c", "docs/CONNECTIONS.md": "eec1286ddc356bbb2cd7d63692e73abae6d0f7e9159bbb2edee6377dd5b3fd72", "docs/DEVELOPMENT.md": "2c21f35be868e9da63d90cbc78b00488837916dbe36fdd631127c4035583ed21", "docs/EXPORTS-AND-APA.md": "73e60e653118d2ee55f2f3e04b5d2aeac427c40dd33c76e1d050337261862d52", "docs/FICTION.md": "6ac2f136e848db8b9fd2bd329d4228894ad765e95d6a9cec317bb9372a847df8", "docs/GETTING-STARTED.md": "d663e4b1ea461de7e87ba8bb2fa15949db7ba8d89de69cbf1c0d1a265d3e2abd", "docs/GRAPH-AND-JEV.md": "6fc0ea4437d5691d50c02899f9a5377f2e42b97b9190cebc1c12d34819259c6e", + "docs/GRAPH-ATLAS.md": "25edf1dc204a598195f8ac511de5e25aa8fe200f60fe40623821942dc454d88e", "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": "cfc955d992212783be2eca8ae4548247a50317674e004ea7a4c826ffd27f6514", "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": "c7a8945a5138768355c2381d446b539523296f7a858796b14d8f73804b1b3778", "docs/SCIENCE.md": "9357a2f4ac714b6330aff4ccfe45ca0457d18e7816a73646314024823fd0ea00", "docs/SECURITY.md": "0f818eb0cf4e8f94ffaaeb6da3cc2c2bc474674b673d185c61b86b2eb5fd3864", "docs/TROUBLESHOOTING.md": "0834f56c656c97e721226a335b24a7aa8afe9cd99b4e28e950412bd86ae3d2d6", @@ -128,7 +132,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 +144,7 @@ "graphpaper/export.py": "fd76bec7858c53700dfe068035c187277ed85fa7e90a46cb1f1c47b57eb1e744", "graphpaper/folders.py": "37f9b255406e3ff93b6f6886294722c14561a4c1195d5c2107fe67c8de6fbca8", "graphpaper/graph.py": "eb9226bb03fc71b1d5eb309b833fa415efe5117b070500cef647704b334cdefe", + "graphpaper/graph_editor.py": "eff72d3dd8372c384e9d713aacfdd9b87b569cca7c096137fdc055e8e5d56540", "graphpaper/graphify_adapter.py": "5c4ef54058240b3e8f82daed1d7d7180ee839c43811d7cbb824f35d040a3d1af", "graphpaper/graphify_gateway.py": "258f8fe1b41682f3a952fe62d4ecb55824f82eea30f3997997565a3a45ff0164", "graphpaper/graphify_process.py": "a499fb74738bee070b1557c388312cce3448057d5be725f75bb5c926ac1e2237", @@ -159,7 +164,7 @@ "graphpaper/science_models.py": "986446c848927d85aa57bc2d2ba9f248f656fac9f6acc67d056e8f55776258e8", "graphpaper/science_routes.py": "cd2e5c76035dcd7a9384c74b4264306da8429072359a476b9d4e060aee86bb69", "graphpaper/secrets.py": "2d90bfed7124875d50cd977c0c4f17c4cb19e79af307445bc85af99596c3766e", - "graphpaper/server.py": "49cbcb61cb2ade46f69ca13aa7f1100c37ef239c9958b0461ff52ba6280224c0", + "graphpaper/server.py": "f6654338a9ff6da2e531d511ff90b5fe3cdb16cb3c74446ea153df0eb462539c", "graphpaper/storage.py": "38b0df820a0c4dae0199bf38656d529e726a4475fe7a47b66e41903e96d14eae", "graphpaper/studio_routes.py": "5597db3ee3cba50e7eb4bd8c4e36d3a74be5768bb01b75749f16ac28e10ee84f", "graphpaper/style_graph.py": "01adedab019d8108fd8643be8b3863ddac53936e368b3f6ef6ec97e5af8b9568", @@ -167,11 +172,12 @@ "graphpaper/voice.py": "74be72d1e6edb70a6b541547836db4a7c4af575314c92a253395f9ede26b7976", "graphpaper/voice_library.py": "2db504911796b564489494b95628fde606b834f1ab890443eea0c2f9db7e670e", "graphpaper/voice_routes.py": "725a858590e93b9f51ecb95378713ae8e8e88dad99fb1c903889f02a272e4532", - "pyproject.toml": "2559651cf1b7748205d3f71a46bba8894655e601bb5d5b1aeccea8d2108e9d9e", + "pyproject.toml": "ce14e8855b5d13a5cccc7c2cf9d0311ec62dcd0fe046d543e5ef9b9cada10d68", "requirements-desktop.txt": "c9df0bdb57d7e9586ae7c927b5dca32f6e20010e71fc71cc4a5656387a129892", "requirements-dev.txt": "40b197f9671e3c6db4d47ca73d9679eabafe662a56d4824c6f9e64255e47a5ce", "requirements-graphify.txt": "9eea3e6ea7fa3ceb1247518901d41d55a0813dc5133475172541c68c122454f1", "requirements.txt": "4e73f195c8a6d32b841b671200bf0dcaa91e5a042c585e935e3132ee5e4a02a3", + "scripts/atlas_ui_checks.py": "efd6735ca387c6f66c2a1c5f4d34891ec77e72f2dc9a175e06fade5d1e92dc79", "scripts/build_release.ps1": "d56a421d287431c42e7971381ebd5234fb561cb67c117f6b321b86717a751c6a", "scripts/build_windows.ps1": "44e0008ff8221097ead7355aa184a60e824d2fcbf0c77e54c74ddfc8f3a54858", "scripts/bundle_codex.py": "651d08e26949dbda47bca14dbc368d97b82e11a28759792cb6096adcea174efa", @@ -184,13 +190,15 @@ "scripts/docs_tool.py": "7fd2a681bae5fd4a49ae394c74639dee8e833ad08e8a156406c292af6db46572", "scripts/graphify_ui_checks.py": "6dc1e45478a10fa8e3d17190864b2145cd973a5a6e23c7500f0d99ddfb824dc6", "scripts/hooks/hook-graphify.py": "5659b57f3b3e11f9327b2da6e00c4bd82a523f7f978e3b9506811c2caf007ff3", - "scripts/native_smoke.py": "fad1f11330719923017a7b7b8813831bba4cd8c05104dea372cfc5f30a5c9c2c", + "scripts/native_smoke.py": "cf5e8926ee7810f2252ae3f442584d29b8ba9ac52b17b3ca9a8b00e0c3473cd9", "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/test_graph_scene.cjs": "24ba69b282ae626147b1469b474bc339b9057d8ce70b6f6b791b007d7e4eff42", + "scripts/ui_atlas_smoke.py": "5366890aa8232b90d7333447f27e1df5a02bd25570d606c1a9a3b17a78e1c9cc", "scripts/ui_graphify_smoke.py": "0f29a01d972ad63006aba345952bb9e092575c452eb9475c505c21d9267a3de3", "scripts/ui_polemic_smoke.py": "3a0304b586d10f6fd6b15df6d31b4895a075f6e93b69483d82cebae3bb926236", "scripts/ui_science_smoke.py": "fa744ee223db49c0e2325e94fe7deec62470f1d6186b28f2b2dacccb1264f2fb", @@ -211,6 +219,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": "8df5d2a8fc7cb66ba14241cecb0f54a6dae0a35bb4165dd26fea393770c4d705", "tests/test_graphify_extraction.py": "d7da1549de893fda3606da1deea3aa1c53d28e92d210261fbe65418cda9090c4", "tests/test_graphify_gateway.py": "f64249c44525b8337f7aa517c5c149b6cddc8b76f42f67a2b5e1a4930a88ebe5", "tests/test_graphify_provenance.py": "ce91f39b3a60bd88c52bd57f70361c92daca7e5e261f9f30868dddfde00384d3", @@ -229,11 +238,17 @@ "tests/test_voice_integration.py": "63c9e4d4dadba187d36169593868af44a4df94896ae1dc06b8b4f451ac0f8eab", "tests/test_voice_library.py": "12071c049d7ccca7d10d531879ed67c19eee97178c0a5c3bc34559aed77417e7", "tests/test_windowless.py": "421620004c87cf24dec771966602b0666e75ecca67fec05071cf40e4b6887f44", - "ui/app.js": "09e96444fe3dc7b25db957616f8b60655e6625ff878e9cf8049fa2eecc1c10e4", + "ui/ASSET-CREDITS.txt": "062db85384b525d5f968c7bd32ab55de70df5b20e9dbdc7be4885055399b4d12", + "ui/app.js": "4f5681c432b8fa5923fa68d57b9ace55cd493ee43be5971316a632f73ec2917d", + "ui/atlas-mark.svg": "c4d8e0793625cbf77c2e460899540374e4f1dd733223f62eeb95178742fffd36", + "ui/atlas.css": "1ed065cf0e47f08238cccdb2a7ef279f3d16b238a196e9ff25fbb927f6432af6", + "ui/graph-atlas.js": "fb72b629fb5633aaa13e3b3dcdd7049c31e67621ca4be6c2e0dd5568bc6fb1c3", + "ui/graph-paper.jpg": "d74cb85ec732b38a13a0184732deee9114468d74fb032397604d4d627b9fd45a", + "ui/graph-scene.js": "634924699b594a81e4619758b2ca785db146e892952884dfc7ab750b2be52b6a", "ui/graphify.js": "1bad42f86764de73c5e9411cca64d0c86d3bdfecaa8682be3e0728663b85d18c", "ui/graphpaper.ico": "f94c4061c53e8e1a1f2be96ef1a8c18b20c19a984bc35f6337f1037c1025a493", "ui/icon.svg": "9f97b80a90020b682f96756d1245dfa3cc9f19ed989b0751f802220a3934be0f", - "ui/index.html": "b157036ea077e65718e3b4e641051b1b959814077f078fad5618c2854b67fbb3", + "ui/index.html": "e2bf75ed71586aeaf91030b376a019a1e52be5932ebd459be3957417a1e2dfa8", "ui/polemic.css": "08e5b07f2b8cda29c79794714456502c058f5baa65ad7e47ec758e2a524cc80b", "ui/polemic.js": "169695b1caffb6a6b40c144f421a0cac846b52bb5be90e205861c75e7599574a", "ui/science.css": "0234fa0a9542afc269ea5b3914a80e0d574843d45f7033394d9e8edaa40ed91c", diff --git a/pyproject.toml b/pyproject.toml index b930d83..ca3911e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphpaper-studio" -version = "0.5.0" +version = "0.6.0" description = "A local-first graph-guided writing studio for nonfiction and fiction" readme = "README.md" requires-python = ">=3.11" @@ -24,7 +24,7 @@ pythonpath = ["."] addopts = "-ra" [tool.setuptools.data-files] -"share/graphpaper/ui" = ["ui/index.html", "ui/styles.css", "ui/app.js", "ui/studio.js", "ui/studio.css", "ui/science.js", "ui/science.css", "ui/graphify.js", "ui/polemic.js", "ui/polemic.css", "ui/voices.js", "ui/voices.css", "ui/icon.svg", "ui/graphpaper.ico"] +"share/graphpaper/ui" = ["ui/index.html", "ui/styles.css", "ui/app.js", "ui/studio.js", "ui/studio.css", "ui/science.js", "ui/science.css", "ui/graphify.js", "ui/polemic.js", "ui/polemic.css", "ui/voices.js", "ui/voices.css", "ui/icon.svg", "ui/graphpaper.ico", "ui/atlas.css", "ui/graph-atlas.js", "ui/graph-scene.js", "ui/atlas-mark.svg", "ui/graph-paper.jpg", "ui/ASSET-CREDITS.txt"] [project.optional-dependencies] desktop = ["pywebview>=6.0,<7", "graphifyy[openai]==0.9.80"] diff --git a/scripts/atlas_ui_checks.py b/scripts/atlas_ui_checks.py new file mode 100644 index 0000000..4c0bacc --- /dev/null +++ b/scripts/atlas_ui_checks.py @@ -0,0 +1,90 @@ +"""Real UI interactions and persistence; synthetic projects only, no model calls.""" +from playwright.sync_api import expect + + +def exercise(page, check, out): + original=page.evaluate('() => state.p?.id || null') + page.evaluate("async () => {const p=await api('/demo',{mode:'nonfiction'});await setProject(p,'graph');}") + expect(page.locator('#graph-svg .atlas-node')).to_have_count(18) + response=page.request.get(page.url.split('/#')[0].rstrip('/')+'/static/graph-paper.jpg') + assert response.ok and response.body()[:2]==b'\xff\xd8' + assert len(response.body())>10000 + assert 'graph-paper.jpg' in page.locator('.sidebar').evaluate('(el)=>getComputedStyle(el).backgroundImage') + assert page.locator('.sidebar').evaluate('(el)=>getComputedStyle(el).backgroundColor')=='rgb(243, 240, 231)' + check('Paper sidebar uses a bundled real scan; workspace uses the new ink palette') + assert len(set(page.locator('[data-node]').evaluate_all('(els)=>els.map(e=>e.dataset.depth)')))>10 + assert len(set(page.locator('.atlas-edge-wrap>path:first-child').evaluate_all('(els)=>els.map(e=>e.getAttribute("stroke"))')))>=2 + check('Nodes occupy 3D coordinates and relationship families have distinct line colors') + node=page.locator('#graph-svg [data-node]').first + nid=node.get_attribute('data-node') + before=page.evaluate('() => JSON.parse(JSON.stringify(state.p))') + node.click() + expect(page.locator('#atlas-edit-form')).to_be_visible() + page.screenshot(path=str(out/'atlas-dark.png'),full_page=True) + page.locator('#atlas-item-label').fill('An argument with teeth') + page.locator('#atlas-item-description').fill('Keep the fucking bite. Do not turn the judgment into a question.') + page.get_by_role('button',name='Save changes',exact=True).click() + page.wait_for_function('(id)=>state.p.graph.nodes.find(n=>n.id===id)?.label==="An argument with teeth"',arg=nid) + saved=page.evaluate('async()=>await api("/projects/"+state.p.id)') + for key in ['draft','sources','angles','outline','usage','voice_profile']: + assert saved[key]==before[key] + edited=next(n for n in saved['graph']['nodes'] if n['id']==nid) + assert 'fucking bite' in edited['description'] + check('Click-to-edit persists titles and notes without changing writing, voice, sources or token usage') + page.get_by_role('button',name='Undo last graph edit',exact=True).click() + page.wait_for_function('(g)=>JSON.stringify(state.p.graph)===JSON.stringify(g)',arg=before['graph']) + check('Undo restores the saved graph independently of the manuscript') + page.get_by_role('button',name='Add node',exact=True).click() + page.locator('#atlas-new-label').fill('A new objection') + page.locator('#atlas-new-kind').select_option('question') + page.locator('.modal').get_by_role('button',name='Add node',exact=True).click() + expect(page.locator('.modal')).to_have_count(0) + page.wait_for_function('() => state.p.graph.nodes.length===19') + added=page.evaluate('()=>state.node') + page.get_by_role('button',name='Connect nodes',exact=True).click() + page.locator('#atlas-from').select_option(added) + page.locator('#atlas-to').select_option(nid) + page.locator('#atlas-link-relation').fill('contradicts') + page.get_by_role('button',name='Save connection',exact=True).click() + expect(page.locator('.modal')).to_have_count(0) + eid=page.evaluate('()=>state.edge') + page.get_by_role('button',name='Clear selection',exact=True).click() + edge=page.locator(f'[data-edge="{eid}"]') + edge.focus();edge.press('Enter') + expect(page.locator('#atlas-item-label')).to_have_value('contradicts') + page.locator('#atlas-item-label').fill('supports') + page.get_by_role('button',name='Save changes',exact=True).click() + page.wait_for_function('(id)=>state.p.graph.edges.find(e=>e.id===id).relation==="supports"',arg=eid) + check('Create nodes, connect them, select a relationship and persist its new type') + page.get_by_role('button',name='Clear selection',exact=True).click() + before_transform=page.locator(f'[data-node="{nid}"]').get_attribute('transform') + page.locator('#graph-svg').focus();page.keyboard.press('ArrowRight') + page.wait_for_function('([id,old])=>document.querySelector(`[data-node="${id}"]`).getAttribute("transform")!==old',arg=[nid,before_transform]) + z=page.evaluate('()=>atlasState().camera.zoom') + page.get_by_role('button',name='Zoom in',exact=True).click() + assert page.evaluate('()=>atlasState().camera.zoom')>z + page.get_by_role('button',name='2D',exact=True).click() + assert page.evaluate('()=>atlasState().camera.flat') is True + page.get_by_role('button',name='3D',exact=True).click() + assert page.evaluate('()=>atlasState().camera.flat') is False + check('Orbit changes perspective; zoom and the 2D/3D switch operate on the live graph') + page.locator(f'[data-node="{nid}"]').click() + page.get_by_role('button',name='Focus neighborhood',exact=True).click() + assert page.locator('#graph-svg [data-node]').count()<19 + check('Neighborhood focus removes unrelated visual clutter') + page.get_by_role('button',name='Reset view',exact=True).click() + page.evaluate("async () => {const p=await api('/projects',{title:'Dense graph regression'});const nodes=Array.from({length:800},(_,i)=>({id:'dense_'+i,label:'Dense idea '+String(i).padStart(4,'0'),kind:i%3?'concept':'claim',community:i%12}));const edges=Array.from({length:799},(_,i)=>({id:'de_'+i,source:'dense_'+i,target:'dense_'+(i+1),relation:i%2?'supports':'contradicts'}));const q=await api('/projects/'+p.id+'/graph/import',{nodes,edges});await setProject(q,'graph');}") + page.wait_for_function('()=>atlasState().view?.clustered===true') + view=page.evaluate('()=>atlasState().view') + assert sum(len(n.get('members',[])) for n in view['nodes'])==800 + assert len(view['nodes'])<80 + page.screenshot(path=str(out/'atlas-dense.png'),full_page=True) + page.locator('#graph-svg [data-node]').first.click() + assert page.locator('#graph-svg [data-node]').count()<100 + page.get_by_role('button',name='Reset view',exact=True).click() + page.locator('#graph-search').fill('Dense idea 0799') + expect(page.locator('#graph-svg [data-node]')).to_have_count(1) + check('800-node overview accounts for every node; expand clusters and find nodes beyond old display caps') + if original: + page.evaluate("async id=>{const p=await api('/projects/'+id);await setProject(p,'graph');}",original) + return {'model_calls':0,'dense_nodes':800,'persistent_edits':True,'spatial_projection':True} diff --git a/scripts/native_smoke.py b/scripts/native_smoke.py index e1453f1..8c6bce0 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('--atlas',action='store_true',help='Exercise spatial graph editing with isolated projects') 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.') @@ -154,6 +155,10 @@ def cdp_ready(): from voice_ui_checks import exercise report['voice_library']=exercise(page,check,out,native=True) responsive(hwnd) + if args.atlas: + from atlas_ui_checks import exercise as atlas_exercise + report['graph_atlas']=atlas_exercise(page,check,out) + responsive(hwnd) page.locator('[data-action="settings"]').click();page.wait_for_selector('#s-provider') expect(page.locator('#s-reasoning_effort')).to_have_count(1) page.get_by_role('button',name='Close dialog',exact=True).click();responsive(hwnd) diff --git a/scripts/test_graph_scene.cjs b/scripts/test_graph_scene.cjs new file mode 100644 index 0000000..b637bad --- /dev/null +++ b/scripts/test_graph_scene.cjs @@ -0,0 +1,42 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const file = path.resolve(__dirname, '../ui/graph-scene.js'); +assert.ok(fs.existsSync(file), 'The spatial graph scene must exist'); +const G = require(file); +const graph = {nodes: Array.from({length: 120}, (_, i) => ({id: 'n'+i, label: 'Idea '+i, kind: i%2?'claim':'concept', community: i%4})), edges: Array.from({length:119}, (_,i)=>({id:'e'+i, source:'n'+i,target:'n'+(i+1),relation:i%2?'supports':'contradicts'}))}; +const before=JSON.stringify(graph); +const layout=G.layout(graph); +assert.equal(Object.keys(layout).length,120); +assert.deepEqual(layout,G.layout(graph),'Layout must be deterministic'); +assert.ok(Object.values(layout).every(p=>[p.x,p.y,p.z].every(Number.isFinite))); +assert.ok(new Set(Object.values(layout).map(p=>p.z)).size>20,'Depth must be real, not a background image'); +assert.equal(JSON.stringify(graph),before,'Layout must never modify evidence'); +const a=G.project({x:110,y:30,z:75},{yaw:0,pitch:0,zoom:1,panX:0,panY:0}); +const b=G.project({x:110,y:30,z:75},{yaw:0.6,pitch:0.2,zoom:1,panX:0,panY:0}); +assert.notEqual(a.x,b.x,'Orbit must change projection'); +assert.ok([a.x,a.y,a.scale,a.depth].every(Number.isFinite)); +assert.equal(G.nodeStyle('claim').color===G.nodeStyle('concept').color,false); +assert.notEqual(G.nodeStyle('source').shape,G.nodeStyle('claim').shape); +assert.notEqual(G.edgeStyle('supports').color,G.edgeStyle('contradicts').color); +assert.notEqual(G.edgeStyle('supports').dash,G.edgeStyle('contradicts').dash); +assert.equal(G.edgeStyle('unfamiliar_relation').family,'Other'); +const view=G.view(graph,{focus:'n50',hops:1}); +assert.deepEqual(view.nodes.map(n=>n.id).sort(),['n49','n50','n51']); +assert.equal(G.view(graph,{query:'Idea 119'}).nodes.some(n=>n.id==='n119'),true); +assert.ok(G.view(graph,{kind:'claim'}).nodes.every(n=>n.kind==='claim')); +assert.ok(G.view(graph,{relation:'contradicts'}).edges.every(e=>e.relation==='contradicts')); +const dense={nodes:Array.from({length:1200},(_,i)=>({id:'d'+i,label:'Dense '+i,kind:'concept',community:i%8})),edges:[]}; +const clustered=G.view(dense,{cluster:true}); +assert.ok(clustered.nodes.length<=60); +assert.equal(clustered.nodes.reduce((n,g)=>n+(g.members?.length||1),0),1200,'Overview must account for every node'); +assert.ok(clustered.nodes.every(n=>n.members?.length)); +const limited=G.view(dense,{limit:200}); +assert.equal(limited.nodes.length,200); +assert.equal(limited.hidden,1000,'Any display limit must be explicit'); +const bad={nodes:[{id:'a',label:'a'},{id:'b',label:'b'}],edges:[{id:'x',source:'a',target:'missing'}]}; +assert.equal(G.view(bad,{}).edges.length,0); +assert.ok(G.path({x:0,y:0},{x:0,y:0},0).includes('C'),'Self loops need visible curves'); +assert.deepEqual(G.view({nodes:[],edges:[]},{}).nodes,[]); +console.log('PASS: spatial projection, deterministic layout, read-only views, shapes, relationships, focus, search, clustering, explicit limits and degenerate edges.'); diff --git a/scripts/ui_atlas_smoke.py b/scripts/ui_atlas_smoke.py new file mode 100644 index 0000000..0385c95 --- /dev/null +++ b/scripts/ui_atlas_smoke.py @@ -0,0 +1,46 @@ +"""Graph Atlas integration against the real local HTTP server.""" +from pathlib import Path +import 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 atlas_ui_checks import exercise +import uvicorn + + +def main(): + out=ROOT/'test-results/atlas-ui';out.mkdir(parents=True,exist_ok=True) + report={'checks':[],'page_errors':[],'live_models':False,'ok':False} + def check(label):report['checks'].append(label);print('PASS',label,flush=True) + with tempfile.TemporaryDirectory(prefix='graphpaper-atlas-') 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(300): + if server.started:break + time.sleep(.05) + assert server.started + with sync_playwright() as pw: + browser=pw.chromium.launch(headless=True);page=browser.new_page(viewport={'width':1586,'height':1000},accept_downloads=True) + page.set_default_timeout(20000);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') + report['atlas']=exercise(page,check,out) + page.get_by_role('button',name='Switch theme',exact=True).click() + page.wait_for_function('()=>document.documentElement.dataset.theme==="light"') + page.screenshot(path=str(out/'atlas-light.png'),full_page=True) + page.set_viewport_size({'width':390,'height':844});page.wait_for_timeout(150) + assert page.evaluate('() => document.documentElement.scrollWidth')<=390 + page.screenshot(path=str(out/'atlas-mobile.png'),full_page=True) + check('Light theme and narrow-window layout render without horizontal page overflow') + 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'));raise + finally: + (out/'report.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8') + 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/test_graph_editing.py b/tests/test_graph_editing.py new file mode 100644 index 0000000..635f5ee --- /dev/null +++ b/tests/test_graph_editing.py @@ -0,0 +1,97 @@ +"""Graph edits are explicit, versioned and isolated from the writing pipeline.""" +import importlib.util +import json +from fastapi.testclient import TestClient +from graphpaper.server import create_app + + +def client_for(tmp_path): + app=create_app(tmp_path) + client=TestClient(app) + client.get('/') + client.headers['X-GraphPaper']='1' + return app,client + + +def test_graph_editor_exists(): + assert importlib.util.find_spec('graphpaper.graph_editor') is not None, 'Persistent click-to-edit graph endpoints are missing' + + +def test_edit_and_undo_preserve_manuscript_sources_and_voice(tmp_path): + app,c=client_for(tmp_path) + p=c.post('/api/demo',json={'mode':'nonfiction'}).json() + original=json.loads(json.dumps(p)) + n=p['graph']['nodes'][0] + r=c.post(f"/api/projects/{p['id']}/graph/edit",json={'version':p['version'],'operation':'node.update','id':n['id'],'values':{'label':'An uncompromising argument','description':'Keep the fucking bite.'}}) + assert r.status_code==200,r.text + q=r.json() + assert q['graph']['nodes'][0]['label']=='An uncompromising argument' + assert q['graph']['nodes'][0]['status']=='proposed' + for key in ['draft','sources','angles','outline','voice_profile','usage']: + assert q[key]==original[key] + assert q['graph']['nodes'][0]['evidence']==n['evidence'] + undo=c.post(f"/api/projects/{p['id']}/graph/undo",json={'version':q['version']}) + assert undo.status_code==200,undo.text + assert undo.json()['graph']==original['graph'] + assert undo.json()['draft']==original['draft'] + + +def test_graph_edits_conflict_and_validate(tmp_path): + app,c=client_for(tmp_path) + p=c.post('/api/demo',json={}).json();pid=p['id'];n=p['graph']['nodes'][0] + base={'version':p['version'],'operation':'node.update','id':n['id']} + for values in [{'label':''},{'label':'x'*201},{'evidence':[]},{'status':'sourced'},{'kind':'x'*81}]: + r=c.post(f'/api/projects/{pid}/graph/edit',json={**base,'values':values}) + assert r.status_code==400,(values,r.text) + r=c.post(f'/api/projects/{pid}/graph/edit',json={**base,'version':-1,'values':{'label':'stale'}}) + assert r.status_code==409 + assert c.get(f'/api/projects/{pid}').json()['graph']==p['graph'] + + +def test_create_connect_delete_and_restart_undo(tmp_path): + app,c=client_for(tmp_path) + p=c.post('/api/projects',json={'title':'Editing fixture'}).json();pid=p['id'] + def edit(op,**data): + nonlocal p + r=c.post(f'/api/projects/{pid}/graph/edit',json={'operation':op,'version':p['version'],**data}) + assert r.status_code==200,r.text + p=r.json();return p + edit('node.add',values={'label':'Source premise','kind':'concept'});a=p['graph']['nodes'][0]['id'] + edit('node.add',values={'label':'Conclusion','kind':'claim'});b=p['graph']['nodes'][1]['id'] + edit('edge.add',values={'source':a,'target':b,'relation':'supports','description':'My proposed connection'}) + e=p['graph']['edges'][0]['id'] + edit('edge.update',id=e,values={'relation':'contradicts'}) + assert p['graph']['edges'][0]['relation']=='contradicts' + before=p['graph'];edit('node.delete',id=a) + assert not p['graph']['edges'] + other_app,other=client_for(tmp_path) + r=other.post(f'/api/projects/{pid}/graph/undo',json={'version':p['version']}) + assert r.status_code==200,r.text + assert r.json()['graph']==before + + +def test_invalid_target_and_missing_id_do_not_write(tmp_path): + app,c=client_for(tmp_path) + p=c.post('/api/demo',json={}).json();pid=p['id'] + for operation,data in [('edge.add',{'values':{'source':p['graph']['nodes'][0]['id'],'target':'missing','relation':'supports'}}),('node.update',{'id':'missing','values':{'label':'bad'}}),('invented',{}),([],{}),({}, {})]: + r=c.post(f'/api/projects/{pid}/graph/edit',json={'version':p['version'],'operation':operation,**data}) + assert r.status_code in (400,404),r.text + assert c.get(f'/api/projects/{pid}').json()['version']==p['version'] + + +def test_active_job_blocks_graph_edit(tmp_path): + app,c=client_for(tmp_path) + p=c.post('/api/demo',json={}).json() + app.state.runner.active=lambda _:True + r=c.post(f"/api/projects/{p['id']}/graph/edit",json={'version':p['version'],'operation':'node.delete','id':p['graph']['nodes'][0]['id']}) + assert r.status_code==400 + + +def test_undo_refuses_intervening_graph_rebuild(tmp_path): + app,c=client_for(tmp_path) + p=c.post('/api/demo',json={}).json();pid=p['id'] + p=c.post(f'/api/projects/{pid}/graph/edit',json={'version':p['version'],'operation':'node.update','id':p['graph']['nodes'][0]['id'],'values':{'label':'changed'}}).json() + p=c.post(f'/api/projects/{pid}/graph/import',json={'nodes':[{'id':'other','label':'Rebuilt'}],'edges':[]}).json() + r=c.post(f'/api/projects/{pid}/graph/undo',json={'version':p['version']}) + assert r.status_code==409 + assert c.get(f'/api/projects/{pid}').json()['graph']==p['graph'] diff --git a/ui/ASSET-CREDITS.txt b/ui/ASSET-CREDITS.txt new file mode 100644 index 0000000..47fec6f --- /dev/null +++ b/ui/ASSET-CREDITS.txt @@ -0,0 +1,5 @@ +Graph-paper sidebar: Graph paper notepad (4562203394), direct scan by Calsidyrose (25 April 2010). +CC BY 2.0: https://creativecommons.org/licenses/by/2.0/ +Source: https://commons.wikimedia.org/wiki/File:Graph_paper_notepad_(4562203394).jpg +Original: https://www.flickr.com/photos/calsidyrose/4562203394/ +Changes: resized JPEG, CSS crop and translucent light overlay. No endorsement implied. 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=`
Select a node to shape an idea.
Select a connection to change the relationship.
Inside a cluster · ${atlasState().members.length} ideas. ${btn('Show whole graph','atlas-reset','','small')}
`:''}Search the graph to find any of the remaining nodes.
':''}Use Clusters for the big picture, then a 1-hop neighborhood to follow a specific argument. The 2D view is always one click away.
${n?'Edit the idea without regenerating the graph.':esc(ids.get(e.source)?.label||e.source)+' → '+esc(ids.get(e.target)?.label||e.target)}
${esc(v.quote)}`).join('')}
No source anchor is attached to this item.
'} + ${n?`Opening your writing studio…