mirror of
https://github.com/simonw/datasette.git
synced 2026-05-27 12:34:37 +02:00
Compare commits
3 commits
main
...
permission
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e35a6b4f7 | ||
|
|
94be9953c5 | ||
|
|
5140f4e815 |
230 changed files with 8100 additions and 48904 deletions
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
|
|
@ -5,7 +5,9 @@ updates:
|
|||
schedule:
|
||||
interval: daily
|
||||
time: "13:00"
|
||||
groups:
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: black
|
||||
versions:
|
||||
- 21.4b0
|
||||
- 21.4b1
|
||||
|
|
|
|||
90
.github/workflows/deploy-latest.yml
vendored
90
.github/workflows/deploy-latest.yml
vendored
|
|
@ -1,11 +1,10 @@
|
|||
name: Deploy latest.datasette.io
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# - 1.0-dev
|
||||
- 1.0-dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -15,35 +14,36 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
python-version: "3.9"
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install . --group dev
|
||||
python -m pip install -e .[test]
|
||||
python -m pip install -e .[docs]
|
||||
python -m pip install sphinx-to-sqlite==0.1a1
|
||||
- name: Run tests
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
- name: Build fixtures.db and other files needed to deploy the demo
|
||||
run: |-
|
||||
python tests/fixtures.py \
|
||||
fixtures.db \
|
||||
fixtures-config.json \
|
||||
fixtures-metadata.json \
|
||||
plugins \
|
||||
--extra-db-filename extra_database.db
|
||||
- name: Build fixtures.db
|
||||
run: python tests/fixtures.py fixtures.db fixtures.json plugins --extra-db-filename extra_database.db
|
||||
- name: Build docs.db
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |-
|
||||
cd docs
|
||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||
sphinx-build -b xml . _build
|
||||
sphinx-to-sqlite ../docs.db _build
|
||||
cd ..
|
||||
- name: Set up the alternate-route demo
|
||||
|
|
@ -57,51 +57,19 @@ jobs:
|
|||
db.route = "alternative-route"
|
||||
' > plugins/alternative_route.py
|
||||
cp fixtures.db fixtures2.db
|
||||
- name: And the counters writable stored query demo
|
||||
- name: Make some modifications to metadata.json
|
||||
run: |
|
||||
cat > plugins/counters.py <<EOF
|
||||
from datasette import hookimpl
|
||||
@hookimpl
|
||||
def startup(datasette):
|
||||
db = datasette.add_memory_database("counters")
|
||||
async def inner():
|
||||
await db.execute_write("create table if not exists counters (name text primary key, value integer)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
||||
for name in ("counter_a", "counter_b", "counter_c"):
|
||||
await datasette.add_query(
|
||||
"counters",
|
||||
"increment_{}".format(name),
|
||||
"update counters set value = value + 1 where name = '{}'".format(name),
|
||||
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
is_write=True,
|
||||
is_trusted=True,
|
||||
)
|
||||
await datasette.add_query(
|
||||
"counters",
|
||||
"decrement_{}".format(name),
|
||||
"update counters set value = value - 1 where name = '{}'".format(name),
|
||||
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
is_write=True,
|
||||
is_trusted=True,
|
||||
)
|
||||
return inner
|
||||
EOF
|
||||
# - name: Make some modifications to metadata.json
|
||||
# run: |
|
||||
# cat fixtures.json | \
|
||||
# jq '.databases |= . + {"ephemeral": {"allow": {"id": "*"}}}' | \
|
||||
# jq '.plugins |= . + {"datasette-ephemeral-tables": {"table_ttl": 900}}' \
|
||||
# > metadata.json
|
||||
# cat metadata.json
|
||||
- id: auth
|
||||
name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v3
|
||||
cat fixtures.json | \
|
||||
jq '.databases |= . + {"ephemeral": {"allow": {"id": "*"}}}' | \
|
||||
jq '.plugins |= . + {"datasette-ephemeral-tables": {"table_ttl": 900}}' \
|
||||
> metadata.json
|
||||
cat metadata.json
|
||||
- name: Set up Cloud Run
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v3
|
||||
version: '318.0.0'
|
||||
service_account_email: ${{ secrets.GCP_SA_EMAIL }}
|
||||
service_account_key: ${{ secrets.GCP_SA_KEY }}
|
||||
- name: Deploy to Cloud Run
|
||||
env:
|
||||
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
|
||||
|
|
@ -113,11 +81,11 @@ jobs:
|
|||
# Replace 1.0 with one-dot-zero in SUFFIX
|
||||
export SUFFIX=${SUFFIX//1.0/one-dot-zero}
|
||||
datasette publish cloudrun fixtures.db fixtures2.db extra_database.db \
|
||||
-m fixtures-metadata.json \
|
||||
-m metadata.json \
|
||||
--plugins-dir=plugins \
|
||||
--branch=$GITHUB_SHA \
|
||||
--version-note=$GITHUB_SHA \
|
||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
|
||||
--install 'datasette-ephemeral-tables>=0.2.2' \
|
||||
--service "datasette-latest$SUFFIX" \
|
||||
--secret $LATEST_DATASETTE_SECRET
|
||||
|
|
|
|||
2
.github/workflows/documentation-links.yml
vendored
2
.github/workflows/documentation-links.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Read the Docs Pull Request Preview
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
|
|
|
|||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
name: Configure npm caching
|
||||
with:
|
||||
path: ~/.npm
|
||||
|
|
|
|||
77
.github/workflows/publish.yml
vendored
77
.github/workflows/publish.yml
vendored
|
|
@ -12,18 +12,23 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install . --group dev
|
||||
pip install -e '.[test]'
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest
|
||||
|
|
@ -31,55 +36,63 @@ jobs:
|
|||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
environment: release
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
python-version: '3.11'
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-publish-pip-
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install setuptools wheel build
|
||||
- name: Build
|
||||
run: |
|
||||
python -m build
|
||||
pip install setuptools wheel twine
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||
run: |
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
|
||||
deploy_static_docs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-publish-pip-
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install . --group dev
|
||||
python -m pip install -e .[docs]
|
||||
python -m pip install sphinx-to-sqlite==0.1a1
|
||||
- name: Build docs.db
|
||||
run: |-
|
||||
cd docs
|
||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||
sphinx-build -b xml . _build
|
||||
sphinx-to-sqlite ../docs.db _build
|
||||
cd ..
|
||||
- id: auth
|
||||
name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
- name: Set up Cloud Run
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v3
|
||||
version: '275.0.0'
|
||||
service_account_email: ${{ secrets.GCP_SA_EMAIL }}
|
||||
service_account_key: ${{ secrets.GCP_SA_KEY }}
|
||||
- name: Deploy stable-docs.datasette.io to Cloud Run
|
||||
run: |-
|
||||
gcloud config set run/region us-central1
|
||||
|
|
@ -92,7 +105,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
deploy_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
21
.github/workflows/spellcheck.yml
vendored
21
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,19 +9,22 @@ jobs:
|
|||
spellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
python-version: 3.9
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install . --group dev
|
||||
pip install -e '.[docs]'
|
||||
- name: Check spelling
|
||||
run: |
|
||||
codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
|
|
|||
76
.github/workflows/stable-docs.yml
vendored
76
.github/workflows/stable-docs.yml
vendored
|
|
@ -1,76 +0,0 @@
|
|||
name: Update Stable Docs
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update_stable_docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||
- name: Set up Git user
|
||||
run: |
|
||||
git config user.name "Automated"
|
||||
git config user.email "actions@users.noreply.github.com"
|
||||
- name: Create stable branch if it does not yet exist
|
||||
run: |
|
||||
if ! git ls-remote --heads origin stable | grep -qE '\bstable\b'; then
|
||||
# Make sure we have all tags locally
|
||||
git fetch --tags --quiet
|
||||
|
||||
# Latest tag that is just numbers and dots (optionally prefixed with 'v')
|
||||
# e.g., 0.65.2 or v0.65.2 — excludes 1.0a20, 1.0-rc1, etc.
|
||||
LATEST_RELEASE=$(
|
||||
git tag -l --sort=-v:refname \
|
||||
| grep -E '^v?[0-9]+(\.[0-9]+){1,3}$' \
|
||||
| head -n1
|
||||
)
|
||||
|
||||
git checkout -b stable
|
||||
|
||||
# If there are any stable releases, copy docs/ from the most recent
|
||||
if [ -n "$LATEST_RELEASE" ]; then
|
||||
rm -rf docs/
|
||||
git checkout "$LATEST_RELEASE" -- docs/ || true
|
||||
fi
|
||||
|
||||
git commit -m "Populate docs/ from $LATEST_RELEASE" || echo "No changes"
|
||||
git push -u origin stable
|
||||
fi
|
||||
- name: Handle Release
|
||||
if: github.event_name == 'release' && !github.event.release.prerelease
|
||||
run: |
|
||||
git fetch --all
|
||||
git checkout stable
|
||||
git reset --hard ${GITHUB_REF#refs/tags/}
|
||||
git push origin stable --force
|
||||
- name: Handle Commit to Main
|
||||
if: contains(github.event.head_commit.message, '!stable-docs')
|
||||
run: |
|
||||
git fetch origin
|
||||
git checkout -b stable origin/stable
|
||||
# Get the list of modified files in docs/ from the current commit
|
||||
FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/)
|
||||
# Check if the list of files is non-empty
|
||||
if [[ -n "$FILES" ]]; then
|
||||
# Checkout those files to the stable branch to over-write with their contents
|
||||
for FILE in $FILES; do
|
||||
git checkout ${{ github.sha }} -- $FILE
|
||||
done
|
||||
git add docs/
|
||||
git commit -m "Doc changes from ${{ github.sha }}"
|
||||
git push origin stable
|
||||
else
|
||||
echo "No changes to docs/ in this commit."
|
||||
exit 0
|
||||
fi
|
||||
19
.github/workflows/test-coverage.yml
vendored
19
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,23 +15,28 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
python-version: 3.9
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install . --group dev
|
||||
python -m pip install -e .[test]
|
||||
python -m pip install pytest-cov
|
||||
- name: Run tests
|
||||
run: |-
|
||||
ls -lah
|
||||
cat .coveragerc
|
||||
pytest -m "not serial" --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term -x
|
||||
pytest --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term
|
||||
ls -lah
|
||||
- name: Upload coverage report
|
||||
uses: codecov/codecov-action@v1
|
||||
|
|
|
|||
8
.github/workflows/test-pyodide.yml
vendored
8
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,15 +12,15 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
cache-dependency-path: '**/setup.py'
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-browsers
|
||||
|
|
|
|||
53
.github/workflows/test-sqlite-support.yml
vendored
53
.github/workflows/test-sqlite-support.yml
vendored
|
|
@ -1,53 +0,0 @@
|
|||
name: Test SQLite versions
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
sqlite-version: [
|
||||
#"3", # latest version
|
||||
"3.46",
|
||||
#"3.45",
|
||||
#"3.27",
|
||||
#"3.26",
|
||||
"3.25",
|
||||
#"3.25.3", # 2018-09-25, window functions breaks test_upsert for some reason on 3.10, skip for now
|
||||
#"3.24", # 2018-06-04, added UPSERT support
|
||||
#"3.23.1" # 2018-04-10, before UPSERT
|
||||
]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Set up SQLite ${{ matrix.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"
|
||||
- run: python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
|
||||
- run: echo $LD_LIBRARY_PATH
|
||||
- name: Build extension for --load-extension test
|
||||
run: |-
|
||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install . --group dev
|
||||
pip freeze
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
30
.github/workflows/test.yml
vendored
30
.github/workflows/test.yml
vendored
|
|
@ -10,35 +10,31 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Build extension for --load-extension test
|
||||
run: |-
|
||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install . --group dev
|
||||
pip install -e '.[test]'
|
||||
pip freeze
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
# And the test that exceeds a localhost HTTPS server
|
||||
tests/test_datasette_https_server.sh
|
||||
- name: Black
|
||||
run: |
|
||||
black --version
|
||||
black --check .
|
||||
- name: Ruff
|
||||
run: ruff check datasette tests
|
||||
- name: Check if cog needs to be run
|
||||
run: |
|
||||
cog --check docs/*.rst
|
||||
|
|
@ -46,7 +42,3 @@ jobs:
|
|||
run: |
|
||||
# This fails on syntax errors, or a diff was applied
|
||||
blacken-docs -l 60 docs/*.rst
|
||||
- name: Test DATASETTE_LOAD_PLUGINS
|
||||
run: |
|
||||
pip install datasette-init datasette-json-html
|
||||
tests/test-datasette-load-plugins.sh
|
||||
|
|
|
|||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
|||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
|
|
|
|||
5
.github/workflows/tmate.yml
vendored
5
.github/workflows/tmate.yml
vendored
|
|
@ -5,14 +5,11 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: read
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -5,12 +5,6 @@ scratchpad
|
|||
|
||||
.vscode
|
||||
|
||||
uv.lock
|
||||
data.db
|
||||
|
||||
# test databases
|
||||
*.db
|
||||
|
||||
# We don't use Pipfile, so ignore them
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
|
|
@ -130,5 +124,3 @@ node_modules
|
|||
tests/*.dylib
|
||||
tests/*.so
|
||||
tests/*.dll
|
||||
|
||||
.idea
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
version: 2
|
||||
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
tools:
|
||||
python: "3.9"
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
build:
|
||||
os: ubuntu-24.04
|
||||
tools:
|
||||
python: "3.13"
|
||||
jobs:
|
||||
python:
|
||||
install:
|
||||
- pip install --upgrade pip
|
||||
- pip install . --group dev
|
||||
|
||||
formats:
|
||||
- pdf
|
||||
- epub
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
|
|
|
|||
60
Justfile
60
Justfile
|
|
@ -1,60 +0,0 @@
|
|||
export DATASETTE_SECRET := "not_a_secret"
|
||||
|
||||
# Run tests and linters
|
||||
@default: test lint
|
||||
|
||||
# Setup project
|
||||
@init:
|
||||
uv sync
|
||||
|
||||
# Run pytest with supplied options
|
||||
@test *options: init
|
||||
uv run pytest -n auto {{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, cog
|
||||
@lint: codespell
|
||||
uv run black datasette tests --check
|
||||
uv run ruff check datasette tests
|
||||
uv run cog --check README.md docs/*.rst
|
||||
|
||||
# Apply ruff fixes
|
||||
@fix:
|
||||
uv run ruff check --fix datasette tests
|
||||
|
||||
# Rebuild docs with cog
|
||||
@cog:
|
||||
uv run cog -r README.md docs/*.rst
|
||||
|
||||
# Serve live docs on localhost:8000
|
||||
@docs: cog blacken-docs
|
||||
uv run make -C docs livehtml
|
||||
|
||||
# Build docs as static HTML
|
||||
@docs-build: cog blacken-docs
|
||||
rm -rf docs/_build && cd docs && uv run make html
|
||||
|
||||
# Apply Black
|
||||
@black:
|
||||
uv run black datasette tests
|
||||
|
||||
# Apply blacken-docs
|
||||
@blacken-docs:
|
||||
uv run blacken-docs -l 60 docs/*.rst
|
||||
|
||||
# Apply prettier
|
||||
@prettier:
|
||||
npm run fix
|
||||
|
||||
# Format code with both black and prettier
|
||||
@format: black prettier blacken-docs
|
||||
|
||||
@serve *options:
|
||||
uv run sqlite-utils create-database data.db
|
||||
uv run sqlite-utils create-table data.db docs id integer title text --pk id --ignore
|
||||
uv run python -m datasette data.db --root --reload {{options}}
|
||||
10
README.md
10
README.md
|
|
@ -1,13 +1,13 @@
|
|||
<img src="https://datasette.io/static/datasette-logo.svg" alt="Datasette">
|
||||
|
||||
[](https://pypi.org/project/datasette/)
|
||||
[](https://docs.datasette.io/en/latest/changelog.html)
|
||||
[](https://docs.datasette.io/en/stable/changelog.html)
|
||||
[](https://pypi.org/project/datasette/)
|
||||
[](https://github.com/simonw/datasette/actions?query=workflow%3ATest)
|
||||
[](https://docs.datasette.io/en/latest/?badge=latest)
|
||||
[](https://github.com/simonw/datasette/blob/main/LICENSE)
|
||||
[](https://hub.docker.com/r/datasetteproject/datasette)
|
||||
[](https://datasette.io/discord)
|
||||
[](https://discord.gg/ktd74dm5mw)
|
||||
|
||||
*An open source multi-tool for exploring and publishing data*
|
||||
|
||||
|
|
@ -15,14 +15,14 @@ Datasette is a tool for exploring and publishing data. It helps people take data
|
|||
|
||||
Datasette is aimed at data journalists, museum curators, archivists, local governments, scientists, researchers and anyone else who has data that they wish to share with the world.
|
||||
|
||||
[Explore a demo](https://datasette.io/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out [on GitHub Codespaces](https://github.com/datasette/datasette-studio).
|
||||
[Explore a demo](https://global-power-plants.datasettes.com/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out by [uploading and publishing your own CSV data](https://docs.datasette.io/en/stable/getting_started.html#try-datasette-without-installing-anything-using-glitch).
|
||||
|
||||
* [datasette.io](https://datasette.io/) is the official project website
|
||||
* Latest [Datasette News](https://datasette.io/news)
|
||||
* Comprehensive documentation: https://docs.datasette.io/
|
||||
* Examples: https://datasette.io/examples
|
||||
* Live demo of current `main` branch: https://latest.datasette.io/
|
||||
* Questions, feedback or want to talk about the project? Join our [Discord](https://datasette.io/discord)
|
||||
* Questions, feedback or want to talk about the project? Join our [Discord](https://discord.gg/ktd74dm5mw)
|
||||
|
||||
Want to stay up-to-date with the project? Subscribe to the [Datasette newsletter](https://datasette.substack.com/) for tips, tricks and news on what's new in the Datasette ecosystem.
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ You can also install it using `pip` or `pipx`:
|
|||
|
||||
pip install datasette
|
||||
|
||||
Datasette requires Python 3.8 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
||||
Datasette requires Python 3.7 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
||||
|
||||
## Basic usage
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
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, TokenRestrictions # noqa
|
||||
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
|
||||
from .hookspecs import hookspec # noqa
|
||||
|
|
|
|||
|
|
@ -1,108 +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
|
||||
|
||||
from datasette.app import Datasette
|
||||
|
||||
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
|
||||
"datasette_active_instances", default=None
|
||||
)
|
||||
|
||||
_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_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
|
||||
1999
datasette/app.py
1999
datasette/app.py
File diff suppressed because it is too large
Load diff
428
datasette/cli.py
428
datasette/cli.py
|
|
@ -4,17 +4,16 @@ import click
|
|||
from click import formatting
|
||||
from click.types import CompositeParamType
|
||||
from click_default_group import DefaultGroup
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from runpy import run_module
|
||||
import shutil
|
||||
from subprocess import call
|
||||
import sys
|
||||
import textwrap
|
||||
from runpy import run_module
|
||||
import webbrowser
|
||||
from .app import (
|
||||
OBSOLETE_SETTINGS,
|
||||
Datasette,
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS,
|
||||
|
|
@ -25,13 +24,11 @@ from .utils import (
|
|||
LoadExtension,
|
||||
StartupError,
|
||||
check_connection,
|
||||
deep_dict_update,
|
||||
find_spatialite,
|
||||
parse_metadata,
|
||||
ConnectionProblem,
|
||||
SpatialiteConnectionProblem,
|
||||
initial_path_for_datasette,
|
||||
pairs_to_nested_config,
|
||||
temporary_docker_directory,
|
||||
value_as_boolean,
|
||||
SpatialiteNotFound,
|
||||
|
|
@ -42,18 +39,6 @@ from .utils.sqlite import sqlite3
|
|||
from .utils.testing import TestClient
|
||||
from .version import __version__
|
||||
|
||||
|
||||
def run_sync(coro_func):
|
||||
"""Run an async callable to completion on a fresh event loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro_func())
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
|
||||
# Use Rich for tracebacks if it is installed
|
||||
try:
|
||||
from rich.traceback import install
|
||||
|
|
@ -63,61 +48,93 @@ except ImportError:
|
|||
pass
|
||||
|
||||
|
||||
class Config(click.ParamType):
|
||||
# This will be removed in Datasette 1.0 in favour of class Setting
|
||||
name = "config"
|
||||
|
||||
def convert(self, config, param, ctx):
|
||||
if ":" not in config:
|
||||
self.fail(f'"{config}" should be name:value', param, ctx)
|
||||
return
|
||||
name, value = config.split(":", 1)
|
||||
if name not in DEFAULT_SETTINGS:
|
||||
msg = (
|
||||
OBSOLETE_SETTINGS.get(name)
|
||||
or f"{name} is not a valid option (--help-settings to see all)"
|
||||
)
|
||||
self.fail(
|
||||
msg,
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return
|
||||
# Type checking
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, value_as_boolean(value)
|
||||
except ValueAsBooleanError:
|
||||
self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx)
|
||||
return
|
||||
elif isinstance(default, int):
|
||||
if not value.isdigit():
|
||||
self.fail(f'"{name}" should be an integer', param, ctx)
|
||||
return
|
||||
return name, int(value)
|
||||
elif isinstance(default, str):
|
||||
return name, value
|
||||
else:
|
||||
# Should never happen:
|
||||
self.fail("Invalid option")
|
||||
|
||||
|
||||
class Setting(CompositeParamType):
|
||||
name = "setting"
|
||||
arity = 2
|
||||
|
||||
def convert(self, config, param, ctx):
|
||||
name, value = config
|
||||
if name in DEFAULT_SETTINGS:
|
||||
# For backwards compatibility with how this worked prior to
|
||||
# Datasette 1.0, we turn bare setting names into setting.name
|
||||
# Type checking for those older settings
|
||||
if name not in DEFAULT_SETTINGS:
|
||||
msg = (
|
||||
OBSOLETE_SETTINGS.get(name)
|
||||
or f"{name} is not a valid option (--help-settings to see all)"
|
||||
)
|
||||
self.fail(
|
||||
msg,
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return
|
||||
# Type checking
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
name = "settings.{}".format(name)
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, "true" if value_as_boolean(value) else "false"
|
||||
return name, value_as_boolean(value)
|
||||
except ValueAsBooleanError:
|
||||
self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx)
|
||||
return
|
||||
elif isinstance(default, int):
|
||||
if not value.isdigit():
|
||||
self.fail(f'"{name}" should be an integer', param, ctx)
|
||||
return name, value
|
||||
return
|
||||
return name, int(value)
|
||||
elif isinstance(default, str):
|
||||
return name, value
|
||||
else:
|
||||
# Should never happen:
|
||||
self.fail("Invalid option")
|
||||
return name, value
|
||||
|
||||
|
||||
def sqlite_extensions(fn):
|
||||
fn = click.option(
|
||||
return click.option(
|
||||
"sqlite_extensions",
|
||||
"--load-extension",
|
||||
type=LoadExtension(),
|
||||
envvar="DATASETTE_LOAD_EXTENSION",
|
||||
envvar="SQLITE_EXTENSIONS",
|
||||
multiple=True,
|
||||
help="Path to a SQLite extension to load, and optional entrypoint",
|
||||
)(fn)
|
||||
|
||||
# Wrap it in a custom error handler
|
||||
@functools.wraps(fn)
|
||||
def wrapped(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except AttributeError as e:
|
||||
if "enable_load_extension" in str(e):
|
||||
raise click.ClickException(textwrap.dedent("""
|
||||
Your Python installation does not have the ability to load SQLite extensions.
|
||||
|
||||
More information: https://datasette.io/help/extensions
|
||||
""").strip())
|
||||
raise
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@click.group(cls=DefaultGroup, default="serve", default_if_no_args=True)
|
||||
@click.version_option(version=__version__)
|
||||
|
|
@ -142,7 +159,9 @@ def inspect(files, inspect_file, sqlite_extensions):
|
|||
This can then be passed to "datasette --inspect-file" to speed up count
|
||||
operations against immutable database files.
|
||||
"""
|
||||
inspect_data = run_sync(lambda: inspect_(files, sqlite_extensions))
|
||||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||
loop = asyncio.get_event_loop()
|
||||
inspect_data = loop.run_until_complete(inspect_(files, sqlite_extensions))
|
||||
if inspect_file == "-":
|
||||
sys.stdout.write(json.dumps(inspect_data, indent=2))
|
||||
else:
|
||||
|
|
@ -154,6 +173,9 @@ async def inspect_(files, sqlite_extensions):
|
|||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||
data = {}
|
||||
for name, database in app.databases.items():
|
||||
if name == "_internal":
|
||||
# Don't include the in-memory _internal database
|
||||
continue
|
||||
counts = await database.table_counts(limit=3600 * 1000)
|
||||
data[name] = {
|
||||
"hash": database.hash,
|
||||
|
|
@ -179,22 +201,14 @@ pm.hook.publish_subcommand(publish=publish)
|
|||
|
||||
@cli.command()
|
||||
@click.option("--all", help="Include built-in default plugins", is_flag=True)
|
||||
@click.option(
|
||||
"--requirements", help="Output requirements.txt of installed plugins", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--plugins-dir",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
||||
help="Path to directory containing custom plugins",
|
||||
)
|
||||
def plugins(all, requirements, plugins_dir):
|
||||
def plugins(all, plugins_dir):
|
||||
"""List currently installed plugins"""
|
||||
app = Datasette([], plugins_dir=plugins_dir)
|
||||
if requirements:
|
||||
for plugin in app._plugins():
|
||||
if plugin["version"]:
|
||||
click.echo("{}=={}".format(plugin["name"], plugin["version"]))
|
||||
else:
|
||||
click.echo(json.dumps(app._plugins(all=all), indent=4))
|
||||
|
||||
|
||||
|
|
@ -305,32 +319,15 @@ def package(
|
|||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("packages", nargs=-1)
|
||||
@click.argument("packages", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version"
|
||||
)
|
||||
@click.option(
|
||||
"-r",
|
||||
"--requirement",
|
||||
type=click.Path(exists=True),
|
||||
help="Install from requirements file",
|
||||
)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--editable",
|
||||
help="Install a project in editable mode from this path",
|
||||
)
|
||||
def install(packages, upgrade, requirement, editable):
|
||||
def install(packages, upgrade):
|
||||
"""Install plugins and packages from PyPI into the same environment as Datasette"""
|
||||
if not packages and not requirement and not editable:
|
||||
raise click.UsageError("Please specify at least one package to install")
|
||||
args = ["pip", "install"]
|
||||
if upgrade:
|
||||
args += ["--upgrade"]
|
||||
if editable:
|
||||
args += ["--editable", editable]
|
||||
if requirement:
|
||||
args += ["-r", requirement]
|
||||
args += list(packages)
|
||||
sys.argv = args
|
||||
run_module("pip", run_name="__main__")
|
||||
|
|
@ -411,17 +408,16 @@ def uninstall(packages, yes):
|
|||
)
|
||||
@click.option("--memory", is_flag=True, help="Make /_memory database available")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
type=click.File(mode="r"),
|
||||
help="Path to JSON/YAML Datasette configuration file",
|
||||
type=Config(),
|
||||
help="Deprecated: set config option using configname:value. Use --setting instead.",
|
||||
multiple=True,
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--setting",
|
||||
"settings",
|
||||
type=Setting(),
|
||||
help="nested.key, value setting to use in Datasette configuration",
|
||||
help="Setting, see docs.datasette.io/en/stable/settings.html",
|
||||
multiple=True,
|
||||
)
|
||||
@click.option(
|
||||
|
|
@ -434,28 +430,10 @@ def uninstall(packages, yes):
|
|||
help="Output URL that sets a cookie authenticating the root user",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--default-deny",
|
||||
help="Deny all permissions by default",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--get",
|
||||
help="Run an HTTP GET request against this path, print results and exit",
|
||||
)
|
||||
@click.option(
|
||||
"--headers",
|
||||
is_flag=True,
|
||||
help="Include HTTP headers in --get output",
|
||||
)
|
||||
@click.option(
|
||||
"--token",
|
||||
help="API token to send with --get requests",
|
||||
)
|
||||
@click.option(
|
||||
"--actor",
|
||||
help="Actor to use for --get requests (JSON string)",
|
||||
)
|
||||
@click.option("--version-note", help="Additional note to show on /-/versions")
|
||||
@click.option("--help-settings", is_flag=True, help="Show available settings")
|
||||
@click.option("--pdb", is_flag=True, help="Launch debugger on any errors")
|
||||
|
|
@ -484,17 +462,10 @@ def uninstall(packages, yes):
|
|||
@click.option(
|
||||
"--ssl-keyfile",
|
||||
help="SSL key file",
|
||||
envvar="DATASETTE_SSL_KEYFILE",
|
||||
)
|
||||
@click.option(
|
||||
"--ssl-certfile",
|
||||
help="SSL certificate file",
|
||||
envvar="DATASETTE_SSL_CERTFILE",
|
||||
)
|
||||
@click.option(
|
||||
"--internal",
|
||||
type=click.Path(),
|
||||
help="Path to a persistent Datasette internal SQLite database",
|
||||
)
|
||||
def serve(
|
||||
files,
|
||||
|
|
@ -515,11 +486,7 @@ def serve(
|
|||
settings,
|
||||
secret,
|
||||
root,
|
||||
default_deny,
|
||||
get,
|
||||
headers,
|
||||
token,
|
||||
actor,
|
||||
version_note,
|
||||
help_settings,
|
||||
pdb,
|
||||
|
|
@ -529,7 +496,6 @@ def serve(
|
|||
nolock,
|
||||
ssl_keyfile,
|
||||
ssl_certfile,
|
||||
internal,
|
||||
return_instance=False,
|
||||
):
|
||||
"""Serve up specified SQLite database files with a web UI"""
|
||||
|
|
@ -547,11 +513,9 @@ def serve(
|
|||
if reload:
|
||||
import hupper
|
||||
|
||||
reloader = hupper.start_reloader("datasette.cli.cli")
|
||||
reloader = hupper.start_reloader("datasette.cli.serve")
|
||||
if immutable:
|
||||
reloader.watch_files(immutable)
|
||||
if config:
|
||||
reloader.watch_files([config.name])
|
||||
if metadata:
|
||||
reloader.watch_files([metadata.name])
|
||||
|
||||
|
|
@ -564,60 +528,44 @@ def serve(
|
|||
if metadata:
|
||||
metadata_data = parse_metadata(metadata.read())
|
||||
|
||||
config_data = None
|
||||
combined_settings = {}
|
||||
if config:
|
||||
config_data = parse_metadata(config.read())
|
||||
|
||||
config_data = config_data or {}
|
||||
|
||||
# Merge in settings from -s/--setting
|
||||
if settings:
|
||||
settings_updates = pairs_to_nested_config(settings)
|
||||
# Merge recursively, to avoid over-writing nested values
|
||||
# https://github.com/simonw/datasette/issues/2389
|
||||
deep_dict_update(config_data, settings_updates)
|
||||
click.echo(
|
||||
"--config name:value will be deprecated in Datasette 1.0, use --setting name value instead",
|
||||
err=True,
|
||||
)
|
||||
combined_settings.update(config)
|
||||
combined_settings.update(settings)
|
||||
|
||||
kwargs = dict(
|
||||
immutables=immutable,
|
||||
cache_headers=not reload,
|
||||
cors=cors,
|
||||
inspect_data=inspect_data,
|
||||
config=config_data,
|
||||
metadata=metadata_data,
|
||||
sqlite_extensions=sqlite_extensions,
|
||||
template_dir=template_dir,
|
||||
plugins_dir=plugins_dir,
|
||||
static_mounts=static,
|
||||
settings=None, # These are passed in config= now
|
||||
settings=combined_settings,
|
||||
memory=memory,
|
||||
secret=secret,
|
||||
version_note=version_note,
|
||||
pdb=pdb,
|
||||
crossdb=crossdb,
|
||||
nolock=nolock,
|
||||
internal=internal,
|
||||
default_deny=default_deny,
|
||||
)
|
||||
|
||||
# Separate directories from files
|
||||
directories = [f for f in files if os.path.isdir(f)]
|
||||
file_paths = [f for f in files if not os.path.isdir(f)]
|
||||
|
||||
# Handle config_dir - only one directory allowed
|
||||
if len(directories) > 1:
|
||||
raise click.ClickException(
|
||||
"Cannot pass multiple directories. Pass a single directory as config_dir."
|
||||
)
|
||||
elif len(directories) == 1:
|
||||
kwargs["config_dir"] = pathlib.Path(directories[0])
|
||||
# if files is a single directory, use that as config_dir=
|
||||
if 1 == len(files) and os.path.isdir(files[0]):
|
||||
kwargs["config_dir"] = pathlib.Path(files[0])
|
||||
files = []
|
||||
|
||||
# Verify list of files, create if needed (and --create)
|
||||
for file in file_paths:
|
||||
for file in files:
|
||||
if not pathlib.Path(file).exists():
|
||||
if create:
|
||||
conn = sqlite3.connect(file)
|
||||
conn.execute("vacuum")
|
||||
conn.close()
|
||||
sqlite3.connect(file).execute("vacuum")
|
||||
else:
|
||||
raise click.ClickException(
|
||||
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
|
||||
|
|
@ -625,32 +573,8 @@ def serve(
|
|||
)
|
||||
)
|
||||
|
||||
# Check for duplicate files by resolving all paths to their absolute forms
|
||||
# Collect all database files that will be loaded (explicit files + config_dir files)
|
||||
all_db_files = []
|
||||
|
||||
# Add explicit files
|
||||
for file in file_paths:
|
||||
all_db_files.append((file, pathlib.Path(file).resolve()))
|
||||
|
||||
# Add config_dir databases if config_dir is set
|
||||
if "config_dir" in kwargs:
|
||||
config_dir = kwargs["config_dir"]
|
||||
for ext in ("db", "sqlite", "sqlite3"):
|
||||
for db_file in config_dir.glob(f"*.{ext}"):
|
||||
all_db_files.append((str(db_file), db_file.resolve()))
|
||||
|
||||
# Check for duplicates
|
||||
seen = {}
|
||||
for original_path, resolved_path in all_db_files:
|
||||
if resolved_path in seen:
|
||||
raise click.ClickException(
|
||||
f"Duplicate database file: '{original_path}' and '{seen[resolved_path]}' "
|
||||
f"both refer to {resolved_path}"
|
||||
)
|
||||
seen[resolved_path] = original_path
|
||||
|
||||
files = file_paths
|
||||
# De-duplicate files so 'datasette db.db db.db' only attaches one /db
|
||||
files = list(dict.fromkeys(files))
|
||||
|
||||
try:
|
||||
ds = Datasette(files, **kwargs)
|
||||
|
|
@ -663,43 +587,16 @@ def serve(
|
|||
# Private utility mechanism for writing unit tests
|
||||
return ds
|
||||
|
||||
# Run async soundness checks before startup hooks, since invoke_startup
|
||||
# now populates internal tables which requires querying each database
|
||||
run_sync(lambda: check_databases(ds))
|
||||
|
||||
# Run the "startup" plugin hooks
|
||||
try:
|
||||
run_sync(ds.invoke_startup)
|
||||
except StartupError as e:
|
||||
raise click.ClickException(e.args[0])
|
||||
asyncio.get_event_loop().run_until_complete(ds.invoke_startup())
|
||||
|
||||
if headers and not get:
|
||||
raise click.ClickException("--headers can only be used with --get")
|
||||
|
||||
if token and not get:
|
||||
raise click.ClickException("--token can only be used with --get")
|
||||
# Run async soundness checks - but only if we're not under pytest
|
||||
asyncio.get_event_loop().run_until_complete(check_databases(ds))
|
||||
|
||||
if get:
|
||||
client = TestClient(ds)
|
||||
request_headers = {}
|
||||
if token:
|
||||
request_headers["Authorization"] = "Bearer {}".format(token)
|
||||
cookies = {}
|
||||
if actor:
|
||||
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
||||
response = client.get(get, headers=request_headers, cookies=cookies)
|
||||
|
||||
if headers:
|
||||
# Output HTTP status code, headers, two newlines, then the response body
|
||||
click.echo(f"HTTP/1.1 {response.status}")
|
||||
for key, value in response.headers.items():
|
||||
click.echo(f"{key}: {value}")
|
||||
if response.text:
|
||||
click.echo()
|
||||
response = client.get(get)
|
||||
click.echo(response.text)
|
||||
else:
|
||||
click.echo(response.text)
|
||||
|
||||
exit_code = 0 if response.status == 200 else 1
|
||||
sys.exit(exit_code)
|
||||
return
|
||||
|
|
@ -707,15 +604,16 @@ def serve(
|
|||
# Start the server
|
||||
url = None
|
||||
if root:
|
||||
ds.root_enabled = True
|
||||
url = "http://{}:{}{}?token={}".format(
|
||||
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
||||
)
|
||||
click.echo(url)
|
||||
print(url)
|
||||
if open_browser:
|
||||
if url is None:
|
||||
# Figure out most convenient URL - to table, database or homepage
|
||||
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||
path = asyncio.get_event_loop().run_until_complete(
|
||||
initial_path_for_datasette(ds)
|
||||
)
|
||||
url = f"http://{host}:{port}{path}"
|
||||
webbrowser.open(url)
|
||||
uvicorn_kwargs = dict(
|
||||
|
|
@ -730,136 +628,6 @@ def serve(
|
|||
uvicorn.run(ds.app(), **uvicorn_kwargs)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("id")
|
||||
@click.option(
|
||||
"--secret",
|
||||
help="Secret used for signing the API tokens",
|
||||
envvar="DATASETTE_SECRET",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--expires-after",
|
||||
help="Token should expire after this many seconds",
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
"alls",
|
||||
"-a",
|
||||
"--all",
|
||||
type=str,
|
||||
metavar="ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action",
|
||||
)
|
||||
@click.option(
|
||||
"databases",
|
||||
"-d",
|
||||
"--database",
|
||||
type=(str, str),
|
||||
metavar="DB ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action on this database",
|
||||
)
|
||||
@click.option(
|
||||
"resources",
|
||||
"-r",
|
||||
"--resource",
|
||||
type=(str, str, str),
|
||||
metavar="DB RESOURCE ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action on this database resource (a table, SQL view or named query)",
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
help="Show decoded token",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--plugins-dir",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
||||
help="Path to directory containing custom plugins",
|
||||
)
|
||||
def create_token(
|
||||
id, secret, expires_after, alls, databases, resources, debug, plugins_dir
|
||||
):
|
||||
"""
|
||||
Create a signed API token for the specified actor ID
|
||||
|
||||
Example:
|
||||
|
||||
datasette create-token root --secret mysecret
|
||||
|
||||
To allow only "view-database-download" for all databases:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret mysecret \\
|
||||
--all view-database-download
|
||||
|
||||
To allow "create-table" against a specific database:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret mysecret \\
|
||||
--database mydb create-table
|
||||
|
||||
To allow "insert-row" against a specific table:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret myscret \\
|
||||
--resource mydb mytable insert-row
|
||||
|
||||
Restricted actions can be specified multiple times using
|
||||
multiple --all, --database, and --resource options.
|
||||
|
||||
Add --debug to see a decoded version of the token.
|
||||
"""
|
||||
ds = Datasette(secret=secret, plugins_dir=plugins_dir)
|
||||
|
||||
# Run ds.invoke_startup() in an event loop
|
||||
try:
|
||||
run_sync(ds.invoke_startup)
|
||||
except StartupError as e:
|
||||
raise click.ClickException(e.args[0])
|
||||
|
||||
# Warn about any unknown actions
|
||||
actions = []
|
||||
actions.extend(alls)
|
||||
actions.extend([p[1] for p in databases])
|
||||
actions.extend([p[2] for p in resources])
|
||||
for action in actions:
|
||||
if not ds.actions.get(action):
|
||||
click.secho(
|
||||
f" Unknown permission: {action} ",
|
||||
fg="red",
|
||||
err=True,
|
||||
)
|
||||
|
||||
from datasette.tokens import TokenRestrictions
|
||||
|
||||
restrictions = TokenRestrictions()
|
||||
for action in alls:
|
||||
restrictions.allow_all(action)
|
||||
for database, action in databases:
|
||||
restrictions.allow_database(database, action)
|
||||
for database, resource, action in resources:
|
||||
restrictions.allow_resource(database, resource, action)
|
||||
|
||||
token = run_sync(
|
||||
lambda: ds.create_token(
|
||||
id,
|
||||
expires_after=expires_after,
|
||||
restrictions=restrictions,
|
||||
handler="signed",
|
||||
)
|
||||
)
|
||||
click.echo(token)
|
||||
if debug:
|
||||
encoded = token[len("dstok_") :]
|
||||
click.echo("\nDecoded:\n")
|
||||
click.echo(json.dumps(ds.unsign(encoded, namespace="token"), indent=2))
|
||||
|
||||
|
||||
pm.hook.register_commands(cli=cli)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class SQLiteType(Enum):
|
||||
TEXT = "TEXT"
|
||||
INTEGER = "INTEGER"
|
||||
REAL = "REAL"
|
||||
BLOB = "BLOB"
|
||||
NULL = "NULL"
|
||||
|
||||
@classmethod
|
||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None":
|
||||
if declared_type is None:
|
||||
return cls.NULL
|
||||
|
||||
normalized = declared_type.strip().upper()
|
||||
if not normalized:
|
||||
return cls.NULL
|
||||
|
||||
if normalized == cls.NULL.value:
|
||||
return cls.NULL
|
||||
if "INT" in normalized:
|
||||
return cls.INTEGER
|
||||
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
|
||||
return cls.TEXT
|
||||
if "BLOB" in normalized:
|
||||
return cls.BLOB
|
||||
if any(
|
||||
token in normalized
|
||||
for token in ("REAL", "FLOA", "DOUB") # codespell:ignore doub
|
||||
):
|
||||
return cls.REAL
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ColumnType:
|
||||
"""
|
||||
Base class for column types.
|
||||
|
||||
Subclasses must define ``name`` and ``description`` as class attributes:
|
||||
|
||||
- ``name``: Unique identifier string. Lowercase, no spaces.
|
||||
Examples: "markdown", "file", "email", "url", "point", "image".
|
||||
- ``description``: Human-readable label for admin UI dropdowns.
|
||||
Examples: "Markdown text", "File reference", "Email address".
|
||||
- ``sqlite_types``: Optional tuple of SQLiteType values restricting
|
||||
which SQLite column types this ColumnType can be assigned to.
|
||||
|
||||
Instantiate with an optional ``config`` dict to bind per-column
|
||||
configuration::
|
||||
|
||||
ct = MyColumnType(config={"key": "value"})
|
||||
ct.config # {"key": "value"}
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
sqlite_types: tuple[SQLiteType, ...] | None = None
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
async def render_cell(self, value, column, table, database, datasette, request):
|
||||
"""
|
||||
Return an HTML string to render this cell value, or None to
|
||||
fall through to the default render_cell plugin hook chain.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
"""
|
||||
Validate a value before it is written. Return None if valid,
|
||||
or a string error message if invalid.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def transform_value(self, value, datasette):
|
||||
"""
|
||||
Transform a value before it appears in JSON API output.
|
||||
Return the transformed value. Default: return unchanged.
|
||||
"""
|
||||
return value
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
"""
|
||||
Header-based CSRF (Cross-Origin) protection.
|
||||
|
||||
Datasette uses the Sec-Fetch-Site + Origin header approach described in
|
||||
Filippo Valsorda's article (https://words.filippo.io/csrf/) and implemented
|
||||
in Go 1.25's http.CrossOriginProtection. This replaces the previous
|
||||
token-based asgi-csrf mechanism.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import urllib.parse
|
||||
|
||||
from .utils.asgi import asgi_send
|
||||
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
|
||||
|
||||
|
||||
def _normalize_headers(raw_headers):
|
||||
"""Lowercase header names; for duplicates, last value wins."""
|
||||
result = {}
|
||||
for name, value in raw_headers:
|
||||
if isinstance(name, str):
|
||||
name = name.encode("latin-1")
|
||||
if isinstance(value, str):
|
||||
value = value.encode("latin-1")
|
||||
result[name.lower()] = value
|
||||
return result
|
||||
|
||||
|
||||
def _origin_tuple(value):
|
||||
"""
|
||||
Parse an origin-like string into ``(scheme, host, port)`` with default
|
||||
ports filled in. Raises ``ValueError`` for malformed input.
|
||||
"""
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not scheme or not host:
|
||||
raise ValueError("missing scheme or host in {!r}".format(value))
|
||||
port = parsed.port # may raise ValueError on bad ports
|
||||
if port is None:
|
||||
port = DEFAULT_PORTS.get(scheme)
|
||||
if port is None:
|
||||
raise ValueError("unknown default port for scheme {!r}".format(scheme))
|
||||
return scheme, host, port
|
||||
|
||||
|
||||
def _install_legacy_csrftoken(scope):
|
||||
"""
|
||||
Populate ``scope["csrftoken"]`` with a callable returning a per-request
|
||||
random token. Provided for plugin compatibility only - core no longer
|
||||
uses this value for CSRF enforcement.
|
||||
"""
|
||||
|
||||
def csrftoken():
|
||||
if "_datasette_legacy_csrftoken" not in scope:
|
||||
scope["_datasette_legacy_csrftoken"] = secrets.token_urlsafe(32)
|
||||
return scope["_datasette_legacy_csrftoken"]
|
||||
|
||||
scope["csrftoken"] = csrftoken
|
||||
|
||||
|
||||
class CrossOriginProtectionMiddleware:
|
||||
"""
|
||||
Modern CSRF protection using the Sec-Fetch-Site and Origin headers.
|
||||
|
||||
Based on Filippo Valsorda's algorithm, as implemented in Go 1.25's
|
||||
http.CrossOriginProtection. See https://words.filippo.io/csrf/
|
||||
|
||||
Unsafe-method requests are allowed through only if they look same-origin.
|
||||
Non-browser clients (curl, etc.) send neither Sec-Fetch-Site nor Origin
|
||||
and are passed through unchanged - CSRF is a browser-only attack.
|
||||
"""
|
||||
|
||||
SAFE_METHODS = SAFE_METHODS
|
||||
|
||||
def __init__(self, app, datasette):
|
||||
self.app = app
|
||||
self.datasette = datasette
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
_install_legacy_csrftoken(scope)
|
||||
|
||||
if scope.get("method", "GET") in self.SAFE_METHODS:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = _normalize_headers(scope.get("headers") or [])
|
||||
|
||||
authorization = headers.get(b"authorization", b"").decode("latin-1")
|
||||
cookie_header = headers.get(b"cookie")
|
||||
# Bearer-token requests are not ambient browser credentials, so they
|
||||
# are not CSRF-vulnerable. Narrowly exempt them from the header check
|
||||
# before evaluating Sec-Fetch-Site / Origin. Only "Bearer" is exempt;
|
||||
# schemes like Basic or Digest can be browser-managed and ambient.
|
||||
# If the request also carries a Cookie header, ambient cookie auth
|
||||
# could be in play, so do NOT treat it as exempt.
|
||||
if authorization and not cookie_header:
|
||||
parts = authorization.split(None, 1)
|
||||
if parts and parts[0].lower() == "bearer":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
origin_bytes = headers.get(b"origin")
|
||||
sec_fetch_site_bytes = headers.get(b"sec-fetch-site")
|
||||
host_bytes = headers.get(b"host", b"")
|
||||
origin = origin_bytes.decode("latin-1") if origin_bytes else None
|
||||
sec_fetch_site = (
|
||||
sec_fetch_site_bytes.decode("latin-1") if sec_fetch_site_bytes else None
|
||||
)
|
||||
host = host_bytes.decode("latin-1")
|
||||
|
||||
# Primary defense: Sec-Fetch-Site (set by browsers, unforgeable from JS)
|
||||
if sec_fetch_site is not None:
|
||||
if sec_fetch_site in ("same-origin", "none"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await self._forbid(
|
||||
send,
|
||||
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
|
||||
sec_fetch_site
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# No Sec-Fetch-Site and no Origin -> non-browser client (curl, API, etc.)
|
||||
if origin is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# Fallback for older browsers: Origin must match the request's own
|
||||
# scheme + host + port. Compare full origin tuples, not host alone.
|
||||
request_scheme = self._request_scheme(scope)
|
||||
try:
|
||||
origin_tuple = _origin_tuple(origin)
|
||||
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
|
||||
except ValueError:
|
||||
await self._forbid(
|
||||
send,
|
||||
"Malformed Origin {!r} or Host {!r}".format(origin, host),
|
||||
)
|
||||
return
|
||||
|
||||
if origin_tuple == expected_tuple:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
await self._forbid(
|
||||
send,
|
||||
"Origin {!r} does not match Host {!r}".format(origin, host),
|
||||
)
|
||||
|
||||
def _request_scheme(self, scope):
|
||||
if self.datasette is not None:
|
||||
try:
|
||||
if self.datasette.setting("force_https_urls"):
|
||||
return "https"
|
||||
except Exception:
|
||||
pass
|
||||
return scope.get("scheme") or "http"
|
||||
|
||||
async def _forbid(self, send, reason):
|
||||
await asgi_send(
|
||||
send,
|
||||
content=await self.datasette.render_template(
|
||||
"csrf_error.html", {"reason": reason}
|
||||
),
|
||||
status=403,
|
||||
content_type="text/html; charset=utf-8",
|
||||
)
|
||||
|
|
@ -1,32 +1,24 @@
|
|||
import asyncio
|
||||
import atexit
|
||||
from collections import namedtuple
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
import janus
|
||||
import queue
|
||||
import sqlite_utils
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from .tracer import trace
|
||||
from .utils import (
|
||||
call_with_supported_arguments,
|
||||
detect_fts,
|
||||
detect_primary_keys,
|
||||
detect_spatialite,
|
||||
get_all_foreign_keys,
|
||||
get_outbound_foreign_keys,
|
||||
md5_not_usedforsecurity,
|
||||
sqlite_timelimit,
|
||||
sqlite3,
|
||||
table_columns,
|
||||
table_column_details,
|
||||
)
|
||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
||||
from .utils.sqlite import sqlite_version
|
||||
from .inspect import inspect_hash
|
||||
|
||||
connections = threading.local()
|
||||
|
|
@ -34,75 +26,29 @@ connections = threading.local()
|
|||
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
|
||||
|
||||
|
||||
class DatasetteClosedError(RuntimeError):
|
||||
"""Raised when using a Datasette or Database instance after close()."""
|
||||
|
||||
|
||||
_SHUTDOWN = object()
|
||||
|
||||
|
||||
class Database:
|
||||
# For table counts stop at this many rows:
|
||||
count_limit = 10000
|
||||
_thread_local_id_counter = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ds,
|
||||
path=None,
|
||||
is_mutable=True,
|
||||
is_memory=False,
|
||||
memory_name=None,
|
||||
mode=None,
|
||||
is_temp_disk=False,
|
||||
self, ds, path=None, is_mutable=True, is_memory=False, memory_name=None
|
||||
):
|
||||
self.name = None
|
||||
self._thread_local_id = f"x{self._thread_local_id_counter}"
|
||||
Database._thread_local_id_counter += 1
|
||||
self.route = None
|
||||
self.ds = ds
|
||||
self.path = path
|
||||
self.is_mutable = is_mutable
|
||||
self.is_memory = is_memory
|
||||
self.memory_name = memory_name
|
||||
self.is_temp_disk = is_temp_disk
|
||||
if memory_name is not None:
|
||||
self.is_memory = True
|
||||
if is_temp_disk:
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".db", prefix="datasette_temp_")
|
||||
os.close(fd)
|
||||
self.path = temp_path
|
||||
self.is_mutable = True
|
||||
self.mode = "rwc"
|
||||
self._wal_enabled = False
|
||||
atexit.register(self._cleanup_temp_file)
|
||||
else:
|
||||
self._wal_enabled = False
|
||||
self.cached_hash = None
|
||||
self.cached_size = None
|
||||
self._cached_table_counts = None
|
||||
self._write_thread = None
|
||||
self._write_queue = None
|
||||
self._closed = False
|
||||
self._pending_execute_futures = set()
|
||||
self._pending_execute_futures_lock = threading.Lock()
|
||||
# These are used when in non-threaded mode:
|
||||
self._read_connection = None
|
||||
self._write_connection = None
|
||||
# This is used to track all file connections so they can be closed
|
||||
self._all_file_connections = []
|
||||
if not is_temp_disk:
|
||||
self.mode = mode
|
||||
|
||||
def _check_not_closed(self):
|
||||
if self._closed:
|
||||
raise DatasetteClosedError(
|
||||
"Database {!r} has been closed".format(self.name)
|
||||
)
|
||||
|
||||
def _remove_pending_execute_future(self, future):
|
||||
with self._pending_execute_futures_lock:
|
||||
self._pending_execute_futures.discard(future)
|
||||
|
||||
@property
|
||||
def cached_table_counts(self):
|
||||
|
|
@ -116,15 +62,7 @@ class Database:
|
|||
}
|
||||
return self._cached_table_counts
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
if self.hash:
|
||||
return self.hash[:6]
|
||||
return md5_not_usedforsecurity(self.name)[:6]
|
||||
|
||||
def suggest_name(self):
|
||||
if self.is_temp_disk:
|
||||
return "_temp_disk"
|
||||
if self.path:
|
||||
return Path(self.path).stem
|
||||
elif self.memory_name:
|
||||
|
|
@ -133,20 +71,18 @@ class Database:
|
|||
return "db"
|
||||
|
||||
def connect(self, write=False):
|
||||
extra_kwargs = {}
|
||||
if write:
|
||||
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
||||
if self.memory_name:
|
||||
uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
|
||||
conn = sqlite3.connect(
|
||||
uri, uri=True, check_same_thread=False, **extra_kwargs
|
||||
uri,
|
||||
uri=True,
|
||||
check_same_thread=False,
|
||||
)
|
||||
if not write:
|
||||
conn.execute("PRAGMA query_only=1")
|
||||
return conn
|
||||
if self.is_memory:
|
||||
return sqlite3.connect(":memory:", uri=True)
|
||||
|
||||
# mode=ro or immutable=1?
|
||||
if self.is_mutable:
|
||||
qs = "?mode=ro"
|
||||
|
|
@ -157,110 +93,36 @@ class Database:
|
|||
assert not (write and not self.is_mutable)
|
||||
if write:
|
||||
qs = ""
|
||||
if self.mode is not None:
|
||||
qs = f"?mode={self.mode}"
|
||||
conn = sqlite3.connect(
|
||||
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
|
||||
f"file:{self.path}{qs}", uri=True, check_same_thread=False
|
||||
)
|
||||
self._all_file_connections.append(conn)
|
||||
if self.is_temp_disk and not self._wal_enabled:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._wal_enabled = True
|
||||
return conn
|
||||
|
||||
def close(self):
|
||||
"""Release all resources held by this database.
|
||||
|
||||
Idempotent. After close() further calls to execute()/execute_fn()/
|
||||
execute_write()/execute_write_fn() raise DatasetteClosedError.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
with self._pending_execute_futures_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
pending_execute_futures = tuple(self._pending_execute_futures)
|
||||
# Shut down the write thread, if any, via a sentinel. The thread
|
||||
# drains any writes already queued before the sentinel and then
|
||||
# closes its own write connection and returns.
|
||||
write_thread = self._write_thread
|
||||
if write_thread is not None and self._write_queue is not None:
|
||||
self._write_queue.put(_SHUTDOWN)
|
||||
write_thread.join(timeout=10)
|
||||
if write_thread.is_alive():
|
||||
sys.stderr.write(
|
||||
"Datasette: write thread for {!r} did not exit within 10s\n".format(
|
||||
self.name
|
||||
)
|
||||
)
|
||||
sys.stderr.flush()
|
||||
for future in pending_execute_futures:
|
||||
try:
|
||||
future.result()
|
||||
except Exception:
|
||||
pass
|
||||
# Close anything still tracked in _all_file_connections
|
||||
# Close all connections - useful to avoid running out of file handles in tests
|
||||
for connection in self._all_file_connections:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._all_file_connections = []
|
||||
# Drop per-thread cached read connections we can reach
|
||||
try:
|
||||
delattr(connections, self._thread_local_id)
|
||||
except AttributeError:
|
||||
pass
|
||||
# Close non-threaded-mode cached connections if still open
|
||||
if self._read_connection is not None:
|
||||
try:
|
||||
self._read_connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._read_connection = None
|
||||
if self._write_connection is not None:
|
||||
try:
|
||||
self._write_connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._write_connection = None
|
||||
if self.is_temp_disk:
|
||||
self._cleanup_temp_file()
|
||||
|
||||
def _cleanup_temp_file(self):
|
||||
if self.is_temp_disk and self.path:
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.unlink(self.path + suffix)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def execute_write(self, sql, params=None, block=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
||||
async def execute_write(self, sql, params=None, block=True):
|
||||
def _inner(conn):
|
||||
with conn:
|
||||
return conn.execute(sql, params or [])
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||
results = await self.execute_write_fn(_inner, block=block)
|
||||
return results
|
||||
|
||||
async def execute_write_script(self, sql, block=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
||||
async def execute_write_script(self, sql, block=True):
|
||||
def _inner(conn):
|
||||
with conn:
|
||||
return conn.executescript(sql)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), executescript=True):
|
||||
results = await self.execute_write_fn(
|
||||
_inner, block=block, transaction=False, request=request
|
||||
)
|
||||
results = await self.execute_write_fn(_inner, block=block)
|
||||
return results
|
||||
|
||||
async def execute_write_many(self, sql, params_seq, block=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
||||
async def execute_write_many(self, sql, params_seq, block=True):
|
||||
def _inner(conn):
|
||||
count = 0
|
||||
|
||||
|
|
@ -270,145 +132,43 @@ class Database:
|
|||
count += 1
|
||||
yield param
|
||||
|
||||
with conn:
|
||||
return conn.executemany(sql, count_params(params_seq)), count
|
||||
|
||||
with trace(
|
||||
"sql", database=self.name, sql=sql.strip(), executemany=True
|
||||
) as kwargs:
|
||||
results, count = await self.execute_write_fn(
|
||||
_inner, block=block, request=request
|
||||
)
|
||||
results, count = await self.execute_write_fn(_inner, block=block)
|
||||
kwargs["count"] = count
|
||||
return results
|
||||
|
||||
async def execute_isolated_fn(self, fn):
|
||||
self._check_not_closed()
|
||||
# Open a new connection just for the duration of this function
|
||||
# blocking the write queue to avoid any writes occurring during it
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = fn(isolated_connection)
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_file_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
return result
|
||||
else:
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
|
||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
||||
self._check_not_closed()
|
||||
|
||||
return await self.execute_isolated_fn(
|
||||
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
|
||||
)
|
||||
|
||||
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||
self._check_not_closed()
|
||||
pending_events = []
|
||||
|
||||
def track_event(event):
|
||||
pending_events.append(event)
|
||||
|
||||
fn = self._wrap_fn_with_hooks(fn, request, transaction, track_event)
|
||||
async def execute_write_fn(self, fn, block=True):
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
if self._write_connection is None:
|
||||
self._write_connection = self.connect(write=True)
|
||||
self.ds._prepare_connection(self._write_connection, self.name)
|
||||
if transaction:
|
||||
with self._write_connection:
|
||||
result = fn(self._write_connection)
|
||||
else:
|
||||
result = fn(self._write_connection)
|
||||
else:
|
||||
result = await self._send_to_write_thread(
|
||||
fn, block=block, transaction=transaction
|
||||
)
|
||||
if block:
|
||||
for event in pending_events:
|
||||
await self.ds.track_event(event)
|
||||
else:
|
||||
# For non-blocking writes, spawn a background task to
|
||||
# dispatch events after the write thread completes
|
||||
task_id, reply_future = result
|
||||
return fn(self._write_connection)
|
||||
|
||||
async def _dispatch_events_after_write():
|
||||
try:
|
||||
await reply_future
|
||||
except Exception:
|
||||
# if the write failed, don't emit success events
|
||||
return
|
||||
for event in pending_events:
|
||||
await self.ds.track_event(event)
|
||||
|
||||
asyncio.ensure_future(_dispatch_events_after_write())
|
||||
result = task_id
|
||||
return result
|
||||
|
||||
def _wrap_fn_with_hooks(self, fn, request, transaction, track_event):
|
||||
from .plugins import pm
|
||||
|
||||
# Wrap fn so it receives track_event if its signature supports it.
|
||||
# Historically fn was called positionally, so any single-parameter
|
||||
# name (conn, connection, db, ...) worked. Preserve that by only
|
||||
# switching to keyword dependency injection when the callback
|
||||
# explicitly opts in by declaring a `track_event` parameter.
|
||||
original_fn = fn
|
||||
|
||||
if "track_event" in inspect.signature(original_fn).parameters:
|
||||
|
||||
def fn_with_track_event(conn):
|
||||
return call_with_supported_arguments(
|
||||
original_fn, conn=conn, track_event=track_event
|
||||
)
|
||||
|
||||
fn = fn_with_track_event
|
||||
|
||||
wrappers = pm.hook.write_wrapper(
|
||||
datasette=self.ds,
|
||||
database=self.name,
|
||||
request=request,
|
||||
transaction=transaction,
|
||||
)
|
||||
wrappers = [w for w in wrappers if w is not None]
|
||||
if not wrappers:
|
||||
return fn
|
||||
# Build the wrapped fn by nesting context manager generators.
|
||||
# The first wrapper returned by pluggy is outermost.
|
||||
for wrapper_factory in reversed(wrappers):
|
||||
fn = _apply_write_wrapper(fn, wrapper_factory, track_event)
|
||||
return fn
|
||||
|
||||
async def _send_to_write_thread(
|
||||
self, fn, block=True, isolated_connection=False, transaction=True
|
||||
):
|
||||
# threaded mode
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
if self._write_queue is None:
|
||||
self._write_queue = queue.Queue()
|
||||
if self._write_thread is None:
|
||||
self._write_thread = threading.Thread(
|
||||
target=self._execute_writes, daemon=True
|
||||
)
|
||||
self._write_thread.name = "_execute_writes for database {}".format(
|
||||
self.name
|
||||
)
|
||||
self._write_thread.start()
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
loop = asyncio.get_running_loop()
|
||||
reply_future = loop.create_future()
|
||||
self._write_queue.put(
|
||||
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
|
||||
)
|
||||
reply_queue = janus.Queue()
|
||||
self._write_queue.put(WriteTask(fn, task_id, reply_queue))
|
||||
if block:
|
||||
return await reply_future
|
||||
result = await reply_queue.async_q.get()
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
else:
|
||||
return task_id, reply_future
|
||||
return result
|
||||
else:
|
||||
return task_id
|
||||
|
||||
def _execute_writes(self):
|
||||
# Infinite looping thread that protects the single write connection
|
||||
|
|
@ -422,47 +182,18 @@ class Database:
|
|||
conn_exception = e
|
||||
while True:
|
||||
task = self._write_queue.get()
|
||||
if task is _SHUTDOWN:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
exception = None
|
||||
result = None
|
||||
if conn_exception is not None:
|
||||
exception = conn_exception
|
||||
elif task.isolated_connection:
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = task.fn(isolated_connection)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_file_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
result = conn_exception
|
||||
else:
|
||||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
_deliver_write_result(task, result, exception)
|
||||
result = e
|
||||
task.reply_queue.sync_q.put(result)
|
||||
|
||||
async def execute_fn(self, fn):
|
||||
self._check_not_closed()
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
if self._read_connection is None:
|
||||
|
|
@ -472,19 +203,16 @@ class Database:
|
|||
|
||||
# threaded mode
|
||||
def in_thread():
|
||||
conn = getattr(connections, self._thread_local_id, None)
|
||||
conn = getattr(connections, self.name, None)
|
||||
if not conn:
|
||||
conn = self.connect()
|
||||
self.ds._prepare_connection(conn, self.name)
|
||||
setattr(connections, self._thread_local_id, conn)
|
||||
setattr(connections, self.name, conn)
|
||||
return fn(conn)
|
||||
|
||||
with self._pending_execute_futures_lock:
|
||||
self._check_not_closed()
|
||||
future = self.ds.executor.submit(in_thread)
|
||||
self._pending_execute_futures.add(future)
|
||||
future.add_done_callback(self._remove_pending_execute_future)
|
||||
return await asyncio.wrap_future(future)
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
self.ds.executor, in_thread
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
|
|
@ -496,7 +224,6 @@ class Database:
|
|||
log_sql_errors=True,
|
||||
):
|
||||
"""Executes sql against db_name in a thread"""
|
||||
self._check_not_closed()
|
||||
page_size = page_size or self.ds.page_size
|
||||
|
||||
def sql_operation_in_thread(conn):
|
||||
|
|
@ -544,7 +271,7 @@ class Database:
|
|||
def hash(self):
|
||||
if self.cached_hash is not None:
|
||||
return self.cached_hash
|
||||
elif self.is_mutable or self.is_memory or self.is_temp_disk:
|
||||
elif self.is_mutable or self.is_memory:
|
||||
return None
|
||||
elif self.ds.inspect_data and self.ds.inspect_data.get(self.name):
|
||||
self.cached_hash = self.ds.inspect_data[self.name]["hash"]
|
||||
|
|
@ -578,7 +305,7 @@ class Database:
|
|||
try:
|
||||
table_count = (
|
||||
await self.execute(
|
||||
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
|
||||
f"select count(*) from [{table}]",
|
||||
custom_time_limit=limit,
|
||||
)
|
||||
).rows[0][0]
|
||||
|
|
@ -603,12 +330,7 @@ class Database:
|
|||
# But SQLite prior to 3.16.0 doesn't support pragma functions
|
||||
results = await self.execute("PRAGMA database_list;")
|
||||
# {'seq': 0, 'name': 'main', 'file': ''}
|
||||
return [
|
||||
AttachedDatabase(*row)
|
||||
for row in results.rows
|
||||
# Filter out the SQLite internal "temp" database, refs #2557
|
||||
if row["seq"] > 0 and row["name"] != "temp"
|
||||
]
|
||||
return [AttachedDatabase(*row) for row in results.rows if row["seq"] > 0]
|
||||
|
||||
async def table_exists(self, table):
|
||||
results = await self.execute(
|
||||
|
|
@ -624,7 +346,7 @@ class Database:
|
|||
|
||||
async def table_names(self):
|
||||
results = await self.execute(
|
||||
"select name from sqlite_master where type='table' order by name"
|
||||
"select name from sqlite_master where type='table'"
|
||||
)
|
||||
return [r[0] for r in results.rows]
|
||||
|
||||
|
|
@ -641,38 +363,12 @@ class Database:
|
|||
return await self.execute_fn(lambda conn: detect_fts(conn, table))
|
||||
|
||||
async def label_column_for_table(self, table):
|
||||
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
|
||||
explicit_label_column = self.ds.table_metadata(self.name, table).get(
|
||||
"label_column"
|
||||
)
|
||||
if explicit_label_column:
|
||||
return explicit_label_column
|
||||
|
||||
def column_details(conn):
|
||||
# Returns {column_name: (type, is_unique)}
|
||||
db = sqlite_utils.Database(conn)
|
||||
columns = db[table].columns_dict
|
||||
indexes = db[table].indexes
|
||||
details = {}
|
||||
for name in columns:
|
||||
is_unique = any(
|
||||
index
|
||||
for index in indexes
|
||||
if index.columns == [name] and index.unique
|
||||
)
|
||||
details[name] = (columns[name], is_unique)
|
||||
return details
|
||||
|
||||
column_details = await self.execute_fn(column_details)
|
||||
# Is there just one unique column that's text?
|
||||
unique_text_columns = [
|
||||
name
|
||||
for name, (type_, is_unique) in column_details.items()
|
||||
if is_unique and type_ is str
|
||||
]
|
||||
if len(unique_text_columns) == 1:
|
||||
return unique_text_columns[0]
|
||||
|
||||
column_names = list(column_details.keys())
|
||||
column_names = await self.execute_fn(lambda conn: table_columns(conn, table))
|
||||
# Is there a name or title column?
|
||||
name_or_title = [c for c in column_names if c.lower() in ("name", "title")]
|
||||
if name_or_title:
|
||||
|
|
@ -682,7 +378,6 @@ class Database:
|
|||
column_names
|
||||
and len(column_names) == 2
|
||||
and ("id" in column_names or "pk" in column_names)
|
||||
and not set(column_names) == {"id", "pk"}
|
||||
):
|
||||
return [c for c in column_names if c not in ("id", "pk")][0]
|
||||
# Couldn't find a label:
|
||||
|
|
@ -694,92 +389,21 @@ class Database:
|
|||
)
|
||||
|
||||
async def hidden_table_names(self):
|
||||
hidden_tables = []
|
||||
# Add any tables marked as hidden in config
|
||||
db_config = self.ds.config.get("databases", {}).get(self.name, {})
|
||||
if "tables" in db_config:
|
||||
hidden_tables += [
|
||||
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
|
||||
# Mark tables 'hidden' if they relate to FTS virtual tables
|
||||
hidden_tables = [
|
||||
r[0]
|
||||
for r in (
|
||||
await self.execute(
|
||||
"""
|
||||
select name from sqlite_master
|
||||
where rootpage = 0
|
||||
and (
|
||||
sql like '%VIRTUAL TABLE%USING FTS%'
|
||||
) or name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
"""
|
||||
)
|
||||
).rows
|
||||
]
|
||||
|
||||
if sqlite_version()[1] >= 37:
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
with shadow_tables as (
|
||||
select name
|
||||
from pragma_table_list
|
||||
where [type] = 'shadow'
|
||||
order by name
|
||||
),
|
||||
core_tables as (
|
||||
select name
|
||||
from sqlite_master
|
||||
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
OR substr(name, 1, 1) == '_'
|
||||
),
|
||||
combined as (
|
||||
select name from shadow_tables
|
||||
union all
|
||||
select name from core_tables
|
||||
)
|
||||
select name from combined order by 1
|
||||
""")]
|
||||
else:
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
WITH base AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
OR substr(name, 1, 1) == '_'
|
||||
),
|
||||
fts_suffixes AS (
|
||||
SELECT column1 AS suffix
|
||||
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
|
||||
),
|
||||
fts5_names AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
|
||||
),
|
||||
fts5_shadow_tables AS (
|
||||
SELECT
|
||||
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
|
||||
FROM fts5_names
|
||||
JOIN fts_suffixes
|
||||
),
|
||||
fts3_suffixes AS (
|
||||
SELECT column1 AS suffix
|
||||
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
|
||||
),
|
||||
fts3_names AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
|
||||
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
|
||||
),
|
||||
fts3_shadow_tables AS (
|
||||
SELECT
|
||||
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
|
||||
FROM fts3_names
|
||||
JOIN fts3_suffixes
|
||||
),
|
||||
final AS (
|
||||
SELECT name FROM base
|
||||
UNION ALL
|
||||
SELECT name FROM fts5_shadow_tables
|
||||
UNION ALL
|
||||
SELECT name FROM fts3_shadow_tables
|
||||
)
|
||||
SELECT name FROM final ORDER BY 1
|
||||
""")]
|
||||
# Also hide any FTS tables that have a content= argument
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%'
|
||||
AND sql LIKE '%USING FTS%'
|
||||
AND sql LIKE '%content=%'
|
||||
""")]
|
||||
|
||||
has_spatialite = await self.execute_fn(detect_spatialite)
|
||||
if has_spatialite:
|
||||
# Also hide Spatialite internal tables
|
||||
|
|
@ -797,12 +421,32 @@ class Database:
|
|||
"KNN",
|
||||
"KNN2",
|
||||
] + [
|
||||
r[0] for r in (await self.execute("""
|
||||
r[0]
|
||||
for r in (
|
||||
await self.execute(
|
||||
"""
|
||||
select name from sqlite_master
|
||||
where name like "idx_%"
|
||||
and type = "table"
|
||||
""")).rows
|
||||
"""
|
||||
)
|
||||
).rows
|
||||
]
|
||||
# Add any from metadata.json
|
||||
db_metadata = self.ds.metadata(database=self.name)
|
||||
if "tables" in db_metadata:
|
||||
hidden_tables += [
|
||||
t
|
||||
for t in db_metadata["tables"]
|
||||
if db_metadata["tables"][t].get("hidden")
|
||||
]
|
||||
# Also mark as hidden any tables which start with the name of a hidden table
|
||||
# e.g. "searchable_fts" implies "searchable_fts_content" should be hidden
|
||||
for table_name in await self.table_names():
|
||||
for hidden_table in hidden_tables[:]:
|
||||
if table_name.startswith(hidden_table):
|
||||
hidden_tables.append(table_name)
|
||||
continue
|
||||
|
||||
return hidden_tables
|
||||
|
||||
|
|
@ -843,8 +487,6 @@ class Database:
|
|||
tags.append("mutable")
|
||||
if self.is_memory:
|
||||
tags.append("memory")
|
||||
if self.is_temp_disk:
|
||||
tags.append("temp_disk")
|
||||
if self.hash:
|
||||
tags.append(f"hash={self.hash}")
|
||||
if self.size is not None:
|
||||
|
|
@ -855,88 +497,13 @@ class Database:
|
|||
return f"<Database: {self.name}{tags_str}>"
|
||||
|
||||
|
||||
def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
||||
"""Apply a single write_wrapper context manager around fn.
|
||||
|
||||
``wrapper_factory`` is a callable that takes ``(conn)`` and optionally
|
||||
``track_event``, and returns a generator that yields exactly once.
|
||||
Code before the yield runs before ``fn(conn)``, code after the yield
|
||||
runs after. The result of ``fn(conn)`` is sent into the generator
|
||||
via ``.send()``, and any exception raised by ``fn(conn)`` is thrown
|
||||
via ``.throw()``.
|
||||
"""
|
||||
|
||||
def wrapped(conn):
|
||||
gen = call_with_supported_arguments(
|
||||
wrapper_factory, conn=conn, track_event=track_event
|
||||
)
|
||||
# Advance to the yield point (run "before" code)
|
||||
try:
|
||||
next(gen)
|
||||
except StopIteration:
|
||||
# Generator didn't yield — just run fn unchanged
|
||||
return fn(conn)
|
||||
|
||||
# Execute the actual write
|
||||
try:
|
||||
result = fn(conn)
|
||||
except Exception:
|
||||
# Throw exception into generator so it can handle it
|
||||
try:
|
||||
gen.throw(*sys.exc_info())
|
||||
except StopIteration:
|
||||
pass
|
||||
# Re-raise the original exception
|
||||
raise
|
||||
else:
|
||||
# Send the result back through the yield
|
||||
try:
|
||||
gen.send(result)
|
||||
except StopIteration:
|
||||
pass
|
||||
return result
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class WriteTask:
|
||||
__slots__ = (
|
||||
"fn",
|
||||
"task_id",
|
||||
"loop",
|
||||
"reply_future",
|
||||
"isolated_connection",
|
||||
"transaction",
|
||||
)
|
||||
__slots__ = ("fn", "task_id", "reply_queue")
|
||||
|
||||
def __init__(
|
||||
self, fn, task_id, loop, reply_future, isolated_connection, transaction
|
||||
):
|
||||
def __init__(self, fn, task_id, reply_queue):
|
||||
self.fn = fn
|
||||
self.task_id = task_id
|
||||
self.loop = loop
|
||||
self.reply_future = reply_future
|
||||
self.isolated_connection = isolated_connection
|
||||
self.transaction = transaction
|
||||
|
||||
|
||||
def _deliver_write_result(task, result, exception):
|
||||
# Called from the write thread. Delivers the result back to the
|
||||
# awaiting coroutine on its event loop via call_soon_threadsafe.
|
||||
def _set():
|
||||
if task.reply_future.done():
|
||||
# Awaiter was cancelled; nothing to do.
|
||||
return
|
||||
if exception is not None:
|
||||
task.reply_future.set_exception(exception)
|
||||
else:
|
||||
task.reply_future.set_result(result)
|
||||
|
||||
try:
|
||||
task.loop.call_soon_threadsafe(_set)
|
||||
except RuntimeError:
|
||||
# Event loop has been closed; the awaiter is gone.
|
||||
pass
|
||||
self.reply_queue = reply_queue
|
||||
|
||||
|
||||
class QueryInterrupted(Exception):
|
||||
|
|
@ -945,9 +512,6 @@ class QueryInterrupted(Exception):
|
|||
self.sql = sql
|
||||
self.params = params
|
||||
|
||||
def __str__(self):
|
||||
return "QueryInterrupted: {}".format(self.e)
|
||||
|
||||
|
||||
class MultipleValues(Exception):
|
||||
pass
|
||||
|
|
@ -975,9 +539,6 @@ class Results:
|
|||
else:
|
||||
raise MultipleValues
|
||||
|
||||
def dicts(self):
|
||||
return [dict(row) for row in self.rows]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.rows)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.permissions import Action
|
||||
from datasette.resources import (
|
||||
DatabaseResource,
|
||||
TableResource,
|
||||
QueryResource,
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_actions():
|
||||
"""Register the core Datasette actions."""
|
||||
return (
|
||||
# Global actions (no resource_class)
|
||||
Action(
|
||||
name="view-instance",
|
||||
abbr="vi",
|
||||
description="View Datasette instance",
|
||||
),
|
||||
Action(
|
||||
name="permissions-debug",
|
||||
abbr="pd",
|
||||
description="Access permission debug tool",
|
||||
),
|
||||
Action(
|
||||
name="debug-menu",
|
||||
abbr="dm",
|
||||
description="View debug menu items",
|
||||
),
|
||||
# Database-level actions (parent-level)
|
||||
Action(
|
||||
name="view-database",
|
||||
abbr="vd",
|
||||
description="View database",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="view-database-download",
|
||||
abbr="vdd",
|
||||
description="Download database file",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="execute-sql",
|
||||
abbr="es",
|
||||
description="Execute read-only SQL queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="execute-write-sql",
|
||||
abbr="ews",
|
||||
description="Execute writable SQL queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="create-table",
|
||||
abbr="ct",
|
||||
description="Create tables",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="store-query",
|
||||
abbr="sq",
|
||||
description="Create stored queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="execute-sql",
|
||||
),
|
||||
# Table-level actions (child-level)
|
||||
Action(
|
||||
name="view-table",
|
||||
abbr="vt",
|
||||
description="View table",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="insert-row",
|
||||
abbr="ir",
|
||||
description="Insert rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="delete-row",
|
||||
abbr="dr",
|
||||
description="Delete rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="update-row",
|
||||
abbr="ur",
|
||||
description="Update rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="alter-table",
|
||||
abbr="at",
|
||||
description="Alter tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="set-column-type",
|
||||
abbr="sct",
|
||||
description="Set column type",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="drop-table",
|
||||
abbr="dt",
|
||||
description="Drop tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
# Query-level actions (child-level)
|
||||
Action(
|
||||
name="view-query",
|
||||
abbr="vq",
|
||||
description="View named query results",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
Action(
|
||||
name="update-query",
|
||||
abbr="uq",
|
||||
description="Update stored queries",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
Action(
|
||||
name="delete-query",
|
||||
abbr="dq",
|
||||
description="Delete stored queries",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
)
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
import markupsafe
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.column_types import ColumnType, SQLiteType
|
||||
|
||||
|
||||
class UrlColumnType(ColumnType):
|
||||
name = "url"
|
||||
description = "URL"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
async def render_cell(self, value, column, table, database, datasette, request):
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
escaped = markupsafe.escape(value.strip())
|
||||
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
return "URL must be a string"
|
||||
if not re.match(r"^https?://\S+$", value.strip()):
|
||||
return "Invalid URL"
|
||||
return None
|
||||
|
||||
|
||||
class EmailColumnType(ColumnType):
|
||||
name = "email"
|
||||
description = "Email address"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
async def render_cell(self, value, column, table, database, datasette, request):
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
escaped = markupsafe.escape(value.strip())
|
||||
return markupsafe.Markup(f'<a href="mailto:{escaped}">{escaped}</a>')
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
return "Email must be a string"
|
||||
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()):
|
||||
return "Invalid email address"
|
||||
return None
|
||||
|
||||
|
||||
class JsonColumnType(ColumnType):
|
||||
name = "json"
|
||||
description = "JSON data"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
async def render_cell(self, value, column, table, database, datasette, request):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value) if isinstance(value, str) else value
|
||||
formatted = json.dumps(parsed, indent=2)
|
||||
escaped = markupsafe.escape(formatted)
|
||||
return markupsafe.Markup(f"<pre>{escaped}</pre>")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return "Invalid JSON"
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_column_types(datasette):
|
||||
return [UrlColumnType, EmailColumnType, JsonColumnType]
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.resources import DatabaseResource
|
||||
|
||||
|
||||
@hookimpl
|
||||
def database_actions(datasette, actor, database, request):
|
||||
async def inner():
|
||||
if not datasette.get_database(database).is_mutable:
|
||||
return []
|
||||
if not await datasette.allowed(
|
||||
action="execute-write-sql",
|
||||
resource=DatabaseResource(database),
|
||||
actor=actor,
|
||||
):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"href": datasette.urls.database(database) + "/-/execute-write",
|
||||
"label": "Execute write SQL",
|
||||
"description": "Run writable SQL with table permission checks.",
|
||||
}
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
DEBUG_MENU_ITEMS = (
|
||||
(
|
||||
"/-/databases",
|
||||
"Databases",
|
||||
"List of databases known to this Datasette instance.",
|
||||
),
|
||||
(
|
||||
"/-/plugins",
|
||||
"Installed plugins",
|
||||
"Review loaded plugins, their versions and their registered hooks.",
|
||||
),
|
||||
(
|
||||
"/-/versions",
|
||||
"Version info",
|
||||
"Check the Python, SQLite and dependency versions used by this server.",
|
||||
),
|
||||
(
|
||||
"/-/settings",
|
||||
"Settings",
|
||||
"Inspect the active Datasette settings and configuration values.",
|
||||
),
|
||||
(
|
||||
"/-/permissions",
|
||||
"Debug permissions",
|
||||
"Test permission checks for actors, actions and resources.",
|
||||
),
|
||||
(
|
||||
"/-/messages",
|
||||
"Debug messages",
|
||||
"Try out temporary flash messages shown to users.",
|
||||
),
|
||||
(
|
||||
"/-/allow-debug",
|
||||
"Debug allow rules",
|
||||
"Explore how allow blocks match actors against permission rules.",
|
||||
),
|
||||
(
|
||||
"/-/threads",
|
||||
"Debug threads",
|
||||
"Inspect worker threads and database tasks.",
|
||||
),
|
||||
(
|
||||
"/-/actor",
|
||||
"Debug actor",
|
||||
"View the actor object for the current signed-in user.",
|
||||
),
|
||||
(
|
||||
"/-/patterns",
|
||||
"Pattern portfolio",
|
||||
"Browse Datasette UI patterns.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
async def inner():
|
||||
if not await datasette.allowed(action="debug-menu", actor=actor):
|
||||
return []
|
||||
|
||||
return [
|
||||
JumpSQL.menu_item(
|
||||
label=label,
|
||||
url=datasette.urls.path(path),
|
||||
description=description,
|
||||
search_text=f"debug {label} {description}",
|
||||
item_type="debug",
|
||||
)
|
||||
for path, label, description in DEBUG_MENU_ITEMS
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
async def inner():
|
||||
database_sql, database_params = await datasette.allowed_resources_sql(
|
||||
action="view-database", actor=actor
|
||||
)
|
||||
table_sql, table_params = await datasette.allowed_resources_sql(
|
||||
action="view-table", actor=actor
|
||||
)
|
||||
query_sql, query_params = await datasette.allowed_resources_sql(
|
||||
action="view-query", actor=actor
|
||||
)
|
||||
return [
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_databases AS (
|
||||
{database_sql}
|
||||
)
|
||||
SELECT
|
||||
'database' AS type,
|
||||
parent AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'database',
|
||||
'database', parent
|
||||
) AS url,
|
||||
parent AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_databases
|
||||
""",
|
||||
params=database_params,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_tables AS (
|
||||
{table_sql}
|
||||
)
|
||||
SELECT
|
||||
CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type,
|
||||
allowed_tables.parent || ': ' || allowed_tables.child AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'table',
|
||||
'database', allowed_tables.parent,
|
||||
'table', allowed_tables.child
|
||||
) AS url,
|
||||
allowed_tables.parent || ' ' || allowed_tables.child AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_tables
|
||||
LEFT JOIN catalog_views
|
||||
ON catalog_views.database_name = allowed_tables.parent
|
||||
AND catalog_views.view_name = allowed_tables.child
|
||||
""",
|
||||
params=table_params,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_queries AS (
|
||||
{query_sql}
|
||||
)
|
||||
SELECT
|
||||
'query' AS type,
|
||||
allowed_queries.parent || ': ' || allowed_queries.child AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'query',
|
||||
'database', allowed_queries.parent,
|
||||
'query', allowed_queries.child
|
||||
) AS url,
|
||||
allowed_queries.parent || ' ' || allowed_queries.child AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_queries
|
||||
""",
|
||||
params=query_params,
|
||||
),
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -24,12 +24,9 @@ def now(key, request):
|
|||
if key == "epoch":
|
||||
return int(time.time())
|
||||
elif key == "date_utc":
|
||||
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
||||
return datetime.datetime.utcnow().date().isoformat()
|
||||
elif key == "datetime_utc":
|
||||
return (
|
||||
datetime.datetime.now(datetime.timezone.utc).strftime(r"%Y-%m-%dT%H:%M:%S")
|
||||
+ "Z"
|
||||
)
|
||||
return datetime.datetime.utcnow().strftime(r"%Y-%m-%dT%H:%M:%S") + "Z"
|
||||
else:
|
||||
raise KeyError
|
||||
|
||||
|
|
|
|||
45
datasette/default_menu_links.py
Normal file
45
datasette/default_menu_links.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from datasette import hookimpl
|
||||
|
||||
|
||||
@hookimpl
|
||||
def menu_links(datasette, actor):
|
||||
async def inner():
|
||||
if not await datasette.permission_allowed(actor, "debug-menu"):
|
||||
return []
|
||||
|
||||
return [
|
||||
{"href": datasette.urls.path("/-/databases"), "label": "Databases"},
|
||||
{
|
||||
"href": datasette.urls.path("/-/plugins"),
|
||||
"label": "Installed plugins",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/versions"),
|
||||
"label": "Version info",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/metadata"),
|
||||
"label": "Metadata",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/settings"),
|
||||
"label": "Settings",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/permissions"),
|
||||
"label": "Debug permissions",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/messages"),
|
||||
"label": "Debug messages",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/allow-debug"),
|
||||
"label": "Debug allow rules",
|
||||
},
|
||||
{"href": datasette.urls.path("/-/threads"), "label": "Debug threads"},
|
||||
{"href": datasette.urls.path("/-/actor"), "label": "Debug actor"},
|
||||
{"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"},
|
||||
]
|
||||
|
||||
return inner
|
||||
218
datasette/default_permissions.py
Normal file
218
datasette/default_permissions.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.utils import actor_matches_allow
|
||||
import click
|
||||
import itsdangerous
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
@hookimpl(tryfirst=True, specname="permission_allowed")
|
||||
def permission_allowed_default(datasette, actor, action, resource):
|
||||
async def inner():
|
||||
# id=root gets some special permissions:
|
||||
if action in (
|
||||
"permissions-debug",
|
||||
"debug-menu",
|
||||
"insert-row",
|
||||
"create-table",
|
||||
"drop-table",
|
||||
"delete-row",
|
||||
"update-row",
|
||||
):
|
||||
if actor and actor.get("id") == "root":
|
||||
return True
|
||||
|
||||
# Resolve metadata view permissions
|
||||
if action in (
|
||||
"view-instance",
|
||||
"view-database",
|
||||
"view-table",
|
||||
"view-query",
|
||||
"execute-sql",
|
||||
):
|
||||
result = await _resolve_metadata_view_permissions(
|
||||
datasette, actor, action, resource
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Check custom permissions: blocks
|
||||
return await _resolve_metadata_permissions_blocks(
|
||||
datasette, actor, action, resource
|
||||
)
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
async def _resolve_metadata_permissions_blocks(datasette, actor, action, resource):
|
||||
# Check custom permissions: blocks - not yet implemented
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_metadata_view_permissions(datasette, actor, action, resource):
|
||||
if action == "view-instance":
|
||||
allow = datasette.metadata("allow")
|
||||
if allow is not None:
|
||||
return actor_matches_allow(actor, allow)
|
||||
elif action == "view-database":
|
||||
if resource == "_internal" and (actor is None or actor.get("id") != "root"):
|
||||
return False
|
||||
database_allow = datasette.metadata("allow", database=resource)
|
||||
if database_allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, database_allow)
|
||||
elif action == "view-table":
|
||||
database, table = resource
|
||||
tables = datasette.metadata("tables", database=database) or {}
|
||||
table_allow = (tables.get(table) or {}).get("allow")
|
||||
if table_allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, table_allow)
|
||||
elif action == "view-query":
|
||||
# Check if this query has a "allow" block in metadata
|
||||
database, query_name = resource
|
||||
query = await datasette.get_canned_query(database, query_name, actor)
|
||||
assert query is not None
|
||||
allow = query.get("allow")
|
||||
if allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, allow)
|
||||
elif action == "execute-sql":
|
||||
# Use allow_sql block from database block, or from top-level
|
||||
database_allow_sql = datasette.metadata("allow_sql", database=resource)
|
||||
if database_allow_sql is None:
|
||||
database_allow_sql = datasette.metadata("allow_sql")
|
||||
if database_allow_sql is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, database_allow_sql)
|
||||
|
||||
|
||||
@hookimpl(specname="permission_allowed")
|
||||
def permission_allowed_actor_restrictions(actor, action, resource):
|
||||
if actor is None:
|
||||
return None
|
||||
if "_r" not in actor:
|
||||
# No restrictions, so we have no opinion
|
||||
return None
|
||||
_r = actor.get("_r")
|
||||
action_initials = "".join([word[0] for word in action.split("-")])
|
||||
# If _r is defined then we use those to further restrict the actor
|
||||
# Crucially, we only use this to say NO (return False) - we never
|
||||
# use it to return YES (True) because that might over-ride other
|
||||
# restrictions placed on this actor
|
||||
all_allowed = _r.get("a")
|
||||
if all_allowed is not None:
|
||||
assert isinstance(all_allowed, list)
|
||||
if action_initials in all_allowed:
|
||||
return None
|
||||
# How about for the current database?
|
||||
if action in ("view-database", "view-database-download", "execute-sql"):
|
||||
database_allowed = _r.get("d", {}).get(resource)
|
||||
if database_allowed is not None:
|
||||
assert isinstance(database_allowed, list)
|
||||
if action_initials in database_allowed:
|
||||
return None
|
||||
# Or the current table? That's any time the resource is (database, table)
|
||||
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
||||
database, table = resource
|
||||
table_allowed = _r.get("t", {}).get(database, {}).get(table)
|
||||
# TODO: What should this do for canned queries?
|
||||
if table_allowed is not None:
|
||||
assert isinstance(table_allowed, list)
|
||||
if action_initials in table_allowed:
|
||||
return None
|
||||
# This action is not specifically allowed, so reject it
|
||||
return False
|
||||
|
||||
|
||||
@hookimpl
|
||||
def actor_from_request(datasette, request):
|
||||
prefix = "dstok_"
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
return None
|
||||
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
||||
authorization = request.headers.get("authorization")
|
||||
if not authorization:
|
||||
return None
|
||||
if not authorization.startswith("Bearer "):
|
||||
return None
|
||||
token = authorization[len("Bearer ") :]
|
||||
if not token.startswith(prefix):
|
||||
return None
|
||||
token = token[len(prefix) :]
|
||||
try:
|
||||
decoded = datasette.unsign(token, namespace="token")
|
||||
except itsdangerous.BadSignature:
|
||||
return None
|
||||
if "t" not in decoded:
|
||||
# Missing timestamp
|
||||
return None
|
||||
created = decoded["t"]
|
||||
if not isinstance(created, int):
|
||||
# Invalid timestamp
|
||||
return None
|
||||
duration = decoded.get("d")
|
||||
if duration is not None and not isinstance(duration, int):
|
||||
# Invalid duration
|
||||
return None
|
||||
if (duration is None and max_signed_tokens_ttl) or (
|
||||
duration is not None
|
||||
and max_signed_tokens_ttl
|
||||
and duration > max_signed_tokens_ttl
|
||||
):
|
||||
duration = max_signed_tokens_ttl
|
||||
if duration:
|
||||
if time.time() - created > duration:
|
||||
# Expired
|
||||
return None
|
||||
actor = {"id": decoded["a"], "token": "dstok"}
|
||||
if "_r" in decoded:
|
||||
actor["_r"] = decoded["_r"]
|
||||
if duration:
|
||||
actor["token_expires"] = created + duration
|
||||
return actor
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_commands(cli):
|
||||
from datasette.app import Datasette
|
||||
|
||||
@cli.command()
|
||||
@click.argument("id")
|
||||
@click.option(
|
||||
"--secret",
|
||||
help="Secret used for signing the API tokens",
|
||||
envvar="DATASETTE_SECRET",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--expires-after",
|
||||
help="Token should expire after this many seconds",
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
help="Show decoded token",
|
||||
is_flag=True,
|
||||
)
|
||||
def create_token(id, secret, expires_after, debug):
|
||||
"Create a signed API token for the specified actor ID"
|
||||
ds = Datasette(secret=secret)
|
||||
bits = {"a": id, "token": "dstok", "t": int(time.time())}
|
||||
if expires_after:
|
||||
bits["d"] = expires_after
|
||||
token = ds.sign(bits, namespace="token")
|
||||
click.echo("dstok_{}".format(token))
|
||||
if debug:
|
||||
click.echo("\nDecoded:\n")
|
||||
click.echo(json.dumps(ds.unsign(token, namespace="token"), indent=2))
|
||||
|
||||
|
||||
@hookimpl
|
||||
def skip_csrf(scope):
|
||||
# Skip CSRF check for requests with content-type: application/json
|
||||
if scope["type"] == "http":
|
||||
headers = scope.get("headers") or {}
|
||||
if dict(headers).get(b"content-type") == b"application/json":
|
||||
return True
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"""
|
||||
Default permission implementations for Datasette.
|
||||
|
||||
This module provides the built-in permission checking logic through implementations
|
||||
of the permission_resources_sql hook. The hooks are organized by their purpose:
|
||||
|
||||
1. Actor Restrictions - Enforces _r allowlists embedded in actor tokens
|
||||
2. Root User - Grants full access when --root flag is used
|
||||
3. Config Rules - Applies permissions from datasette.yaml
|
||||
4. Default Settings - Enforces default_allow_sql and default view permissions
|
||||
|
||||
IMPORTANT: These hooks return PermissionSQL objects that are combined using SQL
|
||||
UNION/INTERSECT operations. The order of evaluation is:
|
||||
- restriction_sql fields are INTERSECTed (all must match)
|
||||
- Regular sql fields are UNIONed and evaluated with cascading priority
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-export all hooks and public utilities
|
||||
from .restrictions import (
|
||||
actor_restrictions_sql as actor_restrictions_sql,
|
||||
restrictions_allow_action as restrictions_allow_action,
|
||||
ActorRestrictions as ActorRestrictions,
|
||||
)
|
||||
from .root import root_user_permissions_sql as root_user_permissions_sql
|
||||
from .config import config_permissions_sql as config_permissions_sql
|
||||
from .defaults import (
|
||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
||||
default_allow_sql_check as default_allow_sql_check,
|
||||
default_action_permissions_sql as default_action_permissions_sql,
|
||||
default_query_permissions_sql as default_query_permissions_sql,
|
||||
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
||||
)
|
||||
|
|
@ -1,442 +0,0 @@
|
|||
"""
|
||||
Config-based permission handling for Datasette.
|
||||
|
||||
Applies permission rules from datasette.yaml configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.utils import actor_matches_allow
|
||||
|
||||
from .helpers import PermissionRowCollector, get_action_name_variants
|
||||
|
||||
|
||||
class ConfigPermissionProcessor:
|
||||
"""
|
||||
Processes permission rules from datasette.yaml configuration.
|
||||
|
||||
Configuration structure:
|
||||
|
||||
permissions: # Root-level permissions block
|
||||
view-instance:
|
||||
id: admin
|
||||
|
||||
databases:
|
||||
mydb:
|
||||
permissions: # Database-level permissions
|
||||
view-database:
|
||||
id: admin
|
||||
allow: # Database-level allow block (for view-*)
|
||||
id: viewer
|
||||
allow_sql: # execute-sql allow block
|
||||
id: analyst
|
||||
tables:
|
||||
users:
|
||||
permissions: # Table-level permissions
|
||||
view-table:
|
||||
id: admin
|
||||
allow: # Table-level allow block
|
||||
id: viewer
|
||||
queries:
|
||||
my_query:
|
||||
permissions: # Query-level permissions
|
||||
view-query:
|
||||
id: admin
|
||||
allow: # Query-level allow block
|
||||
id: viewer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
):
|
||||
self.datasette = datasette
|
||||
self.actor = actor
|
||||
self.action = action
|
||||
self.config = datasette.config or {}
|
||||
self.collector = PermissionRowCollector(prefix="cfg")
|
||||
|
||||
# Pre-compute action variants
|
||||
self.action_checks = get_action_name_variants(datasette, action)
|
||||
self.action_obj = datasette.actions.get(action)
|
||||
|
||||
# Parse restrictions if present
|
||||
self.has_restrictions = actor and "_r" in actor if actor else False
|
||||
self.restrictions = actor.get("_r", {}) if actor else {}
|
||||
|
||||
# Pre-compute restriction info for efficiency
|
||||
self.restricted_databases: Set[str] = set()
|
||||
self.restricted_tables: Set[Tuple[str, str]] = set()
|
||||
|
||||
if self.has_restrictions:
|
||||
self.restricted_databases = {
|
||||
db_name
|
||||
for db_name, db_actions in (self.restrictions.get("d") or {}).items()
|
||||
if self.action_checks.intersection(db_actions)
|
||||
}
|
||||
self.restricted_tables = {
|
||||
(db_name, table_name)
|
||||
for db_name, tables in (self.restrictions.get("r") or {}).items()
|
||||
for table_name, table_actions in tables.items()
|
||||
if self.action_checks.intersection(table_actions)
|
||||
}
|
||||
# Tables implicitly reference their parent databases
|
||||
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
||||
|
||||
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
|
||||
"""Evaluate an allow block against the current actor."""
|
||||
if allow_block is None:
|
||||
return None
|
||||
return actor_matches_allow(self.actor, allow_block)
|
||||
|
||||
def is_in_restriction_allowlist(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
) -> bool:
|
||||
"""Check if resource is allowed by actor restrictions."""
|
||||
if not self.has_restrictions:
|
||||
return True # No restrictions, all resources allowed
|
||||
|
||||
# Check global allowlist
|
||||
if self.action_checks.intersection(self.restrictions.get("a", [])):
|
||||
return True
|
||||
|
||||
# Check database-level allowlist
|
||||
if parent and self.action_checks.intersection(
|
||||
self.restrictions.get("d", {}).get(parent, [])
|
||||
):
|
||||
return True
|
||||
|
||||
# Check table-level allowlist
|
||||
if parent:
|
||||
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
|
||||
if child:
|
||||
table_actions = table_restrictions.get(child, [])
|
||||
if self.action_checks.intersection(table_actions):
|
||||
return True
|
||||
else:
|
||||
# Parent query should proceed if any child in this database is allowlisted
|
||||
for table_actions in table_restrictions.values():
|
||||
if self.action_checks.intersection(table_actions):
|
||||
return True
|
||||
|
||||
# Parent/child both None: include if any restrictions exist for this action
|
||||
if parent is None and child is None:
|
||||
if self.action_checks.intersection(self.restrictions.get("a", [])):
|
||||
return True
|
||||
if self.restricted_databases:
|
||||
return True
|
||||
if self.restricted_tables:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def add_permissions_rule(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
permissions_block: Optional[dict],
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""Add a rule from a permissions:{action} block."""
|
||||
if permissions_block is None:
|
||||
return
|
||||
|
||||
action_allow_block = permissions_block.get(self.action)
|
||||
result = self.evaluate_allow_block(action_allow_block)
|
||||
|
||||
self.collector.add(
|
||||
parent=parent,
|
||||
child=child,
|
||||
allow=result,
|
||||
reason=f"config {'allow' if result else 'deny'} {scope_desc}",
|
||||
if_not_none=True,
|
||||
)
|
||||
|
||||
def add_allow_block_rule(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow_block: Any,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""
|
||||
Add rules from an allow:{} block.
|
||||
|
||||
For allow blocks, if the block exists but doesn't match the actor,
|
||||
this is treated as a deny. We also handle the restriction-gate logic.
|
||||
"""
|
||||
if allow_block is None:
|
||||
return
|
||||
|
||||
# Skip if resource is not in restriction allowlist
|
||||
if not self.is_in_restriction_allowlist(parent, child):
|
||||
return
|
||||
|
||||
result = self.evaluate_allow_block(allow_block)
|
||||
bool_result = bool(result)
|
||||
|
||||
self.collector.add(
|
||||
parent,
|
||||
child,
|
||||
bool_result,
|
||||
f"config {'allow' if result else 'deny'} {scope_desc}",
|
||||
)
|
||||
|
||||
# Handle restriction-gate: add explicit denies for restricted resources
|
||||
self._add_restriction_gate_denies(parent, child, bool_result, scope_desc)
|
||||
|
||||
def _add_restriction_gate_denies(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
is_allowed: bool,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""
|
||||
When a config rule denies at a higher level, add explicit denies
|
||||
for restricted resources to prevent child-level allows from
|
||||
incorrectly granting access.
|
||||
"""
|
||||
if is_allowed or child is not None or not self.has_restrictions:
|
||||
return
|
||||
|
||||
if not self.action_obj:
|
||||
return
|
||||
|
||||
reason = f"config deny {scope_desc} (restriction gate)"
|
||||
|
||||
if parent is None:
|
||||
# Root-level deny: add denies for all restricted resources
|
||||
if self.action_obj.takes_parent:
|
||||
for db_name in self.restricted_databases:
|
||||
self.collector.add(db_name, None, False, reason)
|
||||
if self.action_obj.takes_child:
|
||||
for db_name, table_name in self.restricted_tables:
|
||||
self.collector.add(db_name, table_name, False, reason)
|
||||
else:
|
||||
# Database-level deny: add denies for tables in that database
|
||||
if self.action_obj.takes_child:
|
||||
for db_name, table_name in self.restricted_tables:
|
||||
if db_name == parent:
|
||||
self.collector.add(db_name, table_name, False, reason)
|
||||
|
||||
def process(self) -> Optional[PermissionSQL]:
|
||||
"""Process all config rules and return combined PermissionSQL."""
|
||||
self._process_root_permissions()
|
||||
self._process_databases()
|
||||
self._process_root_allow_blocks()
|
||||
|
||||
return self.collector.to_permission_sql()
|
||||
|
||||
def _process_root_permissions(self) -> None:
|
||||
"""Process root-level permissions block."""
|
||||
root_perms = self.config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
None,
|
||||
None,
|
||||
root_perms,
|
||||
f"permissions for {self.action}",
|
||||
)
|
||||
|
||||
def _process_databases(self) -> None:
|
||||
"""Process database-level and nested configurations."""
|
||||
databases = self.config.get("databases") or {}
|
||||
|
||||
for db_name, db_config in databases.items():
|
||||
self._process_database(db_name, db_config or {})
|
||||
|
||||
def _process_database(self, db_name: str, db_config: dict) -> None:
|
||||
"""Process a single database's configuration."""
|
||||
# Database-level permissions block
|
||||
db_perms = db_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_perms,
|
||||
f"permissions for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# Process tables
|
||||
for table_name, table_config in (db_config.get("tables") or {}).items():
|
||||
self._process_table(db_name, table_name, table_config or {})
|
||||
|
||||
# Process queries
|
||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||
self._process_query(db_name, query_name, query_config)
|
||||
|
||||
# Database-level allow blocks
|
||||
self._process_database_allow_blocks(db_name, db_config)
|
||||
|
||||
def _process_table(
|
||||
self,
|
||||
db_name: str,
|
||||
table_name: str,
|
||||
table_config: dict,
|
||||
) -> None:
|
||||
"""Process a single table's configuration."""
|
||||
# Table-level permissions block
|
||||
table_perms = table_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
table_name,
|
||||
table_perms,
|
||||
f"permissions for {self.action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
# Table-level allow block (for view-table)
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
table_name,
|
||||
table_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
def _process_query(
|
||||
self,
|
||||
db_name: str,
|
||||
query_name: str,
|
||||
query_config: Any,
|
||||
) -> None:
|
||||
"""Process a single query's configuration."""
|
||||
# Query config can be a string (just SQL) or dict
|
||||
if not isinstance(query_config, dict):
|
||||
return
|
||||
|
||||
# Query-level permissions block
|
||||
query_perms = query_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
query_name,
|
||||
query_perms,
|
||||
f"permissions for {self.action} on {db_name}/{query_name}",
|
||||
)
|
||||
|
||||
# Query-level allow block (for view-query)
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
query_name,
|
||||
query_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}/{query_name}",
|
||||
)
|
||||
|
||||
def _process_database_allow_blocks(
|
||||
self,
|
||||
db_name: str,
|
||||
db_config: dict,
|
||||
) -> None:
|
||||
"""Process database-level allow/allow_sql blocks."""
|
||||
# view-database allow block
|
||||
if self.action == "view-database":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# execute-sql allow_sql block
|
||||
if self.action == "execute-sql":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow_sql"),
|
||||
f"allow_sql for {db_name}",
|
||||
)
|
||||
|
||||
# view-table uses database-level allow for inheritance
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# view-query uses database-level allow for inheritance
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
def _process_root_allow_blocks(self) -> None:
|
||||
"""Process root-level allow/allow_sql blocks."""
|
||||
root_allow = self.config.get("allow")
|
||||
|
||||
if self.action == "view-instance":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-instance",
|
||||
)
|
||||
|
||||
if self.action == "view-database":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-database",
|
||||
)
|
||||
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-table",
|
||||
)
|
||||
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-query",
|
||||
)
|
||||
|
||||
if self.action == "execute-sql":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
self.config.get("allow_sql"),
|
||||
"allow_sql",
|
||||
)
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def config_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Apply permission rules from datasette.yaml configuration.
|
||||
|
||||
This processes:
|
||||
- permissions: blocks at root, database, table, and query levels
|
||||
- allow: blocks for view-* actions
|
||||
- allow_sql: blocks for execute-sql action
|
||||
"""
|
||||
processor = ConfigPermissionProcessor(datasette, actor, action)
|
||||
result = processor.process()
|
||||
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
return [result]
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
"""
|
||||
Default permission settings for Datasette.
|
||||
|
||||
Provides default allow rules for standard view/execute actions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
# Actions that are allowed by default (unless --default-deny is used)
|
||||
DEFAULT_ALLOW_ACTIONS = frozenset(
|
||||
{
|
||||
"view-instance",
|
||||
"view-database",
|
||||
"view-database-download",
|
||||
"view-table",
|
||||
"view-query",
|
||||
"execute-sql",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_allow_sql_check(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Enforce the default_allow_sql setting.
|
||||
|
||||
When default_allow_sql is false (the default), execute-sql is denied
|
||||
unless explicitly allowed by config or other rules.
|
||||
"""
|
||||
if action == "execute-sql":
|
||||
if not datasette.setting("default_allow_sql"):
|
||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_action_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Provide default allow rules for standard view/execute actions.
|
||||
|
||||
These defaults are skipped when datasette is started with --default-deny.
|
||||
The restriction_sql mechanism (from actor_restrictions_sql) will still
|
||||
filter these results if the actor has restrictions.
|
||||
"""
|
||||
if datasette.default_deny:
|
||||
return None
|
||||
|
||||
if action in DEFAULT_ALLOW_ACTIONS:
|
||||
reason = f"default allow for {action}".replace("'", "''")
|
||||
return PermissionSQL.allow(reason=reason)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_query_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||
|
||||
if action not in {"view-query", "update-query", "delete-query"}:
|
||||
return None
|
||||
|
||||
params = {"query_owner_id": actor_id}
|
||||
rule_sqls = []
|
||||
if actor_id is not None:
|
||||
if action in {"update-query", "delete-query"}:
|
||||
# Query owner can update/delete query
|
||||
rule_sqls.append("""
|
||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
||||
'query owner' AS reason
|
||||
FROM queries
|
||||
WHERE source = 'user'
|
||||
AND owner_id = :query_owner_id
|
||||
""")
|
||||
else:
|
||||
# Query owner can view-query
|
||||
rule_sqls.append("""
|
||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
||||
'query owner' AS reason
|
||||
FROM queries
|
||||
WHERE owner_id = :query_owner_id
|
||||
""")
|
||||
|
||||
# restriction_sql enforces private queries ONLY visible/mutable by owner
|
||||
return PermissionSQL(
|
||||
sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None,
|
||||
restriction_sql="""
|
||||
SELECT database_name AS parent, name AS child
|
||||
FROM queries
|
||||
WHERE is_private = 0
|
||||
OR owner_id = :query_owner_id
|
||||
""",
|
||||
params=params,
|
||||
)
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"""
|
||||
Shared helper utilities for default permission implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List, Optional, Set
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
|
||||
"""
|
||||
Get all name variants for an action (full name and abbreviation).
|
||||
|
||||
Example:
|
||||
get_action_name_variants(ds, "view-table") -> {"view-table", "vt"}
|
||||
"""
|
||||
variants = {action}
|
||||
action_obj = datasette.actions.get(action)
|
||||
if action_obj and action_obj.abbr:
|
||||
variants.add(action_obj.abbr)
|
||||
return variants
|
||||
|
||||
|
||||
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
|
||||
"""Check if an action (or its abbreviation) is in a list."""
|
||||
return bool(get_action_name_variants(datasette, action).intersection(action_list))
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionRow:
|
||||
"""A single permission rule row."""
|
||||
|
||||
parent: Optional[str]
|
||||
child: Optional[str]
|
||||
allow: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class PermissionRowCollector:
|
||||
"""Collects permission rows and converts them to PermissionSQL."""
|
||||
|
||||
def __init__(self, prefix: str = "row"):
|
||||
self.rows: List[PermissionRow] = []
|
||||
self.prefix = prefix
|
||||
|
||||
def add(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow: Optional[bool],
|
||||
reason: str,
|
||||
if_not_none: bool = False,
|
||||
) -> None:
|
||||
"""Add a permission row. If if_not_none=True, only add if allow is not None."""
|
||||
if if_not_none and allow is None:
|
||||
return
|
||||
self.rows.append(PermissionRow(parent, child, allow, reason))
|
||||
|
||||
def to_permission_sql(self) -> Optional[PermissionSQL]:
|
||||
"""Convert collected rows to a PermissionSQL object."""
|
||||
if not self.rows:
|
||||
return None
|
||||
|
||||
parts = []
|
||||
params = {}
|
||||
|
||||
for idx, row in enumerate(self.rows):
|
||||
key = f"{self.prefix}_{idx}"
|
||||
parts.append(
|
||||
f"SELECT :{key}_parent AS parent, :{key}_child AS child, "
|
||||
f":{key}_allow AS allow, :{key}_reason AS reason"
|
||||
)
|
||||
params[f"{key}_parent"] = row.parent
|
||||
params[f"{key}_child"] = row.child
|
||||
params[f"{key}_allow"] = 1 if row.allow else 0
|
||||
params[f"{key}_reason"] = row.reason
|
||||
|
||||
sql = "\nUNION ALL\n".join(parts)
|
||||
return PermissionSQL(sql=sql, params=params)
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
"""
|
||||
Actor restriction handling for Datasette permissions.
|
||||
|
||||
This module handles the _r (restrictions) key in actor dictionaries, which
|
||||
contains allowlists of resources the actor can access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
from .helpers import action_in_list, get_action_name_variants
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActorRestrictions:
|
||||
"""Parsed actor restrictions from the _r key."""
|
||||
|
||||
global_actions: List[str] # _r.a - globally allowed actions
|
||||
database_actions: dict # _r.d - {db_name: [actions]}
|
||||
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
||||
|
||||
@classmethod
|
||||
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
|
||||
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
||||
if not actor:
|
||||
return None
|
||||
assert isinstance(actor, dict), "actor must be a dictionary"
|
||||
|
||||
restrictions = actor.get("_r")
|
||||
if restrictions is None:
|
||||
return None
|
||||
|
||||
return cls(
|
||||
global_actions=restrictions.get("a", []),
|
||||
database_actions=restrictions.get("d", {}),
|
||||
table_actions=restrictions.get("r", {}),
|
||||
)
|
||||
|
||||
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
|
||||
"""Check if action is in the global allowlist."""
|
||||
return action_in_list(datasette, action, self.global_actions)
|
||||
|
||||
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
|
||||
"""Get database names where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, db_actions in self.database_actions.items():
|
||||
if action_in_list(datasette, action, db_actions):
|
||||
allowed.add(db_name)
|
||||
return allowed
|
||||
|
||||
def get_allowed_tables(
|
||||
self, datasette: "Datasette", action: str
|
||||
) -> Set[Tuple[str, str]]:
|
||||
"""Get (database, table) pairs where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, tables in self.table_actions.items():
|
||||
for table_name, table_actions in tables.items():
|
||||
if action_in_list(datasette, action, table_actions):
|
||||
allowed.add((db_name, table_name))
|
||||
return allowed
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def actor_restrictions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Handle actor restriction-based permission rules.
|
||||
|
||||
When an actor has an "_r" key, it contains an allowlist of resources they
|
||||
can access. This function returns restriction_sql that filters the final
|
||||
results to only include resources in that allowlist.
|
||||
|
||||
The _r structure:
|
||||
{
|
||||
"a": ["vi", "pd"], # Global actions allowed
|
||||
"d": {"mydb": ["vt", "es"]}, # Database-level actions
|
||||
"r": {"mydb": {"users": ["vt"]}} # Table-level actions
|
||||
}
|
||||
"""
|
||||
if not actor:
|
||||
return None
|
||||
|
||||
restrictions = ActorRestrictions.from_actor(actor)
|
||||
|
||||
if restrictions is None:
|
||||
# No restrictions - all resources allowed
|
||||
return []
|
||||
|
||||
# If globally allowed, no filtering needed
|
||||
if restrictions.is_action_globally_allowed(datasette, action):
|
||||
return []
|
||||
|
||||
# Build restriction SQL
|
||||
allowed_dbs = restrictions.get_allowed_databases(datasette, action)
|
||||
allowed_tables = restrictions.get_allowed_tables(datasette, action)
|
||||
|
||||
# If nothing is allowed for this action, return empty-set restriction
|
||||
if not allowed_dbs and not allowed_tables:
|
||||
return [
|
||||
PermissionSQL(
|
||||
params={"deny": f"actor restrictions: {action} not in allowlist"},
|
||||
restriction_sql="SELECT NULL AS parent, NULL AS child WHERE 0",
|
||||
)
|
||||
]
|
||||
|
||||
# Build UNION of allowed resources
|
||||
selects = []
|
||||
params = {}
|
||||
counter = 0
|
||||
|
||||
# Database-level entries (parent, NULL) - allows all children
|
||||
for db_name in allowed_dbs:
|
||||
key = f"restr_{counter}"
|
||||
counter += 1
|
||||
selects.append(f"SELECT :{key}_parent AS parent, NULL AS child")
|
||||
params[f"{key}_parent"] = db_name
|
||||
|
||||
# Table-level entries (parent, child)
|
||||
for db_name, table_name in allowed_tables:
|
||||
key = f"restr_{counter}"
|
||||
counter += 1
|
||||
selects.append(f"SELECT :{key}_parent AS parent, :{key}_child AS child")
|
||||
params[f"{key}_parent"] = db_name
|
||||
params[f"{key}_child"] = table_name
|
||||
|
||||
restriction_sql = "\nUNION ALL\n".join(selects)
|
||||
|
||||
return [PermissionSQL(params=params, restriction_sql=restriction_sql)]
|
||||
|
||||
|
||||
def restrictions_allow_action(
|
||||
datasette: "Datasette",
|
||||
restrictions: dict,
|
||||
action: str,
|
||||
resource: Optional[str | Tuple[str, str]],
|
||||
) -> bool:
|
||||
"""
|
||||
Check if restrictions allow the requested action on the requested resource.
|
||||
|
||||
This is a synchronous utility function for use by other code that needs
|
||||
to quickly check restriction allowlists.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
restrictions: The _r dict from an actor
|
||||
action: The action name to check
|
||||
resource: None for global, str for database, (db, table) tuple for table
|
||||
|
||||
Returns:
|
||||
True if allowed, False if denied
|
||||
"""
|
||||
# Does this action have an abbreviation?
|
||||
to_check = get_action_name_variants(datasette, action)
|
||||
|
||||
# Check global level (any resource)
|
||||
all_allowed = restrictions.get("a")
|
||||
if all_allowed is not None:
|
||||
assert isinstance(all_allowed, list)
|
||||
if to_check.intersection(all_allowed):
|
||||
return True
|
||||
|
||||
# Check database level
|
||||
if resource:
|
||||
if isinstance(resource, str):
|
||||
database_name = resource
|
||||
else:
|
||||
database_name = resource[0]
|
||||
database_allowed = restrictions.get("d", {}).get(database_name)
|
||||
if database_allowed is not None:
|
||||
assert isinstance(database_allowed, list)
|
||||
if to_check.intersection(database_allowed):
|
||||
return True
|
||||
|
||||
# Check table/resource level
|
||||
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
||||
database, table = resource
|
||||
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
|
||||
if table_allowed is not None:
|
||||
assert isinstance(table_allowed, list)
|
||||
if to_check.intersection(table_allowed):
|
||||
return True
|
||||
|
||||
# This action is not explicitly allowed, so reject it
|
||||
return False
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
"""
|
||||
Root user permission handling for Datasette.
|
||||
|
||||
Grants full permissions to the root user when --root flag is used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def root_user_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Grant root user full permissions when --root flag is used.
|
||||
"""
|
||||
if not datasette.root_enabled:
|
||||
return None
|
||||
if actor is not None and actor.get("id") == "root":
|
||||
return PermissionSQL.allow(reason="root user")
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"""
|
||||
Token authentication for Datasette.
|
||||
|
||||
Registers the default SignedTokenHandler and delegates token verification
|
||||
to datasette.verify_token() so all registered handlers are tried.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.tokens import SignedTokenHandler
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_token_handler(datasette: "Datasette"):
|
||||
"""Register the default signed token handler."""
|
||||
return SignedTokenHandler()
|
||||
|
||||
|
||||
@hookimpl(specname="actor_from_request")
|
||||
async def actor_from_signed_api_token(
|
||||
datasette: "Datasette", request
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Authenticate requests using API tokens by delegating to all registered
|
||||
token handlers via datasette.verify_token().
|
||||
"""
|
||||
authorization = request.headers.get("authorization")
|
||||
if not authorization:
|
||||
return None
|
||||
if not authorization.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
token = authorization[len("Bearer ") :]
|
||||
return await datasette.verify_token(token)
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
from abc import ABC, abstractproperty
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datasette.hookspecs import hookimpl
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event(ABC):
|
||||
@abstractproperty
|
||||
def name(self):
|
||||
pass
|
||||
|
||||
created: datetime = field(
|
||||
init=False, default_factory=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
actor: dict | None
|
||||
|
||||
def properties(self):
|
||||
properties = asdict(self)
|
||||
properties.pop("actor", None)
|
||||
properties.pop("created", None)
|
||||
return properties
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginEvent(Event):
|
||||
"""
|
||||
Event name: ``login``
|
||||
|
||||
A user (represented by ``event.actor``) has logged in.
|
||||
"""
|
||||
|
||||
name = "login"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogoutEvent(Event):
|
||||
"""
|
||||
Event name: ``logout``
|
||||
|
||||
A user (represented by ``event.actor``) has logged out.
|
||||
"""
|
||||
|
||||
name = "logout"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateTokenEvent(Event):
|
||||
"""
|
||||
Event name: ``create-token``
|
||||
|
||||
A user created an API token.
|
||||
|
||||
:ivar expires_after: Number of seconds after which this token will expire.
|
||||
:type expires_after: int or None
|
||||
:ivar restrict_all: Restricted permissions for this token.
|
||||
:type restrict_all: list
|
||||
:ivar restrict_database: Restricted database permissions for this token.
|
||||
:type restrict_database: dict
|
||||
:ivar restrict_resource: Restricted resource permissions for this token.
|
||||
:type restrict_resource: dict
|
||||
"""
|
||||
|
||||
name = "create-token"
|
||||
expires_after: int | None
|
||||
restrict_all: list
|
||||
restrict_database: dict
|
||||
restrict_resource: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateTableEvent(Event):
|
||||
"""
|
||||
Event name: ``create-table``
|
||||
|
||||
A new table has been created in the database.
|
||||
|
||||
:ivar database: The name of the database where the table was created.
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was created
|
||||
:type table: str
|
||||
:ivar schema: The SQL schema definition for the new table.
|
||||
:type schema: str
|
||||
"""
|
||||
|
||||
name = "create-table"
|
||||
database: str
|
||||
table: str
|
||||
schema: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DropTableEvent(Event):
|
||||
"""
|
||||
Event name: ``drop-table``
|
||||
|
||||
A table has been dropped from the database.
|
||||
|
||||
:ivar database: The name of the database where the table was dropped.
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was dropped
|
||||
:type table: str
|
||||
"""
|
||||
|
||||
name = "drop-table"
|
||||
database: str
|
||||
table: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlterTableEvent(Event):
|
||||
"""
|
||||
Event name: ``alter-table``
|
||||
|
||||
A table has been altered.
|
||||
|
||||
:ivar database: The name of the database where the table was altered
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was altered
|
||||
:type table: str
|
||||
:ivar before_schema: The table's SQL schema before the alteration
|
||||
:type before_schema: str
|
||||
:ivar after_schema: The table's SQL schema after the alteration
|
||||
:type after_schema: str
|
||||
"""
|
||||
|
||||
name = "alter-table"
|
||||
database: str
|
||||
table: str
|
||||
before_schema: str
|
||||
after_schema: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class InsertRowsEvent(Event):
|
||||
"""
|
||||
Event name: ``insert-rows``
|
||||
|
||||
Rows were inserted into a table.
|
||||
|
||||
:ivar database: The name of the database where the rows were inserted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the rows were inserted.
|
||||
:type table: str
|
||||
:ivar num_rows: The number of rows that were requested to be inserted.
|
||||
:type num_rows: int
|
||||
:ivar ignore: Was ignore set?
|
||||
:type ignore: bool
|
||||
:ivar replace: Was replace set?
|
||||
:type replace: bool
|
||||
"""
|
||||
|
||||
name = "insert-rows"
|
||||
database: str
|
||||
table: str
|
||||
num_rows: int
|
||||
ignore: bool
|
||||
replace: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpsertRowsEvent(Event):
|
||||
"""
|
||||
Event name: ``upsert-rows``
|
||||
|
||||
Rows were upserted into a table.
|
||||
|
||||
:ivar database: The name of the database where the rows were inserted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the rows were inserted.
|
||||
:type table: str
|
||||
:ivar num_rows: The number of rows that were requested to be inserted.
|
||||
:type num_rows: int
|
||||
"""
|
||||
|
||||
name = "upsert-rows"
|
||||
database: str
|
||||
table: str
|
||||
num_rows: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateRowEvent(Event):
|
||||
"""
|
||||
Event name: ``update-row``
|
||||
|
||||
A row was updated in a table.
|
||||
|
||||
:ivar database: The name of the database where the row was updated.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the row was updated.
|
||||
:type table: str
|
||||
:ivar pks: The primary key values of the updated row.
|
||||
"""
|
||||
|
||||
name = "update-row"
|
||||
database: str
|
||||
table: str
|
||||
pks: list
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenameTableEvent(Event):
|
||||
"""
|
||||
Event name: ``rename-table``
|
||||
|
||||
A table has been renamed.
|
||||
|
||||
:ivar database: The name of the database containing the renamed table.
|
||||
:type database: str
|
||||
:ivar old_table: The previous name of the table.
|
||||
:type old_table: str
|
||||
:ivar new_table: The new name of the table.
|
||||
:type new_table: str
|
||||
"""
|
||||
|
||||
name = "rename-table"
|
||||
database: str
|
||||
old_table: str
|
||||
new_table: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeleteRowEvent(Event):
|
||||
"""
|
||||
Event name: ``delete-row``
|
||||
|
||||
A row was deleted from a table.
|
||||
|
||||
:ivar database: The name of the database where the row was deleted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the row was deleted.
|
||||
:type table: str
|
||||
:ivar pks: The primary key values of the deleted row.
|
||||
"""
|
||||
|
||||
name = "delete-row"
|
||||
database: str
|
||||
table: str
|
||||
pks: list
|
||||
|
||||
|
||||
@hookimpl
|
||||
def write_wrapper(datasette, database, request, transaction):
|
||||
def wrapper(conn, track_event):
|
||||
# Snapshot rootpage -> name before the write
|
||||
before = {
|
||||
row[1]: row[0]
|
||||
for row in conn.execute(
|
||||
"select name, rootpage from sqlite_master"
|
||||
" where type='table' and rootpage != 0"
|
||||
).fetchall()
|
||||
}
|
||||
yield
|
||||
# Snapshot rootpage -> name after the write
|
||||
after = {
|
||||
row[1]: row[0]
|
||||
for row in conn.execute(
|
||||
"select name, rootpage from sqlite_master"
|
||||
" where type='table' and rootpage != 0"
|
||||
).fetchall()
|
||||
}
|
||||
# Detect renames: same rootpage, different name
|
||||
for rootpage, old_name in before.items():
|
||||
new_name = after.get(rootpage)
|
||||
if new_name and new_name != old_name:
|
||||
track_event(
|
||||
RenameTableEvent(
|
||||
actor=request.actor if request else None,
|
||||
database=database,
|
||||
old_table=old_name,
|
||||
new_table=new_name,
|
||||
)
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_events():
|
||||
return [
|
||||
LoginEvent,
|
||||
LogoutEvent,
|
||||
CreateTableEvent,
|
||||
CreateTokenEvent,
|
||||
AlterTableEvent,
|
||||
RenameTableEvent,
|
||||
DropTableEvent,
|
||||
InsertRowsEvent,
|
||||
UpsertRowsEvent,
|
||||
UpdateRowEvent,
|
||||
DeleteRowEvent,
|
||||
]
|
||||
|
|
@ -11,8 +11,8 @@ from datasette.utils import (
|
|||
)
|
||||
|
||||
|
||||
def load_facet_configs(request, table_config):
|
||||
# Given a request and the configuration for a table, return
|
||||
def load_facet_configs(request, table_metadata):
|
||||
# Given a request and the metadata configuration for a table, return
|
||||
# a dictionary of selected facets, their lists of configs and for each
|
||||
# config whether it came from the request or the metadata.
|
||||
#
|
||||
|
|
@ -20,21 +20,21 @@ def load_facet_configs(request, table_config):
|
|||
# {"source": "metadata", "config": config1},
|
||||
# {"source": "request", "config": config2}]}
|
||||
facet_configs = {}
|
||||
table_config = table_config or {}
|
||||
table_facet_configs = table_config.get("facets", [])
|
||||
for facet_config in table_facet_configs:
|
||||
if isinstance(facet_config, str):
|
||||
table_metadata = table_metadata or {}
|
||||
metadata_facets = table_metadata.get("facets", [])
|
||||
for metadata_config in metadata_facets:
|
||||
if isinstance(metadata_config, str):
|
||||
type = "column"
|
||||
facet_config = {"simple": facet_config}
|
||||
metadata_config = {"simple": metadata_config}
|
||||
else:
|
||||
assert (
|
||||
len(facet_config.values()) == 1
|
||||
len(metadata_config.values()) == 1
|
||||
), "Metadata config dicts should be {type: config}"
|
||||
type, facet_config = list(facet_config.items())[0]
|
||||
if isinstance(facet_config, str):
|
||||
facet_config = {"simple": facet_config}
|
||||
type, metadata_config = list(metadata_config.items())[0]
|
||||
if isinstance(metadata_config, str):
|
||||
metadata_config = {"simple": metadata_config}
|
||||
facet_configs.setdefault(type, []).append(
|
||||
{"source": "metadata", "config": facet_config}
|
||||
{"source": "metadata", "config": metadata_config}
|
||||
)
|
||||
qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
|
||||
for key, values in qs_pairs.items():
|
||||
|
|
@ -45,12 +45,13 @@ def load_facet_configs(request, table_config):
|
|||
elif key.startswith("_facet_"):
|
||||
type = key[len("_facet_") :]
|
||||
for value in values:
|
||||
# The value is the facet_config - either JSON or not
|
||||
facet_config = (
|
||||
json.loads(value) if value.startswith("{") else {"simple": value}
|
||||
)
|
||||
# The value is the config - either JSON or not
|
||||
if value.startswith("{"):
|
||||
config = json.loads(value)
|
||||
else:
|
||||
config = {"simple": value}
|
||||
facet_configs.setdefault(type, []).append(
|
||||
{"source": "request", "config": facet_config}
|
||||
{"source": "request", "config": config}
|
||||
)
|
||||
return facet_configs
|
||||
|
||||
|
|
@ -65,8 +66,6 @@ def register_facet_classes():
|
|||
|
||||
class Facet:
|
||||
type = None
|
||||
# How many rows to consider when suggesting facets:
|
||||
suggest_consider = 1000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -76,23 +75,23 @@ class Facet:
|
|||
sql=None,
|
||||
table=None,
|
||||
params=None,
|
||||
table_config=None,
|
||||
metadata=None,
|
||||
row_count=None,
|
||||
):
|
||||
assert table or sql, "Must provide either table= or sql="
|
||||
self.ds = ds
|
||||
self.request = request
|
||||
self.database = database
|
||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
||||
# For foreign key expansion. Can be None for e.g. canned SQL queries:
|
||||
self.table = table
|
||||
self.sql = sql or f"select * from [{table}]"
|
||||
self.params = params or []
|
||||
self.table_config = table_config
|
||||
self.metadata = metadata
|
||||
# row_count can be None, in which case we calculate it ourselves:
|
||||
self.row_count = row_count
|
||||
|
||||
def get_configs(self):
|
||||
configs = load_facet_configs(self.request, self.table_config)
|
||||
configs = load_facet_configs(self.request, self.metadata)
|
||||
return configs.get(self.type) or []
|
||||
|
||||
def get_querystring_pairs(self):
|
||||
|
|
@ -105,15 +104,10 @@ class Facet:
|
|||
max_returned_rows = self.ds.setting("max_returned_rows")
|
||||
table_facet_size = None
|
||||
if self.table:
|
||||
config_facet_size = (
|
||||
self.ds.config.get("databases", {})
|
||||
.get(self.database, {})
|
||||
.get("tables", {})
|
||||
.get(self.table, {})
|
||||
.get("facet_size")
|
||||
)
|
||||
if config_facet_size:
|
||||
table_facet_size = config_facet_size
|
||||
tables_metadata = self.ds.metadata("tables", database=self.database) or {}
|
||||
table_metadata = tables_metadata.get(self.table) or {}
|
||||
if table_metadata:
|
||||
table_facet_size = table_metadata.get("facet_size")
|
||||
custom_facet_size = self.request.args.get("_facet_size")
|
||||
if custom_facet_size:
|
||||
if custom_facet_size == "max":
|
||||
|
|
@ -147,6 +141,17 @@ class Facet:
|
|||
)
|
||||
).columns
|
||||
|
||||
async def get_row_count(self):
|
||||
if self.row_count is None:
|
||||
self.row_count = (
|
||||
await self.ds.execute(
|
||||
self.database,
|
||||
f"select count(*) from ({self.sql})",
|
||||
self.params,
|
||||
)
|
||||
).rows[0][0]
|
||||
return self.row_count
|
||||
|
||||
|
||||
class ColumnFacet(Facet):
|
||||
type = "column"
|
||||
|
|
@ -161,16 +166,13 @@ class ColumnFacet(Facet):
|
|||
if column in already_enabled:
|
||||
continue
|
||||
suggested_facet_sql = """
|
||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||
select {column} as value, count(*) as n from limited
|
||||
where value is not null
|
||||
select {column} as value, count(*) as n from (
|
||||
{sql}
|
||||
) where value is not null
|
||||
group by value
|
||||
limit {limit}
|
||||
""".format(
|
||||
column=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
limit=facet_size + 1,
|
||||
suggest_consider=self.suggest_consider,
|
||||
column=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
)
|
||||
distinct_values = None
|
||||
try:
|
||||
|
|
@ -205,17 +207,6 @@ class ColumnFacet(Facet):
|
|||
continue
|
||||
return suggested_facets
|
||||
|
||||
async def get_row_count(self):
|
||||
if self.row_count is None:
|
||||
self.row_count = (
|
||||
await self.ds.execute(
|
||||
self.database,
|
||||
f"select count(*) from (select * from ({self.sql}) limit {self.suggest_consider})",
|
||||
self.params,
|
||||
)
|
||||
).rows[0][0]
|
||||
return self.row_count
|
||||
|
||||
async def facet_results(self):
|
||||
facet_results = []
|
||||
facets_timed_out = []
|
||||
|
|
@ -233,7 +224,9 @@ class ColumnFacet(Facet):
|
|||
)
|
||||
where {col} is not null
|
||||
group by {col} order by count desc, value limit {limit}
|
||||
""".format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1)
|
||||
""".format(
|
||||
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
)
|
||||
try:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
self.database,
|
||||
|
|
@ -260,7 +253,7 @@ class ColumnFacet(Facet):
|
|||
# Attempt to expand foreign keys into labels
|
||||
values = [row["value"] for row in facet_rows]
|
||||
expanded = await self.ds.expand_foreign_keys(
|
||||
self.request.actor, self.database, self.table, column, values
|
||||
self.database, self.table, column, values
|
||||
)
|
||||
else:
|
||||
expanded = {}
|
||||
|
|
@ -316,14 +309,11 @@ class ArrayFacet(Facet):
|
|||
continue
|
||||
# Is every value in this column either null or a JSON array?
|
||||
suggested_facet_sql = """
|
||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||
select distinct json_type({column})
|
||||
from limited
|
||||
from ({sql})
|
||||
where {column} is not null and {column} != ''
|
||||
""".format(
|
||||
column=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
suggest_consider=self.suggest_consider,
|
||||
column=escape_sqlite(column), sql=self.sql
|
||||
)
|
||||
try:
|
||||
results = await self.ds.execute(
|
||||
|
|
@ -408,9 +398,7 @@ class ArrayFacet(Facet):
|
|||
order by
|
||||
count(*) desc, value limit {limit}
|
||||
""".format(
|
||||
col=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
limit=facet_size + 1,
|
||||
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
)
|
||||
try:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
|
|
@ -478,9 +466,11 @@ class DateFacet(Facet):
|
|||
# Does this column contain any dates in the first 100 rows?
|
||||
suggested_facet_sql = """
|
||||
select date({column}) from (
|
||||
select * from ({sql}) limit 100
|
||||
) where {column} glob "????-??-*"
|
||||
""".format(column=escape_sqlite(column), sql=self.sql)
|
||||
{sql}
|
||||
) where {column} glob "????-??-*" limit 100;
|
||||
""".format(
|
||||
column=escape_sqlite(column), sql=self.sql
|
||||
)
|
||||
try:
|
||||
results = await self.ds.execute(
|
||||
self.database,
|
||||
|
|
@ -526,7 +516,9 @@ class DateFacet(Facet):
|
|||
)
|
||||
where date({col}) is not null
|
||||
group by date({col}) order by count desc, value limit {limit}
|
||||
""".format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1)
|
||||
""".format(
|
||||
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
)
|
||||
try:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
self.database,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.views.base import DatasetteError
|
||||
from datasette.utils.asgi import BadRequest
|
||||
import json
|
||||
import numbers
|
||||
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
||||
|
||||
|
||||
|
|
@ -13,10 +13,11 @@ def where_filters(request, database, datasette):
|
|||
where_clauses = []
|
||||
extra_wheres_for_ui = []
|
||||
if "_where" in request.args:
|
||||
if not await datasette.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database=database),
|
||||
actor=request.actor,
|
||||
if not await datasette.permission_allowed(
|
||||
request.actor,
|
||||
"execute-sql",
|
||||
resource=database,
|
||||
default=True,
|
||||
):
|
||||
raise DatasetteError("_where= is not allowed", status=403)
|
||||
else:
|
||||
|
|
@ -49,7 +50,7 @@ def search_filters(request, database, table, datasette):
|
|||
extra_context = {}
|
||||
|
||||
# Figure out which fts_table to use
|
||||
table_metadata = await datasette.table_config(database, table)
|
||||
table_metadata = datasette.table_metadata(database, table)
|
||||
db = datasette.get_database(database)
|
||||
fts_table = request.args.get("_fts_table")
|
||||
fts_table = fts_table or table_metadata.get("fts_table")
|
||||
|
|
@ -79,9 +80,9 @@ def search_filters(request, database, table, datasette):
|
|||
"{fts_pk} in (select rowid from {fts_table} where {fts_table} match {match_clause})".format(
|
||||
fts_table=escape_sqlite(fts_table),
|
||||
fts_pk=escape_sqlite(fts_pk),
|
||||
match_clause=(
|
||||
":search" if search_mode_raw else "escape_fts(:search)"
|
||||
),
|
||||
match_clause=":search"
|
||||
if search_mode_raw
|
||||
else "escape_fts(:search)",
|
||||
)
|
||||
)
|
||||
human_descriptions.append(f'search matches "{search}"')
|
||||
|
|
@ -98,11 +99,9 @@ def search_filters(request, database, table, datasette):
|
|||
"rowid in (select rowid from {fts_table} where {search_col} match {match_clause})".format(
|
||||
fts_table=escape_sqlite(fts_table),
|
||||
search_col=escape_sqlite(search_col),
|
||||
match_clause=(
|
||||
":search_{}".format(i)
|
||||
match_clause=":search_{}".format(i)
|
||||
if search_mode_raw
|
||||
else "escape_fts(:search_{})".format(i)
|
||||
),
|
||||
else "escape_fts(:search_{})".format(i),
|
||||
)
|
||||
)
|
||||
human_descriptions.append(
|
||||
|
|
@ -280,13 +279,6 @@ class Filters:
|
|||
'{c} contains "{v}"',
|
||||
format="%{}%",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"notcontains",
|
||||
"does not contain",
|
||||
'"{c}" not like :{p}',
|
||||
'{c} does not contain "{v}"',
|
||||
format="%{}%",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"endswith",
|
||||
"ends with",
|
||||
|
|
@ -367,8 +359,12 @@ class Filters:
|
|||
)
|
||||
_filters_by_key = {f.key: f for f in _filters}
|
||||
|
||||
def __init__(self, pairs):
|
||||
def __init__(self, pairs, units=None, ureg=None):
|
||||
if units is None:
|
||||
units = {}
|
||||
self.pairs = pairs
|
||||
self.units = units
|
||||
self.ureg = ureg
|
||||
|
||||
def lookups(self):
|
||||
"""Yields (lookup, display, no_argument) pairs"""
|
||||
|
|
@ -408,6 +404,20 @@ class Filters:
|
|||
def has_selections(self):
|
||||
return bool(self.pairs)
|
||||
|
||||
def convert_unit(self, column, value):
|
||||
"""If the user has provided a unit in the query, convert it into the column unit, if present."""
|
||||
if column not in self.units:
|
||||
return value
|
||||
|
||||
# Try to interpret the value as a unit
|
||||
value = self.ureg(value)
|
||||
if isinstance(value, numbers.Number):
|
||||
# It's just a bare number, assume it's the column unit
|
||||
return value
|
||||
|
||||
column_unit = self.ureg(self.units[column])
|
||||
return value.to(column_unit).magnitude
|
||||
|
||||
def build_where_clauses(self, table):
|
||||
sql_bits = []
|
||||
params = {}
|
||||
|
|
@ -415,7 +425,9 @@ class Filters:
|
|||
for column, lookup, value in self.selections():
|
||||
filter = self._filters_by_key.get(lookup, None)
|
||||
if filter:
|
||||
sql_bit, param = filter.where_clause(table, column, value, i)
|
||||
sql_bit, param = filter.where_clause(
|
||||
table, column, self.convert_unit(column, value), i
|
||||
)
|
||||
sql_bits.append(sql_bit)
|
||||
if param is not None:
|
||||
if not isinstance(param, list):
|
||||
|
|
|
|||
|
|
@ -1,415 +0,0 @@
|
|||
from datasette.utils.sqlite import sqlite3
|
||||
from datasette.utils import documented
|
||||
import itertools
|
||||
import random
|
||||
import string
|
||||
|
||||
__all__ = [
|
||||
"EXTRA_DATABASE_SQL",
|
||||
"TABLES",
|
||||
"TABLE_PARAMETERIZED_SQL",
|
||||
"generate_compound_rows",
|
||||
"generate_sortable_rows",
|
||||
"populate_extra_database",
|
||||
"populate_fixture_database",
|
||||
"write_extra_database",
|
||||
"write_fixture_database",
|
||||
]
|
||||
|
||||
|
||||
def generate_compound_rows(num):
|
||||
"""Generate rows for the compound_three_primary_keys fixture table."""
|
||||
for a, b, c in itertools.islice(
|
||||
itertools.product(string.ascii_lowercase, repeat=3), num
|
||||
):
|
||||
yield a, b, c, f"{a}-{b}-{c}"
|
||||
|
||||
|
||||
def generate_sortable_rows(num):
|
||||
"""Generate rows for the sortable fixture table."""
|
||||
rand = random.Random(42)
|
||||
for a, b in itertools.islice(
|
||||
itertools.product(string.ascii_lowercase, repeat=2), num
|
||||
):
|
||||
yield {
|
||||
"pk1": a,
|
||||
"pk2": b,
|
||||
"content": f"{a}-{b}",
|
||||
"sortable": rand.randint(-100, 100),
|
||||
"sortable_with_nulls": rand.choice([None, rand.random(), rand.random()]),
|
||||
"sortable_with_nulls_2": rand.choice([None, rand.random(), rand.random()]),
|
||||
"text": rand.choice(["$null", "$blah"]),
|
||||
}
|
||||
|
||||
|
||||
TABLES = (
|
||||
"""
|
||||
CREATE TABLE simple_primary_key (
|
||||
id integer primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE primary_key_multiple_columns (
|
||||
id varchar(30) primary key,
|
||||
content text,
|
||||
content2 text
|
||||
);
|
||||
|
||||
CREATE TABLE primary_key_multiple_columns_explicit_label (
|
||||
id varchar(30) primary key,
|
||||
content text,
|
||||
content2 text
|
||||
);
|
||||
|
||||
CREATE TABLE compound_primary_key (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
content text,
|
||||
PRIMARY KEY (pk1, pk2)
|
||||
);
|
||||
|
||||
INSERT INTO compound_primary_key VALUES ('a', 'b', 'c');
|
||||
INSERT INTO compound_primary_key VALUES ('a/b', '.c-d', 'c');
|
||||
INSERT INTO compound_primary_key VALUES ('d', 'e', 'RENDER_CELL_DEMO');
|
||||
|
||||
CREATE TABLE compound_three_primary_keys (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
pk3 varchar(30),
|
||||
content text,
|
||||
PRIMARY KEY (pk1, pk2, pk3)
|
||||
);
|
||||
CREATE INDEX idx_compound_three_primary_keys_content ON compound_three_primary_keys(content);
|
||||
|
||||
CREATE TABLE foreign_key_references (
|
||||
pk varchar(30) primary key,
|
||||
foreign_key_with_label integer,
|
||||
foreign_key_with_blank_label integer,
|
||||
foreign_key_with_no_label varchar(30),
|
||||
foreign_key_compound_pk1 varchar(30),
|
||||
foreign_key_compound_pk2 varchar(30),
|
||||
FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id),
|
||||
FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id),
|
||||
FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id)
|
||||
FOREIGN KEY (foreign_key_compound_pk1, foreign_key_compound_pk2) REFERENCES compound_primary_key(pk1, pk2)
|
||||
);
|
||||
|
||||
CREATE TABLE sortable (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
content text,
|
||||
sortable integer,
|
||||
sortable_with_nulls real,
|
||||
sortable_with_nulls_2 real,
|
||||
text text,
|
||||
PRIMARY KEY (pk1, pk2)
|
||||
);
|
||||
|
||||
CREATE TABLE no_primary_key (
|
||||
content text,
|
||||
a text,
|
||||
b text,
|
||||
c text
|
||||
);
|
||||
|
||||
CREATE TABLE [123_starts_with_digits] (
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE VIEW paginated_view AS
|
||||
SELECT
|
||||
content,
|
||||
'- ' || content || ' -' AS content_extra
|
||||
FROM no_primary_key;
|
||||
|
||||
CREATE TABLE "Table With Space In Name" (
|
||||
pk varchar(30) primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE "table/with/slashes.csv" (
|
||||
pk varchar(30) primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE "complex_foreign_keys" (
|
||||
pk varchar(30) primary key,
|
||||
f1 integer,
|
||||
f2 integer,
|
||||
f3 integer,
|
||||
FOREIGN KEY ("f1") REFERENCES [simple_primary_key](id),
|
||||
FOREIGN KEY ("f2") REFERENCES [simple_primary_key](id),
|
||||
FOREIGN KEY ("f3") REFERENCES [simple_primary_key](id)
|
||||
);
|
||||
|
||||
CREATE TABLE "custom_foreign_key_label" (
|
||||
pk varchar(30) primary key,
|
||||
foreign_key_with_custom_label text,
|
||||
FOREIGN KEY ("foreign_key_with_custom_label") REFERENCES [primary_key_multiple_columns_explicit_label](id)
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
tag TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE searchable (
|
||||
pk integer primary key,
|
||||
text1 text,
|
||||
text2 text,
|
||||
[name with . and spaces] text
|
||||
);
|
||||
|
||||
CREATE TABLE searchable_tags (
|
||||
searchable_id integer,
|
||||
tag text,
|
||||
PRIMARY KEY (searchable_id, tag),
|
||||
FOREIGN KEY (searchable_id) REFERENCES searchable(pk),
|
||||
FOREIGN KEY (tag) REFERENCES tags(tag)
|
||||
);
|
||||
|
||||
INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther');
|
||||
INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma');
|
||||
|
||||
INSERT INTO tags VALUES ("canine");
|
||||
INSERT INTO tags VALUES ("feline");
|
||||
|
||||
INSERT INTO searchable_tags (searchable_id, tag) VALUES
|
||||
(1, "feline"),
|
||||
(2, "canine")
|
||||
;
|
||||
|
||||
CREATE VIRTUAL TABLE "searchable_fts"
|
||||
USING FTS5 (text1, text2, [name with . and spaces], content="searchable", content_rowid="pk");
|
||||
INSERT INTO "searchable_fts" (searchable_fts) VALUES ('rebuild');
|
||||
|
||||
CREATE TABLE [select] (
|
||||
[group] text,
|
||||
[having] text,
|
||||
[and] text,
|
||||
[json] text
|
||||
);
|
||||
INSERT INTO [select] VALUES ('group', 'having', 'and',
|
||||
'{"href": "http://example.com/", "label":"Example"}'
|
||||
);
|
||||
|
||||
CREATE TABLE infinity (
|
||||
value REAL
|
||||
);
|
||||
INSERT INTO infinity VALUES
|
||||
(1e999),
|
||||
(-1e999),
|
||||
(1.5)
|
||||
;
|
||||
|
||||
CREATE TABLE facet_cities (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
INSERT INTO facet_cities (id, name) VALUES
|
||||
(1, 'San Francisco'),
|
||||
(2, 'Los Angeles'),
|
||||
(3, 'Detroit'),
|
||||
(4, 'Memnonia')
|
||||
;
|
||||
|
||||
CREATE TABLE facetable (
|
||||
pk integer primary key,
|
||||
created text,
|
||||
planet_int integer,
|
||||
on_earth integer,
|
||||
state text,
|
||||
_city_id integer,
|
||||
_neighborhood text,
|
||||
tags text,
|
||||
complex_array text,
|
||||
distinct_some_null,
|
||||
n text,
|
||||
FOREIGN KEY ("_city_id") REFERENCES [facet_cities](id)
|
||||
);
|
||||
INSERT INTO facetable
|
||||
(created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n)
|
||||
VALUES
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Mission', '["tag1", "tag2"]', '[{"foo": "bar"}]', 'one', 'n1'),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Dogpatch', '["tag1", "tag3"]', '[]', 'two', 'n2'),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'SOMA', '[]', '[]', null, null),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Tenderloin', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Bernal Heights', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Hayes Valley', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Hollywood', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Downtown', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Los Feliz', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Koreatown', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'MI', 3, 'Downtown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Greektown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Corktown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Mexicantown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 2, 0, 'MC', 4, 'Arcadia Planitia', '[]', '[]', null, null)
|
||||
;
|
||||
|
||||
CREATE TABLE binary_data (
|
||||
data BLOB
|
||||
);
|
||||
|
||||
-- Many 2 Many demo: roadside attractions!
|
||||
|
||||
CREATE TABLE roadside_attractions (
|
||||
pk integer primary key,
|
||||
name text,
|
||||
address text,
|
||||
url text,
|
||||
latitude real,
|
||||
longitude real
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
1, "The Mystery Spot", "465 Mystery Spot Road, Santa Cruz, CA 95065", "https://www.mysteryspot.com/",
|
||||
37.0167, -122.0024
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
2, "Winchester Mystery House", "525 South Winchester Boulevard, San Jose, CA 95128", "https://winchestermysteryhouse.com/",
|
||||
37.3184, -121.9511
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
3, "Burlingame Museum of PEZ Memorabilia", "214 California Drive, Burlingame, CA 94010", null,
|
||||
37.5793, -122.3442
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
4, "Bigfoot Discovery Museum", "5497 Highway 9, Felton, CA 95018", "https://www.bigfootdiscoveryproject.com/",
|
||||
37.0414, -122.0725
|
||||
);
|
||||
|
||||
CREATE TABLE attraction_characteristic (
|
||||
pk integer primary key,
|
||||
name text
|
||||
);
|
||||
INSERT INTO attraction_characteristic VALUES (
|
||||
1, "Museum"
|
||||
);
|
||||
INSERT INTO attraction_characteristic VALUES (
|
||||
2, "Paranormal"
|
||||
);
|
||||
|
||||
CREATE TABLE roadside_attraction_characteristics (
|
||||
attraction_id INTEGER REFERENCES roadside_attractions(pk),
|
||||
characteristic_id INTEGER REFERENCES attraction_characteristic(pk)
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
1, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
2, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
4, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
3, 1
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
4, 1
|
||||
);
|
||||
|
||||
INSERT INTO simple_primary_key VALUES (1, 'hello');
|
||||
INSERT INTO simple_primary_key VALUES (2, 'world');
|
||||
INSERT INTO simple_primary_key VALUES (3, '');
|
||||
INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO');
|
||||
INSERT INTO simple_primary_key VALUES (5, 'RENDER_CELL_ASYNC');
|
||||
|
||||
INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world');
|
||||
INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2');
|
||||
|
||||
INSERT INTO foreign_key_references VALUES (1, 1, 3, 1, 'a', 'b');
|
||||
INSERT INTO foreign_key_references VALUES (2, null, null, null, null, null);
|
||||
|
||||
INSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1);
|
||||
INSERT INTO custom_foreign_key_label VALUES (1, 1);
|
||||
|
||||
INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey');
|
||||
|
||||
CREATE VIEW simple_view AS
|
||||
SELECT content, upper(content) AS upper_content FROM simple_primary_key;
|
||||
|
||||
CREATE VIEW searchable_view AS
|
||||
SELECT * from searchable;
|
||||
|
||||
CREATE VIEW searchable_view_configured_by_metadata AS
|
||||
SELECT * from searchable;
|
||||
|
||||
"""
|
||||
+ "\n".join(
|
||||
[
|
||||
'INSERT INTO no_primary_key VALUES ({i}, "a{i}", "b{i}", "c{i}");'.format(
|
||||
i=i + 1
|
||||
)
|
||||
for i in range(201)
|
||||
]
|
||||
)
|
||||
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
||||
+ "\n".join(
|
||||
[
|
||||
'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
|
||||
a=a, b=b, c=c, content=content
|
||||
)
|
||||
for a, b, c, content in generate_compound_rows(1001)
|
||||
]
|
||||
)
|
||||
+ "\n".join(["""INSERT INTO sortable VALUES (
|
||||
"{pk1}", "{pk2}", "{content}", {sortable},
|
||||
{sortable_with_nulls}, {sortable_with_nulls_2}, "{text}");
|
||||
""".format(**row).replace("None", "null") for row in generate_sortable_rows(201)])
|
||||
)
|
||||
|
||||
TABLE_PARAMETERIZED_SQL = [
|
||||
("insert into binary_data (data) values (?);", [b"\x15\x1c\x02\xc7\xad\x05\xfe"]),
|
||||
("insert into binary_data (data) values (?);", [b"\x15\x1c\x03\xc7\xad\x05\xfe"]),
|
||||
("insert into binary_data (data) values (null);", []),
|
||||
]
|
||||
|
||||
EXTRA_DATABASE_SQL = """
|
||||
CREATE TABLE searchable (
|
||||
pk integer primary key,
|
||||
text1 text,
|
||||
text2 text
|
||||
);
|
||||
|
||||
CREATE VIEW searchable_view AS SELECT * FROM searchable;
|
||||
|
||||
INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog');
|
||||
INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel');
|
||||
|
||||
CREATE VIRTUAL TABLE "searchable_fts"
|
||||
USING FTS3 (text1, text2, content="searchable");
|
||||
INSERT INTO "searchable_fts" (rowid, text1, text2)
|
||||
SELECT rowid, text1, text2 FROM searchable;
|
||||
"""
|
||||
|
||||
|
||||
@documented(label="datasette_fixtures_populate_fixture_database")
|
||||
def populate_fixture_database(conn):
|
||||
"""Populate a SQLite connection with Datasette's test fixture tables."""
|
||||
conn.executescript(TABLES)
|
||||
for sql, params in TABLE_PARAMETERIZED_SQL:
|
||||
with conn:
|
||||
conn.execute(sql, params)
|
||||
|
||||
|
||||
def populate_extra_database(conn):
|
||||
"""Populate a SQLite connection with the extra database used in tests."""
|
||||
conn.executescript(EXTRA_DATABASE_SQL)
|
||||
|
||||
|
||||
def write_fixture_database(db_filename):
|
||||
"""Write Datasette's test fixture tables to a SQLite database file."""
|
||||
conn = sqlite3.connect(db_filename)
|
||||
try:
|
||||
populate_fixture_database(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write_extra_database(db_filename):
|
||||
"""Write the extra test database tables to a SQLite database file."""
|
||||
conn = sqlite3.connect(db_filename)
|
||||
try:
|
||||
populate_extra_database(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from os import stat
|
||||
from datasette import hookimpl, Response
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
from datasette import hookimpl, Response
|
||||
from .utils import add_cors_headers
|
||||
from .utils import await_me_maybe, add_cors_headers
|
||||
from .utils.asgi import (
|
||||
Base400,
|
||||
Forbidden,
|
||||
)
|
||||
from .views.base import DatasetteError
|
||||
from markupsafe import Markup
|
||||
import pdb
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import ipdb as pdb
|
||||
except ImportError:
|
||||
import pdb
|
||||
from .plugins import pm
|
||||
|
||||
try:
|
||||
import rich
|
||||
|
|
@ -59,8 +57,7 @@ def handle_exception(datasette, request, exception):
|
|||
if request.path.split("?")[0].endswith(".json"):
|
||||
return Response.json(info, status=status, headers=headers)
|
||||
else:
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
template = datasette.jinja_env.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ def startup(datasette):
|
|||
"""Fires directly after Datasette first starts running"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def get_metadata(datasette, key, database, table):
|
||||
"""Return metadata to be merged into Datasette's metadata dictionary"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def asgi_wrapper(datasette):
|
||||
"""Returns an ASGI middleware callable to wrap our ASGI application with"""
|
||||
|
|
@ -55,17 +60,7 @@ def publish_subcommand(publish):
|
|||
|
||||
|
||||
@hookspec
|
||||
def render_cell(
|
||||
row,
|
||||
value,
|
||||
column,
|
||||
table,
|
||||
pks,
|
||||
database,
|
||||
datasette,
|
||||
request,
|
||||
column_type,
|
||||
):
|
||||
def render_cell(row, value, column, table, database, datasette):
|
||||
"""Customize rendering of HTML table cell values"""
|
||||
|
||||
|
||||
|
|
@ -79,16 +74,6 @@ def register_facet_classes():
|
|||
"""Register Facet subclasses"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_actions(datasette):
|
||||
"""Register actions: returns a list of datasette.permission.Action objects"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_column_types(datasette):
|
||||
"""Return a list of ColumnType subclasses"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_routes(datasette):
|
||||
"""Register URL routes: return a list of (regex, view_function) pairs"""
|
||||
|
|
@ -104,16 +89,6 @@ def actor_from_request(datasette, request):
|
|||
"""Return an actor dictionary based on the incoming request"""
|
||||
|
||||
|
||||
@hookspec(firstresult=True)
|
||||
def actors_from_ids(datasette, actor_ids):
|
||||
"""Returns a dictionary mapping those IDs to actor dictionaries"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jinja2_environment_from_request(datasette, request, env):
|
||||
"""Return a Jinja2 environment based on the incoming request"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def filters_from_request(request, database, table, datasette):
|
||||
"""
|
||||
|
|
@ -126,15 +101,13 @@ def filters_from_request(request, database, table, datasette):
|
|||
|
||||
|
||||
@hookspec
|
||||
def permission_resources_sql(datasette, actor, action):
|
||||
"""Return SQL query fragments for permission checks on resources.
|
||||
def permission_allowed(datasette, actor, action, resource):
|
||||
"""Check if actor is allowed to perform this action - return True, False or None"""
|
||||
|
||||
Returns None, a PermissionSQL object, or a list of PermissionSQL objects.
|
||||
Each PermissionSQL contains SQL that should return rows with columns:
|
||||
parent (str|None), child (str|None), allow (int), reason (str).
|
||||
|
||||
Used to efficiently check permissions across multiple resources at once.
|
||||
"""
|
||||
@hookspec
|
||||
def canned_queries(datasette, database, actor):
|
||||
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
@ -152,114 +125,21 @@ def menu_links(datasette, actor, request):
|
|||
"""Links for the navigation menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
"""SQL fragments for extra items in the jump menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Links for the row actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def table_actions(datasette, actor, database, table, request):
|
||||
"""Links for the table actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def view_actions(datasette, actor, database, view, request):
|
||||
"""Links for the view actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||
"""Links for the query and stored query actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def database_actions(datasette, actor, database, request):
|
||||
"""Links for the database actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def homepage_actions(datasette, actor, request):
|
||||
"""Links for the homepage actions menu"""
|
||||
def skip_csrf(datasette, scope):
|
||||
"""Mechanism for skipping CSRF checks for certain requests"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def handle_exception(datasette, request, exception):
|
||||
"""Handle an uncaught exception. Can return a Response or None."""
|
||||
|
||||
|
||||
@hookspec
|
||||
def track_event(datasette, event):
|
||||
"""Respond to an event tracked by Datasette"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_events(datasette):
|
||||
"""Return a list of Event subclasses to use with track_event()"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_homepage(datasette, request):
|
||||
"""HTML to include at the top of the homepage"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_database(datasette, request, database):
|
||||
"""HTML to include at the top of the database page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_table(datasette, request, database, table):
|
||||
"""HTML to include at the top of the table page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_row(datasette, request, database, table, row):
|
||||
"""HTML to include at the top of the row page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_query(datasette, request, database, sql):
|
||||
"""HTML to include at the top of the query results page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_stored_query(datasette, request, database, query_name):
|
||||
"""HTML to include at the top of the stored query page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_token_handler(datasette):
|
||||
"""Return a TokenHandler instance for token creation and verification"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def write_wrapper(datasette, database, request, transaction):
|
||||
"""Called when a write function is about to execute.
|
||||
|
||||
Return a generator function that accepts a ``conn`` argument and
|
||||
optionally a ``track_event`` argument. The generator should
|
||||
``yield`` exactly once: code before the ``yield`` runs before
|
||||
the write, code after the ``yield`` runs after the write
|
||||
completes. The result of the write is sent back through the
|
||||
``yield``, so you can capture it with ``result = yield``.
|
||||
|
||||
If your generator accepts ``track_event``, you can call
|
||||
``track_event(event)`` to queue an event that will be dispatched
|
||||
via ``datasette.track_event()`` after the write commits
|
||||
successfully. Events are discarded if the write raises an
|
||||
exception.
|
||||
|
||||
If the write raises an exception, it is thrown into the generator
|
||||
so you can handle it with a try/except around the ``yield``.
|
||||
|
||||
``request`` may be ``None`` for writes not originating from an
|
||||
HTTP request. ``transaction`` is ``True`` if the write will
|
||||
be wrapped in a transaction.
|
||||
|
||||
Return ``None`` to skip wrapping.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from .utils import (
|
|||
sqlite3,
|
||||
)
|
||||
|
||||
|
||||
HASH_BLOCK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
|
|
@ -69,11 +70,16 @@ def inspect_tables(conn, database_metadata):
|
|||
tables[table]["foreign_keys"] = info
|
||||
|
||||
# Mark tables 'hidden' if they relate to FTS virtual tables
|
||||
hidden_tables = [r["name"] for r in conn.execute("""
|
||||
hidden_tables = [
|
||||
r["name"]
|
||||
for r in conn.execute(
|
||||
"""
|
||||
select name from sqlite_master
|
||||
where rootpage = 0
|
||||
and sql like '%VIRTUAL TABLE%USING FTS%'
|
||||
""")]
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
if detect_spatialite(conn):
|
||||
# Also hide Spatialite internal tables
|
||||
|
|
@ -88,11 +94,14 @@ def inspect_tables(conn, database_metadata):
|
|||
"views_geometry_columns",
|
||||
"virts_geometry_columns",
|
||||
] + [
|
||||
r["name"] for r in conn.execute("""
|
||||
r["name"]
|
||||
for r in conn.execute(
|
||||
"""
|
||||
select name from sqlite_master
|
||||
where name like "idx_%"
|
||||
and type = "table"
|
||||
""")
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
for t in tables.keys():
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class JumpSQL:
|
||||
sql: str
|
||||
params: dict[str, Any] | None = None
|
||||
database: str | None = None
|
||||
|
||||
@classmethod
|
||||
def menu_item(
|
||||
cls,
|
||||
*,
|
||||
label: str,
|
||||
url: str,
|
||||
description: str = "Menu item",
|
||||
search_text: str | None = None,
|
||||
display_name: str | None = None,
|
||||
item_type: str = "menu",
|
||||
) -> "JumpSQL":
|
||||
if search_text is None:
|
||||
search_text = " ".join(
|
||||
text for text in (label, display_name, description) if text is not None
|
||||
)
|
||||
return cls(
|
||||
sql="""
|
||||
SELECT
|
||||
:type AS type,
|
||||
:label AS label,
|
||||
:description AS description,
|
||||
:url AS url,
|
||||
:search_text AS search_text,
|
||||
:display_name AS display_name
|
||||
""",
|
||||
params={
|
||||
"type": item_type,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"search_text": search_text,
|
||||
"display_name": display_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
def namespace_sql_params(sql: str, params: dict[str, Any], prefix: str):
|
||||
"""Rename named SQL parameters so UNION query parameters cannot collide."""
|
||||
if not params:
|
||||
return sql, {}
|
||||
|
||||
renamed = {key: f"{prefix}_{key}" for key in params}
|
||||
|
||||
def replace(match):
|
||||
key = match.group(1)
|
||||
if key not in renamed:
|
||||
return match.group(0)
|
||||
return f":{renamed[key]}"
|
||||
|
||||
return _PARAM_RE.sub(replace, sql), {
|
||||
renamed[key]: value for key, value in params.items()
|
||||
}
|
||||
|
|
@ -1,209 +1,19 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, NamedTuple
|
||||
import contextvars
|
||||
import collections
|
||||
|
||||
# Context variable to track when permission checks should be skipped
|
||||
_skip_permission_checks = contextvars.ContextVar(
|
||||
"skip_permission_checks", default=False
|
||||
Permission = collections.namedtuple(
|
||||
"Permission", ("name", "abbr", "takes_database", "takes_table", "default")
|
||||
)
|
||||
|
||||
|
||||
class SkipPermissions:
|
||||
"""Context manager to temporarily skip permission checks.
|
||||
|
||||
This is not a stable API and may change in future releases.
|
||||
|
||||
Usage:
|
||||
with SkipPermissions():
|
||||
# Permission checks are skipped within this block
|
||||
response = await datasette.client.get("/protected")
|
||||
"""
|
||||
|
||||
def __enter__(self):
|
||||
self.token = _skip_permission_checks.set(True)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
_skip_permission_checks.reset(self.token)
|
||||
return False
|
||||
|
||||
|
||||
class Resource(ABC):
|
||||
"""
|
||||
Base class for all resource types.
|
||||
|
||||
Each subclass represents a type of resource (e.g., TableResource, DatabaseResource).
|
||||
The class itself carries metadata about the resource type.
|
||||
Instances represent specific resources.
|
||||
"""
|
||||
|
||||
# Class-level metadata (subclasses must define these)
|
||||
name: str = None # e.g., "table", "database", "model"
|
||||
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
|
||||
|
||||
# Instance-level optional extra attributes
|
||||
reasons: list[str] | None = None
|
||||
include_reasons: bool | None = None
|
||||
|
||||
def __init__(self, parent: str | None = None, child: str | None = None):
|
||||
"""
|
||||
Create a resource instance.
|
||||
|
||||
Args:
|
||||
parent: The parent identifier (meaning depends on resource type)
|
||||
child: The child identifier (meaning depends on resource type)
|
||||
"""
|
||||
self.parent = parent
|
||||
self.child = child
|
||||
self._private = None # Sentinel to track if private was set
|
||||
|
||||
@property
|
||||
def private(self) -> bool:
|
||||
"""
|
||||
Whether this resource is private (accessible to actor but not anonymous).
|
||||
|
||||
This property is only available on Resource objects returned from
|
||||
allowed_resources() when include_is_private=True is used.
|
||||
|
||||
Raises:
|
||||
AttributeError: If accessed without calling include_is_private=True
|
||||
"""
|
||||
if self._private is None:
|
||||
raise AttributeError(
|
||||
"The 'private' attribute is only available when using "
|
||||
"allowed_resources(..., include_is_private=True)"
|
||||
)
|
||||
return self._private
|
||||
|
||||
@private.setter
|
||||
def private(self, value: bool):
|
||||
self._private = value
|
||||
|
||||
@classmethod
|
||||
def __init_subclass__(cls):
|
||||
"""
|
||||
Validate resource hierarchy doesn't exceed 2 levels.
|
||||
|
||||
Raises:
|
||||
ValueError: If this resource would create a 3-level hierarchy
|
||||
"""
|
||||
super().__init_subclass__()
|
||||
|
||||
if cls.parent_class is None:
|
||||
return # Top of hierarchy, nothing to validate
|
||||
|
||||
# Check if our parent has a parent - that would create 3 levels
|
||||
if cls.parent_class.parent_class is not None:
|
||||
# We have a parent, and that parent has a parent
|
||||
# This creates a 3-level hierarchy, which is not allowed
|
||||
raise ValueError(
|
||||
f"Resource {cls.__name__} creates a 3-level hierarchy: "
|
||||
f"{cls.parent_class.parent_class.__name__} -> {cls.parent_class.__name__} -> {cls.__name__}. "
|
||||
f"Maximum 2 levels allowed (parent -> child)."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||
"""
|
||||
Return SQL query that returns all resources of this type.
|
||||
|
||||
Must return two columns: parent, child
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AllowedResource(NamedTuple):
|
||||
"""A resource with the reason it was allowed (for debugging)."""
|
||||
|
||||
resource: Resource
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class Action:
|
||||
name: str
|
||||
description: str | None
|
||||
abbr: str | None = None
|
||||
resource_class: type[Resource] | None = None
|
||||
also_requires: str | None = None # Optional action name that must also be allowed
|
||||
|
||||
@property
|
||||
def takes_parent(self) -> bool:
|
||||
"""
|
||||
Whether this action requires a parent identifier when instantiating its resource.
|
||||
|
||||
Returns False for global-only actions (no resource_class).
|
||||
Returns True for all actions with a resource_class (all resources require a parent identifier).
|
||||
"""
|
||||
return self.resource_class is not None
|
||||
|
||||
@property
|
||||
def takes_child(self) -> bool:
|
||||
"""
|
||||
Whether this action requires a child identifier when instantiating its resource.
|
||||
|
||||
Returns False for global actions (no resource_class).
|
||||
Returns False for parent-level resources (DatabaseResource - parent_class is None).
|
||||
Returns True for child-level resources (TableResource, QueryResource - have a parent_class).
|
||||
"""
|
||||
if self.resource_class is None:
|
||||
return False
|
||||
return self.resource_class.parent_class is not None
|
||||
|
||||
|
||||
_reason_id = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionSQL:
|
||||
"""
|
||||
A plugin contributes SQL that yields:
|
||||
parent TEXT NULL,
|
||||
child TEXT NULL,
|
||||
allow INTEGER, -- 1 allow, 0 deny
|
||||
reason TEXT
|
||||
|
||||
For restriction-only plugins, sql can be None and only restriction_sql is provided.
|
||||
"""
|
||||
|
||||
sql: str | None = (
|
||||
None # SQL that SELECTs the 4 columns above (can be None for restriction-only)
|
||||
)
|
||||
params: dict[str, Any] | None = (
|
||||
None # bound params for the SQL (values only; no ':' prefix)
|
||||
)
|
||||
source: str | None = None # System will set this to the plugin name
|
||||
restriction_sql: str | None = (
|
||||
None # Optional SQL that returns (parent, child) for restriction filtering
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def allow(cls, reason: str, _allow: bool = True) -> "PermissionSQL":
|
||||
global _reason_id
|
||||
i = _reason_id
|
||||
_reason_id += 1
|
||||
return cls(
|
||||
sql=f"SELECT NULL AS parent, NULL AS child, {1 if _allow else 0} AS allow, :reason_{i} AS reason",
|
||||
params={f"reason_{i}": reason},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def deny(cls, reason: str) -> "PermissionSQL":
|
||||
return cls.allow(reason=reason, _allow=False)
|
||||
|
||||
|
||||
# This is obsolete, replaced by Action and ResourceType
|
||||
@dataclass
|
||||
class Permission:
|
||||
name: str
|
||||
abbr: str | None
|
||||
description: str | None
|
||||
takes_database: bool
|
||||
takes_resource: bool
|
||||
default: bool
|
||||
# This is deliberately undocumented: it's considered an internal
|
||||
# implementation detail for view-table/view-database and should
|
||||
# not be used by plugins as it may change in the future.
|
||||
implies_can_view: bool = False
|
||||
PERMISSIONS = (
|
||||
Permission("view-instance", "vi", False, False, True),
|
||||
Permission("view-database", "vd", True, False, True),
|
||||
Permission("view-database-download", "vdd", True, False, True),
|
||||
Permission("view-table", "vt", True, True, True),
|
||||
Permission("view-query", "vq", True, True, True),
|
||||
Permission("insert-row", "ir", True, True, False),
|
||||
Permission("delete-row", "dr", True, True, False),
|
||||
Permission("drop-table", "dt", True, True, False),
|
||||
Permission("execute-sql", "es", True, False, True),
|
||||
Permission("permissions-debug", "pd", False, False, False),
|
||||
Permission("debug-menu", "dm", False, False, False),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,9 @@
|
|||
import importlib
|
||||
import os
|
||||
import pluggy
|
||||
from pprint import pprint
|
||||
import pkg_resources
|
||||
import sys
|
||||
from . import hookspecs
|
||||
|
||||
if sys.version_info >= (3, 9):
|
||||
import importlib.resources as importlib_resources
|
||||
else:
|
||||
import importlib_resources
|
||||
if sys.version_info >= (3, 10):
|
||||
import importlib.metadata as importlib_metadata
|
||||
else:
|
||||
import importlib_metadata
|
||||
|
||||
|
||||
DEFAULT_PLUGINS = (
|
||||
"datasette.publish.heroku",
|
||||
"datasette.publish.cloudrun",
|
||||
|
|
@ -23,69 +12,20 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.sql_functions",
|
||||
"datasette.actor_auth_cookie",
|
||||
"datasette.default_permissions",
|
||||
"datasette.default_permissions.tokens",
|
||||
"datasette.default_actions",
|
||||
"datasette.default_column_types",
|
||||
"datasette.default_magic_parameters",
|
||||
"datasette.blob_renderer",
|
||||
"datasette.default_debug_menu",
|
||||
"datasette.default_jump_items",
|
||||
"datasette.default_database_actions",
|
||||
"datasette.default_menu_links",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
"datasette.events",
|
||||
)
|
||||
|
||||
pm = pluggy.PluginManager("datasette")
|
||||
pm.add_hookspecs(hookspecs)
|
||||
|
||||
DATASETTE_TRACE_PLUGINS = os.environ.get("DATASETTE_TRACE_PLUGINS", None)
|
||||
|
||||
|
||||
def before(hook_name, hook_impls, kwargs):
|
||||
print(file=sys.stderr)
|
||||
print(f"{hook_name}:", file=sys.stderr)
|
||||
pprint(kwargs, width=40, indent=4, stream=sys.stderr)
|
||||
print("Hook implementations:", file=sys.stderr)
|
||||
pprint(hook_impls, width=40, indent=4, stream=sys.stderr)
|
||||
|
||||
|
||||
def after(outcome, hook_name, hook_impls, kwargs):
|
||||
results = outcome.get_result()
|
||||
if not isinstance(results, list):
|
||||
results = [results]
|
||||
print("Results:", file=sys.stderr)
|
||||
pprint(results, width=40, indent=4, stream=sys.stderr)
|
||||
|
||||
|
||||
if DATASETTE_TRACE_PLUGINS:
|
||||
pm.add_hookcall_monitoring(before, after)
|
||||
|
||||
|
||||
DATASETTE_LOAD_PLUGINS = os.environ.get("DATASETTE_LOAD_PLUGINS", None)
|
||||
|
||||
if not hasattr(sys, "_called_from_test") and DATASETTE_LOAD_PLUGINS is None:
|
||||
if not hasattr(sys, "_called_from_test"):
|
||||
# Only load plugins if not running tests
|
||||
pm.load_setuptools_entrypoints("datasette")
|
||||
|
||||
# Load any plugins specified in DATASETTE_LOAD_PLUGINS")
|
||||
if DATASETTE_LOAD_PLUGINS is not None:
|
||||
for package_name in [
|
||||
name for name in DATASETTE_LOAD_PLUGINS.split(",") if name.strip()
|
||||
]:
|
||||
try:
|
||||
distribution = importlib_metadata.distribution(package_name)
|
||||
entry_points = distribution.entry_points
|
||||
for entry_point in entry_points:
|
||||
if entry_point.group == "datasette":
|
||||
mod = entry_point.load()
|
||||
pm.register(mod, name=entry_point.name)
|
||||
# Ensure name can be found in plugin_to_distinfo later:
|
||||
pm._plugin_distinfo.append((mod, distribution))
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
sys.stderr.write("Plugin {} could not be found\n".format(package_name))
|
||||
|
||||
|
||||
# Load default plugins
|
||||
for plugin in DEFAULT_PLUGINS:
|
||||
mod = importlib.import_module(plugin)
|
||||
|
|
@ -98,24 +38,21 @@ def get_plugins():
|
|||
for plugin in pm.get_plugins():
|
||||
static_path = None
|
||||
templates_path = None
|
||||
plugin_name = (
|
||||
plugin.__name__
|
||||
if hasattr(plugin, "__name__")
|
||||
else plugin.__class__.__name__
|
||||
)
|
||||
if plugin_name not in DEFAULT_PLUGINS:
|
||||
if plugin.__name__ not in DEFAULT_PLUGINS:
|
||||
try:
|
||||
if (importlib_resources.files(plugin_name) / "static").is_dir():
|
||||
static_path = str(importlib_resources.files(plugin_name) / "static")
|
||||
if (importlib_resources.files(plugin_name) / "templates").is_dir():
|
||||
templates_path = str(
|
||||
importlib_resources.files(plugin_name) / "templates"
|
||||
if pkg_resources.resource_isdir(plugin.__name__, "static"):
|
||||
static_path = pkg_resources.resource_filename(
|
||||
plugin.__name__, "static"
|
||||
)
|
||||
except (TypeError, ModuleNotFoundError):
|
||||
# Caused by --plugins_dir= plugins
|
||||
if pkg_resources.resource_isdir(plugin.__name__, "templates"):
|
||||
templates_path = pkg_resources.resource_filename(
|
||||
plugin.__name__, "templates"
|
||||
)
|
||||
except (KeyError, ImportError):
|
||||
# Caused by --plugins_dir= plugins - KeyError/ImportError thrown in Py3.5
|
||||
pass
|
||||
plugin_info = {
|
||||
"name": plugin_name,
|
||||
"name": plugin.__name__,
|
||||
"static_path": static_path,
|
||||
"templates_path": templates_path,
|
||||
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
|
||||
|
|
@ -123,6 +60,6 @@ def get_plugins():
|
|||
distinfo = plugin_to_distinfo.get(plugin)
|
||||
if distinfo:
|
||||
plugin_info["version"] = distinfo.version
|
||||
plugin_info["name"] = distinfo.name or distinfo.project_name
|
||||
plugin_info["name"] = distinfo.project_name
|
||||
plugins.append(plugin_info)
|
||||
return plugins
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import click
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from subprocess import CalledProcessError, check_call, check_output
|
||||
from subprocess import check_call, check_output
|
||||
|
||||
from .common import (
|
||||
add_common_publish_arguments_and_options,
|
||||
|
|
@ -23,9 +23,7 @@ def publish_subcommand(publish):
|
|||
help="Application name to use when building",
|
||||
)
|
||||
@click.option(
|
||||
"--service",
|
||||
default="",
|
||||
help="Cloud Run service to deploy (or over-write)",
|
||||
"--service", default="", help="Cloud Run service to deploy (or over-write)"
|
||||
)
|
||||
@click.option("--spatialite", is_flag=True, help="Enable SpatialLite extension")
|
||||
@click.option(
|
||||
|
|
@ -57,32 +55,13 @@ def publish_subcommand(publish):
|
|||
@click.option(
|
||||
"--max-instances",
|
||||
type=int,
|
||||
default=1,
|
||||
show_default=True,
|
||||
help="Maximum Cloud Run instances (use 0 to remove the limit)",
|
||||
help="Maximum Cloud Run instances",
|
||||
)
|
||||
@click.option(
|
||||
"--min-instances",
|
||||
type=int,
|
||||
help="Minimum Cloud Run instances",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-repository",
|
||||
default="datasette",
|
||||
show_default=True,
|
||||
help="Artifact Registry repository to store the image",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-region",
|
||||
default="us",
|
||||
show_default=True,
|
||||
help="Artifact Registry location (region or multi-region)",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-project",
|
||||
default=None,
|
||||
help="Project ID for Artifact Registry (defaults to the active project)",
|
||||
)
|
||||
def cloudrun(
|
||||
files,
|
||||
metadata,
|
||||
|
|
@ -112,9 +91,6 @@ def publish_subcommand(publish):
|
|||
apt_get_extras,
|
||||
max_instances,
|
||||
min_instances,
|
||||
artifact_repository,
|
||||
artifact_region,
|
||||
artifact_project,
|
||||
):
|
||||
"Publish databases to Datasette running on Cloud Run"
|
||||
fail_if_publish_binary_not_installed(
|
||||
|
|
@ -124,21 +100,6 @@ def publish_subcommand(publish):
|
|||
"gcloud config get-value project", shell=True, universal_newlines=True
|
||||
).strip()
|
||||
|
||||
artifact_project = artifact_project or project
|
||||
|
||||
# Ensure Artifact Registry exists for the target image
|
||||
_ensure_artifact_registry(
|
||||
artifact_project=artifact_project,
|
||||
artifact_region=artifact_region,
|
||||
artifact_repository=artifact_repository,
|
||||
)
|
||||
|
||||
artifact_host = (
|
||||
artifact_region
|
||||
if artifact_region.endswith("-docker.pkg.dev")
|
||||
else f"{artifact_region}-docker.pkg.dev"
|
||||
)
|
||||
|
||||
if not service:
|
||||
# Show the user their current services, then prompt for one
|
||||
click.echo("Please provide a service name for this deployment\n")
|
||||
|
|
@ -156,11 +117,6 @@ def publish_subcommand(publish):
|
|||
click.echo("")
|
||||
service = click.prompt("Service name", type=str)
|
||||
|
||||
image_id = (
|
||||
f"{artifact_host}/{artifact_project}/"
|
||||
f"{artifact_repository}/datasette-{service}"
|
||||
)
|
||||
|
||||
extra_metadata = {
|
||||
"title": title,
|
||||
"license": license,
|
||||
|
|
@ -217,6 +173,7 @@ def publish_subcommand(publish):
|
|||
print(fp.read())
|
||||
print("\n====================\n")
|
||||
|
||||
image_id = f"gcr.io/{project}/{name}"
|
||||
check_call(
|
||||
"gcloud builds submit --tag {}{}".format(
|
||||
image_id, " --timeout {}".format(timeout) if timeout else ""
|
||||
|
|
@ -230,7 +187,7 @@ def publish_subcommand(publish):
|
|||
("--max-instances", max_instances),
|
||||
("--min-instances", min_instances),
|
||||
):
|
||||
if value is not None:
|
||||
if value:
|
||||
extra_deploy_options.append("{} {}".format(option, value))
|
||||
check_call(
|
||||
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
||||
|
|
@ -242,52 +199,6 @@ def publish_subcommand(publish):
|
|||
)
|
||||
|
||||
|
||||
def _ensure_artifact_registry(artifact_project, artifact_region, artifact_repository):
|
||||
"""Ensure Artifact Registry API is enabled and the repository exists."""
|
||||
|
||||
enable_cmd = (
|
||||
"gcloud services enable artifactregistry.googleapis.com "
|
||||
f"--project {artifact_project} --quiet"
|
||||
)
|
||||
try:
|
||||
check_call(enable_cmd, shell=True)
|
||||
except CalledProcessError as exc:
|
||||
raise click.ClickException(
|
||||
"Failed to enable artifactregistry.googleapis.com. "
|
||||
"Please ensure you have permissions to manage services."
|
||||
) from exc
|
||||
|
||||
describe_cmd = (
|
||||
"gcloud artifacts repositories describe {repo} --project {project} "
|
||||
"--location {location} --quiet"
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
project=artifact_project,
|
||||
location=artifact_region,
|
||||
)
|
||||
try:
|
||||
check_call(describe_cmd, shell=True)
|
||||
return
|
||||
except CalledProcessError:
|
||||
create_cmd = (
|
||||
"gcloud artifacts repositories create {repo} --repository-format=docker "
|
||||
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
location=artifact_region,
|
||||
project=artifact_project,
|
||||
)
|
||||
try:
|
||||
check_call(create_cmd, shell=True)
|
||||
click.echo(f"Created Artifact Registry repository '{artifact_repository}'")
|
||||
except CalledProcessError as exc:
|
||||
raise click.ClickException(
|
||||
"Failed to create Artifact Registry repository. "
|
||||
"Use --artifact-repository/--artifact-region to point to an existing repo "
|
||||
"or create one manually."
|
||||
) from exc
|
||||
|
||||
|
||||
def get_existing_services():
|
||||
services = json.loads(
|
||||
check_output(
|
||||
|
|
@ -303,7 +214,6 @@ def get_existing_services():
|
|||
"url": service["status"]["address"]["url"],
|
||||
}
|
||||
for service in services
|
||||
if "url" in service["status"]
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from datasette.utils import (
|
|||
remove_infinites,
|
||||
CustomJSONEncoder,
|
||||
path_from_row_pks,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
|
|
@ -20,14 +19,14 @@ def convert_specific_columns_to_json(rows, columns, json_cols):
|
|||
if column in json_cols:
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError) as e:
|
||||
pass
|
||||
new_row.append(value)
|
||||
new_rows.append(new_row)
|
||||
return new_rows
|
||||
|
||||
|
||||
def json_renderer(request, args, data, error, truncated=None):
|
||||
def json_renderer(args, data, view_name):
|
||||
"""Render a response as JSON"""
|
||||
status_code = 200
|
||||
|
||||
|
|
@ -45,38 +44,28 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
data["rows"] = [remove_infinites(row) for row in data["rows"]]
|
||||
|
||||
# Deal with the _shape option
|
||||
shape = args.get("_shape", "objects")
|
||||
shape = args.get("_shape", "arrays")
|
||||
# if there's an error, ignore the shape entirely
|
||||
data["ok"] = True
|
||||
if error:
|
||||
shape = "objects"
|
||||
status_code = 400
|
||||
data["error"] = error
|
||||
data["ok"] = False
|
||||
if data.get("error"):
|
||||
shape = "arrays"
|
||||
|
||||
next_url = data.get("next_url")
|
||||
|
||||
if truncated is not None:
|
||||
data["truncated"] = truncated
|
||||
if shape == "arrayfirst":
|
||||
if not data["rows"]:
|
||||
data = []
|
||||
elif isinstance(data["rows"][0], sqlite3.Row):
|
||||
data = [row[0] for row in data["rows"]]
|
||||
else:
|
||||
assert isinstance(data["rows"][0], dict)
|
||||
data = [next(iter(row.values())) for row in data["rows"]]
|
||||
elif shape in ("objects", "object", "array"):
|
||||
columns = data.get("columns")
|
||||
rows = data.get("rows")
|
||||
if rows and columns and not isinstance(rows[0], dict):
|
||||
if rows and columns:
|
||||
data["rows"] = [dict(zip(columns, row)) for row in rows]
|
||||
if shape == "object":
|
||||
shape_error = None
|
||||
error = None
|
||||
if "primary_keys" not in data:
|
||||
shape_error = "_shape=object is only available on tables"
|
||||
error = "_shape=object is only available on tables"
|
||||
else:
|
||||
pks = data["primary_keys"]
|
||||
if not pks:
|
||||
shape_error = (
|
||||
error = (
|
||||
"_shape=object not available for tables with no primary keys"
|
||||
)
|
||||
else:
|
||||
|
|
@ -85,18 +74,13 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
pk_string = path_from_row_pks(row, pks, not pks)
|
||||
object_rows[pk_string] = row
|
||||
data = object_rows
|
||||
if shape_error:
|
||||
data = {"ok": False, "error": shape_error}
|
||||
if error:
|
||||
data = {"ok": False, "error": error}
|
||||
elif shape == "array":
|
||||
data = data["rows"]
|
||||
|
||||
elif shape == "arrays":
|
||||
if not data["rows"]:
|
||||
pass
|
||||
elif isinstance(data["rows"][0], sqlite3.Row):
|
||||
data["rows"] = [list(row) for row in data["rows"]]
|
||||
else:
|
||||
data["rows"] = [list(row.values()) for row in data["rows"]]
|
||||
else:
|
||||
status_code = 400
|
||||
data = {
|
||||
|
|
@ -105,12 +89,6 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
"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 request.args.getlist("_extra"):
|
||||
data.pop("columns", None)
|
||||
|
||||
# Handle _nl option for _shape=array
|
||||
nl = args.get("_nl", "")
|
||||
if nl and shape == "array":
|
||||
|
|
@ -120,6 +98,8 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
body = json.dumps(data, cls=CustomJSONEncoder)
|
||||
content_type = "application/json; charset=utf-8"
|
||||
headers = {}
|
||||
if next_url:
|
||||
headers["link"] = f'<{next_url}>; rel="next"'
|
||||
return Response(
|
||||
body, status=status_code, headers=headers, content_type=content_type
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
"""Core resource types for Datasette's permission system."""
|
||||
|
||||
from datasette.permissions import Resource
|
||||
|
||||
|
||||
class DatabaseResource(Resource):
|
||||
"""A database in Datasette."""
|
||||
|
||||
name = "database"
|
||||
parent_class = None # Top of the resource hierarchy
|
||||
|
||||
def __init__(self, database: str):
|
||||
super().__init__(parent=database, child=None)
|
||||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||
return """
|
||||
SELECT database_name AS parent, NULL AS child
|
||||
FROM catalog_databases
|
||||
"""
|
||||
|
||||
|
||||
class TableResource(Resource):
|
||||
"""A table in a database."""
|
||||
|
||||
name = "table"
|
||||
parent_class = DatabaseResource
|
||||
|
||||
def __init__(self, database: str, table: str):
|
||||
super().__init__(parent=database, child=table)
|
||||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||
return """
|
||||
SELECT database_name AS parent, table_name AS child
|
||||
FROM catalog_tables
|
||||
UNION ALL
|
||||
SELECT database_name AS parent, view_name AS child
|
||||
FROM catalog_views
|
||||
"""
|
||||
|
||||
|
||||
class QueryResource(Resource):
|
||||
"""A stored query in a database."""
|
||||
|
||||
name = "query"
|
||||
parent_class = DatabaseResource
|
||||
|
||||
def __init__(self, database: str, query: str):
|
||||
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
|
||||
"""
|
||||
|
|
@ -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;
|
||||
|
|
@ -171,22 +163,28 @@ h6,
|
|||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
border-left: 10px solid #666;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
padding-right: 0.2em;
|
||||
}
|
||||
|
||||
.page-action-menu details > summary {
|
||||
.page-header details {
|
||||
display: inline-flex;
|
||||
}
|
||||
.page-header details > summary {
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-action-menu details > summary::-webkit-details-marker {
|
||||
.page-header details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +228,12 @@ button.button-as-link:focus {
|
|||
color: #67C98D;
|
||||
}
|
||||
|
||||
a img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: monospace;
|
||||
|
|
@ -267,28 +271,24 @@ a.not-underlined {
|
|||
|
||||
/* Page Furniture ========================================================= */
|
||||
/* Header */
|
||||
header.hd,
|
||||
footer.ft {
|
||||
header,
|
||||
footer {
|
||||
padding: 0.6rem 1rem 0.5rem 1rem;
|
||||
background-color: #276890;
|
||||
background: linear-gradient(180deg, rgba(96,144,173,1) 0%, rgba(39,104,144,1) 50%);
|
||||
color: rgba(255,255,244,0.9);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
min-height: 2.6rem;
|
||||
}
|
||||
footer.ft {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
header.hd p,
|
||||
footer.ft p {
|
||||
header p,
|
||||
footer p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
header.hd .crumbs {
|
||||
header .crumbs {
|
||||
float: left;
|
||||
}
|
||||
header.hd .actor {
|
||||
header .actor {
|
||||
float: right;
|
||||
text-align: right;
|
||||
padding-left: 1rem;
|
||||
|
|
@ -297,32 +297,32 @@ header.hd .actor {
|
|||
top: -3px;
|
||||
}
|
||||
|
||||
footer.ft a:link,
|
||||
footer.ft a:visited,
|
||||
footer.ft a:hover,
|
||||
footer.ft a:focus,
|
||||
footer.ft a:active,
|
||||
footer.ft button.button-as-link {
|
||||
footer a:link,
|
||||
footer a:visited,
|
||||
footer a:hover,
|
||||
footer a:focus,
|
||||
footer a:active,
|
||||
footer button.button-as-link {
|
||||
color: rgba(255,255,244,0.8);
|
||||
}
|
||||
header.hd a:link,
|
||||
header.hd a:visited,
|
||||
header.hd a:hover,
|
||||
header.hd a:focus,
|
||||
header.hd a:active,
|
||||
header.hd button.button-as-link {
|
||||
header a:link,
|
||||
header a:visited,
|
||||
header a:hover,
|
||||
header a:focus,
|
||||
header a:active,
|
||||
header button.button-as-link {
|
||||
color: rgba(255,255,244,0.8);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer.ft a:hover,
|
||||
footer.ft a:focus,
|
||||
footer.ft a:active,
|
||||
footer.ft .button-as-link:hover,
|
||||
footer.ft .button-as-link:focus,
|
||||
header.hd a:hover,
|
||||
header.hd a:focus,
|
||||
header.hd a:active,
|
||||
footer a:hover,
|
||||
footer a:focus,
|
||||
footer a:active,
|
||||
footer.button-as-link:hover,
|
||||
footer.button-as-link:focus,
|
||||
header a:hover,
|
||||
header a:focus,
|
||||
header a:active,
|
||||
button.button-as-link:hover,
|
||||
button.button-as-link:focus {
|
||||
color: rgba(255,255,244,1);
|
||||
|
|
@ -334,6 +334,11 @@ section.content {
|
|||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Navigation menu */
|
||||
details.nav-menu > summary {
|
||||
list-style: none;
|
||||
|
|
@ -347,85 +352,25 @@ details.nav-menu > summary::-webkit-details-marker {
|
|||
}
|
||||
details .nav-menu-inner {
|
||||
position: absolute;
|
||||
top: 2.6rem;
|
||||
top: 2rem;
|
||||
right: 10px;
|
||||
width: 180px;
|
||||
background-color: #276890;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
padding: 0;
|
||||
}
|
||||
.nav-menu-inner li,
|
||||
form.nav-menu-logout {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-top: 1px solid #ffffff69;
|
||||
}
|
||||
.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 {
|
||||
.page-header {
|
||||
position: relative;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.actions-menu-links {
|
||||
display: inline;
|
||||
}
|
||||
.actions-menu-links .dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
}
|
||||
.page-action-menu .icon-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: .25rem;
|
||||
padding: 5px 12px 3px 7px;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
font-size: 0.8em;
|
||||
background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%);
|
||||
border-color: #007bff;
|
||||
}
|
||||
.page-action-menu .icon-text span {
|
||||
/* Nudge text up a bit */
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.page-action-menu .icon-text:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-action-menu .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
left: -10px;
|
||||
}
|
||||
|
||||
/* Components ============================================================== */
|
||||
|
|
@ -478,30 +423,36 @@ h2 em {
|
|||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table.rows-and-columns {
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.rows-and-columns td {
|
||||
td {
|
||||
border-top: 1px solid #aaa;
|
||||
border-right: 1px solid #eee;
|
||||
padding: 4px;
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
table.rows-and-columns td.type-pk {
|
||||
td.type-pk {
|
||||
font-weight: bold;
|
||||
}
|
||||
table.rows-and-columns td em {
|
||||
td em {
|
||||
font-style: normal;
|
||||
font-size: 0.8em;
|
||||
color: #aaa;
|
||||
}
|
||||
table.rows-and-columns th {
|
||||
th {
|
||||
padding-right: 1em;
|
||||
}
|
||||
table.rows-and-columns a:link {
|
||||
table a:link {
|
||||
text-decoration: none;
|
||||
}
|
||||
.rows-and-columns td:before {
|
||||
display: block;
|
||||
color: black;
|
||||
margin-left: -10%;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.rows-and-columns td ol,
|
||||
.rows-and-columns td ul {
|
||||
list-style: initial;
|
||||
|
|
@ -519,8 +470,10 @@ a.blob-download {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Forms =================================================================== */
|
||||
|
||||
|
||||
form.sql textarea {
|
||||
border: 1px solid #ccc;
|
||||
width: 70%;
|
||||
|
|
@ -529,30 +482,27 @@ form.sql textarea {
|
|||
font-family: monospace;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
form.sql label {
|
||||
form label {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
width: 15%;
|
||||
}
|
||||
.advanced-export form label {
|
||||
width: auto;
|
||||
}
|
||||
.advanced-export input[type=submit] {
|
||||
font-size: 0.6em;
|
||||
margin-left: 1em;
|
||||
}
|
||||
label.sort_by_desc {
|
||||
width: auto;
|
||||
padding-right: 1em;
|
||||
}
|
||||
pre#sql-query {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.core label,
|
||||
label.core {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.core input[type=text],
|
||||
input.core[type=text],
|
||||
.core input[type=search],
|
||||
input.core[type=search] {
|
||||
form input[type=text],
|
||||
form input[type=search] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
width: 60%;
|
||||
|
|
@ -561,27 +511,19 @@ input.core[type=search] {
|
|||
font-size: 1em;
|
||||
font-family: Helvetica, sans-serif;
|
||||
}
|
||||
.core input[type=search],
|
||||
input.core[type=search] {
|
||||
/* Stop Webkit from styling search boxes in an inconsistent way */
|
||||
/* https://css-tricks.com/webkit-html5-search-inputs/ comments */
|
||||
/* Stop Webkit from styling search boxes in an inconsistent way */
|
||||
/* https://css-tricks.com/webkit-html5-search-inputs/ comments */
|
||||
input[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
}
|
||||
.core input[type="search"]::-webkit-search-decoration,
|
||||
input.core[type="search"]::-webkit-search-decoration,
|
||||
.core input[type="search"]::-webkit-search-cancel-button,
|
||||
input.core[type="search"]::-webkit-search-cancel-button,
|
||||
.core input[type="search"]::-webkit-search-results-button,
|
||||
input.core[type="search"]::-webkit-search-results-button,
|
||||
.core input[type="search"]::-webkit-search-results-decoration,
|
||||
input.core[type="search"]::-webkit-search-results-decoration {
|
||||
input[type="search"]::-webkit-search-decoration,
|
||||
input[type="search"]::-webkit-search-cancel-button,
|
||||
input[type="search"]::-webkit-search-results-button,
|
||||
input[type="search"]::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.core input[type=submit],
|
||||
.core button[type=button],
|
||||
input.core[type=submit],
|
||||
button.core[type=button] {
|
||||
form input[type=submit], form button[type=button] {
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
|
|
@ -594,16 +536,14 @@ button.core[type=button] {
|
|||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.core input[type=submit],
|
||||
input.core[type=submit] {
|
||||
form input[type=submit] {
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%);
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
.core button[type=button],
|
||||
button.core[type=button] {
|
||||
form button[type=button] {
|
||||
color: #007bff;
|
||||
background-color: #fff;
|
||||
border-color: #007bff;
|
||||
|
|
@ -633,9 +573,6 @@ button.core[type=button] {
|
|||
display: inline-block;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
.select-wrapper:focus-within {
|
||||
border: 1px solid black;
|
||||
}
|
||||
.select-wrapper.filter-op {
|
||||
width: 80px;
|
||||
}
|
||||
|
|
@ -681,14 +618,10 @@ button.core[type=button] {
|
|||
border-radius: 3px;
|
||||
-webkit-appearance: none;
|
||||
padding: 9px 4px;
|
||||
font-size: 16px;
|
||||
font-size: 1em;
|
||||
font-family: Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
#_search {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -768,474 +701,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: #f5f3ef;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--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: #f5f3ef;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--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: #fcfbf9;
|
||||
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;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
||||
.small-screen-only {
|
||||
|
|
@ -1265,7 +730,7 @@ dialog.set-column-type-dialog::backdrop {
|
|||
left: -9999px;
|
||||
}
|
||||
|
||||
table.rows-and-columns tr {
|
||||
.rows-and-columns tr {
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 10px;
|
||||
|
|
@ -1273,7 +738,7 @@ dialog.set-column-type-dialog::backdrop {
|
|||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
table.rows-and-columns td {
|
||||
.rows-and-columns td {
|
||||
/* Behave like a "row" */
|
||||
border: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
|
@ -1281,7 +746,7 @@ dialog.set-column-type-dialog::backdrop {
|
|||
padding-left: 10%;
|
||||
}
|
||||
|
||||
table.rows-and-columns td:before {
|
||||
.rows-and-columns td:before {
|
||||
display: block;
|
||||
color: black;
|
||||
margin-left: -10%;
|
||||
|
|
@ -1297,43 +762,6 @@ dialog.set-column-type-dialog::backdrop {
|
|||
.filters input.filter-value {
|
||||
width: 140px;
|
||||
}
|
||||
button.choose-columns-mobile,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
svg.dropdown-menu-icon {
|
||||
|
|
@ -1390,13 +818,6 @@ svg.dropdown-menu-icon {
|
|||
.dropdown-menu a:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
.dropdown-menu .dropdown-description {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
max-width: 80vw;
|
||||
white-space: normal;
|
||||
}
|
||||
.dropdown-menu .hook {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
@ -1409,15 +830,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;
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<style>
|
||||
:host {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--accent: #1a56db;
|
||||
--accent-light: #e8effd;
|
||||
--card: #ffffff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
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: slideIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: min(640px, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-meta {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
background: var(--paper);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
padding: 6px 24px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-toolbar button {
|
||||
background: var(--accent-light);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 3px 10px;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.list-toolbar button:hover { background: var(--accent); color: white; }
|
||||
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.list-wrap::before,
|
||||
.list-wrap::after {
|
||||
content: '';
|
||||
position: sticky;
|
||||
display: block;
|
||||
left: 0; right: 0;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.list-wrap::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
|
||||
}
|
||||
.list-wrap::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.scroll-zone {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 72px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.scroll-zone-top { top: 0; }
|
||||
.scroll-zone-bot { bottom: 0; }
|
||||
|
||||
.drag-list {
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.drag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
position: relative;
|
||||
transition: background 0.08s;
|
||||
}
|
||||
|
||||
.drag-item:last-child { border-bottom: none; }
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
color: #c8c4bc;
|
||||
touch-action: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.drag-handle:hover { color: var(--accent); }
|
||||
.drag-handle svg { pointer-events: none; display: block; }
|
||||
|
||||
.drag-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drag-item-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drag-item-check input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drag-item-label {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
line-height: 48px;
|
||||
padding-right: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.drag-item.is-dragging {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drop-indicator {
|
||||
position: absolute;
|
||||
left: 48px;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 99px;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
}
|
||||
.drop-indicator.top { top: -1px; display: block; }
|
||||
.drop-indicator.bottom { bottom: -1px; display: block; }
|
||||
|
||||
.drag-ghost {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1.5px solid var(--accent-light);
|
||||
opacity: 0.97;
|
||||
will-change: transform;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.scroll-pulse {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.scroll-pulse.top { top: 8px; }
|
||||
.scroll-pulse.bot { bottom: 8px; }
|
||||
.scroll-pulse.active {
|
||||
opacity: 0.18;
|
||||
animation: pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }
|
||||
50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { background: #1448c0; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--rule); color: var(--ink); }
|
||||
|
||||
.list-wrap::-webkit-scrollbar { width: 5px; }
|
||||
.list-wrap::-webkit-scrollbar-track { background: transparent; }
|
||||
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
|
||||
|
||||
input, textarea { -webkit-user-select: auto; user-select: auto; }
|
||||
</style>
|
||||
|
||||
<dialog aria-labelledby="modalTitle">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modalTitle">Choose columns</span>
|
||||
<span class="modal-meta" id="selectedCount"></span>
|
||||
</div>
|
||||
<div class="list-toolbar">
|
||||
<button id="selectAllBtn">Select all</button>
|
||||
<button id="deselectAllBtn">Deselect all</button>
|
||||
</div>
|
||||
<div class="list-wrap" id="listWrap">
|
||||
<div class="scroll-pulse top" id="pulseTop"></div>
|
||||
<div class="scroll-pulse bot" id="pulseBot"></div>
|
||||
<ul class="drag-list" id="dragList"></ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span class="footer-info" id="footerInfo"></span>
|
||||
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
|
||||
<button class="btn btn-primary" id="applyBtn">Apply</button>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<span class="drag-handle" aria-label="Drag to reorder">
|
||||
<svg width="12" height="18" viewBox="0 0 12 18" fill="currentColor">
|
||||
<circle cx="3.5" cy="3.5" r="1.8"/>
|
||||
<circle cx="8.5" cy="3.5" r="1.8"/>
|
||||
<circle cx="3.5" cy="9" r="1.8"/>
|
||||
<circle cx="8.5" cy="9" r="1.8"/>
|
||||
<circle cx="3.5" cy="14.5" r="1.8"/>
|
||||
<circle cx="8.5" cy="14.5" r="1.8"/>
|
||||
</svg>
|
||||
</span>
|
||||
<label class="drag-item-content">
|
||||
<span class="drag-item-check">
|
||||
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
|
||||
</span>
|
||||
<span class="drag-item-label">${col}</span>
|
||||
</label>
|
||||
<div class="drop-indicator"></div>
|
||||
`;
|
||||
|
||||
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);
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
// Custom events for use with the native CustomEvent API
|
||||
const DATASETTE_EVENTS = {
|
||||
INIT: "datasette_init", // returns datasette manager instance in evt.detail
|
||||
};
|
||||
|
||||
// Datasette "core" -> Methods/APIs that are foundational
|
||||
// Plugins will have greater stability if they use the functional hooks- but if they do decide to hook into
|
||||
// literal DOM selectors, they'll have an easier time using these addresses.
|
||||
const DOM_SELECTORS = {
|
||||
/** Should have one match */
|
||||
jsonExportLink: ".export-links a[href*=json]",
|
||||
|
||||
/** Event listeners that go outside of the main table, e.g. existing scroll listener */
|
||||
tableWrapper: ".table-wrapper",
|
||||
table: "table.rows-and-columns",
|
||||
aboveTablePanel: ".above-table-panel",
|
||||
|
||||
// These could have multiple matches
|
||||
/** Used for selecting table headers. Use makeColumnActions if you want to add menu items. */
|
||||
tableHeaders: `table.rows-and-columns th`,
|
||||
|
||||
/** Used to add "where" clauses to query using direct manipulation */
|
||||
filterRows: ".filter-row",
|
||||
/** Used to show top available enum values for a column ("facets") */
|
||||
facetResults: ".facet-results [data-column]",
|
||||
};
|
||||
|
||||
/**
|
||||
* Monolith class for interacting with Datasette JS API
|
||||
* Imported with DEFER, runs after main document parsed
|
||||
* For now, manually synced with datasette/version.py
|
||||
*/
|
||||
const datasetteManager = {
|
||||
VERSION: window.datasetteVersion,
|
||||
|
||||
// TODO: Should order of registration matter more?
|
||||
|
||||
// Should plugins be allowed to clobber others or is it last-in takes priority?
|
||||
// Does pluginMetadata need to be serializable, or can we let it be stateful / have functions?
|
||||
plugins: new Map(),
|
||||
|
||||
registerPlugin: (name, pluginMetadata) => {
|
||||
if (datasetteManager.plugins.has(name)) {
|
||||
console.warn(`Warning -> plugin ${name} was redefined`);
|
||||
}
|
||||
datasetteManager.plugins.set(name, pluginMetadata);
|
||||
|
||||
// If the plugin participates in the panel... update the panel.
|
||||
if (pluginMetadata.makeAboveTablePanelConfigs) {
|
||||
datasetteManager.renderAboveTablePanel();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* New DOM elements are created on each click, so the data is not stale.
|
||||
*
|
||||
* Items
|
||||
* - must provide label (text)
|
||||
* - might provide href (string) or an onclick ((evt) => void)
|
||||
*
|
||||
* columnMeta is metadata stored on the column header (TH) as a DOMStringMap
|
||||
* - column: string
|
||||
* - columnNotNull: boolean
|
||||
* - columnType: sqlite datatype enum (text, number, etc)
|
||||
* - isPk: boolean
|
||||
*/
|
||||
makeColumnActions: (columnMeta) => {
|
||||
let columnActions = [];
|
||||
|
||||
// Accept function that returns list of columnActions with keys
|
||||
// Required: label (text)
|
||||
// Optional: onClick or href
|
||||
datasetteManager.plugins.forEach((plugin) => {
|
||||
if (plugin.makeColumnActions) {
|
||||
// Plugins can provide multiple columnActions if they want
|
||||
// If multiple try to create entry with same label, the last one deletes the others
|
||||
columnActions.push(...plugin.makeColumnActions(columnMeta));
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Validate columnAction configs and give informative error message if missing keys.
|
||||
return columnActions;
|
||||
},
|
||||
|
||||
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
|
||||
* shared URL state, so having multiple instances of plugin at same time is problematic.
|
||||
* Currently, we never destroy any panels, we just hide them.
|
||||
*
|
||||
* TODO: nicer panel css, show panel selection state.
|
||||
* TODO: does this hook need to take any arguments?
|
||||
*/
|
||||
renderAboveTablePanel: () => {
|
||||
const aboveTablePanel = document.querySelector(
|
||||
DOM_SELECTORS.aboveTablePanel,
|
||||
);
|
||||
|
||||
if (!aboveTablePanel) {
|
||||
console.warn(
|
||||
"This page does not have a table, the renderAboveTablePanel cannot be used.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let aboveTablePanelWrapper = aboveTablePanel.querySelector(".panels");
|
||||
|
||||
// First render: create wrappers. Otherwise, reuse previous.
|
||||
if (!aboveTablePanelWrapper) {
|
||||
aboveTablePanelWrapper = document.createElement("div");
|
||||
aboveTablePanelWrapper.classList.add("tab-contents");
|
||||
const panelNav = document.createElement("div");
|
||||
panelNav.classList.add("tab-controls");
|
||||
|
||||
// Temporary: css for minimal amount of breathing room.
|
||||
panelNav.style.display = "flex";
|
||||
panelNav.style.gap = "8px";
|
||||
panelNav.style.marginTop = "4px";
|
||||
panelNav.style.marginBottom = "20px";
|
||||
|
||||
aboveTablePanel.appendChild(panelNav);
|
||||
aboveTablePanel.appendChild(aboveTablePanelWrapper);
|
||||
}
|
||||
|
||||
datasetteManager.plugins.forEach((plugin, pluginName) => {
|
||||
const { makeAboveTablePanelConfigs } = plugin;
|
||||
|
||||
if (makeAboveTablePanelConfigs) {
|
||||
const controls = aboveTablePanel.querySelector(".tab-controls");
|
||||
const contents = aboveTablePanel.querySelector(".tab-contents");
|
||||
|
||||
// Each plugin can make multiple panels
|
||||
const configs = makeAboveTablePanelConfigs();
|
||||
|
||||
configs.forEach((config, i) => {
|
||||
const nodeContentId = `${pluginName}_${config.id}_panel-content`;
|
||||
|
||||
// quit if we've already registered this plugin
|
||||
// TODO: look into whether plugins should be allowed to ask
|
||||
// parent to re-render, or if they should manage that internally.
|
||||
if (document.getElementById(nodeContentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add tab control button
|
||||
const pluginControl = document.createElement("button");
|
||||
pluginControl.textContent = config.label;
|
||||
pluginControl.onclick = () => {
|
||||
contents.childNodes.forEach((node) => {
|
||||
if (node.id === nodeContentId) {
|
||||
node.style.display = "block";
|
||||
} else {
|
||||
node.style.display = "none";
|
||||
}
|
||||
});
|
||||
};
|
||||
controls.appendChild(pluginControl);
|
||||
|
||||
// Add plugin content area
|
||||
const pluginNode = document.createElement("div");
|
||||
pluginNode.id = nodeContentId;
|
||||
config.render(pluginNode);
|
||||
pluginNode.style.display = "none"; // Default to hidden unless you're ifrst
|
||||
|
||||
contents.appendChild(pluginNode);
|
||||
});
|
||||
|
||||
// Let first node be selected by default
|
||||
if (contents.childNodes.length) {
|
||||
contents.childNodes[0].style.display = "block";
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Selectors for document (DOM) elements. Store identifier instead of immediate references in case they haven't loaded when Manager starts. */
|
||||
selectors: DOM_SELECTORS,
|
||||
|
||||
// Future API ideas
|
||||
// Fetch page's data in array, and cache so plugins could reuse it
|
||||
// Provide knowledge of what datasette JS or server-side via traditional console autocomplete
|
||||
// State helpers: URL params https://github.com/simonw/datasette/issues/1144 and localstorage
|
||||
// UI Hooks: command + k, tab manager hook
|
||||
// Should we notify plugins that have dependencies
|
||||
// when all dependencies were fulfilled? (leaflet, codemirror, etc)
|
||||
// https://github.com/simonw/datasette-leaflet -> this way
|
||||
// multiple plugins can all request the same copy of leaflet.
|
||||
};
|
||||
|
||||
const initializeDatasette = () => {
|
||||
// Hide the global behind __ prefix. Ideally they should be listening for the
|
||||
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
|
||||
|
||||
window.__DATASETTE__ = datasetteManager;
|
||||
|
||||
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
||||
detail: datasetteManager,
|
||||
});
|
||||
|
||||
document.dispatchEvent(initDatasetteEvent);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function
|
||||
* Fires AFTER the document has been parsed
|
||||
*/
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeDatasette();
|
||||
});
|
||||
|
|
@ -48,7 +48,7 @@ MIT Licensed
|
|||
: color;
|
||||
}
|
||||
return '<span style="color: ' + color + '">' + match + "</span>";
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
|
||||
<span class="modal-meta"></span>
|
||||
</div>
|
||||
<div class="list-wrap mobile-column-list"></div>
|
||||
<div class="modal-footer">
|
||||
<span class="footer-info">Tap a column to reveal actions.</span>
|
||||
<button type="button" class="btn btn-ghost mobile-column-actions-done">Done</button>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
});
|
||||
|
|
@ -1,910 +0,0 @@
|
|||
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();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: var(--modal-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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
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;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.result-item.selected {
|
||||
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;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-text kbd {
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
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 {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
||||
<div class="search-container">
|
||||
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
||||
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
||||
<div id="${this.statusId}" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="${this.inputId}"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Jump to..."
|
||||
aria-label="Jump to"
|
||||
aria-describedby="${this.instructionsId}"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="${this.listboxId}"
|
||||
aria-expanded="false"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button type="button" class="close-search" aria-label="Close jump menu">×</button>
|
||||
</div>
|
||||
<div class="results-container"></div>
|
||||
<div class="hint-text">
|
||||
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
||||
<span><kbd>Enter</kbd> Select</span>
|
||||
<span><kbd>Esc</kbd> Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
// Global keyboard listener for "/"
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "/" && !this.isInputFocused() && !dialog.open) {
|
||||
e.preventDefault();
|
||||
this.openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.moveSelection(1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.moveSelection(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.selectCurrentItem();
|
||||
} else if (e.key === "Escape") {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
this.selectItem(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", (e) => {
|
||||
e.preventDefault();
|
||||
this.closeMenu();
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", () => {
|
||||
this.onMenuClosed();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
isInputFocused() {
|
||||
const activeElement = document.activeElement;
|
||||
return (
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.isContentEditable)
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
this.allItems = JSON.parse(itemsAttr);
|
||||
this.matches = this.allItems;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse items attribute:", e);
|
||||
this.allItems = [];
|
||||
this.matches = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(query) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
if (query.trim()) {
|
||||
this.setStatus("Searching...");
|
||||
} else {
|
||||
this.setStatus("");
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
const url = this.getAttribute("url");
|
||||
|
||||
if (url) {
|
||||
// Fetch from API
|
||||
this.fetchResults(url, query);
|
||||
} else {
|
||||
// Filter local items
|
||||
this.filterLocalItems(query);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async fetchResults(url, query) {
|
||||
try {
|
||||
const searchUrl = `${url}?q=${encodeURIComponent(query)}`;
|
||||
const response = await fetch(searchUrl);
|
||||
const data = await response.json();
|
||||
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 || [];
|
||||
} 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 `<div class="jump-start-content" data-jump-section-index="${index}"${id}></div>`;
|
||||
})
|
||||
.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
|
||||
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
|
||||
: "";
|
||||
const type = match.type
|
||||
? `<div class="result-type">${this.escapeHtml(match.type)}</div>`
|
||||
: "";
|
||||
const description = match.description
|
||||
? `<div class="result-description">${this.escapeHtml(
|
||||
match.description,
|
||||
)}</div>`
|
||||
: "";
|
||||
return `
|
||||
<div
|
||||
id="${this.resultOptionId(index)}"
|
||||
class="result-item ${index === this.selectedIndex ? "selected" : ""}"
|
||||
data-index="${index}"
|
||||
role="option"
|
||||
aria-selected="${index === this.selectedIndex}"
|
||||
>
|
||||
<div>
|
||||
${type}
|
||||
<div class="result-name">${this.escapeHtml(displayName)}</div>
|
||||
${label}
|
||||
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
||||
${description}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results"></div>`;
|
||||
|
||||
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}<div class="no-results">${message}</div>`;
|
||||
}
|
||||
this.updateComboboxState();
|
||||
return;
|
||||
}
|
||||
|
||||
const recentHeading = recentItems.length
|
||||
? `<div class="results-heading" id="${this.recentHeadingId}">Recent</div>`
|
||||
: "";
|
||||
const recentGroup = recentItems.length
|
||||
? `<div role="group" aria-labelledby="${this.recentHeadingId}">${recentItems
|
||||
.map((match, index) => this.resultItemHtml(match, index))
|
||||
.join("")}</div>`
|
||||
: "";
|
||||
const recentActions = recentItems.length
|
||||
? `<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
|
||||
: "";
|
||||
const defaultHtml = defaultMatches
|
||||
.map((match, index) =>
|
||||
this.resultItemHtml(match, recentItems.length + index),
|
||||
)
|
||||
.join("");
|
||||
container.innerHTML =
|
||||
startBlock +
|
||||
recentHeading +
|
||||
`<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results">${recentGroup}${defaultHtml}</div>` +
|
||||
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}"]`,
|
||||
);
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const newIndex = this.selectedIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < matches.length) {
|
||||
this.selectedIndex = newIndex;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrentItem() {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
|
||||
this.selectItem(this.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(index) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const match = matches[index];
|
||||
if (match) {
|
||||
this.saveRecentItem(match);
|
||||
|
||||
// Dispatch custom event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
detail: match,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Navigate to URL
|
||||
window.location.href = match.url;
|
||||
|
||||
this.closeMenu({ restoreFocus: false });
|
||||
}
|
||||
}
|
||||
|
||||
openMenu(trigger) {
|
||||
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);
|
||||
input.value = "";
|
||||
input.focus();
|
||||
|
||||
// Reset state, then populate the default jump list.
|
||||
this.matches = [];
|
||||
this.selectedIndex = -1;
|
||||
this.renderResults();
|
||||
this.setStatus("");
|
||||
}
|
||||
|
||||
closeMenu(options = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("navigation-search", NavigationSearch);
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
var DROPDOWN_HTML = `<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul class="dropdown-actions"></ul>
|
||||
<ul>
|
||||
<li><a class="dropdown-sort-asc" href="#">Sort ascending</a></li>
|
||||
<li><a class="dropdown-sort-desc" href="#">Sort descending</a></li>
|
||||
<li><a class="dropdown-facet" href="#">Facet by this</a></li>
|
||||
<li><a class="dropdown-hide-column" href="#">Hide this column</a></li>
|
||||
<li><a class="dropdown-show-all-columns" href="#">Show all columns</a></li>
|
||||
<li><a class="dropdown-not-blank" href="#">Show not-blank rows</a></li>
|
||||
</ul>
|
||||
<p class="dropdown-column-type"></p>
|
||||
<p class="dropdown-column-description"></p>
|
||||
</div>`;
|
||||
|
|
@ -10,518 +17,64 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>`;
|
||||
|
||||
var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
|
||||
var setColumnTypeDialogState = null;
|
||||
|
||||
function getParams() {
|
||||
(function () {
|
||||
// Feature detection
|
||||
if (!window.URLSearchParams) {
|
||||
return;
|
||||
}
|
||||
function getParams() {
|
||||
return new URLSearchParams(location.search);
|
||||
}
|
||||
|
||||
function paramsToUrl(params) {
|
||||
}
|
||||
function paramsToUrl(params) {
|
||||
var s = params.toString();
|
||||
return s ? "?" + s : location.pathname;
|
||||
}
|
||||
|
||||
function sortDescUrl(column) {
|
||||
}
|
||||
function sortDescUrl(column) {
|
||||
var params = getParams();
|
||||
params.set("_sort_desc", column);
|
||||
params.delete("_sort");
|
||||
params.delete("_next");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function sortAscUrl(column) {
|
||||
}
|
||||
function sortAscUrl(column) {
|
||||
var params = getParams();
|
||||
params.set("_sort", column);
|
||||
params.delete("_sort_desc");
|
||||
params.delete("_next");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function facetUrl(column) {
|
||||
}
|
||||
function facetUrl(column) {
|
||||
var params = getParams();
|
||||
params.append("_facet", column);
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function hideColumnUrl(column) {
|
||||
}
|
||||
function hideColumnUrl(column) {
|
||||
var params = getParams();
|
||||
params.append("_nocol", column);
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function showAllColumnsUrl() {
|
||||
}
|
||||
function showAllColumnsUrl() {
|
||||
var params = getParams();
|
||||
params.delete("_nocol");
|
||||
params.delete("_col");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function notBlankUrl(column) {
|
||||
}
|
||||
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 = `
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="set-column-type-title">Set custom type</span>
|
||||
<span class="modal-meta"></span>
|
||||
</div>
|
||||
<p class="set-column-type-status"></p>
|
||||
<p class="set-column-type-error" hidden></p>
|
||||
<div class="set-column-type-options"></div>
|
||||
<div class="modal-footer">
|
||||
<span class="footer-info"></span>
|
||||
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
|
||||
</div>
|
||||
`;
|
||||
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 closeMenu() {
|
||||
menu.style.display = "none";
|
||||
menu.classList.remove("anim-scale-in");
|
||||
}
|
||||
|
||||
const tableWrapper = document.querySelector(manager.selectors.tableWrapper);
|
||||
// When page loads, add scroll listener on .table-wrapper
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
var tableWrapper = document.querySelector(".table-wrapper");
|
||||
if (tableWrapper) {
|
||||
tableWrapper.addEventListener("scroll", closeMenu);
|
||||
}
|
||||
});
|
||||
document.body.addEventListener("click", (ev) => {
|
||||
/* was this click outside the menu? */
|
||||
var target = ev.target;
|
||||
|
|
@ -532,11 +85,9 @@ const initDatasetteTable = function (manager) {
|
|||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
function onTableHeaderClick(ev) {
|
||||
function iconClicked(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
menu.innerHTML = DROPDOWN_HTML;
|
||||
var th = ev.target;
|
||||
while (th.nodeName != "TH") {
|
||||
th = th.parentNode;
|
||||
|
|
@ -544,41 +95,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,28 +185,7 @@ const initDatasetteTable = function (manager) {
|
|||
menu.style.left = menuLeft + "px";
|
||||
menu.style.display = "block";
|
||||
menu.classList.add("anim-scale-in");
|
||||
|
||||
// Measure width of menu and adjust position if too far right
|
||||
const menuWidth = menu.offsetWidth;
|
||||
const windowWidth = window.innerWidth;
|
||||
if (menuLeft + menuWidth > windowWidth) {
|
||||
menu.style.left = windowWidth - menuWidth - 20 + "px";
|
||||
}
|
||||
// Align menu .hook arrow with the column cog icon
|
||||
const hook = menu.querySelector(".hook");
|
||||
const icon = th.querySelector(".dropdown-menu-icon");
|
||||
const iconRect = icon.getBoundingClientRect();
|
||||
const hookLeft = iconRect.left - menuLeft + 1 + "px";
|
||||
hook.style.left = hookLeft;
|
||||
// Move the whole menu right if the hook is too far right
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
if (iconRect.right > menuRect.right) {
|
||||
menu.style.left = iconRect.right - menuWidth + "px";
|
||||
// And move hook tip as well
|
||||
hook.style.left = menuWidth - 13 + "px";
|
||||
}
|
||||
}
|
||||
|
||||
var svg = document.createElement("div");
|
||||
svg.innerHTML = DROPDOWN_ICON_SVG;
|
||||
svg = svg.querySelector("*");
|
||||
|
|
@ -621,25 +197,23 @@ const initDatasetteTable = function (manager) {
|
|||
menu.style.display = "none";
|
||||
document.body.appendChild(menu);
|
||||
|
||||
var ths = Array.from(
|
||||
document.querySelectorAll(manager.selectors.tableHeaders),
|
||||
);
|
||||
var ths = Array.from(document.querySelectorAll(".rows-and-columns th"));
|
||||
ths.forEach((th) => {
|
||||
if (!th.querySelector("a")) {
|
||||
return;
|
||||
}
|
||||
var icon = svg.cloneNode(true);
|
||||
icon.addEventListener("click", onTableHeaderClick);
|
||||
icon.addEventListener("click", iconClicked);
|
||||
th.appendChild(icon);
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
/* Add x buttons to the filter rows */
|
||||
function addButtonsToFilterRows(manager) {
|
||||
(function () {
|
||||
var x = "✖";
|
||||
var rows = Array.from(
|
||||
document.querySelectorAll(manager.selectors.filterRow),
|
||||
).filter((el) => el.querySelector(".filter-op"));
|
||||
var rows = Array.from(document.querySelectorAll(".filter-row")).filter((el) =>
|
||||
el.querySelector(".filter-op")
|
||||
);
|
||||
rows.forEach((row) => {
|
||||
var a = document.createElement("a");
|
||||
a.setAttribute("href", "#");
|
||||
|
|
@ -660,18 +234,18 @@ function addButtonsToFilterRows(manager) {
|
|||
a.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
/* Set up datalist autocomplete for filter values */
|
||||
function initAutocompleteForFilterValues(manager) {
|
||||
(function () {
|
||||
function createDataLists() {
|
||||
var facetResults = document.querySelectorAll(
|
||||
manager.selectors.facetResults,
|
||||
".facet-results [data-column]"
|
||||
);
|
||||
Array.from(facetResults).forEach(function (facetResult) {
|
||||
// Use link text from all links in the facet result
|
||||
var links = Array.from(
|
||||
facetResult.querySelectorAll("li:not(.facet-truncated) a"),
|
||||
facetResult.querySelectorAll("li:not(.facet-truncated) a")
|
||||
);
|
||||
// Create a datalist element
|
||||
var datalist = document.createElement("datalist");
|
||||
|
|
@ -692,66 +266,9 @@ function initAutocompleteForFilterValues(manager) {
|
|||
document.body.addEventListener("change", function (event) {
|
||||
if (event.target.name === "_filter_column") {
|
||||
event.target
|
||||
.closest(manager.selectors.filterRow)
|
||||
.closest(".filter-row")
|
||||
.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);
|
||||
|
||||
// Other UI functions with interactive JS needs
|
||||
addButtonsToFilterRows(manager);
|
||||
initAutocompleteForFilterValues(manager);
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,623 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .resources import TableResource
|
||||
from .utils import named_parameters, sqlite3, tilde_encode, urlsafe_components
|
||||
from .utils.asgi import Forbidden
|
||||
|
||||
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,
|
||||
"params": list(query.parameters),
|
||||
"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,
|
||||
"has_more": page.has_more,
|
||||
"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_private=bool(query_config.get("is_private")),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_query_write_permissions(
|
||||
datasette: Any,
|
||||
database: str,
|
||||
sql: str,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
analysis: Any = None,
|
||||
) -> Any:
|
||||
write_actions = {
|
||||
"insert": "insert-row",
|
||||
"update": "update-row",
|
||||
"delete": "delete-row",
|
||||
}
|
||||
db = datasette.get_database(database)
|
||||
if analysis is None:
|
||||
if params is None:
|
||||
params = {name: "" for name in named_parameters(sql)}
|
||||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
except sqlite3.DatabaseError as ex:
|
||||
raise Forbidden(f"Could not analyze query: {ex}") from ex
|
||||
|
||||
for access in analysis.table_accesses:
|
||||
action = write_actions.get(access.operation)
|
||||
if action is None:
|
||||
continue
|
||||
if access.database != database:
|
||||
raise Forbidden("Writable queries may not write to attached databases")
|
||||
if not await datasette.allowed(
|
||||
action=action,
|
||||
resource=TableResource(database=access.database, table=access.table),
|
||||
actor=actor,
|
||||
):
|
||||
raise Forbidden(
|
||||
f"Permission denied: need {action} on {access.database}/{access.table}"
|
||||
)
|
||||
return analysis
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{% if action_links %}
|
||||
<div class="page-action-menu">
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary aria-haspopup="menu" aria-expanded="false">
|
||||
<div class="icon-text">
|
||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">{{ action_title }}</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>{{ action_title }}</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul role="menu">
|
||||
{% for link in action_links %}
|
||||
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<p class="dropdown-description">{{ link.description }}</p>
|
||||
{% endif %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
<script>
|
||||
// Common utility functions for debug pages
|
||||
|
||||
// Populate form from URL parameters on page load
|
||||
function populateFormFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
const actionField = document.getElementById('action');
|
||||
if (actionField) {
|
||||
actionField.value = action;
|
||||
}
|
||||
}
|
||||
|
||||
const parent = params.get('parent');
|
||||
if (parent) {
|
||||
const parentField = document.getElementById('parent');
|
||||
if (parentField) {
|
||||
parentField.value = parent;
|
||||
}
|
||||
}
|
||||
|
||||
const child = params.get('child');
|
||||
if (child) {
|
||||
const childField = document.getElementById('child');
|
||||
if (childField) {
|
||||
childField.value = child;
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = params.get('page_size');
|
||||
if (pageSize) {
|
||||
const pageSizeField = document.getElementById('page_size');
|
||||
if (pageSizeField) {
|
||||
pageSizeField.value = pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// HTML escape function
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{% if metadata.get("description_html") or metadata.get("description") %}
|
||||
{% if metadata.description_html or metadata.description %}
|
||||
<div class="metadata-description">
|
||||
{% if metadata.get("description_html") %}
|
||||
{% if metadata.description_html %}
|
||||
{{ metadata.description_html|safe }}
|
||||
{% else %}
|
||||
{{ metadata.description }}
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
<script>
|
||||
window.datasetteSqlAnalysis = (() => {
|
||||
if (
|
||||
window.datasetteSqlAnalysis &&
|
||||
window.datasetteSqlAnalysis.renderAnalysis
|
||||
) {
|
||||
return window.datasetteSqlAnalysis;
|
||||
}
|
||||
|
||||
function appendCodeCell(row, value, emptyText) {
|
||||
const cell = document.createElement("td");
|
||||
if (value) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = value;
|
||||
cell.appendChild(code);
|
||||
} else if (emptyText) {
|
||||
appendNotApplicable(cell);
|
||||
}
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function appendNotApplicable(cell) {
|
||||
const notApplicable = document.createElement("span");
|
||||
notApplicable.className = "execute-write-analysis-na";
|
||||
notApplicable.textContent = "n/a";
|
||||
cell.appendChild(notApplicable);
|
||||
}
|
||||
|
||||
function renderAnalysis(section, data) {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
section.replaceChildren();
|
||||
if (data.has_sql === false) {
|
||||
section.hidden = true;
|
||||
return;
|
||||
}
|
||||
section.hidden = false;
|
||||
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Query operations";
|
||||
section.appendChild(heading);
|
||||
|
||||
if (data.analysis_error) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "message-error";
|
||||
error.textContent = data.analysis_error;
|
||||
section.appendChild(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = data.analysis_rows || [];
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.textContent =
|
||||
"Analysis will show each affected table and required permission.";
|
||||
section.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "table-wrapper";
|
||||
const table = document.createElement("table");
|
||||
table.className = "execute-write-analysis";
|
||||
const thead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
[
|
||||
"Operation",
|
||||
"Database",
|
||||
"Table",
|
||||
"Required permission",
|
||||
"Allowed",
|
||||
].forEach((label) => {
|
||||
const th = document.createElement("th");
|
||||
th.scope = "col";
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
rows.forEach((analysisRow) => {
|
||||
const row = document.createElement("tr");
|
||||
appendCodeCell(row, analysisRow.operation);
|
||||
appendCodeCell(row, analysisRow.database);
|
||||
appendCodeCell(row, analysisRow.table);
|
||||
appendCodeCell(row, analysisRow.required_permission, "n/a");
|
||||
|
||||
const allowedCell = document.createElement("td");
|
||||
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
|
||||
const allowed = document.createElement("span");
|
||||
allowed.className = analysisRow.allowed
|
||||
? "execute-write-analysis-allowed"
|
||||
: "execute-write-analysis-denied";
|
||||
allowed.textContent = analysisRow.allowed ? "yes" : "no";
|
||||
allowedCell.appendChild(allowed);
|
||||
} else {
|
||||
appendNotApplicable(allowedCell);
|
||||
}
|
||||
row.appendChild(allowedCell);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
wrapper.appendChild(table);
|
||||
section.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return { renderAnalysis };
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<style>
|
||||
.execute-write-analysis {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0 1rem;
|
||||
min-width: 44rem;
|
||||
}
|
||||
.execute-write-analysis th,
|
||||
.execute-write-analysis td {
|
||||
border-bottom: 1px solid #d7dde5;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.execute-write-analysis th {
|
||||
background-color: #edf6fb;
|
||||
border-top: 1px solid #d7dde5;
|
||||
color: #39445a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis tbody tr:nth-child(even) {
|
||||
background-color: rgba(39, 104, 144, 0.05);
|
||||
}
|
||||
.execute-write-analysis code {
|
||||
background: transparent;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.execute-write-analysis-allowed {
|
||||
color: #267a3e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis-denied {
|
||||
color: #b00020;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis-na {
|
||||
color: #687386;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<style>
|
||||
.permission-form {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.form-section label {
|
||||
display: block;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-section input[type="text"],
|
||||
.form-section select {
|
||||
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 select:focus {
|
||||
outline: 2px solid #0066cc;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
.form-section small {
|
||||
display: block;
|
||||
margin-top: 0.3em;
|
||||
color: #666;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.submit-btn {
|
||||
padding: 0.6em 1.5em;
|
||||
font-size: 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit-btn:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.results-container {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.results-count {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.results-table th {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
.results-table td {
|
||||
padding: 0.75em;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.results-table tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.results-table tr.allow-row {
|
||||
background-color: #f1f8f4;
|
||||
}
|
||||
.results-table tr.allow-row:hover {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
.results-table tr.deny-row {
|
||||
background-color: #fef5f5;
|
||||
}
|
||||
.results-table tr.deny-row:hover {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
.resource-path {
|
||||
font-family: monospace;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 1.5em;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
}
|
||||
.pagination a {
|
||||
padding: 0.5em 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pagination a:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
.pagination span {
|
||||
color: #666;
|
||||
}
|
||||
.no-results {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.error-message {
|
||||
padding: 1em;
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
border-radius: 5px;
|
||||
color: #c62828;
|
||||
}
|
||||
.loading {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
{% if has_debug_permission %}
|
||||
{% set query_string = '?' + request.query_string if request.query_string else '' %}
|
||||
|
||||
<style>
|
||||
.permissions-debug-tabs {
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
margin-bottom: 2em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.permissions-debug-tabs a {
|
||||
padding: 0.75em 1.25em;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.permissions-debug-tabs a:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-bottom-color: #999;
|
||||
}
|
||||
.permissions-debug-tabs a.active {
|
||||
color: #0066cc;
|
||||
border-bottom-color: #0066cc;
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.permissions-debug-tabs {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.permissions-debug-tabs a {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.permissions-debug-tabs a.active {
|
||||
border-left: 3px solid #0066cc;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="permissions-debug-tabs">
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
|
||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
|
||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
|
||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
|
||||
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
||||
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
<script>
|
||||
window.datasetteSqlParameters = (() => {
|
||||
if (
|
||||
window.datasetteSqlParameters &&
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh
|
||||
) {
|
||||
return window.datasetteSqlParameters;
|
||||
}
|
||||
|
||||
function currentSql(form) {
|
||||
if (window.editor) {
|
||||
return window.editor.state.doc.toString();
|
||||
}
|
||||
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
|
||||
return sqlInput ? sqlInput.value : "";
|
||||
}
|
||||
|
||||
function controlState(control) {
|
||||
return {
|
||||
value: control.value,
|
||||
expanded: control.tagName.toLowerCase() === "textarea",
|
||||
};
|
||||
}
|
||||
|
||||
function syncParameterState(manager) {
|
||||
manager.parameterState = new Map();
|
||||
manager.section
|
||||
.querySelectorAll("[data-parameter-control]")
|
||||
.forEach((control) => {
|
||||
manager.parameterState.set(control.name, controlState(control));
|
||||
});
|
||||
}
|
||||
|
||||
function createControl(parameter, id, state) {
|
||||
const control = document.createElement(state.expanded ? "textarea" : "input");
|
||||
control.id = id;
|
||||
control.name = parameter;
|
||||
control.value = state.value;
|
||||
control.setAttribute("data-parameter-control", "");
|
||||
if (state.expanded) {
|
||||
control.rows = 5;
|
||||
} else {
|
||||
control.type = "text";
|
||||
}
|
||||
return control;
|
||||
}
|
||||
|
||||
function replaceParameterControl(
|
||||
manager,
|
||||
control,
|
||||
button,
|
||||
expand,
|
||||
value,
|
||||
selectionStart
|
||||
) {
|
||||
const replacement = createControl(control.name, control.id, {
|
||||
value: value === undefined ? control.value : value,
|
||||
expanded: expand,
|
||||
});
|
||||
button.textContent = expand ? "Collapse" : "Expand";
|
||||
button.setAttribute("aria-expanded", expand ? "true" : "false");
|
||||
control.replaceWith(replacement);
|
||||
replacement.focus();
|
||||
if (selectionStart !== undefined && replacement.setSelectionRange) {
|
||||
replacement.setSelectionRange(selectionStart, selectionStart);
|
||||
}
|
||||
manager.parameterState.set(replacement.name, controlState(replacement));
|
||||
}
|
||||
|
||||
function renderParameters(manager, parameters) {
|
||||
syncParameterState(manager);
|
||||
const previousState = manager.parameterState;
|
||||
const nextState = new Map();
|
||||
manager.section.replaceChildren();
|
||||
if (!parameters.length) {
|
||||
manager.parameterState = nextState;
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Parameters";
|
||||
manager.section.appendChild(heading);
|
||||
|
||||
parameters.forEach((parameter, index) => {
|
||||
const id = `qp${index + 1}`;
|
||||
const state = previousState.get(parameter) || {
|
||||
value: "",
|
||||
expanded: false,
|
||||
};
|
||||
if (!manager.allowExpand) {
|
||||
state.expanded = false;
|
||||
}
|
||||
nextState.set(parameter, state);
|
||||
|
||||
const row = document.createElement("p");
|
||||
row.className = "sql-parameter-row";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = id;
|
||||
label.textContent = parameter;
|
||||
|
||||
const control = createControl(parameter, id, state);
|
||||
|
||||
row.append(label, control);
|
||||
if (manager.allowExpand) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "sql-parameter-toggle";
|
||||
button.setAttribute("data-parameter-toggle", "");
|
||||
button.setAttribute("aria-controls", id);
|
||||
button.setAttribute("aria-expanded", state.expanded ? "true" : "false");
|
||||
button.textContent = state.expanded ? "Collapse" : "Expand";
|
||||
row.append(" ", button);
|
||||
}
|
||||
manager.section.appendChild(row);
|
||||
});
|
||||
|
||||
manager.parameterState = nextState;
|
||||
}
|
||||
|
||||
function bindParameterControls(manager) {
|
||||
manager.form.addEventListener("input", (event) => {
|
||||
const control = event.target;
|
||||
if (!control.matches || !control.matches("[data-parameter-control]")) {
|
||||
return;
|
||||
}
|
||||
manager.parameterState.set(control.name, controlState(control));
|
||||
});
|
||||
|
||||
if (!manager.allowExpand) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.form.addEventListener("click", (event) => {
|
||||
const button = event.target.closest
|
||||
? event.target.closest("[data-parameter-toggle]")
|
||||
: null;
|
||||
if (!button || !manager.form.contains(button)) {
|
||||
return;
|
||||
}
|
||||
const control = document.getElementById(button.getAttribute("aria-controls"));
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
const expanded = control.tagName.toLowerCase() === "textarea";
|
||||
replaceParameterControl(manager, control, button, !expanded);
|
||||
});
|
||||
|
||||
manager.form.addEventListener("paste", (event) => {
|
||||
const control = event.target;
|
||||
if (
|
||||
!(control instanceof HTMLInputElement) ||
|
||||
!control.matches("[data-parameter-control]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
|
||||
if (!/[\r\n]/.test(pasted)) {
|
||||
return;
|
||||
}
|
||||
const button = document.querySelector(
|
||||
`[data-parameter-toggle][aria-controls="${control.id}"]`
|
||||
);
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const selectionStart = control.selectionStart ?? control.value.length;
|
||||
const selectionEnd = control.selectionEnd ?? selectionStart;
|
||||
const value =
|
||||
control.value.slice(0, selectionStart) +
|
||||
pasted +
|
||||
control.value.slice(selectionEnd);
|
||||
replaceParameterControl(
|
||||
manager,
|
||||
control,
|
||||
button,
|
||||
true,
|
||||
value,
|
||||
selectionStart + pasted.length
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function bindEditorChanges(form, callback) {
|
||||
const editorElement = form.querySelector(".cm-content");
|
||||
if (editorElement) {
|
||||
editorElement.addEventListener("input", callback);
|
||||
}
|
||||
if (!window.editor) {
|
||||
const sqlInput = form.querySelector("textarea#sql-editor");
|
||||
if (sqlInput) {
|
||||
sqlInput.addEventListener("input", callback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!window.editor.datasetteSqlParameterCallbacks) {
|
||||
const editor = window.editor;
|
||||
const originalDispatch = editor.dispatch.bind(editor);
|
||||
editor.datasetteSqlParameterCallbacks = [];
|
||||
editor.dispatch = (...transactions) => {
|
||||
const before = editor.state.doc.toString();
|
||||
originalDispatch(...transactions);
|
||||
if (editor.state.doc.toString() !== before) {
|
||||
editor.datasetteSqlParameterCallbacks.forEach((listener) => listener());
|
||||
}
|
||||
};
|
||||
}
|
||||
window.editor.datasetteSqlParameterCallbacks.push(callback);
|
||||
}
|
||||
|
||||
function setupSqlParameterRefresh(options) {
|
||||
const form =
|
||||
options.form || document.querySelector("form.sql.core[data-parameters-url]");
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
const shouldRenderParameters = options.renderParameters !== false;
|
||||
const section =
|
||||
options.section || form.querySelector("[data-sql-parameters-section]");
|
||||
if (shouldRenderParameters && !section) {
|
||||
return null;
|
||||
}
|
||||
const manager = {
|
||||
form,
|
||||
section,
|
||||
allowExpand:
|
||||
options.allowExpand === undefined
|
||||
? section
|
||||
? section.dataset.allowExpand === "1"
|
||||
: false
|
||||
: options.allowExpand,
|
||||
parameterState: new Map(),
|
||||
};
|
||||
if (section) {
|
||||
bindParameterControls(manager);
|
||||
syncParameterState(manager);
|
||||
}
|
||||
|
||||
const url = options.url || form.dataset.parametersUrl;
|
||||
let refreshTimer = null;
|
||||
let refreshSequence = 0;
|
||||
|
||||
async function refreshParameters() {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const sequence = ++refreshSequence;
|
||||
try {
|
||||
const requestUrl = new URL(url, window.location.href);
|
||||
requestUrl.searchParams.set("sql", currentSql(form));
|
||||
const response = await fetch(requestUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (sequence !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error((data.errors || [response.statusText]).join("; "));
|
||||
}
|
||||
if (shouldRenderParameters) {
|
||||
renderParameters(manager, data.parameters || []);
|
||||
}
|
||||
if (options.onData) {
|
||||
options.onData(data, manager);
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
if (options.onError) {
|
||||
options.onError(error, manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(refreshParameters, options.debounceMs || 350);
|
||||
}
|
||||
|
||||
bindEditorChanges(form, scheduleRefresh);
|
||||
return {
|
||||
currentSql: () => currentSql(form),
|
||||
refreshParameters,
|
||||
renderParameters: (parameters) => renderParameters(manager, parameters),
|
||||
};
|
||||
}
|
||||
|
||||
return { setupSqlParameterRefresh };
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<style>
|
||||
form.sql .sql-editor {
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-editor textarea#sql-editor {
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .sql-parameters-section {
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-parameter-row {
|
||||
align-items: start;
|
||||
column-gap: 0.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 11rem) minmax(16rem, 1fr) auto;
|
||||
margin: 0 0 0.65rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-parameter-row label {
|
||||
overflow-wrap: anywhere;
|
||||
padding-top: 0.55rem;
|
||||
width: auto;
|
||||
}
|
||||
form.sql .sql-parameter-row input[data-parameter-control],
|
||||
form.sql .sql-parameter-row textarea[data-parameter-control] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .sql-parameter-row textarea[data-parameter-control] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
min-height: 7rem;
|
||||
padding: 9px 4px;
|
||||
}
|
||||
form.sql.core button.sql-parameter-toggle[type=button] {
|
||||
font-size: 0.72rem;
|
||||
height: 1.8rem;
|
||||
line-height: 1;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0.25rem 0.45rem;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
form.sql .sql-parameter-row {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
form.sql .sql-parameter-row label {
|
||||
padding-top: 0;
|
||||
}
|
||||
form.sql.core button.sql-parameter-toggle[type=button] {
|
||||
justify-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameters_allow_expand|default(false) %} data-allow-expand="1"{% endif %}>
|
||||
{% if parameter_names %}
|
||||
<h2>Parameters</h2>
|
||||
{% for parameter in parameter_names %}
|
||||
{% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
|
||||
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control>{% if sql_parameters_allow_expand|default(false) %} <button type="button" class="sql-parameter-toggle" data-parameter-toggle aria-controls="{{ parameter_id }}" aria-expanded="false">Expand</button>{% endif %}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
<p class="suggested-facets">
|
||||
Suggested facets: {% for facet in suggested_facets %}<a href="{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}">{{ facet.name }}</a>{% if facet.get("type") %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
Suggested facets: {% for facet in suggested_facets %}<a href="{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}">{{ facet.name }}</a>{% if facet.type %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
|
||||
<div class="above-table-panel"> </div>
|
||||
{% if display_columns %}
|
||||
{% if display_rows %}
|
||||
<div class="table-wrapper">
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in display_columns %}
|
||||
<th {% if column.description %}data-column-description="{{ column.description }}" {% endif %}class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type.lower() }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}"{% if column.is_special_link_column %} data-is-link-column="1"{% endif %}>
|
||||
<th {% if column.description %}data-column-description="{{ column.description }}" {% endif %}class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type.lower() }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}">
|
||||
{% if not column.sortable %}
|
||||
{{ column.name }}
|
||||
{% else %}
|
||||
|
|
@ -31,7 +29,6 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not display_rows %}
|
||||
{% else %}
|
||||
<p class="zero-results">0 records</p>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,9 @@ p.message-warning {
|
|||
|
||||
<h1>Debug allow rules</h1>
|
||||
|
||||
{% set current_tab = "allow_debug" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
||||
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
||||
<form action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
||||
<div class="two-col">
|
||||
<p><label>Allow block</label></p>
|
||||
<textarea name="allow">{{ allow_input }}</textarea>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
<h1>API Explorer{% if private %} 🔒{% endif %}</h1>
|
||||
<h1>API Explorer</h1>
|
||||
|
||||
<p>Use this tool to try out the
|
||||
{% if datasette_version %}
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
</p>
|
||||
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
|
||||
<summary style="cursor: pointer;">GET</summary>
|
||||
<form class="core" method="get" id="api-explorer-get" style="margin-top: 0.7em">
|
||||
<form method="get" id="api-explorer-get" style="margin-top: 0.7em">
|
||||
<div>
|
||||
<label for="path">API path:</label>
|
||||
<input type="text" id="path" name="path" style="width: 60%">
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
</details>
|
||||
<details style="border: 2px solid #ccc; padding: 0.5em">
|
||||
<summary style="cursor: pointer">POST</summary>
|
||||
<form class="core" method="post" id="api-explorer-post" style="margin-top: 0.7em">
|
||||
<form method="post" id="api-explorer-post" style="margin-top: 0.7em">
|
||||
<div>
|
||||
<label for="path">API path:</label>
|
||||
<input type="text" id="path" name="path" style="width: 60%">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
{% import "_crumbs.html" as crumbs with context %}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
{% for url in extra_css_urls %}
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
{% endfor %}
|
||||
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
|
||||
{% for url in extra_js_urls %}
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
{% endfor %}
|
||||
{%- if alternate_url_json -%}
|
||||
<link rel="alternate" type="application/json+datasette" href="{{ alternate_url_json }}">
|
||||
|
|
@ -19,8 +17,8 @@
|
|||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<div class="not-footer">
|
||||
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
||||
{% set links = menu_links() %}
|
||||
<header><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
||||
{% set links = menu_links() %}{% if links or show_logout %}
|
||||
<details class="nav-menu details-menu">
|
||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -29,18 +27,20 @@
|
|||
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
|
||||
</svg></summary>
|
||||
<div class="nav-menu-inner">
|
||||
{% if links %}
|
||||
<ul>
|
||||
<li><button type="button" class="button-as-link" data-navigation-search-open aria-haspopup="dialog" aria-expanded="false" aria-keyshortcuts="/">Jump to... <kbd class="keyboard-shortcut" aria-hidden="true" title="Keyboard shortcut: press / to open Jump to">/</kbd></button></li>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if show_logout %}
|
||||
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</details>{% endif %}
|
||||
{% if actor %}
|
||||
<div class="actor">
|
||||
<strong>{{ display_actor(actor) }}</strong>
|
||||
|
|
@ -70,7 +70,5 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="/-/jump"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,20 +2,33 @@
|
|||
|
||||
{% block title %}Create an API token{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style type="text/css">
|
||||
#restrict-permissions label {
|
||||
display: inline;
|
||||
width: 90%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Create an API token</h1>
|
||||
|
||||
<p>This token will allow API access with the same abilities as your current user, <strong>{{ request.actor.id }}</strong></p>
|
||||
<p>This token will allow API access with the same abilities as your current user.</p>
|
||||
|
||||
{% if errors %}
|
||||
{% for error in errors %}
|
||||
<p class="message-error">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ urls.path('-/create-token') }}" method="post">
|
||||
<div>
|
||||
<div class="select-wrapper" style="width: unset">
|
||||
<select name="expire_type">
|
||||
<option value="">Token never expires</option>
|
||||
<option value="minutes">Expires after X minutes</option>
|
||||
<option value="hours">Expires after X hours</option>
|
||||
<option value="days">Expires after X days</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" name="expire_duration" style="width: 10%">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Create token">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if token %}
|
||||
<div>
|
||||
|
|
@ -27,63 +40,10 @@
|
|||
<!--- show token in a <details> -->
|
||||
<details style="margin-top: 1em">
|
||||
<summary>Token details</summary>
|
||||
<pre>{{ token_bits|tojson(4) }}</pre>
|
||||
<pre>{{ token_bits|tojson }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<h2>Create another token</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
{% for error in errors %}
|
||||
<p class="message-error">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form class="core" action="{{ urls.path('-/create-token') }}" method="post">
|
||||
<div>
|
||||
<div class="select-wrapper" style="width: unset">
|
||||
<select name="expire_type">
|
||||
<option value="">Token never expires</option>
|
||||
<option value="minutes">Expires after X minutes</option>
|
||||
<option value="hours">Expires after X hours</option>
|
||||
<option value="days">Expires after X days</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" name="expire_duration" style="width: 10%">
|
||||
<input type="submit" value="Create token">
|
||||
|
||||
<details style="margin-top: 1em" id="restrict-permissions">
|
||||
<summary style="cursor: pointer;">Restrict actions that can be performed using this token</summary>
|
||||
<h2>All databases and tables</h2>
|
||||
<ul>
|
||||
{% for permission in all_actions %}
|
||||
<li><label><input type="checkbox" name="all:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% for database in database_with_tables %}
|
||||
<h2>All tables in "{{ database.name }}"</h2>
|
||||
<ul>
|
||||
{% for permission in database_actions %}
|
||||
<li><label><input type="checkbox" name="database:{{ database.encoded }}:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<h2>Specific tables</h2>
|
||||
{% for database in database_with_tables %}
|
||||
{% for table in database.tables %}
|
||||
<h3>{{ database.name }}: {{ table.name }}</h3>
|
||||
<ul>
|
||||
{% for permission in child_actions %}
|
||||
<li><label><input type="checkbox" name="resource:{{ database.encoded }}:{{ table.encoded }}:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
var expireDuration = document.querySelector('input[name="expire_duration"]');
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}CSRF check failed{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Form origin check failed</h1>
|
||||
|
||||
<p>Your request's origin could not be validated. Please return to the form and submit it again.</p>
|
||||
|
||||
<details><summary>Technical details</summary>
|
||||
<p>Developers: consult Datasette's <a href="https://docs.datasette.io/en/latest/internals.html#csrf-protection">CSRF protection documentation</a>.</p>
|
||||
<p>Reason: {{ reason }}</p>
|
||||
</details>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -5,34 +5,41 @@
|
|||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="border-color: #{{ database_color }}">
|
||||
<div class="page-header" style="border-color: #{{ database_color(database) }}">
|
||||
<h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set links = database_actions() %}{% if links %}
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
</div>
|
||||
{% set action_links, action_title = database_actions(), "Database actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_database() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% if allow_execute_sql %}
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
||||
<form class="sql" action="{{ urls.database(database) }}" method="get">
|
||||
<h3>Custom SQL query</h3>
|
||||
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
{% set parameter_names = [] %}
|
||||
{% set parameter_values = {} %}
|
||||
{% set sql_parameters_allow_expand = false %}
|
||||
{% include "_sql_parameters.html" %}
|
||||
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
<p>
|
||||
<button id="sql-format" type="button" hidden>Format SQL</button>
|
||||
<input type="submit" value="Run SQL">
|
||||
|
|
@ -45,7 +52,7 @@
|
|||
<p>The following databases are attached to this connection, and can be used for cross-database joins:</p>
|
||||
<ul class="bullets">
|
||||
{% for db_name in attached_databases %}
|
||||
<li><strong>{{ db_name }}</strong> - <a href="{{ urls.database(db_name) }}/-/query?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
|
||||
<li><strong>{{ db_name }}</strong> - <a href="?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -58,13 +65,10 @@
|
|||
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if queries_more %}
|
||||
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if tables %}
|
||||
<h2 id="tables">Tables <a style="font-weight: normal; font-size: 0.75em; padding-left: 0.5em;" href="{{ urls.database(database) }}/-/schema">schema</a></h2>
|
||||
<h2 id="tables">Tables</h2>
|
||||
{% endif %}
|
||||
|
||||
{% for table in tables %}
|
||||
|
|
@ -72,7 +76,7 @@
|
|||
<div class="db-table">
|
||||
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
||||
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
||||
<p>{% 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 %}</p>
|
||||
<p>{% if table.count is none %}Many rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
@ -91,15 +95,9 @@
|
|||
{% endif %}
|
||||
|
||||
{% if allow_download %}
|
||||
<p class="download-sqlite">Download SQLite DB: <a href="{{ urls.database(database) }}.db" rel="nofollow">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
||||
<p class="download-sqlite">Download SQLite DB: <a href="{{ urls.database(database) }}.db">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Registered Actions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Registered actions</h1>
|
||||
|
||||
{% set current_tab = "actions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p style="margin-bottom: 2em;">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Abbr</th>
|
||||
<th>Description</th>
|
||||
<th>Resource</th>
|
||||
<th>Takes Parent</th>
|
||||
<th>Takes Child</th>
|
||||
<th>Also Requires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for action in data %}
|
||||
<tr>
|
||||
<td><strong>{{ action.name }}</strong></td>
|
||||
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
||||
<td>{{ action.description or "" }}</td>
|
||||
<td>{% if action.resource_class %}<code>{{ action.resource_class }}</code>{% endif %}</td>
|
||||
<td>{% if action.takes_parent %}✓{% endif %}</td>
|
||||
<td>{% if action.takes_child %}✓{% endif %}</td>
|
||||
<td>{% if action.also_requires %}<code>{{ action.also_requires }}</code>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Allowed Resources{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Allowed resources</h1>
|
||||
|
||||
{% set current_tab = "allowed" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to check which resources the current actor is allowed to access for a given permission action. It queries the <code>/-/allowed.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="allowed-form" method="get" action="{{ urls.path("-/allowed") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in supported_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Only certain actions are supported by this endpoint</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Filter by parent (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>Filter results to a specific parent resource</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Filter by child (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>Filter results to a specific child resource (requires parent to be set)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Allowed Resources</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Results</h2>
|
||||
<div class="results-count" id="results-count"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-content"></div>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
|
||||
<details style="margin-top: 2em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('allowed-form');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
const resultsCount = document.getElementById('results-count');
|
||||
const pagination = document.getElementById('pagination');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
})();
|
||||
|
||||
async function fetchResults(page = 1) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Loading...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResults(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({ error: error.message });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Allowed Resources';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Update count
|
||||
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total resources (page ${data.page})`;
|
||||
|
||||
// Display results table
|
||||
if (data.items.length === 0) {
|
||||
resultsContent.innerHTML = '<div class="no-results">No allowed resources found for this action.</div>';
|
||||
} else {
|
||||
let html = '<table class="results-table">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
if (hasDebugPermission) {
|
||||
html += '<th>Reason</th>';
|
||||
}
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
for (const item of data.items) {
|
||||
html += '<tr>';
|
||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
if (hasDebugPermission) {
|
||||
// Display reason as JSON array
|
||||
let reasonHtml = '—';
|
||||
if (item.reason && Array.isArray(item.reason)) {
|
||||
reasonHtml = `<code>${escapeHtml(JSON.stringify(item.reason))}</code>`;
|
||||
}
|
||||
html += `<td>${reasonHtml}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
pagination.innerHTML = '';
|
||||
if (data.previous_url || data.next_url) {
|
||||
if (data.previous_url) {
|
||||
const prevLink = document.createElement('a');
|
||||
prevLink.href = data.previous_url;
|
||||
prevLink.textContent = '← Previous';
|
||||
pagination.appendChild(prevLink);
|
||||
}
|
||||
|
||||
const pageInfo = document.createElement('span');
|
||||
pageInfo.textContent = `Page ${data.page}`;
|
||||
pagination.appendChild(pageInfo);
|
||||
|
||||
if (data.next_url) {
|
||||
const nextLink = document.createElement('a');
|
||||
nextLink.href = data.next_url;
|
||||
nextLink.textContent = 'Next →';
|
||||
pagination.appendChild(nextLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Update raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsCount.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
parentInput.addEventListener('input', () => {
|
||||
childInput.disabled = !parentInput.value;
|
||||
if (!parentInput.value) {
|
||||
childInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize disabled state
|
||||
childInput.disabled = !parentInput.value;
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Check{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
<style>
|
||||
#output {
|
||||
margin-top: 2em;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
#output.allowed {
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
#output.denied {
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
#output h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
#output .result-badge {
|
||||
display: inline-block;
|
||||
padding: 0.3em 0.8em;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
#output .allowed-badge {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
#output .denied-badge {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.details-section {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.details-section dt {
|
||||
font-weight: bold;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.details-section dd {
|
||||
margin-left: 1em;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission check</h1>
|
||||
|
||||
{% set current_tab = "check" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Parent resource (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>For database-level permissions, specify the database name</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Child resource (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="output" style="display: none;">
|
||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||
|
||||
<dl class="details-section">
|
||||
<dt>Action:</dt>
|
||||
<dd id="result-action"></dd>
|
||||
|
||||
<dt>Resource Path:</dt>
|
||||
<dd id="result-resource"></dd>
|
||||
|
||||
<dt>Actor ID:</dt>
|
||||
<dd id="result-actor"></dd>
|
||||
|
||||
<div id="additional-details"></div>
|
||||
</dl>
|
||||
|
||||
<details style="margin-top: 1em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('check-form');
|
||||
const output = document.getElementById('output');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
async function performCheck() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Checking...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResult(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Permission';
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
performCheck();
|
||||
}
|
||||
})();
|
||||
|
||||
function displayResult(data) {
|
||||
output.style.display = 'block';
|
||||
|
||||
// Set badge and styling
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
if (data.allowed) {
|
||||
output.className = 'allowed';
|
||||
resultBadge.className = 'result-badge allowed-badge';
|
||||
resultBadge.textContent = 'ALLOWED ✓';
|
||||
} else {
|
||||
output.className = 'denied';
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'DENIED ✗';
|
||||
}
|
||||
|
||||
// Basic details
|
||||
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||
|
||||
// Additional details
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '';
|
||||
|
||||
if (data.reason !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Reason:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.reason || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.source_plugin !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Source Plugin:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.source_plugin || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.used_default !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Used Default:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.depth !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Depth:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.depth;
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
// Raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
// Scroll to output
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
output.style.display = 'block';
|
||||
output.className = 'denied';
|
||||
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'ERROR';
|
||||
|
||||
document.getElementById('result-action').textContent = 'N/A';
|
||||
document.getElementById('result-resource').textContent = 'N/A';
|
||||
document.getElementById('result-actor').textContent = 'N/A';
|
||||
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
childInput.addEventListener('focus', () => {
|
||||
if (!parentInput.value) {
|
||||
alert('Please specify a parent resource first before adding a child resource.');
|
||||
parentInput.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
<style type="text/css">
|
||||
.check-result-true {
|
||||
color: green;
|
||||
}
|
||||
.check-result-false {
|
||||
color: red;
|
||||
}
|
||||
.check-result-no-opinion {
|
||||
color: #aaa;
|
||||
}
|
||||
.check h2 {
|
||||
font-size: 1em
|
||||
}
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission playground</h1>
|
||||
|
||||
{% set current_tab = "permissions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||
|
||||
<div class="permission-form">
|
||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||
<div class="two-col">
|
||||
<div class="form-section">
|
||||
<label>Actor</label>
|
||||
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col" style="vertical-align: top">
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn">Simulate permission check</button>
|
||||
</div>
|
||||
<pre style="margin-top: 1em" id="debugResult"></pre>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var rawPerms = {{ permissions|tojson }};
|
||||
var permissions = Object.fromEntries(rawPerms.map(p => [p.name, p]));
|
||||
var permissionSelect = document.getElementById('permission');
|
||||
var resource1 = document.getElementById('resource_1');
|
||||
var resource2 = document.getElementById('resource_2');
|
||||
var resource1Section = resource1.closest('.form-section');
|
||||
var resource2Section = resource2.closest('.form-section');
|
||||
function updateResourceVisibility() {
|
||||
var permission = permissionSelect.value;
|
||||
var {takes_parent, takes_child} = permissions[permission];
|
||||
resource1Section.style.display = takes_parent ? 'block' : 'none';
|
||||
resource2Section.style.display = takes_child ? 'block' : 'none';
|
||||
}
|
||||
permissionSelect.addEventListener('change', updateResourceVisibility);
|
||||
updateResourceVisibility();
|
||||
|
||||
// When #debug-post form is submitted, use fetch() to POST data
|
||||
var debugPost = document.getElementById('debug-post');
|
||||
var debugResult = document.getElementById('debugResult');
|
||||
debugPost.addEventListener('submit', function(ev) {
|
||||
ev.preventDefault();
|
||||
var formData = new FormData(debugPost);
|
||||
fetch(debugPost.action, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(formData),
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed with status ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
}).then(function(data) {
|
||||
debugResult.innerText = JSON.stringify(data, null, 4);
|
||||
}).catch(function(error) {
|
||||
debugResult.innerText = JSON.stringify({ error: error.message }, null, 4);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>Recent permissions checks</h1>
|
||||
|
||||
<p>
|
||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||
{% if filter != "exclude-yours" %}<a href="?filter=exclude-yours">Exclude yours</a>{% else %}<strong>Exclude yours</strong>{% endif %},
|
||||
{% if filter != "only-yours" %}<a href="?filter=only-yours">Only yours</a>{% else %}<strong>Only yours</strong>{% endif %}
|
||||
</p>
|
||||
|
||||
{% if permission_checks %}
|
||||
<table class="rows-and-columns permission-checks-table" id="permission-checks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Parent</th>
|
||||
<th>Child</th>
|
||||
<th>Actor</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for check in permission_checks %}
|
||||
<tr>
|
||||
<td><span style="font-size: 0.8em">{{ check.when.split('T', 1)[0] }}</span><br>{{ check.when.split('T', 1)[1].split('+', 1)[0].split('-', 1)[0].split('Z', 1)[0] }}</td>
|
||||
<td><code>{{ check.action }}</code></td>
|
||||
<td>{{ check.parent or '—' }}</td>
|
||||
<td>{{ check.child or '—' }}</td>
|
||||
<td>{% if check.actor %}<code>{{ check.actor|tojson }}</code>{% else %}<span class="check-actor-anon">anonymous</span>{% endif %}</td>
|
||||
<td>{% if check.result %}<span class="check-result check-result-true">Allowed</span>{% elif check.result is none %}<span class="check-result check-result-no-opinion">No opinion</span>{% else %}<span class="check-result check-result-false">Denied</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-results">No permission checks have been recorded yet.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission rules</h1>
|
||||
|
||||
{% set current_tab = "rules" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to view the permission rules that allow the current actor to access resources for a given permission action. It queries the <code>/-/rules.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="rules-form" method="get" action="{{ urls.path("-/rules") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">View Permission Rules</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Results</h2>
|
||||
<div class="results-count" id="results-count"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-content"></div>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
|
||||
<details style="margin-top: 2em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('rules-form');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
const resultsCount = document.getElementById('results-count');
|
||||
const pagination = document.getElementById('pagination');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
})();
|
||||
|
||||
async function fetchResults(page = 1) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Loading...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResults(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({ error: error.message });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'View Permission Rules';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Update count
|
||||
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total rules (page ${data.page})`;
|
||||
|
||||
// Display results table
|
||||
if (data.items.length === 0) {
|
||||
resultsContent.innerHTML = '<div class="no-results">No permission rules found for this action.</div>';
|
||||
} else {
|
||||
let html = '<table class="results-table">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Effect</th>';
|
||||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
html += '<th>Source Plugin</th>';
|
||||
html += '<th>Reason</th>';
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
for (const item of data.items) {
|
||||
const rowClass = item.allow ? 'allow-row' : 'deny-row';
|
||||
const effectBadge = item.allow
|
||||
? '<span style="background: #4caf50; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">ALLOW</span>'
|
||||
: '<span style="background: #f44336; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">DENY</span>';
|
||||
|
||||
html += `<tr class="${rowClass}">`;
|
||||
html += `<td>${effectBadge}</td>`;
|
||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
pagination.innerHTML = '';
|
||||
if (data.previous_url || data.next_url) {
|
||||
if (data.previous_url) {
|
||||
const prevLink = document.createElement('a');
|
||||
prevLink.href = data.previous_url;
|
||||
prevLink.textContent = '← Previous';
|
||||
pagination.appendChild(prevLink);
|
||||
}
|
||||
|
||||
const pageInfo = document.createElement('span');
|
||||
pageInfo.textContent = `Page ${data.page}`;
|
||||
pagination.appendChild(pageInfo);
|
||||
|
||||
if (data.next_url) {
|
||||
const nextLink = document.createElement('a');
|
||||
nextLink.href = data.next_url;
|
||||
nextLink.textContent = 'Next →';
|
||||
pagination.appendChild(nextLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Update raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsCount.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Write to this database{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
<style>
|
||||
.execute-write-template-menu {
|
||||
margin: 0.9rem 0 0.8rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.execute-write-template-menu summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.execute-write-template-controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.4rem 0 0.7rem;
|
||||
}
|
||||
.execute-write-template-menu .execute-write-template-controls label {
|
||||
margin-right: 0.25rem;
|
||||
width: auto;
|
||||
}
|
||||
.execute-write-template-controls select,
|
||||
.execute-write-template-controls button[type=button] {
|
||||
box-sizing: border-box;
|
||||
font-size: 0.78rem;
|
||||
height: 2rem;
|
||||
line-height: 1.1;
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
.execute-write-template-controls select {
|
||||
background-color: #fff;
|
||||
border: 1px solid #777;
|
||||
border-radius: 0.25rem;
|
||||
min-width: 13rem;
|
||||
}
|
||||
</style>
|
||||
{% 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 %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Write to this database</h1>
|
||||
|
||||
<p>Execute SQL to insert, update or delete rows in this database.</p>
|
||||
|
||||
{% if execution_message %}
|
||||
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}{% for link in execution_links %} <a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
|
||||
{% if write_template_tables %}
|
||||
<div class="execute-write-template-menu">
|
||||
<details>
|
||||
<summary>Start with a template</summary>
|
||||
<p class="execute-write-template-controls">
|
||||
<label for="execute-write-template-table">Table</label>
|
||||
<select id="execute-write-template-table">
|
||||
{% for table_name, columns in write_template_tables|dictsort %}
|
||||
<option value="{{ table_name }}">{{ table_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" data-sql-template="insert">Insert row</button>
|
||||
<button type="button" data-sql-template="update">Update rows</button>
|
||||
<button type="button" data-sql-template="delete">Delete rows</button>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
|
||||
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
|
||||
{% set sql_parameters_allow_expand = true %}
|
||||
{% include "_sql_parameters.html" %}
|
||||
|
||||
<div id="execute-write-analysis-section">
|
||||
<h2>Query operations</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">Required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.operation }}</code></td>
|
||||
<td><code>{{ row.database }}</code></td>
|
||||
<td><code>{{ row.table }}</code></td>
|
||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
|
||||
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<input type="submit" value="Execute" data-execute-write-submit{% if execute_disabled %} disabled{% endif %}>
|
||||
{% if save_query_base_url %}<a href="{{ save_query_url or save_query_base_url }}" class="save-query" data-save-query-link data-save-query-base-url="{{ save_query_base_url }}"{% if not save_query_url %} hidden{% endif %}>Save this query</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
||||
if (executeWriteSqlInput && !executeWriteSqlInput.value) {
|
||||
executeWriteSqlInput.value = "\n\n\n";
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
||||
const submitButton = form
|
||||
? form.querySelector("[data-execute-write-submit]")
|
||||
: null;
|
||||
const saveQueryLink = form
|
||||
? form.querySelector("[data-save-query-link]")
|
||||
: null;
|
||||
|
||||
function updateSaveQueryLink(data) {
|
||||
if (!saveQueryLink) {
|
||||
return;
|
||||
}
|
||||
const sql = window.editor
|
||||
? window.editor.state.doc.toString()
|
||||
: executeWriteSqlInput.value;
|
||||
if (!sql.trim() || !data.ok || data.execute_disabled) {
|
||||
saveQueryLink.hidden = true;
|
||||
return;
|
||||
}
|
||||
const url = new URL(saveQueryLink.dataset.saveQueryBaseUrl, window.location.href);
|
||||
url.searchParams.set("sql", sql);
|
||||
saveQueryLink.href = url.pathname + url.search + url.hash;
|
||||
saveQueryLink.hidden = false;
|
||||
}
|
||||
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
||||
form,
|
||||
url: form.dataset.analyzeUrl,
|
||||
allowExpand: true,
|
||||
onData(data) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = data.execute_disabled;
|
||||
}
|
||||
updateSaveQueryLink(data);
|
||||
},
|
||||
onError(error) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
||||
analysis_error: error.message,
|
||||
analysis_rows: [],
|
||||
});
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
}
|
||||
if (saveQueryLink) {
|
||||
saveQueryLink.hidden = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if write_template_tables %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const tableColumns = {{ write_template_tables|tojson(2) }};
|
||||
const tableSelect = document.querySelector("#execute-write-template-table");
|
||||
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
||||
|
||||
function quoteIdentifier(identifier) {
|
||||
return `"${identifier.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function parameterNames(columns) {
|
||||
const seen = new Set();
|
||||
const names = {};
|
||||
columns.forEach((column) => {
|
||||
let base = column
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
if (!base) {
|
||||
base = "value";
|
||||
}
|
||||
if (/^[0-9]/.test(base)) {
|
||||
base = `p_${base}`;
|
||||
}
|
||||
let name = base;
|
||||
let index = 2;
|
||||
while (seen.has(name)) {
|
||||
name = `${base}_${index}`;
|
||||
index += 1;
|
||||
}
|
||||
seen.add(name);
|
||||
names[column] = name;
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function preferredWhereColumn(table, columns) {
|
||||
const lowerTableId = `${table.toLowerCase()}_id`;
|
||||
return (
|
||||
columns.find((column) => column.toLowerCase() === "id") ||
|
||||
columns.find((column) => column.toLowerCase() === lowerTableId) ||
|
||||
columns[0]
|
||||
);
|
||||
}
|
||||
|
||||
function insertSql(table, columns) {
|
||||
const names = parameterNames(columns);
|
||||
return [
|
||||
`insert into ${quoteIdentifier(table)} (`,
|
||||
columns.map((column) => ` ${quoteIdentifier(column)}`).join(",\n"),
|
||||
")",
|
||||
"values (",
|
||||
columns.map((column) => ` :${names[column]}`).join(",\n"),
|
||||
")",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function updateSql(table, columns) {
|
||||
const names = parameterNames(columns);
|
||||
const whereColumn = preferredWhereColumn(table, columns);
|
||||
const setColumns = columns.filter((column) => column !== whereColumn);
|
||||
if (!setColumns.length) {
|
||||
return [
|
||||
`update ${quoteIdentifier(table)}`,
|
||||
`set ${quoteIdentifier(whereColumn)} = :new_${names[whereColumn]}`,
|
||||
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`update ${quoteIdentifier(table)}`,
|
||||
"set " +
|
||||
setColumns
|
||||
.map((column, index) => {
|
||||
const indent = index ? " " : "";
|
||||
return `${indent}${quoteIdentifier(column)} = :${names[column]}`;
|
||||
})
|
||||
.join(",\n"),
|
||||
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function deleteSql(table, columns) {
|
||||
const names = parameterNames(columns);
|
||||
const whereColumn = preferredWhereColumn(table, columns);
|
||||
return [
|
||||
`delete from ${quoteIdentifier(table)}`,
|
||||
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function templateSql(operation, table, columns) {
|
||||
if (operation === "insert") {
|
||||
return insertSql(table, columns);
|
||||
}
|
||||
if (operation === "update") {
|
||||
return updateSql(table, columns);
|
||||
}
|
||||
return deleteSql(table, columns);
|
||||
}
|
||||
|
||||
templateButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const table = tableSelect.value;
|
||||
const columns = tableColumns[table] || [];
|
||||
if (!columns.length) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(
|
||||
"sql",
|
||||
templateSql(button.dataset.sqlTemplate, table, columns)
|
||||
);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -2,26 +2,17 @@
|
|||
|
||||
{% block title %}{{ metadata.title or "Datasette" }}: {% for database in databases %}{{ database.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if noindex %}<meta name="robots" content="noindex">{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}index{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
{% set action_links, action_title = homepage_actions, "Homepage actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_homepage() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% for database in databases %}
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #{{ database.color }}"><a href="{{ urls.database(database.name) }}">{{ database.name }}</a>{% if database.private %} 🔒{% endif %}</h2>
|
||||
<p>
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.hidden_tables_count %}, {% endif -%}
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.tables_count and database.hidden_tables_count %}, {% endif -%}
|
||||
{% if database.hidden_tables_count -%}
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
|
||||
{% endif -%}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
|
||||
<p>You are logged in as <strong>{{ display_actor(actor) }}</strong></p>
|
||||
|
||||
<form class="core" action="{{ urls.logout() }}" method="post">
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<div>
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Log out">
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<p>Set a message:</p>
|
||||
|
||||
<form class="core" action="{{ urls.path('-/messages') }}" method="post">
|
||||
<form action="{{ urls.path('-/messages') }}" method="post">
|
||||
<div>
|
||||
<input type="text" name="message" style="width: 40%">
|
||||
<div class="select-wrapper">
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
<option>all</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Add message">
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<title>Datasette: Pattern Portfolio</title>
|
||||
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hd"><nav>
|
||||
<header><nav>
|
||||
<p class="crumbs">
|
||||
<a href="/">home</a>
|
||||
</p>
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
<li><a href="/-/plugins">Installed plugins</a></li>
|
||||
<li><a href="/-/versions">Version info</a></li>
|
||||
</ul>
|
||||
<form class="nav-menu-logout" action="/-/logout" method="post">
|
||||
<form action="/-/logout" method="post">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
|
||||
<h2 class="pattern-heading">Header for /database/table/row and Messages</h2>
|
||||
|
||||
<header class="hd">
|
||||
<header>
|
||||
<nav>
|
||||
<p class="crumbs">
|
||||
<a href="/">home</a> /
|
||||
|
|
@ -96,24 +96,18 @@
|
|||
<section class="content">
|
||||
<div class="page-header" style="border-color: #ff0000">
|
||||
<h1>fixtures</h1>
|
||||
</div>
|
||||
<div class="page-action-menu">
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary>
|
||||
<div class="icon-text">
|
||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Database actions</title>
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>Database actions</span>
|
||||
</div>
|
||||
</summary>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul>
|
||||
<li><a href="#">Action one</a></li>
|
||||
<li><a href="#">Action two</a></li>
|
||||
<li><a href="#">Database action</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -164,24 +158,18 @@
|
|||
<section class="content">
|
||||
<div class="page-header" style="border-color: #ff0000">
|
||||
<h1>roadside_attraction_characteristics</h1>
|
||||
</div>
|
||||
<div class="page-action-menu">
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary>
|
||||
<div class="icon-text">
|
||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Database actions</title>
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>Table actions</span>
|
||||
</div>
|
||||
</summary>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul>
|
||||
<li><a href="#">Action one</a></li>
|
||||
<li><a href="#">Action two</a></li>
|
||||
<li><a href="#">Table action</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
|||
139
datasette/templates/permissions_debug.html
Normal file
139
datasette/templates/permissions_debug.html
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style type="text/css">
|
||||
.check-result-true {
|
||||
color: green;
|
||||
}
|
||||
.check-result-false {
|
||||
color: red;
|
||||
}
|
||||
.check-result-no-opinion {
|
||||
color: #aaa;
|
||||
}
|
||||
.check h2 {
|
||||
font-size: 1em
|
||||
}
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Permission check testing tool</h1>
|
||||
|
||||
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||
|
||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post" style="margin-bottom: 1em">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<div class="two-col">
|
||||
<p><label>Actor</label></p>
|
||||
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
<div class="two-col" style="vertical-align: top">
|
||||
<p><label for="permission" style="display:block">Permission</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.0 }}">{{ permission.name }} (default {{ permission.default }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p><label for="resource_1">Database name</label><input type="text" id="resource_1" name="resource_1"></p>
|
||||
<p><label for="resource_2">Table or query name</label><input type="text" id="resource_2" name="resource_2"></p>
|
||||
</div>
|
||||
<div style="margin-top: 1em;">
|
||||
<input type="submit" value="Simulate permission check">
|
||||
</div>
|
||||
<pre style="margin-top: 1em" id="debugResult"></pre>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
var rawPerms = {{ permissions|tojson }};
|
||||
var permissions = Object.fromEntries(rawPerms.map(([label, abbr, needs_resource_1, needs_resource_2, def]) => [label, {needs_resource_1, needs_resource_2, def}]))
|
||||
var permissionSelect = document.getElementById('permission');
|
||||
var resource1 = document.getElementById('resource_1');
|
||||
var resource2 = document.getElementById('resource_2');
|
||||
function updateResourceVisibility() {
|
||||
var permission = permissionSelect.value;
|
||||
var {needs_resource_1, needs_resource_2} = permissions[permission];
|
||||
if (needs_resource_1) {
|
||||
resource1.closest('p').style.display = 'block';
|
||||
} else {
|
||||
resource1.closest('p').style.display = 'none';
|
||||
}
|
||||
if (needs_resource_2) {
|
||||
resource2.closest('p').style.display = 'block';
|
||||
} else {
|
||||
resource2.closest('p').style.display = 'none';
|
||||
}
|
||||
}
|
||||
permissionSelect.addEventListener('change', updateResourceVisibility);
|
||||
updateResourceVisibility();
|
||||
|
||||
// When #debug-post form is submitted, use fetch() to POST data
|
||||
var debugPost = document.getElementById('debug-post');
|
||||
var debugResult = document.getElementById('debugResult');
|
||||
debugPost.addEventListener('submit', function(ev) {
|
||||
ev.preventDefault();
|
||||
var formData = new FormData(debugPost);
|
||||
console.log(formData);
|
||||
fetch(debugPost.action, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(formData),
|
||||
}).then(function(response) {
|
||||
return response.json();
|
||||
}).then(function(data) {
|
||||
debugResult.innerText = JSON.stringify(data, null, 4);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>Recent permissions checks</h1>
|
||||
|
||||
{% for check in permission_checks %}
|
||||
<div class="check">
|
||||
<h2>
|
||||
<span class="check-action">{{ check.action }}</span>
|
||||
checked at
|
||||
<span class="check-when">{{ check.when }}</span>
|
||||
{% if check.result %}
|
||||
<span class="check-result check-result-true">✓</span>
|
||||
{% elif check.result is none %}
|
||||
<span class="check-result check-result-no-opinion">none</span>
|
||||
{% else %}
|
||||
<span class="check-result check-result-false">✗</span>
|
||||
{% endif %}
|
||||
{% if check.used_default %}
|
||||
<span class="check-used-default">(used default)</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p><strong>Actor:</strong> {{ check.actor|tojson }}</p>
|
||||
{% if check.resource %}
|
||||
<p><strong>Resource:</strong> {{ check.resource }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -14,10 +14,9 @@
|
|||
</style>
|
||||
{% endif %}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query db-{{ database|to_css_class }}{% if stored_query %} query-{{ stored_query|to_css_class }}{% endif %}{% endblock %}
|
||||
{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
|
|
@ -25,19 +24,15 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% if stored_query_write and db_is_immutable %}
|
||||
{% if canned_write and db_is_immutable %}
|
||||
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
||||
{% endif %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set action_links, action_title = query_actions(), "Query actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}{% if stored_query %}/{{ stored_query }}{% endif %}" method="{% if stored_query_write %}post{% else %}get{% endif %}" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
||||
<form class="sql" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_write %}post{% else %}get{% endif %}">
|
||||
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %}
|
||||
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
||||
{% endif %}</h3>
|
||||
|
|
@ -46,28 +41,30 @@
|
|||
{% endif %}
|
||||
{% if not hide_sql %}
|
||||
{% if editable and allow_execute_sql %}
|
||||
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
||||
>{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
||||
<p><textarea id="sql-editor" name="sql"{% if query and query.sql %} style="height: {{ query.sql.split("\n")|length + 2 }}em"{% endif %}
|
||||
>{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
||||
{% else %}
|
||||
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if not stored_query %}
|
||||
{% if not canned_query %}
|
||||
<input type="hidden" name="sql"
|
||||
value="{% if query and query.sql %}{{ query.sql }}{% elif tables %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
||||
value="{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}"
|
||||
>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set parameter_names = named_parameter_values.keys()|list %}
|
||||
{% set parameter_values = named_parameter_values %}
|
||||
{% set sql_parameters_allow_expand = false %}
|
||||
{% include "_sql_parameters.html" %}
|
||||
{% if named_parameter_values %}
|
||||
<h3>Query parameters</h3>
|
||||
{% for name, value in named_parameter_values.items() %}
|
||||
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<p>
|
||||
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
||||
<input type="submit" value="Run SQL"{% if stored_query_write and db_is_immutable %} disabled{% endif %}>
|
||||
{% if canned_write %}<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">{% endif %}
|
||||
<input type="submit" value="Run SQL"{% if canned_write and db_is_immutable %} disabled{% endif %}>
|
||||
{{ show_hide_hidden }}
|
||||
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save this query</a>{% endif %}
|
||||
{% if stored_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="stored-query-edit-sql">Edit SQL</a>{% endif %}
|
||||
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
|
|
@ -90,17 +87,11 @@
|
|||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
{% if not stored_query_write and not error %}
|
||||
{% if not canned_write and not error %}
|
||||
<p class="zero-results">0 results</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,302 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create query{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_execute_write_analysis_styles.html" %}
|
||||
<style>
|
||||
.query-create-page {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.query-create-form {
|
||||
--query-create-label-width: clamp(7rem, 18vw, 10rem);
|
||||
--query-create-column-gap: 0.8rem;
|
||||
--query-create-control-width: minmax(16rem, 1fr);
|
||||
}
|
||||
.query-create-fields {
|
||||
margin: 0 0 0.85rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.query-create-field {
|
||||
align-items: start;
|
||||
column-gap: var(--query-create-column-gap);
|
||||
display: grid;
|
||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
.query-create-field label {
|
||||
padding-top: 0.55rem;
|
||||
width: auto;
|
||||
}
|
||||
.query-create-field input[type=text],
|
||||
.query-create-field textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .query-create-field textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-url-control {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: max-content minmax(12rem, 1fr);
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-url-prefix {
|
||||
color: #4f5b6d;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.query-create-url-control input[type=text] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.query-create-field textarea {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
min-height: 5rem;
|
||||
padding: 9px 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
form.sql .query-create-sql {
|
||||
column-gap: var(--query-create-column-gap);
|
||||
display: grid;
|
||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
||||
margin: 0.9rem 0 0.75rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.query-create-sql .cm-editor,
|
||||
form.sql .query-create-sql textarea#sql-editor {
|
||||
grid-column: 2;
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-options {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem 1.4rem;
|
||||
margin: 0 0 0.9rem calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
||||
max-width: calc(52rem - var(--query-create-label-width) - var(--query-create-column-gap));
|
||||
}
|
||||
.query-create-options label {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
width: auto;
|
||||
}
|
||||
.query-create-options input[type=checkbox] {
|
||||
margin: 0;
|
||||
}
|
||||
.query-create-option-note,
|
||||
.query-create-analysis-note {
|
||||
color: #4f5b6d;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.query-create-option-note {
|
||||
margin: -0.45rem 0 0;
|
||||
}
|
||||
.query-create-analysis-note {
|
||||
margin: 0;
|
||||
}
|
||||
.query-create-action {
|
||||
margin: 0.35rem 0 1rem;
|
||||
}
|
||||
.query-create-analysis {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
.query-create-submit {
|
||||
margin-left: calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
||||
margin-bottom: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.query-create-form {
|
||||
--query-create-label-width: 1fr;
|
||||
--query-create-column-gap: 0;
|
||||
}
|
||||
.query-create-field {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
.query-create-field label {
|
||||
padding-top: 0;
|
||||
}
|
||||
form.sql .query-create-sql {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.query-create-sql .cm-editor,
|
||||
form.sql .query-create-sql textarea#sql-editor {
|
||||
grid-column: 1;
|
||||
}
|
||||
.query-create-options,
|
||||
.query-create-submit {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="query-create-page">
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
|
||||
|
||||
<form class="sql core query-create-form" action="{{ urls.database(database) }}/-/queries/store" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
|
||||
<div class="query-create-fields">
|
||||
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
|
||||
<p class="query-create-field"><label for="query-url-slug">URL</label> <span class="query-create-url-control"><span class="query-create-url-prefix">{{ urls.database(database) }}/</span><input id="query-url-slug" name="name" type="text" value="{{ name or "" }}"></span></p>
|
||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
|
||||
</div>
|
||||
|
||||
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
|
||||
<p class="query-create-options">
|
||||
<span class="query-create-analysis-note" data-query-create-analysis-note aria-live="polite">{% if analysis_error %}This query cannot be saved until the SQL is valid.{% elif not has_sql %}Enter SQL to analyze this query.{% elif analysis_is_write %}This query updates data in the database.{% else %}This is a read-only query.{% endif %}</span>
|
||||
<input type="hidden" name="is_private" value="0">
|
||||
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
|
||||
<span class="query-create-option-note">Queries marked private can only be seen by you, their creator.</span>
|
||||
</p>
|
||||
{% if sql and analysis_is_write %}
|
||||
<p class="query-create-action"><a href="{{ urls.database(database) }}/-/execute-write?{{ {'sql': sql}|urlencode|safe }}">Execute write SQL</a></p>
|
||||
{% endif %}
|
||||
|
||||
<p class="query-create-submit"><input type="submit" value="Save query" data-query-create-submit{% if save_disabled %} disabled{% endif %}></p>
|
||||
|
||||
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
|
||||
{% if has_sql %}
|
||||
<h2>Query operations</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">Required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.operation }}</code></td>
|
||||
<td><code>{{ row.database }}</code></td>
|
||||
<td><code>{{ row.table }}</code></td>
|
||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
|
||||
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const titleInput = document.querySelector("#query-title");
|
||||
const urlInput = document.querySelector("#query-url-slug");
|
||||
let urlEdited = Boolean(urlInput && urlInput.value);
|
||||
|
||||
function slugify(value) {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
if (titleInput && urlInput) {
|
||||
titleInput.addEventListener("input", () => {
|
||||
if (!urlEdited) {
|
||||
urlInput.value = slugify(titleInput.value);
|
||||
}
|
||||
});
|
||||
urlInput.addEventListener("input", () => {
|
||||
urlEdited = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#query-create-analysis-section");
|
||||
const submitButton = form
|
||||
? form.querySelector("[data-query-create-submit]")
|
||||
: null;
|
||||
const analysisNote = form
|
||||
? form.querySelector("[data-query-create-analysis-note]")
|
||||
: null;
|
||||
|
||||
function updateAnalysisNote(data) {
|
||||
if (!analysisNote) {
|
||||
return;
|
||||
}
|
||||
if (data.analysis_error) {
|
||||
analysisNote.textContent = "This query cannot be saved until the SQL is valid.";
|
||||
} else if (data.has_sql === false) {
|
||||
analysisNote.textContent = "Enter SQL to analyze this query.";
|
||||
} else if (data.analysis_is_write) {
|
||||
analysisNote.textContent = "This query updates data in the database.";
|
||||
} else {
|
||||
analysisNote.textContent = "This is a read-only query.";
|
||||
}
|
||||
}
|
||||
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
||||
form,
|
||||
url: form.dataset.analyzeUrl,
|
||||
renderParameters: false,
|
||||
onData(data) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = data.save_disabled;
|
||||
}
|
||||
updateAnalysisNote(data);
|
||||
},
|
||||
onError(error) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
||||
analysis_error: error.message,
|
||||
analysis_rows: [],
|
||||
});
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
}
|
||||
updateAnalysisNote({ analysis_error: error.message });
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
<style>
|
||||
.query-list-page {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.query-list-filters {
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
}
|
||||
.query-list-search {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.query-list-search label {
|
||||
width: auto;
|
||||
}
|
||||
.query-list-search input[type=search] {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 18rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
.query-list-search button[type=submit] {
|
||||
font-size: 0.78rem;
|
||||
height: 2rem;
|
||||
line-height: 1.1;
|
||||
padding: 0.35rem 0.65rem;
|
||||
}
|
||||
.query-list-facets {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.6rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.query-list-facet {
|
||||
margin: 0;
|
||||
}
|
||||
.query-list-facet h2 {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.query-list-facet ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.query-list-facet-link,
|
||||
.query-list-facet-link:link,
|
||||
.query-list-facet-link:visited,
|
||||
.query-list-facet-link:hover,
|
||||
.query-list-facet-link:focus,
|
||||
.query-list-facet-link:active {
|
||||
align-items: center;
|
||||
border: 1px solid #c8d1dc;
|
||||
border-radius: 0.25rem;
|
||||
color: #39445a;
|
||||
display: inline-flex;
|
||||
font-size: 0.82rem;
|
||||
gap: 0.4rem;
|
||||
line-height: 1.1;
|
||||
padding: 0.35rem 0.55rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.query-list-facet-link:hover {
|
||||
border-color: #7ca5c8;
|
||||
color: #1f5d85;
|
||||
}
|
||||
.query-list-facet-link-active {
|
||||
background-color: #edf6fb;
|
||||
border-color: #6d9fc0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-list-facet-disabled {
|
||||
color: #7b8794;
|
||||
cursor: default;
|
||||
}
|
||||
.query-list-facet-count {
|
||||
color: #4f5b6d;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.query-list-results {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0 1rem;
|
||||
min-width: 42rem;
|
||||
width: 100%;
|
||||
}
|
||||
.query-list-results th,
|
||||
.query-list-results td {
|
||||
border-bottom: 1px solid #d7dde5;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.query-list-results th {
|
||||
background-color: #edf6fb;
|
||||
border-top: 1px solid #d7dde5;
|
||||
color: #39445a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-list-results tbody tr:nth-child(even) {
|
||||
background-color: rgba(39, 104, 144, 0.05);
|
||||
}
|
||||
.query-list-results a.query-list-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.query-list-description {
|
||||
color: #4f5b6d;
|
||||
font-size: 0.78rem;
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
.query-list-owner {
|
||||
color: #39445a;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.query-list-flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.query-list-pill {
|
||||
background-color: #eef1f5;
|
||||
border: 1px solid #d7dde5;
|
||||
border-radius: 0.25rem;
|
||||
color: #39445a;
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0.25rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.query-list-pill-write {
|
||||
background-color: #fff4db;
|
||||
border-color: #e2b64e;
|
||||
}
|
||||
.query-list-pill-public {
|
||||
background-color: #e7f5ec;
|
||||
border-color: #9ecfab;
|
||||
color: #267a3e;
|
||||
}
|
||||
.query-list-pill-private {
|
||||
background-color: #f7edf0;
|
||||
border-color: #dbb8c1;
|
||||
}
|
||||
.query-list-pill-trusted {
|
||||
background-color: #e7f5ec;
|
||||
border-color: #9ecfab;
|
||||
color: #267a3e;
|
||||
}
|
||||
.query-list-empty {
|
||||
color: #6b7280;
|
||||
}
|
||||
.query-list-footnotes {
|
||||
border-top: 1px solid #d7dde5;
|
||||
color: #4f5b6d;
|
||||
font-size: 0.82rem;
|
||||
margin: 0.35rem 0 1rem;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
.query-list-footnotes p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.query-list-footnotes .query-list-pill {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.query-list-pagination a {
|
||||
border: 1px solid #007bff;
|
||||
border-radius: 0.25rem;
|
||||
display: inline-block;
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
.query-list-pagination-bottom {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.query-list-search input[type=search] {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="query-list-page">
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{% if database_color %}{{ database_color }}{% else %}666{% endif %}">Queries</h1>
|
||||
|
||||
<form class="query-list-filters core" action="{{ query_list_path }}" method="get">
|
||||
<p class="query-list-search">
|
||||
<label for="query-search">Search</label>
|
||||
<input id="query-search" type="search" name="q" value="{{ filters.q }}">
|
||||
{% if filters.is_write %}<input type="hidden" name="is_write" value="{{ filters.is_write }}">{% endif %}
|
||||
{% if filters.is_private %}<input type="hidden" name="is_private" value="{{ filters.is_private }}">{% endif %}
|
||||
{% if filters.source %}<input type="hidden" name="source" value="{{ filters.source }}">{% endif %}
|
||||
{% if filters.owner_id %}<input type="hidden" name="owner_id" value="{{ filters.owner_id }}">{% endif %}
|
||||
<button type="submit">Search</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<nav class="query-list-facets" aria-label="Query filters">
|
||||
{% for facet in facets %}
|
||||
<section class="query-list-facet">
|
||||
<h2>{{ facet.title }}</h2>
|
||||
<ul>
|
||||
{% for item in facet["items"] %}
|
||||
<li>{% if item.href %}<a class="query-list-facet-link{% if item.active %} query-list-facet-link-active{% endif %}" href="{{ item.href }}"{% if item.active %} aria-current="true"{% endif %}>{% else %}<span class="query-list-facet-link query-list-facet-disabled">{% endif %}<span>{{ item.label }}</span><span class="query-list-facet-count">{{ item.count }}</span>{% if item.href %}</a>{% else %}</span>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
{% if queries %}
|
||||
<div class="table-wrapper"><table class="query-list-results">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if show_database %}<th scope="col">Database</th>{% endif %}
|
||||
<th scope="col">Query</th>
|
||||
<th scope="col">Owner</th>
|
||||
<th scope="col">Flags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for query in queries %}
|
||||
<tr>
|
||||
{% if show_database %}
|
||||
<td><a class="query-list-database" href="{{ urls.database(query.database) }}">{{ query.database }}</a></td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<a class="query-list-title" href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
|
||||
{% if query.description %}<p class="query-list-description">{{ query.description }}</p>{% endif %}
|
||||
</td>
|
||||
<td class="query-list-owner">{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}<span class="query-list-empty">-</span>{% endif %}</td>
|
||||
<td>
|
||||
<span class="query-list-flags">
|
||||
{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}
|
||||
{% if query.is_private %}<span class="query-list-pill query-list-pill-private">Private</span>{% endif %}
|
||||
{% if query.is_trusted %}<span class="query-list-pill query-list-pill-trusted">Trusted</span>{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% if show_private_note or show_trusted_note %}
|
||||
<div class="query-list-footnotes">
|
||||
{% if show_private_note %}<p><span class="query-list-pill query-list-pill-private">Private</span>Only the owning actor can view this query.</p>{% endif %}
|
||||
{% if show_trusted_note %}<p><span class="query-list-pill query-list-pill-trusted">Trusted</span>Execution skips the usual SQL and write permission checks after view-query allows access.</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>No queries found.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if next_url %}
|
||||
<nav class="query-list-pagination query-list-pagination-bottom" aria-label="Query pagination"><a href="{{ next_url }}">Next page</a></nav>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -20,12 +20,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
{% set action_links, action_title = row_actions, "Row actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_row() }}
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if is_instance %}Schema for all databases{% elif table_name %}Schema for {{ schemas[0].database }}.{{ table_name }}{% else %}Schema for {{ schemas[0].database }}{% endif %}{% endblock %}
|
||||
|
||||
{% block body_class %}schema{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{% if is_instance %}
|
||||
{{ crumbs.nav(request=request) }}
|
||||
{% elif table_name %}
|
||||
{{ crumbs.nav(request=request, database=schemas[0].database, table=table_name) }}
|
||||
{% else %}
|
||||
{{ crumbs.nav(request=request, database=schemas[0].database) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{% if is_instance %}Schema for all databases{% elif table_name %}Schema for {{ table_name }}{% else %}Schema for {{ schemas[0].database }}{% endif %}</h1>
|
||||
</div>
|
||||
|
||||
{% for item in schemas %}
|
||||
{% if is_instance %}
|
||||
<h2>{{ item.database }}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if item.schema %}
|
||||
<pre style="background-color: #f5f5f5; padding: 1em; overflow-x: auto; border: 1px solid #ddd; border-radius: 4px;"><code>{{ item.schema }}</code></pre>
|
||||
{% else %}
|
||||
<p><em>No schema available for this database.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% if not loop.last %}
|
||||
<hr style="margin: 2em 0;">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not schemas %}
|
||||
<p><em>No databases with viewable schemas found.</em></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
{% block title %}{{ database }}: {{ table }}: {% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} row{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
|
@ -19,21 +17,37 @@
|
|||
{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database, table=table) }}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="border-color: #{{ database_color }}">
|
||||
<h1>{{ metadata.get("title") or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
<div class="page-header" style="border-color: #{{ database_color(database) }}">
|
||||
<h1>{{ metadata.title or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set links = table_actions() %}{% if links %}
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
</div>
|
||||
{% set action_links, action_title = actions(), "View actions" if is_view else "Table actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_table() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% if metadata.get("columns") %}
|
||||
{% if metadata.columns %}
|
||||
<dl class="column-descriptions">
|
||||
{% for column_name, column_description in metadata.columns.items() %}
|
||||
<dt>{{ column_name }}</dt><dd>{{ column_description }}</dd>
|
||||
|
|
@ -41,16 +55,13 @@
|
|||
</dl>
|
||||
{% endif %}
|
||||
|
||||
{% if count or human_description_en %}
|
||||
<h3>
|
||||
{% if count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows
|
||||
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
{% if filtered_table_rows_count or human_description_en %}
|
||||
<h3>{% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} row{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||
</h3>
|
||||
{% endif %}
|
||||
|
||||
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
<form class="filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
{% if supports_search %}
|
||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
||||
{% endif %}
|
||||
|
|
@ -83,7 +94,7 @@
|
|||
</div><div class="select-wrapper filter-op">
|
||||
<select name="_filter_op">
|
||||
{% for key, display, no_argument in filters.lookups() %}
|
||||
<option value="{{ key }}{% if no_argument %}__1{% endif %}">{{ display }}</option>
|
||||
<option value="{{ key }}{% if no_argument %}__1{% endif %}"{% if key == lookup %} selected{% endif %}>{{ display }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div><input type="text" name="_filter_value" class="filter-value">
|
||||
|
|
@ -138,26 +149,6 @@
|
|||
{% include "_facet_results.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% if all_columns %}
|
||||
<column-chooser></column-chooser>
|
||||
<button class="choose-columns-mobile small-screen-only" onclick="openColumnChooser()">Choose columns</button>
|
||||
<button type="button" class="column-actions-mobile small-screen-only">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>Column actions</span>
|
||||
</button>
|
||||
<script>
|
||||
window._columnChooserData = {{ {"allColumns": all_columns, "selectedColumns": display_columns|map(attribute='name')|list, "primaryKeys": primary_keys}|tojson }};
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if set_column_type_ui %}
|
||||
<script>
|
||||
window._setColumnTypeData = {{ set_column_type_ui|tojson }};
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% include custom_table_templates %}
|
||||
|
||||
{% if next_url %}
|
||||
|
|
@ -174,7 +165,7 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
|
|||
<a href="{{ append_querystring(renderers['json'], '_shape=object') }}">object</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<form class="core" action="{{ url_csv_path }}" method="get">
|
||||
<form action="{{ url_csv_path }}" method="get">
|
||||
<p>
|
||||
CSV options:
|
||||
<label><input type="checkbox" name="_dl"> download file</label>
|
||||
|
|
@ -197,41 +188,4 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
|
|||
<pre class="wrapped-sql">{{ view_definition }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% if allow_execute_sql and query.sql %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const countLink = document.querySelector('a.count-sql');
|
||||
if (countLink) {
|
||||
countLink.addEventListener('click', async function(ev) {
|
||||
ev.preventDefault();
|
||||
// Replace countLink with span with same style attribute
|
||||
const span = document.createElement('span');
|
||||
span.textContent = 'counting...';
|
||||
span.setAttribute('style', countLink.getAttribute('style'));
|
||||
countLink.replaceWith(span);
|
||||
countLink.setAttribute('disabled', 'disabled');
|
||||
let url = countLink.href.replace(/(\?|$)/, '.json$1');
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
console.log({response});
|
||||
const data = await response.json();
|
||||
console.log({data});
|
||||
if (!response.ok) {
|
||||
console.log('throw error');
|
||||
throw new Error(data.title || data.error);
|
||||
}
|
||||
const count = data['rows'][0]['count(*)'];
|
||||
const formattedCount = count.toLocaleString();
|
||||
span.closest('h3').textContent = formattedCount + ' rows';
|
||||
} catch (error) {
|
||||
console.log('Update', span, 'with error message', error);
|
||||
span.textContent = error.message;
|
||||
span.style.color = 'red';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
"""
|
||||
Token handler system for Datasette.
|
||||
|
||||
Provides a base class for token handlers and the default signed token handler.
|
||||
Plugins can implement register_token_handler to provide custom token backends
|
||||
(e.g. database-backed tokens that can be revoked and audited).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import itsdangerous
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TokenRestrictions:
|
||||
"""
|
||||
Restrictions to apply to a token, limiting which actions it can perform.
|
||||
|
||||
Use the builder methods to construct restrictions::
|
||||
|
||||
restrictions = (TokenRestrictions()
|
||||
.allow_all("view-instance")
|
||||
.allow_database("mydb", "create-table")
|
||||
.allow_resource("mydb", "mytable", "insert-row"))
|
||||
"""
|
||||
|
||||
all: list[str] = dataclasses.field(default_factory=list)
|
||||
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
||||
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
|
||||
|
||||
def allow_all(self, action: str) -> "TokenRestrictions":
|
||||
"""Allow an action across all databases and resources."""
|
||||
self.all.append(action)
|
||||
return self
|
||||
|
||||
def allow_database(self, database: str, action: str) -> "TokenRestrictions":
|
||||
"""Allow an action on a specific database."""
|
||||
self.database.setdefault(database, []).append(action)
|
||||
return self
|
||||
|
||||
def allow_resource(
|
||||
self, database: str, resource: str, action: str
|
||||
) -> "TokenRestrictions":
|
||||
"""Allow an action on a specific resource within a database."""
|
||||
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
||||
return self
|
||||
|
||||
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
|
||||
"""
|
||||
Return the abbreviated ``_r`` dictionary shape for this set of
|
||||
restrictions, using action abbreviations registered with ``datasette``.
|
||||
Returns ``None`` if no restrictions are set.
|
||||
"""
|
||||
if not (self.all or self.database or self.resource):
|
||||
return None
|
||||
|
||||
def abbreviate_action(action):
|
||||
action_obj = datasette.actions.get(action)
|
||||
if not action_obj:
|
||||
return action
|
||||
return action_obj.abbr or action
|
||||
|
||||
result: dict = {}
|
||||
if self.all:
|
||||
result["a"] = [abbreviate_action(a) for a in self.all]
|
||||
if self.database:
|
||||
result["d"] = {
|
||||
database: [abbreviate_action(a) for a in actions]
|
||||
for database, actions in self.database.items()
|
||||
}
|
||||
if self.resource:
|
||||
result["r"] = {}
|
||||
for database, resources in self.resource.items():
|
||||
for resource, actions in resources.items():
|
||||
result["r"].setdefault(database, {})[resource] = [
|
||||
abbreviate_action(a) for a in actions
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
class TokenHandler:
|
||||
"""
|
||||
Base class for token handlers.
|
||||
|
||||
Subclass this and implement create_token() and verify_token() to provide
|
||||
a custom token backend. Return an instance from the register_token_handler hook.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
datasette: "Datasette",
|
||||
actor_id: str,
|
||||
*,
|
||||
expires_after: Optional[int] = None,
|
||||
restrictions: Optional[TokenRestrictions] = None,
|
||||
) -> str:
|
||||
"""Create and return a token string for the given actor."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||
"""
|
||||
Verify a token and return an actor dict, or None if this handler
|
||||
does not recognize the token.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SignedTokenHandler(TokenHandler):
|
||||
"""
|
||||
Default token handler using itsdangerous signed tokens (dstok_ prefix).
|
||||
"""
|
||||
|
||||
name = "signed"
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
datasette: "Datasette",
|
||||
actor_id: str,
|
||||
*,
|
||||
expires_after: Optional[int] = None,
|
||||
restrictions: Optional[TokenRestrictions] = None,
|
||||
) -> str:
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
raise ValueError(
|
||||
"Signed tokens are not enabled for this Datasette instance"
|
||||
)
|
||||
|
||||
token = {"a": actor_id, "t": int(time.time())}
|
||||
|
||||
if expires_after:
|
||||
token["d"] = expires_after
|
||||
if restrictions is not None:
|
||||
abbreviated = restrictions.abbreviated(datasette)
|
||||
if abbreviated is not None:
|
||||
token["_r"] = abbreviated
|
||||
return "dstok_{}".format(datasette.sign(token, namespace="token"))
|
||||
|
||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
||||
prefix = "dstok_"
|
||||
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
return None
|
||||
|
||||
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
||||
|
||||
if not token.startswith(prefix):
|
||||
return None
|
||||
|
||||
raw = token[len(prefix) :]
|
||||
try:
|
||||
decoded = datasette.unsign(raw, namespace="token")
|
||||
except itsdangerous.BadSignature:
|
||||
return None
|
||||
|
||||
if "t" not in decoded:
|
||||
return None
|
||||
created = decoded["t"]
|
||||
if not isinstance(created, int):
|
||||
return None
|
||||
|
||||
duration = decoded.get("d")
|
||||
if duration is not None and not isinstance(duration, int):
|
||||
return None
|
||||
|
||||
if (duration is None and max_signed_tokens_ttl) or (
|
||||
duration is not None
|
||||
and max_signed_tokens_ttl
|
||||
and duration > max_signed_tokens_ttl
|
||||
):
|
||||
duration = max_signed_tokens_ttl
|
||||
|
||||
if duration:
|
||||
if time.time() - created > duration:
|
||||
return None
|
||||
|
||||
actor = {"id": decoded["a"], "token": "dstok"}
|
||||
|
||||
if "_r" in decoded:
|
||||
actor["_r"] = decoded["_r"]
|
||||
|
||||
if duration:
|
||||
actor["token_expires"] = created + duration
|
||||
|
||||
return actor
|
||||
|
|
@ -32,7 +32,7 @@ def trace_child_tasks():
|
|||
|
||||
|
||||
@contextmanager
|
||||
def trace(trace_type, **kwargs):
|
||||
def trace(type, **kwargs):
|
||||
assert not TRACE_RESERVED_KEYS.intersection(
|
||||
kwargs.keys()
|
||||
), f".trace() keyword parameters cannot include {TRACE_RESERVED_KEYS}"
|
||||
|
|
@ -45,21 +45,14 @@ def trace(trace_type, **kwargs):
|
|||
yield kwargs
|
||||
return
|
||||
start = time.perf_counter()
|
||||
captured_error = None
|
||||
try:
|
||||
yield kwargs
|
||||
except Exception as ex:
|
||||
captured_error = ex
|
||||
raise
|
||||
finally:
|
||||
end = time.perf_counter()
|
||||
trace_info = {
|
||||
"type": trace_type,
|
||||
"type": type,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"duration_ms": (end - start) * 1000,
|
||||
"traceback": traceback.format_list(traceback.extract_stack(limit=6)[:-3]),
|
||||
"error": str(captured_error) if captured_error else None,
|
||||
}
|
||||
trace_info.update(kwargs)
|
||||
tracer.append(trace_info)
|
||||
|
|
@ -97,7 +90,6 @@ class AsgiTracer:
|
|||
|
||||
async def wrapped_send(message):
|
||||
nonlocal accumulated_body, size_limit_exceeded, response_headers
|
||||
|
||||
if message["type"] == "http.response.start":
|
||||
response_headers = message["headers"]
|
||||
await send(message)
|
||||
|
|
@ -110,12 +102,11 @@ class AsgiTracer:
|
|||
# Accumulate body until the end or until size is exceeded
|
||||
accumulated_body += message["body"]
|
||||
if len(accumulated_body) > self.max_body_bytes:
|
||||
# Send what we have accumulated so far
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": accumulated_body,
|
||||
"more_body": bool(message.get("more_body")),
|
||||
"more_body": True,
|
||||
}
|
||||
)
|
||||
size_limit_exceeded = True
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue