Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable directory uploads with FileInput #6808

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 additions & 3 deletions panel/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from .state import State # noqa
from .trend import TrendIndicator # noqa
from .widgets import ( # noqa
Audio, Button, CheckboxButtonGroup, CustomSelect, FileDownload, Player,
Progress, RadioButtonGroup, SingleSelect, TextAreaInput, TooltipIcon,
Video, VideoStream,
Audio, Button, CheckboxButtonGroup, CustomSelect, FileDownload, FileInput,
Player, Progress, RadioButtonGroup, SingleSelect, TextAreaInput,
TooltipIcon, Video, VideoStream,
)
136 changes: 136 additions & 0 deletions panel/models/file_input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {InputWidget, InputWidgetView} from "@bokehjs/models/widgets/input_widget"
import type {StyleSheetLike} from "@bokehjs/core/dom"
import {input} from "@bokehjs/core/dom"
import {isString, is_nullish} from "@bokehjs/core/util/types"
import * as p from "@bokehjs/core/properties"
import * as inputs from "@bokehjs/styles/widgets/inputs.css"
import buttons_css from "@bokehjs/styles/buttons.css"

export class FileInputView extends InputWidgetView {
declare model: FileInput
declare input_el: HTMLInputElement

override stylesheets(): StyleSheetLike[] {
return [...super.stylesheets(), buttons_css]
}

protected _render_input(): HTMLElement {
const {multiple, disabled, directory, accept} = this.model
const accept_str = isString(accept) ? accept : accept.join(",")
return this.input_el = input({type: "file", class: inputs.input, multiple, accept: accept_str, disabled, webkitdirectory: directory})
}

override connect_signals(): void {
super.connect_signals()
const {_clear_input} = this.model.properties
this.on_change(_clear_input, this.clear_input)
}

override render(): void {
super.render()

this.input_el.addEventListener("change", async () => {
const {files} = this.input_el
if (files != null) {
await this.load_files(files)
}
})
}

async load_files(files: FileList): Promise<void> {
const values: string[] = []
const filenames: string[] = []
const mime_types: string[] = []
const {directory, multiple, accept} = this.model

for (const file of files) {
const data_url = await this._read_file(file)
const [, mime_type="",, value=""] = data_url.split(/[:;,]/, 4)

if (directory) {
const ext = file.name.split(".").pop()
if ((!is_nullish(accept) && isString(ext)) ? accept.includes(`.${ext}`) : true) {
filenames.push(file.webkitRelativePath)
values.push(value)
mime_types.push(mime_type)
}
} else {
filenames.push(file.name)
values.push(value)
mime_types.push(mime_type)
}
}

const [value, filename, mime_type] = (() =>{
if (directory || multiple) {
return [values, filenames, mime_types]
} else if (files.length != 0) {
return [values[0], filenames[0], mime_types[0]]
} else {
return ["", "", ""]
}
})()

this.model.setv({value, filename, mime_type})
}

protected _read_file(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const {result} = reader
if (result != null) {
resolve(result as string)
} else {
reject(reader.error ?? new Error(`unable to read '${file.name}'`))
}
}
reader.readAsDataURL(file)
})
}
protected clear_input(): void {
this.input_el.value = ""
this.model.setv({value: "", filename: "", mime_type: ""})
}

}

export namespace FileInput {
export type Attrs = p.AttrsOf<Props>
export type Props = InputWidget.Props & {
value: p.Property<string | string[]>
mime_type: p.Property<string | string[]>
filename: p.Property<string | string[]>
accept: p.Property<string | string[]>
multiple: p.Property<boolean>
directory: p.Property<boolean>
_clear_input: p.Property<number>
}
}

export interface FileInput extends FileInput.Attrs {}

export class FileInput extends InputWidget {
declare properties: FileInput.Props
declare __view_type__: FileInputView

constructor(attrs?: Partial<FileInput.Attrs>) {
super(attrs)
}

static override __module__ = "panel.models.widgets"

static {
this.prototype.default_view = FileInputView

this.define<FileInput.Props>(({Bool, Str, List, Or, Int}) => ({
value: [ Or(Str, List(Str)), p.unset, {readonly: true} ],
mime_type: [ Or(Str, List(Str)), p.unset, {readonly: true} ],
filename: [ Or(Str, List(Str)), p.unset, {readonly: true} ],
accept: [ Or(Str, List(Str)), "" ],
multiple: [ Bool, false ],
directory: [ Bool, false ],
_clear_input: [ Int, 0],
}))
}
}
1 change: 1 addition & 0 deletions panel/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {DeckGLPlot} from "./deckgl"
export {ECharts} from "./echarts"
export {Feed} from "./feed"
export {FileDownload} from "./file_download"
export {FileInput} from "./file_input"
export {HTML} from "./html"
export {IPyWidget} from "./ipywidget"
export {JSON} from "./json"
Expand Down
12 changes: 11 additions & 1 deletion panel/models/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from bokeh.models.ui.icons import Icon
from bokeh.models.widgets import (
Button as bkButton, CheckboxButtonGroup as bkCheckboxButtonGroup,
InputWidget, RadioButtonGroup as bkRadioButtonGroup, Select,
FileInput as bkFileInput, InputWidget,
RadioButtonGroup as bkRadioButtonGroup, Select,
TextAreaInput as BkTextAreaInput, Widget,
)

Expand Down Expand Up @@ -253,3 +254,12 @@ class RadioButtonGroup(bkRadioButtonGroup):
Delay (in milliseconds) to display the tooltip after the cursor has
hovered over the Button, default is 500ms.
""")

class FileInput(bkFileInput):
directory = Bool(False, help="""
Whether to allow selection of directories.
""")

_clear_input = Int(0, help="""
A private property to clear the file input.
""")
20 changes: 17 additions & 3 deletions panel/widgets/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@
from bokeh.models.widgets import (
Checkbox as _BkCheckbox, ColorPicker as _BkColorPicker,
DatePicker as _BkDatePicker, DateRangePicker as _BkDateRangePicker,
Div as _BkDiv, FileInput as _BkFileInput, NumericInput as _BkNumericInput,
Div as _BkDiv, NumericInput as _BkNumericInput,
PasswordInput as _BkPasswordInput, Spinner as _BkSpinner,
Switch as _BkSwitch, TextInput as _BkTextInput,
)

from ..config import config
from ..layout import Column, Panel
from ..models import (
DatetimePicker as _bkDatetimePicker, TextAreaInput as _bkTextAreaInput,
DatetimePicker as _bkDatetimePicker, FileInput as _BkFileInput,
TextAreaInput as _bkTextAreaInput,
)
from ..util import param_reprs, try_datetime64_to_datetime
from .base import CompositeWidget, Widget
Expand Down Expand Up @@ -175,6 +176,9 @@ class FileInput(Widget):
description = param.String(default=None, doc="""
An HTML string describing the function of this component.""")

directory = param.Boolean(default=False, doc="""
Selecting directories instead of files. Can report wrong numbers when used with accept""")

filename = param.ClassSelector(
default=None, class_=(str, list), is_instance=True)

Expand All @@ -185,6 +189,8 @@ class FileInput(Widget):

value = param.Parameter(default=None)

_clear_input = param.Integer(default=0, constant=True)

_rename: ClassVar[Mapping[str, str | None]] = {
'filename': None, 'name': None
}
Expand Down Expand Up @@ -212,9 +218,13 @@ def _process_property_change(self, msg):
msg = super()._process_property_change(msg)
if 'value' in msg:
if isinstance(msg['value'], str):
msg['value'] = b64decode(msg['value'])
msg['value'] = b64decode(msg['value']) if msg['value'] else None
else:
msg['value'] = [b64decode(content) for content in msg['value']]
if 'filename' in msg and len(msg['filename']) == 0:
msg['filename'] = None
if 'mime_type' in msg and len(msg['mime_type']) == 0:
msg['mime_type'] = None
return msg

def save(self, filename):
Expand Down Expand Up @@ -251,6 +261,10 @@ def save(self, filename):
else:
fn.write(val)

def clear_input(self):
with param.edit_constant(self):
self._clear_input += 1


class StaticText(Widget):
"""
Expand Down