diff --git a/datasette/static/app.css b/datasette/static/app.css index ce800f61..5bfd0aca 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1700,6 +1700,118 @@ textarea.row-edit-input { background: var(--paper); } +.row-edit-binary-control { + display: grid; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 10px; + background: #fff; +} + +.row-edit-binary-control:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.row-edit-binary-preview[hidden] { + display: none; +} + +.row-edit-binary-preview img { + display: block; + max-width: min(240px, 100%); + max-height: 180px; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper); +} + +.row-edit-binary-status { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.row-edit-binary-size { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.86rem; +} + +.row-edit-binary-name { + color: var(--muted); + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.row-edit-binary-name[hidden] { + display: none; +} + +.row-edit-binary-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.row-edit-binary-file-button, +.row-edit-binary-clear { + appearance: none; + border: 1px solid var(--rule); + border-radius: 4px; + background: #fff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + line-height: 1.2; + padding: 6px 8px; +} + +.row-edit-binary-file-button:hover, +.row-edit-binary-file-button:focus-within, +.row-edit-binary-clear:hover, +.row-edit-binary-clear:focus { + background: #f8fafc; +} + +.row-edit-binary-file-button:focus-within, +.row-edit-binary-clear:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.row-edit-binary-file-button input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + overflow: hidden; +} + +.row-edit-binary-clear[hidden] { + display: none; +} + +.row-edit-binary-drop-target { + border: 1px dashed var(--rule); + border-radius: 4px; + padding: 7px 8px; + color: var(--muted); + font-size: 0.78rem; +} + +.row-edit-binary-dragover .row-edit-binary-drop-target { + border-color: var(--accent); + background: var(--paper); + color: var(--ink); +} + .row-edit-default { display: grid; grid-template-columns: minmax(0, 1fr) 7.25rem; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 9f4f89b9..b2891b5f 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -2,6 +2,7 @@ var ROW_DELETE_DIALOG_ID = "row-delete-dialog"; var rowDeleteDialogState = null; var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; var rowEditDialogState = null; +var ROW_EDIT_BINARY_IMAGE_PREVIEW_MAX_BYTES = 10 * 1024 * 1024; var TABLE_CREATE_DIALOG_ID = "table-create-dialog"; var tableCreateDialogState = null; var TABLE_ALTER_DIALOG_ID = "table-alter-dialog"; @@ -3598,7 +3599,7 @@ function rowJsonUrl(row) { return ""; } url.pathname = url.pathname + ".json"; - url.searchParams.set("_extra", "columns,column_types"); + url.searchParams.set("_extra", "columns,column_types,column_details"); return url.toString(); } @@ -3878,10 +3879,155 @@ function initRowDeleteActions(manager) { }); } +function isBase64JsonValue(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + var keys = Object.keys(value); + return ( + keys.length === 2 && + Object.prototype.hasOwnProperty.call(value, "$base64") && + Object.prototype.hasOwnProperty.call(value, "encoded") && + value.$base64 === true && + typeof value.encoded === "string" + ); +} + +function shouldUseBinaryControl(value, options) { + options = options || {}; + var sqliteType = (options.sqliteType || "").toLowerCase(); + return ( + isBase64JsonValue(value) || + sqliteType === "blob" || + options.valueKind === "binary" + ); +} + +function binaryEncodedValue(value) { + return isBase64JsonValue(value) ? value.encoded : ""; +} + +function binaryByteLengthFromBase64(encoded) { + encoded = (encoded || "").replace(/\s/g, ""); + if (!encoded) { + return 0; + } + var padding = 0; + if (encoded.slice(-2) === "==") { + padding = 2; + } else if (encoded.slice(-1) === "=") { + padding = 1; + } + return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding); +} + +function rowEditBinaryValue(encoded) { + return { + $base64: true, + encoded: encoded || "", + }; +} + +function formatRowEditBinarySize(byteLength) { + var number = byteLength.toLocaleString(); + return "Binary: " + number + " byte" + (byteLength === 1 ? "" : "s"); +} + +function base64ToUint8Array(encoded) { + var binary = window.atob(encoded || ""); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function uint8ArrayToBase64(bytes) { + var chunks = []; + var chunkSize = 0x8000; + for (var i = 0; i < bytes.length; i += chunkSize) { + chunks.push( + String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)), + ); + } + return window.btoa(chunks.join("")); +} + +function rowEditBinaryImageMimeType(bytes) { + if (!bytes || !bytes.length) { + return null; + } + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return "image/png"; + } + if ( + bytes.length >= 3 && + bytes[0] === 0xff && + bytes[1] === 0xd8 && + bytes[2] === 0xff + ) { + return "image/jpeg"; + } + if ( + bytes.length >= 6 && + bytes[0] === 0x47 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x38 && + (bytes[4] === 0x37 || bytes[4] === 0x39) && + bytes[5] === 0x61 + ) { + return "image/gif"; + } + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return "image/webp"; + } + if ( + bytes.length >= 12 && + bytes[4] === 0x66 && + bytes[5] === 0x74 && + bytes[6] === 0x79 && + bytes[7] === 0x70 && + bytes[8] === 0x61 && + bytes[9] === 0x76 && + bytes[10] === 0x69 && + (bytes[11] === 0x66 || bytes[11] === 0x73) + ) { + return "image/avif"; + } + if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) { + return "image/bmp"; + } + return null; +} + function valueToEditText(value) { if (value === null || typeof value === "undefined") { return ""; } + if (isBase64JsonValue(value)) { + return value.encoded; + } if (typeof value === "object") { return JSON.stringify(value, null, 2); } @@ -3892,6 +4038,9 @@ function shouldUseTextarea(value, columnType) { if (columnType && columnType.type === "textarea") { return true; } + if (isBase64JsonValue(value)) { + return false; + } if (value && typeof value === "object") { return true; } @@ -3900,6 +4049,9 @@ function shouldUseTextarea(value, columnType) { } function rowEditValueKind(value) { + if (isBase64JsonValue(value)) { + return "binary"; + } if (value === null || typeof value === "undefined") { return "null"; } @@ -3923,6 +4075,263 @@ function rowEditControlElement(control, autocompleteUrl) { return autocomplete; } +function revokeRowEditBinaryPreview(wrapper) { + if (wrapper && wrapper._rowEditBinaryPreviewUrl) { + URL.revokeObjectURL(wrapper._rowEditBinaryPreviewUrl); + wrapper._rowEditBinaryPreviewUrl = null; + } +} + +function updateRowEditBinaryPreview(wrapper, encoded, byteLength) { + var preview = wrapper.querySelector(".row-edit-binary-preview"); + if (!preview) { + return; + } + revokeRowEditBinaryPreview(wrapper); + preview.hidden = true; + preview.textContent = ""; + if ( + !encoded || + byteLength >= ROW_EDIT_BINARY_IMAGE_PREVIEW_MAX_BYTES || + !window.atob || + !window.Blob || + !window.URL || + !URL.createObjectURL + ) { + return; + } + + var bytes; + try { + bytes = base64ToUint8Array(encoded); + } catch (_error) { + return; + } + var mimeType = rowEditBinaryImageMimeType(bytes); + if (!mimeType) { + return; + } + + var image = document.createElement("img"); + image.alt = ""; + var objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType })); + wrapper._rowEditBinaryPreviewUrl = objectUrl; + image.src = objectUrl; + preview.appendChild(image); + + var showPreview = function () { + if (wrapper._rowEditBinaryPreviewUrl === objectUrl) { + preview.hidden = false; + } + }; + var hidePreview = function () { + if (wrapper._rowEditBinaryPreviewUrl === objectUrl) { + revokeRowEditBinaryPreview(wrapper); + preview.hidden = true; + preview.textContent = ""; + } + }; + if (image.decode) { + image.decode().then(showPreview).catch(hidePreview); + } else { + image.onload = showPreview; + image.onerror = hidePreview; + } +} + +function updateRowEditBinaryDisplay(wrapper, control, fileName) { + var kind = rowEditControlValueKind(control); + var size = wrapper.querySelector(".row-edit-binary-size"); + var name = wrapper.querySelector(".row-edit-binary-name"); + var clear = wrapper.querySelector(".row-edit-binary-clear"); + var byteLength = + kind === "binary" ? binaryByteLengthFromBase64(control.value) : null; + + if (size) { + size.textContent = + kind === "binary" ? formatRowEditBinarySize(byteLength) : "No binary data"; + } + if (name) { + name.textContent = fileName || ""; + name.hidden = !fileName; + } + if (clear) { + clear.hidden = control.dataset.notNull === "1" || kind !== "binary"; + } + if (kind === "binary") { + updateRowEditBinaryPreview(wrapper, control.value, byteLength); + } else { + updateRowEditBinaryPreview(wrapper, "", 0); + } +} + +function setRowEditBinaryControlValue(control, encoded, fileName) { + control.value = encoded || ""; + control.dataset.currentValueKind = "binary"; + updateRowEditBinaryDisplay(control._rowEditBinaryWrapper, control, fileName); + control.dispatchEvent(new Event("input", { bubbles: true })); +} + +function clearRowEditBinaryControlValue(control) { + control.value = ""; + control.dataset.currentValueKind = "null"; + updateRowEditBinaryDisplay(control._rowEditBinaryWrapper, control, ""); + control.dispatchEvent(new Event("input", { bubbles: true })); +} + +function readRowEditBinaryFile(file) { + return new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onload = function () { + try { + var bytes = new Uint8Array(reader.result || new ArrayBuffer(0)); + resolve({ + encoded: uint8ArrayToBase64(bytes), + name: file && file.name ? file.name : "", + }); + } catch (error) { + reject(error); + } + }; + reader.onerror = function () { + reject(reader.error || new Error("Could not read file")); + }; + reader.readAsArrayBuffer(file); + }); +} + +function rowEditBinaryValueFromText(text) { + var encoder = new TextEncoder(); + var bytes = encoder.encode(text || ""); + return { + encoded: uint8ArrayToBase64(bytes), + name: "Pasted text", + }; +} + +function rowEditBinaryFirstClipboardFile(clipboardData) { + if (!clipboardData) { + return null; + } + if (clipboardData.files && clipboardData.files.length) { + return clipboardData.files[0]; + } + var items = clipboardData.items || []; + for (var i = 0; i < items.length; i += 1) { + if (items[i].kind === "file") { + return items[i].getAsFile(); + } + } + return null; +} + +function handleRowEditBinaryFile(control, file) { + if (!file) { + return; + } + readRowEditBinaryFile(file) + .then(function (value) { + setRowEditBinaryControlValue(control, value.encoded, value.name); + }) + .catch(function (error) { + console.error("Could not read binary file", error); + }); +} + +function createRowEditBinaryControlElement(control, value, options, labelId) { + var wrapper = document.createElement("div"); + wrapper.className = "row-edit-binary-control"; + wrapper.dataset.column = control.name; + wrapper.setAttribute("role", "group"); + wrapper.setAttribute("tabindex", "0"); + wrapper.setAttribute("aria-labelledby", labelId); + + var preview = document.createElement("div"); + preview.className = "row-edit-binary-preview"; + preview.hidden = true; + + var status = document.createElement("div"); + status.className = "row-edit-binary-status"; + var size = document.createElement("span"); + size.className = "row-edit-binary-size"; + var name = document.createElement("span"); + name.className = "row-edit-binary-name"; + name.hidden = true; + status.appendChild(size); + status.appendChild(name); + + var actions = document.createElement("div"); + actions.className = "row-edit-binary-actions"; + var fileLabel = document.createElement("label"); + fileLabel.className = "row-edit-binary-file-button"; + fileLabel.textContent = "Attach file"; + var fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.setAttribute("aria-label", "Attach file for " + control.name); + fileInput.addEventListener("change", function () { + if (fileInput.files && fileInput.files.length) { + handleRowEditBinaryFile(control, fileInput.files[0]); + } + }); + fileLabel.appendChild(fileInput); + actions.appendChild(fileLabel); + + var clearButton = document.createElement("button"); + clearButton.type = "button"; + clearButton.className = "row-edit-binary-clear"; + clearButton.textContent = "Set NULL"; + clearButton.addEventListener("click", function () { + clearRowEditBinaryControlValue(control); + wrapper.focus(); + }); + actions.appendChild(clearButton); + + var dropTarget = document.createElement("div"); + dropTarget.className = "row-edit-binary-drop-target"; + dropTarget.textContent = "Drop or paste file contents"; + ["dragenter", "dragover"].forEach(function (eventName) { + wrapper.addEventListener(eventName, function (ev) { + ev.preventDefault(); + wrapper.classList.add("row-edit-binary-dragover"); + }); + }); + ["dragleave", "drop"].forEach(function (eventName) { + wrapper.addEventListener(eventName, function () { + wrapper.classList.remove("row-edit-binary-dragover"); + }); + }); + wrapper.addEventListener("drop", function (ev) { + ev.preventDefault(); + var file = ev.dataTransfer && ev.dataTransfer.files[0]; + handleRowEditBinaryFile(control, file); + }); + wrapper.addEventListener("paste", function (ev) { + var file = rowEditBinaryFirstClipboardFile(ev.clipboardData); + if (file) { + ev.preventDefault(); + handleRowEditBinaryFile(control, file); + return; + } + var text = ev.clipboardData && ev.clipboardData.getData("text"); + if (text) { + ev.preventDefault(); + var pasted = rowEditBinaryValueFromText(text); + setRowEditBinaryControlValue(control, pasted.encoded, pasted.name); + } + }); + + control.type = "hidden"; + control._rowEditBinaryWrapper = wrapper; + wrapper.appendChild(control); + wrapper.appendChild(preview); + wrapper.appendChild(status); + wrapper.appendChild(actions); + wrapper.appendChild(dropTarget); + + updateRowEditBinaryDisplay(wrapper, control, ""); + return wrapper; +} + function columnTypeForContext(columnType) { if (!columnType) { return null; @@ -4186,6 +4595,11 @@ function focusFirstRowEditControl(state, options) { if (focusRowEditPluginControl(field)) { return true; } + var binaryControl = field.querySelector(".row-edit-binary-control"); + if (binaryControl) { + binaryControl.focus(); + return true; + } control.focus(); return true; } @@ -4209,6 +4623,11 @@ function destroyRowEditFields(state) { } } }); + state.fields + .querySelectorAll(".row-edit-binary-control") + .forEach(function (binaryControl) { + revokeRowEditBinaryPreview(binaryControl); + }); state.fields.innerHTML = ""; } @@ -4235,34 +4654,50 @@ function createRowEditField(column, value, isPk, columnType, index, options) { controlWrap.className = "row-edit-control-wrap"; var context = columnFormControlContext(column, isPk, columnType, options); - var pluginControl = makeColumnField(options.manager, context); + var isBinaryField = shouldUseBinaryControl(value, options); + var pluginControl = isBinaryField + ? null + : makeColumnField(options.manager, context); var useTextarea = - (pluginControl && pluginControl.useTextarea === true) || - shouldUseTextarea(value, columnType); + !isBinaryField && + ((pluginControl && pluginControl.useTextarea === true) || + shouldUseTextarea(value, columnType)); var control = useTextarea ? document.createElement("textarea") : document.createElement("input"); + var initialValue = isBinaryField + ? binaryEncodedValue(value) + : valueToEditText(value); + var initialValueKind = options.valueKind || rowEditValueKind(value); + if (isBinaryField) { + initialValueKind = isBase64JsonValue(value) ? "binary" : "null"; + } control.className = "row-edit-input"; control.id = fieldId; control.name = column; - control.value = valueToEditText(value); + control.value = initialValue; control.setAttribute("aria-describedby", metaId); - control.dataset.initialValue = valueToEditText(value); - control.dataset.initialValueKind = - options.valueKind || rowEditValueKind(value); + control.dataset.initialValue = initialValue; + control.dataset.initialValueKind = initialValueKind; control.dataset.primaryKey = isPk ? "1" : "0"; control.dataset.currentValueKind = control.dataset.initialValueKind; + if (isBinaryField) { + control.dataset.binaryField = "1"; + control.dataset.notNull = options.notnull ? "1" : "0"; + } if (hasDefaultExpression) { control.dataset.useSqliteDefault = useSqliteDefault ? "1" : "0"; } if (useSqliteDefault) { control.disabled = true; } - if (options.omitIfBlank) { + if (options.omitIfBlank || (isBinaryField && options.mode === "insert")) { control.dataset.omitIfBlank = "1"; } - if (control.nodeName === "TEXTAREA") { + if (isBinaryField) { + control.type = "hidden"; + } else if (control.nodeName === "TEXTAREA") { control.rows = Math.min(8, Math.max(3, control.value.split("\n").length)); } else { control.type = "text"; @@ -4336,9 +4771,11 @@ function createRowEditField(column, value, isPk, columnType, index, options) { field._datasetteColumnFormField = fieldApi; var pluginControlElement = renderColumnField(pluginControl, fieldApi); var controlElement = + (isBinaryField && + createRowEditBinaryControlElement(control, value, options, labelId)) || pluginControlElement || rowEditControlElement(control, options.autocompleteUrl); - if (options.autocompleteUrl && !pluginControlElement) { + if (options.autocompleteUrl && !pluginControlElement && !isBinaryField) { control.addEventListener("input", function () { setForeignKeyMetaLink(meta, options.autocompleteUrl, null); }); @@ -4460,6 +4897,9 @@ function setRowEditDialogSaving(state, isSaving) { function valueFromRowEditControl(control) { var value = control.value; + if (rowEditControlValueKind(control) === "binary") { + return rowEditBinaryValue(value); + } return valueFromRowEditText( control.name, value, @@ -4470,6 +4910,9 @@ function valueFromRowEditControl(control) { function valueFromRowEditText(name, value, initialValueKind) { var trimmed = value.trim(); + if (initialValueKind === "binary") { + return rowEditBinaryValue(value); + } if (initialValueKind === "null" && value === "") { return null; } @@ -4597,7 +5040,11 @@ function collectRowFormValues(state) { if (control.dataset.useSqliteDefault === "1") { return; } - if (control.dataset.omitIfBlank === "1" && control.value === "") { + if ( + control.dataset.omitIfBlank === "1" && + control.value === "" && + rowEditControlValueKind(control) !== "binary" + ) { return; } if ( @@ -4919,9 +5366,11 @@ function renderRowEditFields(state, data) { var columns = data.columns || (row ? Object.keys(row) : []); var primaryKeys = data.primary_keys || []; var columnTypes = data.column_types || {}; + var columnDetails = data.column_details || {}; destroyRowEditFields(state); columns.forEach(function (column, index) { + var columnDetail = columnDetails[column] || {}; state.fields.appendChild( createRowEditField( column, @@ -4935,7 +5384,9 @@ function renderRowEditFields(state, data) { form: state.form, manager: state.manager, mode: state.mode, + notnull: columnDetail.notnull, primaryKeyReadonly: true, + sqliteType: columnDetail.sqlite_type, }, ), ); diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 468828c5..dfd5440c 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -237,10 +237,8 @@ class CustomJSONEncoder(json.JSONEncoder): - ``sqlite3.Row`` becomes a tuple - ``sqlite3.Cursor`` becomes a list - If a binary blob can be decoded as UTF-8, the encoder returns it as text. - - If it can't (for example, images), it is encoded as an object, with the actual - data base64-encoded, like so: :: + Binary blobs are encoded as an object, with the actual data base64-encoded, + like so: :: { "$base64": True, @@ -256,14 +254,10 @@ class CustomJSONEncoder(json.JSONEncoder): if isinstance(obj, sqlite3.Cursor): return list(obj) if isinstance(obj, bytes): - # Does it encode to utf8? - try: - return obj.decode("utf8") - except UnicodeDecodeError: - return { - "$base64": True, - "encoded": base64.b64encode(obj).decode("latin1"), - } + return { + "$base64": True, + "encoded": base64.b64encode(obj).decode("latin1"), + } return json.JSONEncoder.default(self, obj) diff --git a/docs/binary_data.rst b/docs/binary_data.rst index fc972f98..d41e36be 100644 --- a/docs/binary_data.rst +++ b/docs/binary_data.rst @@ -17,7 +17,7 @@ Datasette includes special handling for these binary values. The Datasette inter Binary values in JSON --------------------- -Binary data is represented in ``.json`` exports using Base64 encoding. +Binary data is represented in ``.json`` exports using Base64 encoding. Datasette uses this representation for every ``BLOB`` value, including binary values that could also be decoded as UTF-8 text. https://latest.datasette.io/fixtures/binary_data.json?_shape=array diff --git a/docs/changelog.rst b/docs/changelog.rst index 7f3fd2c4..0cf1ec0b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,8 @@ Changelog 1.0a36 (in development) ----------------------- +- Datasette's JSON APIs now consistently encode every ``BLOB`` value using the documented :ref:`binary value JSON format `, even when the bytes could be decoded as UTF-8 text. +- The insert and edit row dialogs now provide a dedicated control for ``BLOB`` values. Existing binary values are shown by byte size, image values under 10MB are previewed as thumbnails, and replacements can be attached, dropped or pasted into the control. - The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata. .. _v1_0_a35: diff --git a/tests/test_playwright.py b/tests/test_playwright.py index ee396de5..63e06b29 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1,3 +1,4 @@ +import base64 import json import socket import subprocess @@ -10,6 +11,10 @@ import pytest from datasette.fixtures import write_fixture_database from datasette.utils.sqlite import sqlite3 +PNG_1X1_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" +) + def find_free_port(): with socket.socket() as sock: @@ -102,6 +107,11 @@ def write_playwright_database(db_path): id integer primary key, created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)) ); + create table binary_files ( + id integer primary key, + name text not null, + data blob + ); insert into projects (title, metadata, logo, notes, score) values ( 'Build Datasette', @@ -111,6 +121,19 @@ def write_playwright_database(db_path): 5 ); """) + conn.execute( + "insert into binary_files (name, data) values (?, ?)", + ("Raw bytes", b"\x00\x01\x02\x03"), + ) + conn.execute( + "insert into binary_files (name, data) values (?, ?)", + ("PNG image", PNG_1X1_BYTES), + ) + conn.execute( + "insert into binary_files (name, data) values (?, ?)", + ("Null bytes", None), + ) + conn.commit() finally: conn.close() @@ -145,6 +168,14 @@ def write_playwright_config(config_path): "alter-table": True, }, }, + "binary_files": { + "label_column": "name", + "permissions": { + "insert-row": True, + "update-row": True, + "delete-row": True, + }, + }, }, }, }, @@ -278,6 +309,15 @@ def project_row(datasette_server, pk): return rows[0] +def binary_file_blob(datasette_server, pk): + response = httpx.get( + f"{datasette_server}data/binary_files/{pk}.blob", + params={"_blob_column": "data"}, + ) + response.raise_for_status() + return response.content + + def open_jump_menu(page): page.keyboard.press("/") page.locator("navigation-search .search-input").wait_for() @@ -877,6 +917,131 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): assert data["notes"] == "Edited from Playwright" +@pytest.mark.playwright +def test_edit_row_binary_control_shows_size_and_image_preview(page, datasette_server): + page.goto(f"{datasette_server}data/binary_files") + page.locator('tr[data-row="1"] button[data-row-action="edit"]').click() + + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + raw_control = dialog.locator('.row-edit-binary-control[data-column="data"]') + raw_control.wait_for() + assert ( + raw_control.locator(".row-edit-binary-size").inner_text() == "Binary: 4 bytes" + ) + assert raw_control.locator(".row-edit-binary-preview img").count() == 0 + assert dialog.locator('textarea[name="data"]').count() == 0 + assert dialog.locator('input[type="hidden"][name="data"]').count() == 1 + + dialog.locator(".row-edit-cancel").click() + page.locator('tr[data-row="2"] button[data-row-action="edit"]').click() + + image_control = dialog.locator('.row-edit-binary-control[data-column="data"]') + image_control.wait_for() + assert ( + image_control.locator(".row-edit-binary-size").inner_text() + == f"Binary: {len(PNG_1X1_BYTES)} bytes" + ) + image = image_control.locator(".row-edit-binary-preview img") + image.wait_for() + assert image.get_attribute("src").startswith("blob:") + + +@pytest.mark.playwright +def test_edit_row_binary_control_replaces_blob_from_file(page, datasette_server): + replacement = b"Replacement \x00 bytes" + + page.goto(f"{datasette_server}data/binary_files") + page.locator('tr[data-row="1"] button[data-row-action="edit"]').click() + + dialog = page.locator("#row-edit-dialog") + binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]') + binary_control.wait_for() + binary_control.locator('input[type="file"]').set_input_files( + { + "name": "replacement.bin", + "mimeType": "application/octet-stream", + "buffer": replacement, + } + ) + assert ( + binary_control.locator(".row-edit-binary-size").inner_text() + == f"Binary: {len(replacement)} bytes" + ) + assert "replacement.bin" in binary_control.inner_text() + + dialog.locator(".row-edit-save").click() + page.locator(".row-mutation-status", has_text="Updated row 1").wait_for() + assert binary_file_blob(datasette_server, 1) == replacement + + +@pytest.mark.playwright +def test_edit_row_binary_control_handles_null_blob(page, datasette_server): + replacement = b"From NULL" + + page.goto(f"{datasette_server}data/binary_files") + page.locator('tr[data-row="3"] button[data-row-action="edit"]').click() + + dialog = page.locator("#row-edit-dialog") + binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]') + binary_control.wait_for() + assert ( + binary_control.locator(".row-edit-binary-size").inner_text() == "No binary data" + ) + binary_control.locator('input[type="file"]').set_input_files( + { + "name": "from-null.bin", + "mimeType": "application/octet-stream", + "buffer": replacement, + } + ) + assert ( + binary_control.locator(".row-edit-binary-size").inner_text() + == f"Binary: {len(replacement)} bytes" + ) + + dialog.locator(".row-edit-save").click() + page.locator(".row-mutation-status", has_text="Updated row 3").wait_for() + assert binary_file_blob(datasette_server, 3) == replacement + + +@pytest.mark.playwright +def test_insert_row_binary_control_accepts_pasted_file(page, datasette_server): + pasted = b"Pasted \x00 bytes" + + page.goto(f"{datasette_server}data/binary_files") + page.locator('button[data-table-action="insert-row"]').click() + + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + dialog.locator('input[name="name"]').fill("Pasted bytes") + binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]') + binary_control.wait_for() + binary_control.evaluate( + """(node, bytes) => { + const transfer = new DataTransfer(); + transfer.items.add( + new File([new Uint8Array(bytes)], "pasted.bin", { + type: "application/octet-stream" + }) + ); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { value: transfer }); + node.dispatchEvent(event); + }""", + list(pasted), + ) + assert ( + binary_control.locator(".row-edit-binary-size").inner_text() + == f"Binary: {len(pasted)} bytes" + ) + assert "pasted.bin" in binary_control.inner_text() + + dialog.locator(".row-edit-save").click() + page.locator(".row-mutation-status", has_text="Inserted row 4").wait_for() + assert binary_file_blob(datasette_server, 4) == pasted + + @pytest.mark.playwright def test_delete_row_flow_removes_row(page, datasette_server): page.goto(f"{datasette_server}data/projects") diff --git a/tests/test_utils.py b/tests/test_utils.py index 38ebb51e..46dcd89d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -132,7 +132,11 @@ def test_path_from_row_pks(row, pks, expected_path): """ {"CategoryID": 1, "Description": "Soft drinks", "Picture": {"$base64": true, "encoded": "FRwCx60F/g=="}} """.strip(), - ) + ), + ( + {"message": b"hello"}, + '{"message": {"$base64": true, "encoded": "aGVsbG8="}}', + ), ], ) def test_custom_json_encoder(obj, expected):