mirror of
https://github.com/simonw/datasette.git
synced 2026-07-09 09:04:42 +02:00
Support BLOB values in row edit UI
This commit is contained in:
parent
b476218edb
commit
19dde1c860
7 changed files with 754 additions and 26 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <binary_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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue