├── .github ├── dependabot.yml └── workflows │ ├── auto_author_assign.yml │ ├── check-release.yml │ ├── enforce-label.yml │ ├── license-header.yml │ ├── prep-release.yml │ ├── publish-release.yml │ └── test.yml ├── .gitignore ├── .licenserc.yaml ├── .pre-commit-config.yaml ├── .yarn └── releases │ └── yarn-3.4.1.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── Makefile ├── make.bat └── source │ ├── _static │ ├── jupyter_logo.svg │ └── logo-icon.png │ ├── conf.py │ ├── custom.md │ ├── index.md │ ├── javascript_api.rst │ ├── overview.md │ ├── python_api.rst │ └── schema.md ├── javascript ├── .eslintignore ├── .eslintrc.cjs ├── .prettierignore ├── .prettierrc ├── .vscode │ └── launch.json ├── README.md ├── jest.config.cjs ├── package.json ├── src │ ├── api.ts │ ├── awareness.ts │ ├── index.ts │ ├── utils.ts │ ├── ycell.ts │ ├── ydocument.ts │ ├── yfile.ts │ ├── ynotebook.ts │ └── ytext.ts ├── test │ ├── ycell.spec.ts │ ├── yfile.spec.ts │ └── ynotebook.spec.ts ├── tsconfig.json ├── tsconfig.test.json └── typedoc.json ├── jupyter_ydoc ├── __init__.py ├── py.typed ├── utils.py ├── ybasedoc.py ├── yblob.py ├── yfile.py ├── ynotebook.py └── yunicode.py ├── lerna.json ├── package.json ├── pyproject.toml ├── pytest.ini ├── readthedocs.yml ├── tests ├── conftest.py ├── files │ ├── nb0.ipynb │ ├── nb1.ipynb │ └── plotly_renderer.ipynb ├── package.json ├── test_pycrdt_yjs.py ├── test_ydocs.py ├── utils.py ├── yjs_client_0.js └── yjs_client_1.js └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Set update schedule for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | # Check for updates to GitHub Actions every weekday 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/workflows/auto_author_assign.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/marketplace/actions/auto-author-assign 2 | name: 'Auto Author Assign' 3 | 4 | on: 5 | pull_request_target: 6 | types: [opened, reopened] 7 | 8 | permissions: 9 | pull-requests: write 10 | 11 | jobs: 12 | assign-author: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: toshimaru/auto-author-assign@v2.1.1 16 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["*"] 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | check_release: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | group: [check_release, link_check] 18 | python-version: ["3.9"] 19 | node-version: ["14.x"] 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Base Setup 24 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 25 | - name: Install Dependencies 26 | run: | 27 | pip install -e . 28 | - name: Check Links 29 | if: ${{ matrix.group == 'link_check' }} 30 | uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 31 | with: 32 | ignore_links: "./api/index.html" 33 | - name: Check Release 34 | if: ${{ matrix.group == 'check_release' }} 35 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 36 | with: 37 | token: ${{ secrets.GITHUB_TOKEN }} 38 | - name: Upload Distributions 39 | if: ${{ matrix.group == 'check_release' }} 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: jupyter-releaser-dist-${{ github.run_number }} 43 | path: .jupyter_releaser_checkout/dist 44 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request_target: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /.github/workflows/license-header.yml: -------------------------------------------------------------------------------- 1 | name: Fix License Headers 2 | 3 | on: 4 | pull_request_target: 5 | 6 | jobs: 7 | header-license-fix: 8 | runs-on: ubuntu-24.04 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | - name: Configure git to use https 21 | run: git config --global hub.protocol https 22 | 23 | - name: Checkout the branch from the PR that triggered the job 24 | run: gh pr checkout ${{ github.event.pull_request.number }} 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Fix License Header 29 | uses: apache/skywalking-eyes/header@v0.7.0 30 | with: 31 | mode: fix 32 | 33 | - name: List files changed 34 | id: files-changed 35 | shell: bash -l {0} 36 | run: | 37 | set -ex 38 | export CHANGES=$(git status --porcelain | tee modified.log | wc -l) 39 | cat modified.log 40 | # Remove the log otherwise it will be committed 41 | rm modified.log 42 | 43 | echo "N_CHANGES=${CHANGES}" >> $GITHUB_OUTPUT 44 | 45 | git diff 46 | 47 | - name: Commit any changes 48 | if: steps.files-changed.outputs.N_CHANGES != '0' 49 | shell: bash -l {0} 50 | run: | 51 | git config user.name "github-actions[bot]" 52 | git config user.email "github-actions[bot]@users.noreply.github.com" 53 | 54 | git pull --no-tags 55 | 56 | git add * 57 | git commit -m "Automatic application of license header" 58 | 59 | git config push.default upstream 60 | git push 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | silent: 16 | description: "Set a placeholder in the changelog and don't publish the release." 17 | required: false 18 | type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 33 | 34 | - name: Prep Release 35 | id: prep-release 36 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | version_spec: ${{ github.event.inputs.version_spec }} 40 | silent: ${{ github.event.inputs.silent }} 41 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 42 | branch: ${{ github.event.inputs.branch }} 43 | since: ${{ github.event.inputs.since }} 44 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 45 | 46 | - name: "** Next Step **" 47 | run: | 48 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 49 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ vars.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | 30 | - name: Populate Release 31 | id: populate-release 32 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 33 | with: 34 | token: ${{ steps.app-token.outputs.token }} 35 | branch: ${{ github.event.inputs.branch }} 36 | release_url: ${{ github.event.inputs.release_url }} 37 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 38 | 39 | - name: Finalize Release 40 | id: finalize-release 41 | env: 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 44 | with: 45 | token: ${{ steps.app-token.outputs.token }} 46 | release_url: ${{ steps.populate-release.outputs.release_url }} 47 | 48 | - name: "** Next Step **" 49 | if: ${{ success() }} 50 | run: | 51 | echo "Verify the final release" 52 | echo ${{ steps.finalize-release.outputs.release_url }} 53 | 54 | - name: "** Failure Message **" 55 | if: ${{ failure() }} 56 | run: | 57 | echo "Failed to Publish the Draft Release Url:" 58 | echo ${{ steps.populate-release.outputs.release_url }} 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | pre-commit: 11 | name: pre-commit 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | - uses: pre-commit/action@v3.0.1 17 | with: 18 | extra_args: --all-files --hook-stage=manual 19 | - name: Help message if pre-commit fail 20 | if: ${{ failure() }} 21 | run: | 22 | echo "You can install pre-commit hooks to automatically run formatting" 23 | echo "on each commit with:" 24 | echo " pre-commit install" 25 | echo "or you can run by hand on staged files with" 26 | echo " pre-commit run" 27 | echo "or after-the-fact on already committed files with" 28 | echo " pre-commit run --all-files --hook-stage=manual" 29 | 30 | test: 31 | name: Run tests on ${{ matrix.os }} 32 | runs-on: ${{ matrix.os }} 33 | timeout-minutes: 10 34 | 35 | strategy: 36 | matrix: 37 | os: [ubuntu-latest, windows-latest, macos-latest] 38 | defaults: 39 | run: 40 | shell: bash -l {0} 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | - name: Install mamba 45 | uses: mamba-org/setup-micromamba@v2 46 | with: 47 | environment-name: jupyter_ydoc 48 | - name: Install dependencies 49 | run: | 50 | micromamba install pip nodejs=18 51 | pip install ".[test]" 52 | - name: Build JavaScript assets 53 | working-directory: javascript 54 | run: | 55 | yarn 56 | yarn build 57 | - name: Linter check 58 | if: ${{ !contains(matrix.os, 'windows') }} 59 | working-directory: javascript 60 | run: | 61 | yarn lint:check 62 | - name: Integrity check 63 | if: ${{ !contains(matrix.os, 'windows') }} 64 | working-directory: javascript 65 | run: | 66 | set -ex 67 | yarn integrity 68 | if [[ $(git ls-files --exclude-standard -m | wc -l) > 0 ]] 69 | then 70 | echo "Integrity test failed; please run locally 'yarn integrity' and commit the changes" 71 | exit 1 72 | fi 73 | - name: Run JS tests 74 | working-directory: javascript 75 | run: | 76 | yarn build:test 77 | yarn test:cov 78 | - name: Run Python tests 79 | run: | 80 | python -m pytest -v 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | javascript/docs 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | node_modules/ 133 | package-lock.json 134 | tests/package-lock.json 135 | javascript/tsconfig.tsbuildinfo 136 | javascript/.eslintcache 137 | javascript/coverage/ 138 | jupyter_ydoc/_version.py 139 | 140 | # Yarn 141 | .pnp.* 142 | .yarn/* 143 | !.yarn/patches 144 | !.yarn/plugins 145 | !.yarn/releases 146 | !.yarn/sdks 147 | !.yarn/versions 148 | docs/source/api 149 | docs/source/changelog.md 150 | # pixi environments 151 | .pixi 152 | -------------------------------------------------------------------------------- /.licenserc.yaml: -------------------------------------------------------------------------------- 1 | header: 2 | license: 3 | spdx-id: BSD-3-Clause 4 | copyright-owner: Jupyter Development Team 5 | software-name: Jupyter YDoc 6 | content: | 7 | Copyright (c) Jupyter Development Team. 8 | Distributed under the terms of the Modified BSD License. 9 | 10 | paths-ignore: 11 | - '**/*.ipynb' 12 | - '**/*.json' 13 | - '**/*.md' 14 | - '**/*.svg' 15 | - '**/*.yml' 16 | - '**/*.yaml' 17 | - '**/build' 18 | - '**/lib' 19 | - '**/node_modules' 20 | - '*.map.js' 21 | - '*.bundle.js' 22 | - '**/.*' 23 | - 'LICENSE' 24 | - 'yarn.lock' 25 | - '**/py.typed' 26 | 27 | comment: on-failure 28 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: check-case-conflict 7 | - id: check-executables-have-shebangs 8 | - id: requirements-txt-fixer 9 | - id: check-added-large-files 10 | - id: check-case-conflict 11 | - id: check-toml 12 | - id: check-yaml 13 | - id: debug-statements 14 | - id: forbid-new-submodules 15 | - id: check-builtin-literals 16 | - id: trailing-whitespace 17 | exclude: ^\.yarn 18 | 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: v0.11.12 21 | hooks: 22 | - id: ruff 23 | args: [--fix, --show-fixes] 24 | - id: ruff-format 25 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.4.1.cjs 2 | nodeLinker: node-modules 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Jupyter Development Team 4 | Copyright (c) 2022, David Brochart 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | 3. Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/jupyter-server/jupyter_ydoc/workflows/Tests/badge.svg)](https://github.com/jupyter-server/jupyter_ydoc/actions) 2 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 3 | [![PyPI](https://img.shields.io/pypi/v/jupyter-ydoc)](https://pypi.org/project/jupyter-ydoc/) 4 | [![npm (scoped)](https://img.shields.io/npm/v/@jupyter/ydoc)](https://www.npmjs.com/package/@jupyter/ydoc) 5 | 6 | # jupyter_ydoc 7 | 8 | `jupyter_ydoc` provides [pycrdt](https://github.com/jupyter-server/pycrdt)-based data structures for various 9 | documents used in the Jupyter ecosystem. Built-in documents include: 10 | - `YBlob`: a generic immutable binary document. 11 | - `YUnicode`: a generic UTF8-encoded text document (`YFile` is an alias to `YUnicode`). 12 | - `YNotebook`: a Jupyter notebook document. 13 | 14 | These documents are registered via an entry point under the `"jupyter_ydoc"` group as `"blob"`, 15 | `"unicode"` (or `"file"`), and `"notebook"`, respectively. You can access them as follows: 16 | 17 | ```py 18 | from jupyter_ydoc import ydocs 19 | 20 | print(ydocs) 21 | # { 22 | # 'blob': , 23 | # 'file': , 24 | # 'notebook': , 25 | # 'unicode': 26 | # } 27 | ``` 28 | 29 | Which is just a shortcut to: 30 | 31 | ```py 32 | from importlib.metadata import entry_points 33 | # for Python < 3.10, install importlib_metadata and do: 34 | # from importlib_metadata import entry_points 35 | 36 | ydocs = {ep.name: ep.load() for ep in entry_points(group="jupyter_ydoc")} 37 | ``` 38 | 39 | Or directly import them: 40 | ```py 41 | from jupyter_ydoc import YBlob, YUnicode, YNotebook 42 | ``` 43 | 44 | The `"jupyter_ydoc"` entry point group can be populated with your own documents, e.g. by adding the 45 | following to your package's `pyproject.toml`: 46 | 47 | ``` 48 | [project.entry-points.jupyter_ydoc] 49 | my_document = "my_package.my_file:MyDocumentClass" 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | # Minimal makefile for Sphinx documentation 5 | # 6 | 7 | # You can set these variables from the command line, and also 8 | # from the environment for the first two. 9 | SPHINXOPTS ?= 10 | SPHINXBUILD ?= sphinx-build 11 | SOURCEDIR = source 12 | BUILDDIR = build 13 | 14 | # Put it first so that "make" without argument is like "make help". 15 | help: 16 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 17 | 18 | .PHONY: help Makefile 19 | 20 | # Catch-all target: route all unknown targets to Sphinx using the new 21 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 22 | %: Makefile 23 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 24 | 25 | clean: 26 | # clean api build as well 27 | -rm -rf "$(SOURCEDIR)/../api" 28 | @$(SPHINXBUILD) -M clean "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | rem Copyright (c) Jupyter Development Team. 2 | rem Distributed under the terms of the Modified BSD License. 3 | 4 | @ECHO OFF 5 | 6 | pushd %~dp0 7 | 8 | REM Command file for Sphinx documentation 9 | 10 | if "%SPHINXBUILD%" == "" ( 11 | set SPHINXBUILD=sphinx-build 12 | ) 13 | set SOURCEDIR=source 14 | set BUILDDIR=build 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.https://www.sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | if "%1" == "" goto help 30 | 31 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 32 | goto end 33 | 34 | :help 35 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 36 | 37 | :end 38 | popd 39 | -------------------------------------------------------------------------------- /docs/source/_static/jupyter_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | logo-5.svg 3 | Created using Figma 0.90 4 | 5 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/source/_static/logo-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_ydoc/090c8f660e0f7f77bc6568c5bac36cebc1b68824/docs/source/_static/logo-icon.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | # Configuration file for the Sphinx documentation builder. 5 | # 6 | # For the full list of built-in configuration values, see the documentation: 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 8 | 9 | import shutil 10 | from pathlib import Path 11 | from subprocess import check_call 12 | 13 | HERE = Path(__file__).parent.resolve() 14 | 15 | # -- Project information ----------------------------------------------------- 16 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 17 | 18 | project = "jupyter-ydoc" 19 | copyright = "2022, Jupyter Development Team" 20 | author = "Jupyter Development Team" 21 | release = "0.3.0" 22 | 23 | # -- General configuration --------------------------------------------------- 24 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 25 | 26 | extensions = ["myst_parser", "sphinx.ext.autodoc"] 27 | 28 | templates_path = ["_templates"] 29 | exclude_patterns = ["_static/api/**"] 30 | source_suffix = { 31 | ".rst": "restructuredtext", 32 | ".md": "markdown", 33 | } 34 | 35 | # -- Options for HTML output ------------------------------------------------- 36 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 37 | 38 | # html_static_path = ['_static'] 39 | 40 | html_theme = "pydata_sphinx_theme" 41 | html_logo = "_static/jupyter_logo.svg" 42 | html_favicon = "_static/logo-icon.png" 43 | # Theme options are theme-specific and customize the look and feel of a theme 44 | # further. For a list of options available for each theme, see the 45 | # documentation. 46 | # 47 | html_theme_options = { 48 | "logo": { 49 | "text": "Jupyter YDoc", 50 | "image_dark": "_static/jupyter_logo.svg", 51 | "alt_text": "Jupyter YDoc", 52 | }, 53 | "icon_links": [ 54 | { 55 | "name": "jupyter.org", 56 | "url": "https://jupyter.org", 57 | "icon": "_static/jupyter_logo.svg", 58 | "type": "local", 59 | } 60 | ], 61 | "github_url": "https://github.com/jupyter-server/jupyter_ydoc", 62 | "use_edit_page_button": True, 63 | "show_toc_level": 1, 64 | "navbar_align": "left", 65 | "navbar_end": ["navbar-icon-links.html"], 66 | "footer_items": ["copyright.html"], 67 | } 68 | 69 | # Output for github to be used in links 70 | html_context = { 71 | "github_user": "jupyter-server", # Username 72 | "github_repo": "jupyter_ydoc", # Repo name 73 | "github_version": "main", # Version 74 | "conf_py_path": "/docs/source/", # Path in the checkout to the docs root 75 | } 76 | 77 | myst_heading_anchors = 3 78 | 79 | 80 | def setup(app): 81 | # Copy changelog.md file 82 | dest = HERE / "changelog.md" 83 | shutil.copy(str(HERE.parent.parent / "CHANGELOG.md"), str(dest)) 84 | 85 | # Build JavaScript Docs 86 | js = HERE.parent.parent / "javascript" 87 | dest_dir = Path(app.outdir) / "api" 88 | 89 | print("Building @jupyter/ydoc API docs") 90 | cmd = ["yarn"] if shutil.which("yarn") is not None else ["npm"] 91 | check_call(cmd + ["install"], cwd=str(js)) 92 | check_call(cmd + ["run", "build"], cwd=str(js)) 93 | check_call(cmd + ["run", "docs"], cwd=str(js)) 94 | 95 | if dest_dir.exists(): 96 | shutil.rmtree(dest_dir) 97 | shutil.copytree(str(js / "docs"), str(dest_dir)) 98 | -------------------------------------------------------------------------------- /docs/source/custom.md: -------------------------------------------------------------------------------- 1 | # Custom documents 2 | 3 | Writing an application for a new kind of collaborative document requires the definition of a custom YDoc. 4 | 5 | This section will focus on extending documents to use in JupyterLab. In JupyterLab, the front-end conforms to the `ISharedDocument` interface and expects a `YDocument`, both present in the [`@jupyter/ydoc`](./overview.md#jupyter-ydoc) package. In contrast, the back-end conforms to the `YBaseDoc` class in [`jupyter_ydoc`](./overview.md#jupyterydoc). 6 | 7 | On a few occasions, we can extend the front-end model and reuse the back-end counterpart without extending it. Extending only the front-end will prevent JupyterLab from saving the new attributes to disk since those new attributes will not be exported by the back-end model when requesting the document's content. This could be the case, for example, when creating a commenting extension where we want to sync the comments through different clients, but we do not want to save them to disk, or at least not within the document. 8 | 9 | Once we implement our new models, it is time to export them to be consumed by JupyterLab. In JupyterLab, we register new file types or new document models for existing file types from the front-end. For this reason, the wording that we will use from now on is highly tight to JupyterLab development, and we highly recommend reading JupyterLab's documentation or at least [the section about documents](https://jupyterlab.readthedocs.io/en/stable/extension/documents.html) before continuing reading. 10 | 11 | The front-end's models are more complicated because JupyterLab wraps the shared models (that is how we name Jupyter YDoc in the JupyterLab source code) inside the old document's models from JupyterLab. In addition, JupyterLab's document models expose the shared models as a single property, and registering new documents involves more knowledge of the document system. For this reason, we recommend following JupyterLab's documentation, [the section about documents](https://jupyterlab.readthedocs.io/en/stable/extension/documents.html) to register new documents on the front-end side. 12 | 13 | On the other side, to export a back-end model, we have to use the entry points provided by Python. We can export our new models by adding them to the configuration file of our Python project using the entry point `jupyter_ydoc`. For example, using a `pyproject.toml` configuration file, we will export our custom model for a `my_document` type as follows: 14 | 15 | ```toml 16 | [project.entry-points.jupyter_ydoc] 17 | my_document = "my_module.my_file:YCustomDocument" 18 | ``` 19 | 20 | You can find an example of a custom document in the [JupyterCAD extension](https://github.com/QuantStack/jupytercad). With an implementation of a new document model [here](https://github.com/jupytercad/jupytercad/blob/21e54e98fbfbc5dd0303901f30cec1619fd5a109/python/jupytercad-core/jupytercad_core/jcad_ydoc.py) and registering it [here](https://github.com/jupytercad/jupytercad/blob/21e54e98fbfbc5dd0303901f30cec1619fd5a109/python/jupytercad-core/pyproject.toml#L34). 21 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | # Welcome to Jupyter YDoc's documentation! 9 | 10 | ```{toctree} 11 | :maxdepth: 2 12 | :caption: Contents 13 | 14 | overview 15 | schema 16 | custom 17 | javascript_api 18 | python_api 19 | changelog 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/source/javascript_api.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | JavaScript API 5 | ============== 6 | 7 | .. meta:: 8 | :http-equiv=refresh: 0;url=./api/index.html 9 | 10 | The `@jupyter/ydoc` API reference docs are `here <./api/index.html>`_ 11 | if you are not redirected automatically. 12 | -------------------------------------------------------------------------------- /docs/source/overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | The `jupyter_ydoc` repository includes various models that JupyterLab uses for collaborative editing. These models use a specific implementation of a CRDT, the Y-CRDTs. To be more precise, the JavaScript package uses [yjs](https://github.com/yjs/yjs), while the Python package uses [pycrdt](https://github.com/jupyter-server/pycrdt). 4 | 5 | Jupyter YDoc was designed to centralize the data structures used for composing a document in a single class, hide the complicated edge cases of CRDTs, and prevent users from inserting invalid data or adding new attributes to the document that are not part of the schema. 6 | 7 | This repository holds a JavaScript package and its Python counterpart. In the JupyterLab context, the JavaScript package, or from now on, `@jupyter/ydoc`, contains the front-end models used to keep the documents in the client's memory. In contrast, the Python package contains the models used in the back-end to serve documents to each client. 8 | 9 | 10 | ## `@jupyter/ydoc` 11 | Built on top of [yjs](https://github.com/yjs/yjs), `@jupyter/ydoc` is a JavaScript package that includes the models used in the JupyterLab front-end for real-time collaboration. This package contains two main classes, `YFile` used for plain text documents and `YNotebook` used for the Notebook format. In the JupyterLab context, we call the models exported by this package, shared models. In addition, this package contains the `IShared` interfaces used to abstract out the implementation of the shared models in JupyterLab to make it easier to replace the CRDT implementation (Yjs) for something else if needed. 12 | 13 | **Source Code:** [GitHub](https://github.com/jupyter-server/jupyter_ydoc/tree/main/javascript) 14 | 15 | **Package:** [NPM](https://www.npmjs.com/package/@jupyter/ydoc) 16 | 17 | **API documentation:**: [JavaScript API](javascript_api.rst) 18 | 19 | 20 | 21 | ## `jupyter-ydoc` 22 | Built on top of [pycrdt](https://github.com/jupyter-server/pycrdt), `jupyter-ydoc` is a Python package that includes the models used in the JupyterLab back-end for representing collaborative documents. 23 | 24 | **Source Code:** [GitHub](https://github.com/jupyter-server/jupyter_ydoc/tree/main/jupyter_ydoc) 25 | 26 | **Package:** [PyPI](https://pypi.org/project/jupyter-ydoc) 27 | 28 | **API documentation:**: [Python API](python_api.rst) 29 | -------------------------------------------------------------------------------- /docs/source/python_api.rst: -------------------------------------------------------------------------------- 1 | .. Copyright (c) Jupyter Development Team. 2 | .. Distributed under the terms of the Modified BSD License. 3 | 4 | Python API 5 | ========== 6 | 7 | .. automodule:: jupyter_ydoc.ybasedoc 8 | :members: 9 | :inherited-members: 10 | 11 | .. automodule:: jupyter_ydoc.yblob 12 | :members: 13 | :inherited-members: 14 | 15 | .. automodule:: jupyter_ydoc.yfile 16 | :members: 17 | :inherited-members: 18 | 19 | .. automodule:: jupyter_ydoc.ynotebook 20 | :members: 21 | :inherited-members: 22 | 23 | .. automodule:: jupyter_ydoc.yunicode 24 | :members: 25 | :inherited-members: 26 | -------------------------------------------------------------------------------- /docs/source/schema.md: -------------------------------------------------------------------------------- 1 | # Schemas 2 | 3 | Yjs is untyped. We must know what each attribute contains at build-time and cast its values when accessing them. It is essential to ensure both models use the same schema, to prevent errors at run-time because of a wrong cast. For this purpose, in the following sections, we can find the description of the schema used by each model in case we want to extend them to create a custom model. 4 | 5 | ## YFile 6 | 7 | ```typescript 8 | { 9 | /** 10 | * Contains the state of the document. 11 | * At the moment the only mandatory attributes are path and dirty. 12 | */ 13 | "state": YMap, 14 | /** 15 | * Contains the content of the document. 16 | */ 17 | "source": YText 18 | 19 | } 20 | ``` 21 | 22 | ### state: 23 | ```typescript 24 | { 25 | /** 26 | * Whether the document is dirty. 27 | */ 28 | "dirty": bool, 29 | /** 30 | * Document's path. 31 | */ 32 | "path": str 33 | } 34 | ``` 35 | 36 | ## YNotebook 37 | 38 | ```typescript 39 | { 40 | /** 41 | * Contains the state of the document. 42 | * At the moment the only mandatory attributes are path and dirty. 43 | */ 44 | "state": YMap, 45 | /** 46 | * Contains document's metadata. 47 | * 48 | * Note: Do not confuse it with the notebook's metadata attribute, 49 | * "meta" has `nbformat`, `nbformat_minor`, and `metadata` 50 | */ 51 | "meta": YMap, 52 | /** 53 | * The list of YMap that stores the data of each cell. 54 | */ 55 | "cells": YArray> 56 | } 57 | ``` 58 | 59 | ### state: 60 | ```typescript 61 | { 62 | /** 63 | * Whether the document is dirty. 64 | */ 65 | "dirty": bool, 66 | /** 67 | * Document's path. 68 | */ 69 | "path": str 70 | } 71 | ``` 72 | 73 | ### meta: 74 | ```typescript 75 | { 76 | /** 77 | * The version of the notebook format supported by the schema. 78 | */ 79 | "nbformat": number, 80 | /** 81 | * The minor version of the notebook format. 82 | */ 83 | "nbformat_minor": number, 84 | /** 85 | * Notebook's metadata. 86 | */ 87 | "metadata": YMap 88 | } 89 | ``` 90 | 91 | ### cells 92 | ```typescript 93 | [ 94 | /** 95 | * The following JSON object is actually a YMap that contains 96 | * the described attributes. 97 | */ 98 | { 99 | /** 100 | * Cell's id. 101 | */ 102 | "id": str, 103 | /** 104 | * Cell type. 105 | */ 106 | "cell_type": str, 107 | /** 108 | * The content of the cell (the code). 109 | */ 110 | "source": YText, 111 | /** 112 | * Cell's metadata. 113 | */ 114 | "metadata": YMap, 115 | /** 116 | * The execution count. 117 | */ 118 | "execution_count": Int | None, 119 | /** 120 | * Cell's outputs. 121 | */ 122 | "outputs": [] | None, 123 | /** 124 | * Cell's attachments. 125 | */ 126 | "attachments": {} | None 127 | } 128 | ] 129 | ``` 130 | -------------------------------------------------------------------------------- /javascript/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | docs 4 | -------------------------------------------------------------------------------- /javascript/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | commonjs: true, 6 | node: true, 7 | 'jest/globals': true 8 | }, 9 | globals: { 10 | BigInt: 'readonly', 11 | HTMLCollectionOf: 'readonly', 12 | NodeJS: 'readonly', 13 | RequestInit: 'readonly', 14 | RequestInfo: 'readonly', 15 | ScrollLogicalPosition: 'readonly' 16 | }, 17 | root: true, 18 | extends: [ 19 | 'eslint:recommended', 20 | 'plugin:@typescript-eslint/eslint-recommended', 21 | 'plugin:@typescript-eslint/recommended', 22 | 'prettier' 23 | ], 24 | parser: '@typescript-eslint/parser', 25 | plugins: ['@typescript-eslint'], 26 | overrides: [ 27 | { 28 | files: ['test/**/*.spec.ts'], 29 | plugins: ['jest'], 30 | extends: ['plugin:jest/recommended'], 31 | rules: { 32 | 'jest/no-conditional-expect': 'warn', 33 | 'jest/valid-title': 'warn', 34 | 'jest/no-standalone-expect': [ 35 | 'error', 36 | { 37 | additionalTestBlockFunctions: ['it'] 38 | } 39 | ] 40 | } 41 | } 42 | ], 43 | rules: { 44 | '@typescript-eslint/naming-convention': [ 45 | 'error', 46 | { 47 | selector: 'interface', 48 | format: ['PascalCase'], 49 | custom: { 50 | regex: '^I[A-Z]', 51 | match: true 52 | } 53 | } 54 | ], 55 | '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], 56 | '@typescript-eslint/no-use-before-define': 'off', 57 | '@typescript-eslint/no-explicit-any': 'off', 58 | '@typescript-eslint/no-non-null-assertion': 'off', 59 | '@typescript-eslint/no-namespace': 'off', 60 | '@typescript-eslint/interface-name-prefix': 'off', 61 | '@typescript-eslint/explicit-function-return-type': 'off', 62 | '@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }], 63 | '@typescript-eslint/ban-types': 'warn', 64 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'warn', 65 | '@typescript-eslint/no-var-requires': 'off', 66 | '@typescript-eslint/no-empty-interface': 'off', 67 | '@typescript-eslint/triple-slash-reference': 'warn', 68 | '@typescript-eslint/no-inferrable-types': 'off', 69 | camelcase: [ 70 | 'error', 71 | { 72 | allow: [ 73 | 'cell_type', 74 | 'display_name', 75 | 'execution_count', 76 | 'orig_nbformat', 77 | 'outputs_hidden', 78 | 'nbformat_minor' 79 | ] 80 | } 81 | ], 82 | 'id-match': ['error', '^[a-zA-Z_]+[a-zA-Z0-9_]*$'], // https://certitude.consulting/blog/en/invisible-backdoor/ 83 | 'no-inner-declarations': 'off', 84 | 'no-prototype-builtins': 'off', 85 | 'no-control-regex': 'warn', 86 | 'no-undef': 'warn', 87 | 'no-case-declarations': 'warn', 88 | 'no-useless-escape': 'off', 89 | 'prefer-const': 'off', 90 | 'sort-imports': [ 91 | 'error', 92 | { 93 | ignoreCase: true, 94 | ignoreDeclarationSort: true, 95 | ignoreMemberSort: false, 96 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], 97 | allowSeparatedGroups: false 98 | } 99 | ] 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /javascript/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | docs 4 | package.json 5 | -------------------------------------------------------------------------------- /javascript/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /javascript/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Attach to jest", 8 | // Usage: 9 | // Open the parent directory in VSCode 10 | // Run `jlpm test:debug:watch` in a terminal 11 | // Run this debugging task 12 | "port": 9229 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /javascript/README.md: -------------------------------------------------------------------------------- 1 | # @jupyter/ydoc 2 | 3 | `@jupyter/ydoc` provides [Yjs](https://github.com/yjs/yjs)-based data structures for various 4 | documents used in the Jupyter ecosystem. Built-in documents include: 5 | - `YFile`: a generic text document. 6 | - `YNotebook`: a Jupyter notebook document. 7 | 8 | The API documentation is available [there](https://jupyter-ydoc.readthedocs.io/en/latest/api/index.html). 9 | -------------------------------------------------------------------------------- /javascript/jest.config.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Jupyter Development Team. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | module.exports = { 7 | testEnvironment: 'node', 8 | testRegex: 'lib/test/.*.spec.js[x]?$', 9 | }; 10 | -------------------------------------------------------------------------------- /javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupyter/ydoc", 3 | "version": "3.0.5", 4 | "type": "module", 5 | "description": "Jupyter document structures for collaborative editing using YJS", 6 | "homepage": "https://github.com/jupyter-server/jupyter_ydoc", 7 | "bugs": { 8 | "url": "https://github.com/jupyter-server/jupyter_ydoc/issues" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/jupyter-server/jupyter_ydoc.git" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": "Project Jupyter", 16 | "main": "lib/index.js", 17 | "types": "lib/index.d.ts", 18 | "directories": { 19 | "lib": "lib/" 20 | }, 21 | "files": [ 22 | "lib/**/*.{d.ts,js,js.map,json}", 23 | "src/**/*.ts" 24 | ], 25 | "scripts": { 26 | "build": "tsc -b", 27 | "build:test": "tsc --build tsconfig.test.json", 28 | "clean": "rimraf lib tsconfig.tsbuildinfo docs", 29 | "docs": "typedoc src", 30 | "eslint": "eslint --ext .js,.jsx,.ts,.tsx --cache --fix .", 31 | "eslint:check": "eslint --ext .js,.jsx,.ts,.tsx --cache .", 32 | "lint": "yarn integrity && yarn prettier && yarn eslint", 33 | "lint:check": "yarn prettier:check && yarn eslint:check", 34 | "prettier": "prettier --write \"**/*{.ts,.tsx,.js,.jsx,.css,.json}\"", 35 | "prettier:check": "prettier --check \"**/*{.ts,.tsx,.js,.jsx,.css,.json}\"", 36 | "integrity": "yarn tsc-esm-fix --src='src' --ext='.js'", 37 | "test": "jest", 38 | "test:cov": "jest --collect-coverage", 39 | "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand", 40 | "test:debug:watch": "node --inspect-brk node_modules/.bin/jest --runInBand --watch", 41 | "watch": "tsc -b --watch" 42 | }, 43 | "dependencies": { 44 | "@jupyterlab/nbformat": "^3.0.0 || ^4.0.0-alpha.21 || ^4.0.0", 45 | "@lumino/coreutils": "^1.11.0 || ^2.0.0", 46 | "@lumino/disposable": "^1.10.0 || ^2.0.0", 47 | "@lumino/signaling": "^1.10.0 || ^2.0.0", 48 | "y-protocols": "^1.0.5", 49 | "yjs": "^13.5.40" 50 | }, 51 | "devDependencies": { 52 | "@types/jest": "^29.0.0", 53 | "@typescript-eslint/eslint-plugin": "^5.36.0", 54 | "@typescript-eslint/parser": "^5.36.0", 55 | "eslint": "^8.17.0", 56 | "eslint-config-prettier": "^8.5.0", 57 | "eslint-plugin-jest": "^27.0.0", 58 | "eslint-plugin-prettier": "^4.0.0", 59 | "jest": "^29.0.0", 60 | "prettier": "^2.8.4", 61 | "rimraf": "^4.4.0", 62 | "tsc-esm-fix": "^2.20.0", 63 | "typedoc": "^0.23.21", 64 | "typescript": "^4.9.5" 65 | }, 66 | "publishConfig": { 67 | "access": "public" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /javascript/src/api.ts: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |----------------------------------------------------------------------------*/ 5 | 6 | /** 7 | * This file defines the shared shared-models types. 8 | * 9 | * - Notebook Type. 10 | * - Notebook Metadata Types. 11 | * - Cell Types. 12 | * - Cell Metadata Types. 13 | * 14 | * It also defines the shared changes to be used in the events. 15 | */ 16 | 17 | import type * as nbformat from '@jupyterlab/nbformat'; 18 | import type { 19 | JSONObject, 20 | JSONValue, 21 | PartialJSONValue 22 | } from '@lumino/coreutils'; 23 | import type { IObservableDisposable } from '@lumino/disposable'; 24 | import type { ISignal } from '@lumino/signaling'; 25 | import * as Y from 'yjs'; 26 | import { IAwareness } from './awareness.js'; 27 | 28 | /** 29 | * Changes on Sequence-like data are expressed as Quill-inspired deltas. 30 | * 31 | * @source https://quilljs.com/docs/delta/ 32 | */ 33 | export type Delta = Array<{ insert?: T; delete?: number; retain?: number }>; 34 | 35 | /** 36 | * Changes on a map-like data. 37 | */ 38 | export type MapChanges = Map< 39 | string, 40 | { 41 | action: 'add' | 'update' | 'delete'; 42 | oldValue: any; 43 | } 44 | >; 45 | 46 | /** 47 | * ISharedBase defines common operations that can be performed on any shared object. 48 | */ 49 | export interface ISharedBase extends IObservableDisposable { 50 | /** 51 | * Undo an operation. 52 | */ 53 | undo(): void; 54 | 55 | /** 56 | * Redo an operation. 57 | */ 58 | redo(): void; 59 | 60 | /** 61 | * Whether the object can redo changes. 62 | */ 63 | canUndo(): boolean; 64 | 65 | /** 66 | * Whether the object can undo changes. 67 | */ 68 | canRedo(): boolean; 69 | 70 | /** 71 | * Clear the change stack. 72 | */ 73 | clearUndoHistory(): void; 74 | 75 | /** 76 | * Perform a transaction. While the function f is called, all changes to the shared 77 | * document are bundled into a single event. 78 | * 79 | * @param f Transaction to execute 80 | * @param undoable Whether to track the change in the action history or not (default `true`) 81 | */ 82 | transact(f: () => void, undoable?: boolean, origin?: any): void; 83 | } 84 | 85 | /** 86 | * Implement an API for Context information on the shared information. 87 | * This is used by, for example, docregistry to share the file-path of the edited content. 88 | */ 89 | interface ISharedDocumentNoSource extends ISharedBase { 90 | /** 91 | * Document version 92 | */ 93 | readonly version: string; 94 | 95 | /** 96 | * Document state 97 | */ 98 | readonly state: JSONObject; 99 | 100 | /** 101 | * Document awareness 102 | */ 103 | readonly awareness: IAwareness; 104 | 105 | /** 106 | * Get the value for a state attribute 107 | * 108 | * @param key Key to get 109 | */ 110 | getState(key: string): JSONValue | undefined; 111 | 112 | /** 113 | * Set the value of a state attribute 114 | * 115 | * @param key Key to set 116 | * @param value New attribute value 117 | */ 118 | setState(key: string, value: JSONValue): void; 119 | 120 | /** 121 | * The changed signal. 122 | */ 123 | readonly changed: ISignal; 124 | } 125 | 126 | /** 127 | * Implement an API for Context information on the shared information. 128 | * This is used by, for example, docregistry to share the file-path of the edited content. 129 | */ 130 | export interface ISharedDocument extends ISharedDocumentNoSource { 131 | /** 132 | * Get the document source. 133 | * 134 | * @returns Source. 135 | */ 136 | getSource(): string | JSONValue; 137 | 138 | /** 139 | * Set the document source. 140 | * 141 | * @param value New source. 142 | */ 143 | setSource(value: string | JSONValue): void; 144 | } 145 | 146 | /** 147 | * The ISharedText interface defines models that can be bound to a text editor like CodeMirror. 148 | */ 149 | export interface ISharedText extends ISharedBase { 150 | /** 151 | * The changed signal. 152 | */ 153 | readonly changed: ISignal; 154 | 155 | /** 156 | * Text 157 | */ 158 | source: string; 159 | 160 | /** 161 | * Get text. 162 | * 163 | * @returns Text. 164 | */ 165 | getSource(): string; 166 | 167 | /** 168 | * Set text. 169 | * 170 | * @param value New text. 171 | */ 172 | setSource(value: string): void; 173 | 174 | /** 175 | * Replace content from `start` to `end` with `value`. 176 | * 177 | * @param start: The start index of the range to replace (inclusive). 178 | * @param end: The end index of the range to replace (exclusive). 179 | * @param value: New source (optional). 180 | */ 181 | updateSource(start: number, end: number, value?: string): void; 182 | } 183 | 184 | /** 185 | * Text/Markdown/Code files are represented as ISharedFile 186 | */ 187 | export interface ISharedFile extends ISharedDocumentNoSource, ISharedText { 188 | /** 189 | * The changed signal. 190 | */ 191 | readonly changed: ISignal; 192 | } 193 | 194 | /** 195 | * Implements an API for nbformat.INotebookContent 196 | */ 197 | export interface ISharedNotebook extends ISharedDocument { 198 | /** 199 | * The changed signal. 200 | */ 201 | readonly changed: ISignal; 202 | 203 | /** 204 | * Signal triggered when a metadata changes. 205 | */ 206 | readonly metadataChanged: ISignal; 207 | 208 | /** 209 | * The list of shared cells in the notebook. 210 | */ 211 | readonly cells: ISharedCell[]; 212 | 213 | /** 214 | * Wether the undo/redo logic should be 215 | * considered on the full document across all cells. 216 | */ 217 | readonly disableDocumentWideUndoRedo?: boolean; 218 | 219 | /** 220 | * Notebook metadata. 221 | */ 222 | metadata: nbformat.INotebookMetadata; 223 | 224 | /** 225 | * The minor version number of the nbformat. 226 | */ 227 | readonly nbformat_minor: number; 228 | 229 | /** 230 | * The major version number of the nbformat. 231 | */ 232 | readonly nbformat: number; 233 | 234 | /** 235 | * Delete a metadata notebook. 236 | * 237 | * @param key The key to delete 238 | */ 239 | deleteMetadata(key: string): void; 240 | 241 | /** 242 | * Returns all metadata associated with the notebook. 243 | * 244 | * @returns Notebook's metadata. 245 | */ 246 | getMetadata(): nbformat.INotebookMetadata; 247 | 248 | /** 249 | * Returns a metadata associated with the notebook. 250 | * 251 | * @param key Key to get from the metadata 252 | * @returns Notebook's metadata. 253 | */ 254 | getMetadata(key: string): PartialJSONValue | undefined; 255 | 256 | /** 257 | * Sets all metadata associated with the notebook. 258 | * 259 | * @param metadata All Notebook's metadata. 260 | */ 261 | setMetadata(metadata: nbformat.INotebookMetadata): void; 262 | 263 | /** 264 | * Sets a metadata associated with the notebook. 265 | * 266 | * @param metadata The key to set. 267 | * @param value New metadata value 268 | */ 269 | setMetadata(metadata: string, value: PartialJSONValue): void; 270 | 271 | /** 272 | * Updates the metadata associated with the notebook. 273 | * 274 | * @param value: Metadata's attribute to update. 275 | */ 276 | updateMetadata(value: Partial): void; 277 | 278 | /** 279 | * Add a shared cell at the notebook bottom. 280 | * 281 | * @param cell Cell to add. 282 | * 283 | * @returns The added cell. 284 | */ 285 | addCell(cell: SharedCell.Cell): ISharedCell; 286 | 287 | /** 288 | * Get a shared cell by index. 289 | * 290 | * @param index: Cell's position. 291 | * 292 | * @returns The requested shared cell. 293 | */ 294 | getCell(index: number): ISharedCell; 295 | 296 | /** 297 | * Insert a shared cell into a specific position. 298 | * 299 | * @param index Cell's position. 300 | * @param cell Cell to insert. 301 | * 302 | * @returns The inserted cell. 303 | */ 304 | insertCell(index: number, cell: SharedCell.Cell): ISharedCell; 305 | 306 | /** 307 | * Insert a list of shared cells into a specific position. 308 | * 309 | * @param index Position to insert the cells. 310 | * @param cells Array of shared cells to insert. 311 | * 312 | * @returns The inserted cells. 313 | */ 314 | insertCells(index: number, cells: Array): ISharedCell[]; 315 | 316 | /** 317 | * Move a cell. 318 | * 319 | * @param fromIndex: Index of the cell to move. 320 | * @param toIndex: New position of the cell. 321 | */ 322 | moveCell(fromIndex: number, toIndex: number): void; 323 | 324 | /** 325 | * Move cells. 326 | * 327 | * @param fromIndex: Index of the first cells to move. 328 | * @param toIndex: New position of the first cell (in the current array). 329 | * @param n: Number of cells to move (default 1) 330 | */ 331 | moveCells(fromIndex: number, toIndex: number, n?: number): void; 332 | 333 | /** 334 | * Remove a cell. 335 | * 336 | * @param index: Index of the cell to remove. 337 | */ 338 | deleteCell(index: number): void; 339 | 340 | /** 341 | * Remove a range of cells. 342 | * 343 | * @param from: The start index of the range to remove (inclusive). 344 | * 345 | * @param to: The end index of the range to remove (exclusive). 346 | */ 347 | deleteCellRange(from: number, to: number): void; 348 | 349 | /** 350 | * Override the notebook with a JSON-serialized document. 351 | * 352 | * @param value The notebook 353 | */ 354 | fromJSON(value: nbformat.INotebookContent): void; 355 | 356 | /** 357 | * Serialize the model to JSON. 358 | */ 359 | toJSON(): nbformat.INotebookContent; 360 | } 361 | 362 | /** 363 | * Definition of the map changes for yjs. 364 | */ 365 | export type MapChange = Map< 366 | string, 367 | { action: 'add' | 'update' | 'delete'; oldValue: any; newValue: any } 368 | >; 369 | 370 | /** 371 | * The namespace for `ISharedNotebook` class statics. 372 | */ 373 | export namespace ISharedNotebook { 374 | /** 375 | * The options used to initialize a a ISharedNotebook 376 | */ 377 | export interface IOptions { 378 | /** 379 | * Wether the the undo/redo logic should be 380 | * considered on the full document across all cells. 381 | */ 382 | disableDocumentWideUndoRedo?: boolean; 383 | 384 | /** 385 | * The content of the notebook. 386 | */ 387 | data?: Partial; 388 | } 389 | } 390 | 391 | /** Cell Types. */ 392 | export type ISharedCell = 393 | | ISharedCodeCell 394 | | ISharedRawCell 395 | | ISharedMarkdownCell 396 | | ISharedUnrecognizedCell; 397 | 398 | /** 399 | * Shared cell namespace 400 | */ 401 | export namespace SharedCell { 402 | /** 403 | * Cell data 404 | */ 405 | export type Cell = ( 406 | | Partial 407 | | Partial 408 | | Partial 409 | | Partial 410 | ) & { cell_type: string }; 411 | 412 | /** 413 | * Shared cell constructor options. 414 | */ 415 | export interface IOptions { 416 | /** 417 | * Optional notebook to which this cell belongs. 418 | * 419 | * If not provided the cell will be standalone. 420 | */ 421 | notebook?: ISharedNotebook; 422 | } 423 | } 424 | 425 | /** 426 | * Implements an API for nbformat.IBaseCell. 427 | */ 428 | export interface ISharedBaseCell< 429 | Metadata extends nbformat.IBaseCellMetadata = nbformat.IBaseCellMetadata 430 | > extends ISharedText { 431 | /** 432 | * The type of the cell. 433 | */ 434 | readonly cell_type: nbformat.CellType | string; 435 | 436 | /** 437 | * The changed signal. 438 | */ 439 | readonly changed: ISignal; 440 | 441 | /** 442 | * Cell id. 443 | */ 444 | readonly id: string; 445 | 446 | /** 447 | * Whether the cell is standalone or not. 448 | * 449 | * If the cell is standalone. It cannot be 450 | * inserted into a YNotebook because the Yjs model is already 451 | * attached to an anonymous Y.Doc instance. 452 | */ 453 | readonly isStandalone: boolean; 454 | 455 | /** 456 | * Cell metadata. 457 | * 458 | * #### Notes 459 | * You should prefer to access and modify the specific key of interest. 460 | */ 461 | metadata: Partial; 462 | 463 | /** 464 | * Signal triggered when the cell metadata changes. 465 | */ 466 | readonly metadataChanged: ISignal; 467 | 468 | /** 469 | * The notebook that this cell belongs to. 470 | */ 471 | readonly notebook: ISharedNotebook | null; 472 | 473 | /** 474 | * Get Cell id. 475 | * 476 | * @returns Cell id. 477 | */ 478 | getId(): string; 479 | 480 | /** 481 | * Delete a metadata cell. 482 | * 483 | * @param key The key to delete 484 | */ 485 | deleteMetadata(key: string): void; 486 | 487 | /** 488 | * Returns all metadata associated with the cell. 489 | * 490 | * @returns Cell's metadata. 491 | */ 492 | getMetadata(): Partial; 493 | 494 | /** 495 | * Returns a metadata associated with the cell. 496 | * 497 | * @param key Metadata key to get 498 | * @returns Cell's metadata. 499 | */ 500 | getMetadata(key: string): PartialJSONValue | undefined; 501 | 502 | /** 503 | * Sets some cell metadata. 504 | * 505 | * @param metadata Cell's metadata. 506 | */ 507 | setMetadata(metadata: Partial): void; 508 | 509 | /** 510 | * Sets a cell metadata. 511 | * 512 | * @param metadata Cell's metadata key. 513 | * @param value Metadata value 514 | */ 515 | setMetadata(metadata: string, value: PartialJSONValue): void; 516 | 517 | /** 518 | * Serialize the model to JSON. 519 | */ 520 | toJSON(): nbformat.IBaseCell; 521 | } 522 | 523 | export type IExecutionState = 'running' | 'idle'; 524 | 525 | /** 526 | * Implements an API for nbformat.ICodeCell. 527 | */ 528 | export interface ISharedCodeCell 529 | extends ISharedBaseCell { 530 | /** 531 | * The type of the cell. 532 | */ 533 | cell_type: 'code'; 534 | 535 | /** 536 | * The code cell's prompt number. Will be null if the cell has not been run. 537 | */ 538 | execution_count: nbformat.ExecutionCount; 539 | 540 | /** 541 | * The code cell's execution state. 542 | */ 543 | executionState: IExecutionState; 544 | 545 | /** 546 | * Cell outputs 547 | */ 548 | outputs: Array; 549 | 550 | /** 551 | * Execution, display, or stream outputs. 552 | */ 553 | getOutputs(): Array; 554 | 555 | /** 556 | * Add/Update output. 557 | */ 558 | setOutputs(outputs: Array): void; 559 | 560 | /** 561 | * Replace content from `start' to `end` with `outputs`. 562 | * 563 | * @param start: The start index of the range to replace (inclusive). 564 | * 565 | * @param end: The end index of the range to replace (exclusive). 566 | * 567 | * @param outputs: New outputs (optional). 568 | */ 569 | updateOutputs( 570 | start: number, 571 | end: number, 572 | outputs: Array 573 | ): void; 574 | 575 | /** 576 | * Serialize the model to JSON. 577 | */ 578 | toJSON(): nbformat.IBaseCell; 579 | } 580 | 581 | /** 582 | * Cell with attachment interface. 583 | */ 584 | export interface ISharedAttachmentsCell 585 | extends ISharedBaseCell { 586 | /** 587 | * Cell attachments 588 | */ 589 | attachments?: nbformat.IAttachments; 590 | 591 | /** 592 | * Gets the cell attachments. 593 | * 594 | * @returns The cell attachments. 595 | */ 596 | getAttachments(): nbformat.IAttachments | undefined; 597 | 598 | /** 599 | * Sets the cell attachments 600 | * 601 | * @param attachments: The cell attachments. 602 | */ 603 | setAttachments(attachments: nbformat.IAttachments | undefined): void; 604 | } 605 | 606 | /** 607 | * Implements an API for nbformat.IMarkdownCell. 608 | */ 609 | export interface ISharedMarkdownCell extends ISharedAttachmentsCell { 610 | /** 611 | * String identifying the type of cell. 612 | */ 613 | cell_type: 'markdown'; 614 | 615 | /** 616 | * Serialize the model to JSON. 617 | */ 618 | toJSON(): nbformat.IMarkdownCell; 619 | } 620 | 621 | /** 622 | * Implements an API for nbformat.IRawCell. 623 | */ 624 | export interface ISharedRawCell extends ISharedAttachmentsCell { 625 | /** 626 | * String identifying the type of cell. 627 | */ 628 | cell_type: 'raw'; 629 | 630 | /** 631 | * Serialize the model to JSON. 632 | */ 633 | toJSON(): nbformat.IRawCell; 634 | } 635 | 636 | /** 637 | * Implements an API for nbformat.IUnrecognizedCell. 638 | */ 639 | export interface ISharedUnrecognizedCell 640 | extends ISharedBaseCell { 641 | /** 642 | * The type of the cell. 643 | * 644 | * The notebook format specified the type will not be 'markdown' | 'raw' | 'code' 645 | */ 646 | cell_type: string; 647 | 648 | /** 649 | * Serialize the model to JSON. 650 | */ 651 | toJSON(): nbformat.IUnrecognizedCell; 652 | } 653 | 654 | export type StateChange = { 655 | /** 656 | * Key changed 657 | */ 658 | name: string; 659 | /** 660 | * Old value 661 | */ 662 | oldValue?: T; 663 | /** 664 | * New value 665 | */ 666 | newValue?: T; 667 | }; 668 | 669 | /** 670 | * Generic document change 671 | */ 672 | export type DocumentChange = { 673 | /** 674 | * Change occurring in the document state. 675 | */ 676 | stateChange?: StateChange[]; 677 | }; 678 | 679 | /** 680 | * The change types which occur on an observable map. 681 | */ 682 | export type MapChangeType = 683 | /** 684 | * An entry was added. 685 | */ 686 | | 'add' 687 | 688 | /** 689 | * An entry was removed. 690 | */ 691 | | 'remove' 692 | 693 | /** 694 | * An entry was changed. 695 | */ 696 | | 'change'; 697 | 698 | /** 699 | * The changed args object which is emitted by an observable map. 700 | */ 701 | export interface IMapChange { 702 | /** 703 | * The type of change undergone by the map. 704 | */ 705 | type: MapChangeType; 706 | 707 | /** 708 | * The key of the change. 709 | */ 710 | key: string; 711 | 712 | /** 713 | * The old value of the change. 714 | */ 715 | oldValue?: T; 716 | 717 | /** 718 | * The new value of the change. 719 | */ 720 | newValue?: T; 721 | } 722 | 723 | /** 724 | * Text source change 725 | */ 726 | export type SourceChange = { 727 | /** 728 | * Text source change 729 | */ 730 | sourceChange?: Delta; 731 | }; 732 | 733 | /** 734 | * Definition of the shared Notebook changes. 735 | */ 736 | export type NotebookChange = DocumentChange & { 737 | /** 738 | * Cell changes 739 | */ 740 | cellsChange?: Delta; 741 | /** 742 | * Notebook metadata changes 743 | */ 744 | metadataChange?: MapChanges; 745 | /** 746 | * nbformat version change 747 | */ 748 | nbformatChanged?: { 749 | key: string; 750 | oldValue?: number; 751 | newValue?: number; 752 | }; 753 | }; 754 | 755 | /** 756 | * File change 757 | */ 758 | export type FileChange = DocumentChange & SourceChange; 759 | 760 | /** 761 | * Definition of the shared Cell changes. 762 | */ 763 | export type CellChange = SourceChange & { 764 | /** 765 | * Cell attachment change 766 | */ 767 | attachmentsChange?: { 768 | oldValue?: nbformat.IAttachments; 769 | newValue?: nbformat.IAttachments; 770 | }; 771 | /** 772 | * Cell output changes 773 | */ 774 | outputsChange?: Delta>; 775 | /** 776 | * Cell stream output text changes 777 | */ 778 | streamOutputChange?: Delta; 779 | /** 780 | * Cell execution count change 781 | */ 782 | executionCountChange?: { 783 | oldValue?: number; 784 | newValue?: number; 785 | }; 786 | /** 787 | * Cell execution state change 788 | */ 789 | executionStateChange?: { 790 | oldValue?: IExecutionState; 791 | newValue?: IExecutionState; 792 | }; 793 | /** 794 | * Cell metadata change 795 | */ 796 | metadataChange?: MapChanges; 797 | }; 798 | -------------------------------------------------------------------------------- /javascript/src/awareness.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import type { Awareness } from 'y-protocols/awareness'; 5 | 6 | /** 7 | * The awareness interface. 8 | */ 9 | export type IAwareness = Awareness; 10 | -------------------------------------------------------------------------------- /javascript/src/index.ts: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |----------------------------------------------------------------------------*/ 5 | /** 6 | * @packageDocumentation 7 | * @module ydoc 8 | */ 9 | 10 | export * from './api.js'; 11 | export * from './utils.js'; 12 | export * from './awareness.js'; 13 | 14 | export * from './ytext.js'; 15 | export * from './ydocument.js'; 16 | export * from './yfile.js'; 17 | export * from './ynotebook.js'; 18 | export { 19 | YCellType, 20 | YBaseCell, 21 | YRawCell, 22 | YMarkdownCell, 23 | YCodeCell, 24 | createStandaloneCell 25 | } from './ycell.js'; 26 | -------------------------------------------------------------------------------- /javascript/src/utils.ts: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |----------------------------------------------------------------------------*/ 5 | 6 | import * as Y from 'yjs'; 7 | import * as models from './api.js'; 8 | 9 | export function convertYMapEventToMapChange( 10 | event: Y.YMapEvent 11 | ): models.MapChange { 12 | let changes = new Map(); 13 | event.changes.keys.forEach((event, key) => { 14 | changes.set(key, { 15 | action: event.action, 16 | oldValue: event.oldValue, 17 | newValue: this.ymeta.get(key) 18 | }); 19 | }); 20 | return changes; 21 | } 22 | 23 | /** 24 | * Creates a mutual exclude function with the following property: 25 | * 26 | * ```js 27 | * const mutex = createMutex() 28 | * mutex(() => { 29 | * // This function is immediately executed 30 | * mutex(() => { 31 | * // This function is not executed, as the mutex is already active. 32 | * }) 33 | * }) 34 | * ``` 35 | */ 36 | export const createMutex = (): ((f: () => void) => void) => { 37 | let token = true; 38 | return (f: () => void): void => { 39 | if (token) { 40 | token = false; 41 | try { 42 | f(); 43 | } finally { 44 | token = true; 45 | } 46 | } 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /javascript/src/ydocument.ts: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |----------------------------------------------------------------------------*/ 5 | 6 | import { JSONExt, JSONObject, JSONValue } from '@lumino/coreutils'; 7 | import { ISignal, Signal } from '@lumino/signaling'; 8 | import { Awareness } from 'y-protocols/awareness'; 9 | import * as Y from 'yjs'; 10 | import type { DocumentChange, ISharedDocument, StateChange } from './api.js'; 11 | 12 | /** 13 | * Generic shareable document. 14 | */ 15 | export abstract class YDocument 16 | implements ISharedDocument 17 | { 18 | constructor(options?: YDocument.IOptions) { 19 | this._ydoc = options?.ydoc ?? new Y.Doc(); 20 | 21 | this._ystate = this._ydoc.getMap('state'); 22 | 23 | this._undoManager = new Y.UndoManager([], { 24 | trackedOrigins: new Set([this]), 25 | doc: this._ydoc 26 | }); 27 | 28 | this._awareness = new Awareness(this._ydoc); 29 | 30 | this._ystate.observe(this.onStateChanged); 31 | } 32 | 33 | /** 34 | * Document version 35 | */ 36 | abstract readonly version: string; 37 | 38 | /** 39 | * YJS document. 40 | */ 41 | get ydoc(): Y.Doc { 42 | return this._ydoc; 43 | } 44 | 45 | /** 46 | * Shared state 47 | */ 48 | get ystate(): Y.Map { 49 | return this._ystate; 50 | } 51 | 52 | /** 53 | * YJS document undo manager 54 | */ 55 | get undoManager(): Y.UndoManager { 56 | return this._undoManager; 57 | } 58 | 59 | /** 60 | * Shared awareness 61 | */ 62 | get awareness(): Awareness { 63 | return this._awareness; 64 | } 65 | 66 | /** 67 | * The changed signal. 68 | */ 69 | get changed(): ISignal { 70 | return this._changed; 71 | } 72 | 73 | /** 74 | * A signal emitted when the document is disposed. 75 | */ 76 | get disposed(): ISignal { 77 | return this._disposed; 78 | } 79 | 80 | /** 81 | * Whether the document is disposed or not. 82 | */ 83 | get isDisposed(): boolean { 84 | return this._isDisposed; 85 | } 86 | 87 | /** 88 | * Document state 89 | */ 90 | get state(): JSONObject { 91 | return JSONExt.deepCopy(this.ystate.toJSON()); 92 | } 93 | 94 | /** 95 | * Whether the object can undo changes. 96 | */ 97 | canUndo(): boolean { 98 | return this.undoManager.undoStack.length > 0; 99 | } 100 | 101 | /** 102 | * Whether the object can redo changes. 103 | */ 104 | canRedo(): boolean { 105 | return this.undoManager.redoStack.length > 0; 106 | } 107 | 108 | /** 109 | * Dispose of the resources. 110 | */ 111 | dispose(): void { 112 | if (this._isDisposed) { 113 | return; 114 | } 115 | this._isDisposed = true; 116 | this.ystate.unobserve(this.onStateChanged); 117 | this.awareness.destroy(); 118 | this.undoManager.destroy(); 119 | this.ydoc.destroy(); 120 | this._disposed.emit(); 121 | Signal.clearData(this); 122 | } 123 | 124 | /** 125 | * Get the value for a state attribute 126 | * 127 | * @param key Key to get 128 | */ 129 | getState(key: string): JSONValue | undefined { 130 | const value = this.ystate.get(key); 131 | return typeof value === 'undefined' 132 | ? value 133 | : (JSONExt.deepCopy(value) as unknown as JSONValue); 134 | } 135 | 136 | /** 137 | * Set the value of a state attribute 138 | * 139 | * @param key Key to set 140 | * @param value New attribute value 141 | */ 142 | setState(key: string, value: JSONValue): void { 143 | if (!JSONExt.deepEqual(this.ystate.get(key), value)) { 144 | this.ystate.set(key, value); 145 | } 146 | } 147 | 148 | /** 149 | * Get the document source 150 | * 151 | * @returns The source 152 | */ 153 | get source(): JSONValue | string { 154 | return this.getSource(); 155 | } 156 | 157 | /** 158 | * Set the document source 159 | * 160 | * @param value The source to set 161 | */ 162 | set source(value: JSONValue | string) { 163 | this.setSource(value); 164 | } 165 | 166 | /** 167 | * Get the document source 168 | * 169 | * @returns The source 170 | */ 171 | abstract getSource(): JSONValue | string; 172 | 173 | /** 174 | * Set the document source 175 | * 176 | * @param value The source to set 177 | */ 178 | abstract setSource(value: JSONValue | string): void; 179 | 180 | /** 181 | * Undo an operation. 182 | */ 183 | undo(): void { 184 | this.undoManager.undo(); 185 | } 186 | 187 | /** 188 | * Redo an operation. 189 | */ 190 | redo(): void { 191 | this.undoManager.redo(); 192 | } 193 | 194 | /** 195 | * Clear the change stack. 196 | */ 197 | clearUndoHistory(): void { 198 | this.undoManager.clear(); 199 | } 200 | 201 | /** 202 | * Perform a transaction. While the function f is called, all changes to the shared 203 | * document are bundled into a single event. 204 | */ 205 | transact(f: () => void, undoable = true, origin: any = null): void { 206 | this.ydoc.transact(f, undoable ? this : origin); 207 | } 208 | 209 | /** 210 | * Handle a change to the ystate. 211 | */ 212 | protected onStateChanged = (event: Y.YMapEvent): void => { 213 | const stateChange = new Array>(); 214 | event.keysChanged.forEach(key => { 215 | const change = event.changes.keys.get(key); 216 | if (change) { 217 | stateChange.push({ 218 | name: key, 219 | oldValue: change.oldValue, 220 | newValue: this.ystate.get(key) 221 | }); 222 | } 223 | }); 224 | 225 | this._changed.emit({ stateChange } as any); 226 | }; 227 | 228 | protected _changed = new Signal(this); 229 | private _ydoc: Y.Doc; 230 | private _ystate: Y.Map; 231 | private _undoManager: Y.UndoManager; 232 | private _awareness: Awareness; 233 | private _isDisposed = false; 234 | private _disposed = new Signal(this); 235 | } 236 | 237 | /** 238 | * YDocument namespace 239 | */ 240 | export namespace YDocument { 241 | /** 242 | * YDocument constructor options 243 | */ 244 | export interface IOptions { 245 | /** 246 | * The optional YJS document for YDocument. 247 | */ 248 | ydoc?: Y.Doc; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /javascript/src/yfile.ts: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |----------------------------------------------------------------------------*/ 5 | 6 | import * as Y from 'yjs'; 7 | import type { Delta, FileChange, ISharedFile, ISharedText } from './api.js'; 8 | import { IYText } from './ytext.js'; 9 | import { YDocument } from './ydocument.js'; 10 | 11 | /** 12 | * Shareable text file. 13 | */ 14 | export class YFile 15 | extends YDocument 16 | implements ISharedFile, ISharedText, IYText 17 | { 18 | /** 19 | * Create a new file 20 | * 21 | * #### Notes 22 | * The document is empty and must be populated 23 | */ 24 | constructor() { 25 | super(); 26 | this.undoManager.addToScope(this.ysource); 27 | this.ysource.observe(this._modelObserver); 28 | } 29 | 30 | /** 31 | * Document version 32 | */ 33 | readonly version: string = '1.0.0'; 34 | 35 | /** 36 | * Creates a standalone YFile 37 | */ 38 | static create(): YFile { 39 | return new YFile(); 40 | } 41 | 42 | /** 43 | * YJS file text. 44 | */ 45 | readonly ysource = this.ydoc.getText('source'); 46 | 47 | /** 48 | * File text 49 | */ 50 | get source(): string { 51 | return this.getSource(); 52 | } 53 | set source(v: string) { 54 | this.setSource(v); 55 | } 56 | 57 | /** 58 | * Dispose of the resources. 59 | */ 60 | dispose(): void { 61 | if (this.isDisposed) { 62 | return; 63 | } 64 | this.ysource.unobserve(this._modelObserver); 65 | super.dispose(); 66 | } 67 | 68 | /** 69 | * Get the file text. 70 | * 71 | * @returns File text. 72 | */ 73 | getSource(): string { 74 | return this.ysource.toString(); 75 | } 76 | 77 | /** 78 | * Set the file text. 79 | * 80 | * @param value New text 81 | */ 82 | setSource(value: string): void { 83 | this.transact(() => { 84 | const ytext = this.ysource; 85 | ytext.delete(0, ytext.length); 86 | ytext.insert(0, value); 87 | }); 88 | } 89 | 90 | /** 91 | * Replace content from `start' to `end` with `value`. 92 | * 93 | * @param start: The start index of the range to replace (inclusive). 94 | * @param end: The end index of the range to replace (exclusive). 95 | * @param value: New source (optional). 96 | */ 97 | updateSource(start: number, end: number, value = ''): void { 98 | this.transact(() => { 99 | const ysource = this.ysource; 100 | // insert and then delete. 101 | // This ensures that the cursor position is adjusted after the replaced content. 102 | ysource.insert(start, value); 103 | ysource.delete(start + value.length, end - start); 104 | }); 105 | } 106 | 107 | /** 108 | * Handle a change to the ymodel. 109 | */ 110 | private _modelObserver = (event: Y.YTextEvent) => { 111 | this._changed.emit({ sourceChange: event.changes.delta as Delta }); 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /javascript/src/ynotebook.ts: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |----------------------------------------------------------------------------*/ 5 | 6 | import type * as nbformat from '@jupyterlab/nbformat'; 7 | import { JSONExt, JSONValue, PartialJSONValue } from '@lumino/coreutils'; 8 | import { ISignal, Signal } from '@lumino/signaling'; 9 | import * as Y from 'yjs'; 10 | import type { 11 | Delta, 12 | IMapChange, 13 | ISharedCell, 14 | ISharedNotebook, 15 | MapChanges, 16 | NotebookChange, 17 | SharedCell 18 | } from './api.js'; 19 | 20 | import { YDocument } from './ydocument.js'; 21 | import { 22 | createCell, 23 | createCellModelFromSharedType, 24 | YBaseCell, 25 | YCellType 26 | } from './ycell.js'; 27 | 28 | /** 29 | * Shared implementation of the Shared Document types. 30 | * 31 | * Shared cells can be inserted into a SharedNotebook. 32 | * Shared cells only start emitting events when they are connected to a SharedNotebook. 33 | * 34 | * "Standalone" cells must not be inserted into a (Shared)Notebook. 35 | * Standalone cells emit events immediately after they have been created, but they must not 36 | * be included into a (Shared)Notebook. 37 | */ 38 | export class YNotebook 39 | extends YDocument 40 | implements ISharedNotebook 41 | { 42 | /** 43 | * Create a new notebook 44 | * 45 | * #### Notes 46 | * The document is empty and must be populated 47 | * 48 | * @param options 49 | */ 50 | constructor(options: Omit = {}) { 51 | super(); 52 | this._disableDocumentWideUndoRedo = 53 | options.disableDocumentWideUndoRedo ?? false; 54 | this.cells = this._ycells.toArray().map(ycell => { 55 | if (!this._ycellMapping.has(ycell)) { 56 | this._ycellMapping.set( 57 | ycell, 58 | createCellModelFromSharedType(ycell, { notebook: this }) 59 | ); 60 | } 61 | return this._ycellMapping.get(ycell) as YCellType; 62 | }); 63 | 64 | this.undoManager.addToScope(this._ycells); 65 | this._ycells.observe(this._onYCellsChanged); 66 | this.ymeta.observeDeep(this._onMetaChanged); 67 | } 68 | 69 | /** 70 | * Document version 71 | */ 72 | readonly version: string = '2.0.0'; 73 | 74 | /** 75 | * Creates a standalone YNotebook 76 | * 77 | * Note: This method is useful when we need to initialize 78 | * the YNotebook from the JavaScript side. 79 | */ 80 | static create(options: ISharedNotebook.IOptions = {}): YNotebook { 81 | const ynotebook = new YNotebook({ 82 | disableDocumentWideUndoRedo: options.disableDocumentWideUndoRedo ?? false 83 | }); 84 | 85 | const data: nbformat.INotebookContent = { 86 | cells: options.data?.cells ?? [], 87 | nbformat: options.data?.nbformat ?? 4, 88 | nbformat_minor: options.data?.nbformat_minor ?? 5, 89 | metadata: options.data?.metadata ?? {} 90 | }; 91 | 92 | ynotebook.fromJSON(data); 93 | return ynotebook; 94 | } 95 | 96 | /** 97 | * YJS map for the notebook metadata 98 | */ 99 | readonly ymeta: Y.Map = this.ydoc.getMap('meta'); 100 | /** 101 | * Cells list 102 | */ 103 | readonly cells: YCellType[]; 104 | 105 | /** 106 | * Wether the undo/redo logic should be 107 | * considered on the full document across all cells. 108 | * 109 | * Default: false 110 | */ 111 | get disableDocumentWideUndoRedo(): boolean { 112 | return this._disableDocumentWideUndoRedo; 113 | } 114 | 115 | /** 116 | * Notebook metadata 117 | */ 118 | get metadata(): nbformat.INotebookMetadata { 119 | return this.getMetadata(); 120 | } 121 | set metadata(v: nbformat.INotebookMetadata) { 122 | this.setMetadata(v); 123 | } 124 | 125 | /** 126 | * Signal triggered when a metadata changes. 127 | */ 128 | get metadataChanged(): ISignal { 129 | return this._metadataChanged; 130 | } 131 | 132 | /** 133 | * nbformat major version 134 | */ 135 | get nbformat(): number { 136 | return this.ymeta.get('nbformat'); 137 | } 138 | set nbformat(value: number) { 139 | this.transact(() => { 140 | this.ymeta.set('nbformat', value); 141 | }, false); 142 | } 143 | 144 | /** 145 | * nbformat minor version 146 | */ 147 | get nbformat_minor(): number { 148 | return this.ymeta.get('nbformat_minor'); 149 | } 150 | set nbformat_minor(value: number) { 151 | this.transact(() => { 152 | this.ymeta.set('nbformat_minor', value); 153 | }, false); 154 | } 155 | 156 | /** 157 | * Dispose of the resources. 158 | */ 159 | dispose(): void { 160 | if (this.isDisposed) { 161 | return; 162 | } 163 | this._ycells.unobserve(this._onYCellsChanged); 164 | this.ymeta.unobserveDeep(this._onMetaChanged); 165 | super.dispose(); 166 | } 167 | 168 | /** 169 | * Get a shared cell by index. 170 | * 171 | * @param index: Cell's position. 172 | * 173 | * @returns The requested shared cell. 174 | */ 175 | getCell(index: number): YCellType { 176 | return this.cells[index]; 177 | } 178 | 179 | /** 180 | * Add a shared cell at the notebook bottom. 181 | * 182 | * @param cell Cell to add. 183 | * 184 | * @returns The added cell. 185 | */ 186 | addCell(cell: SharedCell.Cell): YBaseCell { 187 | return this.insertCell(this._ycells.length, cell); 188 | } 189 | 190 | /** 191 | * Insert a shared cell into a specific position. 192 | * 193 | * @param index: Cell's position. 194 | * @param cell: Cell to insert. 195 | * 196 | * @returns The inserted cell. 197 | */ 198 | insertCell( 199 | index: number, 200 | cell: SharedCell.Cell 201 | ): YBaseCell { 202 | return this.insertCells(index, [cell])[0]; 203 | } 204 | 205 | /** 206 | * Insert a list of shared cells into a specific position. 207 | * 208 | * @param index: Position to insert the cells. 209 | * @param cells: Array of shared cells to insert. 210 | * 211 | * @returns The inserted cells. 212 | */ 213 | insertCells( 214 | index: number, 215 | cells: SharedCell.Cell[] 216 | ): YBaseCell[] { 217 | const yCells = cells.map(c => { 218 | const cell = createCell(c, this); 219 | this._ycellMapping.set(cell.ymodel, cell); 220 | return cell; 221 | }); 222 | 223 | this.transact(() => { 224 | this._ycells.insert( 225 | index, 226 | yCells.map(cell => cell.ymodel) 227 | ); 228 | }); 229 | 230 | return yCells; 231 | } 232 | 233 | /** 234 | * Move a cell. 235 | * 236 | * @param fromIndex: Index of the cell to move. 237 | * @param toIndex: New position of the cell. 238 | */ 239 | moveCell(fromIndex: number, toIndex: number): void { 240 | this.moveCells(fromIndex, toIndex); 241 | } 242 | 243 | /** 244 | * Move cells. 245 | * 246 | * @param fromIndex: Index of the first cells to move. 247 | * @param toIndex: New position of the first cell (in the current array). 248 | * @param n: Number of cells to move (default 1) 249 | */ 250 | moveCells(fromIndex: number, toIndex: number, n = 1): void { 251 | // FIXME we need to use yjs move feature to preserve undo history 252 | const clones = new Array(n) 253 | .fill(true) 254 | .map((_, idx) => this.getCell(fromIndex + idx).toJSON()); 255 | this.transact(() => { 256 | this._ycells.delete(fromIndex, n); 257 | this._ycells.insert( 258 | fromIndex > toIndex ? toIndex : toIndex - n + 1, 259 | clones.map(clone => createCell(clone, this).ymodel) 260 | ); 261 | }); 262 | } 263 | 264 | /** 265 | * Remove a cell. 266 | * 267 | * @param index: Index of the cell to remove. 268 | */ 269 | deleteCell(index: number): void { 270 | this.deleteCellRange(index, index + 1); 271 | } 272 | 273 | /** 274 | * Remove a range of cells. 275 | * 276 | * @param from: The start index of the range to remove (inclusive). 277 | * @param to: The end index of the range to remove (exclusive). 278 | */ 279 | deleteCellRange(from: number, to: number): void { 280 | // Cells will be removed from the mapping in the model event listener. 281 | this.transact(() => { 282 | this._ycells.delete(from, to - from); 283 | }); 284 | } 285 | 286 | /** 287 | * Delete a metadata notebook. 288 | * 289 | * @param key The key to delete 290 | */ 291 | deleteMetadata(key: string): void { 292 | if (typeof this.getMetadata(key) === 'undefined') { 293 | return; 294 | } 295 | 296 | const allMetadata = this.metadata; 297 | delete allMetadata[key]; 298 | this.setMetadata(allMetadata); 299 | } 300 | 301 | /** 302 | * Returns some metadata associated with the notebook. 303 | * 304 | * If no `key` is provided, it will return all metadata. 305 | * Else it will return the value for that key. 306 | * 307 | * @param key Key to get from the metadata 308 | * @returns Notebook's metadata. 309 | */ 310 | getMetadata(): nbformat.INotebookMetadata; 311 | getMetadata(key: string): PartialJSONValue | undefined; 312 | getMetadata( 313 | key?: string 314 | ): nbformat.INotebookMetadata | PartialJSONValue | undefined { 315 | const ymetadata: Y.Map | undefined = this.ymeta.get('metadata'); 316 | 317 | // Transiently the metadata can be missing - like during destruction 318 | if (ymetadata === undefined) { 319 | return undefined; 320 | } 321 | 322 | if (typeof key === 'string') { 323 | const value = ymetadata.get(key); 324 | return typeof value === 'undefined' 325 | ? undefined // undefined is converted to `{}` by `JSONExt.deepCopy` 326 | : JSONExt.deepCopy(value); 327 | } else { 328 | return JSONExt.deepCopy(ymetadata.toJSON()); 329 | } 330 | } 331 | 332 | /** 333 | * Sets some metadata associated with the notebook. 334 | * 335 | * If only one argument is provided, it will override all notebook metadata. 336 | * Otherwise a single key will be set to a new value. 337 | * 338 | * @param metadata All Notebook's metadata or the key to set. 339 | * @param value New metadata value 340 | */ 341 | setMetadata(metadata: nbformat.INotebookMetadata): void; 342 | setMetadata(metadata: string, value: PartialJSONValue): void; 343 | setMetadata( 344 | metadata: nbformat.INotebookMetadata | string, 345 | value?: PartialJSONValue 346 | ): void { 347 | if (typeof metadata === 'string') { 348 | if (typeof value === 'undefined') { 349 | throw new TypeError( 350 | `Metadata value for ${metadata} cannot be 'undefined'; use deleteMetadata.` 351 | ); 352 | } 353 | 354 | if (JSONExt.deepEqual(this.getMetadata(metadata) ?? null, value)) { 355 | return; 356 | } 357 | 358 | const update: Partial = {}; 359 | update[metadata] = value; 360 | this.updateMetadata(update); 361 | } else { 362 | if (!this.metadata || !JSONExt.deepEqual(this.metadata, metadata)) { 363 | const clone = JSONExt.deepCopy(metadata); 364 | const ymetadata: Y.Map = this.ymeta.get('metadata'); 365 | 366 | // Transiently the metadata can be missing - like during destruction 367 | if (ymetadata === undefined) { 368 | return undefined; 369 | } 370 | 371 | this.transact(() => { 372 | ymetadata.clear(); 373 | for (const [key, value] of Object.entries(clone)) { 374 | ymetadata.set(key, value); 375 | } 376 | }); 377 | } 378 | } 379 | } 380 | 381 | /** 382 | * Updates the metadata associated with the notebook. 383 | * 384 | * @param value: Metadata's attribute to update. 385 | */ 386 | updateMetadata(value: Partial): void { 387 | // TODO: Maybe modify only attributes instead of replacing the whole metadata? 388 | const clone = JSONExt.deepCopy(value); 389 | const ymetadata: Y.Map = this.ymeta.get('metadata'); 390 | 391 | // Transiently the metadata can be missing - like during destruction 392 | if (ymetadata === undefined) { 393 | return undefined; 394 | } 395 | 396 | this.transact(() => { 397 | for (const [key, value] of Object.entries(clone)) { 398 | ymetadata.set(key, value); 399 | } 400 | }); 401 | } 402 | 403 | /** 404 | * Get the notebook source 405 | * 406 | * @returns The notebook 407 | */ 408 | getSource(): JSONValue { 409 | return this.toJSON() as JSONValue; 410 | } 411 | 412 | /** 413 | * Set the notebook source 414 | * 415 | * @param value The notebook 416 | */ 417 | setSource(value: JSONValue): void { 418 | this.fromJSON(value as nbformat.INotebookContent); 419 | } 420 | 421 | /** 422 | * Override the notebook with a JSON-serialized document. 423 | * 424 | * @param value The notebook 425 | */ 426 | fromJSON(value: nbformat.INotebookContent): void { 427 | this.transact(() => { 428 | this.nbformat = value.nbformat; 429 | this.nbformat_minor = value.nbformat_minor; 430 | 431 | const metadata = value.metadata; 432 | if (metadata['orig_nbformat'] !== undefined) { 433 | delete metadata['orig_nbformat']; 434 | } 435 | 436 | if (!this.metadata) { 437 | const ymetadata = new Y.Map(); 438 | for (const [key, value] of Object.entries(metadata)) { 439 | ymetadata.set(key, value); 440 | } 441 | this.ymeta.set('metadata', ymetadata); 442 | } else { 443 | this.metadata = metadata; 444 | } 445 | 446 | const useId = value.nbformat === 4 && value.nbformat_minor >= 5; 447 | const ycells = value.cells.map(cell => { 448 | if (!useId) { 449 | delete cell.id; 450 | } 451 | return cell; 452 | }); 453 | this.insertCells(this.cells.length, ycells); 454 | this.deleteCellRange(0, this.cells.length); 455 | }); 456 | } 457 | 458 | /** 459 | * Serialize the model to JSON. 460 | */ 461 | toJSON(): nbformat.INotebookContent { 462 | // strip cell ids if we have notebook format 4.0-4.4 463 | const pruneCellId = this.nbformat === 4 && this.nbformat_minor <= 4; 464 | 465 | return { 466 | metadata: this.metadata, 467 | nbformat_minor: this.nbformat_minor, 468 | nbformat: this.nbformat, 469 | cells: this.cells.map(c => { 470 | const raw = c.toJSON(); 471 | if (pruneCellId) { 472 | delete raw.id; 473 | } 474 | return raw; 475 | }) 476 | }; 477 | } 478 | 479 | /** 480 | * Handle a change to the ystate. 481 | */ 482 | private _onMetaChanged = (events: Y.YEvent[]) => { 483 | const metadataEvents = events.find( 484 | event => event.target === this.ymeta.get('metadata') 485 | ); 486 | 487 | if (metadataEvents) { 488 | const metadataChange = metadataEvents.changes.keys; 489 | const ymetadata = this.ymeta.get('metadata') as Y.Map; 490 | metadataEvents.changes.keys.forEach((change, key) => { 491 | switch (change.action) { 492 | case 'add': 493 | this._metadataChanged.emit({ 494 | key, 495 | type: 'add', 496 | newValue: ymetadata.get(key) 497 | }); 498 | break; 499 | case 'delete': 500 | this._metadataChanged.emit({ 501 | key, 502 | type: 'remove', 503 | oldValue: change.oldValue 504 | }); 505 | break; 506 | case 'update': 507 | { 508 | const newValue = ymetadata.get(key); 509 | const oldValue = change.oldValue; 510 | let equal = true; 511 | if (typeof oldValue == 'object' && typeof newValue == 'object') { 512 | equal = JSONExt.deepEqual(oldValue, newValue); 513 | } else { 514 | equal = oldValue === newValue; 515 | } 516 | 517 | if (!equal) { 518 | this._metadataChanged.emit({ 519 | key, 520 | type: 'change', 521 | oldValue, 522 | newValue 523 | }); 524 | } 525 | } 526 | break; 527 | } 528 | }); 529 | 530 | this._changed.emit({ metadataChange }); 531 | } 532 | 533 | const metaEvent = events.find(event => event.target === this.ymeta) as 534 | | undefined 535 | | Y.YMapEvent; 536 | 537 | if (!metaEvent) { 538 | return; 539 | } 540 | 541 | if (metaEvent.keysChanged.has('metadata')) { 542 | // Handle metadata change when adding/removing the YMap 543 | const change = metaEvent.changes.keys.get('metadata'); 544 | if (change?.action === 'add' && !change.oldValue) { 545 | const metadataChange: MapChanges = new Map(); 546 | for (const key of Object.keys(this.metadata)) { 547 | metadataChange.set(key, { 548 | action: 'add', 549 | oldValue: undefined 550 | }); 551 | this._metadataChanged.emit({ 552 | key, 553 | type: 'add', 554 | newValue: this.getMetadata(key) 555 | }); 556 | } 557 | this._changed.emit({ metadataChange }); 558 | } 559 | } 560 | 561 | if (metaEvent.keysChanged.has('nbformat')) { 562 | const change = metaEvent.changes.keys.get('nbformat'); 563 | const nbformatChanged = { 564 | key: 'nbformat', 565 | oldValue: change?.oldValue ? change!.oldValue : undefined, 566 | newValue: this.nbformat 567 | }; 568 | this._changed.emit({ nbformatChanged }); 569 | } 570 | 571 | if (metaEvent.keysChanged.has('nbformat_minor')) { 572 | const change = metaEvent.changes.keys.get('nbformat_minor'); 573 | const nbformatChanged = { 574 | key: 'nbformat_minor', 575 | oldValue: change?.oldValue ? change!.oldValue : undefined, 576 | newValue: this.nbformat_minor 577 | }; 578 | this._changed.emit({ nbformatChanged }); 579 | } 580 | }; 581 | 582 | /** 583 | * Handle a change to the list of cells. 584 | */ 585 | private _onYCellsChanged = (event: Y.YArrayEvent>) => { 586 | // update the type cell mapping by iterating through the added/removed types 587 | event.changes.added.forEach(item => { 588 | const type = (item.content as Y.ContentType).type as Y.Map; 589 | if (!this._ycellMapping.has(type)) { 590 | const c = createCellModelFromSharedType(type, { notebook: this }); 591 | this._ycellMapping.set(type, c); 592 | } 593 | }); 594 | event.changes.deleted.forEach(item => { 595 | const type = (item.content as Y.ContentType).type as Y.Map; 596 | const model = this._ycellMapping.get(type); 597 | if (model) { 598 | model.dispose(); 599 | this._ycellMapping.delete(type); 600 | } 601 | }); 602 | let index = 0; 603 | 604 | // this reflects the event.changes.delta, but replaces the content of delta.insert with ycells 605 | const cellsChange: Delta = []; 606 | event.changes.delta.forEach((d: any) => { 607 | if (d.insert != null) { 608 | const insertedCells = d.insert.map((ycell: Y.Map) => 609 | this._ycellMapping.get(ycell) 610 | ); 611 | cellsChange.push({ insert: insertedCells }); 612 | this.cells.splice(index, 0, ...insertedCells); 613 | 614 | index += d.insert.length; 615 | } else if (d.delete != null) { 616 | cellsChange.push(d); 617 | this.cells.splice(index, d.delete); 618 | } else if (d.retain != null) { 619 | cellsChange.push(d); 620 | index += d.retain; 621 | } 622 | }); 623 | 624 | this._changed.emit({ 625 | cellsChange: cellsChange 626 | }); 627 | }; 628 | 629 | protected _metadataChanged = new Signal(this); 630 | /** 631 | * Internal Yjs cells list 632 | */ 633 | protected readonly _ycells: Y.Array> = this.ydoc.getArray('cells'); 634 | 635 | private _disableDocumentWideUndoRedo: boolean; 636 | private _ycellMapping: WeakMap, YCellType> = new WeakMap(); 637 | } 638 | -------------------------------------------------------------------------------- /javascript/src/ytext.ts: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |----------------------------------------------------------------------------*/ 5 | 6 | import { Awareness } from 'y-protocols/awareness'; 7 | import * as Y from 'yjs'; 8 | import type { ISharedText } from './api.js'; 9 | 10 | /** 11 | * Abstract interface to define Shared Models that can be bound to a text editor using any existing 12 | * Yjs-based editor binding. 13 | */ 14 | export interface IYText extends ISharedText { 15 | /** 16 | * Shareable text 17 | */ 18 | readonly ysource: Y.Text; 19 | /** 20 | * Shareable awareness 21 | */ 22 | readonly awareness: Awareness | null; 23 | /** 24 | * Undo manager 25 | */ 26 | readonly undoManager: Y.UndoManager | null; 27 | } 28 | -------------------------------------------------------------------------------- /javascript/test/ycell.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | // Copyright (c) Jupyter Development Team. 3 | // Distributed under the terms of the Modified BSD License. 4 | 5 | import { createStandaloneCell, IMapChange, YCodeCell, YNotebook } from '../src'; 6 | 7 | describe('@jupyter/ydoc', () => { 8 | // Fix awareness timeout open handle 9 | beforeEach(() => { 10 | jest.useFakeTimers(); 11 | }); 12 | afterEach(() => { 13 | jest.clearAllTimers(); 14 | }); 15 | 16 | describe('createStandaloneCell', () => { 17 | test('should convert a source set as a list of string to a single string', () => { 18 | const cell = createStandaloneCell({ 19 | cell_type: 'code', 20 | source: ['import this\n', 'import math'] 21 | }); 22 | 23 | expect(cell.source).toEqual('import this\nimport math'); 24 | }); 25 | }); 26 | 27 | describe('YCell standalone', () => { 28 | test('should set source', () => { 29 | const codeCell = YCodeCell.create(); 30 | codeCell.setSource('test'); 31 | expect(codeCell.getSource()).toBe('test'); 32 | codeCell.dispose(); 33 | }); 34 | 35 | test('should update source', () => { 36 | const codeCell = YCodeCell.create(); 37 | codeCell.setSource('test'); 38 | codeCell.updateSource(0, 0, 'hello'); 39 | expect(codeCell.getSource()).toBe('hellotest'); 40 | codeCell.dispose(); 41 | }); 42 | 43 | test('should get metadata', () => { 44 | const cell = YCodeCell.create(); 45 | const metadata = { 46 | collapsed: true, 47 | editable: false, 48 | name: 'cell-name' 49 | }; 50 | 51 | cell.setMetadata(metadata); 52 | 53 | expect(cell.metadata).toEqual({ 54 | ...metadata, 55 | jupyter: { outputs_hidden: true } 56 | }); 57 | cell.dispose(); 58 | }); 59 | 60 | test('should get all metadata', () => { 61 | const cell = YCodeCell.create(); 62 | const metadata = { 63 | jupyter: { outputs_hidden: true }, 64 | editable: false, 65 | name: 'cell-name' 66 | }; 67 | 68 | cell.setMetadata(metadata); 69 | 70 | expect(cell.getMetadata()).toEqual({ ...metadata, collapsed: true }); 71 | cell.dispose(); 72 | }); 73 | 74 | test('should get one metadata', () => { 75 | const cell = YCodeCell.create(); 76 | const metadata = { 77 | collapsed: true, 78 | editable: false, 79 | name: 'cell-name' 80 | }; 81 | 82 | cell.setMetadata(metadata); 83 | 84 | expect(cell.getMetadata('editable')).toEqual(metadata.editable); 85 | cell.dispose(); 86 | }); 87 | 88 | it.each([null, undefined, 1, true, 'string', { a: 1 }, [1, 2]])( 89 | 'should get single metadata %s', 90 | value => { 91 | const cell = YCodeCell.create(); 92 | const metadata = { 93 | collapsed: true, 94 | editable: false, 95 | name: 'cell-name', 96 | test: value 97 | }; 98 | 99 | cell.setMetadata(metadata); 100 | 101 | expect(cell.getMetadata('test')).toEqual(value); 102 | cell.dispose(); 103 | } 104 | ); 105 | 106 | test('should set one metadata', () => { 107 | const cell = YCodeCell.create(); 108 | const metadata = { 109 | collapsed: true, 110 | editable: false, 111 | name: 'cell-name' 112 | }; 113 | 114 | cell.setMetadata(metadata); 115 | cell.setMetadata('test', 'banana'); 116 | 117 | expect(cell.getMetadata('test')).toEqual('banana'); 118 | cell.dispose(); 119 | }); 120 | 121 | test('should emit all metadata changes', () => { 122 | const notebook = YNotebook.create(); 123 | 124 | const metadata = { 125 | collapsed: true, 126 | editable: false, 127 | name: 'cell-name' 128 | }; 129 | 130 | const changes: IMapChange[] = []; 131 | notebook.metadataChanged.connect((_, c) => { 132 | changes.push(c); 133 | }); 134 | notebook.metadata = metadata; 135 | 136 | expect(changes).toHaveLength(3); 137 | expect(changes).toEqual([ 138 | { 139 | type: 'add', 140 | key: 'collapsed', 141 | newValue: metadata.collapsed, 142 | oldValue: undefined 143 | }, 144 | { 145 | type: 'add', 146 | key: 'editable', 147 | newValue: metadata.editable, 148 | oldValue: undefined 149 | }, 150 | { 151 | type: 'add', 152 | key: 'name', 153 | newValue: metadata.name, 154 | oldValue: undefined 155 | } 156 | ]); 157 | 158 | notebook.dispose(); 159 | }); 160 | 161 | test('should emit a add metadata change', () => { 162 | const cell = YCodeCell.create(); 163 | const metadata = { 164 | collapsed: true, 165 | editable: false, 166 | name: 'cell-name' 167 | }; 168 | cell.metadata = metadata; 169 | 170 | const changes: IMapChange[] = []; 171 | cell.metadataChanged.connect((_, c) => { 172 | changes.push(c); 173 | }); 174 | cell.setMetadata('test', 'banana'); 175 | 176 | try { 177 | expect(changes).toHaveLength(1); 178 | expect(changes).toEqual([ 179 | { type: 'add', key: 'test', newValue: 'banana', oldValue: undefined } 180 | ]); 181 | } finally { 182 | cell.dispose(); 183 | } 184 | }); 185 | 186 | test('should emit a delete metadata change', () => { 187 | const cell = YCodeCell.create(); 188 | const metadata = { 189 | collapsed: true, 190 | editable: false, 191 | name: 'cell-name' 192 | }; 193 | cell.metadata = metadata; 194 | 195 | const changes: IMapChange[] = []; 196 | cell.setMetadata('test', 'banana'); 197 | 198 | cell.metadataChanged.connect((_, c) => { 199 | changes.push(c); 200 | }); 201 | cell.deleteMetadata('test'); 202 | 203 | try { 204 | expect(changes).toHaveLength(1); 205 | expect(changes).toEqual([ 206 | { 207 | type: 'remove', 208 | key: 'test', 209 | newValue: undefined, 210 | oldValue: 'banana' 211 | } 212 | ]); 213 | } finally { 214 | cell.dispose(); 215 | } 216 | }); 217 | 218 | test('should emit an update metadata change', () => { 219 | const cell = YCodeCell.create(); 220 | const metadata = { 221 | collapsed: true, 222 | editable: false, 223 | name: 'cell-name' 224 | }; 225 | cell.metadata = metadata; 226 | 227 | const changes: IMapChange[] = []; 228 | cell.setMetadata('test', 'banana'); 229 | 230 | cell.metadataChanged.connect((_, c) => { 231 | changes.push(c); 232 | }); 233 | cell.setMetadata('test', 'orange'); 234 | 235 | try { 236 | expect(changes).toHaveLength(1); 237 | expect(changes).toEqual([ 238 | { 239 | type: 'change', 240 | key: 'test', 241 | newValue: 'orange', 242 | oldValue: 'banana' 243 | } 244 | ]); 245 | } finally { 246 | cell.dispose(); 247 | } 248 | }); 249 | }); 250 | 251 | describe('#undo', () => { 252 | test('should undo source change', () => { 253 | const codeCell = YCodeCell.create(); 254 | codeCell.setSource('test'); 255 | codeCell.undoManager?.stopCapturing(); 256 | codeCell.updateSource(0, 0, 'hello'); 257 | 258 | codeCell.undo(); 259 | 260 | expect(codeCell.getSource()).toEqual('test'); 261 | }); 262 | 263 | test('should not undo execution count change', () => { 264 | const codeCell = YCodeCell.create(); 265 | codeCell.setSource('test'); 266 | codeCell.undoManager?.stopCapturing(); 267 | codeCell.execution_count = 22; 268 | codeCell.undoManager?.stopCapturing(); 269 | codeCell.execution_count = 42; 270 | 271 | codeCell.undo(); 272 | 273 | expect(codeCell.execution_count).toEqual(42); 274 | }); 275 | 276 | test('should not undo output change', () => { 277 | const codeCell = YCodeCell.create(); 278 | codeCell.setSource('test'); 279 | codeCell.undoManager?.stopCapturing(); 280 | const outputs = [ 281 | { 282 | data: { 283 | 'application/geo+json': { 284 | geometry: { 285 | coordinates: [-118.4563712, 34.0163116], 286 | type: 'Point' 287 | }, 288 | type: 'Feature' 289 | }, 290 | 'text/plain': [''] 291 | }, 292 | metadata: { 293 | 'application/geo+json': { 294 | expanded: false 295 | } 296 | }, 297 | output_type: 'display_data' 298 | }, 299 | { 300 | data: { 301 | 'application/vnd.jupyter.widget-view+json': { 302 | model_id: '4619c172d65e496baa5d1230894b535a', 303 | version_major: 2, 304 | version_minor: 0 305 | }, 306 | 'text/plain': [ 307 | "HBox(children=(Text(value='text input', layout=Layout(border='1px dashed red', width='80px')), Button(descript…" 308 | ] 309 | }, 310 | metadata: {}, 311 | output_type: 'display_data' 312 | } 313 | ]; 314 | codeCell.setOutputs(outputs); 315 | codeCell.undoManager?.stopCapturing(); 316 | 317 | codeCell.undo(); 318 | 319 | expect(codeCell.getOutputs()).toEqual(outputs); 320 | }); 321 | 322 | test('should not undo metadata change', () => { 323 | const codeCell = YCodeCell.create(); 324 | codeCell.setSource('test'); 325 | codeCell.undoManager?.stopCapturing(); 326 | codeCell.setMetadata({ collapsed: false }); 327 | codeCell.undoManager?.stopCapturing(); 328 | codeCell.setMetadata({ collapsed: true }); 329 | 330 | codeCell.undo(); 331 | 332 | expect(codeCell.getMetadata('collapsed')).toEqual(true); 333 | }); 334 | }); 335 | }); 336 | -------------------------------------------------------------------------------- /javascript/test/yfile.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { Delta, YFile } from '../src'; 5 | 6 | describe('@jupyter/ydoc', () => { 7 | describe('YFile', () => { 8 | describe('#constructor', () => { 9 | test('should create a document without arguments', () => { 10 | const file = new YFile(); 11 | expect(file.source.length).toBe(0); 12 | file.dispose(); 13 | }); 14 | }); 15 | 16 | describe('#disposed', () => { 17 | test('should be emitted when the document is disposed', () => { 18 | const file = new YFile(); 19 | let disposed = false; 20 | file.disposed.connect(() => { 21 | disposed = true; 22 | }); 23 | file.dispose(); 24 | expect(disposed).toEqual(true); 25 | }); 26 | }); 27 | 28 | describe('source', () => { 29 | test('should set source', () => { 30 | const file = new YFile(); 31 | 32 | const source = 'foo'; 33 | file.setSource(source); 34 | 35 | expect(file.source).toEqual(source); 36 | file.dispose(); 37 | }); 38 | 39 | test('should get source', () => { 40 | const file = new YFile(); 41 | 42 | const source = 'foo'; 43 | file.setSource(source); 44 | 45 | expect(file.getSource()).toEqual(source); 46 | file.dispose(); 47 | }); 48 | 49 | test('should update source', () => { 50 | const file = new YFile(); 51 | 52 | const source = 'fooo bar'; 53 | file.setSource(source); 54 | expect(file.source).toBe(source); 55 | 56 | file.updateSource(3, 5, '/'); 57 | expect(file.source).toBe('foo/bar'); 58 | file.dispose(); 59 | }); 60 | 61 | test('should emit an insert source change', () => { 62 | const file = new YFile(); 63 | expect(file.source).toBe(''); 64 | 65 | const changes: Delta[] = []; 66 | file.changed.connect((_, c) => { 67 | changes.push(c.sourceChange!); 68 | }); 69 | const source = 'foo'; 70 | file.setSource(source); 71 | 72 | expect(changes).toHaveLength(1); 73 | expect(changes).toEqual([ 74 | [ 75 | { 76 | insert: 'foo' 77 | } 78 | ] 79 | ]); 80 | 81 | file.dispose(); 82 | }); 83 | 84 | test('should emit a delete source change', () => { 85 | const file = new YFile(); 86 | const source = 'foo'; 87 | file.setSource(source); 88 | expect(file.source).toBe(source); 89 | 90 | const changes: Delta[] = []; 91 | file.changed.connect((_, c) => { 92 | changes.push(c.sourceChange!); 93 | }); 94 | file.setSource(''); 95 | 96 | expect(changes).toHaveLength(1); 97 | expect(changes).toEqual([ 98 | [ 99 | { 100 | delete: 3 101 | } 102 | ] 103 | ]); 104 | 105 | file.dispose(); 106 | }); 107 | 108 | test('should emit an update source change', () => { 109 | const file = new YFile(); 110 | const source1 = 'foo'; 111 | file.setSource(source1); 112 | expect(file.source).toBe(source1); 113 | 114 | const changes: Delta[] = []; 115 | file.changed.connect((_, c) => { 116 | changes.push(c.sourceChange!); 117 | }); 118 | const source = 'bar'; 119 | file.setSource(source); 120 | 121 | expect(changes).toHaveLength(1); 122 | expect(changes).toEqual([ 123 | [ 124 | { 125 | delete: 3 126 | }, 127 | { 128 | insert: source 129 | } 130 | ] 131 | ]); 132 | 133 | file.dispose(); 134 | }); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /javascript/test/ynotebook.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import type * as nbformat from '@jupyterlab/nbformat'; 5 | import { IMapChange, NotebookChange, YNotebook } from '../src'; 6 | 7 | describe('@jupyter/ydoc', () => { 8 | // Fix awareness timeout open handle 9 | beforeEach(() => { 10 | jest.useFakeTimers(); 11 | }); 12 | afterEach(() => { 13 | jest.clearAllTimers(); 14 | }); 15 | 16 | describe('YNotebook', () => { 17 | describe('#constructor', () => { 18 | test('should create a notebook without arguments', () => { 19 | const notebook = YNotebook.create(); 20 | expect(notebook.cells.length).toBe(0); 21 | notebook.dispose(); 22 | }); 23 | }); 24 | 25 | describe('#factory', () => { 26 | test('should create a notebook with a cell', () => { 27 | const cell: nbformat.ICodeCell = { 28 | id: 'first-cell', 29 | cell_type: 'code', 30 | source: '', 31 | metadata: {}, 32 | outputs: [], 33 | execution_count: null 34 | }; 35 | 36 | const notebook = YNotebook.create({ 37 | data: { 38 | cells: [cell] 39 | } 40 | }); 41 | expect(notebook.cells).toHaveLength(1); 42 | expect(notebook.cells[0].toJSON()).toEqual(cell); 43 | notebook.dispose(); 44 | }); 45 | 46 | test('should create a notebook with metadata', () => { 47 | const metadata: nbformat.INotebookMetadata = { 48 | kernelspec: { 49 | name: 'xeus-python', 50 | display_name: 'Xeus Python' 51 | } 52 | }; 53 | 54 | const notebook = YNotebook.create({ 55 | data: { 56 | nbformat: 1, 57 | nbformat_minor: 0, 58 | metadata 59 | } 60 | }); 61 | expect(notebook.cells).toHaveLength(0); 62 | expect(notebook.nbformat).toEqual(1); 63 | expect(notebook.nbformat_minor).toEqual(0); 64 | expect(notebook.metadata).toEqual(metadata); 65 | notebook.dispose(); 66 | }); 67 | }); 68 | 69 | describe('#disposed', () => { 70 | test('should be emitted when the document is disposed', () => { 71 | const notebook = YNotebook.create(); 72 | let disposed = false; 73 | notebook.disposed.connect(() => { 74 | disposed = true; 75 | }); 76 | notebook.dispose(); 77 | expect(disposed).toEqual(true); 78 | }); 79 | }); 80 | 81 | describe('metadata', () => { 82 | test('should get metadata', () => { 83 | const notebook = YNotebook.create(); 84 | const metadata = { 85 | orig_nbformat: 1, 86 | kernelspec: { 87 | display_name: 'python', 88 | name: 'python' 89 | } 90 | }; 91 | 92 | notebook.setMetadata(metadata); 93 | 94 | expect(notebook.metadata).toEqual(metadata); 95 | notebook.dispose(); 96 | }); 97 | 98 | test('should get all metadata', () => { 99 | const notebook = YNotebook.create(); 100 | const metadata = { 101 | orig_nbformat: 1, 102 | kernelspec: { 103 | display_name: 'python', 104 | name: 'python' 105 | } 106 | }; 107 | 108 | notebook.setMetadata(metadata); 109 | 110 | expect(notebook.getMetadata()).toEqual(metadata); 111 | notebook.dispose(); 112 | }); 113 | 114 | test('should get one metadata', () => { 115 | const notebook = YNotebook.create(); 116 | const metadata = { 117 | orig_nbformat: 1, 118 | kernelspec: { 119 | display_name: 'python', 120 | name: 'python' 121 | } 122 | }; 123 | 124 | notebook.setMetadata(metadata); 125 | 126 | expect(notebook.getMetadata('orig_nbformat')).toEqual(1); 127 | notebook.dispose(); 128 | }); 129 | 130 | test('should set one metadata', () => { 131 | const notebook = YNotebook.create(); 132 | const metadata = { 133 | orig_nbformat: 1, 134 | kernelspec: { 135 | display_name: 'python', 136 | name: 'python' 137 | } 138 | }; 139 | 140 | notebook.setMetadata(metadata); 141 | notebook.setMetadata('test', 'banana'); 142 | 143 | expect(notebook.getMetadata('test')).toEqual('banana'); 144 | notebook.dispose(); 145 | }); 146 | 147 | it.each([null, undefined, 1, true, 'string', { a: 1 }, [1, 2]])( 148 | 'should get single metadata %s', 149 | value => { 150 | const nb = YNotebook.create(); 151 | const metadata = { 152 | orig_nbformat: 1, 153 | kernelspec: { 154 | display_name: 'python', 155 | name: 'python' 156 | }, 157 | test: value 158 | }; 159 | 160 | nb.setMetadata(metadata); 161 | 162 | expect(nb.getMetadata('test')).toEqual(value); 163 | nb.dispose(); 164 | } 165 | ); 166 | 167 | test('should update metadata', () => { 168 | const notebook = YNotebook.create(); 169 | const metadata = notebook.getMetadata(); 170 | expect(metadata).toBeTruthy(); 171 | metadata.orig_nbformat = 1; 172 | metadata.kernelspec = { 173 | display_name: 'python', 174 | name: 'python' 175 | }; 176 | notebook.setMetadata(metadata); 177 | { 178 | const metadata = notebook.getMetadata(); 179 | expect(metadata.kernelspec!.name).toBe('python'); 180 | expect(metadata.orig_nbformat).toBe(1); 181 | } 182 | notebook.updateMetadata({ 183 | orig_nbformat: 2 184 | }); 185 | { 186 | const metadata = notebook.getMetadata(); 187 | expect(metadata.kernelspec!.name).toBe('python'); 188 | expect(metadata.orig_nbformat).toBe(2); 189 | } 190 | notebook.dispose(); 191 | }); 192 | 193 | test('should emit all metadata changes', () => { 194 | const notebook = YNotebook.create(); 195 | 196 | const metadata = { 197 | orig_nbformat: 1, 198 | kernelspec: { 199 | display_name: 'python', 200 | name: 'python' 201 | } 202 | }; 203 | 204 | const changes: IMapChange[] = []; 205 | notebook.metadataChanged.connect((_, c) => { 206 | changes.push(c); 207 | }); 208 | notebook.metadata = metadata; 209 | 210 | expect(changes).toHaveLength(2); 211 | expect(changes).toEqual([ 212 | { 213 | type: 'add', 214 | key: 'orig_nbformat', 215 | newValue: metadata.orig_nbformat 216 | }, 217 | { 218 | type: 'add', 219 | key: 'kernelspec', 220 | newValue: metadata.kernelspec 221 | } 222 | ]); 223 | 224 | notebook.dispose(); 225 | }); 226 | 227 | test('should emit all metadata changes on update', () => { 228 | const notebook = YNotebook.create(); 229 | 230 | const metadata = { 231 | orig_nbformat: 1, 232 | kernelspec: { 233 | display_name: 'python', 234 | name: 'python' 235 | } 236 | }; 237 | 238 | const changes: IMapChange[] = []; 239 | notebook.metadataChanged.connect((_, c) => { 240 | changes.push(c); 241 | }); 242 | notebook.updateMetadata(metadata); 243 | 244 | expect(changes).toHaveLength(2); 245 | expect(changes).toEqual([ 246 | { 247 | type: 'add', 248 | key: 'orig_nbformat', 249 | newValue: metadata.orig_nbformat 250 | }, 251 | { 252 | type: 'add', 253 | key: 'kernelspec', 254 | newValue: metadata.kernelspec 255 | } 256 | ]); 257 | 258 | notebook.dispose(); 259 | }); 260 | 261 | test('should emit a add metadata change', () => { 262 | const notebook = YNotebook.create(); 263 | const metadata = { 264 | orig_nbformat: 1, 265 | kernelspec: { 266 | display_name: 'python', 267 | name: 'python' 268 | } 269 | }; 270 | notebook.metadata = metadata; 271 | 272 | const changes: IMapChange[] = []; 273 | notebook.metadataChanged.connect((_, c) => { 274 | changes.push(c); 275 | }); 276 | notebook.setMetadata('test', 'banana'); 277 | 278 | expect(changes).toHaveLength(1); 279 | expect(changes).toEqual([ 280 | { type: 'add', key: 'test', newValue: 'banana', oldValue: undefined } 281 | ]); 282 | 283 | notebook.dispose(); 284 | }); 285 | 286 | test('should emit a delete metadata change', () => { 287 | const notebook = YNotebook.create(); 288 | const metadata = { 289 | orig_nbformat: 1, 290 | kernelspec: { 291 | display_name: 'python', 292 | name: 'python' 293 | } 294 | }; 295 | notebook.metadata = metadata; 296 | 297 | const changes: IMapChange[] = []; 298 | notebook.setMetadata('test', 'banana'); 299 | 300 | notebook.metadataChanged.connect((_, c) => { 301 | changes.push(c); 302 | }); 303 | notebook.deleteMetadata('test'); 304 | 305 | expect(changes).toHaveLength(1); 306 | expect(changes).toEqual([ 307 | { 308 | type: 'remove', 309 | key: 'test', 310 | newValue: undefined, 311 | oldValue: 'banana' 312 | } 313 | ]); 314 | 315 | notebook.dispose(); 316 | }); 317 | 318 | test('should emit an update metadata change', () => { 319 | const notebook = YNotebook.create(); 320 | const metadata = { 321 | orig_nbformat: 1, 322 | kernelspec: { 323 | display_name: 'python', 324 | name: 'python' 325 | } 326 | }; 327 | notebook.metadata = metadata; 328 | 329 | const changes: IMapChange[] = []; 330 | notebook.setMetadata('test', 'banana'); 331 | 332 | notebook.metadataChanged.connect((_, c) => { 333 | changes.push(c); 334 | }); 335 | notebook.setMetadata('test', 'orange'); 336 | 337 | expect(changes).toHaveLength(1); 338 | expect(changes).toEqual([ 339 | { 340 | type: 'change', 341 | key: 'test', 342 | newValue: 'orange', 343 | oldValue: 'banana' 344 | } 345 | ]); 346 | 347 | notebook.dispose(); 348 | }); 349 | }); 350 | 351 | describe('#insertCell', () => { 352 | test('should insert a cell', () => { 353 | const notebook = YNotebook.create(); 354 | notebook.insertCell(0, { cell_type: 'code' }); 355 | expect(notebook.cells.length).toBe(1); 356 | notebook.dispose(); 357 | }); 358 | test('should set cell source', () => { 359 | const notebook = YNotebook.create(); 360 | const codeCell = notebook.insertCell(0, { cell_type: 'code' }); 361 | codeCell.setSource('test'); 362 | expect(notebook.cells[0].getSource()).toBe('test'); 363 | notebook.dispose(); 364 | }); 365 | test('should update source', () => { 366 | const notebook = YNotebook.create(); 367 | const codeCell = notebook.insertCell(0, { cell_type: 'code' }); 368 | codeCell.setSource('test'); 369 | codeCell.updateSource(0, 0, 'hello'); 370 | expect(codeCell.getSource()).toBe('hellotest'); 371 | notebook.dispose(); 372 | }); 373 | 374 | test('should emit a add cells change', () => { 375 | const notebook = YNotebook.create(); 376 | const changes: NotebookChange[] = []; 377 | notebook.changed.connect((_, c) => { 378 | changes.push(c); 379 | }); 380 | const codeCell = notebook.insertCell(0, { cell_type: 'code' }); 381 | 382 | expect(changes).toHaveLength(1); 383 | expect(changes[0].cellsChange).toEqual([ 384 | { 385 | insert: [codeCell] 386 | } 387 | ]); 388 | notebook.dispose(); 389 | }); 390 | }); 391 | 392 | describe('#deleteCell', () => { 393 | test('should emit a delete cells change', () => { 394 | const notebook = YNotebook.create(); 395 | const changes: NotebookChange[] = []; 396 | const codeCell = notebook.insertCell(0, { cell_type: 'code' }); 397 | 398 | notebook.changed.connect((_, c) => { 399 | changes.push(c); 400 | }); 401 | notebook.deleteCell(0); 402 | 403 | expect(changes).toHaveLength(1); 404 | expect(codeCell.isDisposed).toEqual(true); 405 | expect(changes[0].cellsChange).toEqual([{ delete: 1 }]); 406 | notebook.dispose(); 407 | }); 408 | }); 409 | 410 | describe('#moveCell', () => { 411 | test('should emit add and delete cells changes when moving a cell', () => { 412 | const notebook = YNotebook.create(); 413 | const changes: NotebookChange[] = []; 414 | const codeCell = notebook.addCell({ cell_type: 'code' }); 415 | notebook.addCell({ cell_type: 'markdown' }); 416 | const raw = codeCell.toJSON(); 417 | notebook.changed.connect((_, c) => { 418 | changes.push(c); 419 | }); 420 | notebook.moveCell(0, 1); 421 | 422 | expect(notebook.getCell(1)).not.toEqual(codeCell); 423 | expect(notebook.getCell(1).toJSON()).toEqual(raw); 424 | expect(changes[0].cellsChange).toHaveLength(3); 425 | expect(changes[0].cellsChange).toEqual([ 426 | { delete: 1 }, 427 | { retain: 1 }, 428 | { 429 | insert: [notebook.getCell(1)] 430 | } 431 | ]); 432 | notebook.dispose(); 433 | }); 434 | }); 435 | 436 | describe('#fromJSON', () => { 437 | test('should load a serialize notebook', () => { 438 | const notebook = YNotebook.create(); 439 | notebook.fromJSON({ 440 | cells: [], 441 | metadata: { 442 | dummy: 42 443 | }, 444 | nbformat: 4, 445 | nbformat_minor: 5 446 | }); 447 | 448 | expect(notebook.cells).toHaveLength(0); 449 | expect(notebook.nbformat).toEqual(4); 450 | expect(notebook.nbformat_minor).toEqual(5); 451 | notebook.dispose(); 452 | }); 453 | 454 | test('should remove orig_nbformat', () => { 455 | const notebook = YNotebook.create(); 456 | notebook.fromJSON({ 457 | cells: [], 458 | metadata: { 459 | dummy: 42, 460 | orig_nbformat: 3 461 | }, 462 | nbformat: 4, 463 | nbformat_minor: 5 464 | }); 465 | 466 | expect(notebook.getMetadata('orig_nbformat')).toEqual(undefined); 467 | notebook.dispose(); 468 | }); 469 | 470 | test('should remove cell id for version <4.5', () => { 471 | const notebook = YNotebook.create(); 472 | notebook.fromJSON({ 473 | cells: [ 474 | { 475 | cell_type: 'code', 476 | id: 'first-cell', 477 | source: '', 478 | metadata: {} 479 | } 480 | ], 481 | metadata: { 482 | dummy: 42 483 | }, 484 | nbformat: 4, 485 | nbformat_minor: 4 486 | }); 487 | 488 | expect(notebook.cells).toHaveLength(1); 489 | expect(notebook.cells[0].id).not.toEqual('first-cell'); 490 | notebook.dispose(); 491 | }); 492 | }); 493 | 494 | describe('#undo', () => { 495 | describe('globally', () => { 496 | test('should undo cell addition', () => { 497 | const notebook = YNotebook.create(); 498 | notebook.addCell({ cell_type: 'code' }); 499 | notebook.undoManager.stopCapturing(); 500 | notebook.addCell({ cell_type: 'markdown' }); 501 | 502 | expect(notebook.cells.length).toEqual(2); 503 | 504 | notebook.undo(); 505 | 506 | expect(notebook.cells.length).toEqual(1); 507 | }); 508 | 509 | test('should undo cell source update', () => { 510 | const notebook = YNotebook.create(); 511 | const codeCell = notebook.addCell({ cell_type: 'code' }); 512 | notebook.undoManager.stopCapturing(); 513 | notebook.addCell({ cell_type: 'markdown' }); 514 | notebook.undoManager.stopCapturing(); 515 | codeCell.updateSource(0, 0, 'print(hello);'); 516 | 517 | notebook.undo(); 518 | 519 | expect(notebook.cells.length).toEqual(2); 520 | expect(notebook.getCell(0).getSource()).toEqual(''); 521 | }); 522 | 523 | test('should undo at global level when called locally', () => { 524 | const notebook = YNotebook.create(); 525 | const codeCell = notebook.addCell({ cell_type: 'code' }); 526 | notebook.undoManager.stopCapturing(); 527 | const markdownCell = notebook.addCell({ cell_type: 'markdown' }); 528 | notebook.undoManager.stopCapturing(); 529 | codeCell.updateSource(0, 0, 'print(hello);'); 530 | notebook.undoManager.stopCapturing(); 531 | markdownCell.updateSource(0, 0, '# Title'); 532 | 533 | codeCell.undo(); 534 | 535 | expect(notebook.cells.length).toEqual(2); 536 | expect(notebook.getCell(0).getSource()).toEqual('print(hello);'); 537 | expect(notebook.getCell(1).getSource()).toEqual(''); 538 | }); 539 | }); 540 | 541 | describe('per cells', () => { 542 | test('should undo cell addition', () => { 543 | const notebook = YNotebook.create({ 544 | disableDocumentWideUndoRedo: true 545 | }); 546 | notebook.addCell({ cell_type: 'code' }); 547 | notebook.undoManager.stopCapturing(); 548 | notebook.addCell({ cell_type: 'markdown' }); 549 | 550 | expect(notebook.cells.length).toEqual(2); 551 | 552 | notebook.undo(); 553 | 554 | expect(notebook.cells.length).toEqual(1); 555 | }); 556 | 557 | test('should not undo cell source update', () => { 558 | const notebook = YNotebook.create({ 559 | disableDocumentWideUndoRedo: true 560 | }); 561 | const codeCell = notebook.addCell({ cell_type: 'code' }); 562 | notebook.undoManager.stopCapturing(); 563 | notebook.addCell({ cell_type: 'markdown' }); 564 | 565 | codeCell.updateSource(0, 0, 'print(hello);'); 566 | 567 | notebook.undo(); 568 | 569 | expect(notebook.cells.length).toEqual(1); 570 | expect(notebook.getCell(0).getSource()).toEqual('print(hello);'); 571 | }); 572 | 573 | test('should only undo cell source update', () => { 574 | const notebook = YNotebook.create({ 575 | disableDocumentWideUndoRedo: true 576 | }); 577 | const codeCell = notebook.addCell({ cell_type: 'code' }); 578 | notebook.undoManager.stopCapturing(); 579 | const markdownCell = notebook.addCell({ cell_type: 'markdown' }); 580 | codeCell.updateSource(0, 0, 'print(hello);'); 581 | markdownCell.updateSource(0, 0, '# Title'); 582 | 583 | codeCell.undo(); 584 | 585 | expect(notebook.cells.length).toEqual(2); 586 | expect(notebook.getCell(0).getSource()).toEqual(''); 587 | expect(notebook.getCell(1).getSource()).toEqual('# Title'); 588 | }); 589 | }); 590 | }); 591 | }); 592 | }); 593 | -------------------------------------------------------------------------------- /javascript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "composite": true, 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "incremental": true, 9 | "jsx": "react", 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "noEmitOnError": true, 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "preserveWatchOutput": true, 16 | "resolveJsonModule": true, 17 | "sourceMap": true, 18 | "strictNullChecks": true, 19 | "target": "ES2018", 20 | "types": [], 21 | "outDir": "lib", 22 | "rootDir": "src" 23 | }, 24 | "include": ["src/*"] 25 | } 26 | -------------------------------------------------------------------------------- /javascript/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "noImplicitAny": true, 5 | "noEmitOnError": true, 6 | "noUnusedLocals": true, 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "target": "ES2018", 10 | "outDir": "lib", 11 | "lib": ["DOM", "DOM.iterable"], 12 | "types": ["jest", "node"], 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "strictNullChecks": true, 16 | "skipLibCheck": true 17 | }, 18 | "include": ["src/*", "test/*"] 19 | } 20 | -------------------------------------------------------------------------------- /javascript/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "externalSymbolLinkMappings": { 3 | "@jupyterlab/nbformat": { 4 | "CellType": "https://jupyterlab.readthedocs.io/en/latest/api/types/nbformat.CellType.html", 5 | "ExecutionCount": "https://jupyterlab.readthedocs.io/en/latest/api/types/nbformat.ExecutionCount.html", 6 | "IAttachments": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.IAttachments.html", 7 | "IBaseCell": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.IBaseCell.html", 8 | "IBaseCellMetadata": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.IBaseCellMetadata.html", 9 | "ICodeCell": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.ICodeCell.html", 10 | "IMarkdownCell": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.IMarkdownCell.html", 11 | "INotebookContent": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.INotebookContent.html", 12 | "INotebookMetadata": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.INotebookMetadata.html", 13 | "IOutput": "https://jupyterlab.readthedocs.io/en/latest/api/types/nbformat.IOutput.html", 14 | "IRawCell": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.IRawCell.html", 15 | "IUnrecognizedCell": "https://jupyterlab.readthedocs.io/en/latest/api/interfaces/nbformat.IUnrecognizedCell.html" 16 | }, 17 | "@lumino/coreutils": { 18 | "JSONObject": "https://lumino.readthedocs.io/en/latest/api/interfaces/coreutils.JSONObject.html", 19 | "JSONValue": "https://lumino.readthedocs.io/en/latest/api/types/coreutils.JSONValue.html", 20 | "PartialJSONValue": "https://lumino.readthedocs.io/en/latest/api/types/coreutils.PartialJSONValue.html" 21 | }, 22 | "@lumino/disposable": { 23 | "IObservableDisposable": "https://lumino.readthedocs.io/en/latest/api/interfaces/disposable.IObservableDisposable.html" 24 | }, 25 | "@lumino/signaling": { 26 | "ISignal": "https://lumino.readthedocs.io/en/latest/api/interfaces/signaling.ISignal.html", 27 | "Signal": "https://lumino.readthedocs.io/en/latest/api/classes/signaling.Signal-1.html" 28 | }, 29 | "y-protocols": { 30 | "Awareness": "https://docs.yjs.dev/api/about-awareness" 31 | }, 32 | "yjs": { 33 | "Array": "https://docs.yjs.dev/api/shared-types/y.array", 34 | "Doc": "https://docs.yjs.dev/api/y.doc", 35 | "Map": "https://docs.yjs.dev/api/shared-types/y.map", 36 | "Text": "https://docs.yjs.dev/api/shared-types/y.text", 37 | "UndoManager": "https://docs.yjs.dev/api/undo-manager", 38 | "YArrayEvent": "https://docs.yjs.dev/api/shared-types/y.array#y.arrayevent-api", 39 | "YEvent": "https://docs.yjs.dev/api/y.event", 40 | "YMapEvent": "https://docs.yjs.dev/api/shared-types/y.map#y.mapevent-api", 41 | "YTextEvent": "https://docs.yjs.dev/api/shared-types/y.text#y.textevent-api" 42 | } 43 | }, 44 | "githubPages": false, 45 | "navigationLinks": { 46 | "GitHub": "https://github.com/jupyter-server/jupyter_ydoc", 47 | "Jupyter": "https://jupyter.org" 48 | }, 49 | "titleLink": "https://jupyter-ydoc.readthedocs.io/en/latest", 50 | "out": "./docs" 51 | } 52 | -------------------------------------------------------------------------------- /jupyter_ydoc/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import sys 5 | 6 | from ._version import __version__ as __version__ 7 | from .yblob import YBlob as YBlob 8 | from .yfile import YFile as YFile 9 | from .ynotebook import YNotebook as YNotebook 10 | from .yunicode import YUnicode as YUnicode 11 | 12 | # See compatibility note on `group` keyword in 13 | # https://docs.python.org/3/library/importlib.metadata.html#entry-points 14 | if sys.version_info < (3, 10): 15 | from importlib_metadata import entry_points 16 | else: 17 | from importlib.metadata import entry_points 18 | 19 | ydocs = {ep.name: ep.load() for ep in entry_points(group="jupyter_ydoc")} 20 | -------------------------------------------------------------------------------- /jupyter_ydoc/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_ydoc/090c8f660e0f7f77bc6568c5bac36cebc1b68824/jupyter_ydoc/py.typed -------------------------------------------------------------------------------- /jupyter_ydoc/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from typing import Dict, List, Type, Union 5 | 6 | INT = Type[int] 7 | FLOAT = Type[float] 8 | 9 | 10 | def cast_all( 11 | o: Union[List, Dict], from_type: Union[INT, FLOAT], to_type: Union[FLOAT, INT] 12 | ) -> Union[List, Dict]: 13 | if isinstance(o, list): 14 | for i, v in enumerate(o): 15 | if type(v) is from_type: 16 | v2 = to_type(v) 17 | if v == v2: 18 | o[i] = v2 19 | elif isinstance(v, (list, dict)): 20 | cast_all(v, from_type, to_type) 21 | elif isinstance(o, dict): 22 | for k, v in o.items(): 23 | if type(v) is from_type: 24 | v2 = to_type(v) 25 | if v == v2: 26 | o[k] = v2 27 | elif isinstance(v, (list, dict)): 28 | cast_all(v, from_type, to_type) 29 | return o 30 | -------------------------------------------------------------------------------- /jupyter_ydoc/ybasedoc.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from abc import ABC, abstractmethod 5 | from typing import Any, Callable, Dict, Optional 6 | 7 | from pycrdt import Awareness, Doc, Map, Subscription, UndoManager 8 | 9 | 10 | class YBaseDoc(ABC): 11 | """ 12 | Base YDoc class. 13 | This class, defines the minimum API that any document must provide 14 | to be able to get and set the content of the document as well as 15 | subscribe to changes in the document. 16 | """ 17 | 18 | _ydoc: Doc 19 | _ystate: Map 20 | _subscriptions: Dict[Any, Subscription] 21 | _undo_manager: UndoManager 22 | 23 | def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): 24 | """ 25 | Constructs a YBaseDoc. 26 | 27 | :param ydoc: The :class:`pycrdt.Doc` that will hold the data of the document, if provided. 28 | :type ydoc: :class:`pycrdt.Doc`, optional. 29 | :param awareness: The :class:`pycrdt.Awareness` that shares non persistent data 30 | between clients. 31 | :type awareness: :class:`pycrdt.Awareness`, optional. 32 | """ 33 | if ydoc is None: 34 | self._ydoc = Doc() 35 | else: 36 | self._ydoc = ydoc 37 | self.awareness = awareness 38 | 39 | self._ystate = self._ydoc.get("state", type=Map) 40 | self._subscriptions = {} 41 | self._undo_manager = UndoManager(doc=self._ydoc, capture_timeout_millis=0) 42 | 43 | @property 44 | @abstractmethod 45 | def version(self) -> str: 46 | """ 47 | Returns the version of the document. 48 | 49 | :return: Document's version. 50 | :rtype: str 51 | """ 52 | 53 | @property 54 | def undo_manager(self) -> UndoManager: 55 | """ 56 | A :class:`pycrdt.UndoManager` for the document. 57 | 58 | :return: The document's undo manager. 59 | :rtype: :class:`pycrdt.UndoManager` 60 | """ 61 | return self._undo_manager 62 | 63 | def ystate(self) -> Map: 64 | """ 65 | A :class:`pycrdt.Map` containing the state of the document. 66 | 67 | :return: The document's state. 68 | :rtype: :class:`pycrdt.Map` 69 | """ 70 | return self._ystate 71 | 72 | @property 73 | def ydoc(self) -> Doc: 74 | """ 75 | The underlying :class:`pycrdt.Doc` that contains the data. 76 | 77 | :return: The document's ydoc. 78 | :rtype: :class:`pycrdt.Doc` 79 | """ 80 | return self._ydoc 81 | 82 | @property 83 | def source(self) -> Any: 84 | """ 85 | Returns the content of the document. 86 | 87 | :return: The content of the document. 88 | :rtype: Any 89 | """ 90 | return self.get() 91 | 92 | @source.setter 93 | def source(self, value: Any): 94 | """ 95 | Sets the content of the document. 96 | 97 | :param value: The content of the document. 98 | :type value: Any 99 | """ 100 | return self.set(value) 101 | 102 | @property 103 | def dirty(self) -> Optional[bool]: 104 | """ 105 | Returns whether the document is dirty. 106 | 107 | :return: Whether the document is dirty. 108 | :rtype: Optional[bool] 109 | """ 110 | return self._ystate.get("dirty") 111 | 112 | @dirty.setter 113 | def dirty(self, value: bool) -> None: 114 | """ 115 | Sets the document as clean (all changes committed) or dirty (uncommitted changes). 116 | 117 | :param value: Whether the document is clean or dirty. 118 | :type value: bool 119 | """ 120 | self._ystate["dirty"] = value 121 | 122 | @property 123 | def hash(self) -> Optional[str]: 124 | """ 125 | Returns the document hash as computed by contents manager. 126 | 127 | :return: The document hash. 128 | :rtype: Optional[str] 129 | """ 130 | return self._ystate.get("hash") 131 | 132 | @hash.setter 133 | def hash(self, value: str) -> None: 134 | """ 135 | Sets the document hash. 136 | 137 | :param value: The document hash. 138 | :type value: str 139 | """ 140 | self._ystate["hash"] = value 141 | 142 | @property 143 | def path(self) -> Optional[str]: 144 | """ 145 | Returns document's path. 146 | 147 | :return: Document's path. 148 | :rtype: Optional[str] 149 | """ 150 | return self._ystate.get("path") 151 | 152 | @path.setter 153 | def path(self, value: str) -> None: 154 | """ 155 | Sets document's path. 156 | 157 | :param value: Document's path. 158 | :type value: str 159 | """ 160 | self._ystate["path"] = value 161 | 162 | @abstractmethod 163 | def get(self) -> Any: 164 | """ 165 | Returns the content of the document. 166 | 167 | :return: Document's content. 168 | :rtype: Any 169 | """ 170 | 171 | @abstractmethod 172 | def set(self, value: Any) -> None: 173 | """ 174 | Sets the content of the document. 175 | 176 | :param value: The content of the document. 177 | :type value: Any 178 | """ 179 | 180 | @abstractmethod 181 | def observe(self, callback: Callable[[str, Any], None]) -> None: 182 | """ 183 | Subscribes to document changes. 184 | 185 | :param callback: Callback that will be called when the document changes. 186 | :type callback: Callable[[str, Any], None] 187 | """ 188 | 189 | def unobserve(self) -> None: 190 | """ 191 | Unsubscribes to document changes. 192 | 193 | This method removes all the callbacks. 194 | """ 195 | for k, v in self._subscriptions.items(): 196 | k.unobserve(v) 197 | self._subscriptions = {} 198 | -------------------------------------------------------------------------------- /jupyter_ydoc/yblob.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from functools import partial 5 | from typing import Any, Callable, Optional 6 | 7 | from pycrdt import Awareness, Doc, Map 8 | 9 | from .ybasedoc import YBaseDoc 10 | 11 | 12 | class YBlob(YBaseDoc): 13 | """ 14 | Extends :class:`YBaseDoc`, and represents a blob document. 15 | The Y document is set from bytes. 16 | 17 | Schema: 18 | 19 | .. code-block:: json 20 | 21 | { 22 | "state": YMap, 23 | "source": YMap 24 | } 25 | """ 26 | 27 | def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): 28 | """ 29 | Constructs a YBlob. 30 | 31 | :param ydoc: The :class:`pycrdt.Doc` that will hold the data of the document, if provided. 32 | :type ydoc: :class:`pycrdt.Doc`, optional. 33 | :param awareness: The :class:`pycrdt.Awareness` that shares non persistent data 34 | between clients. 35 | :type awareness: :class:`pycrdt.Awareness`, optional. 36 | """ 37 | super().__init__(ydoc, awareness) 38 | self._ysource = self._ydoc.get("source", type=Map) 39 | self.undo_manager.expand_scope(self._ysource) 40 | 41 | @property 42 | def version(self) -> str: 43 | """ 44 | Returns the version of the document. 45 | 46 | :return: Document's version. 47 | :rtype: str 48 | """ 49 | return "2.0.0" 50 | 51 | def get(self) -> bytes: 52 | """ 53 | Returns the content of the document. 54 | 55 | :return: Document's content. 56 | :rtype: bytes 57 | """ 58 | return self._ysource.get("bytes", b"") 59 | 60 | def set(self, value: bytes) -> None: 61 | """ 62 | Sets the content of the document. 63 | 64 | :param value: The content of the document. 65 | :type value: bytes 66 | """ 67 | self._ysource["bytes"] = value 68 | 69 | def observe(self, callback: Callable[[str, Any], None]) -> None: 70 | """ 71 | Subscribes to document changes. 72 | 73 | :param callback: Callback that will be called when the document changes. 74 | :type callback: Callable[[str, Any], None] 75 | """ 76 | self.unobserve() 77 | self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state")) 78 | self._subscriptions[self._ysource] = self._ysource.observe(partial(callback, "source")) 79 | -------------------------------------------------------------------------------- /jupyter_ydoc/yfile.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from .yunicode import YUnicode 5 | 6 | 7 | class YFile(YUnicode): # for backwards-compatibility 8 | pass 9 | -------------------------------------------------------------------------------- /jupyter_ydoc/ynotebook.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import copy 5 | from functools import partial 6 | from typing import Any, Callable, Dict, Optional 7 | from uuid import uuid4 8 | 9 | from pycrdt import Array, Awareness, Doc, Map, Text 10 | 11 | from .utils import cast_all 12 | from .ybasedoc import YBaseDoc 13 | 14 | # The default major version of the notebook format. 15 | NBFORMAT_MAJOR_VERSION = 4 16 | # The default minor version of the notebook format. 17 | NBFORMAT_MINOR_VERSION = 5 18 | 19 | 20 | class YNotebook(YBaseDoc): 21 | """ 22 | Extends :class:`YBaseDoc`, and represents a Notebook document. 23 | 24 | Schema: 25 | 26 | .. code-block:: json 27 | 28 | { 29 | "state": YMap, 30 | "meta": YMap[ 31 | "nbformat": Int, 32 | "nbformat_minor": Int, 33 | "metadata": YMap 34 | ], 35 | "cells": YArray[ 36 | YMap[ 37 | "id": str, 38 | "cell_type": str, 39 | "source": YText, 40 | "metadata": YMap, 41 | "execution_state": str, 42 | "execution_count": Int | None, 43 | "outputs": [] | None, 44 | "attachments": {} | None 45 | ] 46 | ] 47 | } 48 | """ 49 | 50 | def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): 51 | """ 52 | Constructs a YNotebook. 53 | 54 | :param ydoc: The :class:`pycrdt.Doc` that will hold the data of the document, if provided. 55 | :type ydoc: :class:`pycrdt.Doc`, optional. 56 | :param awareness: The :class:`pycrdt.Awareness` that shares non persistent data 57 | between clients. 58 | :type awareness: :class:`pycrdt.Awareness`, optional. 59 | """ 60 | super().__init__(ydoc, awareness) 61 | self._ymeta = self._ydoc.get("meta", type=Map) 62 | self._ycells = self._ydoc.get("cells", type=Array) 63 | self.undo_manager.expand_scope(self._ycells) 64 | 65 | @property 66 | def version(self) -> str: 67 | """ 68 | Returns the version of the document. 69 | 70 | :return: Document's version. 71 | :rtype: str 72 | """ 73 | return "2.0.0" 74 | 75 | @property 76 | def ycells(self): 77 | """ 78 | Returns the Y-cells. 79 | 80 | :return: The Y-cells. 81 | :rtype: :class:`pycrdt.Array` 82 | """ 83 | return self._ycells 84 | 85 | @property 86 | def cell_number(self) -> int: 87 | """ 88 | Returns the number of cells in the notebook. 89 | 90 | :return: The cell number. 91 | :rtype: int 92 | """ 93 | return len(self._ycells) 94 | 95 | def get_cell(self, index: int) -> Dict[str, Any]: 96 | """ 97 | Returns a cell. 98 | 99 | :param index: The index of the cell. 100 | :type index: int 101 | 102 | :return: A cell. 103 | :rtype: Dict[str, Any] 104 | """ 105 | meta = self._ymeta.to_py() 106 | cell = self._ycells[index].to_py() 107 | cell.pop("execution_state", None) 108 | cast_all(cell, float, int) # cells coming from Yjs have e.g. execution_count as float 109 | if "id" in cell and meta["nbformat"] == 4 and meta["nbformat_minor"] <= 4: 110 | # strip cell IDs if we have notebook format 4.0-4.4 111 | del cell["id"] 112 | if ( 113 | "attachments" in cell 114 | and cell["cell_type"] in ("raw", "markdown") 115 | and not cell["attachments"] 116 | ): 117 | del cell["attachments"] 118 | return cell 119 | 120 | def append_cell(self, value: Dict[str, Any]) -> None: 121 | """ 122 | Appends a cell. 123 | 124 | :param value: A cell. 125 | :type value: Dict[str, Any] 126 | """ 127 | ycell = self.create_ycell(value) 128 | self._ycells.append(ycell) 129 | 130 | def set_cell(self, index: int, value: Dict[str, Any]) -> None: 131 | """ 132 | Sets a cell into indicated position. 133 | 134 | :param index: The index of the cell. 135 | :type index: int 136 | 137 | :param value: A cell. 138 | :type value: Dict[str, Any] 139 | """ 140 | ycell = self.create_ycell(value) 141 | self.set_ycell(index, ycell) 142 | 143 | def create_ycell(self, value: Dict[str, Any]) -> Map: 144 | """ 145 | Creates YMap with the content of the cell. 146 | 147 | :param value: A cell. 148 | :type value: Dict[str, Any] 149 | 150 | :return: A new cell. 151 | :rtype: :class:`pycrdt.Map` 152 | """ 153 | cell = copy.deepcopy(value) 154 | if "id" not in cell: 155 | cell["id"] = str(uuid4()) 156 | cell_type = cell["cell_type"] 157 | cell_source = cell["source"] 158 | cell_source = "".join(cell_source) if isinstance(cell_source, list) else cell_source 159 | cell["source"] = Text(cell_source) 160 | cell["metadata"] = Map(cell.get("metadata", {})) 161 | 162 | if cell_type in ("raw", "markdown"): 163 | if "attachments" in cell and not cell["attachments"]: 164 | del cell["attachments"] 165 | elif cell_type == "code": 166 | outputs = cell.get("outputs", []) 167 | for idx, output in enumerate(outputs): 168 | if output.get("output_type") == "stream": 169 | text = output.get("text", "") 170 | if isinstance(text, str): 171 | ytext = Text(text) 172 | else: 173 | ytext = Text("".join(text)) 174 | output["text"] = ytext 175 | outputs[idx] = Map(output) 176 | cell["outputs"] = Array(outputs) 177 | cell["execution_state"] = "idle" 178 | 179 | return Map(cell) 180 | 181 | def set_ycell(self, index: int, ycell: Map) -> None: 182 | """ 183 | Sets a Y cell into the indicated position. 184 | 185 | :param index: The index of the cell. 186 | :type index: int 187 | 188 | :param ycell: A YMap with the content of a cell. 189 | :type ycell: :class:`pycrdt.Map` 190 | """ 191 | self._ycells[index] = ycell 192 | 193 | def get(self) -> Dict: 194 | """ 195 | Returns the content of the document. 196 | 197 | :return: Document's content. 198 | :rtype: Dict 199 | """ 200 | meta = self._ymeta.to_py() 201 | cast_all(meta, float, int) # notebook coming from Yjs has e.g. nbformat as float 202 | cells = [] 203 | for i in range(len(self._ycells)): 204 | cell = self.get_cell(i) 205 | if ( 206 | "id" in cell 207 | and int(meta.get("nbformat", 0)) == 4 208 | and int(meta.get("nbformat_minor", 0)) <= 4 209 | ): 210 | # strip cell IDs if we have notebook format 4.0-4.4 211 | del cell["id"] 212 | if ( 213 | "attachments" in cell 214 | and cell["cell_type"] in ["raw", "markdown"] 215 | and not cell["attachments"] 216 | ): 217 | del cell["attachments"] 218 | cells.append(cell) 219 | 220 | return dict( 221 | cells=cells, 222 | metadata=meta.get("metadata", {}), 223 | nbformat=int(meta.get("nbformat", 0)), 224 | nbformat_minor=int(meta.get("nbformat_minor", 0)), 225 | ) 226 | 227 | def set(self, value: Dict) -> None: 228 | """ 229 | Sets the content of the document. 230 | 231 | :param value: The content of the document. 232 | :type value: Dict 233 | """ 234 | nb_without_cells = {key: value[key] for key in value.keys() if key != "cells"} 235 | nb = copy.deepcopy(nb_without_cells) 236 | cast_all(nb, int, float) # Yjs expects numbers to be floating numbers 237 | cells = value["cells"] or [ 238 | { 239 | "cell_type": "code", 240 | "execution_count": None, 241 | # auto-created empty code cell without outputs ought be trusted 242 | "metadata": {"trusted": True}, 243 | "outputs": [], 244 | "source": "", 245 | "id": str(uuid4()), 246 | } 247 | ] 248 | 249 | with self._ydoc.transaction(): 250 | # clear document 251 | self._ymeta.clear() 252 | self._ycells.clear() 253 | for key in [k for k in self._ystate.keys() if k not in ("dirty", "path")]: 254 | del self._ystate[key] 255 | 256 | # initialize document 257 | self._ycells.extend([self.create_ycell(cell) for cell in cells]) 258 | self._ymeta["nbformat"] = nb.get("nbformat", NBFORMAT_MAJOR_VERSION) 259 | self._ymeta["nbformat_minor"] = nb.get("nbformat_minor", NBFORMAT_MINOR_VERSION) 260 | 261 | metadata = nb.get("metadata", {}) 262 | metadata.setdefault("language_info", {"name": ""}) 263 | metadata.setdefault("kernelspec", {"name": "", "display_name": ""}) 264 | 265 | self._ymeta["metadata"] = Map(metadata) 266 | 267 | def observe(self, callback: Callable[[str, Any], None]) -> None: 268 | """ 269 | Subscribes to document changes. 270 | 271 | :param callback: Callback that will be called when the document changes. 272 | :type callback: Callable[[str, Any], None] 273 | """ 274 | self.unobserve() 275 | self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state")) 276 | self._subscriptions[self._ymeta] = self._ymeta.observe_deep(partial(callback, "meta")) 277 | self._subscriptions[self._ycells] = self._ycells.observe_deep(partial(callback, "cells")) 278 | -------------------------------------------------------------------------------- /jupyter_ydoc/yunicode.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from functools import partial 5 | from typing import Any, Callable, Optional 6 | 7 | from pycrdt import Awareness, Doc, Text 8 | 9 | from .ybasedoc import YBaseDoc 10 | 11 | 12 | class YUnicode(YBaseDoc): 13 | """ 14 | Extends :class:`YBaseDoc`, and represents a plain text document, encoded as UTF-8. 15 | 16 | Schema: 17 | 18 | .. code-block:: json 19 | 20 | { 21 | "state": YMap, 22 | "source": YText 23 | } 24 | """ 25 | 26 | def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): 27 | """ 28 | Constructs a YUnicode. 29 | 30 | :param ydoc: The :class:`pycrdt.Doc` that will hold the data of the document, if provided. 31 | :type ydoc: :class:`pycrdt.Doc`, optional. 32 | :param awareness: The :class:`pycrdt.Awareness` that shares non persistent data 33 | between clients. 34 | :type awareness: :class:`pycrdt.Awareness`, optional. 35 | """ 36 | super().__init__(ydoc, awareness) 37 | self._ysource = self._ydoc.get("source", type=Text) 38 | self.undo_manager.expand_scope(self._ysource) 39 | 40 | @property 41 | def version(self) -> str: 42 | """ 43 | Returns the version of the document. 44 | 45 | :return: Document's version. 46 | :rtype: str 47 | """ 48 | return "1.0.0" 49 | 50 | def get(self) -> str: 51 | """ 52 | Returns the content of the document. 53 | 54 | :return: Document's content. 55 | :rtype: str 56 | """ 57 | return str(self._ysource) 58 | 59 | def set(self, value: str) -> None: 60 | """ 61 | Sets the content of the document. 62 | 63 | :param value: The content of the document. 64 | :type value: str 65 | """ 66 | with self._ydoc.transaction(): 67 | # clear document 68 | self._ysource.clear() 69 | # initialize document 70 | if value: 71 | self._ysource += value 72 | 73 | def observe(self, callback: Callable[[str, Any], None]) -> None: 74 | """ 75 | Subscribes to document changes. 76 | 77 | :param callback: Callback that will be called when the document changes. 78 | :type callback: Callable[[str, Any], None] 79 | """ 80 | self.unobserve() 81 | self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state")) 82 | self._subscriptions[self._ysource] = self._ysource.observe(partial(callback, "source")) 83 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "useWorkspaces": true, 4 | "version": "independent" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupyter/ydoc-top-repo", 3 | "version": "0.0.1", 4 | "private": true, 5 | "files": [], 6 | "workspaces": [ 7 | "javascript", 8 | "tests" 9 | ], 10 | "scripts": { 11 | "build": "cd javascript && yarn run build", 12 | "test": "cd javascript && yarn run build:test && yarn run test" 13 | }, 14 | "packageManager": "yarn@3.4.1" 15 | } 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | [build-system] 5 | requires = ["hatchling>=1.10.0", "hatch-nodejs-version"] 6 | build-backend = "hatchling.build" 7 | 8 | [project] 9 | name = "jupyter-ydoc" 10 | dynamic = ["version"] 11 | description = "Document structures for collaborative editing using Ypy" 12 | requires-python = ">=3.8" 13 | keywords = ["jupyter", "pycrdt", "yjs"] 14 | dependencies = [ 15 | "importlib_metadata >=3.6; python_version<'3.10'", 16 | "pycrdt >=0.10.1,<0.13.0", 17 | ] 18 | 19 | [[project.authors]] 20 | name = "Jupyter Development Team" 21 | email = "jupyter@googlegroups.com" 22 | 23 | [project.optional-dependencies] 24 | dev = [ 25 | "click", 26 | "jupyter_releaser", 27 | "pre-commit" 28 | ] 29 | test = [ 30 | "pre-commit", 31 | "pytest", 32 | "pytest-asyncio", 33 | "httpx-ws >=0.5.2", 34 | "hypercorn >=0.16.0", 35 | "pycrdt-websocket >=0.15.0,<0.16.0", 36 | ] 37 | docs = [ 38 | "sphinx", 39 | "myst-parser", 40 | "pydata-sphinx-theme" 41 | ] 42 | 43 | [project.entry-points.jupyter_ydoc] 44 | blob = "jupyter_ydoc.yblob:YBlob" 45 | file = "jupyter_ydoc.yfile:YFile" 46 | unicode = "jupyter_ydoc.yunicode:YUnicode" 47 | notebook = "jupyter_ydoc.ynotebook:YNotebook" 48 | 49 | [project.readme] 50 | file = "README.md" 51 | content-type = "text/markdown" 52 | 53 | [project.license] 54 | text = "BSD 3-Clause License" 55 | 56 | [project.urls] 57 | Homepage = "https://jupyter.org" 58 | Source = "https://github.com/jupyter-server/jupyter_ydoc" 59 | 60 | [tool.hatch.version] 61 | source = "nodejs" 62 | path = "javascript/package.json" 63 | 64 | [tool.hatch.build] 65 | exclude = ["javascript", "!javascript/package.json"] 66 | 67 | [tool.hatch.build.hooks.version] 68 | path = "jupyter_ydoc/_version.py" 69 | 70 | [tool.check-manifest] 71 | ignore = [".*"] 72 | 73 | [tool.jupyter-releaser] 74 | skip = [ 75 | "check-links", 76 | "check-manifest", 77 | ] 78 | 79 | [tool.jupyter-releaser.hooks] 80 | before-build-npm = ["cd javascript && yarn && yarn build"] 81 | before-bump-version = ["pip install -e .[dev]"] 82 | 83 | [tool.jupyter-releaser.options] 84 | version_cmd = "hatch version" 85 | 86 | [tool.ruff] 87 | line-length = 100 88 | lint.select = [ 89 | "ASYNC", # flake8-async 90 | "E", "F", "W", # default Flake8 91 | "G", # flake8-logging-format 92 | "I", # isort 93 | "ISC", # flake8-implicit-str-concat 94 | "PGH", # pygrep-hooks 95 | "RUF100", # unused noqa (yesqa) 96 | "UP", # pyupgrade 97 | ] 98 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | ; Copyright (c) Jupyter Development Team. 2 | ; Distributed under the terms of the Modified BSD License. 3 | 4 | [pytest] 5 | asyncio_mode = auto 6 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: "3.8" 7 | nodejs: "14" 8 | 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | python: 13 | install: 14 | - method: pip 15 | path: . 16 | extra_requirements: 17 | - docs 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import json 5 | import subprocess 6 | from functools import partial 7 | from pathlib import Path 8 | 9 | import pytest 10 | from anyio import Event, create_task_group 11 | from hypercorn import Config 12 | from hypercorn.asyncio import serve 13 | from pycrdt_websocket import ASGIServer, WebsocketServer 14 | from utils import ensure_server_running 15 | 16 | # workaround until these PRs are merged: 17 | # - https://github.com/yjs/y-websocket/pull/104 18 | 19 | 20 | def update_json_file(path: Path, d: dict): 21 | with open(path, "rb") as f: 22 | package_json = json.load(f) 23 | package_json.update(d) 24 | with open(path, "w") as f: 25 | json.dump(package_json, f, indent=2) 26 | 27 | 28 | here = Path(__file__).parent 29 | d = {"type": "module"} 30 | update_json_file(here.parent / "node_modules/y-websocket/package.json", d) 31 | 32 | 33 | @pytest.fixture 34 | async def yws_server(request, unused_tcp_port): 35 | try: 36 | async with create_task_group() as tg: 37 | try: 38 | kwargs = request.param 39 | except Exception: 40 | kwargs = {} 41 | websocket_server = WebsocketServer(**kwargs) 42 | app = ASGIServer(websocket_server) 43 | config = Config() 44 | config.bind = [f"localhost:{unused_tcp_port}"] 45 | shutdown_event = Event() 46 | async with websocket_server as websocket_server: 47 | tg.start_soon( 48 | partial(serve, app, config, shutdown_trigger=shutdown_event.wait, mode="asgi") 49 | ) 50 | await ensure_server_running("localhost", unused_tcp_port) 51 | pytest.port = unused_tcp_port 52 | yield unused_tcp_port, websocket_server 53 | shutdown_event.set() 54 | except Exception: 55 | pass 56 | 57 | 58 | @pytest.fixture 59 | def yjs_client(request): 60 | client_id = request.param 61 | p = subprocess.Popen(["node", f"{here / 'yjs_client_'}{client_id}.js", str(pytest.port)]) 62 | yield p 63 | p.terminate() 64 | try: 65 | p.wait(timeout=10) 66 | except Exception: 67 | p.kill() 68 | -------------------------------------------------------------------------------- /tests/files/nb0.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "7fb27b941602401d91542211134fc71a", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "print(\"Hello, World!\")" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": null, 16 | "id": "acae54e37e7d407bbb7b55eff062a284", 17 | "metadata": {}, 18 | "outputs": [], 19 | "source": [ 20 | "print(\"Hello, World!\")" 21 | ] 22 | } 23 | ], 24 | "metadata": { 25 | "kernelspec": { 26 | "display_name": "Python 3 (ipykernel)", 27 | "language": "python", 28 | "name": "python3" 29 | }, 30 | "language_info": { 31 | "codemirror_mode": { 32 | "name": "ipython", 33 | "version": 3 34 | }, 35 | "file_extension": ".py", 36 | "mimetype": "text/x-python", 37 | "name": "python", 38 | "nbconvert_exporter": "python", 39 | "pygments_lexer": "ipython3", 40 | "version": "3.10.2" 41 | } 42 | }, 43 | "nbformat": 4, 44 | "nbformat_minor": 5 45 | } 46 | -------------------------------------------------------------------------------- /tests/files/nb1.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "4166c837-41c7-4ada-b86e-fd9a7720a409", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "Hello," 14 | ] 15 | } 16 | ], 17 | "source": [ 18 | "print(\"Hello,\", end=\"\")" 19 | ] 20 | } 21 | ], 22 | "metadata": { 23 | "kernelspec": { 24 | "display_name": "Python 3 (ipykernel)", 25 | "language": "python", 26 | "name": "python3" 27 | }, 28 | "language_info": { 29 | "codemirror_mode": { 30 | "name": "ipython", 31 | "version": 3 32 | }, 33 | "file_extension": ".py", 34 | "mimetype": "text/x-python", 35 | "name": "python", 36 | "nbconvert_exporter": "python", 37 | "pygments_lexer": "ipython3", 38 | "version": "3.12.3" 39 | } 40 | }, 41 | "nbformat": 4, 42 | "nbformat_minor": 5 43 | } 44 | -------------------------------------------------------------------------------- /tests/files/plotly_renderer.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "2271f67c-af76-4243-bcac-4300c5f478b2", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "data": { 11 | "application/vnd.plotly.v1+json": { 12 | "config": { 13 | "plotlyServerURL": "https://plot.ly" 14 | }, 15 | "data": [ 16 | { 17 | "type": "bar", 18 | "y": [ 19 | 2, 20 | 1, 21 | 3 22 | ] 23 | } 24 | ], 25 | "layout": { 26 | "autosize": true, 27 | "template": { 28 | "data": { 29 | "bar": [ 30 | { 31 | "error_x": { 32 | "color": "#2a3f5f" 33 | }, 34 | "error_y": { 35 | "color": "#2a3f5f" 36 | }, 37 | "marker": { 38 | "line": { 39 | "color": "#E5ECF6", 40 | "width": 0.5 41 | }, 42 | "pattern": { 43 | "fillmode": "overlay", 44 | "size": 10, 45 | "solidity": 0.2 46 | } 47 | }, 48 | "type": "bar" 49 | } 50 | ], 51 | "barpolar": [ 52 | { 53 | "marker": { 54 | "line": { 55 | "color": "#E5ECF6", 56 | "width": 0.5 57 | }, 58 | "pattern": { 59 | "fillmode": "overlay", 60 | "size": 10, 61 | "solidity": 0.2 62 | } 63 | }, 64 | "type": "barpolar" 65 | } 66 | ], 67 | "carpet": [ 68 | { 69 | "aaxis": { 70 | "endlinecolor": "#2a3f5f", 71 | "gridcolor": "white", 72 | "linecolor": "white", 73 | "minorgridcolor": "white", 74 | "startlinecolor": "#2a3f5f" 75 | }, 76 | "baxis": { 77 | "endlinecolor": "#2a3f5f", 78 | "gridcolor": "white", 79 | "linecolor": "white", 80 | "minorgridcolor": "white", 81 | "startlinecolor": "#2a3f5f" 82 | }, 83 | "type": "carpet" 84 | } 85 | ], 86 | "choropleth": [ 87 | { 88 | "colorbar": { 89 | "outlinewidth": 0, 90 | "ticks": "" 91 | }, 92 | "type": "choropleth" 93 | } 94 | ], 95 | "contour": [ 96 | { 97 | "colorbar": { 98 | "outlinewidth": 0, 99 | "ticks": "" 100 | }, 101 | "colorscale": [ 102 | [ 103 | 0, 104 | "#0d0887" 105 | ], 106 | [ 107 | 0.1111111111111111, 108 | "#46039f" 109 | ], 110 | [ 111 | 0.2222222222222222, 112 | "#7201a8" 113 | ], 114 | [ 115 | 0.3333333333333333, 116 | "#9c179e" 117 | ], 118 | [ 119 | 0.4444444444444444, 120 | "#bd3786" 121 | ], 122 | [ 123 | 0.5555555555555556, 124 | "#d8576b" 125 | ], 126 | [ 127 | 0.6666666666666666, 128 | "#ed7953" 129 | ], 130 | [ 131 | 0.7777777777777778, 132 | "#fb9f3a" 133 | ], 134 | [ 135 | 0.8888888888888888, 136 | "#fdca26" 137 | ], 138 | [ 139 | 1, 140 | "#f0f921" 141 | ] 142 | ], 143 | "type": "contour" 144 | } 145 | ], 146 | "contourcarpet": [ 147 | { 148 | "colorbar": { 149 | "outlinewidth": 0, 150 | "ticks": "" 151 | }, 152 | "type": "contourcarpet" 153 | } 154 | ], 155 | "heatmap": [ 156 | { 157 | "colorbar": { 158 | "outlinewidth": 0, 159 | "ticks": "" 160 | }, 161 | "colorscale": [ 162 | [ 163 | 0, 164 | "#0d0887" 165 | ], 166 | [ 167 | 0.1111111111111111, 168 | "#46039f" 169 | ], 170 | [ 171 | 0.2222222222222222, 172 | "#7201a8" 173 | ], 174 | [ 175 | 0.3333333333333333, 176 | "#9c179e" 177 | ], 178 | [ 179 | 0.4444444444444444, 180 | "#bd3786" 181 | ], 182 | [ 183 | 0.5555555555555556, 184 | "#d8576b" 185 | ], 186 | [ 187 | 0.6666666666666666, 188 | "#ed7953" 189 | ], 190 | [ 191 | 0.7777777777777778, 192 | "#fb9f3a" 193 | ], 194 | [ 195 | 0.8888888888888888, 196 | "#fdca26" 197 | ], 198 | [ 199 | 1, 200 | "#f0f921" 201 | ] 202 | ], 203 | "type": "heatmap" 204 | } 205 | ], 206 | "heatmapgl": [ 207 | { 208 | "colorbar": { 209 | "outlinewidth": 0, 210 | "ticks": "" 211 | }, 212 | "colorscale": [ 213 | [ 214 | 0, 215 | "#0d0887" 216 | ], 217 | [ 218 | 0.1111111111111111, 219 | "#46039f" 220 | ], 221 | [ 222 | 0.2222222222222222, 223 | "#7201a8" 224 | ], 225 | [ 226 | 0.3333333333333333, 227 | "#9c179e" 228 | ], 229 | [ 230 | 0.4444444444444444, 231 | "#bd3786" 232 | ], 233 | [ 234 | 0.5555555555555556, 235 | "#d8576b" 236 | ], 237 | [ 238 | 0.6666666666666666, 239 | "#ed7953" 240 | ], 241 | [ 242 | 0.7777777777777778, 243 | "#fb9f3a" 244 | ], 245 | [ 246 | 0.8888888888888888, 247 | "#fdca26" 248 | ], 249 | [ 250 | 1, 251 | "#f0f921" 252 | ] 253 | ], 254 | "type": "heatmapgl" 255 | } 256 | ], 257 | "histogram": [ 258 | { 259 | "marker": { 260 | "pattern": { 261 | "fillmode": "overlay", 262 | "size": 10, 263 | "solidity": 0.2 264 | } 265 | }, 266 | "type": "histogram" 267 | } 268 | ], 269 | "histogram2d": [ 270 | { 271 | "colorbar": { 272 | "outlinewidth": 0, 273 | "ticks": "" 274 | }, 275 | "colorscale": [ 276 | [ 277 | 0, 278 | "#0d0887" 279 | ], 280 | [ 281 | 0.1111111111111111, 282 | "#46039f" 283 | ], 284 | [ 285 | 0.2222222222222222, 286 | "#7201a8" 287 | ], 288 | [ 289 | 0.3333333333333333, 290 | "#9c179e" 291 | ], 292 | [ 293 | 0.4444444444444444, 294 | "#bd3786" 295 | ], 296 | [ 297 | 0.5555555555555556, 298 | "#d8576b" 299 | ], 300 | [ 301 | 0.6666666666666666, 302 | "#ed7953" 303 | ], 304 | [ 305 | 0.7777777777777778, 306 | "#fb9f3a" 307 | ], 308 | [ 309 | 0.8888888888888888, 310 | "#fdca26" 311 | ], 312 | [ 313 | 1, 314 | "#f0f921" 315 | ] 316 | ], 317 | "type": "histogram2d" 318 | } 319 | ], 320 | "histogram2dcontour": [ 321 | { 322 | "colorbar": { 323 | "outlinewidth": 0, 324 | "ticks": "" 325 | }, 326 | "colorscale": [ 327 | [ 328 | 0, 329 | "#0d0887" 330 | ], 331 | [ 332 | 0.1111111111111111, 333 | "#46039f" 334 | ], 335 | [ 336 | 0.2222222222222222, 337 | "#7201a8" 338 | ], 339 | [ 340 | 0.3333333333333333, 341 | "#9c179e" 342 | ], 343 | [ 344 | 0.4444444444444444, 345 | "#bd3786" 346 | ], 347 | [ 348 | 0.5555555555555556, 349 | "#d8576b" 350 | ], 351 | [ 352 | 0.6666666666666666, 353 | "#ed7953" 354 | ], 355 | [ 356 | 0.7777777777777778, 357 | "#fb9f3a" 358 | ], 359 | [ 360 | 0.8888888888888888, 361 | "#fdca26" 362 | ], 363 | [ 364 | 1, 365 | "#f0f921" 366 | ] 367 | ], 368 | "type": "histogram2dcontour" 369 | } 370 | ], 371 | "mesh3d": [ 372 | { 373 | "colorbar": { 374 | "outlinewidth": 0, 375 | "ticks": "" 376 | }, 377 | "type": "mesh3d" 378 | } 379 | ], 380 | "parcoords": [ 381 | { 382 | "line": { 383 | "colorbar": { 384 | "outlinewidth": 0, 385 | "ticks": "" 386 | } 387 | }, 388 | "type": "parcoords" 389 | } 390 | ], 391 | "pie": [ 392 | { 393 | "automargin": true, 394 | "type": "pie" 395 | } 396 | ], 397 | "scatter": [ 398 | { 399 | "fillpattern": { 400 | "fillmode": "overlay", 401 | "size": 10, 402 | "solidity": 0.2 403 | }, 404 | "type": "scatter" 405 | } 406 | ], 407 | "scatter3d": [ 408 | { 409 | "line": { 410 | "colorbar": { 411 | "outlinewidth": 0, 412 | "ticks": "" 413 | } 414 | }, 415 | "marker": { 416 | "colorbar": { 417 | "outlinewidth": 0, 418 | "ticks": "" 419 | } 420 | }, 421 | "type": "scatter3d" 422 | } 423 | ], 424 | "scattercarpet": [ 425 | { 426 | "marker": { 427 | "colorbar": { 428 | "outlinewidth": 0, 429 | "ticks": "" 430 | } 431 | }, 432 | "type": "scattercarpet" 433 | } 434 | ], 435 | "scattergeo": [ 436 | { 437 | "marker": { 438 | "colorbar": { 439 | "outlinewidth": 0, 440 | "ticks": "" 441 | } 442 | }, 443 | "type": "scattergeo" 444 | } 445 | ], 446 | "scattergl": [ 447 | { 448 | "marker": { 449 | "colorbar": { 450 | "outlinewidth": 0, 451 | "ticks": "" 452 | } 453 | }, 454 | "type": "scattergl" 455 | } 456 | ], 457 | "scattermapbox": [ 458 | { 459 | "marker": { 460 | "colorbar": { 461 | "outlinewidth": 0, 462 | "ticks": "" 463 | } 464 | }, 465 | "type": "scattermapbox" 466 | } 467 | ], 468 | "scatterpolar": [ 469 | { 470 | "marker": { 471 | "colorbar": { 472 | "outlinewidth": 0, 473 | "ticks": "" 474 | } 475 | }, 476 | "type": "scatterpolar" 477 | } 478 | ], 479 | "scatterpolargl": [ 480 | { 481 | "marker": { 482 | "colorbar": { 483 | "outlinewidth": 0, 484 | "ticks": "" 485 | } 486 | }, 487 | "type": "scatterpolargl" 488 | } 489 | ], 490 | "scatterternary": [ 491 | { 492 | "marker": { 493 | "colorbar": { 494 | "outlinewidth": 0, 495 | "ticks": "" 496 | } 497 | }, 498 | "type": "scatterternary" 499 | } 500 | ], 501 | "surface": [ 502 | { 503 | "colorbar": { 504 | "outlinewidth": 0, 505 | "ticks": "" 506 | }, 507 | "colorscale": [ 508 | [ 509 | 0, 510 | "#0d0887" 511 | ], 512 | [ 513 | 0.1111111111111111, 514 | "#46039f" 515 | ], 516 | [ 517 | 0.2222222222222222, 518 | "#7201a8" 519 | ], 520 | [ 521 | 0.3333333333333333, 522 | "#9c179e" 523 | ], 524 | [ 525 | 0.4444444444444444, 526 | "#bd3786" 527 | ], 528 | [ 529 | 0.5555555555555556, 530 | "#d8576b" 531 | ], 532 | [ 533 | 0.6666666666666666, 534 | "#ed7953" 535 | ], 536 | [ 537 | 0.7777777777777778, 538 | "#fb9f3a" 539 | ], 540 | [ 541 | 0.8888888888888888, 542 | "#fdca26" 543 | ], 544 | [ 545 | 1, 546 | "#f0f921" 547 | ] 548 | ], 549 | "type": "surface" 550 | } 551 | ], 552 | "table": [ 553 | { 554 | "cells": { 555 | "fill": { 556 | "color": "#EBF0F8" 557 | }, 558 | "line": { 559 | "color": "white" 560 | } 561 | }, 562 | "header": { 563 | "fill": { 564 | "color": "#C8D4E3" 565 | }, 566 | "line": { 567 | "color": "white" 568 | } 569 | }, 570 | "type": "table" 571 | } 572 | ] 573 | }, 574 | "layout": { 575 | "annotationdefaults": { 576 | "arrowcolor": "#2a3f5f", 577 | "arrowhead": 0, 578 | "arrowwidth": 1 579 | }, 580 | "autotypenumbers": "strict", 581 | "coloraxis": { 582 | "colorbar": { 583 | "outlinewidth": 0, 584 | "ticks": "" 585 | } 586 | }, 587 | "colorscale": { 588 | "diverging": [ 589 | [ 590 | 0, 591 | "#8e0152" 592 | ], 593 | [ 594 | 0.1, 595 | "#c51b7d" 596 | ], 597 | [ 598 | 0.2, 599 | "#de77ae" 600 | ], 601 | [ 602 | 0.3, 603 | "#f1b6da" 604 | ], 605 | [ 606 | 0.4, 607 | "#fde0ef" 608 | ], 609 | [ 610 | 0.5, 611 | "#f7f7f7" 612 | ], 613 | [ 614 | 0.6, 615 | "#e6f5d0" 616 | ], 617 | [ 618 | 0.7, 619 | "#b8e186" 620 | ], 621 | [ 622 | 0.8, 623 | "#7fbc41" 624 | ], 625 | [ 626 | 0.9, 627 | "#4d9221" 628 | ], 629 | [ 630 | 1, 631 | "#276419" 632 | ] 633 | ], 634 | "sequential": [ 635 | [ 636 | 0, 637 | "#0d0887" 638 | ], 639 | [ 640 | 0.1111111111111111, 641 | "#46039f" 642 | ], 643 | [ 644 | 0.2222222222222222, 645 | "#7201a8" 646 | ], 647 | [ 648 | 0.3333333333333333, 649 | "#9c179e" 650 | ], 651 | [ 652 | 0.4444444444444444, 653 | "#bd3786" 654 | ], 655 | [ 656 | 0.5555555555555556, 657 | "#d8576b" 658 | ], 659 | [ 660 | 0.6666666666666666, 661 | "#ed7953" 662 | ], 663 | [ 664 | 0.7777777777777778, 665 | "#fb9f3a" 666 | ], 667 | [ 668 | 0.8888888888888888, 669 | "#fdca26" 670 | ], 671 | [ 672 | 1, 673 | "#f0f921" 674 | ] 675 | ], 676 | "sequentialminus": [ 677 | [ 678 | 0, 679 | "#0d0887" 680 | ], 681 | [ 682 | 0.1111111111111111, 683 | "#46039f" 684 | ], 685 | [ 686 | 0.2222222222222222, 687 | "#7201a8" 688 | ], 689 | [ 690 | 0.3333333333333333, 691 | "#9c179e" 692 | ], 693 | [ 694 | 0.4444444444444444, 695 | "#bd3786" 696 | ], 697 | [ 698 | 0.5555555555555556, 699 | "#d8576b" 700 | ], 701 | [ 702 | 0.6666666666666666, 703 | "#ed7953" 704 | ], 705 | [ 706 | 0.7777777777777778, 707 | "#fb9f3a" 708 | ], 709 | [ 710 | 0.8888888888888888, 711 | "#fdca26" 712 | ], 713 | [ 714 | 1, 715 | "#f0f921" 716 | ] 717 | ] 718 | }, 719 | "colorway": [ 720 | "#636efa", 721 | "#EF553B", 722 | "#00cc96", 723 | "#ab63fa", 724 | "#FFA15A", 725 | "#19d3f3", 726 | "#FF6692", 727 | "#B6E880", 728 | "#FF97FF", 729 | "#FECB52" 730 | ], 731 | "font": { 732 | "color": "#2a3f5f" 733 | }, 734 | "geo": { 735 | "bgcolor": "white", 736 | "lakecolor": "white", 737 | "landcolor": "#E5ECF6", 738 | "showlakes": true, 739 | "showland": true, 740 | "subunitcolor": "white" 741 | }, 742 | "hoverlabel": { 743 | "align": "left" 744 | }, 745 | "hovermode": "closest", 746 | "mapbox": { 747 | "style": "light" 748 | }, 749 | "paper_bgcolor": "white", 750 | "plot_bgcolor": "#E5ECF6", 751 | "polar": { 752 | "angularaxis": { 753 | "gridcolor": "white", 754 | "linecolor": "white", 755 | "ticks": "" 756 | }, 757 | "bgcolor": "#E5ECF6", 758 | "radialaxis": { 759 | "gridcolor": "white", 760 | "linecolor": "white", 761 | "ticks": "" 762 | } 763 | }, 764 | "scene": { 765 | "xaxis": { 766 | "backgroundcolor": "#E5ECF6", 767 | "gridcolor": "white", 768 | "gridwidth": 2, 769 | "linecolor": "white", 770 | "showbackground": true, 771 | "ticks": "", 772 | "zerolinecolor": "white" 773 | }, 774 | "yaxis": { 775 | "backgroundcolor": "#E5ECF6", 776 | "gridcolor": "white", 777 | "gridwidth": 2, 778 | "linecolor": "white", 779 | "showbackground": true, 780 | "ticks": "", 781 | "zerolinecolor": "white" 782 | }, 783 | "zaxis": { 784 | "backgroundcolor": "#E5ECF6", 785 | "gridcolor": "white", 786 | "gridwidth": 2, 787 | "linecolor": "white", 788 | "showbackground": true, 789 | "ticks": "", 790 | "zerolinecolor": "white" 791 | } 792 | }, 793 | "shapedefaults": { 794 | "line": { 795 | "color": "#2a3f5f" 796 | } 797 | }, 798 | "ternary": { 799 | "aaxis": { 800 | "gridcolor": "white", 801 | "linecolor": "white", 802 | "ticks": "" 803 | }, 804 | "baxis": { 805 | "gridcolor": "white", 806 | "linecolor": "white", 807 | "ticks": "" 808 | }, 809 | "bgcolor": "#E5ECF6", 810 | "caxis": { 811 | "gridcolor": "white", 812 | "linecolor": "white", 813 | "ticks": "" 814 | } 815 | }, 816 | "title": { 817 | "x": 0.05 818 | }, 819 | "xaxis": { 820 | "automargin": true, 821 | "gridcolor": "white", 822 | "linecolor": "white", 823 | "ticks": "", 824 | "title": { 825 | "standoff": 15 826 | }, 827 | "zerolinecolor": "white", 828 | "zerolinewidth": 2 829 | }, 830 | "yaxis": { 831 | "automargin": true, 832 | "gridcolor": "white", 833 | "linecolor": "white", 834 | "ticks": "", 835 | "title": { 836 | "standoff": 15 837 | }, 838 | "zerolinecolor": "white", 839 | "zerolinewidth": 2 840 | } 841 | } 842 | }, 843 | "title": { 844 | "text": "A Figure Displayed with fig.show()" 845 | }, 846 | "xaxis": { 847 | "autorange": true, 848 | "range": [ 849 | -0.5, 850 | 2.5 851 | ] 852 | }, 853 | "yaxis": { 854 | "autorange": true, 855 | "range": [ 856 | 0, 857 | 3.1578947368421053 858 | ], 859 | "type": "linear" 860 | } 861 | } 862 | } 863 | }, 864 | "metadata": {}, 865 | "output_type": "display_data" 866 | } 867 | ], 868 | "source": [ 869 | "import plotly.graph_objects as go\n", 870 | "\n", 871 | "fig = go.Figure(data=[go.Bar(y=[2, 1, 3])], layout_title_text=\"A Figure Displayed with fig.show()\")\n", 872 | "fig.show()" 873 | ] 874 | } 875 | ], 876 | "metadata": { 877 | "kernelspec": { 878 | "display_name": "Python 3 (ipykernel)", 879 | "language": "python", 880 | "name": "python3" 881 | }, 882 | "language_info": { 883 | "codemirror_mode": { 884 | "name": "ipython", 885 | "version": 3 886 | }, 887 | "file_extension": ".py", 888 | "mimetype": "text/x-python", 889 | "name": "python", 890 | "nbconvert_exporter": "python", 891 | "pygments_lexer": "ipython3", 892 | "version": "3.10.6" 893 | } 894 | }, 895 | "nbformat": 4, 896 | "nbformat_minor": 5 897 | } 898 | -------------------------------------------------------------------------------- /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupyter/ydoc-test", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@jupyter/ydoc": "workspace:javascript", 8 | "ws": "^8.5.0", 9 | "y-websocket": "^1.4.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/test_pycrdt_yjs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import json 5 | from pathlib import Path 6 | 7 | import pytest 8 | from anyio import Event, create_task_group, move_on_after 9 | from httpx_ws import aconnect_ws 10 | from pycrdt import Doc, Map 11 | from pycrdt_websocket import WebsocketProvider 12 | from utils import Websocket 13 | 14 | from jupyter_ydoc import YNotebook 15 | from jupyter_ydoc.utils import cast_all 16 | 17 | files_dir = Path(__file__).parent / "files" 18 | 19 | 20 | def stringify_source(nb: dict) -> dict: 21 | """Stringify in-place the cell sources.""" 22 | for cell in nb["cells"]: 23 | cell["source"] = ( 24 | "".join(cell["source"]) if isinstance(cell["source"], list) else cell["source"] 25 | ) 26 | 27 | return nb 28 | 29 | 30 | class YTest: 31 | def __init__(self, ydoc: Doc, timeout: float = 1.0): 32 | self.timeout = timeout 33 | ydoc["_test"] = self.ytest = Map() 34 | self.clock = -1.0 35 | 36 | def run_clock(self): 37 | self.clock = max(self.clock, 0.0) 38 | self.ytest["clock"] = self.clock 39 | 40 | async def clock_run(self): 41 | change = Event() 42 | 43 | def callback(event): 44 | if "clock" in event.keys: 45 | clk = event.keys["clock"]["newValue"] 46 | if clk > self.clock: 47 | self.clock = clk + 1.0 48 | change.set() 49 | 50 | subscription_id = self.ytest.observe(callback) 51 | async with create_task_group(): 52 | with move_on_after(self.timeout): 53 | await change.wait() 54 | 55 | self.ytest.unobserve(subscription_id) 56 | 57 | @property 58 | def source(self): 59 | return cast_all(self.ytest["source"], float, int) 60 | 61 | 62 | @pytest.mark.asyncio 63 | @pytest.mark.parametrize("yjs_client", "0", indirect=True) 64 | async def test_ypy_yjs_0(yws_server, yjs_client): 65 | port, _ = yws_server 66 | ydoc = Doc() 67 | ynotebook = YNotebook(ydoc) 68 | room_name = "my-roomname" 69 | async with aconnect_ws(f"http://localhost:{port}/{room_name}") as websocket, WebsocketProvider( 70 | ydoc, Websocket(websocket, room_name) 71 | ): 72 | nb = stringify_source(json.loads((files_dir / "nb0.ipynb").read_text())) 73 | ynotebook.source = nb 74 | ytest = YTest(ydoc, 3.0) 75 | ytest.run_clock() 76 | await ytest.clock_run() 77 | assert ytest.source == nb 78 | 79 | 80 | @pytest.mark.asyncio 81 | @pytest.mark.parametrize("yjs_client", "1", indirect=True) 82 | async def test_ypy_yjs_1(yws_server, yjs_client): 83 | port, _ = yws_server 84 | ydoc = Doc() 85 | ynotebook = YNotebook(ydoc) 86 | nb = stringify_source(json.loads((files_dir / "nb1.ipynb").read_text())) 87 | ynotebook.source = nb 88 | room_name = "my-roomname" 89 | async with aconnect_ws(f"http://localhost:{port}/{room_name}") as websocket, WebsocketProvider( 90 | ydoc, Websocket(websocket, room_name) 91 | ): 92 | output_text = ynotebook.ycells[0]["outputs"][0]["text"] 93 | assert output_text.to_py() == "Hello," 94 | event = Event() 95 | 96 | def callback(_event): 97 | event.set() 98 | 99 | output_text.observe(callback) 100 | 101 | with move_on_after(10): 102 | await event.wait() 103 | 104 | assert output_text.to_py() == "Hello,", " World!" 105 | 106 | 107 | def test_plotly_renderer(): 108 | """This test checks in particular that the type cast is not breaking the data.""" 109 | ydoc = Doc() 110 | ynotebook = YNotebook(ydoc) 111 | nb = stringify_source(json.loads((files_dir / "plotly_renderer.ipynb").read_text())) 112 | ynotebook.source = nb 113 | assert ynotebook.source == nb 114 | -------------------------------------------------------------------------------- /tests/test_ydocs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from pycrdt import Awareness, Doc 5 | 6 | from jupyter_ydoc import YBlob, YNotebook 7 | 8 | 9 | def test_yblob(): 10 | yblob = YBlob() 11 | assert yblob.get() == b"" 12 | yblob.set(b"012") 13 | assert yblob.get() == b"012" 14 | changes = [] 15 | 16 | def callback(topic, event): 17 | changes.append((topic, event)) 18 | 19 | yblob.observe(callback) 20 | yblob.set(b"345") 21 | assert len(changes) == 1 22 | topic, event = changes[0] 23 | assert topic == "source" 24 | assert event.keys["bytes"]["oldValue"] == b"012" 25 | assert event.keys["bytes"]["newValue"] == b"345" 26 | 27 | 28 | def test_ynotebook_undo_manager(): 29 | ynotebook = YNotebook() 30 | cell0 = { 31 | "cell_type": "code", 32 | "source": "Hello", 33 | } 34 | ynotebook.append_cell(cell0) 35 | source = ynotebook.ycells[0]["source"] 36 | source += ", World!\n" 37 | cell1 = { 38 | "cell_type": "code", 39 | "source": "print(1 + 1)\n", 40 | } 41 | ynotebook.append_cell(cell1) 42 | assert len(ynotebook.ycells) == 2 43 | assert str(ynotebook.ycells[0]["source"]) == "Hello, World!\n" 44 | assert str(ynotebook.ycells[1]["source"]) == "print(1 + 1)\n" 45 | assert ynotebook.undo_manager.can_undo() 46 | ynotebook.undo_manager.undo() 47 | assert len(ynotebook.ycells) == 1 48 | assert str(ynotebook.ycells[0]["source"]) == "Hello, World!\n" 49 | assert ynotebook.undo_manager.can_undo() 50 | ynotebook.undo_manager.undo() 51 | assert len(ynotebook.ycells) == 1 52 | assert str(ynotebook.ycells[0]["source"]) == "Hello" 53 | assert ynotebook.undo_manager.can_undo() 54 | ynotebook.undo_manager.undo() 55 | assert len(ynotebook.ycells) == 0 56 | assert not ynotebook.undo_manager.can_undo() 57 | 58 | 59 | def test_awareness(): 60 | yblob = YBlob() 61 | assert yblob.awareness is None 62 | 63 | ydoc = Doc() 64 | awareness = Awareness(ydoc) 65 | yblob = YBlob(ydoc, awareness) 66 | assert yblob.awareness == awareness 67 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | from anyio import Lock, connect_tcp 5 | 6 | 7 | class Websocket: 8 | def __init__(self, websocket, path: str): 9 | self._websocket = websocket 10 | self._path = path 11 | self._send_lock = Lock() 12 | 13 | @property 14 | def path(self) -> str: 15 | return self._path 16 | 17 | def __aiter__(self): 18 | return self 19 | 20 | async def __anext__(self) -> bytes: 21 | try: 22 | message = await self.recv() 23 | except Exception: 24 | raise StopAsyncIteration() 25 | return message 26 | 27 | async def send(self, message: bytes): 28 | async with self._send_lock: 29 | await self._websocket.send_bytes(message) 30 | 31 | async def recv(self) -> bytes: 32 | b = await self._websocket.receive_bytes() 33 | return bytes(b) 34 | 35 | 36 | async def ensure_server_running(host: str, port: int) -> None: 37 | while True: 38 | try: 39 | await connect_tcp(host, port) 40 | except OSError: 41 | pass 42 | else: 43 | break 44 | -------------------------------------------------------------------------------- /tests/yjs_client_0.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Jupyter Development Team. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | import { YNotebook } from '@jupyter/ydoc' 7 | import { WebsocketProvider } from 'y-websocket' 8 | 9 | const port = process.argv[2] 10 | const notebook = new YNotebook() 11 | const ytest = notebook.ydoc.getMap('_test') 12 | import ws from 'ws' 13 | 14 | const wsProvider = new WebsocketProvider( 15 | `ws://127.0.0.1:${port}`, 'my-roomname', 16 | notebook.ydoc, 17 | { WebSocketPolyfill: ws } 18 | ) 19 | 20 | wsProvider.on('status', event => { 21 | console.log(event.status) 22 | }) 23 | 24 | var clock = -1 25 | 26 | ytest.observe(event => { 27 | event.changes.keys.forEach((change, key) => { 28 | if (key === 'clock') { 29 | const clk = ytest.get('clock') 30 | if (clk > clock) { 31 | const cells = [] 32 | for (let cell of notebook.cells) { 33 | cells.push(cell.toJSON()) 34 | } 35 | const metadata = notebook.getMetadata() 36 | const nbformat = notebook.nbformat 37 | const nbformat_minor = notebook.nbformat_minor 38 | const source = { 39 | cells, 40 | metadata, 41 | nbformat, 42 | nbformat_minor 43 | } 44 | ytest.set('source', source) 45 | clock = clk + 1 46 | ytest.set('clock', clock) 47 | } 48 | } 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /tests/yjs_client_1.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) Jupyter Development Team. 3 | * Distributed under the terms of the Modified BSD License. 4 | */ 5 | 6 | import { YNotebook } from '@jupyter/ydoc' 7 | import { WebsocketProvider } from 'y-websocket' 8 | import ws from 'ws' 9 | 10 | const port = process.argv[2] 11 | const notebook = new YNotebook() 12 | 13 | const wsProvider = new WebsocketProvider( 14 | `ws://127.0.0.1:${port}`, 'my-roomname', 15 | notebook.ydoc, 16 | { WebSocketPolyfill: ws } 17 | ) 18 | 19 | wsProvider.on('status', event => { 20 | console.log(event.status) 21 | }) 22 | 23 | notebook.changed.connect(() => { 24 | const cell = notebook.getCell(0) 25 | if (cell) { 26 | const youtput = cell.youtputs.get(0) 27 | const text = youtput.get('text') 28 | if (text.length === 1) { 29 | text.insert(1, [' World!']) 30 | } 31 | } 32 | }) 33 | --------------------------------------------------------------------------------