diff --git a/.github/actions/setup-sqlite-version/action.yml b/.github/actions/setup-sqlite-version/action.yml deleted file mode 100644 index fdbc71c9..00000000 --- a/.github/actions/setup-sqlite-version/action.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: "Setup SQLite version" -description: "Build and activate a specific SQLite version from its amalgamation archive" -inputs: - version: - description: "The SQLite version to install" - required: true - cflags: - description: "CFLAGS to use when compiling SQLite" - required: false - default: "" - skip-activate: - description: "Set to true to skip modifying the library path" - required: false - default: "false" - fallback-urls: - description: "Whitespace-separated fallback download URLs to try after sqlite.org" - required: false - default: "" -outputs: - sqlite-location: - description: "Directory containing the compiled SQLite library" - value: ${{ steps.build.outputs.sqlite-location }} -runs: - using: "composite" - steps: - - shell: bash - run: mkdir -p "$RUNNER_TEMP/sqlite-versions/downloads" - - uses: actions/cache@v6 - with: - path: ${{ runner.temp }}/sqlite-versions/downloads - key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1 - - id: build - shell: bash - run: bash "$GITHUB_ACTION_PATH/setup-sqlite-version.sh" - env: - SQLITE_VERSION: ${{ inputs.version }} - SQLITE_CFLAGS: ${{ inputs.cflags }} - SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }} - SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }} diff --git a/.github/actions/setup-sqlite-version/setup-sqlite-version.sh b/.github/actions/setup-sqlite-version/setup-sqlite-version.sh deleted file mode 100644 index 03d6a68f..00000000 --- a/.github/actions/setup-sqlite-version/setup-sqlite-version.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -version_spec="${SQLITE_VERSION:?SQLITE_VERSION is required}" -cflags="${SQLITE_CFLAGS:-}" -skip_activate="${SQLITE_SKIP_ACTIVATE:-false}" -extra_fallback_urls="${SQLITE_EXTRA_FALLBACK_URLS:-}" - -case "$version_spec" in - 3.46 | 3.46.0) - sqlite_version="3.46.0" - sqlite_year="2024" - amalgamation_id="3460000" - builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip" - ;; - 3.25 | 3.25.0) - sqlite_version="3.25.0" - sqlite_year="2018" - amalgamation_id="3250000" - builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3250000.zip?v=1" - ;; - *) - echo "::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh." - exit 1 - ;; -esac - -case "$(uname -s)" in - Linux) - library_name="libsqlite3.so.0" - library_path_var="LD_LIBRARY_PATH" - ;; - Darwin) - library_name="libsqlite3.dylib" - library_path_var="DYLD_LIBRARY_PATH" - ;; - *) - echo "::error::Unsupported platform $(uname -s)" - exit 1 - ;; -esac - -runner_temp="${RUNNER_TEMP:-}" -if [ -z "$runner_temp" ]; then - runner_temp="$(mktemp -d)" -fi - -filename="sqlite-amalgamation-${amalgamation_id}" -official_url="https://www.sqlite.org/${sqlite_year}/${filename}.zip" -download_dir="${runner_temp}/sqlite-versions/downloads" -source_root="${runner_temp}/sqlite-versions/source" -source_dir="${source_root}/${filename}" -build_dir="${runner_temp}/sqlite-versions/build/${sqlite_version}" -archive_path="${download_dir}/${filename}.zip" - -mkdir -p "$download_dir" "$source_root" "$build_dir" - -download_archive() { - local url - local candidate_path="${archive_path}.tmp" - local urls=("$official_url") - - for url in $builtin_fallback_urls $extra_fallback_urls; do - urls+=("$url") - done - - rm -f "$candidate_path" - for url in "${urls[@]}"; do - echo "Downloading SQLite ${sqlite_version} amalgamation from ${url}" - if curl \ - --fail \ - --location \ - --show-error \ - --retry 5 \ - --retry-delay 2 \ - --retry-max-time 180 \ - --retry-all-errors \ - --connect-timeout 20 \ - --max-time 240 \ - --output "$candidate_path" \ - "$url"; then - mv "$candidate_path" "$archive_path" - return 0 - fi - - echo "::warning::Download failed from ${url}" - rm -f "$candidate_path" - done - - echo "::error::Could not download SQLite ${sqlite_version} amalgamation" - return 1 -} - -if [ ! -f "${source_dir}/sqlite3.c" ]; then - if [ ! -f "$archive_path" ]; then - download_archive - fi - - rm -rf "$source_dir" - unzip -q "$archive_path" -d "$source_root" -fi - -if [ ! -f "${source_dir}/sqlite3.c" ]; then - echo "::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}" - exit 1 -fi - -read -r -a cflag_args <<< "$cflags" - -echo "Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}" -gcc \ - -fPIC \ - -shared \ - "${cflag_args[@]}" \ - "${source_dir}/sqlite3.c" \ - "-I${source_dir}" \ - -o "${build_dir}/${library_name}" - -if [ "$library_name" = "libsqlite3.so.0" ]; then - ln -sf "$library_name" "${build_dir}/libsqlite3.so" -fi - -if [ -n "${GITHUB_OUTPUT:-}" ]; then - echo "sqlite-location=${build_dir}" >> "$GITHUB_OUTPUT" -else - echo "sqlite-location=${build_dir}" -fi - -case "$(printf '%s' "$skip_activate" | tr '[:upper:]' '[:lower:]')" in - true | 1 | yes) - echo "Skipping ${library_path_var} activation" - ;; - *) - existing_value="${!library_path_var:-}" - if [ -n "${GITHUB_ENV:-}" ]; then - if [ -n "$existing_value" ]; then - echo "${library_path_var}=${build_dir}:${existing_value}" >> "$GITHUB_ENV" - else - echo "${library_path_var}=${build_dir}" >> "$GITHUB_ENV" - fi - fi - echo "Added ${build_dir} to ${library_path_var}" - ;; -esac diff --git a/.github/workflows/deploy-branch-preview.yml b/.github/workflows/deploy-branch-preview.yml new file mode 100644 index 00000000..e56d9c27 --- /dev/null +++ b/.github/workflows/deploy-branch-preview.yml @@ -0,0 +1,35 @@ +name: Deploy a Datasette branch preview to Vercel + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch to deploy" + required: true + type: string + +jobs: + deploy-branch-preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Install dependencies + run: | + pip install datasette-publish-vercel + - name: Deploy the preview + env: + VERCEL_TOKEN: ${{ secrets.BRANCH_PREVIEW_VERCEL_TOKEN }} + run: | + export BRANCH="${{ github.event.inputs.branch }}" + wget https://latest.datasette.io/fixtures.db + datasette publish vercel fixtures.db \ + --branch $BRANCH \ + --project "datasette-preview-$BRANCH" \ + --token $VERCEL_TOKEN \ + --scope datasette \ + --about "Preview of $BRANCH" \ + --about_url "https://github.com/simonw/datasette/tree/$BRANCH" diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 3fc83438..7349a1ab 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out datasette - uses: actions/checkout@v7 + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 with: @@ -57,7 +57,7 @@ jobs: db.route = "alternative-route" ' > plugins/alternative_route.py cp fixtures.db fixtures2.db - - name: And the counters writable stored query demo + - name: And the counters writable canned query demo run: | cat > plugins/counters.py <=0.2.2' \ --service "datasette-latest$SUFFIX" \ --secret $LATEST_DATASETTE_SECRET diff --git a/.github/workflows/documentation-links.yml b/.github/workflows/documentation-links.yml new file mode 100644 index 00000000..a54bd83a --- /dev/null +++ b/.github/workflows/documentation-links.yml @@ -0,0 +1,16 @@ +name: Read the Docs Pull Request Preview +on: + pull_request_target: + types: + - opened + +permissions: + pull-requests: write + +jobs: + documentation-links: + runs-on: ubuntu-latest + steps: + - uses: readthedocs/actions/preview@v1 + with: + project-slug: "datasette" diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index f5b8dbf6..00000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Playwright - -on: - push: - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - browser: [chromium, firefox, webkit] - steps: - - uses: actions/checkout@v7 - - name: Set up Python 3.14 - uses: actions/setup-python@v6 - with: - python-version: "3.14" - allow-prereleases: true - cache: pip - cache-dependency-path: pyproject.toml - - name: Cache uv - uses: actions/cache@v6 - with: - path: ~/.cache/uv - key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-py3.14-uv- - - name: Cache Playwright browsers - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright/ - key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-playwright-${{ matrix.browser }}- - - name: Install uv - run: python -m pip install uv - - name: Install dependencies - run: uv sync --group dev --group playwright - - name: Install ${{ matrix.browser }} - run: uv run --group dev --group playwright playwright install --with-deps ${{ matrix.browser }} - - name: Run Playwright tests - run: uv run --group dev --group playwright pytest tests/test_playwright.py --playwright --browser ${{ matrix.browser }} diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index d92ab82b..77cce7d1 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -10,8 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repo - uses: actions/checkout@v7 - - uses: actions/cache@v6 + uses: actions/checkout@v4 + - uses: actions/cache@v4 name: Configure npm caching with: path: ~/.npm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 21ed4c12..2e8cea9c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: @@ -35,7 +35,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v6 with: @@ -56,7 +56,7 @@ jobs: needs: [deploy] if: "!github.event.release.prerelease" steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v6 with: @@ -92,7 +92,7 @@ jobs: needs: [deploy] if: "!github.event.release.prerelease" steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Build and push to Docker Hub env: DOCKER_USER: ${{ secrets.DOCKER_USER }} diff --git a/.github/workflows/push_docker_tag.yml b/.github/workflows/push_docker_tag.yml index c5a4f0db..afe8d6b2 100644 --- a/.github/workflows/push_docker_tag.yml +++ b/.github/workflows/push_docker_tag.yml @@ -13,7 +13,7 @@ jobs: deploy_docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v2 - name: Build and push to Docker Hub env: DOCKER_USER: ${{ secrets.DOCKER_USER }} diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index 58635025..d42ae96b 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -9,7 +9,7 @@ jobs: spellcheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/stable-docs.yml b/.github/workflows/stable-docs.yml index ecde5940..3119d617 100644 --- a/.github/workflows/stable-docs.yml +++ b/.github/workflows/stable-docs.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@v5 with: fetch-depth: 0 # We need all commits to find docs/ changes - name: Set up Git user diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index e9bd4bab..1b3d2f2c 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out datasette - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/test-pyodide.yml b/.github/workflows/test-pyodide.yml index 5e81ed82..b490a9bf 100644 --- a/.github/workflows/test-pyodide.yml +++ b/.github/workflows/test-pyodide.yml @@ -12,7 +12,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Set up Python 3.10 uses: actions/setup-python@v6 with: @@ -20,7 +20,7 @@ jobs: cache: 'pip' cache-dependency-path: '**/pyproject.toml' - name: Cache Playwright browsers - uses: actions/cache@v6 + uses: actions/cache@v4 with: path: ~/.cache/ms-playwright/ key: ${{ runner.os }}-browsers diff --git a/.github/workflows/test-sqlite-support.yml b/.github/workflows/test-sqlite-support.yml index 2fdb3a40..c81a3c0b 100644 --- a/.github/workflows/test-sqlite-support.yml +++ b/.github/workflows/test-sqlite-support.yml @@ -25,7 +25,7 @@ jobs: #"3.23.1" # 2018-04-10, before UPSERT ] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: @@ -34,7 +34,7 @@ jobs: cache: pip cache-dependency-path: pyproject.toml - name: Set up SQLite ${{ matrix.sqlite-version }} - uses: ./.github/actions/setup-sqlite-version + uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6 with: version: ${{ matrix.sqlite-version }} cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 751eedfd..b1ba3232 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,11 +9,10 @@ jobs: test: runs-on: ubuntu-latest strategy: - fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: @@ -35,9 +34,7 @@ jobs: # And the test that exceeds a localhost HTTPS server tests/test_datasette_https_server.sh - name: Black - run: | - black --version - black --check . + run: black --check . - name: Ruff run: ruff check datasette tests - name: Check if cog needs to be run diff --git a/.github/workflows/tmate-mac.yml b/.github/workflows/tmate-mac.yml index f2c074a6..fcee0f21 100644 --- a/.github/workflows/tmate-mac.yml +++ b/.github/workflows/tmate-mac.yml @@ -10,6 +10,6 @@ jobs: build: runs-on: macos-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v2 - name: Setup tmate session uses: mxschmitt/action-tmate@v3 diff --git a/.github/workflows/tmate.yml b/.github/workflows/tmate.yml index 5b8818c3..123f6c71 100644 --- a/.github/workflows/tmate.yml +++ b/.github/workflows/tmate.yml @@ -11,7 +11,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v2 - name: Setup tmate session uses: mxschmitt/action-tmate@v3 env: diff --git a/.gitignore b/.gitignore index 2a7f6620..12acd87e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,8 @@ build-metadata.json datasets.json -.playwright-mcp - scratchpad -ignored/ - .vscode uv.lock @@ -135,4 +131,4 @@ tests/*.dylib tests/*.so tests/*.dll -.idea +.idea \ No newline at end of file diff --git a/Justfile b/Justfile index 6ffff870..657881be 100644 --- a/Justfile +++ b/Justfile @@ -11,33 +11,16 @@ export DATASETTE_SECRET := "not_a_secret" @test *options: init uv run pytest -n auto {{options}} -# Install Playwright browser support, Chromium by default -@playwright-install browser="chromium": - uv run --group playwright playwright install {{browser}} - -# Install all Playwright browsers used by the test suite -@playwright-install-all: - uv run --group playwright playwright install chromium firefox webkit - -# Run Playwright tests, Chromium by default -@playwright browser="chromium" *options: - uv run --group playwright pytest tests/test_playwright.py --playwright --browser {{browser}} {{options}} - -# Run Playwright tests against all supported browsers -@playwright-all *options: - uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium --browser firefox --browser webkit {{options}} - @codespell: uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt uv run codespell tests --ignore-words docs/codespell-ignore-words.txt -# Run linters: black, ruff, prettier, cog +# Run linters: black, ruff, cog @lint: codespell uv run black datasette tests --check uv run ruff check datasette tests - npm run prettier -- --check uv run cog --check README.md docs/*.rst # Apply ruff fixes diff --git a/datasette/__init__.py b/datasette/__init__.py index e0022178..47d2b4f6 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,14 +1,7 @@ from datasette.permissions import Permission # noqa from datasette.version import __version_info__, __version__ # noqa from datasette.events import Event # noqa -from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa -from datasette.utils.asgi import ( # noqa - Forbidden, - NotFound, - PayloadTooLarge, - Request, - Response, -) +from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa from datasette.utils import actor_matches_allow # noqa from datasette.views import Context # noqa from .hookspecs import hookimpl # noqa diff --git a/datasette/_pytest_plugin.py b/datasette/_pytest_plugin.py deleted file mode 100644 index 103c616d..00000000 --- a/datasette/_pytest_plugin.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Pytest plugin that automatically closes any Datasette instances constructed -during a pytest test — both in the test body and in function-scoped -fixtures. Instances constructed by session-, module-, class- or package- -scoped fixtures are left alone, because other tests in the session will -still want to use them. - -Registered as a pytest11 entry point in pyproject.toml so that downstream -projects using Datasette get the same FD-safety net for their own tests. - -Opt out by setting ``datasette_autoclose = false`` in pytest.ini (or the -equivalent ini file). -""" - -from __future__ import annotations - -import contextvars -import weakref - -import pytest - -_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar( - "datasette_active_instances", default=None -) - -_original_init = None - - -def _install_tracking(): - # datasette.app is imported lazily here rather than at module level: - # as a pytest11 entry point this module is imported during pytest - # startup, before pytest-cov starts measuring, so a module-level - # import would drag in all of datasette and make every import-time - # line in the package invisible to coverage - global _original_init - if _original_init is not None: - return - from datasette.app import Datasette - - _original_init = Datasette.__init__ - - def _tracking_init(self, *args, **kwargs): - _original_init(self, *args, **kwargs) - instances = _active_instances.get() - if instances is not None: - instances.append(weakref.ref(self)) - - Datasette.__init__ = _tracking_init - - -def pytest_configure(config): - if _enabled(config): - _install_tracking() - - -def pytest_addoption(parser): - parser.addini( - "datasette_autoclose", - help=( - "Automatically close Datasette instances created inside test " - "bodies and function-scoped fixtures (default: true)." - ), - default="true", - ) - - -def _enabled(config) -> bool: - value = config.getini("datasette_autoclose") - if isinstance(value, bool): - return value - return str(value).strip().lower() not in ("false", "0", "no", "off") - - -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_protocol(item, nextitem): - """Track Datasette instances across setup, call and teardown; close at end.""" - if not _enabled(item.config): - yield - return - refs: list[weakref.ref] = [] - token = _active_instances.set(refs) - try: - yield - finally: - _active_instances.reset(token) - for ref in reversed(refs): - ds = ref() - if ds is None: - continue - try: - ds.close() - except Exception as e: - item.warn( - pytest.PytestUnraisableExceptionWarning( - f"Error closing Datasette instance: {e!r}" - ) - ) - - -@pytest.hookimpl(hookwrapper=True) -def pytest_fixture_setup(fixturedef, request): - """Exempt instances created by non-function-scoped fixtures. - - Session-, module-, class- and package-scoped fixtures produce Datasette - instances that must survive beyond the current test — other tests in - the session will still use them. When such a fixture creates one or - more Datasette instances during its setup, we snapshot the tracking - list before the fixture runs and subtract off any instances that were - added during its setup, so they don't get closed at test teardown. - """ - refs = _active_instances.get() - if refs is None: - yield - return - before_ids = {id(ref) for ref in refs} - yield - if fixturedef.scope != "function": - new_refs = [ref for ref in refs if id(ref) not in before_ids] - for new_ref in new_refs: - try: - refs.remove(new_ref) - except ValueError: - pass diff --git a/datasette/app.py b/datasette/app.py index 0e31273d..75f6071e 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1,17 +1,19 @@ from __future__ import annotations +from asgi_csrf import Errors import asyncio import contextvars -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence +from typing import TYPE_CHECKING, Any, Dict, Iterable, List if TYPE_CHECKING: from datasette.permissions import Resource - from datasette.tokens import TokenRestrictions +import asgi_csrf import collections import dataclasses import datetime import functools import glob +import hashlib import httpx import importlib.metadata import inspect @@ -34,44 +36,18 @@ from jinja2 import ( ChoiceLoader, Environment, FileSystemLoader, - pass_context, PrefixLoader, ) from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound from .events import Event -from .column_types import SQLiteType -from . import stored_queries, write_sql from .views import Context -from .views.database import ( - database_download, - DatabaseView, - QueryView, -) -from .views.table_create_alter import ( - DatabaseForeignKeyTargetsView, - TableAlterView, - TableCreateView, - TableForeignKeySuggestionsView, -) -from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView -from .views.stored_queries import ( - QueryCreateAnalyzeView, - QueryDeleteView, - QueryDefinitionView, - QueryEditView, - GlobalQueryListView, - QueryListView, - QueryParametersView, - QueryStoreView, - QueryUpdateView, -) +from .views.database import database_download, DatabaseView, TableCreateView, QueryView from .views.index import IndexView from .views.special import ( JsonDataView, PatternPortfolioView, - AutocompleteDebugView, AuthTokenView, ApiExplorerView, CreateTokenView, @@ -82,18 +58,15 @@ from .views.special import ( AllowedResourcesView, PermissionRulesView, PermissionCheckView, - JumpView, + TablesView, InstanceSchemaView, DatabaseSchemaView, TableSchemaView, ) from .views.table import ( - TableAutocompleteView, TableInsertView, TableUpsertView, - TableSetColumnTypeView, TableDropView, - TableFragmentView, table_view, ) from .views.row import RowView, RowDeleteView, RowUpdateView @@ -111,7 +84,6 @@ from .utils import ( baseconv, call_with_supported_arguments, detect_json1, - add_cors_headers, display_actor, escape_css_string, escape_sqlite, @@ -123,7 +95,6 @@ from .utils import ( parse_metadata, resolve_env_secrets, resolve_routes, - sha256_file, tilde_decode, tilde_encode, to_css_class, @@ -131,10 +102,8 @@ from .utils import ( redact_keys, row_sql_params_pks, ) -from .tokens import TokenInvalid from .utils.asgi import ( AsgiLifespan, - BadRequest, Forbidden, NotFound, DatabaseNotFound, @@ -148,7 +117,6 @@ from .utils.asgi import ( asgi_send_file, asgi_send_redirect, ) -from .csrf import CrossOriginProtectionMiddleware from .utils.internal_db import init_internal_db, populate_schema_tables from .utils.sqlite import ( sqlite3, @@ -209,11 +177,6 @@ SETTINGS = ( 100, "Maximum rows that can be inserted at a time using the bulk insert API", ), - Setting( - "max_post_body_bytes", - 2 * 1024 * 1024, - "Maximum size in bytes for a POST body read into memory, e.g. JSON API requests - set 0 to disable this limit", - ), Setting( "num_sql_threads", 3, @@ -308,21 +271,12 @@ DEFAULT_NOT_SET = object() ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params")) -def _permission_cache_key(actor, action, parent, child): - # Key on the full serialized actor so actors differing in any field - # (e.g. token restrictions) never share cache entries - actor_key = ( - json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None - ) - return (actor_key, action, parent, child) - - async def favicon(request, send): await asgi_send_file( send, str(FAVICON_PATH), content_type="image/png", - headers={"Cache-Control": "max-age=3600, public"}, + headers={"Cache-Control": "max-age=3600, immutable, public"}, ) @@ -339,57 +293,6 @@ def _to_string(value): return json.dumps(value, default=str) -def _template_context_json_default(value): - if dataclasses.is_dataclass(value) and not isinstance(value, type): - return { - field.name: getattr(value, field.name) - for field in dataclasses.fields(value) - } - return repr(value) - - -@pass_context -def _legacy_template_csrftoken(context): - request = context.get("request") - if request and "csrftoken" in request.scope: - return request.scope["csrftoken"]() - return "" - - -def _resolve_static_asset_path(root_path, path): - root = Path(root_path).resolve() - full_path = (root / path).resolve() - try: - full_path.relative_to(root) - except ValueError: - raise ValueError("Static asset path cannot escape static root") from None - return full_path - - -# Documentation for the variables Datasette.render_template() adds to the -# context for every page. This is part of the documented template contract: -# keys added in render_template() must be documented here - the contract -# tests in tests/test_template_context.py enforce this, and the docs in -# docs/template_context.rst are generated from it. -TEMPLATE_BASE_CONTEXT = { - "request": "The current :ref:`Request object `, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.", - "crumb_items": 'Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.', - "urls": "Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.", - "actor": "The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.", - "menu_links": "Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.", - "display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.", - "show_logout": "True if the logout link should be shown in the navigation menu", - "zip": "Python's ``zip()`` builtin, made available to template logic", - "body_scripts": 'List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in `` + """.format( + markupsafe.escape(ex.sql) + ) + ).strip(), + title="SQL Interrupted", + status=400, + message_is_html=True, + ) + except sqlite3.DatabaseError as ex: + query_error = str(ex) + except (sqlite3.OperationalError, InvalidSql) as ex: + raise DatasetteError(str(ex), title="Invalid SQL", status=400) + + return columns, rows, query_error + + async def display_rows(self, rows, columns): + """Format raw result rows into display-ready values. + + Returns a list of lists of display values (strings or Markup). + Override to customise how cell values are rendered. + """ + return await _display_rows( + self.datasette, self.database, self.request, rows, columns + ) + + async def get_templates(self): + """Return ordered list of Jinja template names to try. + + Override to use a custom template for your page. + """ + if self._templates: + return list(self._templates) + templates = [ + "query-{}.html".format(to_css_class(self.database)), + "query.html", + ] + if self.canned_query: + templates.insert( + 0, + "query-{}-{}.html".format( + to_css_class(self.database), + to_css_class(self.canned_query["name"]), + ), + ) + return templates + + async def extra_context(self): + """Return a dict of extra template context variables. + + Override to inject additional context into the template. This is + the simplest customisation point for adding data to the page. + """ + return {} + + async def query_actions(self): + """Return list of action links for the query action menu. + + Override to add custom action links. + """ + links = [] + for hook in pm.hook.query_actions( + datasette=self.datasette, + actor=self.request.actor, + database=self.database, + query_name=( + self.canned_query["name"] if self.canned_query else None + ), + request=self.request, + sql=self.sql, + params=self.params, + ): + extra_links = await await_me_maybe(hook) + if extra_links: + links.extend(extra_links) + return links + + # ------------------------------------------------------------------ + # Core response method + # ------------------------------------------------------------------ + + async def response(self): + """Execute the query and return the appropriate :class:`Response`. + + Dispatches to HTML, JSON, CSV, or a plugin renderer based on the + requested format (derived from the URL extension or query params). + + Returns: + :class:`~datasette.utils.asgi.Response` + """ + format_ = self.request.url_vars.get("format") or "html" + + columns, rows, error = await self.execute_query() + + if format_ == "csv": + return await self._csv_response() + + if format_ in self.datasette.renderers.keys(): + return await self._renderer_response( + format_, columns, rows, error + ) + + if format_ == "html": + return await self._html_response(columns, rows, error) + + raise NotFound("Invalid format: {}".format(format_)) + + # ------------------------------------------------------------------ + # Format-specific response builders + # ------------------------------------------------------------------ + + async def _csv_response(self): + """Return a streaming CSV response.""" + sql = self.sql + params = self.params + + async def fetch_data_for_csv(request, _next=None): + db = self.datasette.get_database(self.database) + results = await db.execute(sql, params, truncate=True) + data = {"rows": results.rows, "columns": results.columns} + return data, None, None + + return await stream_csv( + self.datasette, fetch_data_for_csv, self.request, self.database + ) + + async def _renderer_response(self, format_, columns, rows, error): + """Dispatch to a plugin output renderer.""" + result = call_with_supported_arguments( + self.datasette.renderers[format_][0], + datasette=self.datasette, + columns=columns, + rows=rows, + sql=self.sql, + query_name=( + self.canned_query["name"] if self.canned_query else None + ), + database=self.database, + table=None, + request=self.request, + view_name="query", + truncated=self.truncated, + error=error, + # Deprecated but kept for backwards compat: + args=self.request.args, + data={"ok": True, "rows": rows, "columns": columns}, + ) + if asyncio.iscoroutine(result): + result = await result + if result is None: + raise NotFound("No data") + + if isinstance(result, dict): + r = Response( + body=result.get("body"), + status=result.get("status_code") or 200, + content_type=result.get("content_type", "text/plain"), + headers=result.get("headers"), + ) + elif isinstance(result, Response): + r = result + else: + raise AssertionError( + "{} should be dict or Response".format(result) + ) + if self.datasette.cors: + add_cors_headers(r.headers) + return r + + async def _html_response(self, columns, rows, error): + """Build and return the HTML response.""" + datasette = self.datasette + request = self.request + database = self.database + db = datasette.get_database(database) + + templates = await self.get_templates() + environment = datasette.get_jinja_environment(request) + template = environment.select_template(templates) + + alternate_url_json = datasette.absolute_url( + request, + datasette.urls.path( + path_with_format(request=request, format="json") + ), + ) + headers = { + "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format( + alternate_url_json + ) + } + + metadata = await datasette.get_database_metadata(database) + display_rows = await self.display_rows(rows, columns) + + # Named parameters + named_parameters = [] + if self.canned_query and self.canned_query.get("params"): + named_parameters = self.canned_query["params"] + if not named_parameters and self.sql: + named_parameters = derive_named_parameters(self.sql) + named_parameter_values = { + p: self.params.get(p) or "" + for p in named_parameters + if not p.startswith("_") + } + + # Renderers + renderers = await self._available_renderers(columns, rows) + + allow_execute_sql = await datasette.allowed( + action="execute-sql", + resource=DatabaseResource(database=database), + actor=request.actor, + ) + + # Show/hide SQL controls + canned_query_write = bool( + self.canned_query and self.canned_query.get("write") + ) + show_hide_hidden, hide_sql, show_hide_link, show_hide_text = ( + self._show_hide_sql_controls() + ) + + # Edit SQL URL + edit_sql_url = self._edit_sql_url( + allow_execute_sql, named_parameter_values + ) + + # Tables for autocomplete + from datasette.views.database import ( + get_tables, + _table_columns, + ) + + allowed_tables_page = await datasette.allowed_resources( + "view-table", + request.actor, + parent=database, + include_is_private=True, + limit=1000, + ) + allowed_dict = {r.child: r for r in allowed_tables_page.resources} + + context = { + "database": database, + "database_color": db.color, + "query": {"sql": self.sql, "params": self.params}, + "canned_query": ( + self.canned_query["name"] if self.canned_query else None + ), + "private": self.private, + "canned_query_write": canned_query_write, + "db_is_immutable": not db.is_mutable, + "error": error, + "hide_sql": hide_sql, + "show_hide_link": datasette.urls.path(show_hide_link), + "show_hide_text": show_hide_text, + "editable": self.editable and not self.canned_query, + "allow_execute_sql": allow_execute_sql, + "tables": await get_tables( + datasette, request, db, allowed_dict + ), + "named_parameter_values": named_parameter_values, + "edit_sql_url": edit_sql_url, + "display_rows": display_rows, + "table_columns": ( + await _table_columns(datasette, database) + if allow_execute_sql + else {} + ), + "columns": columns, + "renderers": renderers, + "url_csv": datasette.urls.path( + path_with_format( + request=request, + format="csv", + extra_qs={"_size": "max"}, + ) + ), + "show_hide_hidden": markupsafe.Markup(show_hide_hidden), + "metadata": self.canned_query or metadata, + "alternate_url_json": alternate_url_json, + "select_templates": [ + "{}{}".format( + "*" if t == template.name else "", t + ) + for t in templates + ], + "top_query": make_slot_function( + "top_query", + datasette, + request, + database=database, + sql=self.sql, + ), + "top_canned_query": make_slot_function( + "top_canned_query", + datasette, + request, + database=database, + query_name=( + self.canned_query["name"] + if self.canned_query + else None + ), + ), + "query_actions": self.query_actions, + } + + # Merge extra context from subclass + extra_ctx = await self.extra_context() + if extra_ctx: + context.update(extra_ctx) + + # Merge extra context from constructor + if self._extra_template_context: + context.update(self._extra_template_context) + + r = Response.html( + await datasette.render_template( + template, + context, + request=request, + view_name="database", + ), + headers=headers, + ) + if datasette.cors: + add_cors_headers(r.headers) + return r + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _show_hide_sql_controls(self): + """Compute the show/hide SQL toggle state. + + Returns (show_hide_hidden, hide_sql, show_hide_link, show_hide_text). + """ + params = {key: self.request.args.get(key) for key in self.request.args} + show_hide_hidden = "" + + if self.canned_query and self.canned_query.get("hide_sql"): + if bool(params.get("_show_sql")): + show_hide_link = path_with_removed_args( + self.request, {"_show_sql"} + ) + show_hide_text = "hide" + show_hide_hidden = ( + '' + ) + else: + show_hide_link = path_with_added_args( + self.request, {"_show_sql": 1} + ) + show_hide_text = "show" + else: + if bool(params.get("_hide_sql")): + show_hide_link = path_with_removed_args( + self.request, {"_hide_sql"} + ) + show_hide_text = "show" + show_hide_hidden = ( + '' + ) + else: + show_hide_link = path_with_added_args( + self.request, {"_hide_sql": 1} + ) + show_hide_text = "hide" + + hide_sql = show_hide_text == "show" + return show_hide_hidden, hide_sql, show_hide_link, show_hide_text + + def _edit_sql_url(self, allow_execute_sql, named_parameter_values): + """Build the 'Edit SQL' URL for canned queries, or None.""" + if not self.canned_query: + return None + + is_validated = False + try: + validate_sql_select(self.sql) + is_validated = True + except InvalidSql: + pass + + if allow_execute_sql and is_validated and ":_" not in self.sql: + from urllib.parse import urlencode + + return ( + self.datasette.urls.database(self.database) + + "/-/query" + + "?" + + urlencode({"sql": self.sql, **named_parameter_values}) + ) + return None + + async def _available_renderers(self, columns, rows): + """Build dict of {renderer_name: url} for available output formats.""" + renderers = {} + for key, (_, can_render) in self.datasette.renderers.items(): + it_can_render = call_with_supported_arguments( + can_render, + datasette=self.datasette, + columns=columns or [], + rows=rows or [], + sql=self.sql, + query_name=( + self.canned_query["name"] if self.canned_query else None + ), + database=self.database, + table=None, + request=self.request, + view_name="database", + ) + it_can_render = await await_me_maybe(it_can_render) + if it_can_render: + renderers[key] = self.datasette.urls.path( + path_with_format(request=self.request, format=key) + ) + return renderers + + # ------------------------------------------------------------------ + # Class-level convenience for use as a route handler + # ------------------------------------------------------------------ + + @classmethod + def view(cls, datasette, database, sql, **kwargs): + """Return an async view function suitable for use with register_routes. + + Usage:: + + @hookimpl + def register_routes(datasette): + return [ + (r"/my-query", QueryPage.view( + datasette, + database="_internal", + sql="select * from catalog_tables", + )), + ] + """ + + async def _view(datasette_arg, request): + page = cls( + datasette_arg, + request, + database=database, + sql=sql, + **kwargs, + ) + return await page.response() + + return _view + + +# ------------------------------------------------------------------ +# Shared utility: format rows for display (used by both query and table views) +# ------------------------------------------------------------------ + + +async def _display_rows(datasette, database, request, rows, columns): + """Format raw query result rows into display-ready values. + + Used by both the query page and the table page for rendering cell + values in HTML. Calls the ``render_cell`` plugin hook for each cell. + + Args: + datasette: Datasette instance + database: Database name string + request: Request object + rows: List of row tuples + columns: List of column name strings + + Returns: + List of lists of display values (strings or :class:`markupsafe.Markup`) + """ + display_rows = [] + truncate_cells = datasette.setting("truncate_cells_html") + for row in rows: + display_row = [] + for column, value in zip(columns, row): + display_value = value + # Let plugins have a go + plugin_display_value = None + for candidate in pm.hook.render_cell( + row=row, + value=value, + column=column, + table=None, + database=database, + datasette=datasette, + request=request, + ): + candidate = await await_me_maybe(candidate) + if candidate is not None: + plugin_display_value = candidate + break + if plugin_display_value is not None: + display_value = plugin_display_value + else: + if value in ("", None): + display_value = markupsafe.Markup(" ") + elif is_url(str(display_value).strip()): + display_value = markupsafe.Markup( + '{truncated_url}'.format( + url=markupsafe.escape(value.strip()), + truncated_url=markupsafe.escape( + truncate_url(value.strip(), truncate_cells) + ), + ) + ) + elif isinstance(display_value, bytes): + blob_url = path_with_format( + request=request, + format="blob", + extra_qs={ + "_blob_column": column, + "_blob_hash": hashlib.sha256( + display_value + ).hexdigest(), + }, + ) + formatted = format_bytes(len(value)) + display_value = markupsafe.Markup( + '' + "<Binary: {:,} byte{}>".format( + blob_url, + ( + ' title="{}"'.format(formatted) + if "bytes" not in formatted + else "" + ), + len(value), + "" if len(value) == 1 else "s", + ) + ) + else: + display_value = str(value) + if truncate_cells and len(display_value) > truncate_cells: + display_value = ( + display_value[:truncate_cells] + "\u2026" + ) + display_row.append(display_value) + display_rows.append(display_row) + return display_rows + + +# ------------------------------------------------------------------ +# Shared format dispatch helper (used by table_view too) +# ------------------------------------------------------------------ + + +async def dispatch_renderer( + datasette, + request, + format_, + columns, + rows, + sql, + *, + database, + table=None, + query_name=None, + truncated=False, + error=None, + view_name="table", + data=None, +): + """Dispatch a request to a plugin output renderer. + + This is the shared code used by both the query view and the table + view for handling non-HTML, non-CSV output formats registered by + plugins via the ``register_output_renderer`` hook. + + Args: + datasette: Datasette instance + request: Request object + format_: The format string (e.g. "json") + columns: List of column names + rows: List of result rows + sql: The SQL query string + database: Database name + table: Optional table name + query_name: Optional canned query name + truncated: Whether results were truncated + error: Error message or None + view_name: View name string for plugin hooks + data: Optional full data dict for backwards compat + + Returns: + :class:`Response` object + """ + result = call_with_supported_arguments( + datasette.renderers[format_][0], + datasette=datasette, + columns=columns, + rows=rows, + sql=sql, + query_name=query_name, + database=database, + table=table, + request=request, + view_name=view_name, + truncated=truncated, + error=error, + # Deprecated but kept for backwards compat: + args=request.args, + data=data or {"ok": True, "rows": rows, "columns": columns}, + ) + if asyncio.iscoroutine(result): + result = await result + if result is None: + raise NotFound("No data") + + if isinstance(result, dict): + r = Response( + body=result.get("body"), + status=result.get("status_code") or 200, + content_type=result.get("content_type", "text/plain"), + headers=result.get("headers"), + ) + elif isinstance(result, Response): + r = result + else: + raise AssertionError("{} should be dict or Response".format(result)) + + if datasette.cors: + add_cors_headers(r.headers) + return r diff --git a/datasette/renderer.py b/datasette/renderer.py index 7c94f6ee..acf23e59 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -1,7 +1,5 @@ import json -from datasette.extras import extra_names_from_request from datasette.utils import ( - error_body, value_as_boolean, remove_infinites, CustomJSONEncoder, @@ -53,7 +51,8 @@ def json_renderer(request, args, data, error, truncated=None): if error: shape = "objects" status_code = 400 - data.update(error_body(error, status_code)) + data["error"] = error + data["ok"] = False if truncated is not None: data["truncated"] = truncated @@ -87,8 +86,7 @@ def json_renderer(request, args, data, error, truncated=None): object_rows[pk_string] = row data = object_rows if shape_error: - status_code = 400 - data = error_body(shape_error, status_code) + data = {"ok": False, "error": shape_error} elif shape == "array": data = data["rows"] @@ -101,11 +99,16 @@ def json_renderer(request, args, data, error, truncated=None): data["rows"] = [list(row.values()) for row in data["rows"]] else: status_code = 400 - data = error_body(f"Invalid _shape: {shape}", status_code) + data = { + "ok": False, + "error": f"Invalid _shape: {shape}", + "status": 400, + "title": None, + } # Don't include "columns" in output # https://github.com/simonw/datasette/issues/2136 - if isinstance(data, dict) and "columns" not in extra_names_from_request(request): + if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"): data.pop("columns", None) # Handle _nl option for _shape=array diff --git a/datasette/resources.py b/datasette/resources.py index ee2e6d98..641afb2f 100644 --- a/datasette/resources.py +++ b/datasette/resources.py @@ -13,7 +13,7 @@ class DatabaseResource(Resource): super().__init__(parent=database, child=None) @classmethod - async def resources_sql(cls, datasette, actor=None) -> str: + async def resources_sql(cls, datasette) -> str: return """ SELECT database_name AS parent, NULL AS child FROM catalog_databases @@ -30,7 +30,7 @@ class TableResource(Resource): super().__init__(parent=database, child=table) @classmethod - async def resources_sql(cls, datasette, actor=None) -> str: + async def resources_sql(cls, datasette) -> str: return """ SELECT database_name AS parent, table_name AS child FROM catalog_tables @@ -41,7 +41,7 @@ class TableResource(Resource): class QueryResource(Resource): - """A stored query in a database.""" + """A canned query in a database.""" name = "query" parent_class = DatabaseResource @@ -50,9 +50,41 @@ class QueryResource(Resource): super().__init__(parent=database, child=query) @classmethod - async def resources_sql(cls, datasette, actor=None) -> str: - return """ - SELECT q.database_name AS parent, q.name AS child - FROM queries q - JOIN catalog_databases cd ON cd.database_name = q.database_name - """ + async def resources_sql(cls, datasette) -> str: + from datasette.plugins import pm + from datasette.utils import await_me_maybe + + # Get all databases from catalog + db = datasette.get_internal_database() + result = await db.execute("SELECT database_name FROM catalog_databases") + databases = [row[0] for row in result.rows] + + # Gather all canned queries from all databases + query_pairs = [] + for database_name in databases: + # Call the hook to get queries (including from config via default plugin) + for queries_result in pm.hook.canned_queries( + datasette=datasette, + database=database_name, + actor=None, # Get ALL queries for resource enumeration + ): + queries = await await_me_maybe(queries_result) + if queries: + for query_name in queries.keys(): + query_pairs.append((database_name, query_name)) + + # Build SQL + if not query_pairs: + return "SELECT NULL AS parent, NULL AS child WHERE 0" + + # Generate UNION ALL query + selects = [] + for db_name, query_name in query_pairs: + # Escape single quotes by doubling them + db_escaped = db_name.replace("'", "''") + query_escaped = query_name.replace("'", "''") + selects.append( + f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child" + ) + + return " UNION ALL ".join(selects) diff --git a/datasette/static/app.css b/datasette/static/app.css index d101e4b7..a7fc7fa3 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -63,14 +63,6 @@ em { } /* end reset */ -/* Modal CSS variables (shared by web components via Shadow DOM) */ -:root { - --modal-backdrop-bg: rgba(0, 0, 0, 0.5); - --modal-backdrop-blur: blur(4px); - --modal-border-radius: 0.75rem; - --modal-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); - --modal-animation-duration: 0.2s; -} body { margin: 0; @@ -362,32 +354,6 @@ form.nav-menu-logout { .nav-menu-inner a { display: block; } -.nav-menu-inner button.button-as-link { - display: block; - width: 100%; - text-align: left; - font: inherit; -} -.nav-menu-inner .keyboard-shortcut { - float: right; - box-sizing: border-box; - min-width: 1.4em; - margin-left: 0.75rem; - padding: 0 0.35em; - border: 1px solid rgba(255,255,244,0.6); - border-radius: 3px; - background: rgba(255,255,244,0.12); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.85em; - line-height: 1.35; - text-align: center; - text-decoration: none; -} -@media (max-width: 640px) { - .nav-menu-inner .keyboard-shortcut { - display: none; - } -} /* Table/database actions menu */ .page-action-menu { @@ -609,6 +575,9 @@ button.core[type=button] { border-color: #007bff; } +.filter-row { + margin-bottom: 0.6em; +} .search-row { margin-bottom: 1.8em; } @@ -620,239 +589,72 @@ button.core[type=button] { width: 80px; } -.filters .search-row { - box-sizing: border-box; - display: grid; - grid-template-columns: max-content minmax(16rem, 1fr); - align-items: center; - gap: 0.6rem; - margin: 0 0 0.45rem; - padding-right: var(--filter-two-icon-space); -} - -.filters .search-row label { - width: auto; - padding: 0; - color: var(--filter-muted); - font-size: 0.875rem; - font-weight: 600; -} - -.filters { - --filter-ink: #0f0f0f; - --filter-paper: #eef6ff; - --filter-muted: #6b6b6b; - --filter-rule: #d8e6f5; - --filter-accent: #1a56db; - --filter-control-border: #bfccd9; - --filter-control-height: 2.125rem; - --filter-control-gap: 0.4rem; - --filter-row-icon-size: 2rem; - --filter-two-icon-space: calc( - (2 * var(--filter-row-icon-size)) + (2 * var(--filter-control-gap)) - ); - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 0.55rem; - max-width: 760px; - margin: 0 0 1rem; - padding: 0.75rem; - border: 1px solid var(--filter-rule); - border-radius: 8px; - background: var(--filter-paper); - color: var(--filter-ink); - font-size: 0.875rem; -} - -.filters .filter-row { - margin-bottom: 0; -} - -.filters .filter-controls-row { - display: grid; - min-width: 0; - width: 100%; - max-width: 100%; - box-sizing: border-box; - grid-template-columns: - minmax(8rem, 0.75fr) - minmax(6.5rem, 0.5fr) - minmax(12rem, 1.2fr) - var(--filter-row-icon-size) - var(--filter-row-icon-size); - gap: var(--filter-control-gap); - align-items: center; -} - -.filters .filter-actions-row { - display: flex; - align-items: center; - justify-content: flex-start; - flex-wrap: wrap; - gap: 0.6rem; -} - .select-wrapper { - display: inline-block; + border: 1px solid #ccc; width: 120px; - min-width: 0; + border-radius: 3px; + padding: 0; + background-color: #fafafa; + position: relative; + display: inline-block; + margin-right: 0.3em; +} +.select-wrapper:focus-within { + border: 1px solid black; } - .select-wrapper.filter-op { width: 80px; } - -.filters .select-wrapper { - width: auto; +.select-wrapper::after { + content: "\25BE"; + position: absolute; + top: 0px; + right: 0.4em; + color: #bbb; + pointer-events: none; + font-size: 1.2em; + padding-top: 0.16em; } .select-wrapper select { - box-sizing: border-box; + padding: 9px 8px; width: 100%; - height: var(--filter-control-height); - border: 1px solid var(--filter-control-border, #ccc); - border-radius: 5px; - background-color: #fff; - color: inherit; + border: none; + box-shadow: none; + background: transparent; + background-image: none; + -webkit-appearance: none; + -moz-appearance: none; cursor: pointer; - font-family: inherit; - font-size: 0.875rem; - line-height: 1.25; - padding: 7px 8px; } - +.select-wrapper select { + font-size: 1em; + font-family: Helvetica, sans-serif; +} .select-wrapper option { font-size: 1em; font-family: Helvetica, sans-serif; } .select-wrapper select:focus { - border-color: var(--filter-accent, #000); - box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14); outline: none; } +.filters { + font-size: 0.8em; +} .filters input.filter-value { - box-sizing: border-box; - width: 100%; - min-width: 0; - height: var(--filter-control-height); - border: 1px solid var(--filter-control-border); - border-radius: 5px; - background: #fff; - color: inherit; - font-family: inherit; - font-size: 0.875rem; - line-height: 1.25; - padding: 7px 9px; -} - -.filters input.filter-value:focus { - border-color: var(--filter-accent); - box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14); - outline: none; -} - -.filters input[type=submit] { - border-color: var(--filter-accent); - background: var(--filter-accent); - border-radius: 5px; - font-weight: 500; - padding: 0.55rem 0.85rem; -} - -.filters input[type=submit]:hover, -.filters input[type=submit]:focus { - background: #1949b8; - border-color: #1949b8; -} - -.filters button.filter-row-icon { - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: var(--filter-row-icon-size); - height: var(--filter-row-icon-size); - min-width: var(--filter-row-icon-size); - padding: 0; - border: 1px solid var(--filter-rule); - border-radius: 5px; - background: #fff; - color: var(--filter-muted); - font-family: inherit; - font-size: 1.15rem; - font-weight: 600; - line-height: 1; -} - -.filters button.filter-row-icon[hidden] { - display: none; -} - -.filters button.filter-row-icon:focus-visible { - outline: 3px solid rgba(26, 86, 219, 0.14); - outline-offset: 2px; -} - -.filters .filter-row-remove-icon { - display: block; - height: 14px; - width: 14px; -} - -.filters button.filter-row-remove:hover, -.filters button.filter-row-remove:focus { - border-color: #c9d5e3; - background: #f8fbff; - color: var(--filter-ink); -} - -.filters button.filter-row-add { - border-color: var(--filter-accent); - background: var(--filter-accent); - color: #fff; -} - -.filters .filter-row-add-icon { - display: block; - height: 16px; - width: 16px; -} - -.filters button.filter-row-add:hover, -.filters button.filter-row-add:focus { - border-color: #1949b8; - background: #1949b8; - color: #fff; -} - -.filters button.filter-row-add:focus-visible svg { - color: #fff; - stroke: currentColor; + width: 200px; + border-radius: 3px; + -webkit-appearance: none; + padding: 9px 4px; + font-size: 16px; + font-family: Helvetica, sans-serif; } #_search { font-size: 16px; } -.filters #_search { - box-sizing: border-box; - width: 100%; - height: var(--filter-control-height); - border: 1px solid var(--filter-control-border); - border-radius: 5px; - background: #fff; - color: var(--filter-ink); - font: inherit; - padding: 7px 9px; -} - -.filters #_search:focus { - border-color: var(--filter-accent); - box-shadow: 0 0 0 2px rgba(26, 86, 219, 0.14); - outline: none; -} - @@ -870,11 +672,6 @@ button.core[type=button] { color: #666; padding-right: 0.25em; } -/* The label may wrap (word-break: break-all on the li) but the count should - stay on one line - https://github.com/simonw/datasette/issues/2754 */ -.facet-count { - white-space: nowrap; -} .facet-info li, .facet-info ul { margin: 0; @@ -937,2665 +734,6 @@ p.zero-results { .select-wrapper.small-screen-only { display: none; } - -@keyframes datasette-modal-slide-in { - from { - opacity: 0; - transform: translateY(-20px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes datasette-modal-fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -dialog.mobile-column-actions-dialog { - --ink: #0f0f0f; - --paper: #eef6ff; - --muted: #6b6b6b; - --rule: #d8e6f5; - --accent: #1a56db; - --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; - width: min(420px, calc(100vw - 32px)); - max-width: 95vw; - max-height: min(640px, calc(100vh - 32px)); - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -} - -dialog.mobile-column-actions-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.mobile-column-actions-dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; -} - -.mobile-column-actions-dialog .modal-header { - padding: 20px 24px 16px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-shrink: 0; -} - -.mobile-column-actions-dialog .modal-title { - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.mobile-column-actions-dialog .modal-meta { - font-family: ui-monospace, monospace; - font-size: 0.7rem; - color: var(--muted); - background: var(--paper); - padding: 3px 9px; - border-radius: 20px; -} - -.mobile-column-actions-dialog .list-wrap { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; - position: relative; - overscroll-behavior: contain; - -webkit-overflow-scrolling: touch; -} - -.mobile-column-actions-dialog .list-wrap::before, -.mobile-column-actions-dialog .list-wrap::after { - content: ""; - position: sticky; - display: block; - left: 0; - right: 0; - height: 20px; - pointer-events: none; - z-index: 5; -} - -.mobile-column-actions-dialog .list-wrap::before { - top: 0; - background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent); -} - -.mobile-column-actions-dialog .list-wrap::after { - bottom: 0; - background: linear-gradient(to top, rgba(255,255,255,0.9), transparent); - margin-top: -20px; -} - -.mobile-column-top-actions { - padding: 10px 24px 0; -} - -.mobile-column-top-action { - display: inline-block; - text-decoration: none; -} - -.mobile-column-section { - border-bottom: 1px solid var(--rule); -} - -.mobile-column-actions-dialog .col-header { - width: 100%; - padding: 12px 24px; - font: inherit; - font-weight: 600; - border: 0; - background: none; - cursor: pointer; - display: flex; - justify-content: space-between; - align-items: center; - text-align: left; -} - -.mobile-column-header-text { - display: flex; - flex-direction: column; - gap: 0.15rem; -} - -.mobile-column-name { - color: var(--ink); -} - -.mobile-column-meta { - color: var(--muted); - font-size: 0.78em; - font-family: ui-monospace, monospace; - font-weight: normal; -} - -.mobile-column-chevron { - color: var(--muted); - transition: transform 0.2s ease-out; -} - -.mobile-column-actions-dialog .col-header[aria-expanded="true"] .mobile-column-chevron { - transform: rotate(180deg); -} - -.mobile-column-actions-dialog .col-actions[hidden] { - display: none; -} - -.mobile-column-actions-dialog .col-actions ul, -.mobile-column-actions-dialog .col-actions li { - margin: 0; - padding: 0; - list-style-type: none; -} - -.mobile-column-actions-dialog .col-actions a, -.mobile-column-actions-dialog .col-actions button { - display: block; - width: 100%; - padding: 10px 24px 10px 40px; - color: var(--ink); - text-align: left; - font: inherit; - text-decoration: none; - background: none; - border: 0; - border-top: 1px solid #f5f5f5; - cursor: pointer; -} - -.mobile-column-actions-dialog .col-actions a:hover, -.mobile-column-actions-dialog .col-actions button:hover { - background: var(--paper); -} - -.mobile-column-actions-dialog .col-actions a:active, -.mobile-column-actions-dialog .col-actions button:active { - background: #eee; -} - -.mobile-column-description, -.mobile-column-no-actions { - margin: 0; - padding: 0 24px 12px 24px; - color: var(--muted); - font-size: 0.85em; -} - -.mobile-column-actions-dialog .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; - background: var(--paper); -} - -.mobile-column-actions-dialog .footer-info { - flex: 1; - font-family: ui-monospace, monospace; - font-size: 0.68rem; - color: var(--muted); -} - -.mobile-column-actions-dialog .btn { - border: none; - border-radius: 5px; - padding: 9px 20px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; - transition: background 0.12s; -} - -.mobile-column-actions-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.mobile-column-actions-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -dialog.set-column-type-dialog { - --ink: #0f0f0f; - --paper: #eef6ff; - --muted: #6b6b6b; - --rule: #d8e6f5; - --accent: #1a56db; - --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; - width: min(520px, calc(100vw - 32px)); - max-width: 95vw; - max-height: min(720px, calc(100vh - 32px)); - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -} - -dialog.set-column-type-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.set-column-type-dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; -} - -.set-column-type-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-shrink: 0; -} - -.set-column-type-dialog .modal-title { - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.set-column-type-dialog .modal-meta { - font-family: ui-monospace, monospace; - font-size: 0.7rem; - color: var(--muted); - background: var(--paper); - padding: 3px 9px; - border-radius: 20px; -} - -.set-column-type-status, -.set-column-type-empty, -.set-column-type-error { - margin: 0; - padding: 12px 24px 0; -} - -.set-column-type-status, -.set-column-type-empty { - color: var(--muted); - font-size: 0.9rem; -} - -.set-column-type-error { - color: #b91c1c; - font-size: 0.9rem; -} - -.set-column-type-options { - padding: 16px 24px 24px; - overflow-y: auto; - display: grid; - gap: 12px; -} - -.set-column-type-option { - display: grid; - grid-template-columns: auto 1fr; - gap: 12px; - align-items: start; - padding: 14px 16px; - border: 1px solid var(--rule); - border-radius: 8px; - background: #fbfdff; - cursor: pointer; -} - -.set-column-type-option:focus-within { - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.12); -} - -.set-column-type-option input { - margin-top: 3px; -} - -.set-column-type-option-content { - display: grid; - gap: 4px; -} - -.set-column-type-option-name { - font-family: ui-monospace, monospace; - font-size: 0.95rem; - color: var(--ink); -} - -.set-column-type-option-description { - color: var(--muted); - font-size: 0.9rem; -} - -.set-column-type-dialog .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 10px; - flex-shrink: 0; - background: var(--paper); -} - -.set-column-type-dialog .footer-info { - flex: 1; - font-family: ui-monospace, monospace; - font-size: 0.68rem; - color: var(--muted); -} - -.set-column-type-dialog .btn { - border: none; - border-radius: 5px; - padding: 9px 20px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; - transition: background 0.12s; -} - -.set-column-type-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.set-column-type-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.set-column-type-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.set-column-type-dialog .btn-primary:hover { - background: #1949b8; -} - -.set-column-type-dialog .btn:disabled { - opacity: 0.65; - cursor: wait; -} - -.row-mutation-status { - margin: 0 0 0.75rem; - padding: 8px 10px; - border-left: 4px solid #54AC8E; - background: rgba(103,201,141,0.12); - color: #222; -} - -.row-mutation-status[hidden] { - display: none; -} - -.row-mutation-status-error { - border-left-color: #D0021B; - background: rgba(208,2,27,0.12); -} - -.table-row-toolbar { - margin: 0 0 0.75rem; -} - -button.table-insert-row { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -button.table-insert-row svg { - display: block; - flex-shrink: 0; -} - -dialog.row-delete-dialog { - --ink: #0f0f0f; - --paper: #eef6ff; - --muted: #6b6b6b; - --rule: #d8e6f5; - --accent: #1a56db; - --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; - width: min(440px, calc(100vw - 32px)); - max-width: 95vw; - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -} - -dialog.row-delete-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.row-delete-dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; -} - -.row-delete-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-start; - gap: 12px; - flex-shrink: 0; - min-width: 0; -} - -.row-delete-dialog .modal-title { - display: flex; - align-items: center; - gap: 0.35rem; - min-width: 0; - max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.row-delete-message, -.row-delete-error { - margin: 0; - padding: 16px 24px 0; -} - -.row-delete-message { - color: var(--ink); - font-size: 0.95rem; -} - -.row-delete-id { - display: inline; - padding: 2px 5px; - border: 1px solid var(--rule); - border-radius: 4px; - background: var(--paper); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.92em; - overflow-wrap: anywhere; -} - -.row-delete-error { - color: #b91c1c; - font-size: 0.9rem; -} - -.row-delete-dialog .modal-footer { - padding: 18px 20px 14px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-shrink: 0; - background: var(--paper); - margin-top: 18px; -} - -.row-delete-dialog .btn { - border: none; - border-radius: 5px; - padding: 9px 20px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; - transition: background 0.12s; -} - -.row-delete-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.row-delete-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.row-delete-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.row-delete-dialog .btn-primary:hover { - background: #1949b8; -} - -.row-delete-dialog .btn:disabled { - opacity: 0.65; - cursor: wait; -} - -dialog.row-edit-dialog { - --ink: #0f0f0f; - --paper: #eef6ff; - --muted: #6b6b6b; - --rule: #d8e6f5; - --accent: #1a56db; - --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; - width: min(720px, calc(100vw - 32px)); - max-width: 95vw; - max-height: min(780px, calc(100vh - 32px)); - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -} - -dialog.row-edit-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.row-edit-dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; -} - -.row-edit-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; - min-width: 0; -} - -.row-edit-dialog .modal-title { - display: flex; - align-items: center; - gap: 0.35rem; - min-width: 0; - max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.row-edit-dialog .modal-title .row-dialog-action, -.row-delete-dialog .modal-title .row-dialog-action { - flex: 0 0 auto; - white-space: nowrap; -} - -.row-edit-dialog .modal-title code, -.row-delete-dialog .modal-title code { - display: inline; - flex: 0 0 auto; - padding: 2px 5px; - border: 1px solid var(--rule); - border-radius: 4px; - background: var(--paper); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.92em; - overflow-wrap: anywhere; -} - -.row-edit-dialog .modal-title .row-dialog-label, -.row-delete-dialog .modal-title .row-dialog-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.row-edit-form { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; -} - -.row-edit-summary, -.row-edit-loading, -.row-edit-error { - margin: 0; - padding: 12px 24px 0; -} - -.row-edit-summary, -.row-edit-loading { - color: var(--muted); - font-size: 0.9rem; -} - -.row-edit-error { - border-left: 4px solid #b91c1c; - border-radius: 4px; - background: #fff1f1; - color: #7f1d1d; - font-size: 0.9rem; - margin: 12px 24px 0; - padding: 10px 12px; -} - -.row-edit-error:focus { - outline: 3px solid rgba(185, 28, 28, 0.18); - outline-offset: 2px; -} - -.row-edit-fields { - display: grid; - gap: 14px; - padding: 16px 24px 24px; - overflow-y: auto; -} - -.row-edit-fields[hidden], -.row-edit-bulk[hidden] { - display: none; -} - -.row-edit-field { - display: grid; - grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); - gap: 12px; - align-items: start; -} - -.row-edit-label { - padding-top: 8px; - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; - overflow-wrap: anywhere; -} - -.row-edit-control-wrap { - display: grid; - gap: 5px; -} - -.row-edit-input { - box-sizing: border-box; - width: 100%; - min-width: 0; - border: 1px solid var(--rule); - border-radius: 5px; - padding: 8px 10px; - color: var(--ink); - background: #fff; - font: inherit; -} - -textarea.row-edit-input { - resize: vertical; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; - line-height: 1.45; -} - -.row-edit-input:focus { - border-color: var(--accent); - outline: 3px solid rgba(26, 86, 219, 0.12); -} - -.row-edit-input[aria-invalid="true"] { - border-color: #b42318; - background: #fff8f7; -} - -.row-edit-input[aria-invalid="true"]:focus { - border-color: #b42318; - outline-color: rgba(180, 35, 24, 0.16); -} - -.row-edit-input[readonly] { - color: var(--muted); - background: var(--paper); -} - -.row-edit-binary-control { - display: grid; - gap: 8px; - box-sizing: border-box; - width: 100%; - min-width: 0; - border: 1px solid var(--rule); - border-radius: 5px; - padding: 10px; - background: #fff; -} - -.row-edit-binary-control:focus { - border-color: var(--accent); - outline: 3px solid rgba(26, 86, 219, 0.12); -} - -.row-edit-binary-preview[hidden] { - display: none; -} - -.row-edit-binary-preview img { - display: block; - max-width: min(240px, 100%); - max-height: 180px; - border: 1px solid var(--rule); - border-radius: 4px; - background: var(--paper); -} - -.row-edit-binary-status { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 8px; - min-width: 0; -} - -.row-edit-binary-size { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.86rem; -} - -.row-edit-binary-name { - color: var(--muted); - font-size: 0.82rem; - overflow-wrap: anywhere; -} - -.row-edit-binary-name[hidden] { - display: none; -} - -.row-edit-binary-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.row-edit-binary-file-button, -.row-edit-binary-clear { - appearance: none; - border: 1px solid var(--rule); - border-radius: 4px; - background: #fff; - color: var(--accent); - cursor: pointer; - font: inherit; - font-size: 0.78rem; - line-height: 1.2; - padding: 6px 8px; -} - -.row-edit-binary-file-button:hover, -.row-edit-binary-file-button:focus-within, -.row-edit-binary-clear:hover, -.row-edit-binary-clear:focus { - background: #f8fafc; -} - -.row-edit-binary-file-button:focus-within, -.row-edit-binary-clear:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 1px; -} - -.row-edit-binary-file-button input[type="file"] { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - overflow: hidden; -} - -.row-edit-binary-clear[hidden] { - display: none; -} - -.row-edit-binary-drop-target { - border: 1px dashed var(--rule); - border-radius: 4px; - padding: 7px 8px; - color: var(--muted); - font-size: 0.78rem; -} - -.row-edit-binary-dragover .row-edit-binary-drop-target { - border-color: var(--accent); - background: var(--paper); - color: var(--ink); -} - -.row-edit-default { - display: grid; - grid-template-columns: minmax(0, 1fr) 7.25rem; - align-items: center; - gap: 8px; - min-width: 0; - border: 1px solid var(--rule); - border-radius: 5px; - padding: 7px 8px 7px 10px; - background: var(--paper); - color: var(--ink); -} - -.row-edit-default[hidden], -.row-edit-custom-value[hidden] { - display: none; -} - -.row-edit-default-text { - min-width: 0; - overflow-wrap: anywhere; -} - -.row-edit-default-code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; -} - -.row-edit-custom-value { - display: grid; - grid-template-columns: minmax(0, 1fr) 7.25rem; - gap: 8px; - align-items: center; - min-height: 45px; - padding-right: 8px; -} - -.row-edit-default-button { - appearance: none; - border: 1px solid var(--rule); - border-radius: 4px; - background: #fff; - color: var(--accent); - cursor: pointer; - font: inherit; - font-size: 0.78rem; - line-height: 1.2; - padding: 6px 8px; - white-space: nowrap; - width: 100%; - align-self: center; -} - -.row-edit-default-button:hover, -.row-edit-default-button:focus { - background: #f8fafc; -} - -.row-edit-default-button:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 1px; -} - -.row-edit-field-meta { - color: var(--muted); - font-size: 0.78rem; -} - -.row-edit-field-validation-error { - color: #b42318; - display: block; - margin-top: 2px; -} - -.row-edit-field-validation-error[hidden] { - display: none; -} - -.row-edit-field-meta-autocomplete { - line-height: 1.2; - min-height: 1.2em; -} - -.row-edit-fk-pk { - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.row-edit-fk-link { - overflow-wrap: anywhere; -} - -.row-edit-empty { - color: var(--muted); - font-size: 0.9rem; - margin: 0; -} - -.row-edit-bulk { - display: grid; - gap: 8px; - padding: 16px 24px 24px; - overflow-y: auto; -} - -.row-edit-bulk-editor { - display: grid; - gap: 8px; -} - -.row-edit-bulk-editor[hidden] { - display: none; -} - -.row-edit-bulk-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-start; -} - -.row-edit-bulk-actions .btn { - padding-left: 12px; - padding-right: 12px; -} - -.row-edit-bulk-conflict { - display: grid; - grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); - gap: 8px 12px; - align-items: start; -} - -.row-edit-bulk-conflict[hidden] { - display: none; -} - -.row-edit-bulk-conflict-label { - color: var(--ink); - font-size: 0.82rem; - padding-top: 8px; -} - -.row-edit-bulk-conflict-control { - display: grid; - gap: 4px; -} - -.row-edit-bulk-conflict-help { - color: var(--muted); - font-size: 0.78rem; - margin: 0; -} - -.row-edit-copy-template-label-narrow { - display: none; -} - -.row-edit-bulk-template-note { - color: var(--muted); - font-size: 0.82rem; -} - -.row-edit-bulk-template-note-narrow { - display: none; -} - -.row-edit-bulk-textarea { - min-height: 16rem; - resize: vertical; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; - line-height: 1.45; -} - -.row-edit-bulk-textarea.row-edit-bulk-drop-target { - border-color: var(--accent); - background: #f8fbff; - outline: 3px solid rgba(26, 86, 219, 0.12); -} - -.row-edit-bulk-note { - color: var(--muted); - font-size: 0.82rem; - margin: 0; -} - -.row-edit-bulk-note label, -.row-edit-bulk-note .button-as-link { - font: inherit; -} - -@media (max-width: 640px) { - .row-edit-copy-template-label-wide { - display: none; - } - - .row-edit-copy-template-label-narrow { - display: inline; - } - - .row-edit-bulk-template-note-wide { - display: none; - } - - .row-edit-bulk-template-note-narrow { - display: inline; - } -} - -.row-edit-bulk-preview { - display: grid; - gap: 8px; - margin-top: 8px; -} - -.row-edit-bulk-preview[hidden] { - display: none; -} - -.row-edit-bulk-preview-summary { - color: var(--ink); - font-size: 0.9rem; - font-weight: 600; - margin: 0; -} - -.row-edit-bulk-preview-table-wrap { - border: 1px solid var(--rule); - border-radius: 5px; - max-height: 18rem; - overflow: auto; - background: #fff; -} - -.row-edit-bulk-preview-table { - border-collapse: collapse; - font-size: 0.78rem; - min-width: 100%; - width: max-content; -} - -.row-edit-bulk-preview-table th, -.row-edit-bulk-preview-table td { - border-bottom: 1px solid var(--rule); - border-right: 1px solid var(--rule); - max-width: 18rem; - overflow-wrap: anywhere; - padding: 6px 8px; - text-align: left; - vertical-align: top; - white-space: normal; -} - -.row-edit-bulk-preview-table th { - background: var(--paper); - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-weight: 600; - position: sticky; - top: 0; - z-index: 1; -} - -.row-edit-bulk-preview-table tr:last-child td { - border-bottom: none; -} - -.row-edit-bulk-preview-table th:last-child, -.row-edit-bulk-preview-table td:last-child { - border-right: none; -} - -.row-edit-bulk-preview-null, -.row-edit-bulk-preview-auto { - color: var(--muted); - font-style: italic; -} - -.row-edit-bulk-progress { - display: grid; - gap: 6px; -} - -.row-edit-bulk-progress[hidden] { - display: none; -} - -.row-edit-bulk-progress-bar { - width: 100%; -} - -.row-edit-bulk-progress-status { - color: var(--ink); - font-size: 0.9rem; - margin: 0; -} - -datasette-autocomplete { - display: block; - position: relative; - max-width: 38rem; -} - -datasette-autocomplete input[type="text"], -.debug-autocomplete-form input[type="text"] { - box-sizing: border-box; - width: 100%; - max-width: 38rem; -} - -.datasette-autocomplete-list { - background: #fff; - border: 1px solid var(--rule); - border-radius: 5px; - box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); - box-sizing: border-box; - left: 0; - max-height: 16rem; - overflow-y: auto; - position: fixed; - right: auto; - top: auto; - z-index: 10000; -} - -.datasette-autocomplete-list[hidden] { - display: none; -} - -.datasette-autocomplete-option { - cursor: pointer; - padding: 7px 9px; -} - -.datasette-autocomplete-option:hover, -.datasette-autocomplete-option[aria-selected="true"] { - background: var(--paper); -} - -.datasette-autocomplete-option[aria-selected="true"] { - background: var(--paper); - font-weight: 600; -} - -.datasette-autocomplete-status { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} - -.debug-autocomplete-demo { - margin: 1rem 0; -} - -.debug-autocomplete-selected { - max-width: 46rem; -} - -.row-edit-dialog .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-shrink: 0; - background: var(--paper); -} - -.row-edit-mode-link { - color: var(--accent); - font-size: 0.9rem; - margin-right: auto; -} - -.row-edit-mode-link[hidden] { - display: none; -} - -.row-edit-dialog .btn { - border: none; - border-radius: 5px; - padding: 9px 20px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; - transition: background 0.12s; -} - -.row-edit-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.row-edit-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.row-edit-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.row-edit-dialog .btn-primary:hover { - background: #1949b8; -} - -.row-edit-dialog .btn:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -dialog.table-create-dialog { - --ink: #0f0f0f; - --paper: #eef6ff; - --muted: #6b6b6b; - --rule: #d8e6f5; - --accent: #1a56db; - --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; - width: min(980px, calc(100vw - 32px)); - max-width: 95vw; - max-height: min(780px, calc(100vh - 32px)); - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -} - -dialog.table-create-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.table-create-dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; -} - -.table-create-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; - min-width: 0; -} - -.table-create-dialog .modal-title { - display: flex; - align-items: center; - min-width: 0; - max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.table-create-form { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; -} - -.table-create-error { - border-left: 4px solid #b91c1c; - border-radius: 4px; - background: #fff1f1; - color: #7f1d1d; - font-size: 0.9rem; - margin: 12px 24px 0; - padding: 10px 12px; -} - -.table-create-error:focus { - outline: 3px solid rgba(185, 28, 28, 0.18); - outline-offset: 2px; -} - -.table-create-fields { - display: grid; - gap: 18px; - padding: 16px 24px 24px; - overflow-y: auto; -} - -.table-create-field { - display: grid; - grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); - gap: 12px; - align-items: start; -} - -.table-create-label, -.table-create-column-headings { - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; -} - -.table-create-label { - padding-top: 8px; -} - -.table-create-column-label { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} - -.table-create-input { - box-sizing: border-box; - min-width: 0; - min-height: 46px; - border: 1px solid var(--rule); - border-radius: 5px; - padding: 8px 10px; - color: var(--ink); - background: #fff; - font: inherit; - line-height: 1.35; -} - -select.table-create-input { - height: 46px; -} - -.table-create-input-placeholder { - color: var(--muted); -} - -.table-create-foreign-key-target option, -.table-create-custom-column-type option, -.table-create-default-expr option { - color: var(--ink); -} - -.table-create-foreign-key-target option[value=""], -.table-create-custom-column-type option[value=""], -.table-create-default-expr option[value=""] { - color: var(--muted); -} - -.table-create-table-name { - width: 100%; -} - -.table-create-input:focus { - border-color: var(--accent); - outline: 3px solid rgba(26, 86, 219, 0.12); -} - -.table-create-columns { - display: grid; - gap: 10px; -} - -.table-create-columns[hidden], -.table-create-data[hidden], -.table-create-data-editor[hidden], -.table-create-data-preview[hidden] { - display: none; -} - -.table-create-data, -.table-create-data-editor { - display: grid; - gap: 8px; -} - -.table-create-data-label { - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; -} - -.table-create-data-textarea { - min-height: 16rem; - resize: vertical; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; - line-height: 1.45; -} - -.table-create-data-textarea.table-create-data-drop-target { - border-color: var(--accent); - background: #f8fbff; - outline: 3px solid rgba(26, 86, 219, 0.12); -} - -.table-create-data-note { - color: var(--muted); - font-size: 0.82rem; - margin: 0; -} - -.table-create-data-note label, -.table-create-data-note .button-as-link { - font: inherit; -} - -.table-create-data-preview { - display: grid; - gap: 10px; -} - -.table-create-data-preview-summary { - color: var(--ink); - font-size: 0.9rem; - font-weight: 600; - margin: 0; -} - -.table-create-data-pk-field { - display: grid; - grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); - gap: 12px; - align-items: center; -} - -.table-create-data-preview-table-wrap { - border: 1px solid var(--rule); - border-radius: 5px; - max-height: 18rem; - overflow: auto; - background: #fff; -} - -.table-create-data-preview-table { - border-collapse: collapse; - font-size: 0.78rem; - min-width: 100%; - width: max-content; -} - -.table-create-data-preview-table th, -.table-create-data-preview-table td { - border-bottom: 1px solid var(--rule); - border-right: 1px solid var(--rule); - max-width: 18rem; - overflow-wrap: anywhere; - padding: 6px 8px; - text-align: left; - vertical-align: top; - white-space: normal; -} - -.table-create-data-preview-table th { - background: var(--paper); - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-weight: 600; - position: sticky; - top: 0; - z-index: 1; -} - -.table-create-data-preview-table tr:last-child td { - border-bottom: none; -} - -.table-create-data-preview-table th:last-child, -.table-create-data-preview-table td:last-child { - border-right: none; -} - -.table-create-data-preview-null { - color: var(--muted); - font-style: italic; -} - -.table-create-column-list { - display: grid; - gap: 8px; -} - -.table-create-column-headings, -.table-create-column-main { - display: grid; - grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) max-content 32px; - align-items: center; - gap: 8px; - min-width: 0; -} - -.table-create-column-row { - display: grid; - gap: 8px; - min-width: 0; -} - -.table-create-column-headings { - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.72rem; - padding: 0 1px; -} - -.table-create-column-details { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - align-items: start; - gap: 12px 16px; - padding: 12px; - border-left: 3px solid var(--rule); - background: #f8fafc; -} - -.table-create-column-details[hidden] { - display: none; -} - -.table-create-detail-field { - display: grid; - gap: 4px; - min-width: 0; -} - -.table-create-detail-label { - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.72rem; -} - -.table-create-detail-help, -.table-alter-detail-help { - color: var(--muted); - font-size: 0.82rem; - line-height: 1.35; - margin: 0; -} - -.table-create-detail-check { - display: inline-flex; - align-items: flex-start; - gap: 8px; - color: var(--ink); - font-size: 0.85rem; - line-height: 1.35; - min-width: 0; - white-space: normal; -} - -.table-create-not-null, -.table-create-primary-key, -.table-create-foreign-key-field, -.table-create-default-options { - grid-column: 1 / -1; -} - -.table-create-default-options, -.table-alter-default-options { - border: 1px solid var(--rule); - border-radius: 5px; - background: #fff; - color: var(--ink); - min-width: 0; -} - -.table-create-default-options > summary, -.table-alter-default-options > summary { - cursor: pointer; - color: var(--accent); - font-size: 0.85rem; - padding: 8px 10px; -} - -.table-create-default-options > summary:focus, -.table-alter-default-options > summary:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 1px; -} - -.table-create-default-grid, -.table-alter-default-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 12px 16px; - padding: 0 10px 10px; -} - -.table-create-detail-check input { - flex: 0 0 auto; - margin: 0.15rem 0 0; -} - -.table-create-detail-check span { - min-width: 0; - overflow-wrap: break-word; -} - -.table-create-move-controls { - display: grid; - grid-template-columns: repeat(4, 32px); - gap: 4px; - justify-content: start; -} - -.table-create-more-options { - appearance: none; - border: 0; - background: transparent; - color: var(--accent); - cursor: pointer; - font: inherit; - font-size: 0.85rem; - justify-self: start; - padding: 0; - grid-column: 1 / -1; - text-align: left; -} - -.table-create-more-options:hover, -.table-create-more-options:focus { - text-decoration: underline; -} - -.table-create-more-options:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 2px; -} - -.table-create-more-options:disabled { - color: var(--muted); - cursor: default; - text-decoration: none; -} - -.table-create-icon-button { - appearance: none; - border: 1px solid rgba(74, 85, 104, 0.24); - background: transparent; - color: #4a5568; - border-radius: 4px; - cursor: pointer; - display: inline-grid; - place-items: center; - height: 32px; - width: 32px; - padding: 0; -} - -.table-create-icon-button:hover, -.table-create-icon-button:focus { - background: rgba(74, 85, 104, 0.07); -} - -.table-create-icon-button:focus { - outline: 3px solid #b3d4ff; - outline-offset: 1px; -} - -.table-create-icon-button svg { - display: block; -} - -.table-create-add-column { - appearance: none; - justify-self: start; - border: 1px solid var(--rule); - border-radius: 5px; - background: #fff; - color: var(--accent); - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 6px; - font: inherit; - font-size: 0.85rem; - padding: 7px 10px; -} - -.table-create-add-column svg { - display: block; - flex: 0 0 auto; -} - -.table-create-add-column:hover, -.table-create-add-column:focus { - background: #f8fafc; -} - -.table-create-add-column:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 1px; -} - -.table-create-dialog .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-shrink: 0; - background: var(--paper); -} - -.table-create-mode-link { - color: var(--accent); - font-size: 0.9rem; - margin-right: auto; -} - -.table-create-mode-link[hidden] { - display: none; -} - -.table-create-dialog .btn { - border: none; - border-radius: 5px; - padding: 9px 20px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; - transition: background 0.12s; -} - -.table-create-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.table-create-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.table-create-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.table-create-dialog .btn-primary:hover { - background: #1949b8; -} - -.table-create-dialog .btn:disabled, -.table-create-add-column:disabled, -.table-create-icon-button:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -dialog.table-alter-dialog { - --ink: #0f0f0f; - --paper: #eef6ff; - --muted: #6b6b6b; - --rule: #d8e6f5; - --accent: #1a56db; - --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; - width: min(980px, calc(100vw - 32px)); - max-width: 95vw; - max-height: min(780px, calc(100vh - 32px)); - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -} - -dialog.table-alter-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.table-alter-dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; -} - -.table-alter-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; - min-width: 0; -} - -.table-alter-dialog .modal-title { - display: flex; - align-items: center; - min-width: 0; - max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.table-alter-form { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; -} - -.table-alter-error { - border-left: 4px solid #b91c1c; - border-radius: 4px; - background: #fff1f1; - color: #7f1d1d; - font-size: 0.9rem; - margin: 12px 24px 0; - padding: 10px 12px; -} - -.table-alter-error:focus { - outline: 3px solid rgba(185, 28, 28, 0.18); - outline-offset: 2px; -} - -.table-alter-fields { - display: grid; - gap: 18px; - padding: 16px 24px 24px; - overflow-y: auto; -} - -.table-alter-table-options { - border-top: 1px solid var(--rule); - padding-top: 12px; -} - -.table-alter-table-options > summary { - color: var(--ink); - cursor: pointer; - font-weight: 600; -} - -.table-alter-table-options > summary:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 3px; -} - -.table-alter-table-name-field { - display: grid; - gap: 4px; - margin-top: 10px; - max-width: 24rem; -} - -.table-alter-fields[hidden], -.table-alter-dialog .modal-footer [hidden] { - display: none; -} - -.table-alter-review { - display: grid; - gap: 12px; - overflow-y: auto; - padding: 16px 24px 24px; -} - -.table-alter-review[hidden] { - display: none; -} - -.table-alter-review-title { - color: var(--ink); - font-size: 1rem; - line-height: 1.35; - margin: 0; -} - -.table-alter-review-title:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 2px; -} - -.table-alter-review-intro { - color: var(--muted); - font-size: 0.9rem; - margin: 0; -} - -.table-alter-review-warning { - border-left: 4px solid #b91c1c; - border-radius: 4px; - background: #fff1f1; - color: #7f1d1d; - font-size: 0.9rem; - margin: 0; - padding: 10px 12px; -} - -.table-alter-review-list { - display: grid; - gap: 8px; - margin: 0; - padding-left: 1.4rem; -} - -.table-alter-review-list li { - color: var(--ink); - line-height: 1.4; -} - -.table-alter-review-damaging { - font-weight: 600; -} - -.table-alter-review-name { - background: #eef6ff; - border: 1px solid #c9ddf2; - border-radius: 4px; - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.85em; - padding: 1px 4px; - white-space: nowrap; -} - -.table-alter-columns { - display: grid; - gap: 10px; -} - -.table-alter-column-list { - display: grid; - gap: 8px; -} - -.table-alter-column-headings, -.table-alter-column-main { - display: grid; - grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) max-content 32px; - align-items: center; - gap: 8px; - min-width: 0; -} - -.table-alter-column-row { - display: grid; - gap: 8px; - min-width: 0; -} - -.table-alter-column-headings { - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.72rem; - padding: 0 1px; -} - -.table-alter-column-label { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} - -.table-alter-input { - box-sizing: border-box; - min-width: 0; - min-height: 46px; - border: 1px solid var(--rule); - border-radius: 5px; - padding: 8px 10px; - color: var(--ink); - background: #fff; - font: inherit; - line-height: 1.35; -} - -select.table-alter-input { - height: 46px; -} - -.table-alter-input-placeholder { - color: var(--muted); -} - -.table-alter-default-expr option, -.table-alter-custom-column-type option, -.table-alter-foreign-key-target option { - color: var(--ink); -} - -.table-alter-default-expr option[value=""], -.table-alter-custom-column-type option[value=""], -.table-alter-foreign-key-target option[value=""] { - color: var(--muted); -} - -.table-alter-input:focus { - border-color: var(--accent); - outline: 3px solid rgba(26, 86, 219, 0.12); -} - -.table-alter-column-details { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - align-items: start; - gap: 12px 16px; - padding: 12px; - border-left: 3px solid var(--rule); - background: #f8fafc; -} - -.table-alter-column-details[hidden] { - display: none; -} - -.table-alter-detail-field { - display: grid; - gap: 4px; - min-width: 0; -} - -.table-alter-detail-label { - color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.72rem; -} - -.table-alter-detail-check { - display: inline-flex; - align-items: flex-start; - gap: 8px; - color: var(--ink); - font-size: 0.85rem; - line-height: 1.35; - min-width: 0; - white-space: normal; -} - -.table-alter-not-null, -.table-alter-primary-key, -.table-alter-foreign-key-field, -.table-alter-default-options { - grid-column: 1 / -1; -} - -.table-alter-detail-check input { - flex: 0 0 auto; - margin: 0.15rem 0 0; -} - -.table-alter-detail-check span { - min-width: 0; - overflow-wrap: break-word; -} - -.table-alter-move-controls { - display: grid; - grid-template-columns: repeat(4, 32px); - gap: 4px; - justify-content: start; -} - -.table-alter-more-options { - appearance: none; - border: 0; - background: transparent; - color: var(--accent); - cursor: pointer; - font: inherit; - font-size: 0.85rem; - justify-self: start; - padding: 0; - grid-column: 1 / -1; - text-align: left; -} - -.table-alter-more-options:hover, -.table-alter-more-options:focus { - text-decoration: underline; -} - -.table-alter-more-options:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 2px; -} - -.table-alter-more-options:disabled { - color: var(--muted); - cursor: default; - text-decoration: none; -} - -.table-alter-icon-button { - appearance: none; - border: 1px solid rgba(74, 85, 104, 0.24); - background: transparent; - color: #4a5568; - border-radius: 4px; - cursor: pointer; - display: inline-grid; - place-items: center; - height: 32px; - width: 32px; - padding: 0; -} - -.table-alter-icon-button:hover, -.table-alter-icon-button:focus { - background: rgba(74, 85, 104, 0.07); -} - -.table-alter-icon-button:focus { - outline: 3px solid #b3d4ff; - outline-offset: 1px; -} - -.table-alter-icon-button svg { - display: block; -} - -.table-alter-add-column { - appearance: none; - justify-self: start; - border: 1px solid var(--rule); - border-radius: 5px; - background: #fff; - color: var(--accent); - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 6px; - font: inherit; - font-size: 0.85rem; - padding: 7px 10px; -} - -.table-alter-add-column svg { - display: block; - flex: 0 0 auto; -} - -.table-alter-add-column:hover, -.table-alter-add-column:focus { - background: #f8fafc; -} - -.table-alter-add-column:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 1px; -} - -.table-alter-dialog .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-shrink: 0; - background: var(--paper); -} - -.table-alter-dialog .btn { - border: none; - border-radius: 5px; - padding: 9px 20px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; - transition: background 0.12s; -} - -.table-alter-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.table-alter-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.table-alter-dialog .btn-danger { - background: #b91c1c; - color: #fff; - margin-right: auto; -} - -.table-alter-dialog .btn-danger:hover { - background: #991b1b; -} - -.table-alter-dialog .btn-danger:disabled, -.table-alter-dialog .btn-danger:disabled:hover { - background: #d98c8c; - color: #fff; -} - -.table-alter-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.table-alter-dialog .btn-primary:hover { - background: #1949b8; -} - -.table-alter-dialog .btn-primary:disabled, -.table-alter-dialog .btn-primary:disabled:hover { - background: #a0aec0; - color: #fff; -} - -.table-alter-dialog .btn:disabled, -.table-alter-add-column:disabled, -.table-alter-icon-button:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -@media (max-width: 900px) { - dialog.table-alter-dialog { - width: 95vw; - max-height: 85vh; - border-radius: 0.5rem; - } - - .table-alter-dialog .modal-header, - .table-alter-fields, - .table-alter-review { - padding-left: 18px; - padding-right: 18px; - } - - .table-alter-error { - margin-left: 18px; - margin-right: 18px; - } - - .table-alter-column-headings { - display: none; - } - - .table-alter-column-row { - padding-bottom: 8px; - border-bottom: 1px solid var(--rule); - } - - .table-alter-column-main { - grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; - align-items: end; - } - - .table-alter-column-name { - grid-column: 1; - grid-row: 1; - } - - .table-alter-column-type { - grid-column: 2; - grid-row: 1; - } - - .table-alter-remove-column { - grid-column: 3; - grid-row: 1; - justify-self: end; - } - - .table-alter-move-controls { - grid-column: 1; - grid-row: 2; - justify-self: start; - } - - .table-alter-more-options { - align-self: center; - grid-column: 2 / 4; - grid-row: 2; - } - - .table-alter-column-details { - grid-template-columns: 1fr; - } - - .table-alter-default-grid { - grid-template-columns: 1fr; - } - - .table-alter-dialog .modal-footer { - padding-left: 18px; - padding-right: 18px; - } -} - -.row-link-with-actions { - display: inline-flex; - align-items: center; - gap: 0.4rem; - flex-wrap: wrap; -} - -.row-inline-actions { - display: inline-flex; - gap: 0.2rem; - align-items: center; -} - -.row-inline-action { - appearance: none; - border: 1px solid rgba(74, 85, 104, 0.24); - background: transparent; - color: #4a5568; - border-radius: 4px; - cursor: pointer; - display: inline-grid; - place-items: center; - min-height: 24px; - min-width: 24px; - padding: 2px; - position: relative; -} - -.row-inline-action:hover, -.row-inline-action:focus { - background: rgba(74, 85, 104, 0.07); -} - -.row-inline-action:focus { - outline: 3px solid #b3d4ff; - outline-offset: 1px; -} - -.row-inline-action-icon { - display: block; - height: 13px; - width: 13px; -} - -@media (max-width: 640px) { - dialog.mobile-column-actions-dialog { - width: 95vw; - max-height: 85vh; - border-radius: 0.5rem; - } - - .mobile-column-actions-dialog .modal-header { - padding: 16px 18px 14px; - } - - .mobile-column-top-actions { - padding-left: 18px; - padding-right: 18px; - } - - .mobile-column-actions-dialog .col-header { - padding-left: 18px; - padding-right: 18px; - } - - .mobile-column-actions-dialog .col-actions a, - .mobile-column-actions-dialog .col-actions button { - padding-left: 34px; - padding-right: 18px; - } - - .mobile-column-description, - .mobile-column-no-actions { - padding-left: 18px; - padding-right: 18px; - } - - dialog.set-column-type-dialog { - width: 95vw; - max-height: 85vh; - border-radius: 0.5rem; - } - - .set-column-type-dialog .modal-header, - .set-column-type-status, - .set-column-type-empty, - .set-column-type-error, - .set-column-type-options { - padding-left: 18px; - padding-right: 18px; - } - - dialog.row-delete-dialog { - width: 95vw; - max-height: 85vh; - border-radius: 0.5rem; - } - - .row-delete-dialog .modal-header, - .row-delete-message, - .row-delete-error { - padding-left: 18px; - padding-right: 18px; - } - - .row-delete-dialog .modal-footer { - padding-left: 18px; - padding-right: 18px; - } - - dialog.row-edit-dialog { - width: 95vw; - max-height: 85vh; - border-radius: 0.5rem; - } - - .row-edit-dialog .modal-header, - .row-edit-summary, - .row-edit-loading, - .row-edit-fields, - .row-edit-bulk { - padding-left: 18px; - padding-right: 18px; - } - - .row-edit-error { - margin-left: 18px; - margin-right: 18px; - } - - .row-edit-field { - grid-template-columns: 1fr; - gap: 5px; - } - - .row-edit-label { - padding-top: 0; - } - - .row-edit-bulk-conflict { - grid-template-columns: 1fr; - gap: 5px; - } - - .row-edit-bulk-conflict-label { - padding-top: 0; - } - - .row-edit-dialog .modal-footer { - padding-left: 18px; - padding-right: 18px; - } - - dialog.table-create-dialog { - width: 95vw; - max-height: 85vh; - border-radius: 0.5rem; - } - - .table-create-dialog .modal-header, - .table-create-fields { - padding-left: 18px; - padding-right: 18px; - } - - .table-create-error { - margin-left: 18px; - margin-right: 18px; - } - - .table-create-field { - grid-template-columns: 1fr; - gap: 5px; - } - - .table-create-label { - padding-top: 0; - } - - .table-create-data-pk-field { - grid-template-columns: 1fr; - gap: 5px; - } - - .table-create-column-headings { - display: none; - } - - .table-create-column-row { - padding-bottom: 8px; - border-bottom: 1px solid var(--rule); - } - - .table-create-column-main { - grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; - align-items: end; - } - - .table-create-column-name { - grid-column: 1; - grid-row: 1; - } - - .table-create-column-type { - grid-column: 2; - grid-row: 1; - } - - .table-create-remove-column { - grid-column: 3; - grid-row: 1; - justify-self: end; - } - - .table-create-move-controls { - grid-column: 1; - grid-row: 2; - justify-self: start; - } - - .table-create-more-options { - align-self: center; - grid-column: 2 / 4; - grid-row: 2; - } - - .table-create-column-details { - grid-template-columns: 1fr; - } - - .table-create-default-grid { - grid-template-columns: 1fr; - } - - .table-create-dialog .modal-footer { - padding-left: 18px; - padding-right: 18px; - } - - .row-inline-action { - min-height: 30px; - min-width: 30px; - padding: 4px; - } - - .row-inline-action-icon { - height: 14px; - width: 14px; - } -} - @media only screen and (max-width: 576px) { .small-screen-only { @@ -3648,107 +786,14 @@ select.table-alter-input { font-size: 0.8em; } - .row-inline-actions { - margin-bottom: 0.35rem; + .select-wrapper { + width: 100px; } - - .filters { - max-width: none; - padding: 0.65rem; - } - .filters .search-row { - grid-template-columns: max-content minmax(0, 1fr); - padding-right: 0; - } - .filters .filter-controls-row { - --filter-value-button-space: 0px; - --filter-remove-button-offset: 0px; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .filters .filter-controls-row-one-button { - --filter-value-button-space: calc( - var(--filter-row-icon-size) + var(--filter-control-gap) - ); - } - .filters .filter-controls-row-two-buttons { - --filter-value-button-space: var(--filter-two-icon-space); - --filter-remove-button-offset: calc( - var(--filter-row-icon-size) + var(--filter-control-gap) - ); - } - .filters .filter-controls-row .select-wrapper { - grid-row: 1; - grid-column: 1 / 2; - } - .filters .filter-controls-row .select-wrapper.filter-op { - grid-column: 2 / 3; + .select-wrapper.filter-op { + width: 60px; } .filters input.filter-value { - grid-row: 2; - grid-column: 1 / 3; - justify-self: start; - width: calc(100% - var(--filter-value-button-space)); - } - .filters button.filter-row-icon { - grid-row: 2; - grid-column: 1 / 3; - justify-self: end; - } - .filters .filter-controls-row-two-buttons button.filter-row-remove:not([hidden]) { - margin-right: var(--filter-remove-button-offset); - } - .filters .filter-actions-row { - justify-content: flex-start; - } - .filters .filter-actions-row input[type=submit] { - flex: 0 1 auto; - } - button.choose-columns-mobile, - button.table-insert-row, - button.column-actions-mobile { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.5rem 1rem; - margin-bottom: 1em; - font-size: 0.9rem; - line-height: 1.2; - font-family: inherit; - background: white; - border: 1px solid #ccc; - border-radius: 5px; - cursor: pointer; - vertical-align: top; - box-sizing: border-box; - min-height: 2.5rem; - } - - button.column-actions-mobile { - gap: 0.55rem; - } - - button.column-actions-mobile svg { - display: block; - width: 16px; - height: 16px; - flex-shrink: 0; - } - - button.column-actions-mobile span { - line-height: 1.2; - } - - button.choose-columns-mobile { - margin-right: 0.5rem; - } - - .table-row-toolbar { - margin-bottom: 0.75rem; - } - - button.table-insert-row { - width: 100%; - margin-bottom: 0; + width: 140px; } } @@ -3795,32 +840,18 @@ svg.dropdown-menu-icon { .dropdown-menu a:link, .dropdown-menu a:visited, .dropdown-menu a:hover, -.dropdown-menu a:focus, -.dropdown-menu a:active, -.dropdown-menu button.action-menu-button { +.dropdown-menu a:focus +.dropdown-menu a:active { text-decoration: none; display: block; padding: 4px 8px 2px 8px; color: #222; white-space: nowrap; } -.dropdown-menu button.action-menu-button { - appearance: none; - background: none; - border: none; - box-sizing: border-box; - cursor: pointer; - font: inherit; - text-align: left; - width: 100%; -} -.dropdown-menu a:hover, -.dropdown-menu button.action-menu-button:hover, -.dropdown-menu button.action-menu-button:focus { +.dropdown-menu a:hover { background-color: #eee; } .dropdown-menu .dropdown-description { - display: block; margin: 0; color: #666; font-size: 0.8em; @@ -3839,15 +870,11 @@ svg.dropdown-menu-icon { border-bottom: 5px solid #666; } -.stored-query-edit-sql { +.canned-query-edit-sql { padding-left: 0.5em; position: relative; top: 1px; } -.save-query { - display: inline-block; - margin-left: 0.45em; -} .blob-download { display: block; diff --git a/datasette/static/autocomplete.js b/datasette/static/autocomplete.js deleted file mode 100644 index c615000e..00000000 --- a/datasette/static/autocomplete.js +++ /dev/null @@ -1,344 +0,0 @@ -(function () { - function autocompleteValueFromRow(row) { - var pks = (row && row.pks) || {}; - var keys = Object.keys(pks); - if (!keys.length) { - return ""; - } - if (keys.length === 1) { - return String(pks[keys[0]]); - } - return keys - .map(function (key) { - return key + "=" + pks[key]; - }) - .join(", "); - } - - function autocompleteLabelFromRow(row) { - var value = autocompleteValueFromRow(row); - if (row.label && String(row.label) !== value) { - return row.label + " (" + value + ")"; - } - return value; - } - - if (!window.customElements || customElements.get("datasette-autocomplete")) { - return; - } - - class DatasetteAutocomplete extends HTMLElement { - constructor() { - super(); - this.input = null; - this.listbox = null; - this.status = null; - this.results = []; - this.activeIndex = -1; - this.fetchId = 0; - this.searchTimer = null; - this.boundInput = this.handleInput.bind(this); - this.boundKeydown = this.handleKeydown.bind(this); - this.boundBlur = this.handleBlur.bind(this); - this.boundFocus = this.handleFocus.bind(this); - this.boundPositionListbox = this.positionListbox.bind(this); - } - - connectedCallback() { - if (this.input) { - return; - } - this.input = this.querySelector("input"); - if (!this.input) { - return; - } - - var inputId = - this.input.id || - "datasette-autocomplete-" + Math.random().toString(36).slice(2); - this.input.id = inputId; - var listboxId = inputId + "-listbox"; - var statusId = inputId + "-status"; - - this.classList.add("datasette-autocomplete"); - this.input.setAttribute("role", "combobox"); - this.input.setAttribute("aria-autocomplete", "list"); - this.input.setAttribute("aria-expanded", "false"); - this.input.setAttribute("aria-controls", listboxId); - this.input.setAttribute("autocomplete", "off"); - - this.listbox = document.createElement("div"); - this.listbox.className = "datasette-autocomplete-list"; - this.listbox.id = listboxId; - this.listbox.setAttribute("role", "listbox"); - this.listbox.hidden = true; - - this.status = document.createElement("span"); - this.status.className = "datasette-autocomplete-status"; - this.status.id = statusId; - this.status.setAttribute("role", "status"); - this.status.setAttribute("aria-live", "polite"); - - this.input.setAttribute( - "aria-describedby", - [this.input.getAttribute("aria-describedby"), statusId] - .filter(Boolean) - .join(" "), - ); - - this.appendChild(this.listbox); - this.appendChild(this.status); - - this.input.addEventListener("input", this.boundInput); - this.input.addEventListener("keydown", this.boundKeydown); - this.input.addEventListener("blur", this.boundBlur); - this.input.addEventListener("focus", this.boundFocus); - } - - disconnectedCallback() { - if (!this.input) { - return; - } - this.input.removeEventListener("input", this.boundInput); - this.input.removeEventListener("keydown", this.boundKeydown); - this.input.removeEventListener("blur", this.boundBlur); - this.input.removeEventListener("focus", this.boundFocus); - } - - handleInput() { - this.scheduleSearch(); - } - - handleFocus() { - if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) { - this.scheduleSearch(); - } - } - - handleBlur() { - window.setTimeout(() => this.close(), 150); - } - - handleKeydown(ev) { - if (ev.key === "Escape") { - if (!this.listbox.hidden) { - ev.preventDefault(); - this.close(); - } - return; - } - if (ev.key === "ArrowDown") { - ev.preventDefault(); - if (this.listbox.hidden) { - this.scheduleSearch(); - } else { - this.setActiveIndex(this.activeIndex + 1); - } - return; - } - if (ev.key === "ArrowUp") { - ev.preventDefault(); - if (!this.listbox.hidden) { - this.setActiveIndex(this.activeIndex - 1); - } - return; - } - if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) { - ev.preventDefault(); - this.chooseIndex(this.activeIndex); - } - } - - scheduleSearch() { - window.clearTimeout(this.searchTimer); - this.searchTimer = window.setTimeout(() => this.search(), 150); - } - - async search() { - var query = this.input.value.trim(); - var initial = !query && this.hasAttribute("suggest-on-focus"); - if (!query && !initial) { - this.close(); - this.status.textContent = ""; - return; - } - var src = this.getAttribute("src"); - if (!src) { - return; - } - - var url = new URL(src, location.href); - url.searchParams.set("q", query); - if (initial) { - url.searchParams.set("_initial", "1"); - } else { - url.searchParams.delete("_initial"); - } - var fetchId = this.fetchId + 1; - this.fetchId = fetchId; - this.status.textContent = "Searching..."; - - try { - var response = await fetch(url.toString(), { - headers: { - Accept: "application/json", - }, - }); - if (!response.ok) { - throw new Error("HTTP " + response.status); - } - var data = await response.json(); - if (fetchId !== this.fetchId) { - return; - } - this.results = (data && data.rows) || []; - this.render(); - } catch (_error) { - if (fetchId !== this.fetchId) { - return; - } - this.results = []; - this.close(); - this.status.textContent = "Could not load suggestions"; - } - } - - render() { - this.listbox.textContent = ""; - this.activeIndex = -1; - if (!this.results.length) { - this.close(); - this.status.textContent = "No matches"; - return; - } - - this.results.forEach((row, index) => { - var option = document.createElement("div"); - option.className = "datasette-autocomplete-option"; - option.id = this.input.id + "-option-" + index; - option.setAttribute("role", "option"); - option.setAttribute("aria-selected", "false"); - option.dataset.index = String(index); - option.dataset.value = autocompleteValueFromRow(row); - option.textContent = autocompleteLabelFromRow(row); - option.addEventListener("mousedown", (ev) => { - ev.preventDefault(); - this.chooseIndex(index); - }); - this.listbox.appendChild(option); - }); - - this.listbox.hidden = false; - this.input.setAttribute("aria-expanded", "true"); - this.status.textContent = - this.results.length + (this.results.length === 1 ? " match" : " matches"); - this.positionListbox(); - this.setActiveIndex(0); - } - - positionListbox() { - if (!this.input || !this.listbox || this.listbox.hidden) { - return; - } - - var gap = 3; - var margin = 8; - var inputRect = this.input.getBoundingClientRect(); - this.listbox.style.maxHeight = ""; - var defaultMaxHeight = parseFloat( - window.getComputedStyle(this.listbox).maxHeight, - ); - if (!Number.isFinite(defaultMaxHeight)) { - defaultMaxHeight = 256; - } - var scrollHeight = Math.ceil(this.listbox.scrollHeight); - var desiredHeight = Math.min(scrollHeight, defaultMaxHeight); - var availableBelow = Math.max( - 0, - (window.innerHeight || document.documentElement.clientHeight) - - inputRect.bottom - - gap - - margin, - ); - - this.listbox.style.left = inputRect.left + "px"; - this.listbox.style.top = inputRect.bottom + gap + "px"; - this.listbox.style.width = inputRect.width + "px"; - if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) { - this.listbox.style.maxHeight = "none"; - } else { - this.listbox.style.maxHeight = - Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) + - "px"; - } - window.addEventListener("resize", this.boundPositionListbox); - document.addEventListener("scroll", this.boundPositionListbox, true); - } - - setActiveIndex(index) { - var options = this.listbox.querySelectorAll("[role='option']"); - if (!options.length) { - this.activeIndex = -1; - this.input.removeAttribute("aria-activedescendant"); - return; - } - if (index < 0) { - index = options.length - 1; - } - if (index >= options.length) { - index = 0; - } - options.forEach((option, optionIndex) => { - option.setAttribute( - "aria-selected", - optionIndex === index ? "true" : "false", - ); - }); - this.activeIndex = index; - this.input.setAttribute("aria-activedescendant", options[index].id); - } - - chooseIndex(index) { - var row = this.results[index]; - if (!row) { - return; - } - var value = autocompleteValueFromRow(row); - var label = autocompleteLabelFromRow(row); - this.input.value = value; - this.input.dispatchEvent(new Event("change", { bubbles: true })); - this.close(); - this.status.textContent = "Selected " + label; - this.dispatchEvent( - new CustomEvent("datasette-autocomplete-select", { - bubbles: true, - detail: { - row: row, - value: value, - label: label, - }, - }), - ); - } - - close() { - if (this.listbox) { - this.listbox.hidden = true; - this.listbox.textContent = ""; - this.listbox.style.left = ""; - this.listbox.style.maxHeight = ""; - this.listbox.style.top = ""; - this.listbox.style.width = ""; - } - if (this.input) { - this.input.setAttribute("aria-expanded", "false"); - this.input.removeAttribute("aria-activedescendant"); - } - window.removeEventListener("resize", this.boundPositionListbox); - document.removeEventListener("scroll", this.boundPositionListbox, true); - this.activeIndex = -1; - } - } - - customElements.define("datasette-autocomplete", DatasetteAutocomplete); -})(); diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js deleted file mode 100644 index 198641f3..00000000 --- a/datasette/static/column-chooser.js +++ /dev/null @@ -1,699 +0,0 @@ -class ColumnChooser extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: "open" }); - - // State - this._items = []; - this._checked = new Set(); - this._savedItems = null; - this._savedChecked = null; - this._onApply = null; - - // Drag state - this._ghost = null; - this._dragSrcIdx = null; - this._dropTargetIdx = null; - this._dropPosition = null; - this._ghostOffX = 0; - this._ghostOffY = 0; - this._autoScrollRAF = null; - this._lastPointerY = 0; - this._lastPointerX = 0; - this._SCROLL_ZONE = 72; - this._SCROLL_SPEED = 0.4; - - // Bound handlers - this._onMove = this._onMove.bind(this); - this._onUp = this._onUp.bind(this); - - this.shadowRoot.innerHTML = ` - - - - -
- - -
-
-
-
-
    -
    - -
    - `; - - // DOM refs - this._dialog = this.shadowRoot.querySelector("dialog"); - this._listWrap = this.shadowRoot.getElementById("listWrap"); - this._dragList = this.shadowRoot.getElementById("dragList"); - this._pulseTop = this.shadowRoot.getElementById("pulseTop"); - this._pulseBot = this.shadowRoot.getElementById("pulseBot"); - this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn"); - this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn"); - this._cancelBtn = this.shadowRoot.getElementById("cancelBtn"); - this._applyBtn = this.shadowRoot.getElementById("applyBtn"); - this._countEl = this.shadowRoot.getElementById("selectedCount"); - this._footerEl = this.shadowRoot.getElementById("footerInfo"); - - // Event listeners - this._selectAllBtn.addEventListener("click", () => this._selectAll()); - this._deselectAllBtn.addEventListener("click", () => this._deselectAll()); - this._cancelBtn.addEventListener("click", () => this._close()); - this._applyBtn.addEventListener("click", () => this._apply()); - this._dialog.addEventListener("click", (e) => { - if (e.target === this._dialog) this._close(); - }); - this._dialog.addEventListener("cancel", (e) => { - e.preventDefault(); - this._close(); - }); - } - - /** - * Open the column chooser dialog. - * @param {Object} opts - * @param {string[]} opts.columns - All available column names, in display order. - * @param {string[]} opts.selected - Column names that should be pre-checked. - * @param {function(string[]): void} opts.onApply - Called with the selected columns in order when Apply is clicked. - */ - open({ columns, selected = [], onApply }) { - this._items = [...columns]; - this._checked = new Set(selected); - this._onApply = onApply || null; - - // Save state for cancel/restore - this._savedItems = [...this._items]; - this._savedChecked = new Set(this._checked); - - this._render(); - this._dialog.showModal(); - } - - // ── Internal methods ── - - _close() { - this._items = this._savedItems ? [...this._savedItems] : this._items; - this._checked = this._savedChecked - ? new Set(this._savedChecked) - : this._checked; - this._dialog.close(); - } - - _selectAll() { - this._items.forEach((col) => this._checked.add(col)); - this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => { - cb.checked = true; - }); - this._updateCounts(); - } - - _deselectAll() { - this._checked.clear(); - this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => { - cb.checked = false; - }); - this._updateCounts(); - } - - _apply() { - const selected = this._items.filter((col) => this._checked.has(col)); - this._dialog.close(); - if (this._onApply) { - this._onApply(selected); - } - } - - _render() { - this._dragList.innerHTML = ""; - this._items.forEach((col, i) => { - const li = document.createElement("li"); - li.className = "drag-item"; - li.dataset.idx = i; - li.innerHTML = ` - - - - - - - - - - - -
    - `; - - li.querySelector("input").addEventListener("change", (e) => { - e.target.checked ? this._checked.add(col) : this._checked.delete(col); - this._updateCounts(); - }); - - li.querySelector(".drag-handle").addEventListener("pointerdown", (e) => - this._startDrag(e, i), - ); - this._dragList.appendChild(li); - }); - - this._updateCounts(); - } - - _updateCounts() { - const n = this._checked.size; - this._countEl.textContent = `${n} of ${this._items.length} selected`; - this._footerEl.textContent = `${this._items.length} columns`; - } - - // ── Drag engine ── - - _startDrag(e, idx) { - e.preventDefault(); - this._dragSrcIdx = idx; - - const srcEl = this._dragList.children[idx]; - const rect = srcEl.getBoundingClientRect(); - - this._ghostOffX = e.clientX - rect.left; - this._ghostOffY = e.clientY - rect.top; - - // Build ghost inside shadow DOM - this._ghost = document.createElement("div"); - this._ghost.className = "drag-ghost"; - this._ghost.style.width = rect.width + "px"; - this._ghost.style.height = rect.height + "px"; - this._ghost.innerHTML = srcEl.innerHTML; - this._ghost.querySelector(".drop-indicator")?.remove(); - const h = this._ghost.querySelector(".drag-handle"); - if (h) h.style.color = "var(--accent)"; - this.shadowRoot.appendChild(this._ghost); - - srcEl.classList.add("is-dragging"); - this._positionGhost(e.clientX, e.clientY); - - document.addEventListener("pointermove", this._onMove); - document.addEventListener("pointerup", this._onUp); - document.addEventListener("pointercancel", this._onUp); - } - - _positionGhost(cx, cy) { - this._ghost.style.left = cx - this._ghostOffX + "px"; - this._ghost.style.top = cy - this._ghostOffY + "px"; - } - - _onMove(e) { - this._lastPointerX = e.clientX; - this._lastPointerY = e.clientY; - this._positionGhost(e.clientX, e.clientY); - this._updateDropTarget(e.clientY); - this._updateAutoScroll(e.clientY); - } - - _onUp() { - document.removeEventListener("pointermove", this._onMove); - document.removeEventListener("pointerup", this._onUp); - document.removeEventListener("pointercancel", this._onUp); - - this._stopAutoScroll(); - - const noMove = - this._dropTargetIdx === null || this._dropTargetIdx === this._dragSrcIdx; - this._clearDropIndicators(); - - let dest = null; - if (!noMove) { - const moved = this._items.splice(this._dragSrcIdx, 1)[0]; - dest = this._dropTargetIdx; - if (this._dropPosition === "after") dest++; - if (dest > this._dragSrcIdx) dest--; - this._items.splice(dest, 0, moved); - } - - this._dragSrcIdx = null; - this._dropTargetIdx = null; - this._dropPosition = null; - - const g = this._ghost; - this._ghost = null; - - if (noMove) { - if (g) g.remove(); - this._render(); - return; - } - - this._render(); - - if (g && dest !== null) { - const landedEl = this._dragList.children[dest]; - if (landedEl) { - landedEl.style.opacity = "0"; - const r = landedEl.getBoundingClientRect(); - g.getBoundingClientRect(); - g.style.transition = - "left 0.15s cubic-bezier(0.22, 1, 0.36, 1), top 0.15s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.15s, opacity 0.1s 0.1s"; - g.style.left = r.left + "px"; - g.style.top = r.top + "px"; - g.style.boxShadow = "0 1px 4px rgba(0,0,0,0.08)"; - g.style.opacity = "0"; - setTimeout(() => { - g.remove(); - if (landedEl) landedEl.style.opacity = ""; - }, 160); - } else { - g.remove(); - } - } else if (g) { - g.remove(); - } - } - - _updateDropTarget(clientY) { - this._clearDropIndicators(); - const listItems = [ - ...this._dragList.querySelectorAll(".drag-item:not(.is-dragging)"), - ]; - if (!listItems.length) return; - - let best = null, - bestDist = Infinity; - listItems.forEach((li) => { - const r = li.getBoundingClientRect(); - const mid = r.top + r.height / 2; - const dist = Math.abs(clientY - mid); - if (dist < bestDist) { - bestDist = dist; - best = li; - } - }); - - if (!best) return; - const r = best.getBoundingClientRect(); - const mid = r.top + r.height / 2; - const above = clientY < mid; - const indic = best.querySelector(".drop-indicator"); - - this._dropTargetIdx = parseInt(best.dataset.idx); - this._dropPosition = above ? "before" : "after"; - - if (indic) { - indic.className = "drop-indicator " + (above ? "top" : "bottom"); - } - } - - _clearDropIndicators() { - this._dragList.querySelectorAll(".drop-indicator").forEach((el) => { - el.className = "drop-indicator"; - }); - } - - _updateAutoScroll(clientY) { - const rect = this._listWrap.getBoundingClientRect(); - const relY = clientY - rect.top; - const distTop = relY; - const distBot = rect.height - relY; - - const inTop = distTop < this._SCROLL_ZONE && distTop >= 0; - const inBot = distBot < this._SCROLL_ZONE && distBot >= 0; - - this._pulseTop.classList.toggle("active", inTop); - this._pulseBot.classList.toggle("active", inBot); - - if ((inTop || inBot) && !this._autoScrollRAF) { - let lastTime = null; - const loop = (ts) => { - if (!this._ghost) { - this._stopAutoScroll(); - return; - } - if (lastTime !== null) { - const dt = ts - lastTime; - const rect2 = this._listWrap.getBoundingClientRect(); - const relY2 = this._lastPointerY - rect2.top; - const dTop = relY2; - const dBot = rect2.height - relY2; - - if (dTop < this._SCROLL_ZONE && dTop >= 0) { - const factor = 1 - dTop / this._SCROLL_ZONE; - this._listWrap.scrollTop -= this._SCROLL_SPEED * dt * factor * 2.5; - } else if (dBot < this._SCROLL_ZONE && dBot >= 0) { - const factor = 1 - dBot / this._SCROLL_ZONE; - this._listWrap.scrollTop += this._SCROLL_SPEED * dt * factor * 2.5; - } else { - this._stopAutoScroll(); - return; - } - this._updateDropTarget(this._lastPointerY); - } - lastTime = ts; - this._autoScrollRAF = requestAnimationFrame(loop); - }; - this._autoScrollRAF = requestAnimationFrame(loop); - } - - if (!inTop && !inBot) this._stopAutoScroll(); - } - - _stopAutoScroll() { - if (this._autoScrollRAF) { - cancelAnimationFrame(this._autoScrollRAF); - this._autoScrollRAF = null; - } - this._pulseTop.classList.remove("active"); - this._pulseBot.classList.remove("active"); - } -} - -customElements.define("column-chooser", ColumnChooser); diff --git a/datasette/static/datasette-manager.js b/datasette/static/datasette-manager.js index b049fc73..d2347ab3 100644 --- a/datasette/static/datasette-manager.js +++ b/datasette/static/datasette-manager.js @@ -82,48 +82,6 @@ const datasetteManager = { return columnActions; }, - /** - * Allows JavaScript plugins to replace or enhance insert/edit modal fields - * for specific Datasette column types. - * - * The first plugin to return a control object wins. Returning null or - * undefined means "I do not handle this field". - */ - makeColumnField: (context) => { - for (const [pluginName, plugin] of datasetteManager.plugins) { - if (!plugin.makeColumnField) { - continue; - } - let control = null; - try { - control = plugin.makeColumnField(context); - } catch (error) { - console.error( - `Error in makeColumnField() for plugin ${pluginName}`, - error, - ); - continue; - } - if (control) { - return Object.assign({ pluginName }, control); - } - } - return null; - }, - - makeJumpSections: (context) => { - let jumpSections = []; - - datasetteManager.plugins.forEach((plugin) => { - if (plugin.makeJumpSections) { - const sections = plugin.makeJumpSections(context) || []; - jumpSections.push(...sections); - } - }); - - return jumpSections; - }, - /** * In MVP, each plugin can only have 1 instance. * In future, panels could be repeated. We omit that for now since so many plugins depend on @@ -234,6 +192,7 @@ const initializeDatasette = () => { // DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window. window.__DATASETTE__ = datasetteManager; + console.debug("Datasette Manager Created!"); const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, { detail: datasetteManager, diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js deleted file mode 100644 index 9e8b93f6..00000000 --- a/datasette/static/edit-tools.js +++ /dev/null @@ -1,7622 +0,0 @@ -var ROW_DELETE_DIALOG_ID = "row-delete-dialog"; -var rowDeleteDialogState = null; -var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; -var rowEditDialogState = null; -var ROW_EDIT_BINARY_IMAGE_PREVIEW_MAX_BYTES = 10 * 1024 * 1024; -var TABLE_CREATE_DIALOG_ID = "table-create-dialog"; -var tableCreateDialogState = null; -var TABLE_CREATE_AUTOMATIC_PK = "__datasette_automatic_pk__"; -var TABLE_ALTER_DIALOG_ID = "table-alter-dialog"; -var tableAlterDialogState = null; - -function ensureRowMutationStatus(manager) { - var status = document.querySelector(".row-mutation-status"); - if (status) { - return status; - } - - status = document.createElement("p"); - status.className = "row-mutation-status"; - status.hidden = true; - status.setAttribute("role", "status"); - status.setAttribute("aria-live", "polite"); - status.setAttribute("tabindex", "-1"); - - var tableWrapper = document.querySelector(manager.selectors.tableWrapper); - if (tableWrapper && tableWrapper.parentNode) { - tableWrapper.parentNode.insertBefore(status, tableWrapper); - } else { - document.body.appendChild(status); - } - return status; -} - -function showRowMutationStatus(manager, message, isError) { - var status = ensureRowMutationStatus(manager); - status.hidden = false; - status.classList.toggle("row-mutation-status-error", !!isError); - status.textContent = message; - return status; -} - -function hideRowMutationStatus() { - var status = document.querySelector(".row-mutation-status"); - if (!status) { - return; - } - status.hidden = true; - status.classList.remove("row-mutation-status-error"); - status.textContent = ""; -} - -function databaseCreateTableData() { - return ( - window._datasetteDatabaseData && window._datasetteDatabaseData.createTable - ); -} - -function tableCreateColumnTypes() { - var data = databaseCreateTableData() || {}; - return data.columnTypes && data.columnTypes.length - ? data.columnTypes - : ["text", "integer", "float", "blob"]; -} - -function tableCreateDefaultExpressions() { - var data = databaseCreateTableData() || {}; - return data.defaultExpressions || []; -} - -var SQLITE_COLUMN_TYPE_LABELS = { - float: "floating point number", - real: "floating point number", - blob: "blob - binary data", -}; - -function sqliteColumnTypeLabel(type) { - return SQLITE_COLUMN_TYPE_LABELS[type] || type; -} - -function populateSqliteColumnTypeSelect(select, type, options) { - options.forEach(function (option) { - var optionElement = document.createElement("option"); - optionElement.value = option; - optionElement.textContent = sqliteColumnTypeLabel(option); - select.appendChild(optionElement); - }); - select.value = options.indexOf(type) === -1 ? options[0] : type; -} - -function updateSelectPlaceholder(select, placeholderClass) { - select.classList.toggle(placeholderClass, !select.value); -} - -function createCustomColumnTypeSelect(options, className, placeholderClass) { - var select = document.createElement("select"); - select.className = className; - select.setAttribute("aria-label", "Custom column type"); - var blankOption = document.createElement("option"); - blankOption.value = ""; - blankOption.textContent = "- custom type -"; - select.appendChild(blankOption); - options.forEach(function (option) { - var optionElement = document.createElement("option"); - optionElement.value = option.name; - optionElement.textContent = option.description - ? option.description + " (" + option.name + ")" - : option.name; - select.appendChild(optionElement); - }); - updateSelectPlaceholder(select, placeholderClass); - return select; -} - -function normalizeDefaultExpressionOption(option) { - if (typeof option === "string") { - return { - value: option, - label: option.replace(/_/g, " "), - sqliteType: "", - }; - } - option = option || {}; - return { - value: option.value || "", - label: option.label || option.value || "", - sqliteType: option.sqliteType || "", - }; -} - -function defaultExpressionOptionForValue(options, value) { - var match = null; - (options || []).some(function (option) { - var normalized = normalizeDefaultExpressionOption(option); - if (normalized.value === value) { - match = normalized; - return true; - } - return false; - }); - return match; -} - -function defaultExpressionLabelForValue(options, value) { - var option = defaultExpressionOptionForValue(options, value); - return option && option.label - ? option.label - : value - ? value.replace(/_/g, " ") - : ""; -} - -function applyDefaultExpressionColumnType(row, prefix, options, columnTypes) { - var defaultExprSelect = row.querySelector("." + prefix + "-default-expr"); - var typeSelect = row.querySelector("." + prefix + "-column-type"); - if (!defaultExprSelect || !typeSelect || !defaultExprSelect.value) { - return false; - } - var option = defaultExpressionOptionForValue( - options, - defaultExprSelect.value, - ); - if ( - option && - option.sqliteType && - (columnTypes || []).indexOf(option.sqliteType) !== -1 && - typeSelect.value !== option.sqliteType - ) { - typeSelect.value = option.sqliteType; - return true; - } - return false; -} - -function createDefaultExpressionSelect( - options, - className, - placeholderClass, - value, -) { - var select = document.createElement("select"); - select.className = className; - var blankOption = document.createElement("option"); - blankOption.value = ""; - blankOption.textContent = "- default expr -"; - select.appendChild(blankOption); - options.forEach(function (option) { - var normalized = normalizeDefaultExpressionOption(option); - if (!normalized.value) { - return; - } - var optionElement = document.createElement("option"); - optionElement.value = normalized.value; - optionElement.textContent = normalized.label; - select.appendChild(optionElement); - }); - select.value = value || ""; - updateSelectPlaceholder(select, placeholderClass); - return select; -} - -function createSchemaDialogDefaultControls(prefix, index, expressions, column) { - var defaultDetails = document.createElement("details"); - defaultDetails.className = prefix + "-default-options"; - defaultDetails.open = !!( - column && - (column.defaultValue || column.defaultExpr) - ); - var summary = document.createElement("summary"); - summary.textContent = "Set a default value"; - defaultDetails.appendChild(summary); - - var defaultGrid = document.createElement("div"); - defaultGrid.className = prefix + "-default-grid"; - - var defaultExprId = prefix + "-column-default-expr-" + index; - var defaultExprField = document.createElement("div"); - defaultExprField.className = prefix + "-detail-field"; - var defaultExprLabel = document.createElement("label"); - defaultExprLabel.className = prefix + "-detail-label"; - defaultExprLabel.setAttribute("for", defaultExprId); - defaultExprLabel.textContent = "Default expression"; - var defaultExprSelect = createDefaultExpressionSelect( - expressions, - prefix + "-input " + prefix + "-default-expr", - prefix + "-input-placeholder", - column && column.defaultExpr, - ); - defaultExprSelect.id = defaultExprId; - defaultExprSelect.setAttribute("aria-label", "Default expression"); - defaultExprField.appendChild(defaultExprLabel); - defaultExprField.appendChild(defaultExprSelect); - - var defaultId = prefix + "-column-default-" + index; - var defaultField = document.createElement("div"); - defaultField.className = prefix + "-detail-field"; - var defaultLabel = document.createElement("label"); - defaultLabel.className = prefix + "-detail-label"; - defaultLabel.setAttribute("for", defaultId); - defaultLabel.textContent = "or default to a specific value"; - var defaultInput = document.createElement("input"); - defaultInput.id = defaultId; - defaultInput.className = prefix + "-input " + prefix + "-default"; - defaultInput.type = "text"; - defaultInput.autocomplete = "off"; - defaultInput.placeholder = "default"; - defaultInput.setAttribute("aria-label", "or default to a specific value"); - defaultInput.value = column && column.defaultValue ? column.defaultValue : ""; - defaultField.appendChild(defaultLabel); - defaultField.appendChild(defaultInput); - - defaultGrid.appendChild(defaultExprField); - defaultGrid.appendChild(defaultField); - defaultDetails.appendChild(defaultGrid); - - return { - controls: defaultDetails, - defaultInput: defaultInput, - defaultExprSelect: defaultExprSelect, - }; -} - -function syncSchemaDialogDefaultControls(row, prefix) { - if (!row) { - return; - } - var defaultInput = row.querySelector("." + prefix + "-default"); - var defaultExprSelect = row.querySelector("." + prefix + "-default-expr"); - if (!defaultInput || !defaultExprSelect) { - return; - } - updateSelectPlaceholder(defaultExprSelect, prefix + "-input-placeholder"); -} - -var COLUMN_MOVE_ICONS = { - top: '', - up: '', - down: '', - bottom: - '', - remove: - '', -}; - -function createSchemaDialogIconButton(prefix, modifier, ariaLabel, title, svg) { - var button = document.createElement("button"); - button.type = "button"; - button.className = prefix + "-icon-button " + prefix + "-" + modifier; - button.setAttribute("aria-label", ariaLabel); - button.title = title; - button.dataset.defaultTitle = title; - button.innerHTML = svg; - return button; -} - -function createSchemaDialogMoveControls(prefix) { - var moveControls = document.createElement("div"); - moveControls.className = prefix + "-move-controls"; - - var moveTopButton = createSchemaDialogIconButton( - prefix, - "move-top", - "Move column to top", - "Move column to top", - COLUMN_MOVE_ICONS.top, - ); - var moveUpButton = createSchemaDialogIconButton( - prefix, - "move-up", - "Move column up", - "Move column up", - COLUMN_MOVE_ICONS.up, - ); - var moveDownButton = createSchemaDialogIconButton( - prefix, - "move-down", - "Move column down", - "Move column down", - COLUMN_MOVE_ICONS.down, - ); - var moveBottomButton = createSchemaDialogIconButton( - prefix, - "move-bottom", - "Move column to bottom", - "Move column to bottom", - COLUMN_MOVE_ICONS.bottom, - ); - - moveControls.appendChild(moveTopButton); - moveControls.appendChild(moveUpButton); - moveControls.appendChild(moveDownButton); - moveControls.appendChild(moveBottomButton); - - return { - controls: moveControls, - topButton: moveTopButton, - upButton: moveUpButton, - downButton: moveDownButton, - bottomButton: moveBottomButton, - }; -} - -function createSchemaDialogMoreOptionsButton(prefix, details) { - var expandButton = document.createElement("button"); - expandButton.type = "button"; - expandButton.className = prefix + "-more-options"; - expandButton.setAttribute("aria-label", "Toggle column settings"); - expandButton.setAttribute("aria-controls", details.id); - expandButton.setAttribute("aria-expanded", details.hidden ? "false" : "true"); - updateSchemaDialogMoreOptionsButton(expandButton); - return expandButton; -} - -function updateSchemaDialogMoreOptionsButton(button) { - var isExpanded = button.getAttribute("aria-expanded") === "true"; - button.textContent = isExpanded ? "v Hide options" : "> Advanced options"; - button.title = isExpanded ? "Hide column settings" : "Show column settings"; -} - -function toggleSchemaDialogMoreOptions(button, details) { - var isExpanded = button.getAttribute("aria-expanded") === "true"; - details.hidden = isExpanded; - button.setAttribute("aria-expanded", isExpanded ? "false" : "true"); - updateSchemaDialogMoreOptionsButton(button); -} - -function schemaDialogRows(state, prefix) { - return Array.prototype.slice.call( - state.columnList.querySelectorAll("." + prefix + "-column-row"), - ); -} - -function schemaDialogRowIsPrimaryKey(row, prefix) { - var input = row && row.querySelector("." + prefix + "-primary-key-input"); - return !!(input && input.checked); -} - -function schemaDialogFirstNonPrimaryRow(state, prefix) { - var rows = schemaDialogRows(state, prefix); - for (var i = 0; i < rows.length; i += 1) { - if (!schemaDialogRowIsPrimaryKey(rows[i], prefix)) { - return rows[i]; - } - } - return null; -} - -function updateSchemaDialogMoveButtons(state, prefix) { - if (!state || !state.columnList) { - return; - } - var firstNonPrimary = schemaDialogFirstNonPrimaryRow(state, prefix); - var rows = schemaDialogRows(state, prefix); - var hasPrimaryKeys = rows.some(function (row) { - return schemaDialogRowIsPrimaryKey(row, prefix); - }); - var primaryKeyMoveTitle = "Primary key columns are always listed first"; - rows.forEach(function (row) { - var isPrimaryKey = schemaDialogRowIsPrimaryKey(row, prefix); - var previous = row.previousElementSibling; - var next = row.nextElementSibling; - row - .querySelectorAll("." + prefix + "-move-controls button") - .forEach(function (button) { - button.title = button.dataset.defaultTitle || button.title; - button.disabled = state.isSaving || isPrimaryKey; - if (isPrimaryKey) { - button.title = primaryKeyMoveTitle; - } - }); - if (!isPrimaryKey) { - var topButton = row.querySelector("." + prefix + "-move-top"); - var upButton = row.querySelector("." + prefix + "-move-up"); - var downButton = row.querySelector("." + prefix + "-move-down"); - var bottomButton = row.querySelector("." + prefix + "-move-bottom"); - topButton.disabled = - state.isSaving || !firstNonPrimary || row === firstNonPrimary; - upButton.disabled = - state.isSaving || - !previous || - schemaDialogRowIsPrimaryKey(previous, prefix); - downButton.disabled = state.isSaving || !next; - bottomButton.disabled = state.isSaving || !next; - if (hasPrimaryKeys && row === firstNonPrimary) { - topButton.title = primaryKeyMoveTitle; - upButton.title = primaryKeyMoveTitle; - } - } - }); -} - -function normalizeSchemaDialogPrimaryKeyRows(state, prefix) { - var rows = schemaDialogRows(state, prefix); - rows - .filter(function (row) { - return schemaDialogRowIsPrimaryKey(row, prefix); - }) - .concat( - rows.filter(function (row) { - return !schemaDialogRowIsPrimaryKey(row, prefix); - }), - ) - .forEach(function (row) { - state.columnList.appendChild(row); - }); -} - -function tableCreateCustomColumnTypes() { - var data = databaseCreateTableData() || {}; - return data.customColumnTypes || []; -} - -function tableCreateCustomColumnType(name) { - var options = tableCreateCustomColumnTypes(); - for (var i = 0; i < options.length; i += 1) { - if (options[i].name === name) { - return options[i]; - } - } - return null; -} - -function tableCreateCustomTypeAppliesToSqliteType(option, sqliteType) { - return ( - option && - option.sqliteTypes && - option.sqliteTypes.indexOf(sqliteType) !== -1 - ); -} - -function tableCreateDialogRows(state) { - return schemaDialogRows(state, "table-create"); -} - -function tableCreateRowIsPrimaryKey(row) { - return schemaDialogRowIsPrimaryKey(row, "table-create"); -} - -function tableCreateFirstNonPrimaryRow(state) { - return schemaDialogFirstNonPrimaryRow(state, "table-create"); -} - -function updateTableCreateMoveButtons(state) { - updateSchemaDialogMoveButtons(state, "table-create"); -} - -function schemaDialogTypeAffinity(type) { - if (type === "float") { - return "real"; - } - return type; -} - -function foreignKeyTypesCompatible(sourceAffinity, targetAffinity) { - if (sourceAffinity === targetAffinity) { - return true; - } - var numericAffinities = ["integer", "real", "numeric"]; - if (sourceAffinity === "numeric") { - return numericAffinities.indexOf(targetAffinity) !== -1; - } - if (targetAffinity === "numeric") { - return numericAffinities.indexOf(sourceAffinity) !== -1; - } - return false; -} - -function foreignKeyTargetKey(target) { - return target.fk_table + "\u001f" + target.fk_column; -} - -function foreignKeyTargetLabel(target) { - return ( - target.fk_table + - "." + - target.fk_column + - " (" + - sqliteColumnTypeLabel(target.type) + - ")" - ); -} - -function appendForeignKeyTargetOption(select, target) { - var optionElement = document.createElement("option"); - optionElement.value = foreignKeyTargetKey(target); - optionElement.dataset.fkTable = target.fk_table; - optionElement.dataset.fkColumn = target.fk_column; - optionElement.dataset.fkType = target.type; - optionElement.textContent = foreignKeyTargetLabel(target); - select.appendChild(optionElement); - return optionElement; -} - -function sqliteColumnTypeForForeignKeyTarget(type) { - var affinity = schemaDialogTypeAffinity(type); - if (affinity === "real" || affinity === "numeric") { - return "float"; - } - if (["text", "integer", "blob"].indexOf(affinity) !== -1) { - return affinity; - } - return ""; -} - -function selectedSchemaDialogForeignKeyOption(foreignKeySelect) { - return foreignKeySelect && foreignKeySelect.selectedOptions - ? foreignKeySelect.selectedOptions[0] - : null; -} - -function setBlankSchemaDialogColumnNameFromForeignKey( - row, - prefix, - foreignKeyOption, -) { - var nameInput = row.querySelector("." + prefix + "-column-name"); - if ( - nameInput && - !nameInput.value.trim() && - foreignKeyOption && - foreignKeyOption.dataset.fkTable && - foreignKeyOption.dataset.fkColumn - ) { - nameInput.value = - foreignKeyOption.dataset.fkTable + - "_" + - foreignKeyOption.dataset.fkColumn; - } -} - -function tableCreateForeignKeyTargetsUrl() { - var data = databaseCreateTableData() || {}; - if (data.foreignKeyTargetsPath) { - return data.foreignKeyTargetsPath; - } - if (!data.path) { - return null; - } - return data.path.replace(/\/-\/create$/, "/-/foreign-key-targets"); -} - -function populateSchemaDialogForeignKeySelect( - select, - state, - prefix, - sourceType, - options, -) { - options = options || {}; - var previousKey = select.value || select.dataset.selectedKey || ""; - select.textContent = ""; - - var blankOption = document.createElement("option"); - blankOption.value = ""; - blankOption.textContent = "- no foreign key -"; - select.appendChild(blankOption); - - if (state.foreignKeyTargetsLoading) { - var loadingOption = document.createElement("option"); - loadingOption.value = ""; - loadingOption.disabled = true; - loadingOption.textContent = "Loading foreign keys..."; - select.appendChild(loadingOption); - } else if (state.foreignKeyTargetsError) { - var errorOption = document.createElement("option"); - errorOption.value = ""; - errorOption.disabled = true; - errorOption.textContent = "Could not load foreign keys"; - select.appendChild(errorOption); - } else { - var sourceAffinity = schemaDialogTypeAffinity(sourceType); - (state.foreignKeyTargets || []).forEach(function (target) { - if ( - options.filterByType !== false && - !foreignKeyTypesCompatible(sourceAffinity, target.type) - ) { - return; - } - appendForeignKeyTargetOption(select, target); - }); - } - - select.value = previousKey; - if ( - previousKey && - select.value !== previousKey && - select.dataset.currentFkTable && - select.dataset.currentFkColumn - ) { - appendForeignKeyTargetOption(select, { - fk_table: select.dataset.currentFkTable, - fk_column: select.dataset.currentFkColumn, - type: select.dataset.currentFkType || sourceType, - }); - select.value = previousKey; - } - if (select.value !== previousKey) { - select.value = ""; - } - select.dataset.selectedKey = select.value; - select.disabled = state.isSaving || select.options.length <= 1; - updateSelectPlaceholder(select, prefix + "-input-placeholder"); -} - -function syncSchemaDialogForeignKeyOptions(row, state, prefix, options) { - var typeSelect = row.querySelector("." + prefix + "-column-type"); - var foreignKeySelect = row.querySelector( - "." + prefix + "-foreign-key-target", - ); - if (!typeSelect || !foreignKeySelect) { - return; - } - populateSchemaDialogForeignKeySelect( - foreignKeySelect, - state, - prefix, - typeSelect.value, - options, - ); -} - -function syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix) { - var customTypeSelect = row.querySelector( - "." + prefix + "-custom-column-type", - ); - var foreignKeySelect = row.querySelector( - "." + prefix + "-foreign-key-target", - ); - if (!foreignKeySelect) { - return; - } - - var hasCustomType = customTypeSelect && !!customTypeSelect.value; - var hasForeignKey = !!foreignKeySelect.value; - - if (customTypeSelect && hasForeignKey) { - customTypeSelect.value = ""; - updateSelectPlaceholder(customTypeSelect, prefix + "-input-placeholder"); - hasCustomType = false; - } - - if (hasCustomType) { - foreignKeySelect.value = ""; - foreignKeySelect.dataset.selectedKey = ""; - updateSelectPlaceholder(foreignKeySelect, prefix + "-input-placeholder"); - hasForeignKey = false; - } - - if (customTypeSelect) { - customTypeSelect.disabled = state.isSaving; - } - foreignKeySelect.disabled = - state.isSaving || foreignKeySelect.options.length <= 1; -} - -function handleSchemaDialogForeignKeyChange(row, state, prefix, options) { - options = options || {}; - var foreignKeySelect = row.querySelector( - "." + prefix + "-foreign-key-target", - ); - var typeSelect = row.querySelector("." + prefix + "-column-type"); - var customTypeSelect = row.querySelector( - "." + prefix + "-custom-column-type", - ); - if (!foreignKeySelect) { - return; - } - foreignKeySelect.dataset.selectedKey = foreignKeySelect.value; - updateSelectPlaceholder(foreignKeySelect, prefix + "-input-placeholder"); - - var foreignKeyOption = selectedSchemaDialogForeignKeyOption(foreignKeySelect); - setBlankSchemaDialogColumnNameFromForeignKey(row, prefix, foreignKeyOption); - - var columnTypes = options.columnTypes || []; - var foreignKeyColumnType = - foreignKeyOption && foreignKeyOption.dataset.fkType - ? sqliteColumnTypeForForeignKeyTarget(foreignKeyOption.dataset.fkType) - : ""; - if ( - options.matchType && - typeSelect && - foreignKeyColumnType && - columnTypes.indexOf(foreignKeyColumnType) !== -1 && - typeSelect.value !== foreignKeyColumnType - ) { - typeSelect.value = foreignKeyColumnType; - syncSchemaDialogForeignKeyOptions( - row, - state, - prefix, - options.foreignKeyOptions, - ); - } - - if (customTypeSelect && foreignKeySelect.value) { - customTypeSelect.value = ""; - updateSelectPlaceholder(customTypeSelect, prefix + "-input-placeholder"); - } - syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix); -} - -function refreshSchemaDialogForeignKeyControls(state, prefix, options) { - schemaDialogRows(state, prefix).forEach(function (row) { - syncSchemaDialogForeignKeyOptions(row, state, prefix, options); - syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix); - }); -} - -async function loadSchemaDialogForeignKeyTargets(state, prefix, url, options) { - if (!url || !window.fetch) { - state.foreignKeyTargets = []; - state.foreignKeyTargetsLoading = false; - refreshSchemaDialogForeignKeyControls(state, prefix, options); - return; - } - state.foreignKeyTargets = []; - state.foreignKeyTargetsError = null; - state.foreignKeyTargetsLoading = true; - refreshSchemaDialogForeignKeyControls(state, prefix, options); - try { - var response = await fetch(url, { - headers: { - Accept: "application/json", - }, - }); - var data = await response.json(); - if (!response.ok || data.ok === false) { - throw rowMutationRequestError(response, data); - } - state.foreignKeyTargets = data.targets || []; - } catch (error) { - state.foreignKeyTargets = []; - state.foreignKeyTargetsError = error; - } finally { - state.foreignKeyTargetsLoading = false; - refreshSchemaDialogForeignKeyControls(state, prefix, options); - } -} - -function syncTableCreateForeignKeyOptions(row, state) { - syncSchemaDialogForeignKeyOptions(row, state, "table-create", { - filterByType: false, - }); -} - -function syncTableCreateCustomTypeAndForeignKey(row, state) { - syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-create"); -} - -function refreshTableCreateForeignKeyControls(state) { - tableCreateDialogRows(state).forEach(function (row) { - syncTableCreateForeignKeyOptions(row, state); - syncTableCreateCustomTypeAndForeignKey(row, state); - }); -} - -function updateTableCreateColumnRules(state) { - normalizeSchemaDialogPrimaryKeyRows(state, "table-create"); - tableCreateDialogRows(state).forEach(function (row) { - syncTableCreateForeignKeyOptions(row, state); - syncTableCreateCustomTypeAndForeignKey(row, state); - syncSchemaDialogDefaultControls(row, "table-create"); - }); - updateTableCreateMoveButtons(state); -} - -async function loadTableCreateForeignKeyTargets(state) { - return loadSchemaDialogForeignKeyTargets( - state, - "table-create", - tableCreateForeignKeyTargetsUrl(), - { filterByType: false }, - ); -} - -function tableCreateIsDataMode(state) { - return state && state.mode === "data"; -} - -function tableCreateSaveButtonText(state) { - if (tableCreateIsDataMode(state)) { - return state.dataPreviewReady ? "Create table" : "Preview rows"; - } - return "Create table"; -} - -function tableCreateCanInsertRows() { - var data = databaseCreateTableData() || {}; - return !!data.canInsertRows; -} - -function syncTableCreateModeUi(state) { - if (!state) { - return; - } - var isDataMode = tableCreateIsDataMode(state); - state.columnsPanel.hidden = isDataMode; - state.dataPanel.hidden = !isDataMode; - state.dataEditor.hidden = !isDataMode || state.dataPreviewReady; - state.dataPreview.hidden = !isDataMode || !state.dataPreviewReady; - state.createFromDataLink.hidden = isDataMode || !tableCreateCanInsertRows(); - state.manualCreateLink.hidden = !isDataMode; -} - -function updateTableCreateDialogButtons(state) { - if (!state) { - return; - } - syncTableCreateModeUi(state); - state.cancelButton.disabled = state.isSaving; - state.saveButton.disabled = state.isSaving; - state.addColumnButton.disabled = state.isSaving; - state.cancelButton.textContent = - tableCreateIsDataMode(state) && state.dataPreviewReady ? "Back" : "Cancel"; - state.saveButton.textContent = state.isSaving - ? "Creating..." - : tableCreateSaveButtonText(state); -} - -function tableCreateDialogSignature(state) { - if (!state || !state.form) { - return ""; - } - var signature = { - table: state.tableName.value, - data: state.dataTextarea ? state.dataTextarea.value : "", - dataPrimaryKey: state.dataPkSelect - ? state.dataPkSelect.value - : TABLE_CREATE_AUTOMATIC_PK, - columns: tableCreateDialogRows(state).map(function (row) { - return { - name: row.querySelector(".table-create-column-name").value, - type: row.querySelector(".table-create-column-type").value, - customType: - ( - row.querySelector(".table-create-custom-column-type") || { - value: "", - } - ).value || "", - pk: row.querySelector(".table-create-primary-key-input").checked, - notNull: row.querySelector(".table-create-not-null-input").checked, - defaultValue: row.querySelector(".table-create-default").value, - defaultExpr: row.querySelector(".table-create-default-expr").value, - foreignKey: - ( - row.querySelector(".table-create-foreign-key-target") || { - value: "", - } - ).value || "", - }; - }), - }; - return JSON.stringify(signature); -} - -function tableCreateDialogHasChanges(state) { - return ( - !!state && - !state.isSaving && - tableCreateDialogSignature(state) !== state.initialSignature - ); -} - -function clearTableCreateDialogError(state) { - state.error.hidden = true; - state.error.textContent = ""; - state.dialog.removeAttribute("aria-describedby"); -} - -function showTableCreateDialogError(state, message) { - state.error.hidden = false; - state.error.textContent = message; - state.dialog.setAttribute("aria-describedby", "table-create-error"); - state.error.focus(); -} - -function setTableCreateDialogSaving(state, isSaving) { - state.isSaving = isSaving; - state.columnList - .querySelectorAll("input, select, button") - .forEach(function (control) { - control.disabled = isSaving; - }); - state.fields - .querySelectorAll( - ".table-create-data input, .table-create-data select, .table-create-data textarea, .table-create-data button", - ) - .forEach(function (control) { - control.disabled = isSaving; - }); - state.tableName.disabled = isSaving; - if (!isSaving) { - updateTableCreateColumnRules(state); - } - updateTableCreateDialogButtons(state); - updateTableCreateMoveButtons(state); -} - -function tableCreateSelectTypeValue(select, type) { - var options = tableCreateColumnTypes(); - populateSqliteColumnTypeSelect(select, type, options); -} - -function updateTableCreateCustomColumnTypePlaceholder(select) { - updateSelectPlaceholder(select, "table-create-input-placeholder"); -} - -function createTableCustomColumnTypeSelect() { - var options = tableCreateCustomColumnTypes(); - return createCustomColumnTypeSelect( - options, - "table-create-input table-create-custom-column-type", - "table-create-input-placeholder", - ); -} - -function syncTableCreateCustomTypeForSqliteType(row) { - var typeSelect = row.querySelector(".table-create-column-type"); - var customTypeSelect = row.querySelector(".table-create-custom-column-type"); - if (!typeSelect || !customTypeSelect || !customTypeSelect.value) { - return; - } - var option = tableCreateCustomColumnType(customTypeSelect.value); - if (!tableCreateCustomTypeAppliesToSqliteType(option, typeSelect.value)) { - customTypeSelect.value = ""; - updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); - } -} - -function createTableColumnRow(state, column) { - var index = state.nextColumnIndex; - state.nextColumnIndex += 1; - - var row = document.createElement("div"); - row.className = "table-create-column-row"; - - var main = document.createElement("div"); - main.className = "table-create-column-main"; - - var details = document.createElement("div"); - details.className = "table-create-column-details"; - details.id = "table-create-column-details-" + index; - details.hidden = !(column && column.expanded); - - var expandButton = createSchemaDialogMoreOptionsButton( - "table-create", - details, - ); - - var nameId = "table-create-column-name-" + index; - var nameLabel = document.createElement("label"); - nameLabel.className = "table-create-column-label"; - nameLabel.setAttribute("for", nameId); - nameLabel.textContent = "Column"; - - var nameInput = document.createElement("input"); - nameInput.id = nameId; - nameInput.className = "table-create-input table-create-column-name"; - nameInput.type = "text"; - nameInput.required = true; - nameInput.autocomplete = "off"; - nameInput.placeholder = "column name"; - nameInput.value = column && column.name ? column.name : ""; - - var typeSelect = document.createElement("select"); - typeSelect.className = "table-create-input table-create-column-type"; - typeSelect.setAttribute("aria-label", "Column type"); - tableCreateSelectTypeValue(typeSelect, column && column.type); - - var customTypeSelect = null; - var customTypeField = null; - if (tableCreateCustomColumnTypes().length) { - var customTypeId = "table-create-column-custom-type-" + index; - customTypeField = document.createElement("div"); - customTypeField.className = - "table-create-detail-field table-create-custom-type-field"; - var customTypeLabel = document.createElement("label"); - customTypeLabel.className = "table-create-detail-label"; - customTypeLabel.setAttribute("for", customTypeId); - customTypeLabel.textContent = "Custom type"; - var customTypeHelpId = "table-create-column-custom-type-help-" + index; - var customTypeHelp = document.createElement("p"); - customTypeHelp.id = customTypeHelpId; - customTypeHelp.className = "table-create-detail-help"; - customTypeHelp.textContent = - "Controls how Datasette displays and edits this column"; - customTypeSelect = createTableCustomColumnTypeSelect(); - customTypeSelect.id = customTypeId; - customTypeSelect.setAttribute("aria-describedby", customTypeHelpId); - customTypeSelect.value = - column && column.customType ? column.customType : ""; - updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); - customTypeField.appendChild(customTypeLabel); - customTypeField.appendChild(customTypeHelp); - customTypeField.appendChild(customTypeSelect); - } - - var pkLabel = document.createElement("label"); - pkLabel.className = "table-create-detail-check table-create-primary-key"; - var pkInput = document.createElement("input"); - pkInput.type = "checkbox"; - pkInput.className = "table-create-primary-key-input"; - pkInput.checked = !!(column && column.primaryKey); - var pkText = document.createElement("span"); - var pkStrong = document.createElement("strong"); - pkStrong.textContent = "Primary key"; - pkText.appendChild(pkStrong); - pkText.appendChild( - document.createTextNode(" This ID uniquely identifies the record"), - ); - pkLabel.appendChild(pkInput); - pkLabel.appendChild(pkText); - - var foreignKeyId = "table-create-column-foreign-key-" + index; - var foreignKeyHelpId = "table-create-column-foreign-key-help-" + index; - var foreignKeyField = document.createElement("div"); - foreignKeyField.className = - "table-create-detail-field table-create-foreign-key-field"; - var foreignKeyLabel = document.createElement("label"); - foreignKeyLabel.className = "table-create-detail-label"; - foreignKeyLabel.setAttribute("for", foreignKeyId); - foreignKeyLabel.textContent = "Foreign key"; - var foreignKeyHelp = document.createElement("p"); - foreignKeyHelp.id = foreignKeyHelpId; - foreignKeyHelp.className = "table-create-detail-help"; - foreignKeyHelp.textContent = "Link this column to another table."; - var foreignKeySelect = document.createElement("select"); - foreignKeySelect.id = foreignKeyId; - foreignKeySelect.className = - "table-create-input table-create-foreign-key-target"; - foreignKeySelect.setAttribute("aria-label", "Foreign key target"); - foreignKeySelect.setAttribute("aria-describedby", foreignKeyHelpId); - foreignKeyField.appendChild(foreignKeyLabel); - foreignKeyField.appendChild(foreignKeyHelp); - foreignKeyField.appendChild(foreignKeySelect); - - var notNullLabel = document.createElement("label"); - notNullLabel.className = "table-create-detail-check table-create-not-null"; - var notNullInput = document.createElement("input"); - notNullInput.type = "checkbox"; - notNullInput.className = "table-create-not-null-input"; - notNullInput.checked = !!(column && column.notNull); - var notNullText = document.createElement("span"); - var notNullStrong = document.createElement("strong"); - notNullStrong.textContent = "Not null"; - notNullText.appendChild(notNullStrong); - notNullText.appendChild( - document.createTextNode(" This value cannot be left unset"), - ); - notNullLabel.appendChild(notNullInput); - notNullLabel.appendChild(notNullText); - - var defaultControls = createSchemaDialogDefaultControls( - "table-create", - index, - tableCreateDefaultExpressions(), - { - defaultValue: column && column.defaultValue, - defaultExpr: column && column.defaultExpr, - }, - ); - - var moveControls = createSchemaDialogMoveControls("table-create"); - - var removeButton = createSchemaDialogIconButton( - "table-create", - "remove-column", - "Remove column", - "Remove column", - COLUMN_MOVE_ICONS.remove, - ); - - main.appendChild(nameLabel); - main.appendChild(nameInput); - main.appendChild(typeSelect); - main.appendChild(moveControls.controls); - main.appendChild(removeButton); - main.appendChild(expandButton); - - if (customTypeField) { - details.appendChild(customTypeField); - } - details.appendChild(defaultControls.controls); - details.appendChild(notNullLabel); - details.appendChild(pkLabel); - details.appendChild(foreignKeyField); - row.appendChild(main); - row.appendChild(details); - - removeButton.addEventListener("click", function () { - if (state.isSaving) { - return; - } - row.remove(); - clearTableCreateDialogError(state); - updateTableCreateColumnRules(state); - var nextInput = state.columnList.querySelector(".table-create-column-name"); - if (nextInput) { - nextInput.focus(); - } else { - state.addColumnButton.focus(); - } - }); - - nameInput.addEventListener("input", function () { - clearTableCreateDialogError(state); - }); - typeSelect.addEventListener("change", function () { - clearTableCreateDialogError(state); - syncTableCreateCustomTypeForSqliteType(row); - syncTableCreateForeignKeyOptions(row, state); - syncTableCreateCustomTypeAndForeignKey(row, state); - }); - if (customTypeSelect) { - customTypeSelect.addEventListener("change", function () { - clearTableCreateDialogError(state); - updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); - if (customTypeSelect.value) { - foreignKeySelect.value = ""; - foreignKeySelect.dataset.selectedKey = ""; - } - var option = tableCreateCustomColumnType(customTypeSelect.value); - if ( - option && - option.fixedSqliteType && - tableCreateColumnTypes().indexOf(option.fixedSqliteType) !== -1 - ) { - typeSelect.value = option.fixedSqliteType; - syncTableCreateForeignKeyOptions(row, state); - } - syncTableCreateCustomTypeAndForeignKey(row, state); - }); - } - pkInput.addEventListener("change", function () { - clearTableCreateDialogError(state); - updateTableCreateColumnRules(state); - }); - notNullInput.addEventListener("change", function () { - clearTableCreateDialogError(state); - }); - defaultControls.defaultInput.addEventListener("input", function () { - if (defaultControls.defaultInput.value) { - defaultControls.defaultExprSelect.value = ""; - syncSchemaDialogDefaultControls(row, "table-create"); - } - clearTableCreateDialogError(state); - }); - defaultControls.defaultExprSelect.addEventListener("change", function () { - if (defaultControls.defaultExprSelect.value) { - defaultControls.defaultInput.value = ""; - } - if ( - applyDefaultExpressionColumnType( - row, - "table-create", - tableCreateDefaultExpressions(), - tableCreateColumnTypes(), - ) - ) { - syncTableCreateCustomTypeForSqliteType(row); - syncTableCreateForeignKeyOptions(row, state); - syncTableCreateCustomTypeAndForeignKey(row, state); - } - syncSchemaDialogDefaultControls(row, "table-create"); - clearTableCreateDialogError(state); - }); - foreignKeySelect.addEventListener("change", function () { - clearTableCreateDialogError(state); - handleSchemaDialogForeignKeyChange(row, state, "table-create", { - columnTypes: tableCreateColumnTypes(), - foreignKeyOptions: { filterByType: false }, - matchType: true, - }); - }); - - expandButton.addEventListener("click", function () { - toggleSchemaDialogMoreOptions(expandButton, details); - }); - - moveControls.topButton.addEventListener("click", function () { - var first = tableCreateFirstNonPrimaryRow(state); - if ( - state.isSaving || - tableCreateRowIsPrimaryKey(row) || - !first || - first === row - ) { - return; - } - state.columnList.insertBefore(row, first); - clearTableCreateDialogError(state); - updateTableCreateColumnRules(state); - row.querySelector(".table-create-column-name").focus(); - }); - - moveControls.upButton.addEventListener("click", function () { - var previous = row.previousElementSibling; - if ( - state.isSaving || - tableCreateRowIsPrimaryKey(row) || - !previous || - tableCreateRowIsPrimaryKey(previous) - ) { - return; - } - state.columnList.insertBefore(row, previous); - clearTableCreateDialogError(state); - updateTableCreateColumnRules(state); - row.querySelector(".table-create-column-name").focus(); - }); - - moveControls.downButton.addEventListener("click", function () { - var next = row.nextElementSibling; - if (state.isSaving || tableCreateRowIsPrimaryKey(row) || !next) { - return; - } - state.columnList.insertBefore(next, row); - clearTableCreateDialogError(state); - updateTableCreateColumnRules(state); - row.querySelector(".table-create-column-name").focus(); - }); - - moveControls.bottomButton.addEventListener("click", function () { - var last = state.columnList.lastElementChild; - if ( - state.isSaving || - tableCreateRowIsPrimaryKey(row) || - !last || - last === row - ) { - return; - } - state.columnList.appendChild(row); - clearTableCreateDialogError(state); - updateTableCreateColumnRules(state); - row.querySelector(".table-create-column-name").focus(); - }); - - syncSchemaDialogDefaultControls(row, "table-create"); - return row; -} - -function addTableCreateColumn(state, column) { - var row = createTableColumnRow(state, column || { type: "text" }); - state.columnList.appendChild(row); - updateTableCreateColumnRules(state); - return row; -} - -function resetTableCreateDialog(state) { - state.mode = "manual"; - state.nextColumnIndex = 0; - state.tableName.value = ""; - state.dataTextarea.value = ""; - resetTableCreateDataPreview(state); - state.columnList.textContent = ""; - addTableCreateColumn(state, { - name: "id", - type: "integer", - primaryKey: true, - }); - addTableCreateColumn(state, { - name: "", - type: "text", - primaryKey: false, - }); - updateTableCreateColumnRules(state); - updateTableCreateDialogButtons(state); - state.initialSignature = tableCreateDialogSignature(state); -} - -function showTableCreateDataMode(state) { - if (!state || state.isSaving || !tableCreateCanInsertRows()) { - return; - } - state.mode = "data"; - clearTableCreateDialogError(state); - updateTableCreateDialogButtons(state); - if (state.dataPreviewReady && state.dataPkSelect) { - state.dataPkSelect.focus(); - } else { - state.dataTextarea.focus(); - } -} - -function showTableCreateManualMode(state) { - if (!state || state.isSaving) { - return; - } - state.mode = "manual"; - clearTableCreateDialogError(state); - updateTableCreateDialogButtons(state); - var firstInput = state.columnList.querySelector(".table-create-column-name"); - if (firstInput) { - firstInput.focus(); - } else { - state.tableName.focus(); - } -} - -function collectTableCreatePayload(state) { - var payload = { - table: state.tableName.value.trim(), - columns: [], - }; - var primaryKeys = []; - tableCreateDialogRows(state).forEach(function (row) { - var name = row.querySelector(".table-create-column-name").value.trim(); - var type = row.querySelector(".table-create-column-type").value; - var column = { name: name, type: type }; - var foreignKeySelect = row.querySelector( - ".table-create-foreign-key-target", - ); - var foreignKeyOption = - foreignKeySelect && foreignKeySelect.selectedOptions - ? foreignKeySelect.selectedOptions[0] - : null; - if ( - foreignKeyOption && - foreignKeyOption.dataset.fkTable && - foreignKeyOption.dataset.fkColumn - ) { - column.fk_table = foreignKeyOption.dataset.fkTable; - column.fk_column = foreignKeyOption.dataset.fkColumn; - } - if (row.querySelector(".table-create-not-null-input").checked) { - column.not_null = true; - } - var defaultExpr = row.querySelector(".table-create-default-expr").value; - var defaultValue = row.querySelector(".table-create-default").value; - if (defaultExpr) { - column.default_expr = defaultExpr; - } else if (defaultValue) { - column.default = defaultValue; - } - payload.columns.push(column); - if (row.querySelector(".table-create-primary-key-input").checked) { - primaryKeys.push(name); - } - }); - if (primaryKeys.length === 1) { - payload.pk = primaryKeys[0]; - } else if (primaryKeys.length > 1) { - payload.pks = primaryKeys; - } - return payload; -} - -function collectTableCreateColumnTypeAssignments(state) { - var assignments = []; - tableCreateDialogRows(state).forEach(function (row) { - var customTypeSelect = row.querySelector( - ".table-create-custom-column-type", - ); - if (!customTypeSelect || !customTypeSelect.value) { - return; - } - assignments.push({ - column: row.querySelector(".table-create-column-name").value.trim(), - columnType: customTypeSelect.value, - sqliteType: row.querySelector(".table-create-column-type").value, - }); - }); - return assignments; -} - -function validateTableCreatePayload(payload) { - var tableNameError = validateTableCreateTableName(payload.table); - if (tableNameError) { - return tableNameError; - } - if (!payload.columns.length) { - return "At least one column is required."; - } - var seen = {}; - var supportedTypes = tableCreateColumnTypes(); - for (var i = 0; i < payload.columns.length; i += 1) { - var column = payload.columns[i]; - if (!column.name) { - return "Column name is required."; - } - if (column.name.indexOf("\n") !== -1) { - return "Column names cannot contain newlines."; - } - var columnKey = column.name.toLowerCase(); - if (seen[columnKey]) { - return "Duplicate column name: " + column.name; - } - seen[columnKey] = true; - if (supportedTypes.indexOf(column.type) === -1) { - return "Unsupported column type: " + column.type; - } - if (column.default && column.default_expr) { - return "Use either a default value or a default expression."; - } - } - return null; -} - -function validateTableCreateTableName(tableName) { - if (!tableName) { - return "Table name is required."; - } - if (tableName.indexOf("\n") !== -1) { - return "Table name cannot contain newlines."; - } - if (/^sqlite_/i.test(tableName)) { - return "Table name cannot start with sqlite_."; - } - return null; -} - -function validateTableCreateColumnTypeAssignments(assignments) { - for (var i = 0; i < assignments.length; i += 1) { - var assignment = assignments[i]; - var option = tableCreateCustomColumnType(assignment.columnType); - if (!option) { - return "Unknown custom column type: " + assignment.columnType; - } - if ( - !tableCreateCustomTypeAppliesToSqliteType(option, assignment.sqliteType) - ) { - return ( - "Custom type " + - assignment.columnType + - " cannot be used with SQLite type " + - assignment.sqliteType + - "." - ); - } - } - return null; -} - -function normalizeCreateTableDataJsonValue(value) { - if (typeof value === "undefined") { - return null; - } - if (Array.isArray(value) || (value && typeof value === "object")) { - return JSON.stringify(value); - } - return value; -} - -function createTableDataColumnObjects(names) { - return names.map(function (name) { - return { name: name }; - }); -} - -function validateCreateTableDataHeaders(headers) { - if (!headers.length) { - throw new Error("No columns found to preview."); - } - var seen = {}; - headers.forEach(function (name, index) { - if (!name) { - throw new Error("Column header " + (index + 1) + " is blank."); - } - if (name.indexOf("\n") !== -1) { - throw new Error("Column names cannot contain newlines."); - } - var key = name.toLowerCase(); - if (seen[key]) { - throw new Error("Duplicate column name: " + name); - } - seen[key] = true; - }); -} - -function jsonRowIsObject(item) { - return !!(item && typeof item === "object" && !Array.isArray(item)); -} - -function extractJsonObjectRows(parsed) { - if (Array.isArray(parsed)) { - return parsed; - } - if (!jsonRowIsObject(parsed)) { - throw new Error( - "JSON must be an array of objects, or an object containing an array of objects.", - ); - } - - var bestRows = null; - Object.keys(parsed).forEach(function (key) { - var value = parsed[key]; - if (!Array.isArray(value) || !value.every(jsonRowIsObject)) { - return; - } - if (!bestRows || value.length > bestRows.length) { - bestRows = value; - } - }); - if (!bestRows) { - throw new Error( - "JSON object must contain at least one root key with an array of objects.", - ); - } - return bestRows; -} - -function parseJsonObjectRows(text) { - var parsed; - try { - parsed = JSON.parse(text); - } catch (error) { - throw new Error("Invalid JSON: " + error.message); - } - var rows = extractJsonObjectRows(parsed); - if (!rows.length) { - throw new Error("No rows found to preview."); - } - return rows; -} - -function parseJsonCreateTableRows(text) { - var parsed = parseJsonObjectRows(text); - - var columnNames = []; - var columnMap = {}; - parsed.forEach(function (item, index) { - if (!item || typeof item !== "object" || Array.isArray(item)) { - throw new Error("JSON row " + (index + 1) + " must be an object."); - } - Object.keys(item).forEach(function (name) { - if (!columnMap[name]) { - columnMap[name] = true; - columnNames.push(name); - } - }); - }); - validateCreateTableDataHeaders(columnNames); - - var rows = parsed.map(function (item) { - var row = {}; - columnNames.forEach(function (name) { - row[name] = Object.prototype.hasOwnProperty.call(item, name) - ? normalizeCreateTableDataJsonValue(item[name]) - : null; - }); - return row; - }); - return { - columns: createTableDataColumnObjects(columnNames), - rows: rows, - }; -} - -function createTableDelimitedValueIsInteger(value) { - return /^[-+]?\d+$/.test(String(value).trim()); -} - -function createTableDelimitedValueIsFloat(value) { - var trimmed = String(value).trim(); - if (!trimmed) { - return false; - } - var numberValue = Number(trimmed); - return Number.isFinite(numberValue); -} - -function inferCreateTableDelimitedColumnType(values) { - var nonBlankValues = values.filter(function (value) { - return String(value).trim() !== ""; - }); - if (!nonBlankValues.length) { - return "text"; - } - if (nonBlankValues.every(createTableDelimitedValueIsInteger)) { - return "integer"; - } - if (nonBlankValues.every(createTableDelimitedValueIsFloat)) { - return "float"; - } - return "text"; -} - -function coerceCreateTableDelimitedValue(value, type) { - var trimmed = String(value).trim(); - if (trimmed === "") { - return type === "integer" || type === "float" ? null : ""; - } - if (type === "integer") { - return parseInt(trimmed, 10); - } - if (type === "float") { - return Number(trimmed); - } - return value; -} - -function detectCreateTableDataDelimiter(text) { - var firstLine = - text.split(/\r\n|\n|\r/).find(function (line) { - return line.trim() !== ""; - }) || ""; - var csvRows = delimiterPreviewRows(firstLine, ","); - var tsvRows = delimiterPreviewRows(firstLine, "\t"); - var csvColumns = csvRows.length ? csvRows[0].length : 0; - var tsvColumns = tsvRows.length ? tsvRows[0].length : 0; - - if (firstLine.indexOf("\t") !== -1 && firstLine.indexOf(",") === -1) { - return "\t"; - } - if (tsvColumns > csvColumns) { - return "\t"; - } - if (csvColumns > 1) { - return ","; - } - if (tsvColumns > 1) { - return "\t"; - } - return null; -} - -function parseDelimitedCreateTableRows(text) { - var delimiter = detectCreateTableDataDelimiter(text); - var rows = ( - delimiter === null - ? splitSingleColumnRows(text) - : splitDelimitedRows(text, delimiter) - ).filter(function (row) { - return !bulkInsertDelimitedRowIsBlank(row); - }); - if (!rows.length) { - throw new Error("No rows found to preview."); - } - - var headers = rows[0].map(function (value) { - return value.trim(); - }); - validateCreateTableDataHeaders(headers); - var dataRows = rows.slice(1); - if (!dataRows.length) { - throw new Error("No data rows found to preview."); - } - - dataRows.forEach(function (row, index) { - if (row.length > headers.length) { - throw new Error( - "Row " + - (index + 1) + - " has " + - row.length + - " values, but only " + - headers.length + - " columns were provided.", - ); - } - }); - - var columnTypes = headers.map(function (_name, columnIndex) { - return inferCreateTableDelimitedColumnType( - dataRows.map(function (row) { - return row[columnIndex] || ""; - }), - ); - }); - - return { - columns: createTableDataColumnObjects(headers), - rows: dataRows.map(function (row) { - var rowObject = {}; - headers.forEach(function (name, columnIndex) { - rowObject[name] = coerceCreateTableDelimitedValue( - row[columnIndex] || "", - columnTypes[columnIndex], - ); - }); - return rowObject; - }), - }; -} - -function parseCreateTableDataRows(text) { - var trimmed = text.trim(); - if (!trimmed) { - throw new Error("Paste rows before previewing."); - } - if (trimmed[0] === "[" || trimmed[0] === "{") { - return parseJsonCreateTableRows(trimmed); - } - return parseDelimitedCreateTableRows(trimmed); -} - -function tableCreateDataRecommendedPrimaryKey(columns, rows) { - var candidates = []; - columns.forEach(function (column, columnIndex) { - var distinctValues = {}; - var maxLength = 0; - var valid = rows.length > 0; - rows.forEach(function (row) { - if (!valid) { - return; - } - var value = row[column.name]; - if (value === null || typeof value === "undefined") { - valid = false; - return; - } - var text = String(value).trim(); - if (!text || text.length >= 20 || /\s/.test(text)) { - valid = false; - return; - } - if (distinctValues[text]) { - valid = false; - return; - } - distinctValues[text] = true; - maxLength = Math.max(maxLength, text.length); - }); - if (valid) { - candidates.push({ - name: column.name, - maxLength: maxLength, - columnIndex: columnIndex, - }); - } - }); - candidates.sort(function (left, right) { - if (left.maxLength !== right.maxLength) { - return left.maxLength - right.maxLength; - } - return left.columnIndex - right.columnIndex; - }); - return candidates.length ? candidates[0].name : TABLE_CREATE_AUTOMATIC_PK; -} - -function renderTableCreateDataPreview(state, preview) { - state.dataPreview.textContent = ""; - - var summary = document.createElement("p"); - summary.className = "table-create-data-preview-summary"; - summary.textContent = - "Previewing " + - preview.rows.length + - " row" + - (preview.rows.length === 1 ? "." : "s."); - state.dataPreview.appendChild(summary); - - var pkField = document.createElement("div"); - pkField.className = "table-create-data-pk-field"; - var pkLabel = document.createElement("label"); - pkLabel.className = "table-create-data-label"; - pkLabel.setAttribute("for", "table-create-data-primary-key"); - pkLabel.textContent = "Primary key"; - var pkSelect = document.createElement("select"); - pkSelect.id = "table-create-data-primary-key"; - pkSelect.className = "table-create-input table-create-data-primary-key"; - var automaticOption = document.createElement("option"); - automaticOption.value = TABLE_CREATE_AUTOMATIC_PK; - automaticOption.textContent = "Automatic ID column"; - pkSelect.appendChild(automaticOption); - preview.columns.forEach(function (column) { - var option = document.createElement("option"); - option.value = column.name; - option.textContent = column.name; - pkSelect.appendChild(option); - }); - pkSelect.value = tableCreateDataRecommendedPrimaryKey( - preview.columns, - preview.rows, - ); - pkSelect.addEventListener("change", function () { - clearTableCreateDialogError(state); - }); - state.dataPkSelect = pkSelect; - pkField.appendChild(pkLabel); - pkField.appendChild(pkSelect); - state.dataPreview.appendChild(pkField); - - var tableWrap = document.createElement("div"); - tableWrap.className = "table-create-data-preview-table-wrap"; - var table = document.createElement("table"); - table.className = "table-create-data-preview-table"; - var thead = document.createElement("thead"); - var headerRow = document.createElement("tr"); - preview.columns.forEach(function (column) { - var th = document.createElement("th"); - th.scope = "col"; - th.textContent = column.name; - headerRow.appendChild(th); - }); - thead.appendChild(headerRow); - table.appendChild(thead); - - var tbody = document.createElement("tbody"); - preview.rows.forEach(function (row) { - var tr = document.createElement("tr"); - preview.columns.forEach(function (column) { - var td = document.createElement("td"); - var value = row[column.name]; - td.textContent = bulkInsertPreviewValue(value); - if (value === null) { - td.className = "table-create-data-preview-null"; - } - tr.appendChild(td); - }); - tbody.appendChild(tr); - }); - table.appendChild(tbody); - tableWrap.appendChild(table); - state.dataPreview.appendChild(tableWrap); - state.dataPreview.hidden = false; -} - -function resetTableCreateDataPreview(state) { - state.dataPreviewRows = null; - state.dataPreviewColumns = []; - state.dataPreviewReady = false; - state.dataPkSelect = null; - state.dataPreview.hidden = true; - state.dataPreview.textContent = ""; - syncTableCreateModeUi(state); -} - -function tableCreateTableNameFromFileName(fileName) { - var baseName = (fileName || "").replace(/^.*[\\/]/, ""); - var nameWithoutExtension = baseName.replace(/\.[^.]*$/, ""); - return nameWithoutExtension - .trim() - .replace(/\s+/g, "_") - .toLowerCase() - .replace(/[^a-z0-9_]/g, ""); -} - -async function loadTableCreateDataTextFile(state, file) { - if (!file) { - return; - } - try { - var text = await readTextFile(file); - var tableName = tableCreateTableNameFromFileName(file.name); - if (tableName) { - state.tableName.value = tableName; - state.tableName.dispatchEvent(new Event("input", { bubbles: true })); - } - state.dataTextarea.value = text; - state.dataTextarea.dispatchEvent(new Event("input", { bubbles: true })); - clearTableCreateDialogError(state); - state.dataTextarea.focus(); - } catch (_error) { - showTableCreateDialogError(state, "Could not read that text file."); - } -} - -function previewTableCreateDataRows(state) { - clearTableCreateDialogError(state); - resetTableCreateDataPreview(state); - try { - var preview = parseCreateTableDataRows(state.dataTextarea.value); - state.dataPreviewRows = preview.rows; - state.dataPreviewColumns = preview.columns; - state.dataPreviewReady = true; - renderTableCreateDataPreview(state, preview); - updateTableCreateDialogButtons(state); - } catch (error) { - showTableCreateDialogError( - state, - error.message || "Could not preview rows.", - ); - updateTableCreateDialogButtons(state); - } -} - -function collectTableCreateDataPayload(state) { - var payload = { - table: state.tableName.value.trim(), - rows: state.dataPreviewRows || [], - }; - var primaryKey = state.dataPkSelect - ? state.dataPkSelect.value - : TABLE_CREATE_AUTOMATIC_PK; - payload.pk = - primaryKey === TABLE_CREATE_AUTOMATIC_PK ? "id" : primaryKey || undefined; - return payload; -} - -function validateTableCreateDataPayload(payload, state) { - var tableNameError = validateTableCreateTableName(payload.table); - if (tableNameError) { - return tableNameError; - } - if (!payload.rows.length) { - return "No rows found to create."; - } - if ( - state.dataPkSelect && - state.dataPkSelect.value === TABLE_CREATE_AUTOMATIC_PK && - payload.rows.some(function (row) { - return Object.prototype.hasOwnProperty.call(row, "id"); - }) - ) { - return ( - "Automatic ID column cannot be used because the pasted data " + - "already has an id column." - ); - } - return null; -} - -function fallbackTableUrl(tableName) { - var data = databaseCreateTableData() || {}; - if (!data.path) { - return null; - } - return data.path.replace(/\/-\/create$/, "/" + encodeURIComponent(tableName)); -} - -function tableCreateSetColumnTypeUrl(responseData, payload) { - var tableUrl = - responseData.table_url || - fallbackTableUrl(responseData.table || payload.table); - if (!tableUrl) { - return null; - } - var url = new URL(tableUrl, location.href); - url.hash = ""; - url.search = ""; - url.pathname = url.pathname.replace(/\/$/, "") + "/-/set-column-type"; - return url.toString(); -} - -async function assignTableCreateColumnTypes( - responseData, - payload, - assignments, -) { - if (!assignments.length) { - return; - } - var url = tableCreateSetColumnTypeUrl(responseData, payload); - if (!url) { - throw new Error("Could not find the set column type URL."); - } - for (var i = 0; i < assignments.length; i += 1) { - var assignment = assignments[i]; - var response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - column: assignment.column, - column_type: { - type: assignment.columnType, - }, - }), - }); - var data = null; - try { - data = await response.json(); - } catch (_error) { - data = null; - } - if (!response.ok || (data && data.ok === false)) { - var error = rowMutationRequestError(response, data); - throw new Error( - "Created table, but could not set custom type for " + - assignment.column + - ": " + - error.message, - ); - } - } -} - -async function createTableFromDataPreview(state) { - var data = databaseCreateTableData(); - if (!data || !data.path) { - showTableCreateDialogError(state, "Could not find the create table URL."); - return; - } - var payload = collectTableCreateDataPayload(state); - var validationError = validateTableCreateDataPayload(payload, state); - if (validationError) { - showTableCreateDialogError(state, validationError); - return; - } - clearTableCreateDialogError(state); - setTableCreateDialogSaving(state, true); - try { - var response = await fetch(data.path, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(payload), - }); - var responseData = null; - try { - responseData = await response.json(); - } catch (_error) { - responseData = null; - } - if (!response.ok || (responseData && responseData.ok === false)) { - throw rowMutationRequestError(response, responseData); - } - var tableUrl = - responseData.table_url || - fallbackTableUrl(responseData.table || payload.table); - state.shouldRestoreFocus = false; - state.dialog.close(); - if (tableUrl) { - location.href = tableUrl; - } else { - location.reload(); - } - } catch (error) { - setTableCreateDialogSaving(state, false); - showTableCreateDialogError( - state, - error.message || "Could not create table", - ); - } -} - -async function saveTableCreateDialog(state) { - if (state.isSaving) { - return; - } - var data = databaseCreateTableData(); - if (!data || !data.path) { - showTableCreateDialogError(state, "Could not find the create table URL."); - return; - } - if (tableCreateIsDataMode(state)) { - if (!state.dataPreviewReady) { - previewTableCreateDataRows(state); - } else { - await createTableFromDataPreview(state); - } - return; - } - clearTableCreateDialogError(state); - var payload = collectTableCreatePayload(state); - var columnTypeAssignments = collectTableCreateColumnTypeAssignments(state); - var validationError = validateTableCreatePayload(payload); - if (validationError) { - showTableCreateDialogError(state, validationError); - return; - } - var columnTypeValidationError = validateTableCreateColumnTypeAssignments( - columnTypeAssignments, - ); - if (columnTypeValidationError) { - showTableCreateDialogError(state, columnTypeValidationError); - return; - } - setTableCreateDialogSaving(state, true); - try { - var response = await fetch(data.path, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(payload), - }); - var responseData = null; - try { - responseData = await response.json(); - } catch (_error) { - responseData = null; - } - if (!response.ok || (responseData && responseData.ok === false)) { - throw rowMutationRequestError(response, responseData); - } - await assignTableCreateColumnTypes( - responseData, - payload, - columnTypeAssignments, - ); - var tableUrl = - responseData.table_url || - fallbackTableUrl(responseData.table || payload.table); - state.shouldRestoreFocus = false; - state.dialog.close(); - if (tableUrl) { - location.href = tableUrl; - } else { - location.reload(); - } - } catch (error) { - setTableCreateDialogSaving(state, false); - showTableCreateDialogError( - state, - error.message || "Could not create table", - ); - } -} - -function confirmDiscardTableCreateChanges(state) { - if (!tableCreateDialogHasChanges(state)) { - return true; - } - return window.confirm("Discard this new table?"); -} - -function closeTableCreateDialogIfConfirmed(state) { - if (!state || state.isSaving) { - return false; - } - if (!confirmDiscardTableCreateChanges(state)) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - -function ensureTableCreateDialog(manager) { - if (tableCreateDialogState) { - return tableCreateDialogState; - } - if (!window.HTMLDialogElement) { - return null; - } - - var dialog = document.createElement("dialog"); - dialog.id = TABLE_CREATE_DIALOG_ID; - dialog.className = "table-create-dialog"; - dialog.setAttribute("aria-labelledby", "table-create-title"); - dialog.innerHTML = ` - -
    - -
    -
    - - -
    -
    - -
    - -
    - -
    - -
    - `; - document.body.appendChild(dialog); - - tableCreateDialogState = { - dialog: dialog, - form: dialog.querySelector(".table-create-form"), - title: dialog.querySelector(".modal-title"), - error: dialog.querySelector(".table-create-error"), - fields: dialog.querySelector(".table-create-fields"), - tableName: dialog.querySelector(".table-create-table-name"), - columnsPanel: dialog.querySelector(".table-create-columns"), - columnList: dialog.querySelector(".table-create-column-list"), - addColumnButton: dialog.querySelector(".table-create-add-column"), - dataPanel: dialog.querySelector(".table-create-data"), - dataEditor: dialog.querySelector(".table-create-data-editor"), - dataTextarea: dialog.querySelector(".table-create-data-textarea"), - dataOpenFileButton: dialog.querySelector(".table-create-data-open-file"), - dataFileInput: dialog.querySelector(".table-create-data-file-input"), - dataPreview: dialog.querySelector(".table-create-data-preview"), - createFromDataLink: dialog.querySelector(".table-create-from-data"), - manualCreateLink: dialog.querySelector(".table-create-manual"), - cancelButton: dialog.querySelector(".table-create-cancel"), - saveButton: dialog.querySelector(".table-create-save"), - currentButton: null, - shouldRestoreFocus: true, - isSaving: false, - mode: "manual", - dataPreviewRows: null, - dataPreviewColumns: [], - dataPreviewReady: false, - dataPkSelect: null, - initialSignature: "", - nextColumnIndex: 0, - foreignKeyTargets: [], - foreignKeyTargetsError: null, - foreignKeyTargetsLoading: false, - manager: manager, - }; - - tableCreateDialogState.form.addEventListener("submit", function (ev) { - ev.preventDefault(); - saveTableCreateDialog(tableCreateDialogState); - }); - - tableCreateDialogState.addColumnButton.addEventListener("click", function () { - if (tableCreateDialogState.isSaving) { - return; - } - var row = addTableCreateColumn(tableCreateDialogState, { type: "text" }); - clearTableCreateDialogError(tableCreateDialogState); - row.querySelector(".table-create-column-name").focus(); - }); - - tableCreateDialogState.cancelButton.addEventListener("click", function () { - if ( - tableCreateIsDataMode(tableCreateDialogState) && - tableCreateDialogState.dataPreviewReady && - !tableCreateDialogState.isSaving - ) { - resetTableCreateDataPreview(tableCreateDialogState); - updateTableCreateDialogButtons(tableCreateDialogState); - tableCreateDialogState.dataTextarea.focus(); - return; - } - closeTableCreateDialogIfConfirmed(tableCreateDialogState); - }); - - tableCreateDialogState.createFromDataLink.addEventListener( - "click", - function (ev) { - ev.preventDefault(); - showTableCreateDataMode(tableCreateDialogState); - }, - ); - - tableCreateDialogState.manualCreateLink.addEventListener( - "click", - function (ev) { - ev.preventDefault(); - showTableCreateManualMode(tableCreateDialogState); - }, - ); - - tableCreateDialogState.tableName.addEventListener("input", function () { - clearTableCreateDialogError(tableCreateDialogState); - }); - - tableCreateDialogState.dataOpenFileButton.addEventListener( - "click", - function () { - tableCreateDialogState.dataFileInput.click(); - }, - ); - - tableCreateDialogState.dataFileInput.addEventListener( - "change", - async function (ev) { - var files = ev.target.files; - await loadTableCreateDataTextFile( - tableCreateDialogState, - files && files.length ? files[0] : null, - ); - ev.target.value = ""; - }, - ); - - tableCreateDialogState.dataTextarea.addEventListener( - "dragenter", - function (ev) { - ev.preventDefault(); - tableCreateDialogState.dataTextarea.classList.add( - "table-create-data-drop-target", - ); - }, - ); - - tableCreateDialogState.dataTextarea.addEventListener( - "dragover", - function (ev) { - ev.preventDefault(); - tableCreateDialogState.dataTextarea.classList.add( - "table-create-data-drop-target", - ); - }, - ); - - tableCreateDialogState.dataTextarea.addEventListener( - "dragleave", - function () { - tableCreateDialogState.dataTextarea.classList.remove( - "table-create-data-drop-target", - ); - }, - ); - - tableCreateDialogState.dataTextarea.addEventListener( - "drop", - async function (ev) { - ev.preventDefault(); - tableCreateDialogState.dataTextarea.classList.remove( - "table-create-data-drop-target", - ); - var files = ev.dataTransfer && ev.dataTransfer.files; - if (!files || !files.length) { - return; - } - await loadTableCreateDataTextFile(tableCreateDialogState, files[0]); - }, - ); - - tableCreateDialogState.dataTextarea.addEventListener("dragend", function () { - tableCreateDialogState.dataTextarea.classList.remove( - "table-create-data-drop-target", - ); - }); - - tableCreateDialogState.dataTextarea.addEventListener("input", function () { - resetTableCreateDataPreview(tableCreateDialogState); - clearTableCreateDialogError(tableCreateDialogState); - updateTableCreateDialogButtons(tableCreateDialogState); - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeTableCreateDialogIfConfirmed(tableCreateDialogState); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if (ev.key !== "Escape") { - return; - } - ev.preventDefault(); - closeTableCreateDialogIfConfirmed(tableCreateDialogState); - }); - - dialog.addEventListener("cancel", function (ev) { - ev.preventDefault(); - closeTableCreateDialogIfConfirmed(tableCreateDialogState); - }); - - dialog.addEventListener("close", function () { - var state = tableCreateDialogState; - clearTableCreateDialogError(state); - setTableCreateDialogSaving(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } - }); - - return tableCreateDialogState; -} - -function openTableCreateDialog(button, manager) { - var data = databaseCreateTableData(); - if (!data) { - return; - } - var state = ensureTableCreateDialog(manager); - if (!state) { - return; - } - - var menu = button.closest("details"); - if (menu) { - menu.open = false; - } - state.manager = manager; - state.currentButton = button; - state.shouldRestoreFocus = true; - state.title.textContent = "Create a table in " + data.databaseName; - clearTableCreateDialogError(state); - resetTableCreateDialog(state); - loadTableCreateForeignKeyTargets(state); - if (!state.dialog.open) { - state.dialog.showModal(); - } - state.tableName.focus(); -} - -function initTableCreateActions(manager) { - if ( - !window.fetch || - !window.HTMLDialogElement || - !databaseCreateTableData() - ) { - return; - } - document.addEventListener("click", function (ev) { - var button = ev.target.closest( - 'button[data-database-action="create-table"]', - ); - if (!button) { - return; - } - ev.preventDefault(); - openTableCreateDialog(button, manager); - }); -} - -function setRowDeleteDialogBusy(state, isBusy) { - state.isBusy = isBusy; - state.confirmButton.disabled = isBusy; - state.cancelButton.disabled = isBusy; - state.confirmButton.textContent = isBusy ? "Deleting..." : "Delete row"; -} - -function clearRowDeleteDialogError(state) { - state.error.hidden = true; - state.error.textContent = ""; -} - -function showRowDeleteDialogError(state, message) { - state.error.hidden = false; - state.error.textContent = message; -} - -function rowMutationRequestError(response, data) { - if (data && data.errors) { - return new Error(data.errors.join(" ")); - } - if (data && data.error) { - return new Error(data.error); - } - if (data && data.title) { - return new Error(data.title); - } - return new Error("Request failed with HTTP " + response.status); -} - -function tildeDecode(value) { - if (!value) { - return ""; - } - var placeholder = "__datasette_percent_placeholder__"; - try { - return decodeURIComponent( - value.replace(/%/g, placeholder).replace(/~/g, "%").replace(/\+/g, " "), - ).replace(new RegExp(placeholder, "g"), "%"); - } catch (_error) { - return value; - } -} - -function tildeEncode(value) { - var bytes = new TextEncoder().encode(String(value)); - var encoded = ""; - bytes.forEach(function (byte) { - var isSafe = - (byte >= 65 && byte <= 90) || - (byte >= 97 && byte <= 122) || - (byte >= 48 && byte <= 57) || - byte === 95 || - byte === 45; - if (isSafe) { - encoded += String.fromCharCode(byte); - } else if (byte === 32) { - encoded += "+"; - } else { - encoded += "~" + byte.toString(16).toUpperCase().padStart(2, "0"); - } - }); - return encoded; -} - -function rowDisplayLabel(row) { - return tildeDecode(row.getAttribute("data-row") || ""); -} - -function rowTitleLabel(row) { - return row.getAttribute("data-row-label") || ""; -} - -function insertedRowStatusMessage(rowId, rowLabel) { - var message = "Inserted row " + rowId; - if (rowLabel && rowLabel !== rowId) { - message += " (" + rowLabel + ")"; - } - return message + "."; -} - -function tableBaseUrl() { - var tableUrl = - window._datasetteTableData && window._datasetteTableData.tableUrl; - var url = new URL(tableUrl || location.href, location.href); - url.hash = ""; - url.search = ""; - return url; -} - -function tablePageData() { - return window._datasetteTableData || {}; -} - -function tableInsertData() { - return tablePageData().insertRow; -} - -function tableAlterData() { - return tablePageData().alterTable; -} - -function tableAlterColumnTypes() { - var data = tableAlterData() || {}; - return data.columnTypes && data.columnTypes.length - ? data.columnTypes - : ["text", "integer", "float", "blob"]; -} - -function tableAlterDefaultExpressions() { - var data = tableAlterData() || {}; - return data.defaultExpressions || []; -} - -function tableAlterForeignKeyTargetsUrl() { - var data = tableAlterData() || {}; - return data.foreignKeyTargetsPath || null; -} - -function tableAlterCustomColumnTypes() { - var data = tableAlterData() || {}; - return data.customColumnTypes || []; -} - -function tableAlterCustomColumnType(name) { - var options = tableAlterCustomColumnTypes(); - for (var i = 0; i < options.length; i += 1) { - if (options[i].name === name) { - return options[i]; - } - } - return null; -} - -function tableAlterCustomTypeAppliesToSqliteType(option, sqliteType) { - return ( - option && - option.sqliteTypes && - option.sqliteTypes.indexOf(sqliteType) !== -1 - ); -} - -function tableAlterDialogRows(state) { - return schemaDialogRows(state, "table-alter"); -} - -function syncTableAlterForeignKeyOptions(row, state) { - syncSchemaDialogForeignKeyOptions(row, state, "table-alter", { - filterByType: false, - }); -} - -function tableAlterRowSignature(row) { - var foreignKeySelect = row.querySelector(".table-alter-foreign-key-target"); - var foreignKeyValue = foreignKeySelect - ? foreignKeySelect.value || foreignKeySelect.dataset.selectedKey || "" - : ""; - var foreignKeyOption = - foreignKeySelect && foreignKeySelect.selectedOptions - ? foreignKeySelect.selectedOptions[0] - : null; - return { - existing: row.dataset.existing === "1", - originalName: row.dataset.originalName || "", - name: row.querySelector(".table-alter-column-name").value, - type: row.querySelector(".table-alter-column-type").value, - customType: - ( - row.querySelector(".table-alter-custom-column-type") || { - value: "", - } - ).value || "", - notNull: row.querySelector(".table-alter-not-null-input").checked, - defaultValue: row.querySelector(".table-alter-default").value, - defaultExpr: row.querySelector(".table-alter-default-expr").value, - pk: row.querySelector(".table-alter-primary-key-input").checked, - foreignKey: foreignKeyValue, - foreignKeyTable: - foreignKeyOption && foreignKeyOption.dataset.fkTable - ? foreignKeyOption.dataset.fkTable - : "", - foreignKeyColumn: - foreignKeyOption && foreignKeyOption.dataset.fkColumn - ? foreignKeyOption.dataset.fkColumn - : "", - }; -} - -function tableAlterDialogSignature(state) { - if (!state || !state.form) { - return ""; - } - return JSON.stringify({ - tableName: state.tableNameInput ? state.tableNameInput.value.trim() : "", - columns: tableAlterDialogRows(state).map(tableAlterRowSignature), - deletedColumns: state.deletedColumns.slice(), - }); -} - -function tableAlterDialogHasChanges(state) { - return ( - !!state && - !state.isSaving && - tableAlterDialogSignature(state) !== state.initialSignature - ); -} - -function updateTableAlterSaveButtonState(state) { - if (!state || !state.saveButton) { - return; - } - state.saveButton.disabled = - state.isSaving || - (state.mode !== "review" && - tableAlterDialogSignature(state) === state.initialSignature); -} - -function tableAlterRowIsPrimaryKey(row) { - return schemaDialogRowIsPrimaryKey(row, "table-alter"); -} - -function tableAlterFirstNonPrimaryRow(state) { - return schemaDialogFirstNonPrimaryRow(state, "table-alter"); -} - -function updateTableAlterMoveButtons(state) { - updateSchemaDialogMoveButtons(state, "table-alter"); -} - -function normalizeTableAlterPrimaryKeyRows(state) { - normalizeSchemaDialogPrimaryKeyRows(state, "table-alter"); -} - -function clearTableAlterDialogError(state) { - state.error.hidden = true; - state.error.textContent = ""; - state.dialog.removeAttribute("aria-describedby"); -} - -function showTableAlterDialogError(state, message) { - state.error.hidden = false; - state.error.textContent = message; - state.dialog.setAttribute("aria-describedby", "table-alter-error"); - state.error.focus(); -} - -function setTableAlterDialogSaving(state, isSaving) { - state.isSaving = isSaving; - state.cancelButton.disabled = isSaving; - state.addColumnButton.disabled = isSaving; - state.backButton.disabled = isSaving; - state.dropButton.disabled = isSaving; - state.saveButton.textContent = isSaving - ? state.mode === "review" - ? "Applying..." - : "Preparing..." - : tableAlterSaveButtonText(state); - state.fields - .querySelectorAll("input, select, button") - .forEach(function (control) { - control.disabled = isSaving; - }); - if (!isSaving) { - state.columnList - .querySelectorAll(".table-alter-default-expr") - .forEach(function (select) { - syncSchemaDialogDefaultControls( - select.closest(".table-alter-column-row"), - "table-alter", - ); - }); - refreshSchemaDialogForeignKeyControls(state, "table-alter"); - } - updateTableAlterMoveButtons(state); - updateTableAlterSaveButtonState(state); -} - -function tableAlterSaveButtonText(state) { - return state && state.mode === "review" ? "Apply changes" : "Review changes"; -} - -function tableAlterSelectTypeValue(select, type) { - var options = tableAlterColumnTypes(); - populateSqliteColumnTypeSelect(select, type, options); -} - -function updateTableAlterCustomColumnTypePlaceholder(select) { - updateSelectPlaceholder(select, "table-alter-input-placeholder"); -} - -function createTableAlterCustomColumnTypeSelect() { - var options = tableAlterCustomColumnTypes(); - return createCustomColumnTypeSelect( - options, - "table-alter-input table-alter-custom-column-type", - "table-alter-input-placeholder", - ); -} - -function syncTableAlterCustomTypeForSqliteType(row) { - var typeSelect = row.querySelector(".table-alter-column-type"); - var customTypeSelect = row.querySelector(".table-alter-custom-column-type"); - if (!typeSelect || !customTypeSelect || !customTypeSelect.value) { - return; - } - var option = tableAlterCustomColumnType(customTypeSelect.value); - if (!tableAlterCustomTypeAppliesToSqliteType(option, typeSelect.value)) { - customTypeSelect.value = ""; - updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); - } -} - -function createTableAlterColumnRow(state, column) { - var index = state.nextColumnIndex; - state.nextColumnIndex += 1; - var existing = !!(column && column.existing); - var originalName = existing ? column.name || "" : ""; - var originalCustomType = - existing && column.column_type ? column.column_type.type || "" : ""; - var originalDefaultExpr = - existing && column.has_default && column.default_expr - ? column.default_expr - : ""; - var originalDefault = - existing && - column.has_default && - !originalDefaultExpr && - column.default !== null - ? String(column.default) - : ""; - var originalForeignKey = - existing && column.foreign_key - ? foreignKeyTargetKey(column.foreign_key) - : ""; - - var row = document.createElement("div"); - row.className = "table-alter-column-row"; - row.dataset.existing = existing ? "1" : "0"; - row.dataset.originalName = originalName; - row.dataset.originalType = existing ? column.type || "text" : ""; - row.dataset.originalNotNull = existing && column.notnull ? "1" : "0"; - row.dataset.originalHasDefault = existing && column.has_default ? "1" : "0"; - row.dataset.originalDefault = originalDefault; - row.dataset.originalDefaultExpr = originalDefaultExpr; - row.dataset.originalPk = existing && column.is_pk ? "1" : "0"; - row.dataset.originalCustomType = originalCustomType; - row.dataset.originalForeignKey = originalForeignKey; - - var main = document.createElement("div"); - main.className = "table-alter-column-main"; - - var details = document.createElement("div"); - details.className = "table-alter-column-details"; - details.id = "table-alter-column-details-" + index; - details.hidden = !(column && column.expanded); - - var expandButton = createSchemaDialogMoreOptionsButton( - "table-alter", - details, - ); - - var nameId = "table-alter-column-name-" + index; - var nameLabel = document.createElement("label"); - nameLabel.className = "table-alter-column-label"; - nameLabel.setAttribute("for", nameId); - nameLabel.textContent = "Column"; - - var nameInput = document.createElement("input"); - nameInput.id = nameId; - nameInput.className = "table-alter-input table-alter-column-name"; - nameInput.type = "text"; - nameInput.required = true; - nameInput.autocomplete = "off"; - nameInput.placeholder = "column name"; - nameInput.value = column && column.name ? column.name : ""; - - var typeSelect = document.createElement("select"); - typeSelect.className = "table-alter-input table-alter-column-type"; - typeSelect.setAttribute("aria-label", "Column type"); - tableAlterSelectTypeValue(typeSelect, column && column.type); - - var customTypeSelect = null; - var customTypeField = null; - if (tableAlterCustomColumnTypes().length) { - var customTypeId = "table-alter-column-custom-type-" + index; - customTypeField = document.createElement("div"); - customTypeField.className = - "table-alter-detail-field table-alter-custom-type-field"; - var customTypeLabel = document.createElement("label"); - customTypeLabel.className = "table-alter-detail-label"; - customTypeLabel.setAttribute("for", customTypeId); - customTypeLabel.textContent = "Custom type"; - var customTypeHelpId = "table-alter-column-custom-type-help-" + index; - var customTypeHelp = document.createElement("p"); - customTypeHelp.id = customTypeHelpId; - customTypeHelp.className = "table-alter-detail-help"; - customTypeHelp.textContent = - "Controls how Datasette displays and edits this column"; - customTypeSelect = createTableAlterCustomColumnTypeSelect(); - customTypeSelect.id = customTypeId; - customTypeSelect.setAttribute("aria-describedby", customTypeHelpId); - customTypeSelect.value = originalCustomType; - updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); - customTypeField.appendChild(customTypeLabel); - customTypeField.appendChild(customTypeHelp); - customTypeField.appendChild(customTypeSelect); - } - - var notNullLabel = document.createElement("label"); - notNullLabel.className = "table-alter-detail-check table-alter-not-null"; - var notNullInput = document.createElement("input"); - notNullInput.type = "checkbox"; - notNullInput.className = "table-alter-not-null-input"; - notNullInput.checked = !!(column && column.notnull); - var notNullText = document.createElement("span"); - var notNullStrong = document.createElement("strong"); - notNullStrong.textContent = "Not null"; - notNullText.appendChild(notNullStrong); - notNullText.appendChild( - document.createTextNode(" This value cannot be left unset"), - ); - notNullLabel.appendChild(notNullInput); - notNullLabel.appendChild(notNullText); - - var defaultControls = createSchemaDialogDefaultControls( - "table-alter", - index, - tableAlterDefaultExpressions(), - { - defaultValue: originalDefault, - defaultExpr: originalDefaultExpr, - }, - ); - var defaultInput = defaultControls.defaultInput; - var defaultExprSelect = defaultControls.defaultExprSelect; - - var foreignKeyId = "table-alter-column-foreign-key-" + index; - var foreignKeyHelpId = "table-alter-column-foreign-key-help-" + index; - var foreignKeyField = document.createElement("div"); - foreignKeyField.className = - "table-alter-detail-field table-alter-foreign-key-field"; - var foreignKeyLabel = document.createElement("label"); - foreignKeyLabel.className = "table-alter-detail-label"; - foreignKeyLabel.setAttribute("for", foreignKeyId); - foreignKeyLabel.textContent = "Foreign key"; - var foreignKeyHelp = document.createElement("p"); - foreignKeyHelp.id = foreignKeyHelpId; - foreignKeyHelp.className = "table-alter-detail-help"; - foreignKeyHelp.textContent = "Link this column to another table."; - var foreignKeySelect = document.createElement("select"); - foreignKeySelect.id = foreignKeyId; - foreignKeySelect.className = - "table-alter-input table-alter-foreign-key-target"; - foreignKeySelect.setAttribute("aria-label", "Foreign key target"); - foreignKeySelect.setAttribute("aria-describedby", foreignKeyHelpId); - foreignKeySelect.dataset.selectedKey = originalForeignKey; - if (column && column.foreign_key) { - foreignKeySelect.dataset.currentFkTable = column.foreign_key.fk_table; - foreignKeySelect.dataset.currentFkColumn = column.foreign_key.fk_column; - appendForeignKeyTargetOption(foreignKeySelect, { - fk_table: column.foreign_key.fk_table, - fk_column: column.foreign_key.fk_column, - type: column.type || "text", - }); - foreignKeySelect.value = originalForeignKey; - } - foreignKeyField.appendChild(foreignKeyLabel); - foreignKeyField.appendChild(foreignKeyHelp); - foreignKeyField.appendChild(foreignKeySelect); - - var pkLabel = document.createElement("label"); - pkLabel.className = "table-alter-detail-check table-alter-primary-key"; - var pkInput = document.createElement("input"); - pkInput.type = "checkbox"; - pkInput.className = "table-alter-primary-key-input"; - pkInput.checked = !!(column && column.is_pk); - var pkText = document.createElement("span"); - var pkStrong = document.createElement("strong"); - pkStrong.textContent = "Primary key"; - pkText.appendChild(pkStrong); - pkText.appendChild( - document.createTextNode(" This ID uniquely identifies the record"), - ); - pkLabel.appendChild(pkInput); - pkLabel.appendChild(pkText); - - var moveControls = createSchemaDialogMoveControls("table-alter"); - - var removeButton = createSchemaDialogIconButton( - "table-alter", - "remove-column", - existing ? "Drop column" : "Remove column", - existing ? "Drop column" : "Remove column", - COLUMN_MOVE_ICONS.remove, - ); - main.appendChild(nameLabel); - main.appendChild(nameInput); - main.appendChild(typeSelect); - main.appendChild(moveControls.controls); - main.appendChild(removeButton); - main.appendChild(expandButton); - - if (customTypeField) { - details.appendChild(customTypeField); - } - details.appendChild(defaultControls.controls); - details.appendChild(notNullLabel); - details.appendChild(pkLabel); - details.appendChild(foreignKeyField); - row.appendChild(main); - row.appendChild(details); - - var controls = [ - nameInput, - typeSelect, - notNullInput, - defaultInput, - defaultExprSelect, - pkInput, - foreignKeySelect, - ]; - if (customTypeSelect) { - controls.push(customTypeSelect); - } - controls.forEach(function (control) { - control.addEventListener("input", function () { - clearTableAlterDialogError(state); - updateTableAlterSaveButtonState(state); - }); - control.addEventListener("change", function () { - clearTableAlterDialogError(state); - updateTableAlterSaveButtonState(state); - }); - }); - - defaultInput.addEventListener("input", function () { - if (defaultInput.value) { - defaultExprSelect.value = ""; - syncSchemaDialogDefaultControls(row, "table-alter"); - } - updateTableAlterSaveButtonState(state); - }); - defaultExprSelect.addEventListener("change", function () { - if (defaultExprSelect.value) { - defaultInput.value = ""; - } - if ( - applyDefaultExpressionColumnType( - row, - "table-alter", - tableAlterDefaultExpressions(), - tableAlterColumnTypes(), - ) - ) { - syncTableAlterCustomTypeForSqliteType(row); - syncTableAlterForeignKeyOptions(row, state); - syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); - } - syncSchemaDialogDefaultControls(row, "table-alter"); - updateTableAlterSaveButtonState(state); - }); - pkInput.addEventListener("change", function () { - normalizeTableAlterPrimaryKeyRows(state); - updateTableAlterMoveButtons(state); - updateTableAlterSaveButtonState(state); - }); - - expandButton.addEventListener("click", function () { - toggleSchemaDialogMoreOptions(expandButton, details); - }); - - typeSelect.addEventListener("change", function () { - syncTableAlterCustomTypeForSqliteType(row); - syncTableAlterForeignKeyOptions(row, state); - syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); - updateTableAlterSaveButtonState(state); - }); - if (customTypeSelect) { - customTypeSelect.addEventListener("change", function () { - updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); - var option = tableAlterCustomColumnType(customTypeSelect.value); - if ( - option && - option.fixedSqliteType && - tableAlterColumnTypes().indexOf(option.fixedSqliteType) !== -1 - ) { - typeSelect.value = option.fixedSqliteType; - syncTableAlterForeignKeyOptions(row, state); - } - syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); - updateTableAlterSaveButtonState(state); - }); - } - foreignKeySelect.addEventListener("change", function () { - handleSchemaDialogForeignKeyChange(row, state, "table-alter", { - columnTypes: tableAlterColumnTypes(), - foreignKeyOptions: { filterByType: false }, - matchType: true, - }); - updateTableAlterSaveButtonState(state); - }); - - moveControls.topButton.addEventListener("click", function () { - var first = tableAlterFirstNonPrimaryRow(state); - if ( - state.isSaving || - tableAlterRowIsPrimaryKey(row) || - !first || - first === row - ) { - return; - } - state.columnList.insertBefore(row, first); - clearTableAlterDialogError(state); - updateTableAlterMoveButtons(state); - updateTableAlterSaveButtonState(state); - row.querySelector(".table-alter-column-name").focus(); - }); - - moveControls.upButton.addEventListener("click", function () { - var previous = row.previousElementSibling; - if ( - state.isSaving || - tableAlterRowIsPrimaryKey(row) || - !previous || - tableAlterRowIsPrimaryKey(previous) - ) { - return; - } - state.columnList.insertBefore(row, previous); - clearTableAlterDialogError(state); - updateTableAlterMoveButtons(state); - updateTableAlterSaveButtonState(state); - row.querySelector(".table-alter-column-name").focus(); - }); - - moveControls.downButton.addEventListener("click", function () { - var next = row.nextElementSibling; - if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !next) { - return; - } - state.columnList.insertBefore(next, row); - clearTableAlterDialogError(state); - updateTableAlterMoveButtons(state); - updateTableAlterSaveButtonState(state); - row.querySelector(".table-alter-column-name").focus(); - }); - - moveControls.bottomButton.addEventListener("click", function () { - var last = state.columnList.lastElementChild; - if ( - state.isSaving || - tableAlterRowIsPrimaryKey(row) || - !last || - last === row - ) { - return; - } - state.columnList.appendChild(row); - clearTableAlterDialogError(state); - updateTableAlterMoveButtons(state); - updateTableAlterSaveButtonState(state); - row.querySelector(".table-alter-column-name").focus(); - }); - - removeButton.addEventListener("click", function () { - if (state.isSaving) { - return; - } - if (row.dataset.existing === "1") { - state.deletedColumns.push(row.dataset.originalName); - } - row.remove(); - clearTableAlterDialogError(state); - updateTableAlterMoveButtons(state); - updateTableAlterSaveButtonState(state); - var nextInput = state.columnList.querySelector(".table-alter-column-name"); - if (nextInput) { - nextInput.focus(); - } else { - state.addColumnButton.focus(); - } - }); - - syncSchemaDialogDefaultControls(row, "table-alter"); - syncTableAlterForeignKeyOptions(row, state); - syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); - return row; -} - -function addTableAlterColumn(state, column) { - var row = createTableAlterColumnRow(state, column || { type: "text" }); - state.columnList.appendChild(row); - return row; -} - -function resetTableAlterDialog(state, data) { - state.nextColumnIndex = 0; - state.deletedColumns = []; - state.originalTableName = data.tableName || ""; - state.tableNameInput.value = state.originalTableName; - state.tableOptions.open = false; - state.originalPrimaryKeys = (data.primaryKeys || []).slice(); - state.originalColumnNames = (data.columns || []).map(function (column) { - return column.name; - }); - state.columnList.textContent = ""; - (data.columns || []).forEach(function (column) { - addTableAlterColumn( - state, - Object.assign({}, column, { - existing: true, - }), - ); - }); - normalizeTableAlterPrimaryKeyRows(state); - state.initialSignature = tableAlterDialogSignature(state); - showTableAlterEditor(state); -} - -function collectTableAlterRows(state) { - return tableAlterDialogRows(state).map(function (row) { - var signature = tableAlterRowSignature(row); - signature.originalType = row.dataset.originalType || ""; - signature.originalNotNull = row.dataset.originalNotNull === "1"; - signature.originalHasDefault = row.dataset.originalHasDefault === "1"; - signature.originalDefault = row.dataset.originalDefault || ""; - signature.originalDefaultExpr = row.dataset.originalDefaultExpr || ""; - signature.originalPk = row.dataset.originalPk === "1"; - signature.originalCustomType = row.dataset.originalCustomType || ""; - signature.originalForeignKey = row.dataset.originalForeignKey || ""; - return signature; - }); -} - -function validateTableAlterRows(state, rows) { - var tableName = state.tableNameInput.value.trim(); - if (!tableName) { - return "Table name is required."; - } - if (tableName.indexOf("\n") !== -1) { - return "Table names cannot contain newlines."; - } - if (/^sqlite_/.test(tableName)) { - return "Table names cannot start with sqlite_."; - } - if (!rows.length) { - return "At least one column is required."; - } - var seen = {}; - var supportedTypes = tableAlterColumnTypes(); - for (var i = 0; i < rows.length; i += 1) { - var row = rows[i]; - var name = row.name.trim(); - if (!name) { - return "Column name is required."; - } - if (name.indexOf("\n") !== -1) { - return "Column names cannot contain newlines."; - } - var columnKey = name.toLowerCase(); - if (seen[columnKey]) { - return "Duplicate column name: " + name; - } - seen[columnKey] = true; - if (supportedTypes.indexOf(row.type) === -1) { - return "Unsupported column type: " + row.type; - } - if (row.customType) { - var option = tableAlterCustomColumnType(row.customType); - if (!option) { - return "Unknown custom column type: " + row.customType; - } - if (!tableAlterCustomTypeAppliesToSqliteType(option, row.type)) { - return ( - "Custom type " + - row.customType + - " cannot be used with SQLite type " + - row.type + - "." - ); - } - } - if (row.defaultValue && row.defaultExpr) { - return "Use either a default value or a default expression."; - } - if (!row.existing && row.notNull && !row.defaultValue && !row.defaultExpr) { - return "New NOT NULL columns need a default or default expression."; - } - } - var pkColumns = rows.filter(function (row) { - return row.pk; - }); - if (state.originalPrimaryKeys.length && !pkColumns.length) { - return "At least one primary key column is required."; - } - return null; -} - -function collectTableAlterColumnTypeAssignments(rows) { - var assignments = []; - if (!tableAlterCustomColumnTypes().length) { - return assignments; - } - rows.forEach(function (row) { - var renamed = row.existing && row.name.trim() !== row.originalName; - if (row.customType === row.originalCustomType && !renamed) { - return; - } - if (!row.customType && !row.originalCustomType) { - return; - } - assignments.push({ - column: row.name.trim(), - columnType: row.customType || null, - sqliteType: row.type, - }); - }); - return assignments; -} - -function tableAlterPkIdentityColumns(rows) { - return rows - .filter(function (row) { - return row.pk; - }) - .map(function (row) { - return row.existing ? row.originalName : row.name.trim(); - }); -} - -function tableAlterPkChanged(state, rows) { - return ( - JSON.stringify(tableAlterPkIdentityColumns(rows)) !== - JSON.stringify(state.originalPrimaryKeys) - ); -} - -function tableAlterNaturalColumnOrder(state, rows) { - var existingRowsByOriginalName = {}; - var newRows = []; - rows.forEach(function (row) { - if (row.existing) { - existingRowsByOriginalName[row.originalName] = row; - } else { - newRows.push(row); - } - }); - var naturalOrder = []; - state.originalColumnNames.forEach(function (originalName) { - var row = existingRowsByOriginalName[originalName]; - if (row) { - naturalOrder.push(row.name.trim()); - } - }); - newRows.forEach(function (row) { - naturalOrder.push(row.name.trim()); - }); - return naturalOrder; -} - -function tableAlterColumnsReordered(state, rows) { - var finalOrder = rows.map(function (row) { - return row.name.trim(); - }); - return ( - JSON.stringify(finalOrder) !== - JSON.stringify(tableAlterNaturalColumnOrder(state, rows)) - ); -} - -function tableAlterForeignKeyIdentity(row) { - return [ - row.name.trim(), - row.foreignKeyTable || "", - row.foreignKeyColumn || "", - ].join("\u001f"); -} - -function tableAlterOriginalForeignKeyIdentity(row) { - return [row.originalName || "", row.originalForeignKey].join("\u001f"); -} - -function tableAlterForeignKeyRows(rows) { - return rows - .filter(function (row) { - return row.foreignKey && row.foreignKeyTable && row.foreignKeyColumn; - }) - .map(function (row) { - return { - column: row.name.trim(), - fk_table: row.foreignKeyTable, - fk_column: row.foreignKeyColumn, - }; - }); -} - -function tableAlterForeignKeysChanged(rows) { - var original = rows - .filter(function (row) { - return row.existing && row.originalForeignKey; - }) - .map(tableAlterOriginalForeignKeyIdentity); - var final = rows - .filter(function (row) { - return row.foreignKey && row.foreignKeyTable && row.foreignKeyColumn; - }) - .map(tableAlterForeignKeyIdentity); - return JSON.stringify(original) !== JSON.stringify(final); -} - -function collectTableAlterPayload(state) { - var rows = collectTableAlterRows(state); - var validationError = validateTableAlterRows(state, rows); - if (validationError) { - return { error: validationError }; - } - - var operations = []; - var tableName = state.tableNameInput.value.trim(); - if (tableName !== state.originalTableName) { - operations.push({ - op: "rename_table", - args: { to: tableName }, - }); - } - var columnTypeAssignments = collectTableAlterColumnTypeAssignments(rows); - rows.forEach(function (row) { - var name = row.name.trim(); - if (!row.existing) { - var addArgs = { - name: name, - type: row.type, - not_null: row.notNull, - }; - if (row.defaultExpr) { - addArgs.default_expr = row.defaultExpr; - } else if (row.defaultValue || row.notNull) { - addArgs.default = row.defaultValue; - } - operations.push({ op: "add_column", args: addArgs }); - return; - } - - var originalName = row.originalName; - if (name !== originalName) { - operations.push({ - op: "rename_column", - args: { name: originalName, to: name }, - }); - } - - var alterArgs = { name: originalName }; - if (row.type !== row.originalType) { - alterArgs.type = row.type; - } - if (row.notNull !== row.originalNotNull) { - alterArgs.not_null = row.notNull; - } - if (row.defaultExpr !== row.originalDefaultExpr) { - if (row.defaultExpr) { - alterArgs.default_expr = row.defaultExpr; - } else { - alterArgs.default = row.defaultValue === "" ? null : row.defaultValue; - } - } else if (row.originalHasDefault) { - if (row.defaultValue !== row.originalDefault) { - alterArgs.default = row.defaultValue === "" ? null : row.defaultValue; - } - } else if (row.defaultValue) { - alterArgs.default = row.defaultValue; - } - if (Object.keys(alterArgs).length > 1) { - operations.push({ op: "alter_column", args: alterArgs }); - } - }); - - state.deletedColumns.forEach(function (name) { - operations.push({ op: "drop_column", args: { name: name } }); - }); - - var pkColumns = rows - .filter(function (row) { - return row.pk; - }) - .map(function (row) { - return row.name.trim(); - }); - if (tableAlterPkChanged(state, rows)) { - operations.push({ op: "set_primary_key", args: { columns: pkColumns } }); - } - - if (tableAlterColumnsReordered(state, rows)) { - operations.push({ - op: "reorder_columns", - args: { - columns: rows.map(function (row) { - return row.name.trim(); - }), - }, - }); - } - - if (tableAlterForeignKeysChanged(rows)) { - operations.push({ - op: "set_foreign_keys", - args: { - foreign_keys: tableAlterForeignKeyRows(rows), - }, - }); - } - - if (!operations.length && !columnTypeAssignments.length) { - return { error: "No changes to apply." }; - } - return { - payload: operations.length ? { operations: operations } : null, - columnTypeAssignments: columnTypeAssignments, - }; -} - -function tableAlterQuotedName(name) { - return '"' + name + '"'; -} - -function tableAlterReadableDefaultExpression(value) { - return defaultExpressionLabelForValue(tableAlterDefaultExpressions(), value); -} - -function tableAlterReadableValue(value) { - if (value === null) { - return "NULL"; - } - return '"' + String(value) + '"'; -} - -function tableAlterOperationSummary(operation) { - var args = operation.args || {}; - if (operation.op === "rename_table") { - return { - text: "Rename table to " + tableAlterQuotedName(args.to) + ".", - damaging: false, - }; - } - if (operation.op === "add_column") { - var addDetails = ["as " + args.type]; - if (args.not_null) { - addDetails.push("with values required"); - } - if (args.default_expr) { - addDetails.push( - "defaulting to " + - tableAlterReadableDefaultExpression(args.default_expr), - ); - } else if (Object.prototype.hasOwnProperty.call(args, "default")) { - addDetails.push( - "with default value " + tableAlterReadableValue(args.default), - ); - } - return { - text: - "Add column " + - tableAlterQuotedName(args.name) + - " " + - addDetails.join(", ") + - ".", - damaging: false, - }; - } - if (operation.op === "rename_column") { - return { - text: - "Rename column " + - tableAlterQuotedName(args.name) + - " to " + - tableAlterQuotedName(args.to) + - ".", - damaging: false, - }; - } - if (operation.op === "alter_column") { - var changes = []; - if (args.type) { - changes.push("set type to " + args.type); - } - if (Object.prototype.hasOwnProperty.call(args, "not_null")) { - changes.push( - args.not_null ? "not null (require values)" : "allow unset values", - ); - } - if (args.default_expr) { - changes.push( - "default to " + tableAlterReadableDefaultExpression(args.default_expr), - ); - } else if (Object.prototype.hasOwnProperty.call(args, "default")) { - changes.push( - args.default === null - ? "remove the default value" - : "set default value to " + tableAlterReadableValue(args.default), - ); - } - return { - text: - "Change column " + - tableAlterQuotedName(args.name) + - ": " + - changes.join(", ") + - ".", - damaging: false, - }; - } - if (operation.op === "drop_column") { - return { - text: "Drop column " + tableAlterQuotedName(args.name) + ".", - damaging: true, - }; - } - if (operation.op === "set_primary_key") { - return { - text: - "Set primary key to " + - (args.columns || []).map(tableAlterQuotedName).join(", ") + - ".", - damaging: false, - }; - } - if (operation.op === "reorder_columns") { - return { - text: - "Set column order to " + - (args.columns || []).map(tableAlterQuotedName).join(", ") + - ".", - damaging: false, - }; - } - if (operation.op === "set_foreign_keys") { - var foreignKeys = args.foreign_keys || []; - return { - text: foreignKeys.length - ? "Set foreign keys to " + - foreignKeys - .map(function (foreignKey) { - return ( - tableAlterQuotedName(foreignKey.column) + - " -> " + - foreignKey.fk_table + - "." + - foreignKey.fk_column - ); - }) - .join(", ") + - "." - : "Remove all foreign keys.", - damaging: false, - }; - } - return { - text: "Run " + operation.op + ".", - damaging: false, - }; -} - -function tableAlterColumnTypeAssignmentSummary(assignment) { - return { - text: assignment.columnType - ? "Set custom type for column " + - tableAlterQuotedName(assignment.column) + - " to " + - assignment.columnType + - "." - : "Remove custom type from column " + - tableAlterQuotedName(assignment.column) + - ".", - damaging: false, - }; -} - -function tableAlterReviewItems(result) { - var items = []; - var operations = result.payload ? result.payload.operations || [] : []; - operations.forEach(function (operation) { - items.push(tableAlterOperationSummary(operation)); - }); - (result.columnTypeAssignments || []).forEach(function (assignment) { - items.push(tableAlterColumnTypeAssignmentSummary(assignment)); - }); - return items; -} - -function tableAlterReviewHasDamagingItems(items) { - return items.some(function (item) { - return item.damaging; - }); -} - -function appendTableAlterReviewText(element, text) { - text.split(/("[^"]+")/g).forEach(function (part) { - if (!part) { - return; - } - if (part.charAt(0) === '"' && part.charAt(part.length - 1) === '"') { - var name = document.createElement("code"); - name.className = "table-alter-review-name"; - name.textContent = part.slice(1, -1); - element.appendChild(name); - } else { - element.appendChild(document.createTextNode(part)); - } - }); -} - -function tableAlterSetColumnTypeUrl(tableUrl) { - if (tableUrl) { - return tableUrl.replace(/\/$/, "") + "/-/set-column-type"; - } - var data = tableAlterData(); - if (!data || !data.path) { - return null; - } - var url = new URL(data.path, location.href); - url.pathname = url.pathname.replace(/\/-\/alter\/?$/, "/-/set-column-type"); - return url.toString(); -} - -async function assignTableAlterColumnTypes(assignments, tableUrl) { - if (!assignments.length) { - return; - } - var url = tableAlterSetColumnTypeUrl(tableUrl); - if (!url) { - throw new Error("Could not find the set column type URL."); - } - for (var i = 0; i < assignments.length; i += 1) { - var assignment = assignments[i]; - var response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - column: assignment.column, - column_type: assignment.columnType - ? { - type: assignment.columnType, - } - : null, - }), - }); - var data = null; - try { - data = await response.json(); - } catch (_error) { - data = null; - } - if (!response.ok || (data && data.ok === false)) { - var error = rowMutationRequestError(response, data); - throw new Error( - "Saved schema changes, but could not set custom type for " + - assignment.column + - ": " + - error.message, - ); - } - } -} - -function tableAlterResultRenamesTable(result) { - return !!( - result && - result.payload && - (result.payload.operations || []).some(function (operation) { - return operation.op === "rename_table"; - }) - ); -} - -function showTableAlterEditor(state) { - state.mode = "edit"; - state.reviewResult = null; - state.dialog.classList.remove("table-alter-reviewing"); - state.fields.hidden = false; - state.review.hidden = true; - state.review.textContent = ""; - state.backButton.hidden = true; - var data = tableAlterData(); - state.dropButton.hidden = !(data && data.dropPath); - state.saveButton.textContent = tableAlterSaveButtonText(state); - updateTableAlterMoveButtons(state); - updateTableAlterSaveButtonState(state); -} - -function showTableAlterReview(state, result) { - var items = tableAlterReviewItems(result); - state.mode = "review"; - state.reviewResult = result; - state.dialog.classList.add("table-alter-reviewing"); - state.fields.hidden = true; - state.review.hidden = false; - state.review.textContent = ""; - state.backButton.hidden = false; - state.dropButton.hidden = true; - state.saveButton.textContent = tableAlterSaveButtonText(state); - updateTableAlterSaveButtonState(state); - - var heading = document.createElement("h3"); - heading.className = "table-alter-review-title"; - heading.tabIndex = -1; - heading.textContent = "Review changes"; - state.review.appendChild(heading); - - var intro = document.createElement("p"); - intro.className = "table-alter-review-intro"; - intro.textContent = "These changes will be applied to the table."; - state.review.appendChild(intro); - - if (tableAlterReviewHasDamagingItems(items)) { - var warning = document.createElement("p"); - warning.className = "table-alter-review-warning"; - warning.setAttribute("role", "alert"); - warning.textContent = - "Warning: data in dropped columns will be permanently lost."; - state.review.appendChild(warning); - } - - var list = document.createElement("ol"); - list.className = "table-alter-review-list"; - items.forEach(function (item) { - var listItem = document.createElement("li"); - appendTableAlterReviewText(listItem, item.text); - if (item.damaging) { - listItem.className = "table-alter-review-damaging"; - } - list.appendChild(listItem); - }); - state.review.appendChild(list); - heading.focus(); -} - -async function applyTableAlterChanges(state, result) { - if (state.isSaving) { - return; - } - if (!result) { - showTableAlterDialogError(state, "Could not find the reviewed changes."); - return; - } - var data = tableAlterData(); - if (!data || !data.path) { - showTableAlterDialogError(state, "Could not find the alter table URL."); - return; - } - clearTableAlterDialogError(state); - if (result.error) { - showTableAlterDialogError(state, result.error); - return; - } - setTableAlterDialogSaving(state, true); - try { - var responseData = null; - if (result.payload) { - var response = await fetch(data.path, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(result.payload), - }); - try { - responseData = await response.json(); - } catch (_error) { - responseData = null; - } - if (!response.ok || (responseData && responseData.ok === false)) { - throw rowMutationRequestError(response, responseData); - } - } - var tableUrl = responseData && responseData.table_url; - await assignTableAlterColumnTypes( - result.columnTypeAssignments || [], - tableUrl, - ); - state.shouldRestoreFocus = false; - state.dialog.close(); - if (tableAlterResultRenamesTable(result) && tableUrl) { - window.location.href = tableUrl; - } else { - location.reload(); - } - } catch (error) { - setTableAlterDialogSaving(state, false); - showTableAlterDialogError(state, error.message || "Could not alter table"); - } -} - -function tableAlterDatabaseUrl() { - var data = tableAlterData(); - if (!data || !data.path) { - return null; - } - var url = new URL(data.path, location.href); - url.pathname = url.pathname.replace(/\/[^/]+\/-\/alter\/?$/, ""); - url.search = ""; - url.hash = ""; - return url.toString(); -} - -async function dropTableFromAlterDialog(state) { - if (state.isSaving) { - return; - } - var data = tableAlterData(); - if (!data || !data.dropPath) { - return; - } - if ( - !window.confirm( - 'Permanently delete the table "' + - data.tableName + - '"? This will delete all of its data and cannot be undone.', - ) - ) { - return; - } - clearTableAlterDialogError(state); - setTableAlterDialogSaving(state, true); - try { - var response = await fetch(data.dropPath, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ confirm: true }), - }); - var responseData = null; - try { - responseData = await response.json(); - } catch (_error) { - responseData = null; - } - if (!response.ok || (responseData && responseData.ok === false)) { - throw rowMutationRequestError(response, responseData); - } - state.shouldRestoreFocus = false; - state.dialog.close(); - window.location.href = tableAlterDatabaseUrl() || "/"; - } catch (error) { - setTableAlterDialogSaving(state, false); - showTableAlterDialogError(state, error.message || "Could not drop table"); - } -} - -async function saveTableAlterDialog(state) { - if (state.isSaving) { - return; - } - if (state.mode === "review") { - if (!state.reviewResult) { - showTableAlterDialogError(state, "Could not find the reviewed changes."); - return; - } - await applyTableAlterChanges(state, state.reviewResult); - return; - } - clearTableAlterDialogError(state); - var result = collectTableAlterPayload(state); - if (result.error) { - showTableAlterDialogError(state, result.error); - return; - } - showTableAlterReview(state, result); -} - -function confirmDiscardTableAlterChanges(state) { - if (!tableAlterDialogHasChanges(state)) { - return true; - } - return window.confirm("Discard table changes?"); -} - -function closeTableAlterDialogIfConfirmed(state) { - if (!state || state.isSaving) { - return false; - } - if (!confirmDiscardTableAlterChanges(state)) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - -function closeTableAlterDialog(state) { - if (!state || state.isSaving) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - -function ensureTableAlterDialog(manager) { - if (tableAlterDialogState) { - return tableAlterDialogState; - } - if (!window.HTMLDialogElement) { - return null; - } - - var dialog = document.createElement("dialog"); - dialog.id = TABLE_ALTER_DIALOG_ID; - dialog.className = "table-alter-dialog"; - dialog.setAttribute("aria-labelledby", "table-alter-title"); - dialog.innerHTML = ` - -
    - -
    -
    - -
    - -
    -
    - Rename table -
    - - -
    -
    -
    - - -
    - `; - document.body.appendChild(dialog); - - tableAlterDialogState = { - dialog: dialog, - form: dialog.querySelector(".table-alter-form"), - title: dialog.querySelector(".modal-title"), - error: dialog.querySelector(".table-alter-error"), - fields: dialog.querySelector(".table-alter-fields"), - tableOptions: dialog.querySelector(".table-alter-table-options"), - tableNameInput: dialog.querySelector(".table-alter-table-name"), - review: dialog.querySelector(".table-alter-review"), - columnList: dialog.querySelector(".table-alter-column-list"), - addColumnButton: dialog.querySelector(".table-alter-add-column"), - backButton: dialog.querySelector(".table-alter-back"), - dropButton: dialog.querySelector(".table-alter-drop"), - cancelButton: dialog.querySelector(".table-alter-cancel"), - saveButton: dialog.querySelector(".table-alter-save"), - currentButton: null, - shouldRestoreFocus: true, - isSaving: false, - initialSignature: "", - originalTableName: "", - nextColumnIndex: 0, - deletedColumns: [], - originalColumnNames: [], - originalPrimaryKeys: [], - foreignKeyTargets: [], - foreignKeyTargetsError: null, - foreignKeyTargetsLoading: false, - mode: "edit", - reviewResult: null, - manager: manager, - }; - - tableAlterDialogState.form.addEventListener("submit", function (ev) { - ev.preventDefault(); - saveTableAlterDialog(tableAlterDialogState); - }); - - tableAlterDialogState.tableNameInput.addEventListener("input", function () { - clearTableAlterDialogError(tableAlterDialogState); - updateTableAlterSaveButtonState(tableAlterDialogState); - }); - - tableAlterDialogState.addColumnButton.addEventListener("click", function () { - if (tableAlterDialogState.isSaving) { - return; - } - var row = addTableAlterColumn(tableAlterDialogState, { - type: "text", - existing: false, - expanded: true, - }); - clearTableAlterDialogError(tableAlterDialogState); - updateTableAlterMoveButtons(tableAlterDialogState); - updateTableAlterSaveButtonState(tableAlterDialogState); - row.querySelector(".table-alter-column-name").focus(); - }); - - tableAlterDialogState.cancelButton.addEventListener("click", function () { - closeTableAlterDialog(tableAlterDialogState); - }); - - tableAlterDialogState.dropButton.addEventListener("click", function () { - dropTableFromAlterDialog(tableAlterDialogState); - }); - - tableAlterDialogState.backButton.addEventListener("click", function () { - if (tableAlterDialogState.isSaving) { - return; - } - clearTableAlterDialogError(tableAlterDialogState); - showTableAlterEditor(tableAlterDialogState); - var firstName = tableAlterDialogState.columnList.querySelector( - ".table-alter-column-name", - ); - if (firstName) { - firstName.focus(); - } - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeTableAlterDialogIfConfirmed(tableAlterDialogState); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if (ev.key !== "Escape") { - return; - } - ev.preventDefault(); - closeTableAlterDialogIfConfirmed(tableAlterDialogState); - }); - - dialog.addEventListener("cancel", function (ev) { - ev.preventDefault(); - closeTableAlterDialogIfConfirmed(tableAlterDialogState); - }); - - dialog.addEventListener("close", function () { - var state = tableAlterDialogState; - clearTableAlterDialogError(state); - setTableAlterDialogSaving(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } - }); - - return tableAlterDialogState; -} - -function openTableAlterDialog(button, manager) { - var data = tableAlterData(); - if (!data) { - return; - } - var state = ensureTableAlterDialog(manager); - if (!state) { - return; - } - - var menu = button.closest("details"); - if (menu) { - menu.open = false; - } - state.manager = manager; - state.currentButton = button; - state.shouldRestoreFocus = true; - state.title.textContent = "Alter table " + data.tableName; - clearTableAlterDialogError(state); - resetTableAlterDialog(state, data); - loadSchemaDialogForeignKeyTargets( - state, - "table-alter", - tableAlterForeignKeyTargetsUrl(), - { filterByType: false }, - ); - if (!state.dialog.open) { - state.dialog.showModal(); - } - var firstName = state.columnList.querySelector(".table-alter-column-name"); - if (firstName) { - firstName.focus(); - } -} - -function initTableAlterActions(manager) { - if (!window.fetch || !window.HTMLDialogElement || !tableAlterData()) { - return; - } - document.addEventListener("click", function (ev) { - var button = ev.target.closest('button[data-table-action="alter-table"]'); - if (!button) { - return; - } - ev.preventDefault(); - openTableAlterDialog(button, manager); - }); -} - -function tableForeignKeys() { - return tablePageData().foreignKeys || {}; -} - -function isRowPage() { - return document.body && document.body.classList.contains("row"); -} - -function rowElementForActionButton(button) { - return ( - button.closest("[data-row]") || - (button.getAttribute("data-row") ? button : null) - ); -} - -function foreignKeyAutocompleteUrl(column) { - return tableForeignKeys()[column] || null; -} - -function autocompleteRowPk(row) { - var pks = (row && row.pks) || {}; - var keys = Object.keys(pks); - if (keys.length !== 1) { - return null; - } - return pks[keys[0]]; -} - -function foreignKeyRowUrl(autocompleteUrl, pk) { - var url = new URL(autocompleteUrl, location.href); - if (!/\/-\/autocomplete\/?$/.test(url.pathname)) { - return null; - } - url.pathname = - url.pathname.replace(/\/-\/autocomplete\/?$/, "") + "/" + tildeEncode(pk); - url.search = ""; - url.hash = ""; - return url.toString(); -} - -function foreignKeyLabelText(row) { - var pk = autocompleteRowPk(row); - var label = row && row.label; - if ( - label !== null && - typeof label !== "undefined" && - String(label) !== String(pk) - ) { - return String(label); - } - return "View row"; -} - -function rowEditMetaTextWithoutCurrentValue(meta) { - return (meta.dataset.baseMeta || "") - .split(" · ") - .filter(function (part) { - return part !== "Current value: NULL"; - }) - .join(" · "); -} - -function updateRowEditForeignKeySeparator(meta) { - var separator = meta.querySelector(".row-edit-fk-separator"); - if (!separator) { - return; - } - var baseMeta = meta.querySelector(".row-edit-base-meta"); - var hasBaseMeta = !!(baseMeta && baseMeta.textContent); - separator.textContent = hasBaseMeta ? " · " : ""; - separator.hidden = !hasBaseMeta; -} - -function updateRowEditFieldMetaHidden(meta) { - var baseMeta = meta.querySelector(".row-edit-base-meta"); - var hasBaseMeta = !!(baseMeta && baseMeta.textContent); - var foreignKeyLinkWrap = meta.querySelector(".row-edit-fk-link-wrap"); - var hasForeignKeyLink = foreignKeyLinkWrap && !foreignKeyLinkWrap.hidden; - meta.hidden = - meta.dataset.reserveSpace !== "1" && !hasBaseMeta && !hasForeignKeyLink; -} - -function setRowEditBaseMetaText(meta, text) { - var baseMeta = meta.querySelector(".row-edit-base-meta"); - if (!baseMeta) { - return; - } - baseMeta.textContent = text || ""; - updateRowEditForeignKeySeparator(meta); - updateRowEditFieldMetaHidden(meta); -} - -function setForeignKeyMetaLink(meta, autocompleteUrl, row) { - var wrap = meta.querySelector(".row-edit-fk-link-wrap"); - if (!wrap) { - return; - } - var pkSpan = wrap.querySelector(".row-edit-fk-pk"); - var link = wrap.querySelector("a"); - var pk = autocompleteRowPk(row); - var url = - pk === null || typeof pk === "undefined" - ? null - : foreignKeyRowUrl(autocompleteUrl, pk); - if (!url) { - wrap.hidden = true; - pkSpan.textContent = ""; - link.removeAttribute("href"); - link.textContent = ""; - link.removeAttribute("aria-label"); - setRowEditBaseMetaText(meta, meta.dataset.baseMeta || ""); - updateRowEditFieldMetaHidden(meta); - return; - } - setRowEditBaseMetaText(meta, rowEditMetaTextWithoutCurrentValue(meta)); - var pkText = String(pk); - var linkText = foreignKeyLabelText(row); - pkSpan.textContent = pkText; - link.href = url; - link.textContent = linkText; - link.setAttribute( - "aria-label", - "Open referenced row " + pkText + " " + linkText + " in a new tab", - ); - wrap.hidden = false; - updateRowEditFieldMetaHidden(meta); -} - -async function resolveForeignKeyMetaLink(control, autocompleteUrl, meta) { - var value = control.value.trim(); - if (!value) { - setForeignKeyMetaLink(meta, autocompleteUrl, null); - return; - } - - var url = new URL(autocompleteUrl, location.href); - url.searchParams.set("q", value); - try { - var response = await fetch(url.toString(), { - headers: { - Accept: "application/json", - }, - }); - if (!response.ok) { - throw new Error("HTTP " + response.status); - } - var data = await response.json(); - if (control.value.trim() !== value) { - return; - } - var rows = (data && data.rows) || []; - var row = rows.find(function (candidate) { - var pk = autocompleteRowPk(candidate); - return pk !== null && typeof pk !== "undefined" && String(pk) === value; - }); - setForeignKeyMetaLink(meta, autocompleteUrl, row || null); - } catch (_error) { - if (control.value.trim() === value) { - setForeignKeyMetaLink(meta, autocompleteUrl, null); - } - } -} - -function tableInsertUrl() { - var data = tableInsertData(); - if (data && data.path) { - return new URL(data.path, location.href).toString(); - } - var url = tableBaseUrl(); - url.pathname = url.pathname.replace(/\/$/, "") + "/-/insert"; - return url.toString(); -} - -function tableUpsertUrl() { - var data = tableInsertData(); - if (data && data.upsertPath) { - return new URL(data.upsertPath, location.href).toString(); - } - return null; -} - -function rowResourceUrl(row) { - var rowId = row.getAttribute("data-row"); - if (!rowId) { - return null; - } - var url = tableBaseUrl(); - url.pathname = url.pathname.replace(/\/$/, "") + "/" + rowId; - return url; -} - -function rowJsonUrl(row) { - var url = rowResourceUrl(row); - if (!url) { - return ""; - } - url.pathname = url.pathname + ".json"; - url.searchParams.set("_extra", "columns,column_types,column_details"); - return url.toString(); -} - -function rowDeleteUrl(row) { - var url = rowResourceUrl(row); - if (!url) { - return ""; - } - url.pathname = url.pathname.replace(/\/$/, "") + "/-/delete"; - if (isRowPage()) { - url.searchParams.set("_redirect_to_table", "1"); - } - return url.toString(); -} - -function rowUpdateUrl(row) { - var url = rowResourceUrl(row); - if (!url) { - return ""; - } - url.pathname = url.pathname.replace(/\/$/, "") + "/-/update"; - if (isRowPage()) { - url.searchParams.set("_message", "1"); - } - return url.toString(); -} - -function rowFragmentUrl(row) { - var rowId = row.getAttribute("data-row"); - return rowFragmentUrlById(rowId); -} - -function rowFragmentUrlById(rowId) { - if (!rowId) { - return ""; - } - var url = tableBaseUrl(); - url.search = new URL(location.href).search; - url.pathname = url.pathname.replace(/\/$/, "") + "/-/fragment"; - url.searchParams.delete("_next"); - url.searchParams.set("_row", rowId); - url.searchParams.set("_nocount", "1"); - url.searchParams.set("_nofacet", "1"); - url.searchParams.set("_nosuggest", "1"); - return url.toString(); -} - -function nextRowActionFocusTarget(row, action) { - var selector = 'button[data-row-action="' + action + '"]:not([disabled])'; - var sibling = row.nextElementSibling; - while (sibling) { - var nextButton = sibling.querySelector(selector); - if (nextButton) { - return nextButton; - } - sibling = sibling.nextElementSibling; - } - - sibling = row.previousElementSibling; - while (sibling) { - var previousButton = sibling.querySelector(selector); - if (previousButton) { - return previousButton; - } - sibling = sibling.previousElementSibling; - } - - return null; -} - -function nextRowDeleteFocusTarget(row, manager) { - return ( - nextRowActionFocusTarget(row, "delete") || ensureRowMutationStatus(manager) - ); -} - -function ensureRowDeleteDialog(manager) { - if (rowDeleteDialogState) { - return rowDeleteDialogState; - } - if (!window.HTMLDialogElement) { - return null; - } - - var dialog = document.createElement("dialog"); - dialog.id = ROW_DELETE_DIALOG_ID; - dialog.className = "row-delete-dialog"; - dialog.setAttribute("aria-labelledby", "row-delete-title"); - dialog.setAttribute("aria-describedby", "row-delete-message"); - dialog.innerHTML = ` - -

    Delete row ?

    - - - `; - document.body.appendChild(dialog); - - rowDeleteDialogState = { - dialog: dialog, - title: dialog.querySelector(".modal-title"), - message: dialog.querySelector(".row-delete-message"), - rowId: dialog.querySelector(".row-delete-id"), - error: dialog.querySelector(".row-delete-error"), - cancelButton: dialog.querySelector(".row-delete-cancel"), - confirmButton: dialog.querySelector(".row-delete-confirm"), - currentRow: null, - currentDeleteUrl: null, - currentPkPath: null, - manager: manager, - isBusy: false, - shouldRestoreFocus: true, - }; - - rowDeleteDialogState.cancelButton.addEventListener("click", function () { - if (!rowDeleteDialogState.isBusy) { - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - } - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog && !rowDeleteDialogState.isBusy) { - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if ( - ev.key === "Enter" && - document.activeElement === rowDeleteDialogState.confirmButton - ) { - ev.preventDefault(); - if (!rowDeleteDialogState.isBusy) { - rowDeleteDialogState.confirmButton.click(); - } - return; - } - if (ev.key !== "Escape") { - return; - } - if (rowDeleteDialogState.isBusy) { - ev.preventDefault(); - return; - } - ev.preventDefault(); - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - }); - - dialog.addEventListener("cancel", function (ev) { - if (rowDeleteDialogState.isBusy) { - ev.preventDefault(); - } else { - rowDeleteDialogState.shouldRestoreFocus = true; - } - }); - - dialog.addEventListener("close", function () { - var state = rowDeleteDialogState; - clearRowDeleteDialogError(state); - setRowDeleteDialogBusy(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } - }); - - rowDeleteDialogState.confirmButton.addEventListener( - "click", - async function () { - var state = rowDeleteDialogState; - clearRowDeleteDialogError(state); - setRowDeleteDialogBusy(state, true); - - try { - var response = await fetch(state.currentDeleteUrl, { - method: "POST", - headers: { - Accept: "application/json", - }, - }); - var data = null; - try { - data = await response.json(); - } catch (_error) { - data = null; - } - if (!response.ok || (data && data.ok === false)) { - throw rowMutationRequestError(response, data); - } - if (data && data.redirect) { - state.shouldRestoreFocus = false; - state.dialog.close(); - location.href = data.redirect; - return; - } - - var focusTarget = nextRowDeleteFocusTarget( - state.currentRow, - state.manager, - ); - var statusMessage = state.currentPkPath - ? "Deleted row " + state.currentPkPath + "." - : "Deleted row."; - state.shouldRestoreFocus = false; - state.dialog.close(); - state.currentRow.remove(); - showRowMutationStatus(state.manager, statusMessage, false); - if (focusTarget && document.contains(focusTarget)) { - focusTarget.focus(); - } else { - ensureRowMutationStatus(state.manager).focus(); - } - } catch (error) { - setRowDeleteDialogBusy(state, false); - showRowDeleteDialogError(state, error.message || "Delete failed"); - } - }, - ); - - return rowDeleteDialogState; -} - -function openRowDeleteDialog(button, manager) { - var row = rowElementForActionButton(button); - if (!row || !row.getAttribute("data-row")) { - return; - } - var state = ensureRowDeleteDialog(manager); - if (!state) { - return; - } - - state.manager = manager; - state.currentButton = button; - state.currentRow = row; - state.currentDeleteUrl = rowDeleteUrl(row); - state.currentPkPath = rowDisplayLabel(row); - state.shouldRestoreFocus = true; - - clearRowDeleteDialogError(state); - setRowDeleteDialogBusy(state, false); - setRowDialogTitle( - state.title, - "Delete row", - state.currentPkPath || "this row", - rowTitleLabel(row), - ); - state.rowId.textContent = state.currentPkPath || "this row"; - - if (!state.dialog.open) { - state.dialog.showModal(); - } - state.confirmButton.focus(); -} - -function initRowDeleteActions(manager) { - if (!window.fetch || !window.HTMLDialogElement) { - return; - } - document.addEventListener("click", function (ev) { - var button = ev.target.closest('button[data-row-action="delete"]'); - if (!button) { - return; - } - ev.preventDefault(); - openRowDeleteDialog(button, manager); - }); -} - -function isBase64JsonValue(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - var keys = Object.keys(value); - return ( - keys.length === 2 && - Object.prototype.hasOwnProperty.call(value, "$base64") && - Object.prototype.hasOwnProperty.call(value, "encoded") && - value.$base64 === true && - typeof value.encoded === "string" - ); -} - -function shouldUseBinaryControl(value, options) { - options = options || {}; - var sqliteType = (options.sqliteType || "").toLowerCase(); - return ( - isBase64JsonValue(value) || - sqliteType === "blob" || - options.valueKind === "binary" - ); -} - -function binaryEncodedValue(value) { - return isBase64JsonValue(value) ? value.encoded : ""; -} - -function binaryByteLengthFromBase64(encoded) { - encoded = (encoded || "").replace(/\s/g, ""); - if (!encoded) { - return 0; - } - var padding = 0; - if (encoded.slice(-2) === "==") { - padding = 2; - } else if (encoded.slice(-1) === "=") { - padding = 1; - } - return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding); -} - -function rowEditBinaryValue(encoded) { - return { - $base64: true, - encoded: encoded || "", - }; -} - -function formatRowEditBinarySize(byteLength) { - var number = byteLength.toLocaleString(); - return "Binary: " + number + " byte" + (byteLength === 1 ? "" : "s"); -} - -function base64ToUint8Array(encoded) { - var binary = window.atob(encoded || ""); - var bytes = new Uint8Array(binary.length); - for (var i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -function uint8ArrayToBase64(bytes) { - var chunks = []; - var chunkSize = 0x8000; - for (var i = 0; i < bytes.length; i += chunkSize) { - chunks.push( - String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)), - ); - } - return window.btoa(chunks.join("")); -} - -function rowEditBinaryImageMimeType(bytes) { - if (!bytes || !bytes.length) { - return null; - } - if ( - bytes.length >= 8 && - bytes[0] === 0x89 && - bytes[1] === 0x50 && - bytes[2] === 0x4e && - bytes[3] === 0x47 && - bytes[4] === 0x0d && - bytes[5] === 0x0a && - bytes[6] === 0x1a && - bytes[7] === 0x0a - ) { - return "image/png"; - } - if ( - bytes.length >= 3 && - bytes[0] === 0xff && - bytes[1] === 0xd8 && - bytes[2] === 0xff - ) { - return "image/jpeg"; - } - if ( - bytes.length >= 6 && - bytes[0] === 0x47 && - bytes[1] === 0x49 && - bytes[2] === 0x46 && - bytes[3] === 0x38 && - (bytes[4] === 0x37 || bytes[4] === 0x39) && - bytes[5] === 0x61 - ) { - return "image/gif"; - } - if ( - bytes.length >= 12 && - bytes[0] === 0x52 && - bytes[1] === 0x49 && - bytes[2] === 0x46 && - bytes[3] === 0x46 && - bytes[8] === 0x57 && - bytes[9] === 0x45 && - bytes[10] === 0x42 && - bytes[11] === 0x50 - ) { - return "image/webp"; - } - if ( - bytes.length >= 12 && - bytes[4] === 0x66 && - bytes[5] === 0x74 && - bytes[6] === 0x79 && - bytes[7] === 0x70 && - bytes[8] === 0x61 && - bytes[9] === 0x76 && - bytes[10] === 0x69 && - (bytes[11] === 0x66 || bytes[11] === 0x73) - ) { - return "image/avif"; - } - if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) { - return "image/bmp"; - } - return null; -} - -function valueToEditText(value) { - if (value === null || typeof value === "undefined") { - return ""; - } - if (isBase64JsonValue(value)) { - return value.encoded; - } - if (typeof value === "object") { - return JSON.stringify(value, null, 2); - } - return String(value); -} - -function shouldUseTextarea(value, columnType) { - if (columnType && columnType.type === "textarea") { - return true; - } - if (isBase64JsonValue(value)) { - return false; - } - if (value && typeof value === "object") { - return true; - } - var text = valueToEditText(value); - return text.length > 80 || /[\r\n]/.test(text); -} - -function rowEditValueKind(value) { - if (isBase64JsonValue(value)) { - return "binary"; - } - if (value === null || typeof value === "undefined") { - return "null"; - } - if (typeof value === "number") { - return "number"; - } - if (typeof value === "boolean") { - return "boolean"; - } - return "string"; -} - -function rowEditControlElement(control, autocompleteUrl) { - if (!autocompleteUrl || control.nodeName !== "INPUT") { - return control; - } - var autocomplete = document.createElement("datasette-autocomplete"); - autocomplete.setAttribute("src", autocompleteUrl); - autocomplete.setAttribute("suggest-on-focus", ""); - autocomplete.appendChild(control); - return autocomplete; -} - -function revokeRowEditBinaryPreview(wrapper) { - if (wrapper && wrapper._rowEditBinaryPreviewUrl) { - URL.revokeObjectURL(wrapper._rowEditBinaryPreviewUrl); - wrapper._rowEditBinaryPreviewUrl = null; - } -} - -function updateRowEditBinaryPreview(wrapper, encoded, byteLength) { - var preview = wrapper.querySelector(".row-edit-binary-preview"); - if (!preview) { - return; - } - revokeRowEditBinaryPreview(wrapper); - preview.hidden = true; - preview.textContent = ""; - if ( - !encoded || - byteLength >= ROW_EDIT_BINARY_IMAGE_PREVIEW_MAX_BYTES || - !window.atob || - !window.Blob || - !window.URL || - !URL.createObjectURL - ) { - return; - } - - var bytes; - try { - bytes = base64ToUint8Array(encoded); - } catch (_error) { - return; - } - var mimeType = rowEditBinaryImageMimeType(bytes); - if (!mimeType) { - return; - } - - var image = document.createElement("img"); - image.alt = ""; - var objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType })); - wrapper._rowEditBinaryPreviewUrl = objectUrl; - - var showPreview = function () { - if (wrapper._rowEditBinaryPreviewUrl === objectUrl) { - preview.hidden = false; - } - }; - var hidePreview = function () { - if (wrapper._rowEditBinaryPreviewUrl === objectUrl) { - revokeRowEditBinaryPreview(wrapper); - preview.hidden = true; - preview.textContent = ""; - } - }; - image.onload = showPreview; - image.onerror = hidePreview; - image.src = objectUrl; - preview.appendChild(image); -} - -function updateRowEditBinaryDisplay(wrapper, control, fileName) { - var kind = rowEditControlValueKind(control); - var size = wrapper.querySelector(".row-edit-binary-size"); - var name = wrapper.querySelector(".row-edit-binary-name"); - var clear = wrapper.querySelector(".row-edit-binary-clear"); - var byteLength = - kind === "binary" ? binaryByteLengthFromBase64(control.value) : null; - - if (size) { - size.textContent = - kind === "binary" - ? formatRowEditBinarySize(byteLength) - : "No binary data"; - } - if (name) { - name.textContent = fileName || ""; - name.hidden = !fileName; - } - if (clear) { - clear.hidden = control.dataset.notNull === "1" || kind !== "binary"; - } - if (kind === "binary") { - updateRowEditBinaryPreview(wrapper, control.value, byteLength); - } else { - updateRowEditBinaryPreview(wrapper, "", 0); - } -} - -function setRowEditBinaryControlValue(control, encoded, fileName) { - control.value = encoded || ""; - control.dataset.currentValueKind = "binary"; - updateRowEditBinaryDisplay(control._rowEditBinaryWrapper, control, fileName); - control.dispatchEvent(new Event("input", { bubbles: true })); -} - -function clearRowEditBinaryControlValue(control) { - control.value = ""; - control.dataset.currentValueKind = "null"; - updateRowEditBinaryDisplay(control._rowEditBinaryWrapper, control, ""); - control.dispatchEvent(new Event("input", { bubbles: true })); -} - -function readRowEditBinaryFile(file) { - return new Promise(function (resolve, reject) { - var reader = new FileReader(); - reader.onload = function () { - try { - var bytes = new Uint8Array(reader.result || new ArrayBuffer(0)); - resolve({ - encoded: uint8ArrayToBase64(bytes), - name: file && file.name ? file.name : "", - }); - } catch (error) { - reject(error); - } - }; - reader.onerror = function () { - reject(reader.error || new Error("Could not read file")); - }; - reader.readAsArrayBuffer(file); - }); -} - -function rowEditBinaryValueFromText(text) { - var encoder = new TextEncoder(); - var bytes = encoder.encode(text || ""); - return { - encoded: uint8ArrayToBase64(bytes), - name: "Pasted text", - }; -} - -function rowEditBinaryFirstClipboardFile(clipboardData) { - if (!clipboardData) { - return null; - } - if (clipboardData.files && clipboardData.files.length) { - return clipboardData.files[0]; - } - var items = clipboardData.items || []; - for (var i = 0; i < items.length; i += 1) { - if (items[i].kind === "file") { - return items[i].getAsFile(); - } - } - return null; -} - -function handleRowEditBinaryFile(control, file) { - if (!file) { - return; - } - readRowEditBinaryFile(file) - .then(function (value) { - setRowEditBinaryControlValue(control, value.encoded, value.name); - }) - .catch(function (error) { - console.error("Could not read binary file", error); - }); -} - -function createRowEditBinaryControlElement(control, value, options, labelId) { - var wrapper = document.createElement("div"); - wrapper.className = "row-edit-binary-control"; - wrapper.dataset.column = control.name; - wrapper.setAttribute("role", "group"); - wrapper.setAttribute("tabindex", "0"); - wrapper.setAttribute("aria-labelledby", labelId); - - var preview = document.createElement("div"); - preview.className = "row-edit-binary-preview"; - preview.hidden = true; - - var status = document.createElement("div"); - status.className = "row-edit-binary-status"; - var size = document.createElement("span"); - size.className = "row-edit-binary-size"; - var name = document.createElement("span"); - name.className = "row-edit-binary-name"; - name.hidden = true; - status.appendChild(size); - status.appendChild(name); - - var actions = document.createElement("div"); - actions.className = "row-edit-binary-actions"; - var fileLabel = document.createElement("label"); - fileLabel.className = "row-edit-binary-file-button"; - fileLabel.textContent = "Attach file"; - var fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.setAttribute("aria-label", "Attach file for " + control.name); - fileInput.addEventListener("change", function () { - if (fileInput.files && fileInput.files.length) { - handleRowEditBinaryFile(control, fileInput.files[0]); - } - }); - fileLabel.appendChild(fileInput); - actions.appendChild(fileLabel); - - var clearButton = document.createElement("button"); - clearButton.type = "button"; - clearButton.className = "row-edit-binary-clear"; - clearButton.textContent = "Set NULL"; - clearButton.addEventListener("click", function () { - clearRowEditBinaryControlValue(control); - wrapper.focus(); - }); - actions.appendChild(clearButton); - - var dropTarget = document.createElement("div"); - dropTarget.className = "row-edit-binary-drop-target"; - dropTarget.textContent = "Drop or paste file contents"; - ["dragenter", "dragover"].forEach(function (eventName) { - wrapper.addEventListener(eventName, function (ev) { - ev.preventDefault(); - wrapper.classList.add("row-edit-binary-dragover"); - }); - }); - ["dragleave", "drop"].forEach(function (eventName) { - wrapper.addEventListener(eventName, function () { - wrapper.classList.remove("row-edit-binary-dragover"); - }); - }); - wrapper.addEventListener("drop", function (ev) { - ev.preventDefault(); - var file = ev.dataTransfer && ev.dataTransfer.files[0]; - handleRowEditBinaryFile(control, file); - }); - wrapper.addEventListener("paste", function (ev) { - var file = rowEditBinaryFirstClipboardFile(ev.clipboardData); - if (file) { - ev.preventDefault(); - handleRowEditBinaryFile(control, file); - return; - } - var text = ev.clipboardData && ev.clipboardData.getData("text"); - if (text) { - ev.preventDefault(); - var pasted = rowEditBinaryValueFromText(text); - setRowEditBinaryControlValue(control, pasted.encoded, pasted.name); - } - }); - - control.type = "hidden"; - control._rowEditBinaryWrapper = wrapper; - wrapper.appendChild(control); - wrapper.appendChild(preview); - wrapper.appendChild(status); - wrapper.appendChild(actions); - wrapper.appendChild(dropTarget); - - updateRowEditBinaryDisplay(wrapper, control, ""); - return wrapper; -} - -function columnTypeForContext(columnType) { - if (!columnType) { - return null; - } - return { - type: columnType.type, - config: columnType.config || {}, - }; -} - -function defaultExpressionForContext(expression) { - if (expression === null || typeof expression === "undefined") { - return null; - } - return expression; -} - -function columnFormControlContext(column, isPk, columnType, options) { - var pageData = tablePageData(); - var defaultExpression = defaultExpressionForContext( - options.defaultExpression, - ); - return { - mode: options.mode || "edit", - database: pageData.database || null, - table: - pageData.table || - (tableInsertData() && tableInsertData().tableName) || - null, - tableUrl: pageData.tableUrl || null, - column: column, - columnType: columnTypeForContext(columnType), - sqliteType: options.sqliteType || null, - notNull: !!options.notnull, - isPk: !!isPk, - defaultExpression: defaultExpression, - form: options.form || null, - dialog: options.dialog || null, - }; -} - -function makeColumnField(manager, context) { - if (!manager || !manager.makeColumnField) { - return null; - } - return manager.makeColumnField(context); -} - -function createColumnFieldApi(options) { - var control = options.control; - var context = options.context; - var field = { - context: context, - id: options.id, - labelId: options.labelId, - descriptionId: options.descriptionId, - root: null, - form: options.form || null, - dialog: options.dialog || null, - input: control, - control: control, - meta: options.meta || null, - validationMessageElement: null, - getValue: function () { - return valueFromRowEditControl(control); - }, - setValue: function (value) { - if ( - value !== null && - typeof value !== "undefined" && - typeof value === "object" - ) { - throw new TypeError( - "field.setValue() accepts strings, numbers, booleans or null; serialize objects before setting the field value", - ); - } - field.stopUsingSqliteDefault(); - control.value = valueToEditText(value); - control.dataset.currentValueKind = rowEditValueKind(value); - }, - getInitialValue: function () { - return initialValueFromRowEditControl(control); - }, - hasChanged: function () { - return rowEditControlHasChanged(control); - }, - clearValue: function () { - field.setValue(null); - }, - isUsingSqliteDefault: function () { - return control.dataset.useSqliteDefault === "1"; - }, - useSqliteDefault: function () { - if ( - context.defaultExpression === null || - typeof context.defaultExpression === "undefined" - ) { - return; - } - control.dataset.useSqliteDefault = "1"; - control.disabled = true; - control.value = ""; - control.dataset.currentValueKind = "null"; - field.syncSqliteDefaultUi(); - }, - stopUsingSqliteDefault: function () { - if (control.dataset.useSqliteDefault !== "1") { - return; - } - control.dataset.useSqliteDefault = "0"; - control.disabled = false; - field.syncSqliteDefaultUi(); - }, - syncSqliteDefaultUi: function () {}, - markClean: function () { - markRowEditControlClean(control); - }, - setValidity: function (message) { - message = message || ""; - control.setCustomValidity(message); - if (message) { - control.setAttribute("aria-invalid", "true"); - } else { - control.removeAttribute("aria-invalid"); - } - var validationMessage = ensureColumnFieldValidationMessage(field); - if (validationMessage) { - validationMessage.textContent = message; - validationMessage.hidden = !message; - } - }, - clearValidity: function () { - field.setValidity(""); - }, - }; - field.markClean(); - return field; -} - -function ensureColumnFieldValidationMessage(field) { - if (field.validationMessageElement) { - return field.validationMessageElement; - } - if (!field.meta) { - return null; - } - var validationMessage = document.createElement("span"); - validationMessage.id = field.id + "-validation-error"; - validationMessage.className = "row-edit-field-validation-error"; - validationMessage.hidden = true; - validationMessage.setAttribute("role", "alert"); - field.meta.appendChild(validationMessage); - field.validationMessageElement = validationMessage; - return validationMessage; -} - -function renderColumnField(pluginControl, fieldApi) { - if (!pluginControl || !pluginControl.render) { - return null; - } - var pluginWrap = document.createElement("div"); - pluginWrap.className = "row-edit-plugin-control"; - pluginWrap.dataset.pluginName = pluginControl.pluginName || ""; - pluginWrap.dataset.column = fieldApi.context.column; - if (fieldApi.context.columnType && fieldApi.context.columnType.type) { - pluginWrap.dataset.columnType = fieldApi.context.columnType.type; - } - fieldApi.root = pluginWrap; - try { - var rendered = pluginControl.render(fieldApi); - if (rendered && rendered.nodeType) { - pluginWrap.appendChild(rendered); - } - } catch (error) { - console.error("Error rendering column form control", error); - return null; - } - pluginWrap._datasetteColumnField = pluginControl; - pluginWrap._datasetteColumnFormField = fieldApi; - return pluginWrap; -} - -function validateJsonColumnField(field) { - var value = field.input.value; - if (value.trim() === "") { - field.clearValidity(); - return true; - } - try { - JSON.parse(value); - field.clearValidity(); - return true; - } catch (error) { - field.setValidity( - "Invalid JSON" + (error && error.message ? ": " + error.message : ""), - ); - return false; - } -} - -function registerBuiltinColumnFieldPlugins(manager) { - if (!manager || !manager.registerPlugin) { - return; - } - manager.registerPlugin("datasette-json-column", { - version: "1.0", - makeColumnField: function (context) { - if (!context.columnType || context.columnType.type !== "json") { - return; - } - return { - useTextarea: true, - render: function (field) { - field.input.addEventListener("input", function () { - validateJsonColumnField(field); - }); - field.input.addEventListener("change", function () { - validateJsonColumnField(field); - }); - validateJsonColumnField(field); - return field.input; - }, - focus: function (field) { - field.input.focus(); - }, - }; - }, - }); -} - -function focusRowEditPluginControl(field) { - var pluginWrap = field.querySelector(".row-edit-plugin-control"); - if (!pluginWrap) { - return false; - } - var pluginControl = pluginWrap._datasetteColumnField; - var fieldApi = pluginWrap._datasetteColumnFormField; - if (pluginControl && pluginControl.focus) { - try { - pluginControl.focus(fieldApi); - return true; - } catch (error) { - console.error("Error focusing column form control", error); - } - } - return false; -} - -function focusFirstRowEditControl(state, options) { - options = options || {}; - var fields = state.fields.querySelectorAll(".row-edit-field"); - for (var i = 0; i < fields.length; i += 1) { - var field = fields[i]; - var control = field.querySelector(".row-edit-input"); - if (!control) { - continue; - } - if (options.skipReadonly && (control.readOnly || control.disabled)) { - continue; - } - if (focusRowEditPluginControl(field)) { - return true; - } - var binaryControl = field.querySelector(".row-edit-binary-control"); - if (binaryControl) { - binaryControl.focus(); - return true; - } - control.focus(); - return true; - } - return false; -} - -function destroyRowEditFields(state) { - if (!state || !state.fields) { - return; - } - state.fields - .querySelectorAll(".row-edit-plugin-control") - .forEach(function (pluginWrap) { - var pluginControl = pluginWrap._datasetteColumnField; - var fieldApi = pluginWrap._datasetteColumnFormField; - if (pluginControl && pluginControl.destroy) { - try { - pluginControl.destroy(fieldApi); - } catch (error) { - console.error("Error destroying column form control", error); - } - } - }); - state.fields - .querySelectorAll(".row-edit-binary-control") - .forEach(function (binaryControl) { - revokeRowEditBinaryPreview(binaryControl); - }); - state.fields.innerHTML = ""; -} - -function createRowEditField(column, value, isPk, columnType, index, options) { - options = options || {}; - var field = document.createElement("div"); - field.className = "row-edit-field"; - var defaultExpression = defaultExpressionForContext( - options.defaultExpression, - ); - var hasDefaultExpression = defaultExpression !== null; - var useSqliteDefault = hasDefaultExpression && options.useSqliteDefault; - - var fieldId = "row-edit-field-" + index; - var metaId = "row-edit-field-meta-" + index; - var labelId = "row-edit-field-label-" + index; - var label = document.createElement("label"); - label.className = "row-edit-label"; - label.id = labelId; - label.setAttribute("for", fieldId); - label.textContent = column; - - var controlWrap = document.createElement("div"); - controlWrap.className = "row-edit-control-wrap"; - - var context = columnFormControlContext(column, isPk, columnType, options); - var isBinaryField = shouldUseBinaryControl(value, options); - var pluginControl = isBinaryField - ? null - : makeColumnField(options.manager, context); - var useTextarea = - !isBinaryField && - ((pluginControl && pluginControl.useTextarea === true) || - shouldUseTextarea(value, columnType)); - var control = useTextarea - ? document.createElement("textarea") - : document.createElement("input"); - var initialValue = isBinaryField - ? binaryEncodedValue(value) - : valueToEditText(value); - var initialValueKind = options.valueKind || rowEditValueKind(value); - if (isBinaryField) { - initialValueKind = isBase64JsonValue(value) ? "binary" : "null"; - } - control.className = "row-edit-input"; - control.id = fieldId; - control.name = column; - control.value = initialValue; - control.setAttribute("aria-describedby", metaId); - control.dataset.initialValue = initialValue; - control.dataset.initialValueKind = initialValueKind; - control.dataset.primaryKey = isPk ? "1" : "0"; - control.dataset.currentValueKind = control.dataset.initialValueKind; - if (isBinaryField) { - control.dataset.binaryField = "1"; - control.dataset.notNull = options.notnull ? "1" : "0"; - } - if (hasDefaultExpression) { - control.dataset.useSqliteDefault = useSqliteDefault ? "1" : "0"; - } - if (useSqliteDefault) { - control.disabled = true; - } - if (options.omitIfBlank || (isBinaryField && options.mode === "insert")) { - control.dataset.omitIfBlank = "1"; - } - - if (isBinaryField) { - control.type = "hidden"; - } else if (control.nodeName === "TEXTAREA") { - control.rows = Math.min(8, Math.max(3, control.value.split("\n").length)); - } else { - control.type = "text"; - } - - if (isPk && options.primaryKeyReadonly !== false) { - control.readOnly = true; - } - - var meta = document.createElement("span"); - meta.id = metaId; - meta.className = "row-edit-field-meta"; - if (options.autocompleteUrl) { - meta.classList.add("row-edit-field-meta-autocomplete"); - meta.dataset.reserveSpace = "1"; - } - var metaParts = []; - if (isPk) { - metaParts.push("Primary key"); - } - if (options.notnull) { - metaParts.push("Required"); - } - if (hasDefaultExpression && !useSqliteDefault) { - metaParts.push("SQLite default: " + defaultExpression); - } - if (value === null) { - metaParts.push("Current value: NULL"); - control.placeholder = "NULL"; - } - if (columnType && columnType.type) { - metaParts.push("Custom type: " + columnType.type); - } - meta.dataset.baseMeta = metaParts.join(" · "); - var baseMeta = document.createElement("span"); - baseMeta.className = "row-edit-base-meta"; - baseMeta.textContent = meta.dataset.baseMeta; - meta.appendChild(baseMeta); - if (options.autocompleteUrl) { - var foreignKeyLinkWrap = document.createElement("span"); - foreignKeyLinkWrap.className = "row-edit-fk-link-wrap"; - foreignKeyLinkWrap.hidden = true; - var foreignKeySeparator = document.createElement("span"); - foreignKeySeparator.className = "row-edit-fk-separator"; - foreignKeySeparator.textContent = meta.dataset.baseMeta ? " · " : ""; - foreignKeySeparator.hidden = !meta.dataset.baseMeta; - foreignKeyLinkWrap.appendChild(foreignKeySeparator); - var foreignKeyPk = document.createElement("span"); - foreignKeyPk.className = "row-edit-fk-pk"; - foreignKeyLinkWrap.appendChild(foreignKeyPk); - foreignKeyLinkWrap.appendChild(document.createTextNode(" ")); - var foreignKeyLink = document.createElement("a"); - foreignKeyLink.className = "row-edit-fk-link"; - foreignKeyLink.target = "_blank"; - foreignKeyLink.rel = "noopener noreferrer"; - foreignKeyLinkWrap.appendChild(foreignKeyLink); - meta.appendChild(foreignKeyLinkWrap); - updateRowEditFieldMetaHidden(meta); - } - var fieldApi = createColumnFieldApi({ - id: fieldId, - labelId: labelId, - descriptionId: metaId, - control: control, - meta: meta, - input: control, - form: options.form || null, - dialog: options.dialog || null, - context: context, - }); - field._datasetteColumnFormField = fieldApi; - var pluginControlElement = renderColumnField(pluginControl, fieldApi); - var controlElement = - (isBinaryField && - createRowEditBinaryControlElement(control, value, options, labelId)) || - pluginControlElement || - rowEditControlElement(control, options.autocompleteUrl); - if (options.autocompleteUrl && !pluginControlElement && !isBinaryField) { - control.addEventListener("input", function () { - setForeignKeyMetaLink(meta, options.autocompleteUrl, null); - }); - control.addEventListener("change", function () { - resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta); - }); - controlElement.addEventListener( - "datasette-autocomplete-select", - function (ev) { - setForeignKeyMetaLink( - meta, - options.autocompleteUrl, - ev.detail && ev.detail.row, - ); - }, - ); - resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta); - } - - if (hasDefaultExpression) { - var defaultBlock = document.createElement("div"); - defaultBlock.className = "row-edit-default"; - defaultBlock.setAttribute("aria-describedby", metaId); - - var defaultText = document.createElement("span"); - defaultText.className = "row-edit-default-text"; - defaultText.appendChild(document.createTextNode("default ")); - var defaultCode = document.createElement("code"); - defaultCode.className = "row-edit-default-code"; - defaultCode.textContent = defaultExpression; - defaultText.appendChild(defaultCode); - - var setValueButton = document.createElement("button"); - setValueButton.type = "button"; - setValueButton.className = - "row-edit-default-button row-edit-default-set-value"; - setValueButton.textContent = "Set value"; - setValueButton.setAttribute("aria-label", "Set value for " + column); - - var customWrap = document.createElement("div"); - customWrap.className = "row-edit-custom-value"; - customWrap.hidden = true; - - var useSqliteDefaultButton = document.createElement("button"); - useSqliteDefaultButton.type = "button"; - useSqliteDefaultButton.className = "row-edit-default-button"; - useSqliteDefaultButton.textContent = "Use default"; - useSqliteDefaultButton.setAttribute( - "aria-label", - "Use SQLite default for " + column, - ); - - setValueButton.addEventListener("click", function () { - fieldApi.stopUsingSqliteDefault(); - control.focus(); - }); - - useSqliteDefaultButton.addEventListener("click", function () { - fieldApi.useSqliteDefault(); - setValueButton.focus(); - }); - - defaultBlock.appendChild(defaultText); - defaultBlock.appendChild(setValueButton); - customWrap.appendChild(controlElement); - customWrap.appendChild(useSqliteDefaultButton); - controlWrap.appendChild(defaultBlock); - controlWrap.appendChild(customWrap); - fieldApi.syncSqliteDefaultUi = function () { - var usingDefault = fieldApi.isUsingSqliteDefault(); - defaultBlock.hidden = !usingDefault; - customWrap.hidden = usingDefault; - }; - fieldApi.syncSqliteDefaultUi(); - } else { - controlWrap.appendChild(controlElement); - } - if (meta.textContent || options.autocompleteUrl) { - controlWrap.appendChild(meta); - } - field.appendChild(label); - field.appendChild(controlWrap); - return field; -} - -function clearRowEditDialogError(state) { - state.error.hidden = true; - state.error.textContent = ""; -} - -function showRowEditDialogError(state, message, options) { - state.error.hidden = false; - state.error.textContent = message; - if (!options || options.focus !== false) { - state.error.focus(); - } -} - -function rowEditIsMultipleInsert(state) { - return state.mode === "insert" && state.insertMode === "multiple"; -} - -function syncRowEditInsertModeUi(state) { - var isInsert = state.mode === "insert"; - var isMultiple = rowEditIsMultipleInsert(state); - state.fields.hidden = isMultiple; - state.bulkInsertPanel.hidden = !isMultiple; - state.bulkInsertEditor.hidden = !isMultiple || state.bulkInsertPreviewReady; - state.bulkInsertPreview.hidden = !isMultiple || !state.bulkInsertPreviewReady; - state.bulkInsertLink.hidden = !isInsert || isMultiple; - state.singleInsertLink.hidden = !isInsert || !isMultiple; -} - -function updateRowEditDialogButtons(state) { - state.saveButton.disabled = - state.isLoading || - state.isSaving || - !state.hasLoaded || - state.bulkInsertInserted || - (rowEditIsMultipleInsert(state) && - !state.bulkInsertPreviewReady && - !!state.bulkInsertLiveValidationError); - state.cancelButton.disabled = state.isSaving; - syncRowEditInsertModeUi(state); - state.cancelButton.textContent = - rowEditIsMultipleInsert(state) && - state.bulkInsertPreviewReady && - !state.bulkInsertInserted - ? "Back" - : state.bulkInsertInserted - ? "Close and view table" - : "Cancel"; - var saveLabel = rowEditIsMultipleInsert(state) - ? state.bulkInsertInserted - ? "Inserted" - : state.bulkInsertPreviewReady - ? bulkInsertSaveLabel(state) - : "Preview rows" - : state.mode === "insert" - ? "Insert row" - : "Save"; - state.saveButton.textContent = state.isSaving - ? rowEditIsMultipleInsert(state) - ? bulkInsertConflictMode(state) === "upsert" - ? "Updating..." - : "Inserting..." - : "Saving..." - : saveLabel; - state.form.setAttribute( - "aria-busy", - state.isLoading || state.isSaving ? "true" : "false", - ); -} - -function setRowEditDialogLoading(state, isLoading) { - state.isLoading = isLoading; - state.loading.hidden = !isLoading; - updateRowEditDialogButtons(state); -} - -function setRowEditDialogSaving(state, isSaving) { - state.isSaving = isSaving; - updateRowEditDialogButtons(state); -} - -function valueFromRowEditControl(control) { - var value = control.value; - if (rowEditControlValueKind(control) === "binary") { - return rowEditBinaryValue(value); - } - return valueFromRowEditText( - control.name, - value, - rowEditControlValueKind(control), - ); -} - -function valueFromRowEditText(name, value, initialValueKind) { - var trimmed = value.trim(); - - if (initialValueKind === "binary") { - return rowEditBinaryValue(value); - } - if (initialValueKind === "null" && value === "") { - return null; - } - if (initialValueKind === "number") { - if (trimmed === "") { - return null; - } - var numberValue = Number(trimmed); - if (Number.isNaN(numberValue)) { - throw new Error(name + " must be a number"); - } - return numberValue; - } - if (initialValueKind === "boolean") { - if (/^(true|1|yes)$/i.test(trimmed)) { - return true; - } - if (/^(false|0|no)$/i.test(trimmed)) { - return false; - } - throw new Error(name + " must be true or false"); - } - return value; -} - -function initialValueFromRowEditControl(control) { - return valueFromRowEditText( - control.name, - control.dataset.initialValue || "", - control.dataset.initialValueKind || "string", - ); -} - -function rowEditControlValueKind(control) { - return ( - control.dataset.currentValueKind || - control.dataset.initialValueKind || - "string" - ); -} - -function rowEditControlCleanValue(control) { - if (Object.prototype.hasOwnProperty.call(control.dataset, "cleanValue")) { - return control.dataset.cleanValue; - } - return control.dataset.initialValue || ""; -} - -function rowEditControlCleanValueKind(control) { - return ( - control.dataset.cleanValueKind || - control.dataset.initialValueKind || - "string" - ); -} - -function rowEditControlCleanUsesSqliteDefault(control) { - if ( - Object.prototype.hasOwnProperty.call( - control.dataset, - "cleanUseSqliteDefault", - ) - ) { - return control.dataset.cleanUseSqliteDefault === "1"; - } - return false; -} - -function markRowEditControlClean(control) { - control.dataset.cleanValue = control.value; - control.dataset.cleanValueKind = rowEditControlValueKind(control); - control.dataset.cleanUseSqliteDefault = - control.dataset.useSqliteDefault === "1" ? "1" : "0"; -} - -function cleanValueFromRowEditControl(control) { - return valueFromRowEditText( - control.name, - rowEditControlCleanValue(control), - rowEditControlCleanValueKind(control), - ); -} - -function rowEditValuesMatch(left, right) { - if (left === right) { - return true; - } - if (left && right && typeof left === "object" && typeof right === "object") { - return JSON.stringify(left) === JSON.stringify(right); - } - return false; -} - -function rowEditControlHasChanged(control) { - var usingSqliteDefault = control.dataset.useSqliteDefault === "1"; - var cleanUsesSqliteDefault = rowEditControlCleanUsesSqliteDefault(control); - if (usingSqliteDefault || cleanUsesSqliteDefault) { - return usingSqliteDefault !== cleanUsesSqliteDefault; - } - if ( - control.value === rowEditControlCleanValue(control) && - rowEditControlValueKind(control) === rowEditControlCleanValueKind(control) - ) { - return false; - } - try { - return !rowEditValuesMatch( - valueFromRowEditControl(control), - cleanValueFromRowEditControl(control), - ); - } catch (_error) { - return true; - } -} - -function collectRowFormValues(state) { - var values = {}; - state.fields.querySelectorAll(".row-edit-input").forEach(function (control) { - if ( - state.mode === "edit" && - (control.readOnly || control.dataset.primaryKey === "1") - ) { - return; - } - if (control.dataset.useSqliteDefault === "1") { - return; - } - if ( - control.dataset.omitIfBlank === "1" && - control.value === "" && - rowEditControlValueKind(control) !== "binary" - ) { - return; - } - if ( - state.mode === "edit" && - control.value === (control.dataset.initialValue || "") && - (control.dataset.currentValueKind || - control.dataset.initialValueKind || - "string") === (control.dataset.initialValueKind || "string") - ) { - return; - } - var value = valueFromRowEditControl(control); - if (state.mode === "edit") { - try { - if ( - rowEditValuesMatch(value, initialValueFromRowEditControl(control)) - ) { - return; - } - } catch (_error) { - // If the original value cannot be parsed using the field's current - // type, treat the field as changed and submit the corrected value. - } - } - values[control.name] = value; - }); - return values; -} - -function rowEditDialogHasChanges(state) { - if (!state || !state.hasLoaded || state.isLoading) { - return false; - } - if (state.bulkInsertInserted) { - return false; - } - if ( - state.mode === "insert" && - state.bulkInsertTextarea && - state.bulkInsertTextarea.value.trim() - ) { - return true; - } - var fields = state.fields.querySelectorAll(".row-edit-field"); - for (var i = 0; i < fields.length; i += 1) { - var fieldApi = fields[i]._datasetteColumnFormField; - if (fieldApi && fieldApi.hasChanged && fieldApi.hasChanged()) { - return true; - } - } - return false; -} - -function confirmDiscardRowEditChanges(state) { - if (!rowEditDialogHasChanges(state)) { - return true; - } - var message = - state.mode === "insert" - ? "Discard this new row?" - : "Discard unsaved changes to this row?"; - return window.confirm(message); -} - -function closeRowEditDialogIfConfirmed(state) { - if (!state || state.isSaving) { - return false; - } - if (!confirmDiscardRowEditChanges(state)) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - -function setRowInsertDialogTitle(state) { - var insertData = tableInsertData() || {}; - var title = rowEditIsMultipleInsert(state) - ? "Insert multiple rows" - : "Insert row"; - setRowDialogTitle( - state.title, - insertData.tableName ? title + " into " + insertData.tableName : title, - ); -} - -function showMultipleRowInsert(state) { - if (!state || state.mode !== "insert" || state.isSaving) { - return; - } - state.insertMode = "multiple"; - if (state.bulkInsertPreviewReady || state.bulkInsertInserted) { - resetBulkInsertPreview(state); - } - clearRowEditDialogError(state); - setRowInsertDialogTitle(state); - syncBulkInsertConflictUi(state); - syncBulkInsertTextareaValidation(state); - updateRowEditDialogButtons(state); - state.bulkInsertTextarea.focus(); -} - -function showSingleRowInsert(state) { - if (!state || state.mode !== "insert" || state.isSaving) { - return; - } - state.insertMode = "single"; - clearRowEditDialogError(state); - setRowInsertDialogTitle(state); - updateRowEditDialogButtons(state); - if (!focusFirstRowEditControl(state, { skipReadonly: true })) { - state.saveButton.focus(); - } -} - -function bulkInsertConflictMode(state) { - if (!state || !state.bulkInsertHasPrimaryKeyColumns) { - return "insert"; - } - return state.bulkInsertConflictMode || "ignore"; -} - -function syncBulkInsertConflictUi(state) { - if (!state || !state.bulkInsertConflictField) { - return; - } - var insertData = tableInsertData() || {}; - var primaryKeys = insertData.primaryKeys || []; - var hasPrimaryKeys = primaryKeys.length > 0; - var hasPrimaryKeyColumns = - hasPrimaryKeys && - bulkInsertTextIncludesPrimaryKeyColumns( - state.bulkInsertTextarea.value, - state.bulkInsertColumnDetails, - primaryKeys, - ); - state.bulkInsertHasPrimaryKeyColumns = hasPrimaryKeyColumns; - var canUpsert = hasPrimaryKeyColumns && !!state.currentUpsertUrl; - var upsertOption = state.bulkInsertConflictSelect.querySelector( - 'option[value="upsert"]', - ); - if (upsertOption) { - upsertOption.disabled = !canUpsert; - upsertOption.hidden = !canUpsert; - } - if ( - !hasPrimaryKeyColumns || - (!canUpsert && bulkInsertConflictMode(state) === "upsert") - ) { - state.bulkInsertConflictMode = hasPrimaryKeys ? "ignore" : "insert"; - state.bulkInsertConflictSelect.value = state.bulkInsertConflictMode; - } - state.bulkInsertConflictField.hidden = !hasPrimaryKeyColumns; - state.bulkInsertConflictSelect.value = bulkInsertConflictMode(state); - var helpText = ""; - if (bulkInsertConflictMode(state) === "upsert") { - helpText = - "Rows with existing primary keys will be updated; new primary keys will be inserted."; - } else if (bulkInsertConflictMode(state) === "ignore") { - helpText = "Rows with existing primary keys will be skipped."; - } else { - helpText = "Rows with existing primary keys will stop the import."; - } - state.bulkInsertConflictHelp.textContent = helpText; -} - -function bulkInsertSaveLabel(state) { - if (!state.bulkInsertPreviewReady) { - return "Preview rows"; - } - if (bulkInsertConflictMode(state) === "upsert") { - return "Update or insert rows"; - } - return "Insert these rows"; -} - -function readTextFile(file) { - if (file.text) { - return file.text(); - } - return new Promise(function (resolve, reject) { - var reader = new FileReader(); - reader.onload = function () { - resolve(reader.result || ""); - }; - reader.onerror = function () { - reject(reader.error); - }; - reader.readAsText(file); - }); -} - -async function loadBulkInsertTextFile(state, file) { - if (!file) { - return; - } - try { - state.bulkInsertTextarea.value = await readTextFile(file); - state.bulkInsertTextarea.dispatchEvent( - new Event("input", { bubbles: true }), - ); - state.bulkInsertTextarea.focus(); - } catch (_error) { - showRowEditDialogError(state, "Could not read that text file."); - } -} - -function bulkInsertTemplateText(state) { - return (state.bulkInsertTemplateColumns || []).join("\t"); -} - -async function copyTextToClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - return; - } - var textarea = document.createElement("textarea"); - textarea.value = text; - textarea.setAttribute("readonly", ""); - textarea.style.position = "fixed"; - textarea.style.top = "-1000px"; - textarea.style.left = "-1000px"; - document.body.appendChild(textarea); - textarea.select(); - var copied = document.execCommand("copy"); - textarea.remove(); - if (!copied) { - throw new Error("copy failed"); - } -} - -function setBulkInsertCopyButtonReady(state) { - state.copyTemplateButton.textContent = ""; - var wideLabel = document.createElement("span"); - wideLabel.className = "row-edit-copy-template-label-wide"; - wideLabel.textContent = "Copy spreadsheet template"; - state.copyTemplateButton.appendChild(wideLabel); - var narrowLabel = document.createElement("span"); - narrowLabel.className = "row-edit-copy-template-label-narrow"; - narrowLabel.textContent = "Copy template"; - state.copyTemplateButton.appendChild(narrowLabel); -} - -function setBulkInsertCopyButtonCopied(state) { - state.copyTemplateButton.textContent = "Copied"; - clearTimeout(state.copyTemplateResetTimer); - state.copyTemplateResetTimer = setTimeout(function () { - setBulkInsertCopyButtonReady(state); - }, 1500); -} - -function resetBulkInsertPreview(state) { - state.bulkInsertPreviewRows = null; - state.bulkInsertPreviewReady = false; - state.bulkInsertInserted = false; - state.bulkInsertInsertedCount = 0; - state.bulkInsertPreview.hidden = true; - state.bulkInsertPreview.textContent = ""; - state.bulkInsertProgress.hidden = true; - state.bulkInsertProgressBar.value = 0; - state.bulkInsertProgressBar.max = 1; - state.bulkInsertProgressStatus.textContent = ""; - syncBulkInsertConflictUi(state); - syncRowEditInsertModeUi(state); -} - -function normalizeBulkInsertCell(column, value) { - if (typeof value === "undefined") { - return column.notnull ? "" : null; - } - if (value === null) { - return column.notnull ? "" : null; - } - if (value === "" && column.notnull) { - return ""; - } - if (column.value_kind === "number" && typeof value === "string") { - return valueFromRowEditText(column.name, value, "number"); - } - return value; -} - -function rowObjectForBulkInsert(valuesByColumn, columns) { - var row = {}; - columns.forEach(function (column) { - var hasValue = Object.prototype.hasOwnProperty.call( - valuesByColumn, - column.name, - ); - if (!hasValue) { - return; - } - row[column.name] = normalizeBulkInsertCell( - column, - valuesByColumn[column.name], - ); - }); - return row; -} - -function splitDelimitedRows(text, delimiter) { - var rows = []; - var row = []; - var cell = ""; - var inQuotes = false; - - for (var i = 0; i < text.length; i += 1) { - var character = text[i]; - if (inQuotes) { - if (character === '"') { - if (text[i + 1] === '"') { - cell += '"'; - i += 1; - } else { - inQuotes = false; - } - } else { - cell += character; - } - continue; - } - - if (character === '"') { - inQuotes = true; - } else if (character === delimiter) { - row.push(cell); - cell = ""; - } else if (character === "\n" || character === "\r") { - row.push(cell); - rows.push(row); - row = []; - cell = ""; - if (character === "\r" && text[i + 1] === "\n") { - i += 1; - } - } else { - cell += character; - } - } - - if (inQuotes) { - throw new Error("Unclosed quoted value."); - } - row.push(cell); - rows.push(row); - - while (rows.length && bulkInsertDelimitedRowIsBlank(rows[rows.length - 1])) { - rows.pop(); - } - return rows; -} - -function bulkInsertDelimitedRowIsBlank(row) { - return row.every(function (value) { - return value.trim() === ""; - }); -} - -function delimiterPreviewRows(text, delimiter) { - try { - return splitDelimitedRows(text, delimiter); - } catch (_error) { - return []; - } -} - -function splitSingleColumnRows(text) { - var rows = text.split(/\r\n|\n|\r/).map(function (line) { - return [line]; - }); - while (rows.length && bulkInsertDelimitedRowIsBlank(rows[rows.length - 1])) { - rows.pop(); - } - return rows; -} - -function detectBulkInsertDelimiter(text, columns) { - var firstLine = - text.split(/\r\n|\n|\r/).find(function (line) { - return line.trim() !== ""; - }) || ""; - var csvRows = delimiterPreviewRows(firstLine, ","); - var tsvRows = delimiterPreviewRows(firstLine, "\t"); - var csvColumns = csvRows.length ? csvRows[0].length : 0; - var tsvColumns = tsvRows.length ? tsvRows[0].length : 0; - - if (firstLine.indexOf("\t") !== -1 && firstLine.indexOf(",") === -1) { - return "\t"; - } - if (tsvColumns > csvColumns) { - return "\t"; - } - if (csvColumns > 1) { - return ","; - } - if (tsvColumns > 1) { - return "\t"; - } - if (columns.length === 1 || bulkInsertColumnMap(columns)[firstLine.trim()]) { - return null; - } - throw new Error("Could not detect CSV or TSV columns."); -} - -function bulkInsertColumnMap(columns) { - var map = {}; - columns.forEach(function (column) { - map[column.name] = column; - }); - return map; -} - -function bulkInsertTextIncludesPrimaryKeyColumns(text, columns, primaryKeys) { - if (!primaryKeys.length || !text.trim()) { - return false; - } - var trimmed = text.trim(); - try { - if (trimmed[0] === "[" || trimmed[0] === "{") { - return jsonBulkInsertTextIncludesPrimaryKeyColumns(trimmed, primaryKeys); - } - return delimitedBulkInsertTextIncludesPrimaryKeyColumns( - trimmed, - columns, - primaryKeys, - ); - } catch (_error) { - return false; - } -} - -function jsonBulkInsertTextIncludesPrimaryKeyColumns(text, primaryKeys) { - var rows = parseJsonObjectRows(text); - var seenKeys = {}; - rows.forEach(function (row) { - Object.keys(row).forEach(function (key) { - seenKeys[key] = true; - }); - }); - return primaryKeys.every(function (key) { - return !!seenKeys[key]; - }); -} - -function delimitedBulkInsertTextIncludesPrimaryKeyColumns( - text, - columns, - primaryKeys, -) { - var delimiter = detectBulkInsertDelimiter(text, columns); - var rows = ( - delimiter === null - ? splitSingleColumnRows(text) - : splitDelimitedRows(text, delimiter) - ).filter(function (row) { - return !bulkInsertDelimitedRowIsBlank(row); - }); - if (!rows.length) { - return false; - } - - var columnMap = bulkInsertColumnMap(columns); - var header = rows[0].map(function (value) { - return value.trim(); - }); - var headerMatches = header.filter(function (name) { - return !!columnMap[name]; - }).length; - if (headerMatches > 0) { - return primaryKeys.every(function (key) { - return header.indexOf(key) !== -1; - }); - } - - var headers = columns.map(function (column) { - return column.name; - }); - var suppliedColumnCount = rows.reduce(function (count, row) { - return Math.max(count, row.length); - }, 0); - return primaryKeys.every(function (key) { - var index = headers.indexOf(key); - return index !== -1 && index < suppliedColumnCount; - }); -} - -function bulkInsertLiveValidationShouldWait(message) { - return ( - message === "Paste rows before previewing." || - message === "No data rows found to preview." || - message.indexOf("Invalid JSON:") === 0 - ); -} - -function bulkInsertLiveValidationError(state) { - var text = state.bulkInsertTextarea.value; - if (!text.trim()) { - return null; - } - try { - parseBulkInsertRows(text, state.bulkInsertColumnDetails); - } catch (error) { - var message = error.message || "Could not preview rows."; - return bulkInsertLiveValidationShouldWait(message) ? null : message; - } - return null; -} - -function syncBulkInsertTextareaValidation(state) { - if (!rowEditIsMultipleInsert(state) || state.bulkInsertPreviewReady) { - state.bulkInsertLiveValidationError = null; - return; - } - state.bulkInsertLiveValidationError = bulkInsertLiveValidationError(state); - if (state.bulkInsertLiveValidationError) { - showRowEditDialogError(state, state.bulkInsertLiveValidationError, { - focus: false, - }); - } else { - clearRowEditDialogError(state); - } -} - -function parseJsonBulkInsertRows(text, columns) { - var parsed = parseJsonObjectRows(text); - - var columnMap = bulkInsertColumnMap(columns); - return parsed.map(function (item, index) { - if (!item || typeof item !== "object" || Array.isArray(item)) { - throw new Error("JSON row " + (index + 1) + " must be an object."); - } - Object.keys(item).forEach(function (key) { - if (!columnMap[key]) { - throw new Error( - "JSON row " + (index + 1) + " has unknown column " + key + ".", - ); - } - }); - return rowObjectForBulkInsert(item, columns); - }); -} - -function parseDelimitedBulkInsertRows(text, columns) { - var delimiter = detectBulkInsertDelimiter(text, columns); - var rows = ( - delimiter === null - ? splitSingleColumnRows(text) - : splitDelimitedRows(text, delimiter) - ).filter(function (row) { - return !bulkInsertDelimitedRowIsBlank(row); - }); - if (!rows.length) { - throw new Error("No rows found to preview."); - } - - var columnMap = bulkInsertColumnMap(columns); - var header = rows[0].map(function (value) { - return value.trim(); - }); - var headerMatches = header.filter(function (name) { - return !!columnMap[name]; - }).length; - var hasHeader = headerMatches > 0; - var dataRows = hasHeader ? rows.slice(1) : rows; - var headers = hasHeader - ? header - : columns.map(function (column) { - return column.name; - }); - var seenHeaders = {}; - - if (hasHeader) { - headers.forEach(function (name) { - if (!name) { - return; - } - if (!columnMap[name]) { - throw new Error("Unknown column " + name + " in header row."); - } - if (seenHeaders[name]) { - throw new Error("Duplicate column " + name + " in header row."); - } - seenHeaders[name] = true; - }); - } - - if (!dataRows.length) { - throw new Error("No data rows found to preview."); - } - - return dataRows.map(function (row, rowIndex) { - if (row.length > headers.length) { - throw new Error( - "Row " + - (rowIndex + 1) + - " has " + - row.length + - " values, but only " + - headers.length + - " columns were provided.", - ); - } - var valuesByColumn = {}; - row.forEach(function (value, index) { - var columnName = headers[index]; - if (columnMap[columnName]) { - valuesByColumn[columnName] = value; - } - }); - return rowObjectForBulkInsert(valuesByColumn, columns); - }); -} - -function parseBulkInsertRows(text, columns) { - var trimmed = text.trim(); - if (!trimmed) { - throw new Error("Paste rows before previewing."); - } - if (trimmed[0] === "[" || trimmed[0] === "{") { - return parseJsonBulkInsertRows(trimmed, columns); - } - return parseDelimitedBulkInsertRows(trimmed, columns); -} - -function bulkInsertPreviewValue(value) { - if (value === null) { - return "null"; - } - if (typeof value === "object") { - return JSON.stringify(value); - } - return String(value); -} - -function bulkInsertPreviewCell(column, hasValue, value) { - if (!hasValue && column.is_auto_pk) { - return { - text: "auto", - className: "row-edit-bulk-preview-auto", - }; - } - if (value === null) { - return { - text: bulkInsertPreviewValue(value), - className: "row-edit-bulk-preview-null", - }; - } - return { - text: hasValue ? bulkInsertPreviewValue(value) : "", - className: "", - }; -} - -function renderBulkInsertPreview(state, rows) { - state.bulkInsertPreview.textContent = ""; - var summary = document.createElement("p"); - summary.className = "row-edit-bulk-preview-summary"; - summary.textContent = - "Previewing " + rows.length + " row" + (rows.length === 1 ? "." : "s."); - state.bulkInsertPreview.appendChild(summary); - - var tableWrap = document.createElement("div"); - tableWrap.className = "row-edit-bulk-preview-table-wrap"; - var table = document.createElement("table"); - table.className = "row-edit-bulk-preview-table"; - var thead = document.createElement("thead"); - var headerRow = document.createElement("tr"); - state.bulkInsertColumnDetails.forEach(function (column) { - var th = document.createElement("th"); - th.scope = "col"; - th.textContent = column.name; - headerRow.appendChild(th); - }); - thead.appendChild(headerRow); - table.appendChild(thead); - - var tbody = document.createElement("tbody"); - rows.forEach(function (row) { - var tr = document.createElement("tr"); - state.bulkInsertColumnDetails.forEach(function (column) { - var td = document.createElement("td"); - var hasValue = Object.prototype.hasOwnProperty.call(row, column.name); - var value = hasValue ? row[column.name] : ""; - var cell = bulkInsertPreviewCell(column, hasValue, value); - td.textContent = cell.text; - if (cell.className) { - td.className = cell.className; - } - tr.appendChild(td); - }); - tbody.appendChild(tr); - }); - table.appendChild(tbody); - tableWrap.appendChild(table); - state.bulkInsertPreview.appendChild(tableWrap); - state.bulkInsertPreview.hidden = false; -} - -function previewBulkInsertRows(state) { - clearRowEditDialogError(state); - resetBulkInsertPreview(state); - syncBulkInsertConflictUi(state); - try { - var rows = parseBulkInsertRows( - state.bulkInsertTextarea.value, - state.bulkInsertColumnDetails, - ); - state.bulkInsertPreviewRows = rows; - state.bulkInsertPreviewReady = true; - renderBulkInsertPreview(state, rows); - updateRowEditDialogButtons(state); - } catch (error) { - showRowEditDialogError(state, error.message || "Could not preview rows."); - updateRowEditDialogButtons(state); - } -} - -function updateBulkInsertProgress(state, inserted, total) { - var words = bulkInsertProgressWords(state); - state.bulkInsertProgress.hidden = false; - state.bulkInsertProgressBar.max = total || 1; - state.bulkInsertProgressBar.value = inserted; - state.bulkInsertProgressStatus.textContent = - inserted >= total - ? total + " row" + (total === 1 ? " " : "s ") + words.complete + "." - : words.active + " " + inserted + " of " + total + " rows..."; -} - -function bulkInsertBatches(rows, batchSize) { - var batches = []; - var size = Math.max(1, batchSize || 1); - for (var index = 0; index < rows.length; index += size) { - batches.push(rows.slice(index, index + size)); - } - return batches; -} - -function animateBulkInsertProgress(state, from, to, total, duration) { - state.bulkInsertProgress.hidden = false; - state.bulkInsertProgressBar.max = total || 1; - if (duration <= 0 || !window.requestAnimationFrame) { - updateBulkInsertProgress(state, to, total); - return Promise.resolve(); - } - - return new Promise(function (resolve) { - var startTime = null; - var step = function (timestamp) { - if (startTime === null) { - startTime = timestamp; - } - var progress = Math.min((timestamp - startTime) / duration, 1); - var easedProgress = 1 - Math.pow(1 - progress, 3); - var value = from + (to - from) * easedProgress; - var displayValue = progress === 1 ? to : Math.floor(value); - var words = bulkInsertProgressWords(state); - state.bulkInsertProgressBar.value = value; - state.bulkInsertProgressStatus.textContent = - displayValue >= total - ? total + " row" + (total === 1 ? " " : "s ") + words.complete + "." - : words.active + " " + displayValue + " of " + total + " rows..."; - if (progress < 1) { - window.requestAnimationFrame(step); - } else { - updateBulkInsertProgress(state, to, total); - resolve(); - } - }; - window.requestAnimationFrame(step); - }); -} - -function bulkInsertProgressWords(state) { - var mode = bulkInsertConflictMode(state); - if (mode === "upsert") { - return { - active: "Upserting", - complete: "upserted", - }; - } - if (mode === "ignore") { - return { - active: "Processing", - complete: "processed", - }; - } - return { - active: "Inserting", - complete: "inserted", - }; -} - -function validateBulkInsertConflictRows(state, rows) { - if (bulkInsertConflictMode(state) !== "upsert") { - return null; - } - var insertData = tableInsertData() || {}; - var primaryKeys = insertData.primaryKeys || []; - for (var index = 0; index < rows.length; index += 1) { - var row = rows[index]; - var missing = primaryKeys.filter(function (key) { - return ( - !Object.prototype.hasOwnProperty.call(row, key) || - row[key] === null || - typeof row[key] === "undefined" - ); - }); - if (missing.length) { - return ( - "Row " + - (index + 1) + - " is missing primary key " + - missing.join(", ") + - ". Upsert requires primary key values for every row." - ); - } - } - return null; -} - -async function insertBulkPreviewRows(state) { - if (!state.bulkInsertPreviewRows || state.bulkInsertInserted) { - return; - } - var conflictMode = bulkInsertConflictMode(state); - var url = - conflictMode === "upsert" ? state.currentUpsertUrl : state.currentInsertUrl; - if (!url) { - showRowEditDialogError( - state, - conflictMode === "upsert" - ? "Could not find the row upsert URL." - : "Could not find the row insert URL.", - ); - return; - } - - var rows = state.bulkInsertPreviewRows; - var validationError = validateBulkInsertConflictRows(state, rows); - if (validationError) { - showRowEditDialogError(state, validationError); - return; - } - var total = rows.length; - var inserted = state.bulkInsertInsertedCount || 0; - var batches = bulkInsertBatches( - rows.slice(inserted), - state.bulkInsertMaxRows, - ); - var progressAnimationDuration = 500 / Math.max(batches.length, 1); - - clearRowEditDialogError(state); - updateBulkInsertProgress(state, inserted, total); - setRowEditDialogSaving(state, true); - try { - for (var batchIndex = 0; batchIndex < batches.length; batchIndex += 1) { - var batch = batches[batchIndex]; - var payload = { rows: batch }; - if (conflictMode === "ignore") { - payload.ignore = true; - } - var response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(payload), - }); - var data = null; - try { - data = await response.json(); - } catch (_error) { - data = null; - } - if (!response.ok || (data && data.ok === false)) { - throw rowMutationRequestError(response, data); - } - var previousInserted = inserted; - inserted += batch.length; - state.bulkInsertInsertedCount = inserted; - await animateBulkInsertProgress( - state, - previousInserted, - inserted, - total, - progressAnimationDuration, - ); - } - state.bulkInsertInserted = true; - state.shouldReloadOnClose = true; - state.redirectOnCloseUrl = tableBaseUrl().toString(); - updateBulkInsertProgress(state, inserted, total); - } catch (error) { - showRowEditDialogError(state, error.message || "Could not insert rows."); - } finally { - setRowEditDialogSaving(state, false); - } -} - -function scheduleCloseRowEditDialogIfConfirmed(state) { - // Fix for an issue in Safari where hitting Esc would show - // the confirm() prompt asking if state should be discarded - // but the Esc key press would then cancel that dialog too. - // Wait for keyup, then move the confirm() to a fresh timer tick. - if (!state || state.isSaving || state.isClosePending) { - return false; - } - if (!rowEditDialogHasChanges(state)) { - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; - } - state.isClosePending = true; - var closeAfterKeyup = function () { - if (!state.isClosePending) { - return; - } - state.isClosePending = false; - closeRowEditDialogIfConfirmed(state); - }; - var onKeyup = function (ev) { - if (ev.key !== "Escape") { - return; - } - document.removeEventListener("keyup", onKeyup, true); - setTimeout(closeAfterKeyup, 0); - }; - document.addEventListener("keyup", onKeyup, true); - return true; -} - -function findDataRowElement(root, rowId) { - var elements = root.querySelectorAll("[data-row]"); - for (var i = 0; i < elements.length; i += 1) { - if (elements[i].getAttribute("data-row") === rowId) { - return elements[i]; - } - } - return null; -} - -async function fetchUpdatedRowElement(state) { - if (!state.currentFragmentUrl || !state.currentRowId) { - return null; - } - var response = await fetch(state.currentFragmentUrl, { - headers: { - Accept: "text/html", - }, - }); - var html = await response.text(); - if (!response.ok) { - throw new Error("Could not refresh row: HTTP " + response.status); - } - var doc = new DOMParser().parseFromString(html, "text/html"); - return findDataRowElement(doc, state.currentRowId); -} - -function rowPathFromRowData(row, primaryKeys) { - if (!row) { - return null; - } - var keys = primaryKeys && primaryKeys.length ? primaryKeys : ["rowid"]; - var bits = []; - for (var i = 0; i < keys.length; i += 1) { - var key = keys[i]; - if (typeof row[key] === "undefined") { - return null; - } - bits.push(tildeEncode(row[key])); - } - return bits.join(","); -} - -function addInsertedRowToPage(rowElement) { - var importedRow = document.importNode(rowElement, true); - var firstRow = document.querySelector("[data-row]"); - if (firstRow && firstRow.parentNode) { - firstRow.parentNode.insertBefore(importedRow, firstRow); - } else { - var tbody = document.querySelector("table.rows-and-columns tbody"); - if (!tbody) { - return null; - } - tbody.appendChild(importedRow); - } - var zeroResults = document.querySelector(".zero-results"); - if (zeroResults) { - zeroResults.remove(); - } - return importedRow; -} - -async function saveRowEditDialog(state) { - if (state.isLoading || state.isSaving || !state.hasLoaded) { - return; - } - if (rowEditIsMultipleInsert(state)) { - if (!state.bulkInsertPreviewReady) { - previewBulkInsertRows(state); - } else if (!state.bulkInsertInserted) { - await insertBulkPreviewRows(state); - } - return; - } - clearRowEditDialogError(state); - setRowEditDialogSaving(state, true); - - try { - var url = - state.mode === "insert" ? state.currentInsertUrl : state.currentUpdateUrl; - if (!url) { - throw new Error( - state.mode === "insert" - ? "Could not find the row insert URL" - : "Could not find the row update URL", - ); - } - var formValues = collectRowFormValues(state); - if (state.mode === "edit" && !Object.keys(formValues).length) { - state.shouldRestoreFocus = true; - hideRowMutationStatus(); - state.dialog.close(); - return; - } - var payload = - state.mode === "insert" - ? { row: formValues, return: true } - : { update: formValues, return: true }; - var response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(payload), - }); - var data = null; - try { - data = await response.json(); - } catch (_error) { - data = null; - } - if (!response.ok || (data && data.ok === false)) { - throw rowMutationRequestError(response, data); - } - - if (state.mode === "insert") { - var insertData = tableInsertData() || {}; - var insertedRowData = - data && data.rows && data.rows.length ? data.rows[0] : null; - var insertedRowId = rowPathFromRowData( - insertedRowData, - insertData.primaryKeys || [], - ); - state.shouldRestoreFocus = false; - if (!insertedRowId) { - state.dialog.close(); - var missingIdStatus = showRowMutationStatus( - state.manager, - "Inserted row. Refresh the page to see it.", - false, - ); - missingIdStatus.focus(); - return; - } - - state.currentRowId = insertedRowId; - state.currentFragmentUrl = rowFragmentUrlById(insertedRowId); - var insertedRow = null; - try { - insertedRow = await fetchUpdatedRowElement(state); - } catch (_error) { - state.dialog.close(); - var refreshFailedStatus = showRowMutationStatus( - state.manager, - "Inserted row, but could not refresh the table row. Refresh the page to see it.", - true, - ); - refreshFailedStatus.focus(); - return; - } - if (insertedRow) { - var insertedStatusMessage = insertedRowStatusMessage( - tildeDecode(insertedRowId), - rowTitleLabel(insertedRow), - ); - var addedRow = addInsertedRowToPage(insertedRow); - state.dialog.close(); - showRowMutationStatus(state.manager, insertedStatusMessage, false); - if (addedRow) { - var insertedFocusTarget = - addedRow.querySelector('button[data-row-action="edit"]') || - addedRow; - insertedFocusTarget.focus(); - } - } else { - state.dialog.close(); - var filteredStatus = showRowMutationStatus( - state.manager, - "Inserted row. It does not match the current filters.", - false, - ); - filteredStatus.focus(); - } - return; - } - - if (isRowPage()) { - state.shouldRestoreFocus = false; - state.dialog.close(); - location.reload(); - return; - } - - var updatedRow = await fetchUpdatedRowElement(state); - var focusTarget = null; - if (updatedRow && state.currentRow && document.contains(state.currentRow)) { - var importedRow = document.importNode(updatedRow, true); - state.currentRow.replaceWith(importedRow); - showRowMutationStatus( - state.manager, - state.currentPkPath - ? "Updated row " + state.currentPkPath + "." - : "Updated row.", - false, - ); - focusTarget = - importedRow.querySelector('button[data-row-action="edit"]') || - importedRow; - } else if (state.currentRow && document.contains(state.currentRow)) { - focusTarget = - nextRowActionFocusTarget(state.currentRow, "edit") || - ensureRowMutationStatus(state.manager); - state.currentRow.remove(); - showRowMutationStatus( - state.manager, - state.currentPkPath - ? "Updated row " + - state.currentPkPath + - ". It no longer matches the current filters." - : "Updated row. It no longer matches the current filters.", - false, - ); - } - - state.shouldRestoreFocus = false; - state.dialog.close(); - if (focusTarget && document.contains(focusTarget)) { - focusTarget.focus(); - } - } catch (error) { - setRowEditDialogSaving(state, false); - showRowEditDialogError(state, error.message || "Could not save row"); - } -} - -function renderRowEditFields(state, data) { - var row = data.rows && data.rows.length ? data.rows[0] : null; - var columns = data.columns || (row ? Object.keys(row) : []); - var primaryKeys = data.primary_keys || []; - var columnTypes = data.column_types || {}; - var columnDetails = data.column_details || {}; - - state.insertMode = "single"; - destroyRowEditFields(state); - columns.forEach(function (column, index) { - var columnDetail = columnDetails[column] || {}; - state.fields.appendChild( - createRowEditField( - column, - row ? row[column] : null, - primaryKeys.indexOf(column) !== -1, - columnTypes[column], - index, - { - autocompleteUrl: foreignKeyAutocompleteUrl(column), - dialog: state.dialog, - form: state.form, - manager: state.manager, - mode: state.mode, - notnull: columnDetail.notnull, - primaryKeyReadonly: true, - sqliteType: columnDetail.sqlite_type, - }, - ), - ); - }); - - state.hasLoaded = true; - updateRowEditDialogButtons(state); - if (!focusFirstRowEditControl(state, { skipReadonly: true })) { - focusFirstRowEditControl(state) || state.cancelButton.focus(); - } -} - -function renderRowInsertFields(state, data) { - var columns = data.columns || []; - var bulkColumns = data.bulkColumns || columns; - - state.insertMode = "single"; - state.bulkInsertColumnDetails = bulkColumns.slice(); - state.bulkInsertMaxRows = data.maxInsertRows || 100; - state.bulkInsertColumns = bulkColumns.map(function (column) { - return column.name; - }); - state.bulkInsertTemplateColumns = columns.map(function (column) { - return column.name; - }); - state.copyTemplateButton.disabled = !state.bulkInsertTemplateColumns.length; - setBulkInsertCopyButtonReady(state); - syncBulkInsertConflictUi(state); - clearTimeout(state.copyTemplateResetTimer); - state.copyTemplateResetTimer = null; - resetBulkInsertPreview(state); - destroyRowEditFields(state); - columns.forEach(function (column, index) { - state.fields.appendChild( - createRowEditField( - column.name, - "", - !!column.is_pk, - column.column_type, - index, - { - autocompleteUrl: foreignKeyAutocompleteUrl(column.name), - dialog: state.dialog, - form: state.form, - defaultExpression: column.default, - manager: state.manager, - mode: state.mode, - notnull: column.notnull, - primaryKeyReadonly: false, - sqliteType: column.sqlite_type, - useSqliteDefault: column.default !== null, - valueKind: column.value_kind, - }, - ), - ); - }); - - if (!columns.length) { - var emptyMessage = document.createElement("p"); - emptyMessage.className = "row-edit-empty"; - emptyMessage.textContent = "This row will use the table defaults."; - state.fields.appendChild(emptyMessage); - } - - state.hasLoaded = true; - updateRowEditDialogButtons(state); - var firstDefaultButton = state.fields.querySelector( - ".row-edit-default-set-value", - ); - if (firstDefaultButton) { - firstDefaultButton.focus(); - } else { - focusFirstRowEditControl(state, { skipReadonly: true }) || - state.saveButton.focus(); - } -} - -function setRowDialogTitle(title, text, codeText, labelText) { - title.textContent = ""; - var action = document.createElement("span"); - action.className = "row-dialog-action"; - action.textContent = text; - title.appendChild(action); - if (!codeText) { - return; - } - title.appendChild(document.createTextNode(" ")); - var code = document.createElement("code"); - code.textContent = codeText; - title.appendChild(code); - if (labelText && labelText !== codeText) { - title.appendChild(document.createTextNode(" ")); - var label = document.createElement("span"); - label.className = "row-dialog-label"; - label.textContent = labelText; - title.appendChild(label); - } -} - -function ensureRowEditDialog(manager) { - if (rowEditDialogState) { - return rowEditDialogState; - } - if (!window.HTMLDialogElement) { - return null; - } - - var dialog = document.createElement("dialog"); - dialog.id = ROW_EDIT_DIALOG_ID; - dialog.className = "row-edit-dialog"; - dialog.setAttribute("aria-labelledby", "row-edit-title"); - dialog.innerHTML = ` - -
    - -

    Loading row...

    - -
    - - -
    - `; - document.body.appendChild(dialog); - - rowEditDialogState = { - dialog: dialog, - form: dialog.querySelector(".row-edit-form"), - title: dialog.querySelector(".modal-title"), - summary: dialog.querySelector(".row-edit-summary"), - loading: dialog.querySelector(".row-edit-loading"), - error: dialog.querySelector(".row-edit-error"), - fields: dialog.querySelector(".row-edit-fields"), - bulkInsertPanel: dialog.querySelector(".row-edit-bulk"), - bulkInsertEditor: dialog.querySelector(".row-edit-bulk-editor"), - bulkInsertTextarea: dialog.querySelector(".row-edit-bulk-textarea"), - bulkInsertPreview: dialog.querySelector(".row-edit-bulk-preview"), - bulkInsertProgress: dialog.querySelector(".row-edit-bulk-progress"), - bulkInsertProgressBar: dialog.querySelector(".row-edit-bulk-progress-bar"), - bulkInsertProgressStatus: dialog.querySelector( - ".row-edit-bulk-progress-status", - ), - bulkInsertConflictField: dialog.querySelector(".row-edit-bulk-conflict"), - bulkInsertConflictSelect: dialog.querySelector( - ".row-edit-bulk-conflict-mode", - ), - bulkInsertConflictHelp: dialog.querySelector( - ".row-edit-bulk-conflict-help", - ), - copyTemplateButton: dialog.querySelector(".row-edit-copy-template"), - bulkInsertOpenFileButton: dialog.querySelector(".row-edit-bulk-open-file"), - bulkInsertFileInput: dialog.querySelector(".row-edit-bulk-file-input"), - bulkInsertLink: dialog.querySelector(".row-edit-bulk-insert"), - singleInsertLink: dialog.querySelector(".row-edit-single-insert"), - cancelButton: dialog.querySelector(".row-edit-cancel"), - saveButton: dialog.querySelector(".row-edit-save"), - currentButton: null, - currentRow: null, - currentRowId: null, - currentPkPath: null, - currentInsertUrl: null, - currentUpsertUrl: null, - currentUpdateUrl: null, - currentFragmentUrl: null, - mode: "edit", - insertMode: "single", - bulkInsertConflictMode: "ignore", - bulkInsertHasPrimaryKeyColumns: false, - bulkInsertLiveValidationError: null, - bulkInsertColumns: [], - bulkInsertTemplateColumns: [], - bulkInsertColumnDetails: [], - bulkInsertPreviewRows: null, - bulkInsertPreviewReady: false, - bulkInsertInserted: false, - bulkInsertInsertedCount: 0, - bulkInsertMaxRows: 100, - shouldReloadOnClose: false, - redirectOnCloseUrl: null, - copyTemplateResetTimer: null, - loadId: 0, - manager: manager, - isLoading: false, - isSaving: false, - isClosePending: false, - hasLoaded: false, - shouldRestoreFocus: true, - }; - - rowEditDialogState.form.addEventListener("submit", function (ev) { - ev.preventDefault(); - saveRowEditDialog(rowEditDialogState); - }); - - rowEditDialogState.cancelButton.addEventListener("click", function () { - if ( - rowEditIsMultipleInsert(rowEditDialogState) && - rowEditDialogState.bulkInsertPreviewReady && - !rowEditDialogState.bulkInsertInserted && - !rowEditDialogState.isSaving - ) { - resetBulkInsertPreview(rowEditDialogState); - updateRowEditDialogButtons(rowEditDialogState); - rowEditDialogState.bulkInsertTextarea.focus(); - return; - } - if (!rowEditDialogState.isSaving) { - rowEditDialogState.shouldRestoreFocus = true; - dialog.close(); - } - }); - - rowEditDialogState.bulkInsertLink.addEventListener("click", function (ev) { - ev.preventDefault(); - showMultipleRowInsert(rowEditDialogState); - }); - - rowEditDialogState.singleInsertLink.addEventListener("click", function (ev) { - ev.preventDefault(); - showSingleRowInsert(rowEditDialogState); - }); - - rowEditDialogState.copyTemplateButton.addEventListener( - "click", - async function () { - try { - await copyTextToClipboard(bulkInsertTemplateText(rowEditDialogState)); - clearRowEditDialogError(rowEditDialogState); - setBulkInsertCopyButtonCopied(rowEditDialogState); - } catch (_error) { - showRowEditDialogError( - rowEditDialogState, - "Could not copy the spreadsheet template.", - ); - } - }, - ); - - rowEditDialogState.bulkInsertOpenFileButton.addEventListener( - "click", - function () { - rowEditDialogState.bulkInsertFileInput.click(); - }, - ); - - rowEditDialogState.bulkInsertFileInput.addEventListener( - "change", - async function (ev) { - var files = ev.target.files; - await loadBulkInsertTextFile( - rowEditDialogState, - files && files.length ? files[0] : null, - ); - ev.target.value = ""; - }, - ); - - rowEditDialogState.bulkInsertTextarea.addEventListener( - "dragenter", - function (ev) { - ev.preventDefault(); - rowEditDialogState.bulkInsertTextarea.classList.add( - "row-edit-bulk-drop-target", - ); - }, - ); - - rowEditDialogState.bulkInsertTextarea.addEventListener( - "dragover", - function (ev) { - ev.preventDefault(); - rowEditDialogState.bulkInsertTextarea.classList.add( - "row-edit-bulk-drop-target", - ); - }, - ); - - rowEditDialogState.bulkInsertTextarea.addEventListener( - "dragleave", - function () { - rowEditDialogState.bulkInsertTextarea.classList.remove( - "row-edit-bulk-drop-target", - ); - }, - ); - - rowEditDialogState.bulkInsertTextarea.addEventListener( - "drop", - async function (ev) { - ev.preventDefault(); - rowEditDialogState.bulkInsertTextarea.classList.remove( - "row-edit-bulk-drop-target", - ); - var files = ev.dataTransfer && ev.dataTransfer.files; - if (!files || !files.length) { - return; - } - await loadBulkInsertTextFile(rowEditDialogState, files[0]); - }, - ); - - rowEditDialogState.bulkInsertTextarea.addEventListener( - "dragend", - function () { - rowEditDialogState.bulkInsertTextarea.classList.remove( - "row-edit-bulk-drop-target", - ); - }, - ); - - rowEditDialogState.bulkInsertTextarea.addEventListener("input", function () { - resetBulkInsertPreview(rowEditDialogState); - syncBulkInsertTextareaValidation(rowEditDialogState); - updateRowEditDialogButtons(rowEditDialogState); - }); - - rowEditDialogState.bulkInsertConflictSelect.addEventListener( - "change", - function () { - rowEditDialogState.bulkInsertConflictMode = - rowEditDialogState.bulkInsertConflictSelect.value; - syncBulkInsertConflictUi(rowEditDialogState); - resetBulkInsertPreview(rowEditDialogState); - syncBulkInsertTextareaValidation(rowEditDialogState); - updateRowEditDialogButtons(rowEditDialogState); - }, - ); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeRowEditDialogIfConfirmed(rowEditDialogState); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if (ev.key !== "Escape") { - return; - } - ev.preventDefault(); - scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState); - }); - - dialog.addEventListener("cancel", function (ev) { - ev.preventDefault(); - scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState); - }); - - dialog.addEventListener("close", function () { - var state = rowEditDialogState; - var shouldReloadOnClose = state.shouldReloadOnClose; - var redirectOnCloseUrl = state.redirectOnCloseUrl; - state.loadId += 1; - state.isClosePending = false; - state.bulkInsertLiveValidationError = null; - state.shouldReloadOnClose = false; - state.redirectOnCloseUrl = null; - clearTimeout(state.copyTemplateResetTimer); - state.copyTemplateResetTimer = null; - setBulkInsertCopyButtonReady(state); - resetBulkInsertPreview(state); - clearRowEditDialogError(state); - state.hasLoaded = false; - destroyRowEditFields(state); - setRowEditDialogLoading(state, false); - setRowEditDialogSaving(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } - if (shouldReloadOnClose) { - if (redirectOnCloseUrl) { - location.href = redirectOnCloseUrl; - } else { - location.reload(); - } - } - }); - - return rowEditDialogState; -} - -async function openRowEditDialog(button, manager) { - var row = rowElementForActionButton(button); - if (!row || !row.getAttribute("data-row")) { - return; - } - var state = ensureRowEditDialog(manager); - if (!state) { - return; - } - - state.manager = manager; - state.mode = "edit"; - state.currentButton = button; - state.currentRow = row; - state.currentRowId = row.getAttribute("data-row") || ""; - state.currentPkPath = rowDisplayLabel(row); - state.currentInsertUrl = null; - state.currentUpsertUrl = null; - state.currentUpdateUrl = rowUpdateUrl(row); - state.currentFragmentUrl = rowFragmentUrl(row); - state.insertMode = "single"; - if (state.currentUpdateUrl) { - state.form.action = new URL( - state.currentUpdateUrl, - location.href, - ).toString(); - } else { - state.form.removeAttribute("action"); - } - state.shouldRestoreFocus = true; - state.hasLoaded = false; - state.loadId += 1; - var loadId = state.loadId; - - clearRowEditDialogError(state); - setRowEditDialogLoading(state, true); - destroyRowEditFields(state); - state.dialog.removeAttribute("aria-describedby"); - setRowDialogTitle( - state.title, - "Edit row", - state.currentPkPath || "this row", - rowTitleLabel(row), - ); - state.summary.hidden = true; - state.summary.textContent = ""; - syncRowEditInsertModeUi(state); - - if (!state.dialog.open) { - state.dialog.showModal(); - } - state.cancelButton.focus(); - - try { - var response = await fetch(rowJsonUrl(row), { - headers: { - Accept: "application/json", - }, - }); - var data = await response.json(); - if (loadId !== state.loadId) { - return; - } - if (!response.ok || data.ok === false) { - throw rowMutationRequestError(response, data); - } - setRowEditDialogLoading(state, false); - renderRowEditFields(state, data); - } catch (error) { - if (loadId !== state.loadId) { - return; - } - setRowEditDialogLoading(state, false); - showRowEditDialogError(state, error.message || "Could not load row"); - state.cancelButton.focus(); - } -} - -function openRowInsertDialog(button, manager) { - var insertData = tableInsertData(); - if (!insertData) { - return; - } - var state = ensureRowEditDialog(manager); - if (!state) { - return; - } - - state.manager = manager; - state.mode = "insert"; - state.currentButton = button; - state.currentRow = null; - state.currentRowId = null; - state.currentPkPath = null; - state.currentInsertUrl = tableInsertUrl(); - state.currentUpsertUrl = tableUpsertUrl(); - state.currentUpdateUrl = null; - state.currentFragmentUrl = null; - state.insertMode = "single"; - state.bulkInsertConflictMode = "ignore"; - state.bulkInsertLiveValidationError = null; - state.bulkInsertTextarea.value = ""; - state.shouldReloadOnClose = false; - state.redirectOnCloseUrl = null; - resetBulkInsertPreview(state); - state.shouldRestoreFocus = true; - state.hasLoaded = false; - state.loadId += 1; - - if (state.currentInsertUrl) { - state.form.action = new URL( - state.currentInsertUrl, - location.href, - ).toString(); - } else { - state.form.removeAttribute("action"); - } - - clearRowEditDialogError(state); - setRowEditDialogLoading(state, false); - destroyRowEditFields(state); - state.dialog.removeAttribute("aria-describedby"); - setRowInsertDialogTitle(state); - state.summary.hidden = true; - state.summary.textContent = ""; - syncRowEditInsertModeUi(state); - - if (!state.dialog.open) { - state.dialog.showModal(); - } - renderRowInsertFields(state, insertData); -} - -function initRowEditActions(manager) { - if (!window.fetch || !window.HTMLDialogElement) { - return; - } - document.addEventListener("click", function (ev) { - var button = ev.target.closest('button[data-row-action="edit"]'); - if (!button) { - return; - } - ev.preventDefault(); - openRowEditDialog(button, manager); - }); -} - -function initRowInsertActions(manager) { - if (!window.fetch || !window.HTMLDialogElement || !tableInsertData()) { - return; - } - document.addEventListener("click", function (ev) { - var button = ev.target.closest('button[data-table-action="insert-row"]'); - if (!button) { - return; - } - ev.preventDefault(); - openRowInsertDialog(button, manager); - }); -} - -document.addEventListener("datasette_init", function (evt) { - const { detail: manager } = evt; - - registerBuiltinColumnFieldPlugins(manager); - initTableCreateActions(manager); - initTableAlterActions(manager); - initRowInsertActions(manager); - initRowEditActions(manager); - initRowDeleteActions(manager); -}); diff --git a/datasette/static/mobile-column-actions.js b/datasette/static/mobile-column-actions.js deleted file mode 100644 index a386b1fc..00000000 --- a/datasette/static/mobile-column-actions.js +++ /dev/null @@ -1,318 +0,0 @@ -var MOBILE_COLUMN_BREAKPOINT = 576; -var MOBILE_COLUMN_DIALOG_ID = "mobile-column-actions-dialog"; -var MOBILE_COLUMN_DIALOG_TITLE_ID = "mobile-column-actions-title"; - -function mobileColumnHeaders(manager) { - return Array.from( - document.querySelectorAll(manager.selectors.tableHeaders), - ).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1"); -} - -function mobileColumnMetaText(th) { - var parts = []; - if (th.dataset.columnType) { - parts.push(th.dataset.columnType); - } - if (th.dataset.isPk === "1") { - parts.push("pk"); - } - if (th.dataset.columnNotNull === "1") { - parts.push("not null"); - } - return parts.join(", "); -} - -function createMobileColumnActionNode(itemConfig, closeDialog) { - var actionNode; - if (itemConfig.href) { - actionNode = document.createElement("a"); - actionNode.href = itemConfig.href; - } else { - actionNode = document.createElement("button"); - actionNode.type = "button"; - } - actionNode.textContent = itemConfig.label; - - if (itemConfig.onClick) { - actionNode.addEventListener("click", function (ev) { - try { - itemConfig.onClick.call(actionNode, ev); - } finally { - closeDialog({ restoreFocus: false }); - } - }); - } - - return actionNode; -} - -function initMobileColumnActions(manager) { - var triggerButton = document.querySelector(".column-actions-mobile"); - if (!triggerButton) { - return; - } - - if ( - !window.URLSearchParams || - !window.HTMLDialogElement || - !manager.columnActions - ) { - triggerButton.style.display = "none"; - return; - } - - if (!mobileColumnHeaders(manager).length) { - triggerButton.style.display = "none"; - return; - } - - var dialog = document.createElement("dialog"); - dialog.className = "mobile-column-actions-dialog"; - dialog.id = MOBILE_COLUMN_DIALOG_ID; - dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID); - dialog.innerHTML = ` - -
    - - `; - document.body.appendChild(dialog); - - triggerButton.setAttribute("aria-haspopup", "dialog"); - triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID); - triggerButton.setAttribute("aria-expanded", "false"); - - var countEl = dialog.querySelector(".modal-meta"); - var listWrap = dialog.querySelector(".mobile-column-list"); - var doneButton = dialog.querySelector(".mobile-column-actions-done"); - var expandedSectionId = null; - var shouldRestoreFocus = true; - - function updateExpandedSection() { - Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => { - var controlsId = button.getAttribute("aria-controls"); - var actionList = dialog.querySelector("#" + controlsId); - var isExpanded = controlsId === expandedSectionId; - button.setAttribute("aria-expanded", isExpanded ? "true" : "false"); - actionList.hidden = !isExpanded; - actionList.classList.toggle("expanded", isExpanded); - }); - } - - function scrollExpandedSectionIntoView(section) { - var sectionTop = section.offsetTop; - var sectionBottom = sectionTop + section.offsetHeight; - var visibleTop = listWrap.scrollTop; - var visibleBottom = visibleTop + listWrap.clientHeight; - var sectionHeight = section.offsetHeight; - - if (sectionTop < visibleTop) { - listWrap.scrollTop = sectionTop; - return; - } - - if (sectionBottom <= visibleBottom) { - return; - } - - if (sectionHeight <= listWrap.clientHeight) { - listWrap.scrollTop = sectionBottom - listWrap.clientHeight; - } else { - listWrap.scrollTop = sectionTop; - } - } - - function closeDialog(options) { - options = options || {}; - shouldRestoreFocus = options.restoreFocus !== false; - if (dialog.open) { - dialog.close(); - } else { - triggerButton.setAttribute("aria-expanded", "false"); - if (shouldRestoreFocus) { - triggerButton.focus(); - } - } - } - - function renderDialog() { - var headers = mobileColumnHeaders(manager); - if (!headers.length) { - closeDialog({ restoreFocus: false }); - triggerButton.style.display = "none"; - return false; - } - - if ( - !headers.some( - (_th, index) => `mobile-column-actions-${index}` === expandedSectionId, - ) - ) { - expandedSectionId = null; - } - - countEl.textContent = `${headers.length} column${ - headers.length === 1 ? "" : "s" - }`; - listWrap.innerHTML = ""; - - if (manager.columnActions.shouldShowShowAllColumns()) { - var topActions = document.createElement("div"); - topActions.className = "mobile-column-top-actions"; - - var showAllColumns = document.createElement("a"); - showAllColumns.className = "btn btn-ghost mobile-column-top-action"; - showAllColumns.href = manager.columnActions.showAllColumnsUrl(); - showAllColumns.textContent = "Show all columns"; - - topActions.appendChild(showAllColumns); - listWrap.appendChild(topActions); - } - - headers.forEach((th, index) => { - var sectionId = `mobile-column-actions-${index}`; - var actionState = manager.columnActions.buildColumnActionState(th, { - includeChooseColumns: false, - includeShowAllColumns: false, - }); - var section = document.createElement("section"); - section.className = "mobile-column-section"; - - var headerButton = document.createElement("button"); - headerButton.type = "button"; - headerButton.className = "col-header"; - headerButton.setAttribute("aria-controls", sectionId); - headerButton.setAttribute("aria-expanded", "false"); - - var headerText = document.createElement("span"); - headerText.className = "mobile-column-header-text"; - - var name = document.createElement("span"); - name.className = "mobile-column-name"; - name.textContent = th.dataset.column; - headerText.appendChild(name); - - var metaText = mobileColumnMetaText(th); - if (metaText) { - var meta = document.createElement("span"); - meta.className = "mobile-column-meta"; - meta.textContent = metaText; - headerText.appendChild(meta); - } - - var chevron = document.createElement("span"); - chevron.className = "mobile-column-chevron"; - chevron.setAttribute("aria-hidden", "true"); - chevron.textContent = "▾"; - - headerButton.appendChild(headerText); - headerButton.appendChild(chevron); - headerButton.addEventListener("click", function () { - expandedSectionId = expandedSectionId === sectionId ? null : sectionId; - updateExpandedSection(); - if (expandedSectionId === sectionId) { - scrollExpandedSectionIntoView(section); - } - }); - - var actionContainer = document.createElement("div"); - actionContainer.id = sectionId; - actionContainer.className = "col-actions"; - actionContainer.hidden = true; - - if (actionState.columnDescription) { - var description = document.createElement("p"); - description.className = "mobile-column-description"; - description.textContent = actionState.columnDescription; - actionContainer.appendChild(description); - } - - if (actionState.actionItems.length) { - var actionList = document.createElement("ul"); - actionState.actionItems.forEach((itemConfig) => { - var actionItem = document.createElement("li"); - actionItem.appendChild( - createMobileColumnActionNode(itemConfig, closeDialog), - ); - actionList.appendChild(actionItem); - }); - actionContainer.appendChild(actionList); - } else { - var noActions = document.createElement("p"); - noActions.className = "mobile-column-no-actions"; - noActions.textContent = "No actions available"; - actionContainer.appendChild(noActions); - } - - section.appendChild(headerButton); - section.appendChild(actionContainer); - listWrap.appendChild(section); - }); - - updateExpandedSection(); - return true; - } - - function openDialog() { - if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT) { - return; - } - if (!renderDialog()) { - return; - } - if (!dialog.open) { - dialog.showModal(); - } - triggerButton.setAttribute("aria-expanded", "true"); - var focusTarget = - dialog.querySelector(".mobile-column-top-action") || - dialog.querySelector(".col-header") || - doneButton; - focusTarget.focus(); - } - - triggerButton.addEventListener("click", function () { - if (dialog.open) { - closeDialog(); - } else { - openDialog(); - } - }); - - doneButton.addEventListener("click", function () { - closeDialog(); - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeDialog(); - } - }); - - dialog.addEventListener("cancel", function (ev) { - ev.preventDefault(); - closeDialog(); - }); - - dialog.addEventListener("close", function () { - triggerButton.setAttribute("aria-expanded", "false"); - if (shouldRestoreFocus) { - triggerButton.focus(); - } - }); - - window.addEventListener("resize", function () { - if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) { - closeDialog({ restoreFocus: false }); - } - }); -} - -document.addEventListener("datasette_init", function (evt) { - initMobileColumnActions(evt.detail); -}); diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index ec2d23d8..48de5c4f 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -1,22 +1,10 @@ -let navigationSearchInstanceCounter = 0; - class NavigationSearch extends HTMLElement { constructor() { super(); - this.instanceId = ++navigationSearchInstanceCounter; - this.inputId = `navigation-search-input-${this.instanceId}`; - this.instructionsId = `navigation-search-instructions-${this.instanceId}`; - this.listboxId = `navigation-search-results-${this.instanceId}`; - this.recentHeadingId = `navigation-search-recent-${this.instanceId}`; - this.statusId = `navigation-search-status-${this.instanceId}`; - this.titleId = `navigation-search-title-${this.instanceId}`; this.attachShadow({ mode: "open" }); this.selectedIndex = -1; this.matches = []; - this.renderedMatches = []; this.debounceTimer = null; - this.restoreFocusTarget = null; - this.shouldRestoreFocus = true; this.render(); this.setupEventListeners(); @@ -31,20 +19,19 @@ class NavigationSearch extends HTMLElement { dialog { border: none; - border-radius: var(--modal-border-radius, 0.75rem); + border-radius: 0.75rem; padding: 0; max-width: 90vw; width: 600px; max-height: 80vh; - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: slideIn var(--modal-animation-duration, 0.2s) ease-out; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + animation: slideIn 0.2s ease-out; } dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease-out; } @keyframes slideIn { @@ -66,20 +53,16 @@ class NavigationSearch extends HTMLElement { .search-container { display: flex; flex-direction: column; + height: 100%; } .search-input-wrapper { padding: 1.25rem; border-bottom: 1px solid #e5e7eb; - display: flex; - gap: 0.5rem; - align-items: center; } .search-input { width: 100%; - flex: 1; - min-width: 0; padding: 0.75rem 1rem; font-size: 1rem; border: 2px solid #e5e7eb; @@ -93,36 +76,12 @@ class NavigationSearch extends HTMLElement { border-color: #2563eb; } - .close-search { - background: transparent; - border: 1px solid transparent; - border-radius: 0.375rem; - color: #4b5563; - cursor: pointer; - flex: 0 0 auto; - font: inherit; - font-size: 1.5rem; - height: 2.75rem; - line-height: 1; - width: 2.75rem; - } - - .close-search:hover, - .close-search:focus { - background-color: #f3f4f6; - border-color: #d1d5db; - } - .results-container { overflow-y: auto; height: calc(80vh - 180px); padding: 0.5rem; } - .results-list:empty { - display: none; - } - .result-item { padding: 0.875rem 1rem; cursor: pointer; @@ -141,81 +100,16 @@ class NavigationSearch extends HTMLElement { background-color: #dbeafe; } - .result-item > div { - flex: 1; - min-width: 0; - } - - .jump-start-content { - border-bottom: 1px solid #e5e7eb; - margin-bottom: 0.5rem; - padding: 0.5rem 0.5rem 1rem; - } - - .jump-start-content:empty { - display: none; - } - .result-name { font-weight: 500; color: #111827; } - .result-label { - font-size: 0.875rem; - color: #4b5563; - } - - .result-type { - color: #4b5563; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - } - .result-url { font-size: 0.875rem; color: #6b7280; } - .result-description { - color: #374151; - display: -webkit-box; - font-size: 0.8125rem; - line-height: 1.35; - margin-top: 0.35rem; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - } - - .results-heading { - color: #4b5563; - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0; - padding: 0.5rem 1rem 0.25rem; - text-transform: uppercase; - } - - .recent-actions { - padding: 0.25rem 1rem 0.75rem; - } - - .clear-recent { - background: transparent; - border: 0; - color: #2563eb; - cursor: pointer; - font: inherit; - font-size: 0.875rem; - padding: 0; - } - - .clear-recent:hover { - text-decoration: underline; - } - .no-results { padding: 2rem; text-align: center; @@ -241,18 +135,6 @@ class NavigationSearch extends HTMLElement { font-family: monospace; } - .visually-hidden { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; - } - /* Mobile optimizations */ @media (max-width: 640px) { dialog { @@ -280,29 +162,19 @@ class NavigationSearch extends HTMLElement { } - +
    -

    Jump to

    -

    Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.

    -
    -
    -
    +
    Navigate Enter Select @@ -316,7 +188,6 @@ class NavigationSearch extends HTMLElement { setupEventListeners() { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); - const closeButton = this.shadowRoot.querySelector(".close-search"); const resultsContainer = this.shadowRoot.querySelector(".results-container"); @@ -328,17 +199,6 @@ class NavigationSearch extends HTMLElement { } }); - document.addEventListener("click", (e) => { - const trigger = e.target.closest("[data-navigation-search-open]"); - if (trigger) { - e.preventDefault(); - const details = trigger.closest("details"); - const restoreTarget = details?.querySelector("summary") || trigger; - details?.removeAttribute("open"); - this.openMenu(restoreTarget); - } - }); - // Input event input.addEventListener("input", (e) => { this.handleSearch(e.target.value); @@ -360,19 +220,8 @@ class NavigationSearch extends HTMLElement { } }); - closeButton.addEventListener("click", () => { - this.closeMenu(); - }); - // Click on result item resultsContainer.addEventListener("click", (e) => { - const clearRecent = e.target.closest("[data-clear-recent-items]"); - if (clearRecent) { - e.preventDefault(); - this.clearRecentItems(); - return; - } - const item = e.target.closest(".result-item"); if (item) { const index = parseInt(item.dataset.index); @@ -387,15 +236,6 @@ class NavigationSearch extends HTMLElement { } }); - dialog.addEventListener("cancel", (e) => { - e.preventDefault(); - this.closeMenu(); - }); - - dialog.addEventListener("close", () => { - this.onMenuClosed(); - }); - // Initial load this.loadInitialData(); } @@ -410,106 +250,6 @@ class NavigationSearch extends HTMLElement { ); } - setElementAttribute(element, name, value) { - if (!element) { - return; - } - if (typeof element.setAttribute === "function") { - element.setAttribute(name, value); - } else { - element[name] = String(value); - } - } - - removeElementAttribute(element, name) { - if (!element) { - return; - } - if (typeof element.removeAttribute === "function") { - element.removeAttribute(name); - } else { - delete element[name]; - } - } - - focusRestoreTarget(trigger) { - if (trigger && typeof trigger.focus === "function") { - return trigger; - } - if ( - document.activeElement && - typeof document.activeElement.focus === "function" - ) { - return document.activeElement; - } - return null; - } - - setNavigationTriggersExpanded(expanded) { - if (typeof document.querySelectorAll !== "function") { - return; - } - document - .querySelectorAll("[data-navigation-search-open]") - .forEach((trigger) => { - this.setElementAttribute( - trigger, - "aria-expanded", - expanded ? "true" : "false", - ); - }); - } - - resultOptionId(index) { - return `${this.listboxId}-option-${index}`; - } - - updateComboboxState() { - const dialog = this.shadowRoot.querySelector("dialog"); - const input = this.shadowRoot.querySelector(".search-input"); - const matches = this.renderedMatches || []; - this.setElementAttribute( - input, - "aria-expanded", - dialog && dialog.open && matches.length > 0 ? "true" : "false", - ); - - if ( - dialog && - dialog.open && - this.selectedIndex >= 0 && - this.selectedIndex < matches.length - ) { - this.setElementAttribute( - input, - "aria-activedescendant", - this.resultOptionId(this.selectedIndex), - ); - } else { - this.removeElementAttribute(input, "aria-activedescendant"); - } - } - - setStatus(message) { - const status = this.shadowRoot.querySelector(`#${this.statusId}`); - if (status) { - status.textContent = message || ""; - } - } - - resultsStatus(count, truncated) { - if (truncated) { - return "More than 100 results. Keep typing to narrow the list."; - } - if (count === 0) { - return "No results found."; - } - if (count === 1) { - return "1 result."; - } - return `${count} results.`; - } - loadInitialData() { const itemsAttr = this.getAttribute("items"); if (itemsAttr) { @@ -526,11 +266,6 @@ class NavigationSearch extends HTMLElement { handleSearch(query) { clearTimeout(this.debounceTimer); - if (query.trim()) { - this.setStatus("Searching..."); - } else { - this.setStatus(""); - } this.debounceTimer = setTimeout(() => { const url = this.getAttribute("url"); @@ -553,262 +288,65 @@ class NavigationSearch extends HTMLElement { this.matches = data.matches || []; this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.renderResults(); - if (query.trim()) { - this.setStatus(this.resultsStatus(this.matches.length, data.truncated)); - } else { - this.setStatus(""); - } } catch (e) { console.error("Failed to fetch search results:", e); this.matches = []; this.renderResults(); - this.setStatus("Search failed."); } } filterLocalItems(query) { if (!query.trim()) { - this.matches = this.allItems || []; + this.matches = []; } else { const lowerQuery = query.toLowerCase(); this.matches = (this.allItems || []).filter( (item) => item.name.toLowerCase().includes(lowerQuery) || - (item.display_name || "").toLowerCase().includes(lowerQuery) || item.url.toLowerCase().includes(lowerQuery), ); } this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.renderResults(); - if (query.trim()) { - this.setStatus(this.resultsStatus(this.matches.length, false)); - } else { - this.setStatus(""); - } - } - - recentItemsStorageKey() { - return "datasette.navigationSearch.recentItems"; - } - - loadRecentItems() { - if (typeof localStorage === "undefined") { - return []; - } - - try { - const raw = localStorage.getItem(this.recentItemsStorageKey()); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - return parsed - .filter((item) => item && item.name && item.url) - .map((item) => ({ - name: String(item.name), - display_name: item.display_name ? String(item.display_name) : "", - url: String(item.url), - type: item.type ? String(item.type) : "", - description: item.description ? String(item.description) : "", - })) - .slice(0, 5); - } catch (e) { - return []; - } - } - - saveRecentItem(match) { - if ( - typeof localStorage === "undefined" || - !match || - !match.name || - !match.url - ) { - return; - } - - try { - const item = { - name: String(match.name), - display_name: match.display_name ? String(match.display_name) : "", - url: String(match.url), - type: match.type ? String(match.type) : "", - description: match.description ? String(match.description) : "", - }; - const recentItems = this.loadRecentItems().filter( - (recentItem) => recentItem.url !== item.url, - ); - localStorage.setItem( - this.recentItemsStorageKey(), - JSON.stringify([item, ...recentItems].slice(0, 5)), - ); - } catch (e) { - // localStorage may be unavailable, full, or disabled. - } - } - - clearRecentItems() { - if (typeof localStorage === "undefined") { - return; - } - - try { - localStorage.removeItem(this.recentItemsStorageKey()); - } catch (e) { - localStorage.setItem(this.recentItemsStorageKey(), "[]"); - } - this.renderResults(); - this.setStatus("Recent items cleared."); - } - - jumpSections() { - const manager = window.__DATASETTE__; - if (!manager || typeof manager.makeJumpSections !== "function") { - return []; - } - const sections = manager.makeJumpSections({ - navigationSearch: this, - }); - return Array.isArray(sections) - ? sections.filter( - (section) => section && typeof section.render === "function", - ) - : []; - } - - jumpSectionsHtml(jumpSections) { - return jumpSections - .map((section, index) => { - const id = section.id - ? ` data-jump-section-id="${this.escapeHtml(section.id)}"` - : ""; - return `
    `; - }) - .join(""); - } - - renderJumpSections(container, jumpSections) { - jumpSections.forEach((section, index) => { - const node = container.querySelector( - `[data-jump-section-index="${index}"]`, - ); - if (!node) { - return; - } - section.render(node, { - navigationSearch: this, - container, - input: this.shadowRoot.querySelector(".search-input"), - }); - }); - } - - resultItemHtml(match, index) { - const displayName = match.display_name || match.name; - const label = - match.display_name && match.display_name !== match.name - ? `
    ${this.escapeHtml(match.name)}
    ` - : ""; - const type = match.type - ? `
    ${this.escapeHtml(match.type)}
    ` - : ""; - const description = match.description - ? `
    ${this.escapeHtml( - match.description, - )}
    ` - : ""; - return ` -
    -
    - ${type} -
    ${this.escapeHtml(displayName)}
    - ${label} -
    ${this.escapeHtml(match.url)}
    - ${description} -
    -
    - `; } renderResults() { const container = this.shadowRoot.querySelector(".results-container"); const input = this.shadowRoot.querySelector(".search-input"); - const showStartContent = !input.value.trim(); - const jumpSections = showStartContent ? this.jumpSections() : []; - const startBlock = showStartContent - ? this.jumpSectionsHtml(jumpSections) - : ""; - const recentItems = showStartContent ? this.loadRecentItems() : []; - const defaultMatches = showStartContent ? [] : this.matches; - const renderedMatches = [...recentItems, ...defaultMatches]; - this.renderedMatches = renderedMatches; - const emptyListbox = `
    `; - if (renderedMatches.length) { - if ( - this.selectedIndex < 0 || - this.selectedIndex >= renderedMatches.length - ) { - this.selectedIndex = 0; - } - } else { - this.selectedIndex = -1; - } - - if (renderedMatches.length === 0) { - if (startBlock) { - container.innerHTML = startBlock + emptyListbox; - this.renderJumpSections(container, jumpSections); - } else if (showStartContent) { - container.innerHTML = emptyListbox; - } else { - const message = input.value.trim() - ? "No results found" - : "Start typing to search..."; - container.innerHTML = `${emptyListbox}
    ${message}
    `; - } - this.updateComboboxState(); + if (this.matches.length === 0) { + const message = input.value.trim() + ? "No results found" + : "Start typing to search..."; + container.innerHTML = `
    ${message}
    `; return; } - const recentHeading = recentItems.length - ? `
    Recent
    ` - : ""; - const recentGroup = recentItems.length - ? `
    ${recentItems - .map((match, index) => this.resultItemHtml(match, index)) - .join("")}
    ` - : ""; - const recentActions = recentItems.length - ? `
    ` - : ""; - const defaultHtml = defaultMatches - .map((match, index) => - this.resultItemHtml(match, recentItems.length + index), + container.innerHTML = this.matches + .map( + (match, index) => ` +
    +
    +
    ${this.escapeHtml( + match.name, + )}
    +
    ${this.escapeHtml(match.url)}
    +
    +
    + `, ) .join(""); - container.innerHTML = - startBlock + - recentHeading + - `
    ${recentGroup}${defaultHtml}
    ` + - recentActions; - this.renderJumpSections(container, jumpSections); - this.updateComboboxState(); // Scroll selected item into view if (this.selectedIndex >= 0) { - const selectedItem = container.querySelector( - `.result-item[data-index="${this.selectedIndex}"]`, - ); + const selectedItem = container.children[this.selectedIndex]; if (selectedItem) { selectedItem.scrollIntoView({ block: "nearest" }); } @@ -816,27 +354,22 @@ class NavigationSearch extends HTMLElement { } moveSelection(direction) { - const matches = this.renderedMatches || this.matches; const newIndex = this.selectedIndex + direction; - if (newIndex >= 0 && newIndex < matches.length) { + if (newIndex >= 0 && newIndex < this.matches.length) { this.selectedIndex = newIndex; this.renderResults(); } } selectCurrentItem() { - const matches = this.renderedMatches || this.matches; - if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) { + if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) { this.selectItem(this.selectedIndex); } } selectItem(index) { - const matches = this.renderedMatches || this.matches; - const match = matches[index]; + const match = this.matches[index]; if (match) { - this.saveRecentItem(match); - // Dispatch custom event this.dispatchEvent( new CustomEvent("select", { @@ -849,59 +382,32 @@ class NavigationSearch extends HTMLElement { // Navigate to URL window.location.href = match.url; - this.closeMenu({ restoreFocus: false }); + this.closeMenu(); } } - openMenu(trigger) { + openMenu() { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); - this.restoreFocusTarget = this.focusRestoreTarget(trigger); - this.shouldRestoreFocus = true; - if (!dialog.open) { - dialog.showModal(); - } - this.setNavigationTriggersExpanded(true); + dialog.showModal(); input.value = ""; input.focus(); - // Reset state, then populate the default jump list. + // Reset state - start with no items shown this.matches = []; this.selectedIndex = -1; this.renderResults(); - this.setStatus(""); } - closeMenu(options = {}) { + closeMenu() { const dialog = this.shadowRoot.querySelector("dialog"); - this.shouldRestoreFocus = options.restoreFocus !== false; - if (dialog.open) { - dialog.close(); - } else { - this.onMenuClosed(); - } - } - - onMenuClosed() { - const input = this.shadowRoot.querySelector(".search-input"); - this.setElementAttribute(input, "aria-expanded", "false"); - this.removeElementAttribute(input, "aria-activedescendant"); - this.setNavigationTriggersExpanded(false); - this.setStatus(""); - if ( - this.shouldRestoreFocus && - this.restoreFocusTarget && - typeof this.restoreFocusTarget.focus === "function" - ) { - this.restoreFocusTarget.focus(); - } - this.restoreFocusTarget = null; + dialog.close(); } escapeHtml(text) { const div = document.createElement("div"); - div.textContent = text == null ? "" : text; + div.textContent = text; return div.innerHTML; } } diff --git a/datasette/static/table.js b/datasette/static/table.js index 74a96d8e..0caeeb91 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -1,6 +1,13 @@ var DROPDOWN_HTML = ``; @@ -10,508 +17,54 @@ var DROPDOWN_ICON_SVG = ` `; -var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog"; -var setColumnTypeDialogState = null; -function getParams() { - return new URLSearchParams(location.search); -} - -function paramsToUrl(params) { - var s = params.toString(); - return s ? "?" + s : location.pathname; -} - -function sortDescUrl(column) { - var params = getParams(); - params.set("_sort_desc", column); - params.delete("_sort"); - params.delete("_next"); - return paramsToUrl(params); -} - -function sortAscUrl(column) { - var params = getParams(); - params.set("_sort", column); - params.delete("_sort_desc"); - params.delete("_next"); - return paramsToUrl(params); -} - -function facetUrl(column) { - var params = getParams(); - params.append("_facet", column); - return paramsToUrl(params); -} - -function hideColumnUrl(column) { - var params = getParams(); - params.append("_nocol", column); - return paramsToUrl(params); -} - -function showAllColumnsUrl() { - var params = getParams(); - params.delete("_nocol"); - params.delete("_col"); - return paramsToUrl(params); -} - -function notBlankUrl(column) { - var params = getParams(); - params.set(`${column}__notblank`, "1"); - return paramsToUrl(params); -} - -function getDisplayedFacets() { - return Array.from(document.querySelectorAll(".facet-info")).map( - (el) => el.dataset.column, - ); -} - -function getColumnClassName(th) { - return Array.from(th.classList).find((className) => - className.startsWith("col-"), - ); -} - -function getColumnCells(th) { - var table = th.closest("table"); - var columnClassName = getColumnClassName(th); - if (!table || !columnClassName) { - return []; - } - return Array.from(table.querySelectorAll("td." + columnClassName)); -} - -function getColumnMeta(th) { - return { - columnName: th.dataset.column, - columnNotNull: th.dataset.columnNotNull === "1", - columnType: th.dataset.columnType, - isPk: th.dataset.isPk === "1", - }; -} - -function getColumnTypeText(th) { - var columnType = th.dataset.columnType; - if (!columnType) { - return null; - } - var notNull = th.dataset.columnNotNull === "1" ? " NOT NULL" : ""; - return `Type: ${columnType.toUpperCase()}${notNull}`; -} - -function getSetColumnTypeData() { - return window._setColumnTypeData || null; -} - -function getSetColumnTypeConfig(column) { - var data = getSetColumnTypeData(); - if (!data || !data.columns) { - return null; - } - return data.columns[column] || null; -} - -function canSetColumnType() { - return !!(getSetColumnTypeData() && window.HTMLDialogElement && window.fetch); -} - -function setColumnTypeActionLabel(column) { - var columnConfig = getSetColumnTypeConfig(column); - if (!columnConfig) { - return null; - } - return columnConfig.current - ? `Custom type: ${columnConfig.current.type}` - : "Set custom type"; -} - -function createSetColumnTypeOption(value, name, description, checked) { - var label = document.createElement("label"); - label.className = "set-column-type-option"; - - var input = document.createElement("input"); - input.type = "radio"; - input.name = "set-column-type-choice"; - input.value = value; - input.checked = checked; - - var content = document.createElement("span"); - content.className = "set-column-type-option-content"; - - var title = document.createElement("span"); - title.className = "set-column-type-option-name"; - title.textContent = name; - - var detail = document.createElement("span"); - detail.className = "set-column-type-option-description"; - detail.textContent = description; - - content.appendChild(title); - content.appendChild(detail); - label.appendChild(input); - label.appendChild(content); - return label; -} - -function setSetColumnTypeDialogBusy(state, isBusy) { - state.isBusy = isBusy; - state.saveButton.disabled = isBusy; - state.cancelButton.disabled = isBusy; - Array.from( - state.optionsWrap.querySelectorAll('input[name="set-column-type-choice"]'), - ).forEach(function (input) { - input.disabled = isBusy; - }); - state.saveButton.textContent = isBusy ? "Saving..." : "Save"; -} - -function clearSetColumnTypeDialogError(state) { - state.error.hidden = true; - state.error.textContent = ""; -} - -function showSetColumnTypeDialogError(state, message) { - state.error.hidden = false; - state.error.textContent = message; -} - -function ensureSetColumnTypeDialog() { - if (setColumnTypeDialogState) { - return setColumnTypeDialogState; - } - if (!window.HTMLDialogElement) { - return null; - } - - var dialog = document.createElement("dialog"); - dialog.id = SET_COLUMN_TYPE_DIALOG_ID; - dialog.className = "set-column-type-dialog"; - dialog.setAttribute("aria-labelledby", "set-column-type-title"); - dialog.innerHTML = ` - -

    - -
    - - `; - document.body.appendChild(dialog); - - setColumnTypeDialogState = { - dialog: dialog, - meta: dialog.querySelector(".modal-meta"), - status: dialog.querySelector(".set-column-type-status"), - error: dialog.querySelector(".set-column-type-error"), - optionsWrap: dialog.querySelector(".set-column-type-options"), - footerInfo: dialog.querySelector(".footer-info"), - cancelButton: dialog.querySelector(".set-column-type-cancel"), - saveButton: dialog.querySelector(".set-column-type-save"), - currentColumn: null, - currentConfig: null, - isBusy: false, - }; - - setColumnTypeDialogState.cancelButton.addEventListener("click", function () { - if (!setColumnTypeDialogState.isBusy) { - dialog.close(); - } - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog && !setColumnTypeDialogState.isBusy) { - dialog.close(); - } - }); - - dialog.addEventListener("cancel", function (ev) { - if (setColumnTypeDialogState.isBusy) { - ev.preventDefault(); - } - }); - - dialog.addEventListener("close", function () { - clearSetColumnTypeDialogError(setColumnTypeDialogState); - setSetColumnTypeDialogBusy(setColumnTypeDialogState, false); - }); - - setColumnTypeDialogState.saveButton.addEventListener("click", async function () { - var state = setColumnTypeDialogState; - var selected = state.dialog.querySelector( - 'input[name="set-column-type-choice"]:checked', - ); - var selectedType = selected ? selected.value : ""; - var currentType = state.currentConfig.current - ? state.currentConfig.current.type - : ""; - - if (selectedType === currentType) { - state.dialog.close(); - return; - } - - clearSetColumnTypeDialogError(state); - setSetColumnTypeDialogBusy(state, true); - - var payload = { - column: state.currentColumn, - column_type: selectedType ? { type: selectedType } : null, - }; - - try { - var response = await fetch(getSetColumnTypeData().path, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(payload), - }); - var data = await response.json(); - if (!response.ok || data.ok === false) { - var message = (data.errors || ["Request failed"]).join(" "); - throw new Error(message); - } - location.reload(); - } catch (error) { - setSetColumnTypeDialogBusy(state, false); - showSetColumnTypeDialogError(state, error.message || "Request failed"); - } - }); - - return setColumnTypeDialogState; -} - -function openSetColumnTypeDialog(th) { - var column = th.dataset.column; - var columnConfig = getSetColumnTypeConfig(column); - if (!columnConfig) { - return; - } - - var state = ensureSetColumnTypeDialog(); - if (!state) { - return; - } - - clearSetColumnTypeDialogError(state); - setSetColumnTypeDialogBusy(state, false); - state.currentColumn = column; - state.currentConfig = columnConfig; - state.status.textContent = `Column: ${column}`; - state.meta.textContent = getColumnTypeText(th) || "Type unavailable"; - state.footerInfo.textContent = columnConfig.current - ? `Current custom type: ${columnConfig.current.type}` - : "No custom type set."; - state.optionsWrap.innerHTML = ""; - - var currentType = columnConfig.current ? columnConfig.current.type : ""; - state.optionsWrap.appendChild( - createSetColumnTypeOption( - "", - "No custom type", - "Use standard Datasette rendering without a custom type.", - currentType === "", - ), - ); - - columnConfig.options.forEach(function (option) { - state.optionsWrap.appendChild( - createSetColumnTypeOption( - option.name, - option.name, - option.description, - option.name === currentType, - ), - ); - }); - - if (!columnConfig.options.length) { - var emptyState = document.createElement("p"); - emptyState.className = "set-column-type-empty"; - emptyState.textContent = - "No registered custom types are compatible with this SQLite type."; - state.optionsWrap.appendChild(emptyState); - } - - if (!state.dialog.open) { - state.dialog.showModal(); - } - var selectedOption = state.dialog.querySelector( - 'input[name="set-column-type-choice"]:checked', - ); - if (selectedOption) { - selectedOption.focus(); - } else { - state.saveButton.focus(); - } -} - -function canChooseColumns() { - return !!( - document.querySelector("column-chooser") && window._columnChooserData - ); -} - -function shouldShowShowAllColumns() { - var params = getParams(); - return params.getAll("_nocol").length || params.getAll("_col").length; -} - -function hasMultipleVisibleColumns(manager) { - return ( - Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter( - (th) => th.dataset.column && th.dataset.isLinkColumn !== "1", - ).length > 1 - ); -} - -function buildColumnActionItems(manager, th, options) { - options = options || {}; - var params = getParams(); - var column = th.dataset.column; - var columnActions = []; - var isSortable = !!th.querySelector("a"); - var isFirstColumn = th.parentElement.querySelector("th:first-of-type") === th; - var isSinglePk = - th.dataset.isPk === "1" && - document.querySelectorAll('th[data-is-pk="1"]').length === 1; - var hasBlankValues = getColumnCells(th).some( - (el) => el.innerText.trim() === "", - ); - - if (isSortable && params.get("_sort") !== column) { - columnActions.push({ - label: "Sort ascending", - href: sortAscUrl(column), - }); - } - - if (isSortable && params.get("_sort_desc") !== column) { - columnActions.push({ - label: "Sort descending", - href: sortDescUrl(column), - }); - } - - if ( - DATASETTE_ALLOW_FACET && - !isFirstColumn && - !getDisplayedFacets().includes(column) && - !isSinglePk - ) { - columnActions.push({ - label: "Facet by this", - href: facetUrl(column), - }); - } - - if (options.includeChooseColumns && canChooseColumns()) { - columnActions.push({ - label: "Choose columns", - href: "#", - onClick: - options.onChooseColumns || - function (ev) { - ev.preventDefault(); - openColumnChooser(); - }, - }); - } - - if (canSetColumnType() && getSetColumnTypeConfig(column)) { - columnActions.push({ - label: setColumnTypeActionLabel(column), - href: "#", - onClick: - options.onSetColumnType || - function (ev) { - ev.preventDefault(); - window.setTimeout(function () { - openSetColumnTypeDialog(th); - }, 0); - }, - }); - } - - if (th.dataset.isPk !== "1" && hasMultipleVisibleColumns(manager)) { - columnActions.push({ - label: "Hide this column", - href: hideColumnUrl(column), - }); - } - - if (options.includeShowAllColumns && shouldShowShowAllColumns()) { - columnActions.push({ - label: "Show all columns", - href: showAllColumnsUrl(), - }); - } - - if (params.get(`${column}__notblank`) !== "1" && hasBlankValues) { - columnActions.push({ - label: "Show not-blank rows", - href: notBlankUrl(column), - }); - } - - return columnActions.concat(manager.makeColumnActions(getColumnMeta(th))); -} - -function buildColumnActionState(manager, th, options) { - return { - column: th.dataset.column, - columnDescription: th.dataset.columnDescription || null, - columnMeta: getColumnMeta(th), - columnTypeText: getColumnTypeText(th), - actionItems: buildColumnActionItems(manager, th, options), - }; -} - -function initializeColumnActions(manager) { - manager.columnActions = { - buildColumnActionState: function (th, options) { - return buildColumnActionState(manager, th, options); - }, - buildColumnActionItems: function (th, options) { - return buildColumnActionItems(manager, th, options); - }, - canChooseColumns: canChooseColumns, - facetUrl: facetUrl, - getColumnMeta: getColumnMeta, - getColumnTypeText: getColumnTypeText, - hideColumnUrl: hideColumnUrl, - notBlankUrl: notBlankUrl, - shouldShowShowAllColumns: shouldShowShowAllColumns, - showAllColumnsUrl: showAllColumnsUrl, - sortAscUrl: sortAscUrl, - sortDescUrl: sortDescUrl, - }; -} - -function renderActionLink(itemConfig) { - var newLink = document.createElement("a"); - newLink.textContent = itemConfig.label; - newLink.href = itemConfig.href || "#"; - if (itemConfig.onClick) { - newLink.addEventListener("click", itemConfig.onClick); - } - return newLink; -} - /** Main initialization function for Datasette Table interactions */ const initDatasetteTable = function (manager) { // Feature detection if (!window.URLSearchParams) { return; } + function getParams() { + return new URLSearchParams(location.search); + } + function paramsToUrl(params) { + var s = params.toString(); + return s ? "?" + s : location.pathname; + } + function sortDescUrl(column) { + var params = getParams(); + params.set("_sort_desc", column); + params.delete("_sort"); + params.delete("_next"); + return paramsToUrl(params); + } + function sortAscUrl(column) { + var params = getParams(); + params.set("_sort", column); + params.delete("_sort_desc"); + params.delete("_next"); + return paramsToUrl(params); + } + function facetUrl(column) { + var params = getParams(); + params.append("_facet", column); + return paramsToUrl(params); + } + function hideColumnUrl(column) { + var params = getParams(); + params.append("_nocol", column); + return paramsToUrl(params); + } + function showAllColumnsUrl() { + var params = getParams(); + params.delete("_nocol"); + params.delete("_col"); + return paramsToUrl(params); + } + function notBlankUrl(column) { + var params = getParams(); + params.set(`${column}__notblank`, "1"); + return paramsToUrl(params); + } function closeMenu() { menu.style.display = "none"; menu.classList.remove("anim-scale-in"); @@ -543,41 +96,87 @@ const initDatasetteTable = function (manager) { var rect = th.getBoundingClientRect(); var menuTop = rect.bottom + window.scrollY; var menuLeft = rect.left + window.scrollX; - var actionState = manager.columnActions.buildColumnActionState(th, { - includeChooseColumns: true, - includeShowAllColumns: true, - onChooseColumns: function (ev) { - ev.preventDefault(); - closeMenu(); - openColumnChooser(); - }, - onSetColumnType: function (ev) { - ev.preventDefault(); - closeMenu(); - window.setTimeout(function () { - openSetColumnTypeDialog(th); - }, 0); - }, - }); - var menuList = menu.querySelector("ul.dropdown-actions"); - menuList.innerHTML = ""; - actionState.actionItems.forEach((itemConfig) => { - var menuItem = document.createElement("li"); - menuItem.appendChild(renderActionLink(itemConfig)); - menuList.appendChild(menuItem); - }); - + var column = th.getAttribute("data-column"); + var params = getParams(); + var sort = menu.querySelector("a.dropdown-sort-asc"); + var sortDesc = menu.querySelector("a.dropdown-sort-desc"); + var facetItem = menu.querySelector("a.dropdown-facet"); + var notBlank = menu.querySelector("a.dropdown-not-blank"); + var hideColumn = menu.querySelector("a.dropdown-hide-column"); + var showAllColumns = menu.querySelector("a.dropdown-show-all-columns"); + if (params.get("_sort") == column) { + sort.parentNode.style.display = "none"; + } else { + sort.parentNode.style.display = "block"; + sort.setAttribute("href", sortAscUrl(column)); + } + if (params.get("_sort_desc") == column) { + sortDesc.parentNode.style.display = "none"; + } else { + sortDesc.parentNode.style.display = "block"; + sortDesc.setAttribute("href", sortDescUrl(column)); + } + /* Show hide columns options */ + if (params.get("_nocol") || params.get("_col")) { + showAllColumns.parentNode.style.display = "block"; + showAllColumns.setAttribute("href", showAllColumnsUrl()); + } else { + showAllColumns.parentNode.style.display = "none"; + } + if (th.getAttribute("data-is-pk") != "1") { + hideColumn.parentNode.style.display = "block"; + hideColumn.setAttribute("href", hideColumnUrl(column)); + } else { + hideColumn.parentNode.style.display = "none"; + } + /* Only show "Facet by this" if it's not the first column, not selected, + not a single PK and the Datasette allow_facet setting is True */ + var displayedFacets = Array.from( + document.querySelectorAll(".facet-info"), + ).map((el) => el.dataset.column); + var isFirstColumn = + th.parentElement.querySelector("th:first-of-type") == th; + var isSinglePk = + th.getAttribute("data-is-pk") == "1" && + document.querySelectorAll('th[data-is-pk="1"]').length == 1; + if ( + !DATASETTE_ALLOW_FACET || + isFirstColumn || + displayedFacets.includes(column) || + isSinglePk + ) { + facetItem.parentNode.style.display = "none"; + } else { + facetItem.parentNode.style.display = "block"; + facetItem.setAttribute("href", facetUrl(column)); + } + /* Show notBlank option if not selected AND at least one visible blank value */ + var tdsForThisColumn = Array.from( + th.closest("table").querySelectorAll("td." + th.className), + ); + if ( + params.get(`${column}__notblank`) != "1" && + tdsForThisColumn.filter((el) => el.innerText.trim() == "").length + ) { + notBlank.parentNode.style.display = "block"; + notBlank.setAttribute("href", notBlankUrl(column)); + } else { + notBlank.parentNode.style.display = "none"; + } var columnTypeP = menu.querySelector(".dropdown-column-type"); - if (actionState.columnTypeText) { + var columnType = th.dataset.columnType; + var notNull = th.dataset.columnNotNull == 1 ? " NOT NULL" : ""; + + if (columnType) { columnTypeP.style.display = "block"; - columnTypeP.innerText = actionState.columnTypeText; + columnTypeP.innerText = `Type: ${columnType.toUpperCase()}${notNull}`; } else { columnTypeP.style.display = "none"; } var columnDescriptionP = menu.querySelector(".dropdown-column-description"); - if (actionState.columnDescription) { - columnDescriptionP.innerText = actionState.columnDescription; + if (th.dataset.columnDescription) { + columnDescriptionP.innerText = th.dataset.columnDescription; columnDescriptionP.style.display = "block"; } else { columnDescriptionP.style.display = "none"; @@ -588,6 +187,39 @@ const initDatasetteTable = function (manager) { menu.style.display = "block"; menu.classList.add("anim-scale-in"); + // Custom menu items on each render + // Plugin hook: allow adding JS-based additional menu items + const columnActionsPayload = { + columnName: th.dataset.column, + columnNotNull: th.dataset.columnNotNull === "1", + columnType: th.dataset.columnType, + isPk: th.dataset.isPk === "1", + }; + const columnItemConfigs = manager.makeColumnActions(columnActionsPayload); + + const menuList = menu.querySelector("ul"); + columnItemConfigs.forEach((itemConfig) => { + // Remove items from previous render. We assume entries have unique labels. + const existingItems = menuList.querySelectorAll(`li`); + Array.from(existingItems) + .filter((item) => item.innerText === itemConfig.label) + .forEach((node) => { + node.remove(); + }); + + const newLink = document.createElement("a"); + newLink.textContent = itemConfig.label; + newLink.href = itemConfig.href ?? "#"; + if (itemConfig.onClick) { + newLink.onclick = itemConfig.onClick; + } + + // Attach new elements to DOM + const menuItem = document.createElement("li"); + menuItem.appendChild(newLink); + menuList.appendChild(menuItem); + }); + // Measure width of menu and adjust position if too far right const menuWidth = menu.offsetWidth; const windowWidth = window.innerWidth; @@ -633,151 +265,32 @@ const initDatasetteTable = function (manager) { }); }; -function filterRowSelector(manager) { - return manager.selectors.filterRows || manager.selectors.filterRow; -} - -function filterRowsWithControls(manager) { - return Array.from( - document.querySelectorAll(filterRowSelector(manager)), - ).filter((el) => el.querySelector(".filter-op")); -} - -function filterRowNumberFromName(name) { - var match = name && name.match(/^_filter_column_(\d+)$/); - return match ? parseInt(match[1], 10) : 0; -} - -function nextFilterRowNumber(manager) { - return filterRowsWithControls(manager).reduce((max, row) => { - var column = row.querySelector("select"); - return Math.max(max, filterRowNumberFromName(column && column.name)); - }, 0) + 1; -} - -function setFilterRowNumber(row, number) { - row.querySelector("select").name = `_filter_column_${number}`; - row.querySelector(".filter-op select").name = `_filter_op_${number}`; - row.querySelector("input.filter-value").name = `_filter_value_${number}`; -} - -function resetFilterRow(row) { - row.querySelector("select").value = ""; - row.querySelector(".filter-op select").value = "exact"; - row.querySelector("input.filter-value").value = ""; -} - -function updateFilterRowButtons(manager) { - var rows = filterRowsWithControls(manager); - rows.forEach((row, index) => { - var removeButton = row.querySelector(".filter-row-remove"); - var addButton = row.querySelector(".filter-row-add"); - var column = row.querySelector("select"); - if (removeButton) { - removeButton.hidden = index === 0; - } - if (addButton) { - addButton.hidden = index !== rows.length - 1 || !column.value; - } - var visibleButtonCount = [removeButton, addButton].filter(function (button) { - return button && !button.hidden; - }).length; - row.classList.toggle( - "filter-controls-row-has-buttons", - visibleButtonCount > 0, - ); - row.classList.toggle( - "filter-controls-row-one-button", - visibleButtonCount === 1, - ); - row.classList.toggle( - "filter-controls-row-two-buttons", - visibleButtonCount === 2, - ); - }); -} - -function cloneFilterRow(row) { - var clone = row.cloneNode(true); - clone.querySelector("select").name = "_filter_column"; - clone.querySelector(".filter-op select").name = "_filter_op"; - clone.querySelector("input.filter-value").name = "_filter_value"; - resetFilterRow(clone); - clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove()); - return clone; -} - -var FILTER_REMOVE_ICON_SVG = ``; - -var FILTER_ADD_ICON_SVG = ``; - -function addFilterRowButtons(row, manager) { - var removeButton = document.createElement("button"); - removeButton.type = "button"; - removeButton.className = "filter-row-icon filter-row-remove"; - removeButton.setAttribute("aria-label", "Remove this filter"); - removeButton.title = "Remove this filter"; - removeButton.tabIndex = 0; - removeButton.innerHTML = FILTER_REMOVE_ICON_SVG; - removeButton.addEventListener("click", (ev) => { - var row = ev.currentTarget.closest(filterRowSelector(manager)); - var rows = filterRowsWithControls(manager); - var rowIndex = rows.indexOf(row); - var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null; - row.remove(); - updateFilterRowButtons(manager); - if (focusRow) { - var focusTarget = - focusRow.querySelector(".filter-row-add:not([hidden])") || - focusRow.querySelector("select"); - if (focusTarget) { - focusTarget.focus(); - } - } - }); - row.appendChild(removeButton); - - var addButton = document.createElement("button"); - addButton.type = "button"; - addButton.className = "filter-row-icon filter-row-add"; - addButton.setAttribute("aria-label", "Add another filter"); - addButton.title = "Add another filter"; - addButton.tabIndex = 0; - addButton.innerHTML = FILTER_ADD_ICON_SVG; - addButton.addEventListener("click", (ev) => { - var row = ev.currentTarget.closest(filterRowSelector(manager)); - if (row.querySelector("select").name === "_filter_column") { - setFilterRowNumber(row, nextFilterRowNumber(manager)); - } - var clone = cloneFilterRow(row); - addFilterRowButtons(clone, manager); - row.parentNode.insertBefore(clone, row.nextSibling); - updateFilterRowButtons(manager); - clone.querySelector("select").focus(); - }); - row.appendChild(addButton); - - row.querySelector("select").addEventListener("change", () => { - updateFilterRowButtons(manager); - }); -} - -/* Add buttons to the filter rows */ +/* Add x buttons to the filter rows */ function addButtonsToFilterRows(manager) { - var rows = filterRowsWithControls(manager); + var x = "✖"; + var rows = Array.from( + document.querySelectorAll(manager.selectors.filterRow), + ).filter((el) => el.querySelector(".filter-op")); rows.forEach((row) => { - addFilterRowButtons(row, manager); + var a = document.createElement("a"); + a.setAttribute("href", "#"); + a.setAttribute("aria-label", "Remove this filter"); + a.style.textDecoration = "none"; + a.innerText = x; + a.addEventListener("click", (ev) => { + ev.preventDefault(); + let row = ev.target.closest("div"); + row.querySelector("select").value = ""; + row.querySelector(".filter-op select").value = "exact"; + row.querySelector("input.filter-value").value = ""; + ev.target.closest("a").style.display = "none"; + }); + row.appendChild(a); + var column = row.querySelector("select"); + if (!column.value) { + a.style.display = "none"; + } }); - updateFilterRowButtons(manager); } /* Set up datalist autocomplete for filter values */ @@ -806,66 +319,21 @@ function initAutocompleteForFilterValues(manager) { }); } createDataLists(); - // When any filter column select changes, update the datalist + // When any select with name=_filter_column changes, update the datalist document.body.addEventListener("change", function (event) { - if (event.target.name && event.target.name.startsWith("_filter_column")) { + if (event.target.name === "_filter_column") { event.target - .closest(filterRowSelector(manager)) + .closest(manager.selectors.filterRow) .querySelector(".filter-value") .setAttribute("list", "datalist-" + event.target.value); } }); } -/** Open the column-chooser web component */ -function openColumnChooser() { - var chooser = document.querySelector("column-chooser"); - var data = window._columnChooserData; - if (!chooser || !data) return; - - var nonPkColumns = data.allColumns.filter(function (col) { - return data.primaryKeys.indexOf(col) === -1; - }); - var selected = data.selectedColumns.filter(function (col) { - return data.primaryKeys.indexOf(col) === -1; - }); - - chooser.open({ - columns: nonPkColumns, - selected: selected, - onApply: function (cols) { - var params = new URLSearchParams(location.search); - params.delete("_col"); - params.delete("_nocol"); - params.delete("_next"); - - if (cols.length === nonPkColumns.length) { - // Check if order matches original - if so, no params needed - var orderMatches = cols.every(function (col, i) { - return col === nonPkColumns[i]; - }); - if (!orderMatches) { - cols.forEach(function (col) { - params.append("_col", col); - }); - } - } else { - cols.forEach(function (col) { - params.append("_col", col); - }); - } - var qs = params.toString(); - location.href = qs ? "?" + qs : location.pathname; - }, - }); -} - // Ensures Table UI is initialized only after the Manager is ready. document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; - initializeColumnActions(manager); - // Main table initDatasetteTable(manager); diff --git a/datasette/stored_queries.py b/datasette/stored_queries.py deleted file mode 100644 index f5f977d9..00000000 --- a/datasette/stored_queries.py +++ /dev/null @@ -1,579 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -import json -from typing import Any, Iterable - -from .utils import tilde_encode, urlsafe_components - -UNCHANGED = object() - - -QUERY_OPTION_FIELDS = ( - "hide_sql", - "fragment", - "on_success_message", - "on_success_message_sql", - "on_success_redirect", - "on_error_message", - "on_error_redirect", -) - - -@dataclass -class StoredQuery: - database: str - name: str - sql: str - title: str | None - description: str | None - description_html: str | None - hide_sql: bool - fragment: str | None - parameters: list[str] - is_write: bool - is_private: bool - is_trusted: bool - source: str - owner_id: str | None - on_success_message: str | None - on_success_message_sql: str | None - on_success_redirect: str | None - on_error_message: str | None - on_error_redirect: str | None - private: bool | None = None - - -@dataclass -class StoredQueryPage: - queries: list[StoredQuery] - next: str | None - has_more: bool - limit: int - - -def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]: - data = { - "database": query.database, - "name": query.name, - "sql": query.sql, - "title": query.title, - "description": query.description, - "description_html": query.description_html, - "hide_sql": query.hide_sql, - "fragment": query.fragment, - "parameters": list(query.parameters), - "is_write": query.is_write, - "is_private": query.is_private, - "is_trusted": query.is_trusted, - "source": query.source, - "owner_id": query.owner_id, - "on_success_message": query.on_success_message, - "on_success_message_sql": query.on_success_message_sql, - "on_success_redirect": query.on_success_redirect, - "on_error_message": query.on_error_message, - "on_error_redirect": query.on_error_redirect, - } - if query.private is not None: - data["private"] = query.private - return data - - -def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]: - return { - "queries": [stored_query_to_dict(query) for query in page.queries], - "next": page.next, - "limit": page.limit, - } - - -async def save_queries_from_config(datasette: Any) -> None: - # Apply configured query entries from datasette.yaml to the internal table. - await datasette.get_internal_database().execute_write( - "DELETE FROM queries WHERE source = 'config'" - ) - for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items(): - for query_name, query_config in (db_config.get("queries") or {}).items(): - if not isinstance(query_config, dict): - query_config = {"sql": query_config} - await datasette.add_query( - dbname, - query_name, - query_config["sql"], - title=query_config.get("title"), - description=query_config.get("description"), - description_html=query_config.get("description_html"), - hide_sql=bool(query_config.get("hide_sql")), - fragment=query_config.get("fragment"), - parameters=query_config.get("params"), - is_write=bool(query_config.get("write")), - is_trusted=bool(query_config.get("is_trusted", True)), - source="config", - on_success_message=query_config.get("on_success_message"), - on_success_message_sql=query_config.get("on_success_message_sql"), - on_success_redirect=query_config.get("on_success_redirect"), - on_error_message=query_config.get("on_error_message"), - on_error_redirect=query_config.get("on_error_redirect"), - ) - - -def query_row_to_stored_query( - row: Any, private: bool | None = None -) -> StoredQuery | None: - if row is None: - return None - parameters = json.loads(row["parameters"] or "[]") - options = json.loads(row["options"] or "{}") - return StoredQuery( - database=row["database_name"], - name=row["name"], - sql=row["sql"], - title=row["title"], - description=row["description"], - description_html=row["description_html"], - hide_sql=bool(options.get("hide_sql")), - fragment=options.get("fragment"), - parameters=parameters, - is_write=bool(row["is_write"]), - is_private=bool(row["is_private"]), - is_trusted=bool(row["is_trusted"]), - source=row["source"], - owner_id=row["owner_id"], - on_success_message=options.get("on_success_message"), - on_success_message_sql=options.get("on_success_message_sql"), - on_success_redirect=options.get("on_success_redirect"), - on_error_message=options.get("on_error_message"), - on_error_redirect=options.get("on_error_redirect"), - private=private, - ) - - -def query_options_json(options: dict[str, Any]) -> str: - options_dict = {} - for field in QUERY_OPTION_FIELDS: - value = options.get(field) - if field == "hide_sql": - if value: - options_dict[field] = True - elif value is not None: - options_dict[field] = value - return json.dumps(options_dict, sort_keys=True) - - -async def add_query( - datasette: Any, - database: str, - name: str, - sql: str, - *, - title: str | None = None, - description: str | None = None, - description_html: str | None = None, - hide_sql: bool = False, - fragment: str | None = None, - parameters: Iterable[str] | None = None, - is_write: bool = False, - is_private: bool = False, - is_trusted: bool = False, - source: str = "plugin", - owner_id: str | None = None, - on_success_message: str | None = None, - on_success_message_sql: str | None = None, - on_success_redirect: str | None = None, - on_error_message: str | None = None, - on_error_redirect: str | None = None, - replace: bool = True, -) -> None: - parameters_json = json.dumps(list(parameters or [])) - options_json = query_options_json( - { - "hide_sql": hide_sql, - "fragment": fragment, - "on_success_message": on_success_message, - "on_success_message_sql": on_success_message_sql, - "on_success_redirect": on_success_redirect, - "on_error_message": on_error_message, - "on_error_redirect": on_error_redirect, - } - ) - sql_statement = """ - INSERT INTO queries ( - database_name, name, sql, title, description, description_html, - options, parameters, is_write, is_private, is_trusted, source, owner_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """ - if replace: - sql_statement += """ - ON CONFLICT(database_name, name) DO UPDATE SET - sql = excluded.sql, - title = excluded.title, - description = excluded.description, - description_html = excluded.description_html, - options = excluded.options, - parameters = excluded.parameters, - is_write = excluded.is_write, - is_private = excluded.is_private, - is_trusted = excluded.is_trusted, - source = excluded.source, - owner_id = excluded.owner_id, - updated_at = CURRENT_TIMESTAMP - """ - await datasette.get_internal_database().execute_write( - sql_statement, - [ - database, - name, - sql, - title, - description, - description_html, - options_json, - parameters_json, - int(bool(is_write)), - int(bool(is_private)), - int(bool(is_trusted)), - source, - owner_id, - ], - ) - - -async def update_query( - datasette: Any, - database: str, - name: str, - *, - sql=UNCHANGED, - title=UNCHANGED, - description=UNCHANGED, - description_html=UNCHANGED, - hide_sql=UNCHANGED, - fragment=UNCHANGED, - parameters=UNCHANGED, - is_write=UNCHANGED, - is_private=UNCHANGED, - is_trusted=UNCHANGED, - source=UNCHANGED, - owner_id=UNCHANGED, - on_success_message=UNCHANGED, - on_success_message_sql=UNCHANGED, - on_success_redirect=UNCHANGED, - on_error_message=UNCHANGED, - on_error_redirect=UNCHANGED, -) -> None: - fields = { - "sql": sql, - "title": title, - "description": description, - "description_html": description_html, - "parameters": parameters, - "is_write": is_write, - "is_private": is_private, - "is_trusted": is_trusted, - "source": source, - "owner_id": owner_id, - } - option_fields = { - "hide_sql": hide_sql, - "fragment": fragment, - "on_success_message": on_success_message, - "on_success_message_sql": on_success_message_sql, - "on_success_redirect": on_success_redirect, - "on_error_message": on_error_message, - "on_error_redirect": on_error_redirect, - } - updates = [] - params = [] - for field, value in fields.items(): - if value is UNCHANGED: - continue - if field in {"is_write", "is_private", "is_trusted"}: - value = int(bool(value)) - elif field == "parameters": - value = json.dumps(list(value or [])) - updates.append(f"{field} = ?") - params.append(value) - changed_options = { - field: value for field, value in option_fields.items() if value is not UNCHANGED - } - if changed_options: - rows = await datasette.get_internal_database().execute( - """ - SELECT options FROM queries - WHERE database_name = ? AND name = ? - """, - [database, name], - ) - row = rows.first() - options = json.loads(row["options"] or "{}") if row is not None else {} - for field, value in changed_options.items(): - if field == "hide_sql": - if value: - options[field] = True - else: - options.pop(field, None) - elif value is None: - options.pop(field, None) - else: - options[field] = value - updates.append("options = ?") - params.append(json.dumps(options, sort_keys=True)) - if not updates: - return - updates.append("updated_at = CURRENT_TIMESTAMP") - params.extend([database, name]) - await datasette.get_internal_database().execute_write( - """ - UPDATE queries - SET {} - WHERE database_name = ? AND name = ? - """.format(", ".join(updates)), - params, - ) - - -async def remove_query( - datasette: Any, database: str, name: str, source: str | None = None -) -> None: - sql = "DELETE FROM queries WHERE database_name = ? AND name = ?" - params = [database, name] - if source is not None: - sql += " AND source = ?" - params.append(source) - await datasette.get_internal_database().execute_write(sql, params) - - -async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None: - rows = await datasette.get_internal_database().execute( - """ - SELECT * FROM queries - WHERE database_name = ? AND name = ? - """, - [database, name], - ) - return query_row_to_stored_query(rows.first()) - - -async def count_queries( - datasette: Any, - database: str | None = None, - *, - actor: dict[str, Any] | None = None, - q: str | None = None, - is_write: bool | None = None, - is_private: bool | None = None, - is_trusted: bool | None = None, - source: str | None = None, - owner_id: str | None = None, -) -> int: - allowed_sql, allowed_params = await datasette.allowed_resources_sql( - action="view-query", - actor=actor, - parent=database, - ) - params = dict(allowed_params) - where_clauses = [] - if database is not None: - params["query_database"] = database - where_clauses.append("q.database_name = :query_database") - - if q: - where_clauses.append(""" - ( - q.name LIKE :query_search - OR q.title LIKE :query_search - OR q.description LIKE :query_search - OR q.sql LIKE :query_search - ) - """) - params["query_search"] = "%{}%".format(q) - if is_write is not None: - where_clauses.append("q.is_write = :query_is_write") - params["query_is_write"] = int(bool(is_write)) - if is_private is not None: - where_clauses.append("q.is_private = :query_is_private") - params["query_is_private"] = int(bool(is_private)) - if is_trusted is not None: - where_clauses.append("q.is_trusted = :query_is_trusted") - params["query_is_trusted"] = int(bool(is_trusted)) - if source is not None: - where_clauses.append("q.source = :query_source") - params["query_source"] = source - if owner_id is not None: - where_clauses.append("q.owner_id = :query_owner_id") - params["query_owner_id"] = owner_id - - row = ( - await datasette.get_internal_database().execute( - """ - SELECT count(*) AS count - FROM queries q - JOIN ( - {allowed_sql} - ) allowed - ON allowed.parent = q.database_name - AND allowed.child = q.name - WHERE {where} - """.format( - allowed_sql=allowed_sql, - where=" AND ".join(where_clauses) or "1 = 1", - ), - params, - ) - ).first() - return row["count"] - - -async def list_queries( - datasette: Any, - database: str | None = None, - *, - actor: dict[str, Any] | None = None, - limit: int = 50, - cursor: str | None = None, - q: str | None = None, - is_write: bool | None = None, - is_private: bool | None = None, - is_trusted: bool | None = None, - source: str | None = None, - owner_id: str | None = None, - include_private: bool = False, -) -> StoredQueryPage: - limit = min(max(1, int(limit)), 1000) - allowed_sql, allowed_params = await datasette.allowed_resources_sql( - action="view-query", - actor=actor, - parent=database, - include_is_private=include_private, - ) - params = dict(allowed_params) - params.update({"limit": limit + 1}) - sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))" - where_clauses = [] - order_by = "q.database_name, sort_key, q.name" - if database is not None: - params["query_database"] = database - where_clauses.append("q.database_name = :query_database") - order_by = "sort_key, q.name" - - if cursor: - try: - components = urlsafe_components(cursor) - except ValueError: - components = [] - if database is None and len(components) == 3: - where_clauses.append(""" - ( - q.database_name > :cursor_database - OR ( - q.database_name = :cursor_database - AND ( - {sort_key_sql} > :cursor_sort_key - OR ( - {sort_key_sql} = :cursor_sort_key - AND q.name > :cursor_name - ) - ) - ) - ) - """.format(sort_key_sql=sort_key_sql)) - params["cursor_database"] = components[0] - params["cursor_sort_key"] = components[1] - params["cursor_name"] = components[2] - elif database is not None and len(components) == 2: - where_clauses.append(""" - ( - {sort_key_sql} > :cursor_sort_key - OR ( - {sort_key_sql} = :cursor_sort_key - AND q.name > :cursor_name - ) - ) - """.format(sort_key_sql=sort_key_sql)) - params["cursor_sort_key"] = components[0] - params["cursor_name"] = components[1] - - if q: - where_clauses.append(""" - ( - q.name LIKE :query_search - OR q.title LIKE :query_search - OR q.description LIKE :query_search - OR q.sql LIKE :query_search - ) - """) - params["query_search"] = "%{}%".format(q) - if is_write is not None: - where_clauses.append("q.is_write = :query_is_write") - params["query_is_write"] = int(bool(is_write)) - if is_private is not None: - where_clauses.append("q.is_private = :query_is_private") - params["query_is_private"] = int(bool(is_private)) - if is_trusted is not None: - where_clauses.append("q.is_trusted = :query_is_trusted") - params["query_is_trusted"] = int(bool(is_trusted)) - if source is not None: - where_clauses.append("q.source = :query_source") - params["query_source"] = source - if owner_id is not None: - where_clauses.append("q.owner_id = :query_owner_id") - params["query_owner_id"] = owner_id - - private_select = ", allowed.is_private AS private" if include_private else "" - rows = list( - ( - await datasette.get_internal_database().execute( - """ - SELECT q.*, {sort_key_sql} AS sort_key{private_select} - FROM queries q - JOIN ( - {allowed_sql} - ) allowed - ON allowed.parent = q.database_name - AND allowed.child = q.name - WHERE {where} - ORDER BY {order_by} - LIMIT :limit - """.format( - allowed_sql=allowed_sql, - private_select=private_select, - sort_key_sql=sort_key_sql, - where=" AND ".join(where_clauses) or "1 = 1", - order_by=order_by, - ), - params, - ) - ).rows - ) - has_more = len(rows) > limit - if has_more: - rows = rows[:limit] - - queries = [] - for row in rows: - query = query_row_to_stored_query( - row, private=bool(row["private"]) if include_private else None - ) - assert query is not None - queries.append(query) - - next_token = None - if has_more and rows: - last_row = rows[-1] - if database is None: - next_token = "{},{},{}".format( - tilde_encode(last_row["database_name"]), - tilde_encode(last_row["sort_key"]), - tilde_encode(last_row["name"]), - ) - else: - next_token = "{},{}".format( - tilde_encode(last_row["sort_key"]), - tilde_encode(last_row["name"]), - ) - return StoredQueryPage( - queries=queries, - next=next_token, - has_more=has_more, - limit=limit, - ) diff --git a/datasette/template_contexts.py b/datasette/template_contexts.py deleted file mode 100644 index 232eb051..00000000 --- a/datasette/template_contexts.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Index of the documented template contexts for Datasette's core HTML pages. - -This module deliberately contains no documentation strings of its own - -the documentation lives next to the code it describes: - -- Every page renders a Context dataclass defined in its view module - (DatabaseContext, QueryContext in views/database.py, TableContext in - views/table.py, RowContext in views/row.py). Fields added by view code - carry ``help`` metadata; fields declared with from_extra() take their - documentation from the description on the matching Extra class in - views/table_extras.py. -- The keys render_template() adds to every page are documented in - TEMPLATE_BASE_CONTEXT in datasette/app.py, next to the code that adds - them. - -The contract tests in tests/test_template_context.py assert that the real -rendered context for each page exactly matches what is documented, and -docs/template_context_doc.py generates docs/template_context.rst from the -same classes. -""" - -from datasette.app import TEMPLATE_BASE_CONTEXT -from datasette.views.database import DatabaseContext, QueryContext -from datasette.views.row import RowContext -from datasette.views.table import TableContext - -PAGES = { - "database": DatabaseContext, - "query": QueryContext, - "table": TableContext, - "row": RowContext, -} - - -def documented_context_keys(page_name): - "Set of every documented key for the named page, including base context keys" - return set(TEMPLATE_BASE_CONTEXT) | { - f.name for f in PAGES[page_name].documented_fields() - } diff --git a/datasette/templates/_action_menu.html b/datasette/templates/_action_menu.html index 86089992..7d1d4a55 100644 --- a/datasette/templates/_action_menu.html +++ b/datasette/templates/_action_menu.html @@ -1,7 +1,7 @@ {% if action_links %}
    -{% endif %} +{% endif %} \ No newline at end of file diff --git a/datasette/templates/_close_open_menus.html b/datasette/templates/_close_open_menus.html index a24633d0..3302d77d 100644 --- a/datasette/templates/_close_open_menus.html +++ b/datasette/templates/_close_open_menus.html @@ -13,50 +13,4 @@ document.body.addEventListener('click', (ev) => { (details) => details.open && details != detailsClickedWithin ).forEach(details => details.open = false); }); - -/* Sync aria-expanded and add keyboard navigation for details-menu elements */ -document.querySelectorAll('details.details-menu').forEach(function(details) { - var summary = details.querySelector('summary'); - details.addEventListener('toggle', function() { - if (summary) { - summary.setAttribute('aria-expanded', details.open ? 'true' : 'false'); - } - if (details.open) { - /* Focus first menu item when menu opens */ - var firstItem = details.querySelector('[role="menuitem"]'); - if (firstItem) { firstItem.focus(); } - } - }); -}); - -document.body.addEventListener('keydown', function(ev) { - /* Keyboard navigation for open details-menu elements */ - var openDetails = Array.from(document.querySelectorAll('details.details-menu[open]')); - if (!openDetails.length) { return; } - - if (ev.key === 'Escape') { - openDetails.forEach(function(details) { - details.open = false; - var summary = details.querySelector('summary'); - if (summary) { summary.focus(); } - }); - return; - } - - if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { - var focused = document.activeElement; - openDetails.forEach(function(details) { - var items = Array.from(details.querySelectorAll('[role="menuitem"]')); - if (!items.length) { return; } - var idx = items.indexOf(focused); - if (idx === -1) { return; } - ev.preventDefault(); - if (ev.key === 'ArrowDown') { - items[(idx + 1) % items.length].focus(); - } else { - items[(idx - 1 + items.length) % items.length].focus(); - } - }); - } -}); diff --git a/datasette/templates/_codemirror.html b/datasette/templates/_codemirror.html index 75c16168..c4629aeb 100644 --- a/datasette/templates/_codemirror.html +++ b/datasette/templates/_codemirror.html @@ -1,5 +1,5 @@ - - + + diff --git a/datasette/templates/_facet_results.html b/datasette/templates/_facet_results.html index 570bb37e..034e9678 100644 --- a/datasette/templates/_facet_results.html +++ b/datasette/templates/_facet_results.html @@ -12,9 +12,9 @@
      {% for facet_value in facet_info.results %} {% if not facet_value.selected %} -
    • {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
    • +
    • {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
    • {% else %} -
    • {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }}
    • +
    • {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }}
    • {% endif %} {% endfor %} {% if facet_info.truncated %} diff --git a/datasette/templates/_permission_ui_styles.html b/datasette/templates/_permission_ui_styles.html index 21a2ea8f..53a824f1 100644 --- a/datasette/templates/_permission_ui_styles.html +++ b/datasette/templates/_permission_ui_styles.html @@ -6,20 +6,8 @@ padding: 1.5em; margin-bottom: 2em; } -.permission-form form { - max-width: 60rem; -} -.permission-form-grid { - display: grid; - gap: 1.5rem; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} -.permission-form-result { - margin-top: 1rem; - max-width: 60rem; -} .form-section { - margin-bottom: 1.25em; + margin-bottom: 1em; } .form-section label { display: block; @@ -27,51 +15,22 @@ font-weight: bold; } .form-section input[type="text"], -.form-section input[type="number"], -.form-section select, -.permission-textarea { - background-color: #fff; - border: 1px solid #aaa; - border-radius: 4px; - box-sizing: border-box; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08); - color: #222; - font-family: inherit; - font-size: 1rem; - line-height: 1.4; - max-width: none; - width: 100%; -} -.form-section input[type="text"] { - height: 3rem; - padding: 0.6rem 0.75rem; -} -.form-section input[type="number"] { - height: 3rem; - max-width: 7rem; - padding: 0.6rem 0.75rem; -} .form-section select { - height: 3rem; - padding: 0.6rem 0.75rem; -} -.permission-textarea { - font-family: monospace; - min-height: 12rem; - padding: 0.75rem; - resize: vertical; + width: 100%; + max-width: 500px; + padding: 0.5em; + box-sizing: border-box; + border: 1px solid #ccc; + border-radius: 3px; } .form-section input[type="text"]:focus, -.form-section input[type="number"]:focus, -.form-section select:focus, -.permission-textarea:focus { +.form-section select:focus { + outline: 2px solid #0066cc; border-color: #0066cc; - box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18); - outline: none; } .form-section small { display: block; - margin-top: 0.45em; + margin-top: 0.3em; color: #666; } .form-actions { @@ -183,9 +142,4 @@ text-align: center; color: #666; } -@media only screen and (max-width: 576px) { - .permission-form-grid { - grid-template-columns: minmax(0, 1fr); - } -} diff --git a/datasette/templates/_permissions_debug_tabs.html b/datasette/templates/_permissions_debug_tabs.html index 8e0f486e..d7203c1e 100644 --- a/datasette/templates/_permissions_debug_tabs.html +++ b/datasette/templates/_permissions_debug_tabs.html @@ -44,10 +44,10 @@ diff --git a/datasette/templates/_query_form_styles.html b/datasette/templates/_query_form_styles.html deleted file mode 100644 index cf2dd42c..00000000 --- a/datasette/templates/_query_form_styles.html +++ /dev/null @@ -1,138 +0,0 @@ - diff --git a/datasette/templates/_query_results.html b/datasette/templates/_query_results.html deleted file mode 100644 index 5e1e2f72..00000000 --- a/datasette/templates/_query_results.html +++ /dev/null @@ -1,20 +0,0 @@ -{% if display_rows %} -
      - - - {% for column in columns %}{% endfor %} - - - - {% for row in display_rows %} - - {% for column, td in zip(columns, row) %} - - {% endfor %} - - {% endfor %} - -
      {{ column }}
      {{ td }}
      -{% elif show_zero_results %} -

      0 results

      -{% endif %} diff --git a/datasette/templates/_sql_parameter_scripts.html b/datasette/templates/_sql_parameter_scripts.html deleted file mode 100644 index 9b83889e..00000000 --- a/datasette/templates/_sql_parameter_scripts.html +++ /dev/null @@ -1,307 +0,0 @@ - diff --git a/datasette/templates/_sql_parameter_styles.html b/datasette/templates/_sql_parameter_styles.html deleted file mode 100644 index bc6838f5..00000000 --- a/datasette/templates/_sql_parameter_styles.html +++ /dev/null @@ -1,58 +0,0 @@ - diff --git a/datasette/templates/_sql_parameters.html b/datasette/templates/_sql_parameters.html deleted file mode 100644 index b5c1bde8..00000000 --- a/datasette/templates/_sql_parameters.html +++ /dev/null @@ -1,10 +0,0 @@ -{% set sql_parameter_name_prefix = sql_parameter_name_prefix|default("") %} -
      - {% if parameter_names %} -

      Parameters

      - {% for parameter in parameter_names %} - {% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %} -

      {% if sql_parameters_allow_expand|default(false) %} {% endif %}

      - {% endfor %} - {% endif %} -
      diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index 171b6442..a1329ba7 100644 --- a/datasette/templates/_table.html +++ b/datasette/templates/_table.html @@ -1,12 +1,12 @@
      -{% if display_columns %} +{% if display_rows %}
      {% for column in display_columns %} - {% for row in display_rows %} - + {% for cell in row %} {% endfor %} @@ -31,7 +31,6 @@
      + {% if not column.sortable %} {{ column.name }} {% else %} @@ -22,7 +22,7 @@
      {{ cell.value }}
      -{% endif %} -{% if not display_rows %} +{% else %}

      0 records

      {% endif %} diff --git a/datasette/templates/allow_debug.html b/datasette/templates/allow_debug.html index fda4032c..1ecc92df 100644 --- a/datasette/templates/allow_debug.html +++ b/datasette/templates/allow_debug.html @@ -3,11 +3,29 @@ {% block title %}Debug allow rules{% endblock %} {% block extra_head %} -{% include "_permission_ui_styles.html" %} {% endblock %} @@ -20,28 +38,24 @@ p.message-warning {

      Use this tool to try out different actor and allow combinations. See Defining permissions with "allow" blocks for documentation.

      -
      -
      -
      -
      - - -
      -
      - - -
      -
      -
      - -
      -
      +
      +
      +

      + +
      +
      +

      + +
      +
      + +
      +
      - {% if error %}

      {{ error }}

      {% endif %} +{% if error %}

      {{ error }}

      {% endif %} - {% if result == "True" %}

      Result: allow

      {% endif %} +{% if result == "True" %}

      Result: allow

      {% endif %} - {% if result == "False" %}

      Result: deny

      {% endif %} -
      +{% if result == "False" %}

      Result: deny

      {% endif %} {% endblock %} diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index 4927cb8d..dc393c20 100644 --- a/datasette/templates/api_explorer.html +++ b/datasette/templates/api_explorer.html @@ -3,7 +3,7 @@ {% block title %}API Explorer{% endblock %} {% block extra_head %} - + {% endblock %} {% block content %} @@ -19,7 +19,7 @@

      GET -
      +
      @@ -29,7 +29,7 @@
      POST - +
      diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 18288439..0d89e11c 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -2,13 +2,13 @@ {% block title %}{% endblock %} - + {% for url in extra_css_urls %} {% endfor %} - + {% for url in extra_js_urls %} {% endfor %} @@ -20,7 +20,7 @@
      {% endif %} {% if actor %}
      {{ display_actor(actor) }} @@ -70,7 +72,7 @@ {% endfor %} {% if select_templates %}{% endif %} - - + + diff --git a/datasette/templates/create_token.html b/datasette/templates/create_token.html index 270d9c86..ad7c71b6 100644 --- a/datasette/templates/create_token.html +++ b/datasette/templates/create_token.html @@ -50,6 +50,7 @@
      +
      diff --git a/datasette/templates/csrf_error.html b/datasette/templates/csrf_error.html index b84749d3..7cd4b42b 100644 --- a/datasette/templates/csrf_error.html +++ b/datasette/templates/csrf_error.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}CSRF check failed{% endblock %} +{% block title %}CSRF check failed){% endblock %} {% block content %}

      Form origin check failed

      @@ -7,7 +7,7 @@
      Technical details

      Developers: consult Datasette's CSRF protection documentation.

      -

      Reason: {{ reason }}

      +

      Error code is {{ message_name }}.

      {% endblock %} diff --git a/datasette/templates/database.html b/datasette/templates/database.html index a9fad8dd..42b4ca0b 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -5,11 +5,6 @@ {% block extra_head %} {{- super() -}} {% include "_codemirror.html" %} -{% include "_sql_parameter_styles.html" %} -{% if database_page_data.createTable %} - - -{% endif %} {% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} @@ -30,13 +25,9 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if allow_execute_sql %} -
      +

      Custom SQL query

      -

      - {% set parameter_names = [] %} - {% set parameter_values = {} %} - {% set sql_parameters_allow_expand = false %} - {% include "_sql_parameters.html" %} +

      @@ -62,9 +53,6 @@

    • {{ query.title or query.name }}{% if query.private %} 🔒{% endif %}
    • {% endfor %}
    - {% if queries_more %} -

    View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}

    - {% endif %} {% endif %} {% if tables %} @@ -76,7 +64,7 @@

    {{ table.name }}{% if table.private %} 🔒{% endif %}{% if table.hidden %} (hidden){% endif %}

    {% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}

    -

    {% if table.count is none %}Many rows{% elif table.count_truncated %}>{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}

    +

    {% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}

    {% endif %} {% endfor %} @@ -99,11 +87,5 @@ {% endif %} {% include "_codemirror_foot.html" %} -{% include "_sql_parameter_scripts.html" %} - {% endblock %} diff --git a/datasette/templates/debug_actions.html b/datasette/templates/debug_actions.html index c9dccaaa..0ef7b329 100644 --- a/datasette/templates/debug_actions.html +++ b/datasette/templates/debug_actions.html @@ -9,7 +9,7 @@ {% include "_permissions_debug_tabs.html" %}

    - This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}. + This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}. Actions are used by the permission system to control access to different features.

    @@ -26,7 +26,7 @@ - {% for action in data.actions %} + {% for action in data %} {{ action.name }} {% if action.abbr %}{{ action.abbr }}{% endif %} diff --git a/datasette/templates/debug_allowed.html b/datasette/templates/debug_allowed.html index 80249d9c..add3154a 100644 --- a/datasette/templates/debug_allowed.html +++ b/datasette/templates/debug_allowed.html @@ -3,7 +3,7 @@ {% block title %}Allowed Resources{% endblock %} {% block extra_head %} - + {% include "_permission_ui_styles.html" %} {% include "_debug_common_functions.html" %} {% endblock %} @@ -49,7 +49,7 @@
    - + Number of results per page (max 200)
    @@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }}; (function() { const params = populateFormFromURL(); const action = params.get('action'); - const page = params.get('_page'); + const page = params.get('page'); if (action) { fetchResults(page ? parseInt(page) : 1); } @@ -102,14 +102,14 @@ async function fetchResults(page = 1) { const params = new URLSearchParams(); for (const [key, value] of formData.entries()) { - if (value && key !== '_size' && key !== '_page') { + if (value && key !== 'page_size') { params.append(key, value); } } const pageSize = document.getElementById('page_size').value || '50'; - params.append('_page', page.toString()); - params.append('_size', pageSize); + params.append('page', page.toString()); + params.append('page_size', pageSize); try { const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), { diff --git a/datasette/templates/debug_autocomplete.html b/datasette/templates/debug_autocomplete.html deleted file mode 100644 index 380639a3..00000000 --- a/datasette/templates/debug_autocomplete.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Debug autocomplete{% endblock %} - -{% block extra_head %} -{{ super() }} - -{% endblock %} - -{% block content %} -

    Debug autocomplete

    - - -

    - - -

    -

    - - -

    -

    - - -{% if error %} -

    {{ error }}

    -{% elif autocomplete_url %} -

    {{ database_name }} / {{ table_name }}

    - {% if label_column %} -

    Label column: {{ label_column }}

    - {% else %} -

    No label column detected. Results will use primary key values.

    - {% endif %} -
    - - - - -
    -

    Selected row

    -
    No row selected.
    - -{% else %} -

    Suggested tables

    - {% if suggestions %} -

    Showing up to five tables with a detected label column.

    - - - - - - - - - - {% for suggestion in suggestions %} - - - - - - {% endfor %} - -
    DatabaseTableLabel column
    {{ suggestion.database }}{{ suggestion.table }}{{ suggestion.label_column }}
    - {% else %} -

    No tables with detected label columns found.

    - {% endif %} -

    Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.

    -{% endif %} - -{% endblock %} diff --git a/datasette/templates/debug_check.html b/datasette/templates/debug_check.html index b9fc636a..c2e7997f 100644 --- a/datasette/templates/debug_check.html +++ b/datasette/templates/debug_check.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}Explain a permission decision{% endblock %} +{% block title %}Permission Check{% endblock %} {% block extra_head %} - + {% include "_permission_ui_styles.html" %} {% include "_debug_common_functions.html" %} {% endblock %} {% block content %} -

    Explain a permission decision

    +

    Permission check

    {% set current_tab = "check" %} {% include "_permissions_debug_tabs.html" %} -

    Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.

    +

    Use this tool to test permission checks for the current actor. It queries the /-/check.json API endpoint.

    + +{% if request.actor %} +

    Current actor: {{ request.actor.get("id", "anonymous") }}

    +{% else %} +

    Current actor: anonymous (not logged in)

    +{% endif %}
    -
    +
    - - - Use null for an anonymous actor. This actor is simulated; it does not change who you are signed in as. -
    - -
    - + - The operation to evaluate + The permission action to check
    -
    - +
    + - The database or other parent resource + For database-level permissions, specify the database name
    -
    - - - The table, query or other child resource +
    + + + For table-level permissions, specify the table name (requires parent)
    - +
    + {% endblock %} diff --git a/datasette/templates/debug_permissions_playground.html b/datasette/templates/debug_permissions_playground.html index 8b0cbbcf..91ce1fcf 100644 --- a/datasette/templates/debug_permissions_playground.html +++ b/datasette/templates/debug_permissions_playground.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Permission activity{% endblock %} +{% block title %}Debug permissions{% endblock %} {% block extra_head %} {% include "_permission_ui_styles.html" %} @@ -20,45 +20,61 @@ .check-action, .check-when, .check-result { font-size: 1.3em; } +textarea { + height: 10em; + width: 95%; + box-sizing: border-box; + padding: 0.5em; + border: 2px dotted black; +} +.two-col { + display: inline-block; + width: 48%; +} +.two-col label { + width: 48%; +} +@media only screen and (max-width: 576px) { + .two-col { + width: 100%; + } +} {% endblock %} {% block content %} -

    Permission activity

    +

    Permission playground

    {% set current_tab = "permissions" %} {% include "_permissions_debug_tabs.html" %} -

    Raw simulator

    - -

    This form runs a hypothetical permission check and returns its raw explanation JSON. Use the Explain tool for a visual explanation of the same decision.

    +

    This tool lets you simulate an actor and a permission check for that actor.

    -
    -
    -
    - - -
    + +
    +
    + +
    -
    -
    - - -
    -
    - - -
    -
    - - -
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    @@ -110,7 +126,7 @@ debugPost.addEventListener('submit', function(ev) { }); -

    Recent permission checks

    +

    Recent permissions checks

    {% if filter != "all" %}All{% else %}All{% endif %}, diff --git a/datasette/templates/debug_rules.html b/datasette/templates/debug_rules.html index 233c0e94..9a290803 100644 --- a/datasette/templates/debug_rules.html +++ b/datasette/templates/debug_rules.html @@ -3,7 +3,7 @@ {% block title %}Permission Rules{% endblock %} {% block extra_head %} - + {% include "_permission_ui_styles.html" %} {% include "_debug_common_functions.html" %} {% endblock %} @@ -37,7 +37,7 @@

    - + Number of results per page (max 200)
    @@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn'); (function() { const params = populateFormFromURL(); const action = params.get('action'); - const page = params.get('_page'); + const page = params.get('page'); if (action) { fetchResults(page ? parseInt(page) : 1); } @@ -89,14 +89,14 @@ async function fetchResults(page = 1) { const params = new URLSearchParams(); for (const [key, value] of formData.entries()) { - if (value && key !== '_size' && key !== '_page') { + if (value && key !== 'page_size') { params.append(key, value); } } const pageSize = document.getElementById('page_size').value || '50'; - params.append('_page', page.toString()); - params.append('_size', pageSize); + params.append('page', page.toString()); + params.append('page_size', pageSize); try { const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), { diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html deleted file mode 100644 index 592577f8..00000000 --- a/datasette/templates/execute_write.html +++ /dev/null @@ -1,337 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Write to this database{% endblock %} - -{% block extra_head %} -{{- super() -}} -{% include "_codemirror.html" %} - -{% include "_execute_write_analysis_styles.html" %} -{% include "_sql_parameter_styles.html" %} -{% endblock %} - -{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %} - -{% block crumbs %} -{{ crumbs.nav(request=request, database=database) }} -{% endblock %} - -{% block content %} - -

    Write to this database

    - -

    Execute SQL to insert, update or delete rows in this database.

    - -{% if execution_message %} -

    {{ execution_message }}{% for link in execution_links %} {{ link.label }}{% endfor %}

    -{% endif %} - -{% if execute_write_returns_rows %} -

    Returned rows

    - {% if execute_write_truncated %} -

    Only the first {{ "{:,}".format(execute_write_display_rows|length) }} returned rows are shown.

    - {% endif %} - {% set columns = execute_write_columns %} - {% set display_rows = execute_write_display_rows %} - {% set show_zero_results = true %} - {% include "_query_results.html" %} -{% endif %} - - - {% if write_create_table_template_sql or write_template_tables %} -
    -
    - Start with a template -

    - {% if write_create_table_template_sql %} - - {% endif %} - {% if write_template_tables %} - - - {% for operation in write_template_operations %} - - {% endfor %} - {% endif %} -

    -
    -
    - {% else %} -

    There are no tables that you can currently edit.

    - {% endif %} - -

    - - {% set sql_parameters_section_id = "execute-write-parameters-section" %} - {% set sql_parameters_allow_expand = true %} - {% include "_sql_parameters.html" %} - -
    -

    Query operations

    - {% if analysis_error %} -

    {{ analysis_error }}

    - {% elif analysis_rows %} -
    - - - - - - - - - - - {% for row in analysis_rows %} - - - - - - - - {% endfor %} - -
    OperationDatabaseTableRequired permissionAllowed
    {{ row.operation }}{{ row.database }}{{ row.table }}{% if row.required_permission %}{{ row.required_permission }}{% endif %}{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}
    - {% else %} -

    Analysis will show each affected table and required permission.

    - {% endif %} -
    - -

    - - {{ execute_disabled_reason or "" }} - {% if save_query_url %}Save this query{% endif %} -

    - - -{% include "_codemirror_foot.html" %} -{% include "_sql_parameter_scripts.html" %} -{% include "_execute_write_analysis_scripts.html" %} - - - -{% if write_create_table_template_sql or write_template_tables %} - -{% endif %} - -{% endblock %} diff --git a/datasette/templates/logout.html b/datasette/templates/logout.html index a99870e6..c8fc642a 100644 --- a/datasette/templates/logout.html +++ b/datasette/templates/logout.html @@ -10,6 +10,7 @@
    +
    diff --git a/datasette/templates/messages_debug.html b/datasette/templates/messages_debug.html index 891cf915..2940cd69 100644 --- a/datasette/templates/messages_debug.html +++ b/datasette/templates/messages_debug.html @@ -19,6 +19,7 @@
    +
    diff --git a/datasette/templates/patterns.html b/datasette/templates/patterns.html index 075c0117..7770f7d4 100644 --- a/datasette/templates/patterns.html +++ b/datasette/templates/patterns.html @@ -2,7 +2,7 @@ Datasette: Pattern Portfolio - + @@ -11,7 +11,7 @@