diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index b0640ae8..18c01fdc 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -57,7 +57,7 @@ jobs: db.route = "alternative-route" ' > plugins/alternative_route.py cp fixtures.db fixtures2.db - - name: And the counters writable stored query demo + - name: And the counters writable canned query demo run: | cat > plugins/counters.py <=0.2.2' \ --service "datasette-latest$SUFFIX" \ --secret $LATEST_DATASETTE_SECRET diff --git a/.gitignore b/.gitignore index 8c058692..12acd87e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ build-metadata.json datasets.json -.playwright-mcp - scratchpad .vscode @@ -133,4 +131,4 @@ tests/*.dylib tests/*.so tests/*.dll -.idea +.idea \ No newline at end of file diff --git a/datasette/_pytest_plugin.py b/datasette/_pytest_plugin.py index 103c616d..5fb6b473 100644 --- a/datasette/_pytest_plugin.py +++ b/datasette/_pytest_plugin.py @@ -19,38 +19,23 @@ import weakref import pytest +from datasette.app import Datasette + _active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar( "datasette_active_instances", default=None ) -_original_init = None +_original_init = Datasette.__init__ -def _install_tracking(): - # datasette.app is imported lazily here rather than at module level: - # as a pytest11 entry point this module is imported during pytest - # startup, before pytest-cov starts measuring, so a module-level - # import would drag in all of datasette and make every import-time - # line in the package invisible to coverage - global _original_init - if _original_init is not None: - return - from datasette.app import Datasette - - _original_init = Datasette.__init__ - - def _tracking_init(self, *args, **kwargs): - _original_init(self, *args, **kwargs) - instances = _active_instances.get() - if instances is not None: - instances.append(weakref.ref(self)) - - Datasette.__init__ = _tracking_init +def _tracking_init(self, *args, **kwargs): + _original_init(self, *args, **kwargs) + instances = _active_instances.get() + if instances is not None: + instances.append(weakref.ref(self)) -def pytest_configure(config): - if _enabled(config): - _install_tracking() +Datasette.__init__ = _tracking_init def pytest_addoption(parser): diff --git a/datasette/app.py b/datasette/app.py index 545a65c8..75f05d88 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import contextvars -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence +from typing import TYPE_CHECKING, Any, Dict, Iterable, List if TYPE_CHECKING: from datasette.permissions import Resource @@ -42,31 +42,12 @@ from jinja2.exceptions import TemplateNotFound from .events import Event from .column_types import SQLiteType -from . import stored_queries, write_sql from .views import Context -from .views.database import ( - database_download, - DatabaseView, - TableCreateView, - QueryView, -) -from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView -from .views.stored_queries import ( - QueryCreateAnalyzeView, - QueryDeleteView, - QueryDefinitionView, - QueryEditView, - GlobalQueryListView, - QueryListView, - QueryParametersView, - QueryStoreView, - QueryUpdateView, -) +from .views.database import database_download, DatabaseView, TableCreateView, QueryView from .views.index import IndexView from .views.special import ( JsonDataView, PatternPortfolioView, - AutocompleteDebugView, AuthTokenView, ApiExplorerView, CreateTokenView, @@ -83,12 +64,10 @@ from .views.special import ( TableSchemaView, ) from .views.table import ( - TableAutocompleteView, TableInsertView, TableUpsertView, TableSetColumnTypeView, TableDropView, - TableFragmentView, table_view, ) from .views.row import RowView, RowDeleteView, RowUpdateView @@ -294,15 +273,6 @@ DEFAULT_NOT_SET = object() ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params")) -def _permission_cache_key(actor, action, parent, child): - # Key on the full serialized actor so actors differing in any field - # (e.g. token restrictions) never share cache entries - actor_key = ( - json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None - ) - return (actor_key, action, parent, child) - - async def favicon(request, send): await asgi_send_file( send, @@ -601,9 +571,6 @@ class Datasette: # TODO(alex) is metadata.json was loaded in, and --internal is not memory, then log # a warning to user that they should delete their metadata.json file - async def _save_queries_from_config(self): - await stored_queries.save_queries_from_config(self) - def get_jinja_environment(self, request: Request = None) -> Environment: environment = self._jinja_env if request: @@ -764,7 +731,6 @@ class Datasette: await await_me_maybe(hook) # Ensure internal tables and metadata are populated before startup hooks await self._refresh_schemas() - await self._save_queries_from_config() # Load column_types from config into internal DB await self._apply_column_types_config() for hook in pm.hook.startup(datasette=self): @@ -1041,180 +1007,6 @@ class Datasette: [database_name, resource_name, column_name, key, value], ) - @staticmethod - def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None: - return stored_queries.query_row_to_stored_query(row) - - @staticmethod - def _query_options_json(options): - return stored_queries.query_options_json(options) - - async def add_query( - self, - 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: - return await stored_queries.add_query( - self, - database, - name, - sql, - title=title, - description=description, - description_html=description_html, - hide_sql=hide_sql, - fragment=fragment, - parameters=parameters, - is_write=is_write, - is_private=is_private, - is_trusted=is_trusted, - source=source, - owner_id=owner_id, - 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, - replace=replace, - ) - - async def update_query( - self, - database: str, - name: str, - *, - sql=stored_queries.UNCHANGED, - title=stored_queries.UNCHANGED, - description=stored_queries.UNCHANGED, - description_html=stored_queries.UNCHANGED, - hide_sql=stored_queries.UNCHANGED, - fragment=stored_queries.UNCHANGED, - parameters=stored_queries.UNCHANGED, - is_write=stored_queries.UNCHANGED, - is_private=stored_queries.UNCHANGED, - is_trusted=stored_queries.UNCHANGED, - source=stored_queries.UNCHANGED, - owner_id=stored_queries.UNCHANGED, - on_success_message=stored_queries.UNCHANGED, - on_success_message_sql=stored_queries.UNCHANGED, - on_success_redirect=stored_queries.UNCHANGED, - on_error_message=stored_queries.UNCHANGED, - on_error_redirect=stored_queries.UNCHANGED, - ) -> None: - return await stored_queries.update_query( - self, - database, - name, - sql=sql, - title=title, - description=description, - description_html=description_html, - hide_sql=hide_sql, - fragment=fragment, - parameters=parameters, - is_write=is_write, - is_private=is_private, - is_trusted=is_trusted, - source=source, - owner_id=owner_id, - 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, - ) - - async def remove_query( - self, database: str, name: str, source: str | None = None - ) -> None: - return await stored_queries.remove_query(self, database, name, source=source) - - async def get_query( - self, database: str, name: str - ) -> stored_queries.StoredQuery | None: - return await stored_queries.get_query(self, database, name) - - async def count_queries( - self, - 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: - return await stored_queries.count_queries( - self, - database, - actor=actor, - q=q, - is_write=is_write, - is_private=is_private, - is_trusted=is_trusted, - source=source, - owner_id=owner_id, - ) - - async def list_queries( - self, - 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, - ) -> stored_queries.StoredQueryPage: - return await stored_queries.list_queries( - self, - database, - actor=actor, - limit=limit, - cursor=cursor, - q=q, - is_write=is_write, - is_private=is_private, - is_trusted=is_trusted, - source=source, - owner_id=owner_id, - include_private=include_private, - ) - - async def ensure_query_write_permissions( - self, database, sql, *, actor=None, params=None, analysis=None - ): - # Raise Forbidden or QueryWriteRejected if SQL should not run - return await write_sql.ensure_query_write_permissions( - self, database, sql, actor=actor, params=params, analysis=analysis - ) - # Column types API async def _get_resource_column_details(self, database: str, resource: str): @@ -1446,6 +1238,29 @@ class Datasette: def app_css_hash(self): return self.static_hash("app.css") + async def get_canned_queries(self, database_name, actor): + queries = {} + for more_queries in pm.hook.canned_queries( + datasette=self, + database=database_name, + actor=actor, + ): + more_queries = await await_me_maybe(more_queries) + queries.update(more_queries or {}) + # Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}} + for key in queries: + if not isinstance(queries[key], dict): + queries[key] = {"sql": queries[key]} + # Also make sure "name" is available: + queries[key]["name"] = key + return queries + + async def get_canned_query(self, database_name, query_name, actor): + queries = await self.get_canned_queries(database_name, actor) + query = queries.get(query_name) + if query: + return query + def _prepare_connection(self, conn, database): conn.row_factory = sqlite3.Row conn.text_factory = lambda x: str(x, "utf-8", "replace") @@ -1829,124 +1644,46 @@ class Datasette: # For global actions, resource can be omitted: can_debug = await datasette.allowed(action="permissions-debug", actor=actor) """ - results = await self.allowed_many( - actions=[action], resource=resource, actor=actor - ) - return results[action] + from datasette.utils.actions_sql import check_permission_for_resource - async def allowed_many( - self, - *, - actions: Sequence[str], - resource: "Resource" = None, - actor: dict | None = None, - ) -> dict[str, bool]: - """ - Check several actions against one resource for one actor. + # For global actions, resource remains None - Resolves every action (plus any also_requires dependencies) with a - single internal database query, instead of one or two queries per - action. Results are stored in the request-scoped permission cache, - so subsequent datasette.allowed() calls for the same checks within - the same request are served from the cache. - - Example: - from datasette.resources import TableResource - results = await datasette.allowed_many( - actions=["edit-schema", "drop-table", "insert-row"], - resource=TableResource(database="data", table="exercise"), + # Check if this action has also_requires - if so, check that action first + action_obj = self.actions.get(action) + if action_obj and action_obj.also_requires: + # Must have the required action first + if not await self.allowed( + action=action_obj.also_requires, + resource=resource, actor=actor, - ) - # {"edit-schema": True, "drop-table": True, "insert-row": False} - """ - from datasette.utils.actions_sql import check_permissions_for_actions - from datasette.permissions import ( - _permission_check_cache, - _skip_permission_checks, - ) + ): + return False # For global actions, resource is None parent = resource.parent if resource else None child = resource.child if resource else None - # Expand also_requires dependencies (transitively) so that each - # dependency is resolved within the same batch - expanded = [] + result = await check_permission_for_resource( + datasette=self, + actor=actor, + action=action, + parent=parent, + child=child, + ) - def add_action(name): - if name in expanded: - return - action_obj = self.actions.get(name) - if action_obj is None: - raise ValueError(f"Unknown action: {name}") - expanded.append(name) - if action_obj.also_requires: - add_action(action_obj.also_requires) - - requested = list(dict.fromkeys(actions)) - for name in requested: - add_action(name) - - # Consult the request-scoped cache, unless permission checks are - # being skipped (skip-mode verdicts must never be cached) - skip = _skip_permission_checks.get() - cache = None if skip else _permission_check_cache.get() - - final = {} - to_check = [] - for name in expanded: - if cache is not None: - key = _permission_cache_key(actor, name, parent, child) - if key in cache: - final[name] = cache[key] - continue - to_check.append(name) - - raw = {} - if to_check: - raw = await check_permissions_for_actions( - datasette=self, + # Log the permission check for debugging + self._permission_checks.append( + PermissionCheck( + when=datetime.datetime.now(datetime.timezone.utc).isoformat(), actor=actor, - actions=to_check, + action=action, parent=parent, child=child, + result=result, ) + ) - def resolve(name): - # final verdict = own rules AND verdict of also_requires chain - if name in final: - return final[name] - result = raw[name] - action_obj = self.actions.get(name) - if result and action_obj.also_requires: - result = resolve(action_obj.also_requires) - final[name] = result - return result - - for name in expanded: - resolve(name) - - # Cache the freshly computed checks - if cache is not None: - for name in to_check: - cache[_permission_cache_key(actor, name, parent, child)] = final[name] - - # Log every check (including cache hits) for the debug page, - # dependencies before the actions that required them - when = datetime.datetime.now(datetime.timezone.utc).isoformat() - for name in reversed(expanded): - self._permission_checks.append( - PermissionCheck( - when=when, - actor=actor, - action=name, - parent=parent, - child=child, - result=final[name], - ) - ) - - return {name: final[name] for name in requested} + return result async def ensure_permission( self, @@ -2019,11 +1756,6 @@ class Datasette: other_table = fk["other_table"] other_column = fk["other_column"] - if other_column is None: - other_pks = await db.primary_keys(other_table) - if len(other_pks) != 1: - return {} - other_column = other_pks[0] visible, _ = await self.check_visibility( actor, action="view-table", @@ -2320,8 +2052,6 @@ class Datasette: and "ds_actor" in request.cookies and request.actor, "app_css_hash": self.app_css_hash(), - "edit_tools_js_hash": self.static_hash("edit-tools.js"), - "table_js_hash": self.static_hash("table.js"), "zip": zip, "body_scripts": body_scripts, "format_bytes": format_bytes, @@ -2506,10 +2236,6 @@ class Datasette: JumpView.as_view(self), r"/-/jump(\.(?Pjson))?$", ) - add_route( - GlobalQueryListView.as_view(self), - r"/-/queries(\.(?Pjson))?$", - ) add_route( InstanceSchemaView.as_view(self), r"/-/schema(\.(?Pjson|md))?$", @@ -2546,10 +2272,6 @@ class Datasette: wrap_view(PatternPortfolioView, self), r"/-/patterns$", ) - add_route( - AutocompleteDebugView.as_view(self), - r"/-/debug/autocomplete$", - ) add_route( wrap_view(database_download, self), r"/(?P[^\/\.]+)\.db$", @@ -2559,54 +2281,14 @@ class Datasette: r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") - add_route( - QueryListView.as_view(self), - r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", - ) - add_route( - QueryCreateAnalyzeView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/analyze$", - ) - add_route( - QueryStoreView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/store$", - ) - add_route( - ExecuteWriteAnalyzeView.as_view(self), - r"/(?P[^\/\.]+)/-/execute-write/analyze$", - ) - add_route( - ExecuteWriteView.as_view(self), - r"/(?P[^\/\.]+)/-/execute-write$", - ) add_route( DatabaseSchemaView.as_view(self), r"/(?P[^\/\.]+)/-/schema(\.(?Pjson|md))?$", ) - add_route( - QueryParametersView.as_view(self), - r"/(?P[^\/\.]+)/-/query/parameters$", - ) add_route( wrap_view(QueryView, self), r"/(?P[^\/\.]+)/-/query(\.(?P\w+))?$", ) - add_route( - QueryDefinitionView.as_view(self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/definition$", - ) - add_route( - QueryEditView.as_view(self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/edit$", - ) - add_route( - QueryUpdateView.as_view(self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/update$", - ) - add_route( - QueryDeleteView.as_view(self), - r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/delete$", - ) add_route( wrap_view(table_view, self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)(\.(?P\w+))?$", @@ -2627,14 +2309,6 @@ class Datasette: TableSetColumnTypeView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-column-type$", ) - add_route( - TableFragmentView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/fragment$", - ) - add_route( - TableAutocompleteView.as_view(self), - r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/autocomplete$", - ) add_route( TableDropView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/drop$", @@ -2721,16 +2395,7 @@ class DatasetteRouter: if raw_path: path = raw_path.decode("ascii") path = path.partition("?")[0] - # Give each request a fresh permission check cache, so repeated - # datasette.allowed() checks within the request are memoized but - # results never persist beyond it - from datasette.permissions import _permission_check_cache - - cache_token = _permission_check_cache.set({}) - try: - return await self.route_path(scope, receive, send, path) - finally: - _permission_check_cache.reset(cache_token) + return await self.route_path(scope, receive, send, path) async def route_path(self, scope, receive, send, path): # Strip off base_url if present before routing @@ -2993,22 +2658,19 @@ def wrap_view_function(view_fn, datasette): def permanent_redirect(path, forward_query_string=False, forward_rest=False): - def view(request, send): - redirect_path = ( + return wrap_view( + lambda request, send: Response.redirect( path + (request.url_vars["rest"] if forward_rest else "") + ( ("?" + request.query_string) if forward_query_string and request.query_string else "" - ) - ) - route_path = request.scope.get("route_path") - if route_path and request.path.endswith(route_path): - redirect_path = request.path[: -len(route_path)] + redirect_path - return Response.redirect(redirect_path, status=301) - - return wrap_view(view, datasette=None) + ), + status=301, + ), + datasette=None, + ) _curly_re = re.compile(r"({.*?})") diff --git a/datasette/cli.py b/datasette/cli.py index 90a33e80..93aa22ef 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -21,7 +21,6 @@ from .app import ( SQLITE_LIMIT_ATTACHED, pm, ) -from .inspect import inspect_tables from .utils import ( LoadExtension, StartupError, @@ -155,14 +154,14 @@ async def inspect_(files, sqlite_extensions): app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions) data = {} for name, database in app.databases.items(): - tables = await database.execute_fn(lambda conn: inspect_tables(conn, {})) + counts = await database.table_counts(limit=3600 * 1000) data[name] = { "hash": database.hash, "size": database.size, "file": database.path, "tables": { - table_name: {"count": table["count"]} - for table_name, table in tables.items() + table_name: {"count": table_count} + for table_name, table_count in counts.items() }, } return data diff --git a/datasette/column_types.py b/datasette/column_types.py index 11a14ec0..7320e1d6 100644 --- a/datasette/column_types.py +++ b/datasette/column_types.py @@ -6,17 +6,19 @@ class SQLiteType(Enum): INTEGER = "INTEGER" REAL = "REAL" BLOB = "BLOB" - NUMERIC = "NUMERIC" + NULL = "NULL" @classmethod - def from_declared_type(cls, declared_type: str | None) -> "SQLiteType": + def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None": if declared_type is None: - return cls.BLOB + return cls.NULL normalized = declared_type.strip().upper() if not normalized: - return cls.BLOB + 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")): @@ -29,7 +31,7 @@ class SQLiteType(Enum): ): return cls.REAL - return cls.NUMERIC + return None class ColumnType: diff --git a/datasette/database.py b/datasette/database.py index e7fe1ed9..66d50ffa 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -25,14 +25,11 @@ from .utils import ( table_columns, table_column_details, ) -from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables -from .utils.sqlite import sqlite_hidden_table_names +from .utils.sqlite import sqlite_version from .inspect import inspect_hash connections = threading.local() -EXECUTE_WRITE_RETURNING_LIMIT = 10 - AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file")) @@ -238,24 +235,11 @@ class Database: except OSError: pass - async def execute_write( - self, - sql, - params=None, - block=True, - request=None, - return_all=False, - returning_limit=EXECUTE_WRITE_RETURNING_LIMIT, - ): + async def execute_write(self, sql, params=None, block=True, request=None): self._check_not_closed() - if returning_limit < 0: - raise ValueError("returning_limit must be >= 0") def _inner(conn): - cursor = conn.execute(sql, params or []) - return ExecuteWriteResult.from_cursor( - cursor, return_all=return_all, returning_limit=returning_limit - ) + 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) @@ -298,14 +282,13 @@ class Database: async def execute_isolated_fn(self, fn): self._check_not_closed() - # Open a new connection just for the duration of this function, + # Open a new connection just for the duration of this function # blocking the write queue to avoid any writes occurring during it - write = self.is_mutable - - def _run(): - isolated_connection = self.connect(write=write) + if self.ds.executor is None: + # non-threaded mode + isolated_connection = self.connect(write=True) try: - return fn(isolated_connection) + result = fn(isolated_connection) finally: isolated_connection.close() try: @@ -313,25 +296,10 @@ class Database: except ValueError: # Was probably a memory connection pass - - if self.ds.executor is None: - # non-threaded mode - return _run() - if not write: - # Immutable database - no writes can ever occur, so there is no - # write queue to block; run against a fresh read-only connection - return await asyncio.get_running_loop().run_in_executor( - self.ds.executor, _run - ) - # 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) - ) + return result + else: + # Threaded mode - send to write thread + return await self._send_to_write_thread(fn, isolated_connection=True) async def execute_write_fn(self, fn, block=True, transaction=True, request=None): self._check_not_closed() @@ -458,21 +426,20 @@ class Database: if conn_exception is not None: exception = conn_exception elif task.isolated_connection: + isolated_connection = self.connect(write=True) try: - isolated_connection = self.connect(write=True) - try: - result = task.fn(isolated_connection) - finally: - isolated_connection.close() - try: - self._all_file_connections.remove(isolated_connection) - except ValueError: - # Was probably a memory connection - pass + 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 else: try: if task.transaction: @@ -727,7 +694,83 @@ class Database: t for t in db_config["tables"] if db_config["tables"][t].get("hidden") ] - hidden_tables += await self.execute_fn(sqlite_hidden_table_names) + 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: @@ -829,10 +872,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event): # Execute the actual write try: result = fn(conn) - except Exception as e: + except Exception: # Throw exception into generator so it can handle it try: - gen.throw(e) + gen.throw(*sys.exc_info()) except StopIteration: pass # Re-raise the original exception @@ -902,44 +945,6 @@ class MultipleValues(Exception): pass -class ExecuteWriteResult: - def __init__(self, rowcount, lastrowid, description, rows, truncated): - self.rowcount = rowcount - self.lastrowid = lastrowid - self.description = description - self.truncated = truncated - self._rows = rows - - @classmethod - def from_cursor( - cls, cursor, return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT - ): - rows = [] - truncated = False - description = cursor.description - lastrowid = cursor.lastrowid - try: - if description is not None: - if return_all: - rows = cursor.fetchall() - else: - rows = cursor.fetchmany(returning_limit + 1) - if len(rows) > returning_limit: - rows = rows[:returning_limit] - truncated = True - rowcount = cursor.rowcount - finally: - cursor.close() - if description is not None and not return_all and truncated: - rowcount = -1 - return cls(rowcount, lastrowid, description, rows, truncated) - - def fetchall(self): - rows = self._rows - self._rows = [] - return rows - - class Results: def __init__(self, rows, truncated, description): self.rows = rows diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 2f78570b..149a4e5f 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -48,26 +48,12 @@ def register_actions(): 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", @@ -118,16 +104,4 @@ def register_actions(): 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, - ), ) diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py index f90a733e..24493994 100644 --- a/datasette/default_column_types.py +++ b/datasette/default_column_types.py @@ -76,12 +76,6 @@ class JsonColumnType(ColumnType): return None -class TextareaColumnType(ColumnType): - name = "textarea" - description = "Multiline text" - sqlite_types = (SQLiteType.TEXT,) - - @hookimpl def register_column_types(datasette): - return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType] + return [UrlColumnType, EmailColumnType, JsonColumnType] diff --git a/datasette/default_database_actions.py b/datasette/default_database_actions.py deleted file mode 100644 index e0cb3cdf..00000000 --- a/datasette/default_database_actions.py +++ /dev/null @@ -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 diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py index 8ea3c287..6127b2a6 100644 --- a/datasette/default_debug_menu.py +++ b/datasette/default_debug_menu.py @@ -37,11 +37,6 @@ DEBUG_MENU_ITEMS = ( "Debug allow rules", "Explore how allow blocks match actors against permission rules.", ), - ( - "/-/debug/autocomplete", - "Debug autocomplete", - "Try out table autocomplete against a detected label column.", - ), ( "/-/threads", "Debug threads", diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py index 6cd46f04..9e3bb648 100644 --- a/datasette/default_permissions/__init__.py +++ b/datasette/default_permissions/__init__.py @@ -17,6 +17,13 @@ UNION/INTERSECT operations. The order of evaluation is: from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datasette.app import Datasette + +from datasette import hookimpl + # Re-export all hooks and public utilities from .restrictions import ( actor_restrictions_sql as actor_restrictions_sql, @@ -26,9 +33,16 @@ from .restrictions import ( 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, ) + + +@hookimpl +def canned_queries(datasette: "Datasette", database: str, actor) -> dict: + """Return canned queries defined in datasette.yaml configuration.""" + queries = ( + ((datasette.config or {}).get("databases") or {}).get(database) or {} + ).get("queries") or {} + return queries diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 5bc74425..4c74219d 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -67,48 +67,3 @@ async def default_action_permissions_sql( 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, - ) diff --git a/datasette/default_query_actions.py b/datasette/default_query_actions.py deleted file mode 100644 index 2183e70b..00000000 --- a/datasette/default_query_actions.py +++ /dev/null @@ -1,48 +0,0 @@ -from datasette import hookimpl -from datasette.resources import QueryResource - - -@hookimpl -def query_actions(datasette, actor, database, query_name, request): - # Only stored queries (with a name) can be edited or deleted - if not query_name: - return None - - async def inner(): - query = await datasette.get_query(database, query_name) - if query is None: - return [] - # Config-defined and trusted queries are managed outside the UI - if query.source == "config" or query.is_trusted: - return [] - - links = [] - if await datasette.allowed( - action="update-query", - resource=QueryResource(database, query_name), - actor=actor, - ): - links.append( - { - "href": datasette.urls.table(database, query_name) + "/-/edit", - "label": "Edit this query", - "description": ( - "Change the title, description, SQL or visibility." - ), - } - ) - if await datasette.allowed( - action="delete-query", - resource=QueryResource(database, query_name), - actor=actor, - ): - links.append( - { - "href": datasette.urls.table(database, query_name) + "/-/delete", - "label": "Delete this query", - "description": "Permanently remove this saved query.", - } - ) - return links - - return inner diff --git a/datasette/extras.py b/datasette/extras.py deleted file mode 100644 index 5cab52a4..00000000 --- a/datasette/extras.py +++ /dev/null @@ -1,118 +0,0 @@ -import re -from dataclasses import dataclass -from enum import Enum -from typing import ClassVar - -from asyncinject import Registry - - -def extra_names_from_request(request): - extra_bits = request.args.getlist("_extra") - extras = set() - for bit in extra_bits: - extras.update(part for part in bit.split(",") if part) - return extras - - -class ExtraScope(Enum): - TABLE = "table" - ROW = "row" - QUERY = "query" - - -@dataclass(frozen=True) -class ExtraExample: - path: str | None = None - key: str | None = None - value: object | None = None - note: str | None = None - - -class Provider: - name: ClassVar[str | None] = None - scopes: ClassVar[set[ExtraScope]] = set() - public: ClassVar[bool] = False - - @classmethod - def key(cls): - return cls.name or _camel_to_snake(cls.__name__) - - @classmethod - def available_for(cls, scope): - return scope in cls.scopes - - async def resolve(self, context): - raise NotImplementedError - - -class Extra(Provider): - description: ClassVar[str | None] = None - example: ClassVar[ExtraExample | None] = None - examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {} - public: ClassVar[bool] = True - expensive: ClassVar[bool] = False - docs_note: ClassVar[str | None] = None - - @classmethod - def example_for_scope(cls, scope): - return cls.examples.get(scope, cls.example) - - -class ExtraRegistry: - def __init__(self, classes): - self.classes = list(classes) - self.classes_by_name = {cls.key(): cls for cls in self.classes} - # Lazily-built shared state, keyed by scope. Safe to share across - # requests because Extra instances are stateless and asyncinject's - # Registry keeps per-call state local to each resolve_multi() call. - # If extras classes ever become registerable at runtime (e.g. via a - # plugin hook) these caches will need invalidating. - self._scope_registries = {} - self._allowed_names = {} - - def classes_for_scope(self, scope, include_internal=True): - classes = [ - cls - for cls in self.classes - if cls.available_for(scope) and (include_internal or cls.public) - ] - return classes - - def public_classes_for_scope(self, scope): - return self.classes_for_scope(scope, include_internal=False) - - def _registry_for_scope(self, scope): - registry = self._scope_registries.get(scope) - if registry is None: - registry = Registry() - for cls in self.classes_for_scope(scope): - registry.register(cls().resolve, name=cls.key()) - self._scope_registries[scope] = registry - return registry - - def _allowed_names_for_scope(self, scope, include_internal): - key = (scope, include_internal) - names = self._allowed_names.get(key) - if names is None: - names = { - cls.key() - for cls in self.classes_for_scope( - scope, include_internal=include_internal - ) - } - self._allowed_names[key] = names - return names - - async def resolve(self, requested, context, scope, include_internal=False): - allowed_names = self._allowed_names_for_scope(scope, include_internal) - requested_names = [name for name in requested if name in allowed_names] - resolved = await self._registry_for_scope(scope).resolve_multi( - requested_names, results={"context": context} - ) - return {name: resolved[name] for name in requested_names} - - -def _camel_to_snake(name): - name = re.sub(r"(Extra|Provider)$", "", name) - name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) - return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() diff --git a/datasette/facets.py b/datasette/facets.py index abe0605e..bc4b6904 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -83,7 +83,7 @@ class Facet: 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 [] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 7c56f882..cf95abcb 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -137,6 +137,11 @@ def permission_resources_sql(datasette, actor, action): """ +@hookspec +def canned_queries(datasette, database, actor): + """Return a dictionary of canned query definitions or an awaitable function that returns them""" + + @hookspec def register_magic_parameters(datasette): """Return a list of (name, function) magic parameter functions""" @@ -159,32 +164,32 @@ def jump_items_sql(datasette, actor, request): @hookspec def row_actions(datasette, actor, request, database, table, row): - """Items for the row actions menu""" + """Links for the row actions menu""" @hookspec def table_actions(datasette, actor, database, table, request): - """Items for the table actions menu""" + """Links for the table actions menu""" @hookspec def view_actions(datasette, actor, database, view, request): - """Items for the view actions menu""" + """Links for the view actions menu""" @hookspec def query_actions(datasette, actor, database, query_name, request, sql, params): - """Items for the query and stored query actions menu""" + """Links for the query and canned query actions menu""" @hookspec def database_actions(datasette, actor, database, request): - """Items for the database actions menu""" + """Links for the database actions menu""" @hookspec def homepage_actions(datasette, actor, request): - """Items for the homepage actions menu""" + """Links for the homepage actions menu""" @hookspec @@ -228,8 +233,8 @@ def top_query(datasette, request, database, sql): @hookspec -def top_stored_query(datasette, request, database, query_name): - """HTML to include at the top of the stored query page""" +def top_canned_query(datasette, request, database, query_name): + """HTML to include at the top of the canned query page""" @hookspec diff --git a/datasette/permissions.py b/datasette/permissions.py index 786dc026..917c58ab 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -8,14 +8,6 @@ _skip_permission_checks = contextvars.ContextVar( "skip_permission_checks", default=False ) -# Request-scoped cache of permission check results. The ASGI router sets -# this to a fresh dict at the start of each request, so cached verdicts -# never outlive a request or leak between actors. Keys are -# (actor_json, action, parent, child) tuples, values are booleans. -_permission_check_cache: contextvars.ContextVar[dict | None] = contextvars.ContextVar( - "permission_check_cache", default=None -) - class SkipPermissions: """Context manager to temporarily skip permission checks. @@ -66,16 +58,6 @@ class Resource(ABC): self.child = child self._private = None # Sentinel to track if private was set - def __str__(self) -> str: - return "/".join( - str(part) for part in (self.parent, self.child) if part is not None - ) - - def __repr__(self) -> str: - return "{}(parent={!r}, child={!r})".format( - self.__class__.__name__, self.parent, self.child - ) - @property def private(self) -> bool: """ diff --git a/datasette/plugins.py b/datasette/plugins.py index f0fbc7f8..f532ac60 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -30,8 +30,6 @@ DEFAULT_PLUGINS = ( "datasette.blob_renderer", "datasette.default_debug_menu", "datasette.default_jump_items", - "datasette.default_database_actions", - "datasette.default_query_actions", "datasette.handle_exception", "datasette.forbidden", "datasette.events", diff --git a/datasette/renderer.py b/datasette/renderer.py index f40e3dbb..acf23e59 100644 --- a/datasette/renderer.py +++ b/datasette/renderer.py @@ -1,5 +1,4 @@ import json -from datasette.extras import extra_names_from_request from datasette.utils import ( value_as_boolean, remove_infinites, @@ -109,7 +108,7 @@ def json_renderer(request, args, data, error, truncated=None): # Don't include "columns" in output # https://github.com/simonw/datasette/issues/2136 - if isinstance(data, dict) and "columns" not in extra_names_from_request(request): + if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"): data.pop("columns", None) # Handle _nl option for _shape=array diff --git a/datasette/resources.py b/datasette/resources.py index ee2e6d98..236b3598 100644 --- a/datasette/resources.py +++ b/datasette/resources.py @@ -41,7 +41,7 @@ class TableResource(Resource): class QueryResource(Resource): - """A stored query in a database.""" + """A canned query in a database.""" name = "query" parent_class = DatabaseResource @@ -51,8 +51,42 @@ class QueryResource(Resource): @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 - """ + from datasette.plugins import pm + from datasette.utils import await_me_maybe + + # Get all databases from catalog + db = datasette.get_internal_database() + result = await db.execute("SELECT database_name FROM catalog_databases") + databases = [row[0] for row in result.rows] + + # Gather canned queries for this actor from all databases. + # This keeps allowed_resources("view-query", actor=...) consistent with + # actor-specific canned_queries() implementations. + query_pairs = [] + for database_name in databases: + # Call the hook to get queries (including from config via default plugin) + for queries_result in pm.hook.canned_queries( + datasette=datasette, + database=database_name, + actor=actor, + ): + queries = await await_me_maybe(queries_result) + if queries: + for query_name in queries.keys(): + query_pairs.append((database_name, query_name)) + + # Build SQL + if not query_pairs: + return "SELECT NULL AS parent, NULL AS child WHERE 0" + + # Generate UNION ALL query + selects = [] + for db_name, query_name in query_pairs: + # Escape single quotes by doubling them + db_escaped = db_name.replace("'", "''") + query_escaped = query_name.replace("'", "''") + selects.append( + f"SELECT '{db_escaped}' AS parent, '{query_escaped}' AS child" + ) + + return " UNION ALL ".join(selects) diff --git a/datasette/static/app.css b/datasette/static/app.css index 5fe4502d..c21d0dc4 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -706,11 +706,6 @@ button.core[type=button] { color: #666; padding-right: 0.25em; } -/* The label may wrap (word-break: break-all on the li) but the count should - stay on one line - https://github.com/simonw/datasette/issues/2754 */ -.facet-count { - white-space: nowrap; -} .facet-info li, .facet-info ul { margin: 0; @@ -792,9 +787,9 @@ p.zero-results { dialog.mobile-column-actions-dialog { --ink: #0f0f0f; - --paper: #eef6ff; + --paper: #f5f3ef; --muted: #6b6b6b; - --rule: #d8e6f5; + --rule: #e2dfd8; --accent: #1a56db; --card: #ffffff; border: none; @@ -1020,9 +1015,9 @@ dialog.mobile-column-actions-dialog::backdrop { dialog.set-column-type-dialog { --ink: #0f0f0f; - --paper: #eef6ff; + --paper: #f5f3ef; --muted: #6b6b6b; - --rule: #d8e6f5; + --rule: #e2dfd8; --accent: #1a56db; --card: #ffffff; border: none; @@ -1109,7 +1104,7 @@ dialog.set-column-type-dialog::backdrop { padding: 14px 16px; border: 1px solid var(--rule); border-radius: 8px; - background: #fbfdff; + background: #fcfbf9; cursor: pointer; } @@ -1192,607 +1187,6 @@ dialog.set-column-type-dialog::backdrop { cursor: wait; } -.row-mutation-status { - margin: 0 0 0.75rem; - padding: 8px 10px; - border-left: 4px solid #54AC8E; - background: rgba(103,201,141,0.12); - color: #222; -} - -.row-mutation-status[hidden] { - display: none; -} - -.row-mutation-status-error { - border-left-color: #D0021B; - background: rgba(208,2,27,0.12); -} - -.table-row-toolbar { - margin: 0 0 0.75rem; -} - -button.table-insert-row { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -button.table-insert-row svg { - display: block; - flex-shrink: 0; -} - -dialog.row-delete-dialog { - --ink: #0f0f0f; - --paper: #eef6ff; - --muted: #6b6b6b; - --rule: #d8e6f5; - --accent: #1a56db; - --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; - width: min(440px, calc(100vw - 32px)); - max-width: 95vw; - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -} - -dialog.row-delete-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.row-delete-dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; -} - -.row-delete-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-start; - gap: 12px; - flex-shrink: 0; - min-width: 0; -} - -.row-delete-dialog .modal-title { - display: flex; - align-items: center; - gap: 0.35rem; - min-width: 0; - max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.row-delete-message, -.row-delete-error { - margin: 0; - padding: 16px 24px 0; -} - -.row-delete-message { - color: var(--ink); - font-size: 0.95rem; -} - -.row-delete-id { - display: inline; - padding: 2px 5px; - border: 1px solid var(--rule); - border-radius: 4px; - background: var(--paper); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.92em; - overflow-wrap: anywhere; -} - -.row-delete-error { - color: #b91c1c; - font-size: 0.9rem; -} - -.row-delete-dialog .modal-footer { - padding: 18px 20px 14px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-shrink: 0; - background: var(--paper); - margin-top: 18px; -} - -.row-delete-dialog .btn { - border: none; - border-radius: 5px; - padding: 9px 20px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; - transition: background 0.12s; -} - -.row-delete-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.row-delete-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.row-delete-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.row-delete-dialog .btn-primary:hover { - background: #1949b8; -} - -.row-delete-dialog .btn:disabled { - opacity: 0.65; - cursor: wait; -} - -dialog.row-edit-dialog { - --ink: #0f0f0f; - --paper: #eef6ff; - --muted: #6b6b6b; - --rule: #d8e6f5; - --accent: #1a56db; - --card: #ffffff; - border: none; - border-radius: var(--modal-border-radius, 0.75rem); - padding: 0; - margin: auto; - width: min(720px, calc(100vw - 32px)); - max-width: 95vw; - max-height: min(780px, calc(100vh - 32px)); - box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); - animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; - overflow: hidden; - font-family: system-ui, -apple-system, sans-serif; - background: var(--card); -} - -dialog.row-edit-dialog[open] { - display: flex; - flex-direction: column; -} - -dialog.row-edit-dialog::backdrop { - background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); - backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); - animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; -} - -.row-edit-dialog .modal-header { - padding: 20px 24px 12px; - border-bottom: 1px solid var(--rule); - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; - min-width: 0; -} - -.row-edit-dialog .modal-title { - display: flex; - align-items: center; - gap: 0.35rem; - min-width: 0; - max-width: 100%; - font-size: 1rem; - font-weight: 600; - color: var(--ink); -} - -.row-edit-dialog .modal-title .row-dialog-action, -.row-delete-dialog .modal-title .row-dialog-action { - flex: 0 0 auto; - white-space: nowrap; -} - -.row-edit-dialog .modal-title code, -.row-delete-dialog .modal-title code { - display: inline; - flex: 0 0 auto; - padding: 2px 5px; - border: 1px solid var(--rule); - border-radius: 4px; - background: var(--paper); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.92em; - overflow-wrap: anywhere; -} - -.row-edit-dialog .modal-title .row-dialog-label, -.row-delete-dialog .modal-title .row-dialog-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.row-edit-form { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; -} - -.row-edit-summary, -.row-edit-loading, -.row-edit-error { - margin: 0; - padding: 12px 24px 0; -} - -.row-edit-summary, -.row-edit-loading { - color: var(--muted); - font-size: 0.9rem; -} - -.row-edit-error { - border-left: 4px solid #b91c1c; - border-radius: 4px; - background: #fff1f1; - color: #7f1d1d; - font-size: 0.9rem; - margin: 12px 24px 0; - padding: 10px 12px; -} - -.row-edit-error:focus { - outline: 3px solid rgba(185, 28, 28, 0.18); - outline-offset: 2px; -} - -.row-edit-fields { - display: grid; - gap: 14px; - padding: 16px 24px 24px; - overflow-y: auto; -} - -.row-edit-field { - display: grid; - grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); - gap: 12px; - align-items: start; -} - -.row-edit-label { - padding-top: 8px; - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; - overflow-wrap: anywhere; -} - -.row-edit-control-wrap { - display: grid; - gap: 5px; -} - -.row-edit-input { - box-sizing: border-box; - width: 100%; - min-width: 0; - border: 1px solid var(--rule); - border-radius: 5px; - padding: 8px 10px; - color: var(--ink); - background: #fff; - font: inherit; -} - -textarea.row-edit-input { - resize: vertical; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; - line-height: 1.45; -} - -.row-edit-input:focus { - border-color: var(--accent); - outline: 3px solid rgba(26, 86, 219, 0.12); -} - -.row-edit-input[aria-invalid="true"] { - border-color: #b42318; - background: #fff8f7; -} - -.row-edit-input[aria-invalid="true"]:focus { - border-color: #b42318; - outline-color: rgba(180, 35, 24, 0.16); -} - -.row-edit-input[readonly] { - color: var(--muted); - background: var(--paper); -} - -.row-edit-default { - display: grid; - grid-template-columns: minmax(0, 1fr) 7.25rem; - align-items: center; - gap: 8px; - min-width: 0; - border: 1px solid var(--rule); - border-radius: 5px; - padding: 7px 8px 7px 10px; - background: var(--paper); - color: var(--ink); -} - -.row-edit-default[hidden], -.row-edit-custom-value[hidden] { - display: none; -} - -.row-edit-default-text { - min-width: 0; - overflow-wrap: anywhere; -} - -.row-edit-default-code { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.82rem; -} - -.row-edit-custom-value { - display: grid; - grid-template-columns: minmax(0, 1fr) 7.25rem; - gap: 8px; - align-items: center; - min-height: 45px; - padding-right: 8px; -} - -.row-edit-default-button { - appearance: none; - border: 1px solid var(--rule); - border-radius: 4px; - background: #fff; - color: var(--accent); - cursor: pointer; - font: inherit; - font-size: 0.78rem; - line-height: 1.2; - padding: 6px 8px; - white-space: nowrap; - width: 100%; - align-self: center; -} - -.row-edit-default-button:hover, -.row-edit-default-button:focus { - background: #f8fafc; -} - -.row-edit-default-button:focus { - outline: 3px solid rgba(26, 86, 219, 0.12); - outline-offset: 1px; -} - -.row-edit-field-meta { - color: var(--muted); - font-size: 0.78rem; -} - -.row-edit-field-validation-error { - color: #b42318; - display: block; - margin-top: 2px; -} - -.row-edit-field-validation-error[hidden] { - display: none; -} - -.row-edit-field-meta-autocomplete { - line-height: 1.2; - min-height: 1.2em; -} - -.row-edit-fk-pk { - color: var(--ink); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} - -.row-edit-fk-link { - overflow-wrap: anywhere; -} - -.row-edit-empty { - color: var(--muted); - font-size: 0.9rem; - margin: 0; -} - -datasette-autocomplete { - display: block; - position: relative; - max-width: 38rem; -} - -datasette-autocomplete input[type="text"], -.debug-autocomplete-form input[type="text"] { - box-sizing: border-box; - width: 100%; - max-width: 38rem; -} - -.datasette-autocomplete-list { - background: #fff; - border: 1px solid var(--rule); - border-radius: 5px; - box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); - box-sizing: border-box; - left: 0; - max-height: 16rem; - overflow-y: auto; - position: fixed; - right: auto; - top: auto; - z-index: 10000; -} - -.datasette-autocomplete-list[hidden] { - display: none; -} - -.datasette-autocomplete-option { - cursor: pointer; - padding: 7px 9px; -} - -.datasette-autocomplete-option:hover, -.datasette-autocomplete-option[aria-selected="true"] { - background: var(--paper); -} - -.datasette-autocomplete-option[aria-selected="true"] { - background: var(--paper); - font-weight: 600; -} - -.datasette-autocomplete-status { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} - -.debug-autocomplete-demo { - margin: 1rem 0; -} - -.debug-autocomplete-selected { - max-width: 46rem; -} - -.row-edit-dialog .modal-footer { - padding: 14px 20px; - border-top: 1px solid var(--rule); - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-shrink: 0; - background: var(--paper); -} - -.row-edit-dialog .btn { - border: none; - border-radius: 5px; - padding: 9px 20px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; - transition: background 0.12s; -} - -.row-edit-dialog .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--rule); -} - -.row-edit-dialog .btn-ghost:hover { - background: var(--rule); - color: var(--ink); -} - -.row-edit-dialog .btn-primary { - background: var(--accent); - color: #fff; -} - -.row-edit-dialog .btn-primary:hover { - background: #1949b8; -} - -.row-edit-dialog .btn:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.row-link-with-actions { - display: inline-flex; - align-items: center; - gap: 0.4rem; - flex-wrap: wrap; -} - -.row-inline-actions { - display: inline-flex; - gap: 0.2rem; - align-items: center; -} - -.row-inline-action { - appearance: none; - border: 1px solid rgba(74, 85, 104, 0.24); - background: transparent; - color: #4a5568; - border-radius: 4px; - cursor: pointer; - display: inline-grid; - place-items: center; - min-height: 24px; - min-width: 24px; - padding: 2px; - position: relative; -} - -.row-inline-action:hover, -.row-inline-action:focus { - background: rgba(74, 85, 104, 0.07); -} - -.row-inline-action:focus { - outline: 3px solid #b3d4ff; - outline-offset: 1px; -} - -.row-inline-action-icon { - display: block; - height: 13px; - width: 13px; -} - @media (max-width: 640px) { dialog.mobile-column-actions-dialog { width: 95vw; @@ -1840,68 +1234,6 @@ datasette-autocomplete input[type="text"], padding-left: 18px; padding-right: 18px; } - - dialog.row-delete-dialog { - width: 95vw; - max-height: 85vh; - border-radius: 0.5rem; - } - - .row-delete-dialog .modal-header, - .row-delete-message, - .row-delete-error { - padding-left: 18px; - padding-right: 18px; - } - - .row-delete-dialog .modal-footer { - padding-left: 18px; - padding-right: 18px; - } - - dialog.row-edit-dialog { - width: 95vw; - max-height: 85vh; - border-radius: 0.5rem; - } - - .row-edit-dialog .modal-header, - .row-edit-summary, - .row-edit-loading, - .row-edit-fields { - padding-left: 18px; - padding-right: 18px; - } - - .row-edit-error { - margin-left: 18px; - margin-right: 18px; - } - - .row-edit-field { - grid-template-columns: 1fr; - gap: 5px; - } - - .row-edit-label { - padding-top: 0; - } - - .row-edit-dialog .modal-footer { - padding-left: 18px; - padding-right: 18px; - } - - .row-inline-action { - min-height: 30px; - min-width: 30px; - padding: 4px; - } - - .row-inline-action-icon { - height: 14px; - width: 14px; - } } @media only screen and (max-width: 576px) { @@ -1956,10 +1288,6 @@ datasette-autocomplete input[type="text"], font-size: 0.8em; } - .row-inline-actions { - margin-bottom: 0.35rem; - } - .select-wrapper { width: 100px; } @@ -1970,7 +1298,6 @@ datasette-autocomplete input[type="text"], width: 140px; } button.choose-columns-mobile, - button.table-insert-row, button.column-actions-mobile { display: inline-flex; align-items: center; @@ -2007,15 +1334,6 @@ datasette-autocomplete input[type="text"], button.choose-columns-mobile { margin-right: 0.5rem; } - - .table-row-toolbar { - margin-bottom: 0.75rem; - } - - button.table-insert-row { - width: 100%; - margin-bottom: 0; - } } svg.dropdown-menu-icon { @@ -2061,32 +1379,18 @@ svg.dropdown-menu-icon { .dropdown-menu a:link, .dropdown-menu a:visited, .dropdown-menu a:hover, -.dropdown-menu a:focus, -.dropdown-menu a:active, -.dropdown-menu button.action-menu-button { +.dropdown-menu a:focus +.dropdown-menu a:active { text-decoration: none; display: block; padding: 4px 8px 2px 8px; color: #222; white-space: nowrap; } -.dropdown-menu button.action-menu-button { - appearance: none; - background: none; - border: none; - box-sizing: border-box; - cursor: pointer; - font: inherit; - text-align: left; - width: 100%; -} -.dropdown-menu a:hover, -.dropdown-menu button.action-menu-button:hover, -.dropdown-menu button.action-menu-button:focus { +.dropdown-menu a:hover { background-color: #eee; } .dropdown-menu .dropdown-description { - display: block; margin: 0; color: #666; font-size: 0.8em; @@ -2105,15 +1409,11 @@ svg.dropdown-menu-icon { border-bottom: 5px solid #666; } -.stored-query-edit-sql { +.canned-query-edit-sql { padding-left: 0.5em; position: relative; top: 1px; } -.save-query { - display: inline-block; - margin-left: 0.45em; -} .blob-download { display: block; diff --git a/datasette/static/autocomplete.js b/datasette/static/autocomplete.js deleted file mode 100644 index c615000e..00000000 --- a/datasette/static/autocomplete.js +++ /dev/null @@ -1,344 +0,0 @@ -(function () { - function autocompleteValueFromRow(row) { - var pks = (row && row.pks) || {}; - var keys = Object.keys(pks); - if (!keys.length) { - return ""; - } - if (keys.length === 1) { - return String(pks[keys[0]]); - } - return keys - .map(function (key) { - return key + "=" + pks[key]; - }) - .join(", "); - } - - function autocompleteLabelFromRow(row) { - var value = autocompleteValueFromRow(row); - if (row.label && String(row.label) !== value) { - return row.label + " (" + value + ")"; - } - return value; - } - - if (!window.customElements || customElements.get("datasette-autocomplete")) { - return; - } - - class DatasetteAutocomplete extends HTMLElement { - constructor() { - super(); - this.input = null; - this.listbox = null; - this.status = null; - this.results = []; - this.activeIndex = -1; - this.fetchId = 0; - this.searchTimer = null; - this.boundInput = this.handleInput.bind(this); - this.boundKeydown = this.handleKeydown.bind(this); - this.boundBlur = this.handleBlur.bind(this); - this.boundFocus = this.handleFocus.bind(this); - this.boundPositionListbox = this.positionListbox.bind(this); - } - - connectedCallback() { - if (this.input) { - return; - } - this.input = this.querySelector("input"); - if (!this.input) { - return; - } - - var inputId = - this.input.id || - "datasette-autocomplete-" + Math.random().toString(36).slice(2); - this.input.id = inputId; - var listboxId = inputId + "-listbox"; - var statusId = inputId + "-status"; - - this.classList.add("datasette-autocomplete"); - this.input.setAttribute("role", "combobox"); - this.input.setAttribute("aria-autocomplete", "list"); - this.input.setAttribute("aria-expanded", "false"); - this.input.setAttribute("aria-controls", listboxId); - this.input.setAttribute("autocomplete", "off"); - - this.listbox = document.createElement("div"); - this.listbox.className = "datasette-autocomplete-list"; - this.listbox.id = listboxId; - this.listbox.setAttribute("role", "listbox"); - this.listbox.hidden = true; - - this.status = document.createElement("span"); - this.status.className = "datasette-autocomplete-status"; - this.status.id = statusId; - this.status.setAttribute("role", "status"); - this.status.setAttribute("aria-live", "polite"); - - this.input.setAttribute( - "aria-describedby", - [this.input.getAttribute("aria-describedby"), statusId] - .filter(Boolean) - .join(" "), - ); - - this.appendChild(this.listbox); - this.appendChild(this.status); - - this.input.addEventListener("input", this.boundInput); - this.input.addEventListener("keydown", this.boundKeydown); - this.input.addEventListener("blur", this.boundBlur); - this.input.addEventListener("focus", this.boundFocus); - } - - disconnectedCallback() { - if (!this.input) { - return; - } - this.input.removeEventListener("input", this.boundInput); - this.input.removeEventListener("keydown", this.boundKeydown); - this.input.removeEventListener("blur", this.boundBlur); - this.input.removeEventListener("focus", this.boundFocus); - } - - handleInput() { - this.scheduleSearch(); - } - - handleFocus() { - if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) { - this.scheduleSearch(); - } - } - - handleBlur() { - window.setTimeout(() => this.close(), 150); - } - - handleKeydown(ev) { - if (ev.key === "Escape") { - if (!this.listbox.hidden) { - ev.preventDefault(); - this.close(); - } - return; - } - if (ev.key === "ArrowDown") { - ev.preventDefault(); - if (this.listbox.hidden) { - this.scheduleSearch(); - } else { - this.setActiveIndex(this.activeIndex + 1); - } - return; - } - if (ev.key === "ArrowUp") { - ev.preventDefault(); - if (!this.listbox.hidden) { - this.setActiveIndex(this.activeIndex - 1); - } - return; - } - if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) { - ev.preventDefault(); - this.chooseIndex(this.activeIndex); - } - } - - scheduleSearch() { - window.clearTimeout(this.searchTimer); - this.searchTimer = window.setTimeout(() => this.search(), 150); - } - - async search() { - var query = this.input.value.trim(); - var initial = !query && this.hasAttribute("suggest-on-focus"); - if (!query && !initial) { - this.close(); - this.status.textContent = ""; - return; - } - var src = this.getAttribute("src"); - if (!src) { - return; - } - - var url = new URL(src, location.href); - url.searchParams.set("q", query); - if (initial) { - url.searchParams.set("_initial", "1"); - } else { - url.searchParams.delete("_initial"); - } - var fetchId = this.fetchId + 1; - this.fetchId = fetchId; - this.status.textContent = "Searching..."; - - try { - var response = await fetch(url.toString(), { - headers: { - Accept: "application/json", - }, - }); - if (!response.ok) { - throw new Error("HTTP " + response.status); - } - var data = await response.json(); - if (fetchId !== this.fetchId) { - return; - } - this.results = (data && data.rows) || []; - this.render(); - } catch (_error) { - if (fetchId !== this.fetchId) { - return; - } - this.results = []; - this.close(); - this.status.textContent = "Could not load suggestions"; - } - } - - render() { - this.listbox.textContent = ""; - this.activeIndex = -1; - if (!this.results.length) { - this.close(); - this.status.textContent = "No matches"; - return; - } - - this.results.forEach((row, index) => { - var option = document.createElement("div"); - option.className = "datasette-autocomplete-option"; - option.id = this.input.id + "-option-" + index; - option.setAttribute("role", "option"); - option.setAttribute("aria-selected", "false"); - option.dataset.index = String(index); - option.dataset.value = autocompleteValueFromRow(row); - option.textContent = autocompleteLabelFromRow(row); - option.addEventListener("mousedown", (ev) => { - ev.preventDefault(); - this.chooseIndex(index); - }); - this.listbox.appendChild(option); - }); - - this.listbox.hidden = false; - this.input.setAttribute("aria-expanded", "true"); - this.status.textContent = - this.results.length + (this.results.length === 1 ? " match" : " matches"); - this.positionListbox(); - this.setActiveIndex(0); - } - - positionListbox() { - if (!this.input || !this.listbox || this.listbox.hidden) { - return; - } - - var gap = 3; - var margin = 8; - var inputRect = this.input.getBoundingClientRect(); - this.listbox.style.maxHeight = ""; - var defaultMaxHeight = parseFloat( - window.getComputedStyle(this.listbox).maxHeight, - ); - if (!Number.isFinite(defaultMaxHeight)) { - defaultMaxHeight = 256; - } - var scrollHeight = Math.ceil(this.listbox.scrollHeight); - var desiredHeight = Math.min(scrollHeight, defaultMaxHeight); - var availableBelow = Math.max( - 0, - (window.innerHeight || document.documentElement.clientHeight) - - inputRect.bottom - - gap - - margin, - ); - - this.listbox.style.left = inputRect.left + "px"; - this.listbox.style.top = inputRect.bottom + gap + "px"; - this.listbox.style.width = inputRect.width + "px"; - if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) { - this.listbox.style.maxHeight = "none"; - } else { - this.listbox.style.maxHeight = - Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) + - "px"; - } - window.addEventListener("resize", this.boundPositionListbox); - document.addEventListener("scroll", this.boundPositionListbox, true); - } - - setActiveIndex(index) { - var options = this.listbox.querySelectorAll("[role='option']"); - if (!options.length) { - this.activeIndex = -1; - this.input.removeAttribute("aria-activedescendant"); - return; - } - if (index < 0) { - index = options.length - 1; - } - if (index >= options.length) { - index = 0; - } - options.forEach((option, optionIndex) => { - option.setAttribute( - "aria-selected", - optionIndex === index ? "true" : "false", - ); - }); - this.activeIndex = index; - this.input.setAttribute("aria-activedescendant", options[index].id); - } - - chooseIndex(index) { - var row = this.results[index]; - if (!row) { - return; - } - var value = autocompleteValueFromRow(row); - var label = autocompleteLabelFromRow(row); - this.input.value = value; - this.input.dispatchEvent(new Event("change", { bubbles: true })); - this.close(); - this.status.textContent = "Selected " + label; - this.dispatchEvent( - new CustomEvent("datasette-autocomplete-select", { - bubbles: true, - detail: { - row: row, - value: value, - label: label, - }, - }), - ); - } - - close() { - if (this.listbox) { - this.listbox.hidden = true; - this.listbox.textContent = ""; - this.listbox.style.left = ""; - this.listbox.style.maxHeight = ""; - this.listbox.style.top = ""; - this.listbox.style.width = ""; - } - if (this.input) { - this.input.setAttribute("aria-expanded", "false"); - this.input.removeAttribute("aria-activedescendant"); - } - window.removeEventListener("resize", this.boundPositionListbox); - document.removeEventListener("scroll", this.boundPositionListbox, true); - this.activeIndex = -1; - } - } - - customElements.define("datasette-autocomplete", DatasetteAutocomplete); -})(); diff --git a/datasette/static/column-chooser.js b/datasette/static/column-chooser.js index 198641f3..133e7cb0 100644 --- a/datasette/static/column-chooser.js +++ b/datasette/static/column-chooser.js @@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement { diff --git a/datasette/templates/_facet_results.html b/datasette/templates/_facet_results.html index 570bb37e..034e9678 100644 --- a/datasette/templates/_facet_results.html +++ b/datasette/templates/_facet_results.html @@ -12,9 +12,9 @@
    {% for facet_value in facet_info.results %} {% if not facet_value.selected %} -
  • {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
  • +
  • {{ (facet_value.label | string()) or "-" }} {{ "{:,}".format(facet_value.count) }}
  • {% else %} -
  • {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }}
  • +
  • {{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }}
  • {% endif %} {% endfor %} {% if facet_info.truncated %} diff --git a/datasette/templates/_query_form_styles.html b/datasette/templates/_query_form_styles.html deleted file mode 100644 index cf2dd42c..00000000 --- a/datasette/templates/_query_form_styles.html +++ /dev/null @@ -1,138 +0,0 @@ - diff --git a/datasette/templates/_query_results.html b/datasette/templates/_query_results.html deleted file mode 100644 index 5e1e2f72..00000000 --- a/datasette/templates/_query_results.html +++ /dev/null @@ -1,20 +0,0 @@ -{% if display_rows %} -
- - - {% for column in columns %}{% endfor %} - - - - {% for row in display_rows %} - - {% for column, td in zip(columns, row) %} - - {% endfor %} - - {% endfor %} - -
{{ column }}
{{ td }}
-{% elif show_zero_results %} -

0 results

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

Parameters

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

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

- {% endfor %} - {% endif %} -
diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index 171b6442..f47a325f 100644 --- a/datasette/templates/_table.html +++ b/datasette/templates/_table.html @@ -22,7 +22,7 @@ {% for row in display_rows %} - + {% for cell in row %} {{ cell.value }} {% endfor %} diff --git a/datasette/templates/api_explorer.html b/datasette/templates/api_explorer.html index 58e2a88c..dc393c20 100644 --- a/datasette/templates/api_explorer.html +++ b/datasette/templates/api_explorer.html @@ -19,7 +19,7 @@

GET -
+
@@ -29,7 +29,7 @@
POST - +
diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 82ab48dd..e1767deb 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -71,6 +71,6 @@ {% if select_templates %}{% endif %} - + diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 371f6a22..42b4ca0b 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -5,7 +5,6 @@ {% block extra_head %} {{- super() -}} {% include "_codemirror.html" %} -{% include "_sql_parameter_styles.html" %} {% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} @@ -26,13 +25,9 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if allow_execute_sql %} - +

Custom SQL query

-

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

@@ -58,9 +53,6 @@

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

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

    - {% endif %} {% endif %} {% if tables %} @@ -95,11 +87,5 @@ {% endif %} {% include "_codemirror_foot.html" %} -{% include "_sql_parameter_scripts.html" %} - {% endblock %} diff --git a/datasette/templates/debug_autocomplete.html b/datasette/templates/debug_autocomplete.html deleted file mode 100644 index 84dbc14f..00000000 --- a/datasette/templates/debug_autocomplete.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Debug autocomplete{% endblock %} - -{% block extra_head %} -{{ super() }} - -{% endblock %} - -{% block content %} -

    Debug autocomplete

    - - -

    - - -

    -

    - - -

    -

    - - -{% if error %} -

    {{ error }}

    -{% elif autocomplete_url %} -

    {{ database_name }} / {{ table_name }}

    - {% if label_column %} -

    Label column: {{ label_column }}

    - {% else %} -

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

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

    Selected row

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

    Suggested tables

    - {% if suggestions %} -

    Showing up to five tables with a detected label column.

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

    No tables with detected label columns found.

    - {% endif %} -

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

    -{% endif %} - -{% endblock %} diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html deleted file mode 100644 index 949850ed..00000000 --- a/datasette/templates/execute_write.html +++ /dev/null @@ -1,299 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Write to this database{% endblock %} - -{% block extra_head %} -{{- super() -}} -{% include "_codemirror.html" %} - -{% include "_execute_write_analysis_styles.html" %} -{% include "_sql_parameter_styles.html" %} -{% endblock %} - -{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %} - -{% block crumbs %} -{{ crumbs.nav(request=request, database=database) }} -{% endblock %} - -{% block content %} - -

    Write to this database

    - -

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

    - -{% if execution_message %} -

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

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

    Returned rows

    - {% if execute_write_truncated %} -

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

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

    - - - {% for operation in write_template_operations %} - - {% endfor %} -

    -
    -
    - {% else %} -

    There are no tables that you can currently edit.

    - {% endif %} - -

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

    Query operations

    - {% if analysis_error %} -

    {{ analysis_error }}

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

    Analysis will show each affected table and required permission.

    - {% endif %} -
    - -

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

    -
    - - - -{% include "_codemirror_foot.html" %} -{% include "_sql_parameter_scripts.html" %} -{% include "_execute_write_analysis_scripts.html" %} - - - -{% if write_template_tables %} - -{% endif %} - -{% endblock %} diff --git a/datasette/templates/patterns.html b/datasette/templates/patterns.html index a46478a7..7770f7d4 100644 --- a/datasette/templates/patterns.html +++ b/datasette/templates/patterns.html @@ -11,7 +11,7 @@