From 0d3e7c87bf74948f83ce006a1d340b0f3f5e68b0 Mon Sep 17 00:00:00 2001 From: Robert Jaros Date: Mon, 8 Oct 2018 13:40:04 +0200 Subject: Refactoring to modules --- .../kotlin/pl/treksoft/kvision/KVManagerUpload.kt | 86 +++++ .../pl/treksoft/kvision/form/upload/Upload.kt | 333 +++++++++++++++++++ .../pl/treksoft/kvision/form/upload/UploadInput.kt | 364 +++++++++++++++++++++ 3 files changed, 783 insertions(+) create mode 100644 kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/KVManagerUpload.kt create mode 100644 kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/form/upload/Upload.kt create mode 100644 kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/form/upload/UploadInput.kt (limited to 'kvision-modules/kvision-upload/src/main/kotlin/pl') diff --git a/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/KVManagerUpload.kt b/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/KVManagerUpload.kt new file mode 100644 index 00000000..64a25545 --- /dev/null +++ b/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/KVManagerUpload.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision + +internal val KVManagerUploadInit = KVManagerUpload.init() + +/** + * Internal singleton object which initializes and configures KVision upload module. + */ +@Suppress("EmptyCatchBlock", "TooGenericExceptionCaught") +internal object KVManagerUpload { + fun init() {} + + private val bootstrapFileinputCss = try { + require("bootstrap-fileinput/css/fileinput.min.css") + } catch (e: Throwable) { + } + private val bootstrapFileinputCssFa = try { + require("bootstrap-fileinput/themes/explorer-fa/theme.min.css") + } catch (e: Throwable) { + } + private val bootstrapFileinput = try { + require("bootstrap-fileinput") + require("./js/locales/bootstrap-fileinput/ar.js") + require("./js/locales/bootstrap-fileinput/az.js") + require("./js/locales/bootstrap-fileinput/bg.js") + require("./js/locales/bootstrap-fileinput/ca.js") + require("./js/locales/bootstrap-fileinput/cr.js") + require("./js/locales/bootstrap-fileinput/cs.js") + require("./js/locales/bootstrap-fileinput/da.js") + require("./js/locales/bootstrap-fileinput/de.js") + require("./js/locales/bootstrap-fileinput/el.js") + require("./js/locales/bootstrap-fileinput/es.js") + require("./js/locales/bootstrap-fileinput/et.js") + require("./js/locales/bootstrap-fileinput/fa.js") + require("./js/locales/bootstrap-fileinput/fi.js") + require("./js/locales/bootstrap-fileinput/fr.js") + require("./js/locales/bootstrap-fileinput/gl.js") + require("./js/locales/bootstrap-fileinput/id.js") + require("./js/locales/bootstrap-fileinput/it.js") + require("./js/locales/bootstrap-fileinput/ja.js") + require("./js/locales/bootstrap-fileinput/ka.js") + require("./js/locales/bootstrap-fileinput/ko.js") + require("./js/locales/bootstrap-fileinput/kz.js") + require("./js/locales/bootstrap-fileinput/lt.js") + require("./js/locales/bootstrap-fileinput/nl.js") + require("./js/locales/bootstrap-fileinput/no.js") + require("./js/locales/bootstrap-fileinput/pl.js") + require("./js/locales/bootstrap-fileinput/pt.js") + require("./js/locales/bootstrap-fileinput/ro.js") + require("./js/locales/bootstrap-fileinput/ru.js") + require("./js/locales/bootstrap-fileinput/sk.js") + require("./js/locales/bootstrap-fileinput/sl.js") + require("./js/locales/bootstrap-fileinput/sv.js") + require("./js/locales/bootstrap-fileinput/th.js") + require("./js/locales/bootstrap-fileinput/tr.js") + require("./js/locales/bootstrap-fileinput/uk.js") + require("./js/locales/bootstrap-fileinput/vi.js") + require("./js/locales/bootstrap-fileinput/zh.js") + } catch (e: Throwable) { + } + private val bootstrapFileinputFa = try { + require("bootstrap-fileinput/themes/explorer-fa/theme.min.js") + } catch (e: Throwable) { + } + +} diff --git a/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/form/upload/Upload.kt b/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/form/upload/Upload.kt new file mode 100644 index 00000000..314c9904 --- /dev/null +++ b/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/form/upload/Upload.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.form.upload + +import org.w3c.files.File +import pl.treksoft.kvision.core.Container +import pl.treksoft.kvision.core.StringBoolPair +import pl.treksoft.kvision.core.Widget +import pl.treksoft.kvision.form.FieldLabel +import pl.treksoft.kvision.form.HelpBlock +import pl.treksoft.kvision.form.KFilesFormControl +import pl.treksoft.kvision.panel.SimplePanel +import pl.treksoft.kvision.types.KFile +import pl.treksoft.kvision.utils.SnOn + +/** + * The form field file upload component. + * + * @constructor + * @param uploadUrl the optional URL for the upload processing action + * @param multiple determines if multiple file upload is supported + * @param label label text bound to the input element + * @param rich determines if [label] can contain HTML code + */ +@Suppress("TooManyFunctions") +open class Upload( + uploadUrl: String? = null, multiple: Boolean = false, label: String? = null, + rich: Boolean = false +) : SimplePanel(setOf("form-group")), KFilesFormControl { + + /** + * File input value. + */ + override var value + get() = input.value + set(value) { + input.value = value + } + /** + * The optional URL for the upload processing action. + * If not set the upload button action will default to form submission. + */ + var uploadUrl + get() = input.uploadUrl + set(value) { + input.uploadUrl = value + } + /** + * Determines if multiple file upload is supported. + */ + var multiple + get() = input.multiple + set(value) { + input.multiple = value + } + /** + * The extra data that will be passed as data to the AJAX server call via POST. + */ + var uploadExtraData + get() = input.uploadExtraData + set(value) { + input.uploadExtraData = value + } + /** + * Determines if the explorer theme is used. + */ + var explorerTheme + get() = input.explorerTheme + set(value) { + input.explorerTheme = value + } + /** + * Determines if the input selection is required. + */ + var required + get() = input.required + set(value) { + input.required = value + } + /** + * Determines if the caption is shown. + */ + var showCaption + get() = input.showCaption + set(value) { + input.showCaption = value + } + /** + * Determines if the preview is shown. + */ + var showPreview + get() = input.showPreview + set(value) { + input.showPreview = value + } + /** + * Determines if the remove button is shown. + */ + var showRemove + get() = input.showRemove + set(value) { + input.showRemove = value + } + /** + * Determines if the upload button is shown. + */ + var showUpload + get() = input.showUpload + set(value) { + input.showUpload = value + } + /** + * Determines if the cancel button is shown. + */ + var showCancel + get() = input.showCancel + set(value) { + input.showCancel = value + } + /** + * Determines if the file browse button is shown. + */ + var showBrowse + get() = input.showBrowse + set(value) { + input.showBrowse = value + } + /** + * Determines if the click on the preview zone opens file browse window. + */ + var browseOnZoneClick + get() = input.browseOnZoneClick + set(value) { + input.browseOnZoneClick = value + } + /** + * Determines if the iconic preview is prefered. + */ + var preferIconicPreview + get() = input.preferIconicPreview + set(value) { + input.preferIconicPreview = value + } + /** + * Allowed file types. + */ + var allowedFileTypes + get() = input.allowedFileTypes + set(value) { + input.allowedFileTypes = value + } + /** + * Allowed file extensions. + */ + var allowedFileExtensions + get() = input.allowedFileExtensions + set(value) { + input.allowedFileExtensions = value + } + /** + * Determines if Drag&Drop zone is enabled. + */ + var dropZoneEnabled + get() = input.dropZoneEnabled + set(value) { + input.dropZoneEnabled = value + } + /** + * The label text bound to the spinner input element. + */ + var label + get() = flabel.content + set(value) { + flabel.content = value + } + /** + * Determines if [label] can contain HTML code. + */ + var rich + get() = flabel.rich + set(value) { + flabel.rich = value + } + + protected val idc = "kv_form_upload_$counter" + final override val input: UploadInput = UploadInput(uploadUrl, multiple) + .apply { + this.id = idc + this.name = name + } + final override val flabel: FieldLabel = FieldLabel(idc, label, rich) + final override val validationInfo: HelpBlock = HelpBlock().apply { visible = false } + + init { + @Suppress("LeakingThis") + input.eventTarget = this + this.addInternal(flabel) + this.addInternal(input) + this.addInternal(validationInfo) + counter++ + } + + override fun getSnClass(): List { + val cl = super.getSnClass().toMutableList() + if (validatorError != null) { + cl.add("has-error" to true) + } + return cl + } + + @Suppress("UNCHECKED_CAST") + override fun setEventListener(block: SnOn.() -> Unit): Widget { + input.setEventListener(block) + return this + } + + override fun setEventListener(block: SnOn.() -> Unit): Widget { + input.setEventListener(block) + return this + } + + override fun removeEventListeners(): Widget { + input.removeEventListeners() + return this + } + + override fun getValueAsString(): String? { + return input.getValueAsString() + } + + /** + * Returns the native JavaScript File object. + * @param kFile KFile object + * @return File object + */ + fun getNativeFile(kFile: KFile): File? { + return input.getNativeFile(kFile) + } + + /** + * Resets the file input control. + */ + open fun resetInput() { + input.resetInput() + } + + /** + * Clears the file input control (including the native input). + */ + open fun clearInput() { + input.clearInput() + } + + /** + * Trigger ajax upload (only for ajax mode). + */ + open fun upload() { + input.upload() + } + + /** + * Cancel an ongoing ajax upload (only for ajax mode). + */ + open fun cancel() { + input.cancel() + } + + /** + * Locks the file input (disabling all buttons except a cancel button). + */ + open fun lock() { + input.lock() + } + + /** + * Unlocks the file input. + */ + open fun unlock() { + input.unlock() + } + + override fun focus() { + input.focus() + } + + override fun blur() { + input.blur() + } + + companion object { + internal var counter = 0 + + /** + * DSL builder extension function. + * + * It takes the same parameters as the constructor of the built component. + */ + fun Container.upload( + uploadUrl: String? = null, + multiple: Boolean = false, + label: String? = null, + rich: Boolean = false, + init: (Upload.() -> Unit)? = null + ): Upload { + val upload = Upload(uploadUrl, multiple, label, rich).apply { + init?.invoke( + this + ) + } + this.add(upload) + return upload + } + } +} diff --git a/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/form/upload/UploadInput.kt b/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/form/upload/UploadInput.kt new file mode 100644 index 00000000..51b73aa1 --- /dev/null +++ b/kvision-modules/kvision-upload/src/main/kotlin/pl/treksoft/kvision/form/upload/UploadInput.kt @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2017-present Robert Jaros + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package pl.treksoft.kvision.form.upload + +import com.github.snabbdom.VNode +import org.w3c.files.File +import pl.treksoft.kvision.core.Container +import pl.treksoft.kvision.core.StringBoolPair +import pl.treksoft.kvision.core.StringPair +import pl.treksoft.kvision.core.Widget +import pl.treksoft.kvision.form.Form +import pl.treksoft.kvision.form.FormInput +import pl.treksoft.kvision.form.FormPanel +import pl.treksoft.kvision.form.InputSize +import pl.treksoft.kvision.i18n.I18n +import pl.treksoft.kvision.types.KFile +import pl.treksoft.kvision.utils.getContent +import pl.treksoft.kvision.utils.obj +import kotlin.reflect.KProperty1 + +/** + * The file upload component. + * + * @constructor + * @param uploadUrl the optional URL for the upload processing action + * @param multiple determines if multiple file upload is supported + * @param classes a set of CSS class names + */ +@Suppress("TooManyFunctions") +open class UploadInput(uploadUrl: String? = null, multiple: Boolean = false, classes: Set = setOf()) : + Widget(classes + "form-control"), FormInput { + + /** + * File input value. + */ + var value: List? + get() = getValue() + set(value) { + if (value == null) resetInput() + } + + /** + * The optional URL for the upload processing action. + * If not set the upload button action will default to form submission. + */ + var uploadUrl: String? by refreshOnUpdate(uploadUrl, { refreshUploadInput() }) + /** + * Determines if multiple file upload is supported. + */ + var multiple: Boolean by refreshOnUpdate(multiple, { refresh(); refreshUploadInput() }) + /** + * The extra data that will be passed as data to the AJAX server call via POST. + */ + var uploadExtraData: ((String, Int) -> dynamic)? by refreshOnUpdate({ refreshUploadInput() }) + /** + * Determines if the explorer theme is used. + */ + var explorerTheme: Boolean by refreshOnUpdate(false, { refreshUploadInput() }) + /** + * Determines if the input selection is required. + */ + var required: Boolean by refreshOnUpdate(false, { refreshUploadInput() }) + /** + * Determines if the caption is shown. + */ + var showCaption: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the preview is shown. + */ + var showPreview: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the remove button is shown. + */ + var showRemove: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the upload button is shown. + */ + var showUpload: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the cancel button is shown. + */ + var showCancel: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the file browse button is shown. + */ + var showBrowse: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the click on the preview zone opens file browse window. + */ + var browseOnZoneClick: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the iconic preview is prefered. + */ + var preferIconicPreview: Boolean by refreshOnUpdate(false, { refreshUploadInput() }) + /** + * Allowed file types. + */ + var allowedFileTypes: Set? by refreshOnUpdate({ refreshUploadInput() }) + /** + * Allowed file extensions. + */ + var allowedFileExtensions: Set? by refreshOnUpdate({ refreshUploadInput() }) + /** + * Determines if Drag&Drop zone is enabled. + */ + var dropZoneEnabled: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * The name attribute of the generated HTML input element. + */ + override var name: String? by refreshOnUpdate() + /** + * Determines if the field is disabled. + */ + override var disabled by refreshOnUpdate(false, { refresh(); refreshUploadInput() }) + /** + * The size of the input (currently not working) + */ + override var size: InputSize? by refreshOnUpdate() + + private val nativeFiles: MutableMap = mutableMapOf() + + override fun render(): VNode { + return render("input") + } + + override fun getSnClass(): List { + val cl = super.getSnClass().toMutableList() + size?.let { + cl.add(it.className to true) + } + return cl + } + + override fun getSnAttrs(): List { + val sn = super.getSnAttrs().toMutableList() + sn.add("type" to "file") + name?.let { + sn.add("name" to it) + } + if (multiple) { + sn.add("multiple" to "true") + } + if (disabled) { + sn.add("disabled" to "disabled") + } + return sn + } + + private fun getValue(): List? { + val v = getFiles() + return if (v.isNotEmpty()) v else null + } + + @Suppress("UnsafeCastFromDynamic") + override fun afterInsert(node: VNode) { + getElementJQueryD()?.fileinput(getSettingsObj()) + this.getElementJQuery()?.on("fileselect", { e, _ -> + this.dispatchEvent("fileSelectUpload", obj { detail = e }) + }) + this.getElementJQuery()?.on("fileclear", { e, _ -> + this.dispatchEvent("fileClearUpload", obj { detail = e }) + }) + this.getElementJQuery()?.on("filereset", { e, _ -> + this.dispatchEvent("fileResetUpload", obj { detail = e }) + }) + this.getElementJQuery()?.on("filebrowse", { e, _ -> + this.dispatchEvent("fileBrowseUpload", obj { detail = e }) + }) + this.getElementJQueryD()?.on("filepreupload", lambda@{ _, data, previewId, index -> + data["previewId"] = previewId + data["index"] = index + this.dispatchEvent("filePreUpload", obj { detail = data }) + return@lambda null + }) + } + + override fun afterDestroy() { + getElementJQueryD()?.fileinput("destroy") + } + + private fun refreshUploadInput() { + getElementJQueryD()?.fileinput("refresh", getSettingsObj()) + } + + /** + * Resets the file input control. + */ + open fun resetInput() { + getElementJQueryD()?.fileinput("reset") + } + + /** + * Clears the file input control (including the native input). + */ + open fun clearInput() { + getElementJQueryD()?.fileinput("clear") + } + + /** + * Trigger ajax upload (only for ajax mode). + */ + open fun upload() { + getElementJQueryD()?.fileinput("upload") + } + + /** + * Cancel an ongoing ajax upload (only for ajax mode). + */ + open fun cancel() { + getElementJQueryD()?.fileinput("cancel") + } + + /** + * Locks the file input (disabling all buttons except a cancel button). + */ + open fun lock() { + getElementJQueryD()?.fileinput("lock") + } + + /** + * Unlocks the file input. + */ + open fun unlock() { + getElementJQueryD()?.fileinput("unlock") + } + + /** + * Returns the native JavaScript File object. + * @param kFile KFile object + * @return File object + */ + fun getNativeFile(kFile: KFile): File? { + return nativeFiles[kFile] + } + + private fun getFiles(): List { + nativeFiles.clear() + return (getElementJQueryD()?.fileinput("getFileStack") as Array).toList().map { + val kFile = KFile(it.name, it.size, null) + nativeFiles[kFile] = it + kFile + } + } + + /** + * Returns the value of the file input control as a String. + * @return value as a String + */ + fun getValueAsString(): String? { + return value?.joinToString { it.name } + } + + /** + * Makes the input element focused. + */ + open fun focus() { + getElementJQuery()?.focus() + } + + /** + * Makes the input element blur. + */ + open fun blur() { + getElementJQuery()?.blur() + } + + private fun getSettingsObj(): dynamic { + val language = I18n.language + return obj { + this.uploadUrl = uploadUrl + this.uploadExtraData = uploadExtraData ?: undefined + this.theme = if (explorerTheme) "explorer-fa" else null + this.required = required + this.showCaption = showCaption + this.showPreview = showPreview + this.showRemove = showRemove + this.showUpload = showUpload + this.showCancel = showCancel + this.showBrowse = showBrowse + this.browseOnZoneClick = browseOnZoneClick + this.preferIconicPreview = preferIconicPreview + this.allowedFileTypes = allowedFileTypes?.toTypedArray() + this.allowedFileExtensions = allowedFileExtensions?.toTypedArray() + this.dropZoneEnabled = dropZoneEnabled + this.language = language + } + } + + companion object { + internal var counter = 0 + + /** + * DSL builder extension function. + * + * It takes the same parameters as the constructor of the built component. + */ + fun Container.uploadInput( + uploadUrl: String? = null, + multiple: Boolean = false, + classes: Set = setOf(), + init: (UploadInput.() -> Unit)? = null + ): UploadInput { + val uploadInput = UploadInput(uploadUrl, multiple, classes).apply { + init?.invoke( + this + ) + } + this.add(uploadInput) + return uploadInput + } + + /** + * Returns file with the content read. + * @param key key identifier of the control + * @param kFile object identifying the file + * @return KFile object + */ + @Suppress("EXPERIMENTAL_FEATURE_WARNING") + suspend fun Form.getContent( + key: KProperty1?>, + kFile: KFile + ): KFile { + val control = getControl(key) as Upload + val content = control.getNativeFile(kFile)?.getContent() + return kFile.copy(content = content) + } + + + /** + * Returns file with the content read. + * @param key key identifier of the control + * @param kFile object identifying the file + * @return KFile object + */ + @Suppress("EXPERIMENTAL_FEATURE_WARNING") + suspend fun FormPanel.getContent( + key: KProperty1?>, + kFile: KFile + ): KFile { + val control = getControl(key) as Upload + val content = control.getNativeFile(kFile)?.getContent() + return kFile.copy(content = content) + } + } +} -- cgit