diff --git a/.gitignore b/.gitignore index cc9336b6256771f79eb104a8b1325a55352657e1..ddfb9ac071b823c0b1f9b2495c1e44c49290ec1b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # the build dir /public +/sss_bin # screen logs screenlog.* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 548f86457c3216f5eed7b75b4b81d7f048351248..2a014f451fb7b6de3dd5a8715b3aeae701d806e5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -58,6 +58,14 @@ test: script: - make run-qunit +test-server-side-scripting: + timeout: 10 minutes + tags: [ docker ] + stage: test + script: + - whereis pytest pytest3 py.test pytest-3 py.test-3 + - make test-sss + # Trigger building of server image and integration tests trigger_build: timeout: 15 minutes diff --git a/CHANGELOG.md b/CHANGELOG.md index a700e0a8670aae69b375e48cbe55d007f5c5f530..67446d22180ea9ef38664aee86e288ab13becdc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added (for new features, dependecies etc.) +- Module `ext_bookmarks` which allows users to bookmark entities, store them in + a link or export them to TSV. +- table previews in the bottom line module +- added preview for tif images +* new function `form_elements.make_alert` which generates a proceed/cancel + dialog which intercepts a function call and asks the user for confirmation. +* Deleting entities prompts for user confirmation after hitting the "Delete" + button (edit_mode). +* Plotly preview has an additional parameter for a config object, + e.g., for disabling the plotly logo +- After a SELECT statement now also all referenced files can be downloaded. ### Changed (for changes in existing functionality) +- enabled and enhanced autocompletion + +* Login form is hidden behind another button. ### Deprecated (for soon-to-be removed features) ### Removed (for now removed features) ### Fixed + +- #136 (adding reference properties to entities in edit mode) - exclude configuration files when reading files from build.properties.d +- summaries when opening preview +- #125 special characters like "\t", \"n", "#" are replaced in table + download ### Security (in case of vulnerabilities) diff --git a/README_SETUP.md b/README_SETUP.md index 4706efe28f798a0260b0bc46374491dca7c0d667..127549ec3a273b0cca08ee6a824601fbbd3172bf 100644 --- a/README_SETUP.md +++ b/README_SETUP.md @@ -53,6 +53,10 @@ information. * Run `make install` to compile/copy the webinterface to a newly created `public` folder. +* Also, `make install` will copy the scripts from `src/server_side_scripting/` + to `sss_bin/`. If you want to make the server-side scripts callable for the + server as server-side scripts you need to include the `sss_bin/` directory + into the server property `SERVER_SIDE_SCRIPTING_BIN_DIRS`. # Test diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index a64d39fec41232a8fd889e4b61679b0ffb15ec9c..cb0a89ce6d5cf991de67b9063caaf4349e34b59d 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -45,6 +45,11 @@ BUILD_MODULE_EXT_PREVIEW=ENABLED BUILD_MODULE_EXT_RESOLVE_REFERENCES=ENABLED BUILD_MODULE_EXT_SSS_MARKDOWN=DISABLED BUILD_MODULE_EXT_TRIGGER_CRAWLER_FORM=DISABLED +BUILD_MODULE_EXT_AUTOCOMPLETE=ENABLED +BUILD_MODULE_EXT_BOTTOM_LINE=ENABLED +BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW=DISABLED +BUILD_MODULE_EXT_BOTTOM_LINE_TIFF_PREVIEW=DISABLED +BUILD_MODULE_EXT_BOOKMARKS=ENABLED ############################################################################## # Navbar properties diff --git a/install-sss.sh b/install-sss.sh new file mode 100755 index 0000000000000000000000000000000000000000..bb2db57649000ba1e701786f56dba575753110eb --- /dev/null +++ b/install-sss.sh @@ -0,0 +1,17 @@ +SRC_DIR=$1 +INSTALL_DIR=$2 + +mkdir -p $INSTALL_DIR + +# from here on do your module-wise installing + +# ext_table_preview +if [ "${BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW}" == "ENABLED" ]; then + mkdir -p $INSTALL_DIR/ext_table_preview + cp $SRC_DIR/ext_table_preview/*.py $INSTALL_DIR/ext_table_preview/ + echo "installed all server-side scripts for ext_table_preview" +fi +# ext_file_download; should always be installed - No build variable +mkdir -p $INSTALL_DIR/ext_file_download +cp $SRC_DIR/ext_file_download/*.py $INSTALL_DIR/ext_file_download/ +echo "installed all server-side scripts for ext_file_download" diff --git a/libs/UTIF-8205c1f.zip b/libs/UTIF-8205c1f.zip new file mode 100644 index 0000000000000000000000000000000000000000..7069cd288d5d629a7fc3a03d8d92415e78aeb728 Binary files /dev/null and b/libs/UTIF-8205c1f.zip differ diff --git a/libs/bootstrap-3.3.7-dist.zip b/libs/bootstrap-3.3.7-dist.zip deleted file mode 100644 index 6fbb95ebaa3867ce196ef3b54951a732107d94d2..0000000000000000000000000000000000000000 Binary files a/libs/bootstrap-3.3.7-dist.zip and /dev/null differ diff --git a/libs/bootstrap-3.4.1-dist.zip b/libs/bootstrap-3.4.1-dist.zip new file mode 100644 index 0000000000000000000000000000000000000000..9002b8521706bc582546f41635da3437edf20c3c Binary files /dev/null and b/libs/bootstrap-3.4.1-dist.zip differ diff --git a/libs/bootstrap-autocomplete-2.3.0.zip b/libs/bootstrap-autocomplete-2.3.0.zip deleted file mode 100644 index 206c00f49c87794e996c46da2f086d0a1d118071..0000000000000000000000000000000000000000 Binary files a/libs/bootstrap-autocomplete-2.3.0.zip and /dev/null differ diff --git a/libs/bootstrap-autocomplete-2.3.5.zip b/libs/bootstrap-autocomplete-2.3.5.zip new file mode 100644 index 0000000000000000000000000000000000000000..8cc2e03067955193bb89af2972a35ee9e0260b35 Binary files /dev/null and b/libs/bootstrap-autocomplete-2.3.5.zip differ diff --git a/libs/jquery-3.3.1.zip b/libs/jquery-3.3.1.zip deleted file mode 100644 index 404fac0639caf20bd4cf55419aa9a5f5bb768029..0000000000000000000000000000000000000000 Binary files a/libs/jquery-3.3.1.zip and /dev/null differ diff --git a/libs/jquery-3.5.1.zip b/libs/jquery-3.5.1.zip new file mode 100644 index 0000000000000000000000000000000000000000..c554bbf416786da9c18bef62061cba932646f996 Binary files /dev/null and b/libs/jquery-3.5.1.zip differ diff --git a/libs/pako-dummy.zip b/libs/pako-dummy.zip new file mode 100644 index 0000000000000000000000000000000000000000..e493ee9d673c81a523ad8e488c509c392765da52 Binary files /dev/null and b/libs/pako-dummy.zip differ diff --git a/makefile b/makefile index f0533f8c9f77db23fc69c5312f65493073b8cfb7..d44625927fbef317ce4afa072a616ada36df96ce 100644 --- a/makefile +++ b/makefile @@ -33,14 +33,17 @@ SQ=\' ROOT_DIR = $(abspath .) MISC_DIR = $(abspath misc) PUBLIC_DIR = $(abspath public) +SSS_BIN_DIR = $(abspath sss_bin) CONF_CORE_DIR = $(abspath conf/core) CONF_EXT_DIR = $(abspath conf/ext) SRC_CORE_DIR = $(abspath src/core) SRC_EXT_DIR = $(abspath src/ext) +SRC_SSS_DIR = $(abspath src/server_side_scripting) LIBS_DIR = $(abspath libs) TEST_CORE_DIR = $(abspath test/core/) TEST_EXT_DIR = $(abspath test/ext) -LIBS = fonts css/bootstrap.css js/bootstrap.js js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js +TEST_SSS_DIR =$(abspath test/server_side_scripting) +LIBS = fonts css/bootstrap.css js/bootstrap.js js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js TEST_LIBS = $(LIBS) js/qunit.js css/qunit.css $(subst $(TEST_CORE_DIR)/,,$(shell find $(TEST_CORE_DIR)/)) @@ -49,9 +52,9 @@ LIBS_SUBDIRS = $(addprefix $(LIBS_DIR)/, js css fonts) ALL: install -install: clean cp-src cp-ext cp-conf $(addprefix $(PUBLIC_DIR)/, $(LIBS)) build_properties merge_xsl +install: clean install-sss cp-src cp-ext cp-conf $(addprefix $(PUBLIC_DIR)/, $(LIBS)) build_properties merge_xsl -test: clean cp-src cp-ext cp-ext-test cp-conf $(addprefix $(PUBLIC_DIR)/, $(TEST_LIBS)) build_properties merge_xsl +test: clean install-sss cp-src cp-ext cp-ext-test cp-conf $(addprefix $(PUBLIC_DIR)/, $(TEST_LIBS)) build_properties merge_xsl @for f in $(shell find $(TEST_EXT_DIR) -type f -iname *.js) ; do \ sed -i "/EXTENSIONS/a \<script src=\"$${f#$(TEST_EXT_DIR)/}\" ></script>" $(PUBLIC_DIR)/index.html ; \ echo include $$f; \ @@ -129,18 +132,31 @@ run-qunit: test exit 1; \ fi +install-sss: + @set -a -e ; \ + pushd build.properties.files ; \ + for f in ../build.properties.d/* ; do source "$$f" ; done ; \ + popd ; \ + ./install-sss.sh $(SRC_SSS_DIR) $(SSS_BIN_DIR) + +PYTEST ?= pytest-3 +test-sss: install-sss + $(PYTEST) -vv $(TEST_SSS_DIR) + + +CMD_COPY_EXT_FILES = cp -i -r -L cp-ext: # TODO FIXME Base path for not-XSL-expanded files mkdir -p $(PUBLIC_DIR)/html for f in $(wildcard $(SRC_EXT_DIR)/html/*) ; do \ - echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/html/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/html/ ; \ done for f in $(wildcard $(SRC_EXT_DIR)/js/*) ; do \ - echo "y" | cp -i -r "$$f" $(PUBLIC_DIR)/js/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$f" $(PUBLIC_DIR)/js/ ; \ sed -i "/JS_EXTENSIONS/a \<xsl:element name=\"script\"><xsl:attribute name=\"src\"><xsl:value-of select=\"concat\(\$$basepath, 'webinterface/${BUILD_NUMBER}$${f#$(SRC_EXT_DIR)}'\)\" /></xsl:attribute></xsl:element>" $(PUBLIC_DIR)/xsl/main.xsl ; \ done for f in $(wildcard $(SRC_EXT_DIR)/css/*) ; do \ - echo "y" | cp -i -r "$$f" $(PUBLIC_DIR)/css/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$f" $(PUBLIC_DIR)/css/ ; \ sed -i "/CSS_EXTENSIONS/a \<xsl:element name=\"link\"><xsl:attribute name=\"rel\">stylesheet</xsl:attribute><xsl:attribute name=\"href\"><xsl:value-of select=\"concat\(\$$basepath, 'webinterface/${BUILD_NUMBER}$${f#$(SRC_EXT_DIR)}'\)\" /></xsl:attribute></xsl:element>" $(PUBLIC_DIR)/xsl/main.xsl ; \ for html in $(PUBLIC_DIR)/html/* ; do \ echo "$$html"; \ @@ -148,26 +164,26 @@ cp-ext: done \ done for f in $(wildcard $(SRC_EXT_DIR)/pics/*) ; do \ - echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/pics/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/pics/ ; \ done for f in $(wildcard $(SRC_EXT_DIR)/xsl/*) ; do \ - echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \ done cp-ext-test: for f in $(wildcard $(TEST_EXT_DIR)/js/*) ; do \ - echo "y" | cp -i -r "$$f" $(PUBLIC_DIR)/js/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$f" $(PUBLIC_DIR)/js/ ; \ sed -i "/JS_EXTENSIONS/a \<xsl:element name=\"script\"><xsl:attribute name=\"src\"><xsl:value-of select=\"concat\(\$$basepath, 'webinterface/${BUILD_NUMBER}$${f#$(SRC_EXT_DIR)}'\)\" /></xsl:attribute></xsl:element>" $(PUBLIC_DIR)/xsl/main.xsl ; \ done mkdir -p $(PUBLIC_DIR)/html for f in $(wildcard $(TEST_EXT_DIR)/html/*) ; do \ - echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/html/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/html/ ; \ done for f in $(wildcard $(TEST_EXT_DIR)/pics/*) ; do \ - echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/pics/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/pics/ ; \ done for f in $(wildcard $(TEST_EXT_DIR)/xsl/*) ; do \ - echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \ done cp-conf: convert-yaml @@ -193,13 +209,13 @@ $(PUBLIC_DIR)/%: $(TEST_EXT_DIR)/% cp -r $< $@ $(LIBS_DIR)/fonts: unzip - ln -s $(LIBS_DIR)/bootstrap-3.3.7-dist/fonts $@ + ln -s $(LIBS_DIR)/bootstrap-3.4.1-dist/fonts $@ $(LIBS_DIR)/js/bootstrap.js: unzip $(LIBS_DIR)/js - ln -s $(LIBS_DIR)/bootstrap-3.3.7-dist/js/bootstrap.min.js $@ + ln -s $(LIBS_DIR)/bootstrap-3.4.1-dist/js/bootstrap.min.js $@ $(LIBS_DIR)/css/bootstrap.css: unzip $(LIBS_DIR)/css - ln -s $(LIBS_DIR)/bootstrap-3.3.7-dist/css/bootstrap.min.css $@ + ln -s $(LIBS_DIR)/bootstrap-3.4.1-dist/css/bootstrap.min.css $@ $(LIBS_DIR)/js/bootstrap-select.js: unzip $(LIBS_DIR)/js ln -s $(LIBS_DIR)/bootstrap-select-1.13.9/dist/js/bootstrap-select.min.js $@ @@ -208,7 +224,7 @@ $(LIBS_DIR)/css/bootstrap-select.css: unzip $(LIBS_DIR)/css ln -s $(LIBS_DIR)/bootstrap-select-1.13.9/dist/css/bootstrap-select.min.css $@ $(LIBS_DIR)/js/jquery.js: unzip $(LIBS_DIR)/js - ln -s $(LIBS_DIR)/jquery-3.3.1/jquery-3.3.1.min.js $@ + ln -s $(LIBS_DIR)/jquery-3.5.1/jquery-3.5.1.min.js $@ $(LIBS_DIR)/js/showdown.js: unzip $(LIBS_DIR)/js ln -s $(LIBS_DIR)/showdown-1.8.6/dist/showdown.min.js $@ @@ -259,17 +275,24 @@ $(LIBS_DIR)/css/leaflet-coordinates.css: unzip $(LIBS_DIR)/css ln -s $(LIBS_DIR)/Leaflet.Coordinates-0.1.5/dist/Leaflet.Coordinates-0.1.5.css $@ $(LIBS_DIR)/js/bootstrap-autocomplete.min.js: unzip $(LIBS_DIR)/js - ln -s $(LIBS_DIR)/bootstrap-autocomplete-2.3.0/dist/latest/bootstrap-autocomplete.min.js $@ + ln -s $(LIBS_DIR)/bootstrap-autocomplete-2.3.5/dist/latest/bootstrap-autocomplete.min.js $@ $(LIBS_DIR)/js/plotly.js: unzip $(LIBS_DIR)/js ln -s $(LIBS_DIR)/plotly.js-1.52.2/dist/plotly.min.js $@ +$(LIBS_DIR)/js/pako.js: unzip $(LIBS_DIR)/js + ln -s $(LIBS_DIR)/pako-dummy/pako.js $@ + +$(LIBS_DIR)/js/utif.js: unzip $(LIBS_DIR)/js + ln -s $(LIBS_DIR)/UTIF-8205c1f/UTIF.js $@ + $(addprefix $(LIBS_DIR)/, js css): mkdir $@ || true .PHONY: clean clean: + $(RM) -r $(SSS_BIN_DIR) $(RM) -r $(PUBLIC_DIR) for f in $(LIBS_SUBDIRS); do unlink $$f || $(RM) -r $$f || true; done for f in $(patsubst %.zip,%/,$(LIBS_ZIP)); do $(RM) -r $$f; done @@ -280,11 +303,7 @@ unzip: for f in $(LIBS_ZIP); do unzip -q -o -d libs $$f; done -PYLINT = pylint3 -d all -e E,F +PYLINT ?= pylint3 PYTHON_FILES = $(subst $(ROOT_DIR)/,,$(shell find $(ROOT_DIR)/ -iname "*.py")) pylint: $(PYTHON_FILES) - for f in $(PYTHON_FILES); do $(PYLINT) $$f || exit 1; done - -PYLINT_LOCAL = /usr/bin/pylint3 -d all -e E,F -pylint-local: $(PYTHON_FILES) - for f in $(PYTHON_FILES); do $(PYLINT_LOCAL) $$f || exit 1; done + for f in $(PYTHON_FILES); do $(PYLINT) -d all -e E,F $$f || exit 1; done diff --git a/src/core/css/webcaosdb.css b/src/core/css/webcaosdb.css index 05e36429b7dfe6144915b522de2fa6c36375c484..57399e5787c28a6ecdf0c5fe7ce505b3d23f90d5 100644 --- a/src/core/css/webcaosdb.css +++ b/src/core/css/webcaosdb.css @@ -27,6 +27,26 @@ body { flex-direction: column; } +#top-navbar>ul>li>a { + margin: 8px 0px; + padding: 6px 12px; + display: inline-block; +} + +.caosdb-v-bookmark-button, +.caosdb-v-bookmark-button:focus, +.caosdb-v-bookmark-button:hover { + color: #333; + position: relative; + top: 3px; +} + +.caosdb-v-bookmark-button:active, +.caosdb-v-bookmark-button.active { + color: red; +} + + .caosdb-v-navbar-toolbox li a:hover, .caosdb-v-navbar-toolbox li input:hover, .caosdb-v-navbar-toolbox li button:hover { diff --git a/src/core/js/autocomplete.js b/src/core/js/autocomplete.js deleted file mode 100644 index 345d06b37bc6456645ef02947c70b3f2f5d6c44f..0000000000000000000000000000000000000000 --- a/src/core/js/autocomplete.js +++ /dev/null @@ -1,109 +0,0 @@ -/* - * ** header v3.0 - * This file is a part of the CaosDB Project. - * - * Copyright (C) 2019 IndiScale GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - * - * ** end header - */ - -'use strict'; - -var autocomplete = new function() { - this.version = "0.1"; - - /** - * @type {Promise} promise for a list of entity names. - */ - this.candidates = undefined; - var logger = log.getLogger("autocomplete"); - this.init = function() { - this.candidates = undefined; - this.toggle_completion(); - logger.setLevel("trace"); - }; - - this.retrieve_names = async function () { - var response = $(await connection.get(transaction.generateEntitiesUri( - ["names"]))).find("Response [name]") - - return response.toArray().map(x=> $(x).attr("name")); - }; - - /** - * @return {Promise} promise for a list of entity names. - */ - this.get_candidates = function () { - - if (typeof this.candidates === "undefined"){ - - this.candidates = this.retrieve_names(); - }; - return this.candidates; - }; - - this.filter = function (x, qry){ - return x.toLowerCase().startsWith(qry.toLowerCase()) - }; - - this.search = async function (qry, callback, origJQElement) { - const names = await autocomplete.get_candidates(); - callback(names.filter(x => autocomplete.filter(x, qry))); - }; - - this.typed = function (newValue, origJQElement) { - var cursorpos = origJQElement[0].selectionEnd; - var beginning_of_word = newValue.slice(0, cursorpos).lastIndexOf(" "); - var word = newValue.slice(beginning_of_word+1, cursorpos); - return word; - }; - - this.searchPost = function (resultsFromServer, origJQElement) { - var cursorpos = origJQElement[0].selectionEnd; - var newValue = origJQElement[0].value - var beginning_of_word = newValue.slice(0, cursorpos).lastIndexOf( - " "); - var start = newValue.slice(0, beginning_of_word+1); - var end = origJQElement[0].value.slice(cursorpos); - var result = resultsFromServer.map( x => { - return { - text: start + x + end, - html: x - }}); - return result; - }; - - this.toggle_completion = function () { - var field = $("#caosdb-query-textarea"); - field.toggleClass("basicAutoComplete", true); - field.autoComplete({ - events: { - search: this.search, - typed: this.typed, - searchPost: this.searchPost, - } - } - ); - return field; - }; -}; - -// this will be replaced by require.js in the future. -$(document).ready(function() { - if ("${BUILD_MODULE_EXT_AUTOCOMPLETE}" === "ENABLED") { - caosdb_modules.register(autocomplete); - } -}); diff --git a/src/core/js/edit_mode.js b/src/core/js/edit_mode.js index 0c52f403d5d9b2f0b2feb60a9dc21403c12dc0b2..e81381643bead77c849741c3fc2cb076803d121d 100644 --- a/src/core/js/edit_mode.js +++ b/src/core/js/edit_mode.js @@ -42,6 +42,11 @@ var edit_mode = new function() { */ this.start_edit = new Event("caosdb.edit_mode.start_edit") + /** + * Fired by the entity element after editing (canceled or saved). + */ + this.end_edit = new Event("caosdb.edit_mode.end_edit") + /** * Fired by a list-property when an (input) element is added to the list during editing. */ @@ -1188,7 +1193,7 @@ var edit_mode = new function() { */ this.delete_action = async function(entity) { var app = edit_mode.app; - // this is the callback of the delete button + // show waiting notification edit_mode.smooth_replace(entity, app.waiting); // send delete request @@ -1533,6 +1538,7 @@ var edit_mode = new function() { } hintMessages.hintMessages(app.entity); edit_mode.unfreeze(); + app.entity.dispatchEvent(edit_mode.end_edit); resolve_references.init(); preview.init(); } @@ -1780,7 +1786,24 @@ var edit_mode = new function() { $(entity).find(".caosdb-f-edit-mode-entity-actions-panel").append(button); $(button).click(() => { - edit_mode.delete_action(entity); + // hide other elements + const _alert = form_elements.make_alert({ + title: "Warning", + message: "You are going to delete this entity permanently. This cannot be undone.", + proceed_callback: () => { + edit_mode.delete_action(entity) + }, + cancel_callback: () => { + $(_alert).remove(); + $(entity).find(".caosdb-f-edit-mode-entity-actions-panel").show(); + }, + proceed_text: "Yes, delete!", + remember_my_decision_id : "delete_entity", + }); + $(_alert).addClass("text-right"); + + $(entity).find(".caosdb-f-edit-mode-entity-actions-panel").after(_alert).hide(); + }); } diff --git a/src/core/js/ext_autocomplete.js b/src/core/js/ext_autocomplete.js new file mode 100644 index 0000000000000000000000000000000000000000..a60ad945f76c0e99ef70713aef82970a137f7973 --- /dev/null +++ b/src/core/js/ext_autocomplete.js @@ -0,0 +1,160 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2019 IndiScale GmbH, Henrik tom Wörden + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +/* + * This module uses and extends bootstrap-autocomplete to provide + * autocompletion to the query field. + * Currently, completion exists for names that are stored in CaosDB + */ +'use strict'; + +var ext_autocomplete = new function () { + this.CQL_WORDS = [ + "FIND", + "FILE", + "ENTITY", + "SELECT", + "COUNT", + "RECORD", + "PROPERTY", + "RECORDTYPE", + "REFERENCES", + "REFERENCED BY", + "WHICH", + "WITH", + "CREATED BY", + "CREATED ON", + "SOMEONE", + "STORED AT", + "HAS A PROPERTY", + "HAS BEEN", + ]; + this.version = "0.1"; + + + /** + * configure logging + */ + var logger = log.getLogger("ext_autocomplete"); + + /** + * Initialize this module. + * + * Fetch candidates and prepare bootstrap-autocomplete. + */ + this.init = async function () { + this.candidates = await this.retrieve_names(); + this.switch_on_completion(); + }; + + + /** + * creates a list of names from the server resource Entity/names + */ + this.retrieve_names = async function () { + var response = $(await connection.get(transaction.generateEntitiesUri( + ["names"]))).find("Property[name],RecordType[name],Record[name]") + + response = response.toArray().map(x => $(x).attr("name")); + response = response.concat(ext_autocomplete.CQL_WORDS); + + return response + }; + + /** + * case insensitive filter that returns elements that start with qry + */ + this.starts_with_filter = function (x, qry) { + return x.toLowerCase().startsWith(qry.toLowerCase()) + }; + + /** + * Overwrites the default search function of bootstrap-autocomplete + * uses candidates and filters the list using filter + */ + this.search = async function (qry, callback, origJQElement) { + callback(ext_autocomplete.candidates.filter( + x => ext_autocomplete.starts_with_filter(x, qry))); + }; + + /** + * Overwrites the default typed function of bootstrap-autocomplete + * This assures that only the word before the cursor is used for completion + */ + this.typed = function (newValue, origJQElement) { + var cursorpos = origJQElement[0].selectionEnd; + var beginning_of_word = newValue.slice(0, cursorpos).lastIndexOf(" "); + var word = newValue.slice(beginning_of_word + 1, cursorpos); + return word; + }; + + /** + * Overwrites the default searchPost function of bootstrap-autocomplete + * This assures that only the word before the cursor is replaced. + * It is achieved by returning not a simple list, but an object where each + * element has "text" and "html" fields, where "html" is what is visible + * in the dropdown and "text" will be inserted. + */ + this.searchPost = function (resultsFromServer, origJQElement) { + var cursorpos = origJQElement[0].selectionEnd; + var newValue = origJQElement[0].value + var beginning_of_word = newValue.slice(0, cursorpos).lastIndexOf( + " "); + var start = newValue.slice(0, beginning_of_word + 1); + var end = origJQElement[0].value.slice(cursorpos); + var result = resultsFromServer.map(x => { + return { + text: start + x + end, + html: x + } + }); + return result; + }; + + /** + * enables autocompletion in the query field + */ + this.switch_on_completion = function () { + var field = $("#caosdb-query-textarea"); + field.attr("autocomplete", "off"); + field.toggleClass("basicAutoComplete", true); + field.autoComplete({ + events: { + search: this.search, + typed: this.typed, + searchPost: this.searchPost, + }, + noResultsText: 'No autocompletion suggestions', + bootstrapVersion: "3", + + }); + + return field; + }; +}; + +// this will be replaced by require.js in the future. +$(document).ready(function () { + if ("${BUILD_MODULE_EXT_AUTOCOMPLETE}" == "ENABLED") { + caosdb_modules.register(ext_autocomplete); + } +}); diff --git a/src/core/js/ext_bookmarks.js b/src/core/js/ext_bookmarks.js new file mode 100644 index 0000000000000000000000000000000000000000..07de3014825de6c64040e98e3da143d4ad5b8228 --- /dev/null +++ b/src/core/js/ext_bookmarks.js @@ -0,0 +1,658 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + + +/** + * Keep track of bookmarked entities and provide functions for export, viewing + * all entities and resetting the bookmarks. + * + * @module ext_bookmarks + * @version 0.1 + * + * @param jQuery - well-known library. + * @param log - singleton from loglevel library or javascript console. + * @param {ext_bookmarks_config} config + * + * @type ext_bookmarks_config + * @property {function} [get_context_root] - param-less function which returns the + * uri of the collection resource which returns the bookmarked entities. + * @property {string} [data_attribute=data-bmval] - name of the data-attribute + * which contains the bookmark key. + * @property {Storage} [bookmark_storage=localStorage] - the Storage + * implementation which is used for storing bookmarks. + * @property {function} [set_collection_link] - a function which sets the + * collection link. + * @property {function} [set_counter] - a function which sets the counter + * @property {function} [getPaging] - a function which returns the paging string + * @property {function} [set_export_button_click] - a function which sets the click + * event handler of the export button. + * @property {function} [set_clear_button_click] - a function which sets the click + * event handler of the clear button. + * @property {string[]} [tsv_columns=["URI"]] + * @property {function} [get_export_table_row] - function which receives the + * bookmarked id and must return an array of columns. + * @property {object} [data_getters={}] - a dictionary of functions which + * retrieve bookmark data for a given bookmark id and a data_key. + * @property {string[]} [data_no_cache] - an array of data_keys which are not + * to be cached. + */ +var ext_bookmarks = function ($, logger, config) { + + config = config || {}; + + /** + * This counter is used as a cache for the number of current bookmarks. It + * is used to quickly change the counter in the bookmarks menu and lazily + * updated. + */ + var counter = 0; + + /** + * Currently this is mainly usefull for testing, but in the future it might + * be desirable to have multiple bookmark collection at the same time. It + * would be easy to extent this class for this because the collection_id is + * used in the generated links and as part of the storage key in the + * bookmark_storage. + */ + var collection_id = config["collection_id"] || 0; + + const data_getters = config["data_getters"] || { + "URI": (id) => get_context_root() + id + }; + const data_no_cache = config["data_no_cache"] || ["URI"]; + + const data_attribute = config["data_attribute"] || "data-bmval"; + + /** + * Return all bookmark buttons on this page or which are children of scope. + * + * @param {HTMLElement|string} [scope='body'] + * @return {HTMLElement[]} array of bookmark buttons. + */ + const get_bookmark_buttons = function (scope) { + return $(scope || "body").find(`[${data_attribute}]`).toArray(); + } + + /** + * Sets the click event handler of the clear button. + * + * @param {function} cb - event handler. + */ + const set_clear_button_click = config["set_clear_button_click"] || function (cb) { + $("#caosdb-f-bookmarks-clear") + .toggleClass("disabled", !cb) + .on("click", cb); + } + + /** + * Sets the click event handler of the export button. + * + * @param {function} cb - event handler. + */ + const set_export_button_click = config["set_export_button_click"] || function (cb) { + $("#caosdb-f-bookmarks-export-link") + .toggleClass("disabled", !cb) + .on("click", cb); + } + + const getPaging = config["getPaging"] || (() => "?P=0L10"); + + /** + * The storage backend for the bookmarks. + */ + const bookmark_storage = config["bookmark_storage"] || window.localStorage; + + /** + * Set the href attribute of the bookmark collection link. + * + * @param {string} uri + */ + const set_collection_link = config["set_collection_link"] || function (uri) { + const link = $("#caosdb-f-bookmarks-collection-link") + .toggleClass("disabled", !uri) + .find("a"); + if (uri) { + link.attr("href", uri); + } else { + link.removeAttr("href"); + } + } + + /** + * Set the counter badge in the bookmark menu. + */ + const update_counter = config["set_counter"] || function (counter) { + $("#caosdb-f-bookmarks-collection-counter").text(counter); + } + + const get_context_root = config["get_context_root"] || (() => ""); + + /** + * This is used as a prefix of the key in the bookmark_storage. + */ + const key_prefix = "_bm_"; + + /** + * This marker is used to identify uris which specify a bookmark collection + * which should be reloaded. + */ + const uri_marker = "_bm_"; + + /** + * Extract the bookmark id from the bookmark button. + * + * @param {HTMLElement} button - the bookmark button. + * @return {string} the bookmark id. + */ + const get_value = function (button) { + const result = $(button).attr(data_attribute); + return result; + } + + /** + * Construct the prefix of the key for the bookmark_storage. + * + * This can be used to construct the item key and the data key + * to delete all storage keys which belong to the current bookmark + * collection. + * + * @param {string} + */ + const get_collection_prefix = function () { + return key_prefix + collection_id; + } + + /** + * Generate the key for the bookmark_storage. + * + * @param {string} val - the value which is used to generate the key. + */ + const get_key = function (val) { + return get_collection_prefix() + '_it_' + val; + } + + + /** + * These will be the columns in the TSV file. For each column there should + * exist a data_getter. + */ + const tsv_columns = config["tsv_columns"] || ["URI"]; + + /** + * Generate a single TSV row + * + * @return {string[]} array of row columns + */ + const get_export_table_row = async function (id) { + const row = []; + for (var col of tsv_columns) { + row.push(await get_bookmark_data(id, col)); + } + return row; + } + + /** + * Generate the TSV data for the export callback with all current + * bookmarks. + * + * @param {string[]} bookmarks - array of ids. + */ + const get_export_table = async function (bookmarks, preamble, tab, newline) { + // TODO merge with related code in the module "caosdb_table_export". + preamble = ((typeof preamble == 'undefined') ? "data:text/csv;charset=utf-8,": preamble); + tab = tab || "%09"; + newline = newline || "%0A"; + const header = tsv_columns.join(tab) + newline; + const rows = []; + for (let i = 0; i < bookmarks.length; i++) { + rows.push((await get_export_table_row(bookmarks[i])).join(tab)); + } + return `${preamble}${header}${rows.join(newline)}`; + } + + /** + * Trigger the download of the TSV table with all current bookmarks. + * + * This is the call-back for the export button. + */ + const export_bookmarks = async function () { + const ids = get_bookmarks(); + const export_table = await get_export_table(ids); + window.location.href = export_table; + } + + /** + * Return all current bookmarks. + * + * @return {string[]} array of bookmarked ids. + */ + const get_bookmarks = function () { + const result = []; + + const storage_key_prefix = get_key(""); + for (let i = 0; i < bookmark_storage.length; i++) { + const key = bookmark_storage.key(i); + if (key.indexOf(storage_key_prefix) > -1) { + result.push(bookmark_storage[key]); + } + } + return result; + } + + /** + * Update the clear button (i.e. add a click handler which resets the bookmarks.) + * + * @param {string[]} bookmarks - array of ids. + */ + const update_clear_button = function (bookmarks) { + if (bookmarks.length > 0) { + set_clear_button_click(clear_bookmark_storage); + } else { + set_clear_button_click(false); + } + } + + /** + * Update the export button (i.e. add a click handler which generates the + * tsv file.) + * + * @param {string[]} bookmarks - array of ids. + */ + const update_export_link = function (bookmarks) { + if (bookmarks.length > 0) { + set_export_button_click(export_bookmarks); + } else { + set_export_button_click(false); + } + } + + /** + * Generate the uri for the collection of all bookmarked entities. + * + * @param {string[]} bookmarks - array of ids. + * @return {string} uri + */ + const get_collection_link = function (bookmarks) { + const uri_segment = bookmarks.join("&"); + return get_context_root() + uri_segment + getPaging() + + "#" + uri_marker + collection_id; + } + + /** + * Update the link of the collection of bookmarks in the bookmark drop down + * menu. + * + * @param {string[]} bookmarks - array of ids. + */ + const update_collection_link = function (bookmarks) { + if (bookmarks.length > 0) { + const link = get_collection_link(bookmarks); + set_collection_link(link); + } else { + set_collection_link(false); + } + } + + /** + * Syncronize the bookmark_storage and currently visible bookmark button and + * update all buttons and other visible elements and the bookmarks drop + * down menu. + * + * @param {string[]} [bookmarks] - array of ids. If omitted, the + * get_bookmarks function is being called. + */ + const update_collection = function (bookmarks) { + bookmarks = bookmarks || get_bookmarks(); + update_counter(bookmarks.length); + update_collection_link(bookmarks); + update_export_link(bookmarks); + update_clear_button(bookmarks); + } + + /** + * Toggle the active class of the button and change the title of the button + * accordingly. + * + * @param {HTMLElement} button - bookmark button + * @param {boolean} is_active - whether the new state is active or not. + */ + const set_button_state = function (button, is_active) { + $(button).toggleClass("active", is_active); + if (is_active) { + $(button).attr("title", "Remove bookmark"); + } else { + $(button).attr("title", "Add bookmark"); + } + } + + /** + * Event handler for the click event of the bookmark buttons. + * + * Toggles the buttons state and adds or removes the bookmark. + * + * @param {Event} e - the click event; + */ + const toggle_bookmark_active = function (e) { + const button = $(this); + + const new_is_active = !button.is(".active"); + set_button_state(button, new_is_active); + + const value = get_value(button[0]); + const key = get_key(value); + if (new_is_active) { + bookmark_storage.setItem(key, value); + update_counter(++counter); + + // fill the cache immediately. This is a good idea, because many + // data_getters can work on the DOM tree when the bookmark is being + // selected. Later, when the user has left the current page, the + // getters might need to request the database. We want to prevent + // that. + collect_bookmark_data(value); + } else { + bookmark_storage.removeItem(key); + update_counter(--counter); + remove_bookmark_data(value); + } + update_collection(); + } + + /** + * Fill the cache with data for the export for a bookmark. + * + * @param {string} id - bookmark id. + */ + const collect_bookmark_data = function (id) { + for (let data_key in data_getters) { + // do nothing, only trigger the fetching + get_bookmark_data(id, data_key) + } + } + + /** + * Remove all data item which belong to a bookmark. + * + * @param {string} id - bookmark id. + */ + const remove_bookmark_data = function (id) { + const data_key_prefix = get_data_key(id, ""); + remove_from_storage_by_prefix(data_key_prefix); + } + + /** + * Initialize a single bookmark button. + * + * Fetch the state from the bookmark_storage and set the bookmark button to + * active or inactive. Also add the onclick handler which toggles the + * bookmark state. + * + * @param {HTMLElement} button - The bookmark button + */ + const init_button = function (button) { + // load state + const key = get_key(get_value(button)); + const is_bookmarked = !!key && !!bookmark_storage[key]; + set_button_state(button, is_bookmarked); + + // onlick handler + button.onclick = toggle_bookmark_active; + } + + /** + * Remove all items in the bookmark_storage by a prefix. + * + * Useful for resetting the whole bookmark_storage or just deleting a + * single item along with its data items. + * + * @param {string} prefix + */ + const remove_from_storage_by_prefix = function (prefix) { + const keys = []; + for (let i = 0; i < bookmark_storage.length; i++) { + const key = bookmark_storage.key(i); + if (key.indexOf(prefix) > -1) { + keys.push(key); + } + } + for (let i = 0; i < keys.length; i++) { + bookmark_storage.removeItem(keys[i]); + } + } + + /** + * Remove all bookmarks, clear the counter and reset the buttons + */ + const clear_bookmark_storage = function () { + counter = 0; + update_collection([]); + + // reset all buttons + get_bookmark_buttons().forEach((x) => { + set_button_state(x, false); + }); + + const storage_key_prefix = get_collection_prefix() + remove_from_storage_by_prefix(storage_key_prefix); + } + + /** + * Add all bookmarks to storage. + * + * @param {string[]} ids - an array of ids. + */ + const add_all_bookmarks_to_storage = function (ids) { + counter = counter + ids.length; + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + if (id) { + bookmark_storage[get_key(id)] = id + } + } + update_collection(); + } + + /** + * Parse a list of bookmarked entities and the collection_id from and URI. + * + * @param {string} uri + * @return {object} dict with two keys: + * {string[]} bookmarks + * {string} _collection_id + */ + const parse_uri = function (uri) { + var cut_index = uri.indexOf("#" + uri_marker); + if (cut_index > -1) { + // get collection id + const _collection_id = uri + .substring(cut_index + uri_marker.length + 1) + // remove query + const query_marker = uri.indexOf("?"); + if (query_marker > -1) { + cut_index = query_marker; + } + const uri_segments = uri.substring(0, cut_index).split("/") + const ids = uri_segments[uri_segments.length - 1].split("&"); + logger.debug("found ids in uri", ids); + return { + bookmarks: ids, + collection_id: _collection_id + }; + } + return undefined; + } + + + /** + * Initialize all bookmark buttons which are children of scope. + * + * @param {HTMLElement|string} [scope="body"] - element or jquery selector. + */ + const init_bookmark_buttons = function (scope) { + logger.trace("enter init_bookmark_buttons", scope); + $(get_bookmark_buttons(scope)).each((idx, button) => { + init_button(button); + }); + } + + /** + * Setter for the collection_id. + * + * @param {string} id + */ + const set_collection_id = function (id) { + collection_id = id; + } + + /** + * Initialize this module. + */ + const init = async function (scope) { + logger.info("init ext_bookmarks"); + //add_bookmark_buttons(); + counter = 0; + const parsed_uri = parse_uri(window.location.href); + if (typeof parsed_uri != "undefined") { + // this hack removes the "#_bm" marker from the uri without + // reloading the page. + window.location.href = "#"; + + clear_bookmark_storage(); + collection_id = parsed_uri["collection_id"]; + add_all_bookmarks_to_storage(parsed_uri["bookmarks"]); + } + + init_bookmark_buttons(scope); + update_collection(); + if (edit_mode) { + window.document.body.addEventListener(edit_mode.end_edit.type, (e) => { + init_bookmark_buttons(e.target); + }, true); + } + } + + /** + * Construct the key for data items in the bookmark_storage. + */ + const get_data_key = function (id, data_key) { + return get_collection_prefix() + '_da_' + id + "_" + data_key; + } + + /** + * Get a specific data item which belongs to a bookmark. + * + * This is currently prominently used by the tsv-export. + * + * @param {string} id - the bookmarked id + * @param {string} data_key - an identifier for the data item to be + * retrieved. + * @returns {string} the `data_key` of bookmark `id`. + */ + const get_bookmark_data = async function (id, data_key) { + // get full key (needed for the cache) + const full_data_key = get_data_key(id, data_key); + + // retrieve from cache + const cached = bookmark_storage[full_data_key]; + if (typeof cached != "undefined") { + return cached; + } + + // not in cache, try the data_getters + var uncached = undefined + if (data_getters[data_key]) { + uncached = (await data_getters[data_key](id)) + } + + // don't cache if getting the information is trivial or there are other + // reasons why this is in the data_no_cache array. + if (data_no_cache.indexOf(data_key) == -1) { + bookmark_storage[full_data_key] = uncached || ""; + } + return uncached; + } + + return { + init: init, + parse_uri: parse_uri, + get_bookmarks: get_bookmarks, + get_key: get_key, + get_value: get_value, + set_collection_id: set_collection_id, + bookmark_storage: bookmark_storage, + get_export_table: get_export_table, + clear_bookmark_storage, + clear_bookmark_storage, + update_clear_button: update_clear_button, + update_export_link: update_export_link, + update_collection_link: update_collection_link, + get_collection_link: get_collection_link, + get_bookmark_buttons: get_bookmark_buttons, + init_button: init_button, + get_bookmark_data: get_bookmark_data, + } +}; + + + +$(document).ready(function () { + if ("${BUILD_MODULE_EXT_BOOKMARKS}" == "ENABLED") { + // The following is the configuration for the CaosDB WebUI. + const get_context_root = (() => connection.getBasePath() + "Entity/"); + + // This getter retrieves a file's path from the page or, if necessary, + // from the server. + const get_path = async function (id) { + const entity = $(`[id='${id}']`); + if (entity.length > 0) { + return getEntityPath(entity[0]) || ""; + } + return $(await transaction.retrieveEntityById(id)).attr("path"); + } + + // these columns will be in the export + const tsv_columns = ["ID", "Version", "URI", "Path"]; + // functions for collecting the export data for a particular bookmarked id. + const data_getters = { + "ID": (id) => id.split("@")[0], + "Version": (id) => id.split("@")[1], + "Path": get_path, + "URI": (id) => get_context_root() + id, + }; + // no need to cache these because they can be calculated on-the-fly and + // we don't want to polute the bookmark_storage. + const data_no_cache = ["ID", "Version", "URI"]; + + const config = { + get_context_root: get_context_root, + tsv_columns: tsv_columns, + data_getters: data_getters, + data_no_cache: data_no_cache, + }; + + ext_bookmarks = ext_bookmarks($, log.getLogger("ext_bookmarks"), config); + caosdb_modules.register(ext_bookmarks); + } +}); diff --git a/src/core/js/ext_bottom_line.js b/src/core/js/ext_bottom_line.js index 8c657698cfb9d076b1252f99cab8d57e01239f50..ccd65744c9faa38a438c8fae2128c5b8465ecbb6 100644 --- a/src/core/js/ext_bottom_line.js +++ b/src/core/js/ext_bottom_line.js @@ -42,8 +42,10 @@ * @requires load_config (function from caosdb.js) * @requires getEntityPath (function from caosdb.js) * @requires connection (module from webcaosdb.js) + * @requires UTIF (from utif.js library) + * @requires ext_table_preview (module from ext_table_preview.js) */ -var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntityPath, connection, ext_applicable) { +var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntityPath, connection, UTIF, ext_table_preview, ext_applicable) { const contentShownEvent = new Event("ext_bottom_line.content.shown"); const contentReadyEvent = new Event("ext_bottom_line.content.ready"); @@ -89,7 +91,91 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit */ const _create_video_preview = function(entity) { var path = connection.getFileSystemPath() + getEntityPath(entity); - return $(`<div class="caosdb-v-bottom-line-video-preview"><video controls="controls"><source src="${path}"/></video></div>`)[0]; + return $(`<div class="caosdb-v-bottom-line-video-preview"> + <video controls="controls"><source src="${path}"/></video></div>`)[0]; + } + + /** + * Error class which has the special to_html method. + * + * The to_html method creates a html representation of the error which is + * intended for displaying in the bottom_line container. + */ + const BottomLineError = function(arg) { + this._is_bottom_line_error = true; + + if (arg.message) { + // arg is an Error object + this.message = arg.message; + this.stack = arg.stack; + } else { + this.message = arg; + } + + this.to_html = function() { + return $(`<div><p>An error occured while loading this preview.<p>${ + this.message}<div>`)[0]; + } + } + + const BottomLineWarning = function (arg) { + this._is_bottom_line_error = true; + + if (arg.message) { + // arg is an Error object + this.message = arg.message; + this.stack = arg.stack; + } else { + this.message = arg; + } + + this.to_html = function() { + return $(`<div>${this.message}<div>`)[0]; + } + } + + /** + * Create a preview for tiff files. + * + * Tiff files are decompressed if necessary and converted into png by UTIF library. + * + * @param {HTMLElement} entity + * @return {Promise for HTMLElement} Promise for an IMG element. + */ + const _create_tiff_preview = function(entity) { + const path = connection.getFileSystemPath() + getEntityPath(entity); + const result = $(`<div class="caosdb-v-bottom-line-image-preview"></div>`); + const img = $(`<img src="${path}"/>`)[0]; + result.append(img); + + /** + * Promise which retrieves the tiff file and calls the UTIF library for + * decompression and conversion into png. + */ + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + UTIF._xhrs.push(xhr); + UTIF._imgs.push(img); + xhr.open("GET", path); + xhr.responseType = "arraybuffer"; + xhr.onload = (e) => { + try { + // decompress and convert tiff file + UTIF._imgLoaded(e); + + // return the result if no error occured. + resolve(result); + } catch(err) { + // throw errors from UTIF to the awaiting caller. + reject(new BottomLineError(err)); + } + } + // throw http errors to the awaiting caller. + xhr.onerror = reject; + + // this finally triggers the retrieval + xhr.send(); + }); } /** @@ -105,6 +191,8 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit var fallback_preview = undefined; + const _tiff_preview_enabled = "${BUILD_MODULE_EXT_BOTTOM_LINE_TIFF_PREVIEW}" == "ENABLED"; + /** * Default creators. * @@ -114,12 +202,21 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit id: "_default_creators.pictures", is_applicable: (entity) => ext_applicable.helpers.path_has_file_extension( entity, ["jpg", "png", "gif", "svg"]), - create: _create_picture_preview + create: _create_picture_preview, + }, { + id: "_default_creators.tiff_images", + is_applicable: (entity) => _tiff_preview_enabled && _path_has_file_extension( + entity, ["tif", "tiff","dng","cr2","nef"]), + create: _create_tiff_preview, }, { // videos id: "_default_creators.videos", is_applicable: (entity) => ext_applicable.helpers.path_has_file_extension( entity, ["mp4", "mov", "webm"]), create: _create_video_preview, + }, { // tables + id: "_default_creators.table_preview", + is_applicable: (e) => ext_table_preview.is_table(e), + create: (e) => ext_table_preview.get_preview(e), }, ]; @@ -137,6 +234,71 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit _css_class_preview_container}`)[0]; } + /** + * Append a preview to the entity and removes any pre-existing preview. + * + * If the preview is Promise for a preview a waiting notification is added + * to the entity instead and the actual preview is added after the Promise + * is resolved. If the Promise is rejected, a correspondig error is shown + * instead. + * + * @see root_preview_handler + * + * @async + * @param {HTMLElement} entity + * @param {string|HTMLElement|Promise} preview - A preview for an entity or + * a Promise for a preview (which resolves as a string or an HTMLElement as well). + */ + /* + var set_preview = async function(entity, preview) { + try { + const wait = "Please wait..."; + set_preview_container(entity, wait); + const result = await preview; + set_preview_container(entity, result); + if (result) { + entity.dispatchEvent(previewReadyEvent); + } + } catch (err) { + logger.error(err); + if (!err._is_bottom_line_error) { + err = new BottomLineError(err); + } + set_preview_container(entity, err.to_html()); + } + }*/ + + /** + * Create and return a preview for a given entity. + * + * This root_preview_creator iterates over all the registered creators and + * uses the first match, i.e. the first creator object which return true + * for the `is_applicable(entity)` method of the creator object. + * + * If a creator throws an error during checking whether it `is_applicable` + * or during the `create` the error is logged and the creator is treated as + * if it were not applicable. + * + * @async + * @param {HTMLElement} entity - the entity for which the preview is to be + * created. + * @returns {String|HTMLElement|Promise} A preview which can be added to + * the entity DOM representation or a Promise for such a preview. + */ + /* + var root_preview_creator = async function(entity) { + for (let c of _creators) { + try { + if (await c.is_applicable(entity)) { + return c.create(entity); + } + } catch (err) { + logger.error(err); + } + } + return undefined; + };*/ + /** * Add a preview container to the entity. * @@ -274,7 +436,7 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit get_container: get_container, _css_class_preview_container: _css_class_preview_container, } -}($, log.getLogger("ext_bottom_line"), resolve_references.is_in_viewport_vertically, load_config, getEntityPath, connection, ext_applicable); +}($, log.getLogger("ext_bottom_line"), resolve_references.is_in_viewport_vertically, load_config, getEntityPath, connection, UTIF, ext_table_preview, ext_applicable); /** @@ -297,20 +459,23 @@ var plotly_preview = function(logger, ext_bottom_line, plotly) { * to be plotted. * @param {object[]} layout - dictionary of settings defining the layout of * the plot. + * @param {object[]} settings - object containing additional + * settings for the plot. * @returns {HTMLElement} the element which contains the plot. */ const create_plot = function(data, - layout = { - margin: { - t: 0 - }, - height: 400, - widht: 400 - }) { + layout = { + margin: { + t: 0 + }, + height: 400, + widht: 400 + }, + settings = { + responsive: true + }) { var div = $('<div/>')[0]; - plotly.newPlot(div, data, layout, { - responsive: true - }); + plotly.newPlot(div, data, layout, settings); return div; } @@ -338,7 +503,7 @@ var plotly_preview = function(logger, ext_bottom_line, plotly) { // this will be replaced by require.js in the future. $(document).ready(function() { - if ("${BUILD_MODULE_EXT_BOTTOM_LINE}" === "ENABLED") { + if ("${BUILD_MODULE_EXT_BOTTOM_LINE}" == "ENABLED") { caosdb_modules.register(plotly_preview); caosdb_modules.register(ext_bottom_line); } diff --git a/src/core/js/ext_file_download.js b/src/core/js/ext_file_download.js new file mode 100644 index 0000000000000000000000000000000000000000..477349b421e03d74aa28fddf6b4748f64ffa9f7b --- /dev/null +++ b/src/core/js/ext_file_download.js @@ -0,0 +1,186 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +/** + * The ext_file_download module provides a very basic preview for table files. + * + * The preview is generated using a server side script. + * + * @module ext_file_download + * @version 0.1 + * + * @requires jQuery + * @requires log + */ +var ext_file_download = function ($, logger) { + + /** + * collect the file ids that will be passed to the zip script + * + * @return {string[]} array of entity ids + */ + const collect_ids = function(){ + var properties = $(".caosdb-f-property-value").find( + ".caosdb-id.caosdb-f-property-single-raw-value"); + var id_list = properties.toArray().map(x=>x.textContent); + var entities = $("tr[data-entity-id]") + id_list = id_list.concat(entities.toArray().map( + x=>x.attributes['data-entity-id'].value)); + return id_list + }; + + /** + * chunk list to smaller pieces. + * + * @param {string[]} list - array of (usually) ids + * @param {Number} size - chunk size (use integer) + * @return {string[][]} array of array of string from the original list. + */ + const chunk_list = function(list, size){ + size = size || 20; + var pieces = []; + var index = 0; + while (index < list.length){ + pieces.push(list.slice(index,index+size)); + index+=size; + } + + return pieces + }; + + + /** + * create select statement to find files. + * + * @param {string[]} id_list - array of ids + * @return {string} a query string + */ + const query_files_str = function(id_list){ + var povs = id_list.map(x=>` id=${x} `); + const query_str="SELECT ID FROM FILE WITH " + povs.join("or"); + return query_str; + }; + + /** + * Reduce id list to files. Throw away all ids which do not belong to a + * file entity. + * + * @param {string[]} id_list - array of ids + * @return {string[]} array of file ids. + */ + const reduce_ids = async function(id_list){ + var file_ids = [] + for (var part of chunk_list(id_list)) { + // query for files + var result = await query(query_files_str(part)); + file_ids=file_ids.concat(result.map(x => getEntityID(x))); + } + return file_ids; + }; + + + /** + * Callback function for the download files link. + * + * Collects all the file entities and sends them to a server-side script + * which then puts the files into a zip. + * + * @param {HTMLElement} zip_link - the link element which triggered this + * call and which will be disabled during the execution of this + * function in order to prevent the user from triggering the process + * twice. + */ + const download_files = async function (zip_link) { + const onClickValue = zip_link.getAttribute("onClick"); + try { + // remove click event handler which called this function in the first place + zip_link.removeAttribute("onClick"); + + // add loading info. TODO make an animated one + $("#downloadModal").find(".caosdb-f-modal-footer-left").append( + createWaitingNotification("Collecting files...") + ); + + var ids = collect_ids(); + ids = await reduce_ids(ids); + if (ids.length == 0){ + alert ("There are no file entities in this table."); + return; + } + + var table = await ext_bookmarks.get_export_table(ids, "", "\t", "\n"); + + const result = await connection.runScript( + "ext_file_download/zip_files.py", + { + "-p0": ids, + "-p1": table, + } + ); + const code = result.getElementsByTagName("script")[0].getAttribute("code"); + if (parseInt(code) > 0) { + throw ("An error occurred during execution of the server-side script:\n" + + result.getElementsByTagName("script")[0].outerHTML); + } + const filename = result.getElementsByTagName("stdout")[0].textContent; + if (filename.length == 0) { + throw("Server-side script produced no file or did not return the file name: \n" + + result.getElementsByTagName("script")[0].outerHTML); + } + + + // trigger download of generated file + caosdb_table_export.go_to_script_results(filename); + + //close modal + $("#downloadModal").find(".modal-footer").find(".btn")[0].click(); + } catch (e) { + globalError(e); + } finally { + removeAllWaitingNotifications($("#downloadModal")[0]); + // restore the old click handler - hence a new file is generated with each click. + zip_link.setAttribute("onClick", onClickValue); + } + + }; + + const init = function () { + // only enable when init is being called + logger.info("init ext_file_download"); + if (userIsAnonymous()) { + $("#caosdb-f-query-select-files").parent().hide(); + } + }; + + return { + init: init, + download_files: download_files, + collect_ids: collect_ids, + chunk_list: chunk_list, + }; + +}($, log.getLogger("ext_file_download")); + +// This module is registered by caosdb_table_export. diff --git a/src/core/js/ext_references.js b/src/core/js/ext_references.js index d9829cb9959c1249a4381bd45be9429f7dbef855..7cd597e128e8c09da9134f42f542898fb84a4e53 100644 --- a/src/core/js/ext_references.js +++ b/src/core/js/ext_references.js @@ -450,9 +450,11 @@ var resolve_references = new function () { // Loop over all property values in the container element. Note: each property_value can be a single reference or a list of references. for (const property_value of property_values) { - const lists = $(property_value).find( - ".caosdb-value-list").has( - `.${_unresolved_class_name}`); + var lists = findElementByConditions( + property_value, + x => x.classList.contains("caosdb-value-list"), + x => x.classList.contains("caosdb-preview-container")) + lists = $(lists).has(`.${_unresolved_class_name}`); if (lists.length > 0) { logger.debug("processing list of references", lists); @@ -525,8 +527,10 @@ var resolve_references = new function () { // Load all remaining references. These are single reference values // and those references from lists which are left for lazy loading. - const rs = $(property_value).find( - `.${_unresolved_class_name}`); + const rs = findElementByConditions( + property_value, + x => x.classList.contains(`${_unresolved_class_name}`), + x => x.classList.contains("caosdb-preview-container")); for (var i = 0; i < rs.length; i++) { if (resolve_references.is_in_viewport_vertically( rs[i]) && diff --git a/src/core/js/ext_table_preview.js b/src/core/js/ext_table_preview.js new file mode 100644 index 0000000000000000000000000000000000000000..1d9da6fa9334a52de5eb39a66b585c7eb67cd120 --- /dev/null +++ b/src/core/js/ext_table_preview.js @@ -0,0 +1,97 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +/** + * The ext_table_preview module provides a very basic preview for table files. + * + * The preview is generated using a server side script. + * + * @module ext_table_preview + * @version 0.1 + * + * @requires jQuery + * @requires log + * @requires getEntityPath + * @requires getEntityID + * @requires markdown + */ +var ext_table_preview = function ($, logger, connection, getEntityPath, getEntityID, markdown) { + + const get_preview = async function (entity) { + try { + const script_result = await connection.runScript("ext_table_preview/pandas_table_preview.py", + {"-p0": getEntityID(entity)} + ); + + const code = script_result.getElementsByTagName("script")[0].getAttribute("code"); + if (parseInt(code) > 1) { + return script_result.getElementsByTagName("stderr")[0] + } else if (parseInt(code) != 0) { + throw ("An error occurred during execution of the server-side " + + "script:\n" + + script_result.getElementsByTagName("stderr")[0]); + } else { + const tablecontent = script_result.getElementsByTagName("stdout")[0]; + const unformatted = markdown.textToHtml(tablecontent.textContent) + const formatted = $('<div class="table-responsive"/>').append(unformatted); + formatted.find("table").addClass("table table-bordered table-condensed").removeAttr("border"); + return formatted[0]; + } + } catch (err) { + if (err.message && err.message.indexOf && err.message.indexOf("HTTP status 403") > -1) { + throw new ext_bottom_line.BottomLineWarning("You are not allowed to generate the table preview. Please log in."); + } else { + throw err; + } + } + }; + + const is_table = function (entity) { + const path = getEntityPath(entity); + return path && (path.toLowerCase().endsWith('.xls') + || path.toLowerCase().endsWith('.xlsx') + || path.toLowerCase().endsWith('.csv') + || path.toLowerCase().endsWith('.tsv')); + }; + + const init = function () { + // only enable when init is being called + ext_table_preview.is_table = is_table; + }; + + return { + init: init, + get_preview: get_preview, + is_table: () => false, + }; + +}($, log.getLogger("ext_table_preview"), connection, getEntityPath, getEntityID, markdown); + +// this will be replaced by require.js in the future. +$(document).ready(function () { + if ("${BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW}" == "ENABLED") { + caosdb_modules.register(ext_table_preview); + } +}); diff --git a/src/core/js/ext_xls_download.js b/src/core/js/ext_xls_download.js index 0f607a459b8e502e1d4c451b1636d99ceb01657c..fbedd0c6cd10c75c3d0b2914de1a1eef9cb7b1f0 100644 --- a/src/core/js/ext_xls_download.js +++ b/src/core/js/ext_xls_download.js @@ -24,7 +24,7 @@ /** * @module caosdb_table_export - * @version 0.1 + * @version 0.2 * * Convert Entities for TSV and XLS export. * @@ -35,8 +35,6 @@ var caosdb_table_export = new function () { var logger = log.getLogger("caosdb_table_export"); - var TAB = "%09"; - var NEWLINE = "%0A"; /** * Hide "Download XLS File" link if the user is not authenticted (i.e. @@ -44,11 +42,7 @@ var caosdb_table_export = new function () { */ this.init = function() { logger.info("init caosdb_table_export"); - // TODO with AMD, use userIsAnonymous() - if (Array.from( - document.getElementsByClassName("caosdb-user-role")).map( - el => el.innerText - ).filter(el => el == "anonymous").length > 0) { + if (userIsAnonymous()) { $(".caosdb-v-query-select-data-xsl").parent().hide(); } } @@ -62,19 +56,62 @@ var caosdb_table_export = new function () { * @return {string} */ this.get_tsv_string = function(raw) { + const columns = this._get_column_header(); + const entities = this._get_table_content(); + return this._create_tsv_string(entities, columns, raw); + } + + /** + * In order to create a valid tsv table, characters that have a special + * meaning (line break and tab) need to be removed. + * + * Both characters will be replaced by a single white space + * + * @param {string} raw - the cell content. + * @return {string} cleaned up content + */ + this._clean_cell = function(raw) { + return raw.replaceAll("\t"," ").replaceAll("\n"," ") + } + + /** + * Finds the table in the DOM tree and returns the column names as array + * + * @return {string[]} + */ + this._get_column_header = function() { const table = $('.caosdb-select-table'); - const columns = table.find("th").toArray() - .map(e => e.textContent) + return table.find("th").toArray() + .map(e => caosdb_table_export._clean_cell(e.textContent)) .filter(e => e.length > 0); + } + + /** + * Finds the table in the DOM tree and returns the table content as array + * + * @return {HTMLElement[]} + */ + this._get_table_content = function() { + const table = $('.caosdb-select-table'); // TODO use entity-panel class in table as well (change in query.xsl // and then here) - const entities = table.find("tbody tr").toArray(); - const csv_string = this._get_tsv_string(entities, columns, raw); - return csv_string; + return table.find("tbody tr").toArray(); + } + + /** + * Encode tsv string + * + * @param {string} main - the tsv table string. + * @return {string} the same with prefix and URL encoded + */ + this._encode_tsv_string = function(main) { + const preamble = "data:text/csv;charset=utf-8,"; + main = encodeURIComponent(main); + return `${preamble}${main}`; } /** - * Convert all entities to a tsv string with the given columns. + * Convert all entities to an encoded tsv string with the given columns. * * @param {HTMLElement[]} entities - entities which are converted to rows * of the tsv string. @@ -83,36 +120,56 @@ var caosdb_table_export = new function () { * cells. Otherwise, the displayed data is used instead. * @return {string} */ - this._get_tsv_string = function (entities, columns, raw) { - logger.trace("enter get_tsv_string", entities, columns); - var preamble = "data:text/csv;charset=utf-8,"; - var header = "ID" + TAB + columns.join(TAB) + NEWLINE - var rows = caosdb_table_export._get_tsv_rows(entities, columns, raw).join(NEWLINE); - - const ret = `${preamble}${header}${rows}`; - logger.trace("leave get_tsv_string", ret); - return ret; + this._create_tsv_string = function (entities, columns, raw) { + logger.trace("enter _create_tsv_string ", entities, columns); + var header = "ID\t" + columns.join("\t") + "\n" + var rows = []; + for (const table_row of entities) { + rows.push(caosdb_table_export._get_entity_row(table_row, columns, raw).join("\t")); + } + var rows = rows.join("\n"); + logger.trace("leave _create_tsv_string ", rows); + return `${header}${rows}`; } /** - * Return an array of rows with the given columns of the tsv table, one per - * entity. + * Return an array of cells, one per column, which contain a string + * representation of the value of the properties with the same name (as the + * column). * - * @param {HTMLElement[]} entities - entities which are converted to rows - * of the tsv string. + * @param {HTMLElement} entity - entity from which the cells are extracted. * @param {string[]} columns - array of property names. * @param {boolean} raw - if true, the raw entity ids are put into the * cells. Otherwise, the displayed data is used instead. * @return {string[]} */ - this._get_tsv_rows = function (entities, columns, raw) { - var rows = []; - for (const entity of entities) { - rows.push(this._get_entity_row(entity, columns, raw)); + this._get_entity_row = function (entity, columns, raw) { + var cells = [getEntityID(entity)]; + var properties = getProperties(entity); + + for (const column of columns) { + var cell = ""; + for (const property of properties) { + if(property.name.toLowerCase() === column.toLowerCase()) { + var value = caosdb_table_export + ._get_property_value(property.html); + if (raw) { + cell = value.raw; + } else if (value.summary) { + cell = value.summary; + } else if (value.pretty) { + cell = value.pretty; + } else { + cell = value.raw; + } + } + } + cells.push(caosdb_table_export._clean_cell(cell)); } - return rows; - } + logger.trace("leave _get_entity_row", cells); + return cells; + } /** * Return different string representations of the property's value. @@ -187,73 +244,15 @@ var caosdb_table_export = new function () { } } - - /** - * Return an array of cells, one per column, which contain a string - * representation of the value of the properties with the same name (as the - * column). - * - * @param {HTMLElement} entity - entity from which the cells are extracted. - * @param {string[]} columns - array of property names. - * @param {boolean} raw - if true, the raw entity ids are put into the - * cells. Otherwise, the displayed data is used instead. - * @return {string[]} - */ - this._get_entity_row = function (entity, columns, raw) { - var cells = [getEntityID(entity)]; - var properties = getProperties(entity); - - for (const column of columns) { - var cell = ""; - for (const property of properties) { - if(property.name.toLowerCase() === column.toLowerCase()) { - var value = caosdb_table_export - ._get_property_value(property.html); - if (raw) { - cell = value.raw; - } else if (value.summary) { - cell = value.summary; - } else if (value.pretty) { - cell = value.pretty; - } else { - cell = value.raw; - } - } - } - cells.push(cell); - } - - logger.trace("leave _get_entity_row", cells); - return cells.join(TAB); - } - - /** * Open the resulting xls file by setting href to the location of the resulting * file in the server's `Shared` resource and imitate a click. */ - this._go_to_script_results = function (xls_link, filename) { - xls_link.setAttribute( - "href", - location.protocol + "//" +location.host + "/Shared/" + filename); - xls_link.click(); - } - - - this._get_csv_string = function (){ - const raw = $("input#caosdb-table-export-raw-flag-xls").is(":checked"); - const csv_string = caosdb_table_export.get_tsv_string(raw); - //const csv_string = document.getElementById("caosdb-f-query-select-data-tsv").getAttribute( - //"href"); - if (!csv_string) { - return undefined; - } - - return decodeURIComponent(csv_string.replace(/^data.*utf-8,/, "")); + this.go_to_script_results = function (filename) { + window.location.href = connection.getBasePath() + "Shared/" + filename; } } - /** * This function is called on click by the link button which says "Download TSV * File". @@ -264,12 +263,12 @@ var caosdb_table_export = new function () { */ function downloadTSV(tsv_link) { const raw = $("input#caosdb-table-export-raw-flag-tsv").is(":checked"); - const tsv_string = caosdb_table_export.get_tsv_string(raw); + var tsv_string = caosdb_table_export.get_tsv_string(raw); + tsv_string = caosdb_table_export._encode_tsv_string(tsv_string); $(tsv_link).attr("href", tsv_string); return true; } - /** * This function is called on click by the link button which says "Download XLS * File". @@ -279,15 +278,24 @@ function downloadTSV(tsv_link) { * resulting file. */ async function downloadXLS(xls_link) { - const csv_string = caosdb_table_export._get_csv_string(); - - // remove click event handler which called this function in the first place const onClickValue = xls_link.getAttribute("onClick"); - xls_link.removeAttribute("onClick"); - try { + // remove click event handler which called this function in the first place + xls_link.removeAttribute("onClick"); + + // add loading info. TODO make an animated one + $("#downloadModal").find(".caosdb-f-modal-footer-left").append( + createWaitingNotification("Exporting table...") + ); + + const raw = $("input#caosdb-table-export-raw-flag-xls").is(":checked"); + const tsv_string = caosdb_table_export.get_tsv_string(raw); + // TODO This existed previously. I do not know why. + if (!tsv_string) { + tsv_string = undefined; + } const xls_result = await connection.runScript("xls_from_csv.py", - {"-p0": {"filename": "selected.tsv", "blob": new Blob([csv_string], {type: "text/tab-separated-values;charset=utf-8"})}}); + {"-p0": {"filename": "selected.tsv", "blob": new Blob([tsv_string], {type: "text/tab-separated-values;charset=utf-8"})}}); const code = xls_result.getElementsByTagName("script")[0].getAttribute("code"); if (parseInt(code) > 0) { throw ("An error occurred during execution of the server-side script:\n" @@ -299,11 +307,14 @@ async function downloadXLS(xls_link) { + xls_result.getElementsByTagName("script")[0].outerHTML); } - // set the href in order to download the file and simulate a click. - caosdb_table_export._go_to_script_results(xls_link, filename); + // trigger download of generated file + caosdb_table_export.go_to_script_results(filename); + + } catch (e) { globalError(e); } finally { + removeAllWaitingNotifications($("#downloadModal")[0]); // restore the old click handler - hence a new file is generated with each click. xls_link.setAttribute("onClick", onClickValue); } @@ -314,4 +325,5 @@ async function downloadXLS(xls_link) { $(document).ready(function () { caosdb_modules.register(caosdb_table_export); + caosdb_modules.register(ext_file_download); }); diff --git a/src/core/js/form_elements.js b/src/core/js/form_elements.js index 0014f4db452a55839edaa860d7fa49f98529434e..47c8e0ce0cf0df2599c6ea1f2afee941edfab4cc 100644 --- a/src/core/js/form_elements.js +++ b/src/core/js/form_elements.js @@ -9,8 +9,7 @@ * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * @@ -164,6 +163,113 @@ var form_elements = new function () { } + this._get_alert_decision = function (key) { + return localStorage["form_elements.alert_decision." + key]; + } + + this._set_alert_decision = function (key, val) { + localStorage["form_elements.alert_decision." + key] = val; + } + + /** + * @type {AlertConfig} + * @property {string} [title] - an optional title for the alert. + * @property {string} [severity="danger"] - a bootstrap class suffix. Other + * examples: warning, info + * @property {string} message - informs the user what they are about to do. + * @property {function} proceed_callback - the function which is called + * then the user hits the "Proceed" button. + * @property {function} [cancel_callback] - a callback which is called then + * the cancel button is clicked. By default, only the alert is being + * closed an nothing happens. + * @property {string} [proceed_text="Proceed"] - the text on the proceed button. + * @property {string} [cancel_text="Cancel"] - the text on the cancel button. + * @property {string} [remember_my_decision_id] - if this parameter is + * present, a checkbox is appended to the alert ("Don't ask me + * again."). If the checkbox is checked the next time the make_alert + * function is called with the same remember_my_decision_id is created, + * the alert won't show up and the proceed_callback is called without + * any user interaction. + * @property {string} [remember_my_decision_text="Don't ask me again."] - + * label text for the checkbox. + * @property {HTMLElement} [proceed_button] - an optional custom proceed + * button. + * @property {HTMLElement] [cancel_button] - an optional custom cancel + * button. + */ + + /** + * Make an alert, that is a dialog which can intercept a function call and + * asks the user to proceed or cancel. + * + * @param {AlertConfig} config + * @return {HTMLElement} + */ + this.make_alert = function (config) { + caosdb_utils.assert_string(config.message, "config param `message`"); + caosdb_utils.assert_type(config.proceed_callback, "function", + "config param `proceed_callback`"); + + // define some defaults. + const title = config.title ? `<h4>${config.title}</h4>` : ""; + const proceed_text = config.proceed_text || "Proceed"; + const cancel_text = config.cancel_text || "Cancel"; + const severity = config.severity || "danger"; + const remember = !!config.remember_my_decision_id || false; + + // check if alert should be created at all + if (remember) { + var result = this._get_alert_decision(config.remember_my_decision_id); + if (result == "proceed") { + // call callback asyncronously and return + (async function(){ config.proceed_callback(); })(); + return undefined; + } + } + + // create the alert + const _alert = $(`<div class="alert alert-${severity} + alert-dismissible fade in caosdb-f-form-elements-alert" role="alert">${title} + <p>${config.message}</p> + </div>`); + + // create the "Don't ask me again" checkbox + var checkbox = undefined; + if (remember) { + const remember_my_decision_text = config.remember_my_decision_text + || "Don't ask me again."; + checkbox = $(`<p class="checkbox"><label> + <input type="checkbox"/> ${remember_my_decision_text}</label></p>`); + _alert.append(checkbox); + } + + + // create buttons ... + const cancel_button = config.cancel_button || $(`<button type="button" class="btn btn-default caosdb-f-btn-alert-cancel">${cancel_text}</button>`); + const proceed_button = config.proceed_button || $(`<button type="button" class="btn btn-${severity} caosdb-f-btn-alert-proceed">${proceed_text}</button>`); + _alert.append($("<p/>").append([proceed_button, cancel_button])); + + + // ... and bind callbacks to the buttons. + cancel_button.click(() => { + $(_alert).alert('close'); + if (typeof config.cancel_callback == "function") { + config.cancel_callback(); + } + }); + proceed_button.click(() => { + if (remember && checkbox.find("input[type='checkbox']").is(":checked")) { + // store this decision. + form_elements._set_alert_decision(config.remember_my_decision_id, + "proceed"); + } + $(_alert).alert('close'); + config.proceed_callback(); + }); + + return _alert[0]; + } + /** * (Re-)set this module's functions to standard implementation. */ diff --git a/src/core/js/webcaosdb.js b/src/core/js/webcaosdb.js index df93703d83fafde15908efb40c3ae578824980d9..ac4d908bca32e0f8bf0f0645a6f4d5bc9d628ada 100644 --- a/src/core/js/webcaosdb.js +++ b/src/core/js/webcaosdb.js @@ -146,6 +146,64 @@ this.navbar = new function () { .on("hidden.bs.collapse", function (e) { logger.trace("navbar shrinks", e); }); + this.init_login_show_button(); + } + + + /** + * Initialize the hiding/showing of the input form. + * + * If the viewport is xs (width <= 768px) the login form is hidden in the + * a menu anyways. + * + * If the viewport is greater then the folloging applies: + * + * When loading the page, the login form is hidden behind a "Login" button. + * When the user clicks the Login button the form appears and the other + * button disappears. After a timeout of 10 seconds of inactivity the form + * hides again. When the user starts typing, the timeout is canceled. + * + * If the user leaves the input fields ("blur" event) the timeout is + * reestablished and the form hides after 10 seconds. + */ + this.init_login_show_button = function () { + const form = $("#caosdb-f-login-form"); + const show_button = $("#caosdb-f-login-show-button"); + var timeout = undefined; + + // show form and hide the show_button + const _in = () => { + // xs means viewport <= 768px + form.removeClass("visible-xs-inline-block"); + show_button.addClass("hidden"); + } + // hide form and show the show_button + const _out = () => { + // xs means viewport <= 768px + form.addClass("visible-xs-inline-block"); + show_button.removeClass("hidden"); + } + show_button.on("click", () => { + // show form... + _in(); + + // and hide it after ten seconds if nothing happens + timeout = setTimeout(_out,10000) + }); + form.find("input,button").on("blur", () => { + if (timeout) { + // cancel existing timeout (e.g. from showing) + clearTimeout(timeout); + } + // hide after 10 seconds if nothing happens + timeout = setTimeout(_out,10000) + }); + form.find("input,button").on("change", () => { + // something happens! + if (timeout) { + clearTimeout(timeout); + } + }); } @@ -592,7 +650,8 @@ this.transaction = new function () { * @return {Element[]} array of xml elements. */ this.retrieveEntitiesById = async function _rEBIs(entityIds) { - return $(await connection.get(this.generateEntitiesUri(entityIds))).find('Response [id]').toArray(); + const response = await connection.get(this.generateEntitiesUri(entityIds)); + return $(response).find('Response [id]').toArray(); } /** Sends a PUT request with an xml representation of entities and @@ -979,6 +1038,9 @@ var paging = new function () { return null; } + // remove fragment + uri_old = uri_old.split("#")[0]; + var pattern = /(\?(.*&)?)P=([^&]*)/; if (pattern.test(uri_old)) { // replace existing P=... @@ -1003,7 +1065,7 @@ var paging = new function () { if (uri == null) { throw new Error("uri was null."); } - var pattern = /\?(.*&)?P=([^&]*)/; + var pattern = /\?(.*&)?P=([^&#]*)/; var ret = pattern.exec(uri); return (ret != null ? ret[2] : null); } @@ -1121,7 +1183,6 @@ var queryForm = new function () { See https://developer.mozilla.org/en-US/docs/Web/Events/submit why this is necessary. */ var submithandler = function () { - // store current query var queryField = form.query; var value = queryField.value.toUpperCase(); @@ -1141,6 +1202,13 @@ var queryForm = new function () { queryForm.redirect(queryField.value, paging); }; + $("#caosdb-query-textarea").on("keydown", (e) => { + // prevent submit on enter + if(e.originalEvent.which == 13) { + e.originalEvent.preventDefault(); + } + }) + // handler for the form form.onsubmit = function (e) { diff --git a/src/core/xsl/entity.xsl b/src/core/xsl/entity.xsl index 299b1dac37a1029e5bc7c48008efa3735d61037b..826961b9e3f748daf73017715348fa3682bcbb1a 100644 --- a/src/core/xsl/entity.xsl +++ b/src/core/xsl/entity.xsl @@ -156,6 +156,12 @@ <span class="label caosdb-id caosdb-id-button hidden"> <xsl:value-of select="@id"/> </span> + <button class="btn btn-link caosdb-v-bookmark-button"> + <xsl:attribute name="data-bmval"> + <xsl:value-of select="@id"/>@<xsl:value-of select="Version/@id"/> + </xsl:attribute> + <span class="glyphicon glyphicon-bookmark"/> + </button> <xsl:apply-templates mode="entity-heading-attributes-version" select="Version"> <xsl:with-param name="entityId" select="@id"/> </xsl:apply-templates> @@ -392,8 +398,8 @@ <xsl:otherwise> <xsl:choose> <!-- the referenced entities have been returned. --> - <xsl:when test="*[@id]"> - <xsl:for-each select="*[@id]"> + <xsl:when test="Record|RecordType|Property|File"> + <xsl:for-each select="Record|RecordType|Property|File"> <xsl:call-template name="single-value"> <xsl:with-param name="reference"> <xsl:value-of select="'true'"/> diff --git a/src/core/xsl/main.xsl b/src/core/xsl/main.xsl index 96ed02c906bc9f04949c44e219f0eedf477af4f2..920e7dabac03a49c8e16636907b60f2b9231ff93 100644 --- a/src/core/xsl/main.xsl +++ b/src/core/xsl/main.xsl @@ -142,6 +142,16 @@ <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/webcaosdb.js')"/> </xsl:attribute> </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/pako.js')"/> + </xsl:attribute> + </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/utif.js')"/> + </xsl:attribute> + </xsl:element> <script> $(document).ready(() => paging.initPaging(window.location.href, <xsl:value-of select="/Response/@count"/> )); </script> @@ -157,7 +167,7 @@ </xsl:element> <xsl:element name="script"> <xsl:attribute name="src"> - <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/autocomplete.js')"/> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_autocomplete.js')"/> </xsl:attribute> </xsl:element> <xsl:element name="script"> @@ -170,6 +180,11 @@ <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_references.js')"/> </xsl:attribute> </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_table_preview.js')"/> + </xsl:attribute> + </xsl:element> <xsl:element name="script"> <xsl:attribute name="src"> <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_xls_download.js')"/> @@ -195,6 +210,11 @@ <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/edit_mode.js')"/> </xsl:attribute> </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_file_download.js')"/> + </xsl:attribute> + </xsl:element> <xsl:element name="script"> <xsl:attribute name="src"> <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/leaflet.js')"/> @@ -260,6 +280,11 @@ <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_trigger_crawler_form.js')"/> </xsl:attribute> </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_bookmarks.js')"/> + </xsl:attribute> + </xsl:element> <!--JS_EXTENSIONS--> </xsl:template> <xsl:template name="caosdb-data-container"> diff --git a/src/core/xsl/navbar.xsl b/src/core/xsl/navbar.xsl index 907f27e512250cf5fde0ff2fe93fe85f800428a6..4d83264e88bbcb27e77ca0973c4d6d788ab54ea1 100644 --- a/src/core/xsl/navbar.xsl +++ b/src/core/xsl/navbar.xsl @@ -132,6 +132,23 @@ </ul> </xsl:if> <ul class="nav navbar-nav navbar-right"> + <li class="dropdown"> + <a class="dropdown-toggle" data-toggle="dropdown" href="#"> + <span id="caosdb-f-bookmarks-collection-counter" class="badge">0</span> + Bookmarks + <span class="caret"></span></a> + <ul class="dropdown-menu"> + <li class="disabled" id="caosdb-f-bookmarks-collection-link" + title="Show all bookmarked entities."> + <a>Show all</a></li> + <li class="disabled" id="caosdb-f-bookmarks-export-link" + title="Export all bookmarks to a file."> + <a>Export to file</a></li> + <li class="disabled" id="caosdb-f-bookmarks-clear" + title="Empty the list of bookmarks."> + <a>Clear</a></li> + </ul> + </li> <xsl:call-template name="caosdb-user-menu"/> </ul> </div> @@ -191,17 +208,15 @@ </xsl:when> <xsl:otherwise> <li id="user-menu"> - <form class="navbar-form" method="POST"> + <form id="caosdb-f-login-form" class="navbar-form visible-xs-inline-block" method="POST"> <xsl:attribute name="action"> <xsl:value-of select="concat($basepath, 'login')"/> </xsl:attribute> <input class="form-control" id="username" name="username" placeholder="username" type="text"/> <input class="form-control" id="password" name="password" placeholder="password" type="password"/> - <button class="btn btn-default" type="submit"> - <span class="glyphicon glyphicon-log-in"></span> - Login - </button> + <button class="btn btn-primary" type="submit">Login</button> </form> + <button style="margin-right: 15px" class="btn btn-default navbar-btn hidden-xs" id="caosdb-f-login-show-button" type="button">Login</button> </li> </xsl:otherwise> </xsl:choose> diff --git a/src/core/xsl/query.xsl b/src/core/xsl/query.xsl index be49ed7d889e920cfee87e723c4c5c3b8efb27b2..ca1884aea16d59c7df92de304d3154698dc471bf 100644 --- a/src/core/xsl/query.xsl +++ b/src/core/xsl/query.xsl @@ -89,7 +89,7 @@ </div> <div class="col-xs-6 text-right"> <!-- Trigger the modal with a button --> - <button class="btn btn-info btn-sm" data-target="#downloadModal" data-toggle="modal" type="button">Download this table</button> + <button class="btn btn-info btn-sm caosdb-v-btn-select" data-target="#downloadModal" data-toggle="modal" type="button">Export</button> <!-- Modal --> <div class="modal fade text-left" id="downloadModal" role="dialog"> <div class="modal-dialog"> @@ -102,16 +102,21 @@ <div class="modal-body"> <p> <a id="caosdb-f-query-select-data-tsv" onclick="downloadTSV(this)" href="#selected_data.tsv" download="selected_data.tsv"> - Download TSV File + Download table as TSV File </a> <span class="checkbox" style="margin-top: 0; display: inline; position: absolute; right: 10px"><label><input type="checkbox" name="raw" id="caosdb-table-export-raw-flag-tsv" title="Export raw entity ids instead of the visible page content."/>raw</label></span> </p> <p> <a class="caosdb-v-query-select-data-xsl" onclick="downloadXLS(this)" href="#selected_data.xsl" download=""> - Download XLS File + Download table as XLS File </a> <span class="checkbox" style="margin-top: 0; display: inline; position: absolute; right: 10px"><label><input type="checkbox" name="raw" id="caosdb-table-export-raw-flag-xls" title="Export raw entity ids instead of the visible page content."/>raw</label></span> </p> + <p> + <a id="caosdb-f-query-select-files" onclick="ext_file_download.download_files(this)" href="#selected_data.tsv" download="files.zip" title="Collects file entities listed in the table in a zip file. If the entity belonging to a row is a file entity, it will be included."> + Download files referenced in the table + </a> + </p> <hr/> <p> <small>Download this dataset in Python with:</small> @@ -123,7 +128,13 @@ </p> </div> <div class="modal-footer"> - <button class="btn btn-default" data-dismiss="modal" type="button">Close</button> + <div class="row" style="margin:0px"> + <div class="col-xs-6 caosdb-f-modal-footer-left"> + </div> + <div class="col-xs-6"> + <button class="btn btn-default" data-dismiss="modal" type="button">Close</button> + </div> + </div> </div> </div> </div> diff --git a/src/server_side_scripting/ext_file_download/zip_files.py b/src/server_side_scripting/ext_file_download/zip_files.py new file mode 100755 index 0000000000000000000000000000000000000000..65f27c9d901a6790456b8addb636bd55b7fe0c7e --- /dev/null +++ b/src/server_side_scripting/ext_file_download/zip_files.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2019 IndiScale GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header + +"""Creates a zip file from multiple file entities. """ + +import argparse +import datetime +import io +import logging +import os +import sys +from tempfile import NamedTemporaryFile +from zipfile import ZipFile + +import caosdb as db +import pandas as pd +from caosadvancedtools.serverside import helper +from caosdb import CaosDBException, ConsistencyError, EntityDoesNotExistError + + +def _parse_arguments(): + """Parses the command line arguments. + + Takes into account defaults from the environment (where known). + """ + parser = argparse.ArgumentParser(description='__doc__') + parser.add_argument('-a', '--auth-token', required=False, + help=("An authentication token. If not provided caosdb" + " pylib will search for other methods of " + "authentication if necessary.")) + parser.add_argument('ids', help="list of entity ids.") + parser.add_argument('table', help="tsv table to be saved (as string).") + + return parser.parse_args() + + +def collect_files_in_zip(ids, table): + # File output + now = datetime.datetime.now() + zip_name = "files.{time}.zip".format( + time=now.strftime("%Y-%m-%dT%H_%M_%S")) + zip_display_path, zip_internal_path = helper.get_shared_filename(zip_name) + with ZipFile(zip_internal_path, 'w') as zf: + nc = helper.NameCollector() + + # add the table which has been genereated by the webui table exporter + with NamedTemporaryFile(delete=False) as table_file: + # the file has been transmitted as string and has to be written to + # a file first. + table_file.write(table.encode()) + zf.write(table_file.name, "selected_table.tsv") + + # download and add all files + for file_id in ids: + try: + tmp = db.execute_query("FIND {a:} WITH ID={a:}".format( + a=file_id), + unique=True) + except EntityDoesNotExistError as e: + # TODO + # Current behavior: script terminates with error if just one + # file cannot be retrieved. + # Desired behavior: The script should go on with the other + # ids, but the user should be informed about the missing files. + # How should we do this? + logger = logging.getLogger("caosadvancedtools") + logger.error("Did not find Entity with ID={}.".format( + file_id)) + + raise e + savename = nc.get_unique_savename(os.path.basename(tmp.path)) + val_file = helper.get_file_via_download( + tmp, logger=logging.getLogger("caosadvancedtools")) + + zf.write(val_file, savename) + + return zip_display_path + + +def main(): + args = _parse_arguments() + + if hasattr(args, "auth_token") and args.auth_token: + db.configure_connection(auth_token=args.auth_token) + + id_list = [int(el) for el in args.ids.split(",")] + + zip_file = collect_files_in_zip(id_list, args.table) + + print(zip_file) + + +if __name__ == "__main__": + main() diff --git a/src/server_side_scripting/ext_table_preview/pandas_table_preview.py b/src/server_side_scripting/ext_table_preview/pandas_table_preview.py new file mode 100755 index 0000000000000000000000000000000000000000..c0659d9b1839c43e0629a878d792c414577ea344 --- /dev/null +++ b/src/server_side_scripting/ext_table_preview/pandas_table_preview.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# + +""" +This script tries to read typical table data files (.csv etc.) with pandas and +creates a html (partial) representation of the table. +""" + +import logging +import os +import sys +from datetime import datetime + +import caosdb as db +import pandas as pd +from caosadvancedtools.serverside.helper import get_argument_parser +from caosadvancedtools.serverside.logging import configure_server_side_logging + +MAXIMUMFILESIZE = 1e8 +VALID_ENDINGS = [".csv", ".tsv", ".xls", ".xlsx"] + + +def get_file(eid): + """ retrieves the file entity from caosdb """ + try: + fi = db.File(id=eid) + fi.retrieve() + except db.exceptions.EntityDoesNotExistError: + print("Cannot create preview for Entity with ID={}, because it seems" + "not to exist.".format(eid), file=sys.stderr) + sys.exit(1) + + return fi + + +def size_is_ok(fi): + """ show previews only for files that are not too large """ + + return fi.size <= MAXIMUMFILESIZE + + +def get_ending(fipath): + """ return which of the valid endings (tsv etc.) is the one present""" + + for end in VALID_ENDINGS: + if fipath.lower().endswith(end): + return end + + return None + + +def ending_is_valid(fipath): + """ return whether the ending indicates a file type that can be treated""" + + return get_ending(fipath) is not None + + +def read_file(fipath, ftype): + """ tries to read the provided file """ + + try: + if ftype in [".xls", ".xlsx"]: + df = pd.read_excel(fipath) + elif ftype == ".tsv": + df = pd.read_csv(fipath, sep="\t", comment="#") + elif ftype == ".csv": + df = pd.read_csv(fipath, comment="#") + else: + print("File type unknown: {}".format(ftype)) + raise RuntimeError("") + except Exception: + raise ValueError() + + return df + + +def create_table_preview(fi): + if not ending_is_valid(fi.path): + print("Cannot create preview for Entity with ID={}, because download" + "failed.".format(entity_id), file=sys.stderr) + sys.exit(5) + + ending = get_ending(fi.path) + + if not size_is_ok(fi): + print("Skipped creating a preview for Entity with ID={}, because the" + "file is large!".format(entity_id), file=sys.stderr) + sys.exit(2) + + try: + tmpfile = fi.download() + except Exception: + print("Cannot create preview for Entity with ID={}, because download" + "failed.".format(entity_id), file=sys.stderr) + + sys.exit(3) + + try: + df = read_file(tmpfile, ending) + except ValueError: + print("Cannot read File Entity with ID={}.".format(entity_id), + file=sys.stderr) + sys.exit(4) + + print(df.to_html(max_cols=10, max_rows=10)) + + +if __name__ == "__main__": + conlogger = logging.getLogger("connection") + conlogger.setLevel(level=logging.ERROR) + + parser = get_argument_parser() + args = parser.parse_args() + + debug_file = configure_server_side_logging() + logger = logging.getLogger("caosadvancedtools") + + db.configure_connection(auth_token=args.auth_token) + entity_id = args.filename + + fi = get_file(entity_id) + + create_table_preview(fi) diff --git a/test/core/index.html b/test/core/index.html index e4d6d81634aa73c2d71385545fb917392ba5df1f..5786583c3df7bf281bc3a151f67b378dd22ca5ff 100644 --- a/test/core/index.html +++ b/test/core/index.html @@ -37,6 +37,8 @@ <script src="js/bootstrap.js"></script> <script src="js/bootstrap-select.js"></script> <script src="js/bootstrap-autocomplete.min.js"></script> + <script src="js/utif.js"></script> + <script src="js/pako.js"></script> <script src="js/webcaosdb.js"></script> <script src="js/plotly.js"></script> <script> @@ -54,6 +56,7 @@ <script src="js/edit_mode.js"></script> <script src="js/query_shortcuts.js"></script> <script src="js/ext_references.js"></script> + <script src="js/ext_file_download.js"></script> <script src="js/ext_xls_download.js"></script> <script src="js/form_elements.js"></script> <script src="js/tour.js"></script> @@ -65,11 +68,13 @@ <script src="js/proj4leaflet.js"></script> <script src="js/ext_map.js"></script> <script src="js/ext_applicable.js"></script> + <script src="js/ext_table_preview.js"></script> <script src="js/ext_bottom_line.js"></script> <script src="js/ext_revisions.js"></script> - <script src="js/autocomplete.js"></script> + <script src="js/ext_autocomplete.js"></script> <script src="js/ext_sss_markdown.js"></script> <script src="js/ext_trigger_crawler_form.js"></script> + <script src="js/ext_bookmarks.js"></script> <!--EXTENSIONS--> <script src="js/modules/webcaosdb.js.js"></script> <script src="js/modules/caosdb.js.js"></script> @@ -81,6 +86,7 @@ <script src="js/modules/navbar.xsl.js"></script> <script src="js/modules/edit_mode.js.js"></script> <script src="js/modules/ext_xls_download.js.js"></script> + <script src="js/modules/ext_file_download.js.js"></script> <script src="js/modules/query_shortcuts.js.js"></script> <script src="js/modules/form_elements.js.js"></script> <script src="js/modules/ext_references.js.js"></script> @@ -88,8 +94,9 @@ <script src="js/modules/ext_applicable.js.js"></script> <script src="js/modules/ext_bottom_line.js.js"></script> <script src="js/modules/ext_revisions.js.js"></script> - <script src="js/modules/autocomplete.js.js"></script> + <script src="js/modules/ext_autocomplete.js.js"></script> <script src="js/modules/ext_sss_markdown.js.js"></script> <script src="js/modules/ext_trigger_crawler_form.js.js"></script> + <script src="js/modules/ext_bookmarks.js.js"></script> </body> </html> diff --git a/test/core/js/modules/autocomplete.js.js b/test/core/js/modules/autocomplete.js.js deleted file mode 100644 index b75acdcad8b08e548423513ccdf78d7ca0ea7359..0000000000000000000000000000000000000000 --- a/test/core/js/modules/autocomplete.js.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * ** header v3.0 - * This file is a part of the CaosDB Project. - * - * Copyright (C) 2019 IndiScale GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - * - * ** end header - */ - -'use strict'; - -QUnit.module("autocomplete.js", { - before: function (assert){ - autocomplete.retrieve_names = async function () { - return ['IceCore', 'Bag', 'IceSample', 'IceCream', 'Palette']; - } - } -}); - -QUnit.test("availability", function(assert) { - //assert.ok(bootstrap..init, "init available"); - assert.equal(autocomplete.version, "0.1", "test version"); - assert.ok(autocomplete.init, "init available"); -}); - - - -QUnit.test("filter", function(assert) { - assert.equal(autocomplete.filter('IceCore','Ice'), true, 'test filter') - assert.equal(autocomplete.filter('IceCore','iCe'), true, 'test filter') - assert.equal(autocomplete.filter('IceCore','Core'), false, 'test filter') - assert.equal(autocomplete.filter('Bag','Ice'), false, 'test filter') -}); - -QUnit.test("search", async function(assert) { - - var done = assert.async(2); - var gcallback = function(expresults){ - return function (results) { - assert.propEqual( - results, - expresults, - "test list filter"); - done(); - }; - }; - - await autocomplete.search("Ice", gcallback( - ['IceCore', 'IceSample', 'IceCream'] - )); - - await autocomplete.search("Core", gcallback([])); -}); - -QUnit.test("class", function(assert) { - assert.ok(autocomplete.toggle_completion , "toggle available"); - assert.ok(autocomplete.toggle_completion() , "toggle runs"); -}); diff --git a/test/core/js/modules/entity.xsl.js b/test/core/js/modules/entity.xsl.js index c607ee28bf7888f94e3086abf8e033e1532d0d09..91a6c18c2f0bee58c272461fe4b4fe595508110c 100644 --- a/test/core/js/modules/entity.xsl.js +++ b/test/core/js/modules/entity.xsl.js @@ -264,6 +264,25 @@ QUnit.test("data-version-successor attribute", function(assert) { assert.equal($(html).find("div.caosdb-entity-panel[data-version-successor]").length, 0, "data-version-successor attribute not present"); }); +QUnit.test("Transforming abstract properties", function (assert) { + var xmlstr = `<Property id="3842" name="reftotestrt" datatype="TestRT"> + <Version id="04ad505da057603a9177a1fcf6c9efd5f3690fe4" date="2020-11-23T10:38:02.936+0100" /> + </Property>`; + var xml = str2xml(xmlstr); + var html = applyTemplates(xml, this.entityXSL, "entity-body", "Property"); + var prop = getPropertyFromElement(html.firstElementChild); + assert.propEqual(prop, { + "datatype": "TestRT", + "html": {}, + "id": "3842", + "list": false, + "name": "reftotestrt", + "reference": true, + "unit": undefined, + "value": ""} + ); +}); + /* MISC FUNCTIONS */ function applyTemplates(xml, xsl, mode, select = "*") { let entryRule = '<xsl:template priority="9" match="/"><xsl:apply-templates select="' + select + '" mode="' + mode + '"/></xsl:template>'; diff --git a/test/core/js/modules/ext_autocomplete.js.js b/test/core/js/modules/ext_autocomplete.js.js new file mode 100644 index 0000000000000000000000000000000000000000..e8776f945b7bb46a0d431eb2d0ac0f7fe21419fc --- /dev/null +++ b/test/core/js/modules/ext_autocomplete.js.js @@ -0,0 +1,72 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2019 IndiScale GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +QUnit.module("ext_autocomplete.js", { + before: function (assert){ + ext_autocomplete.retrieve_names = async function () { + return ['IceCore', 'Bag', 'IceSample', 'IceCream', 'Palette']; + } + ext_autocomplete.init(); + + } +}); + +QUnit.test("availability", function(assert) { + //assert.ok(bootstrap..init, "init available"); + assert.equal(ext_autocomplete.version, "0.1", "test version"); + assert.ok(ext_autocomplete.init, "init available"); +}); + + + +QUnit.test("starts_with_filter", function(assert) { + assert.equal(ext_autocomplete.starts_with_filter('IceCore','Ice'), true, 'test filter') + assert.equal(ext_autocomplete.starts_with_filter('IceCore','iCe'), true, 'test filter') + assert.equal(ext_autocomplete.starts_with_filter('IceCore','Core'), false, 'test filter') + assert.equal(ext_autocomplete.starts_with_filter('Bag','Ice'), false, 'test filter') +}); + +QUnit.test("search", async function(assert) { + + var done = assert.async(2); + var gcallback = function(expresults){ + return function (results) { + assert.propEqual( + results, + expresults, + "test list filter"); + done(); + }; + }; + await ext_autocomplete.search("Ice", + gcallback( ['IceCore', 'IceSample', 'IceCream']) + ); + + await ext_autocomplete.search("Core", gcallback([])); +}); + +QUnit.test("class", function(assert) { + assert.ok(ext_autocomplete.switch_on_completion , "toggle available"); + assert.ok(ext_autocomplete.switch_on_completion() , "toggle runs"); +}); diff --git a/test/core/js/modules/ext_bookmarks.js.js b/test/core/js/modules/ext_bookmarks.js.js new file mode 100644 index 0000000000000000000000000000000000000000..24a4b98d7796da411890d9bdb2ca58b5927fda65 --- /dev/null +++ b/test/core/js/modules/ext_bookmarks.js.js @@ -0,0 +1,186 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +QUnit.module("ext_bookmarks.js", { + before: function (assert) { + // setup before module + ext_bookmarks.set_collection_id("test"); + // otherwise tests would collide with actual bookmarks + }, + beforeEach: function (assert) { + // setup before each test + }, + afterEach: function (assert) { + // teardown after each test + ext_bookmarks.clear_bookmark_storage(); + connection._init(); + }, + after: function (assert) { + // teardown after module + } +}); + +QUnit.test("parse_uri", function(assert) { + assert.equal(typeof ext_bookmarks.parse_uri(""), "undefined"); + assert.equal(typeof ext_bookmarks.parse_uri("asdf"), "undefined"); + assert.equal(typeof ext_bookmarks.parse_uri("https://localhost:1234/Entity/sada?sadfasd#sdfgdsf"), "undefined"); + + assert.propEqual(ext_bookmarks.parse_uri("safgsa/123&456&789?sadfasdf#_bm_1"), + {bookmarks: ["123", "456", "789"], collection_id: "1"}); +}); + +QUnit.test("get_bookmarks, clear_bookmark_storage", function(assert) { + assert.propEqual(ext_bookmarks.get_bookmarks(), []); + + ext_bookmarks.bookmark_storage[ext_bookmarks.get_key("sdfg")] = "3456" + assert.propEqual(ext_bookmarks.get_bookmarks(), ["3456"]); + + ext_bookmarks.clear_bookmark_storage(); + assert.propEqual(ext_bookmarks.get_bookmarks(), []); +}); + +QUnit.test("get_export_table", async function (assert) { + connection.get = (id) => `<root><Response><File id="${id}" path="testpath_${id.split("/")[1]}"/></Response></root>`; + const TAB = "%09"; + const NEWL = "%0A"; + const context_root = connection.getBasePath() + "Entity/"; + var table = await ext_bookmarks.get_export_table( + ["123@ver1", "456@ver2", "789@ver3", "101112", "@131415"]); + assert.equal(table, + `data:text/csv;charset=utf-8,ID${TAB}Version${TAB}URI${TAB}Path${NEWL}123${TAB}ver1${TAB}${context_root}123@ver1${TAB}testpath_123@ver1${NEWL}456${TAB}ver2${TAB}${context_root}456@ver2${TAB}testpath_456@ver2${NEWL}789${TAB}ver3${TAB}${context_root}789@ver3${TAB}testpath_789@ver3${NEWL}101112${TAB}${TAB}${context_root}101112${TAB}testpath_101112${NEWL}${TAB}131415${TAB}${context_root}@131415${TAB}testpath_@131415`); + + connection.get = (id) => {throw new Error("path should be in cache");}; + table = await ext_bookmarks.get_export_table( + ["123@ver1", "456@ver2", "789@ver3", "101112", "@131415"]); + assert.equal(table, + `data:text/csv;charset=utf-8,ID${TAB}Version${TAB}URI${TAB}Path${NEWL}123${TAB}ver1${TAB}${context_root}123@ver1${TAB}testpath_123@ver1${NEWL}456${TAB}ver2${TAB}${context_root}456@ver2${TAB}testpath_456@ver2${NEWL}789${TAB}ver3${TAB}${context_root}789@ver3${TAB}testpath_789@ver3${NEWL}101112${TAB}${TAB}${context_root}101112${TAB}testpath_101112${NEWL}${TAB}131415${TAB}${context_root}@131415${TAB}testpath_@131415`); +}); + +QUnit.test("update_clear_button", function (assert) { + const clear_button = $(`<div id="caosdb-f-bookmarks-clear"/>`); + $("body").append(clear_button); + + assert.notOk(clear_button.is(".disabled")); + ext_bookmarks.update_clear_button([]); + assert.ok(clear_button.is(".disabled")); + + ext_bookmarks.update_clear_button(["asdf"]); + assert.notOk(clear_button.is(".disabled")); + + ext_bookmarks.update_clear_button(["asdf"]); + assert.notOk(clear_button.is(".disabled")); + + ext_bookmarks.update_clear_button([]); + assert.ok(clear_button.is(".disabled")); + + clear_button.remove(); +}); + +QUnit.test("update_export_link", function (assert) { + const export_link = $(`<div id="caosdb-f-bookmarks-export-link"/>`); + $("body").append(export_link); + + assert.notOk(export_link.is(".disabled")); + ext_bookmarks.update_export_link([]); + assert.ok(export_link.is(".disabled")); + + ext_bookmarks.update_export_link(["asdf"]); + assert.notOk(export_link.is(".disabled")); + + ext_bookmarks.update_export_link(["asdf"]); + assert.notOk(export_link.is(".disabled")); + + ext_bookmarks.update_export_link([]); + assert.ok(export_link.is(".disabled")); + + export_link.remove(); +}); + +QUnit.test("update_collection_link", function (assert) { + const collection_link = $( + `<div id="caosdb-f-bookmarks-collection-link"><a/></div>`); + const a = collection_link.find("a")[0]; + $("body").append(collection_link); + + assert.notOk(collection_link.is(".disabled")); + assert.notOk(a.href); + + ext_bookmarks.update_collection_link([]); + assert.ok(collection_link.is(".disabled")); + assert.notOk(a.href); + + ext_bookmarks.update_collection_link(["asdf"]); + assert.notOk(collection_link.is(".disabled")); + assert.equal(a.href, ext_bookmarks.get_collection_link(["asdf"])); + + ext_bookmarks.update_collection_link(["asdf", "sdfg"]); + assert.notOk(collection_link.is(".disabled")); + assert.equal(a.href, ext_bookmarks.get_collection_link(["asdf", "sdfg"])); + + ext_bookmarks.update_collection_link([]); + assert.ok(collection_link.is(".disabled")); + assert.notOk(a.href); + + collection_link.remove(); +}); + +QUnit.test("bookmark buttons", function (assert) { + const inactive_button = $(`<div data-bmval="id1"/>`); + const active_button = $(`<div class="active" data-bmval="id2"/>`); + const broken_button = $(`<div data-bmval=""/>`); + const non_button = $(`<div data-bla="sadf"/>)`); + const outside_button = $(`<div data-bmval="id3"/>`); + const inside_buttons = $("<div/>").append([inactive_button, active_button, + broken_button, non_button]); + + // get_bookmark_buttons + assert.equal(ext_bookmarks.get_bookmark_buttons("body").length, 0); + + $("body").append([outside_button, inside_buttons]); + + assert.equal(ext_bookmarks.get_bookmark_buttons("body").length, 4, "all but no_button"); + assert.equal(ext_bookmarks.get_bookmark_buttons(inside_buttons).length, 3, "all but non_button and outside_button"); + + // get_value + assert.equal(ext_bookmarks.get_value(inactive_button), "id1"); + assert.notOk(ext_bookmarks.get_value(non_button)); + assert.notOk(ext_bookmarks.get_value(broken_button)); + + // init_button + assert.ok(active_button.is(".active")); + ext_bookmarks.init_button(active_button); + assert.notOk(active_button.is(".active")); + + ext_bookmarks.bookmark_storage[ext_bookmarks.get_key("id1")] = "id1" + assert.notOk(inactive_button.is(".active")); + ext_bookmarks.init_button(inactive_button); + assert.ok(inactive_button.is(".active")); + + ext_bookmarks.clear_bookmark_storage(); + assert.notOk(inactive_button.is(".active"), "clear_bookmark_storage removes active class"); + + inside_buttons.remove(); + outside_button.remove(); +}); diff --git a/test/core/js/modules/ext_bottom_line.js.js b/test/core/js/modules/ext_bottom_line.js.js index 067aade473ba9918f9c1d44eee2b9bb60e8cc863..d4add1a8997b86dbf9f39566aa5f14b0b6721df1 100644 --- a/test/core/js/modules/ext_bottom_line.js.js +++ b/test/core/js/modules/ext_bottom_line.js.js @@ -45,7 +45,7 @@ var ext_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) { }, { "id": "test.success-2", "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) != 'TestPreviewRecord-fall-back'", - "create": "(entity) => { return plotly_preview.create_plot([{x: [1,2,3,4,5], y: [1,2,4,8,16]}], { 'xaxis': {'title': 'time [samples]'}}); }" + "create": "(entity) => { return plotly_preview.create_plot([{x: [1,2,3,4,5], y: [1,2,4,8,16]}], { 'xaxis': {'title': 'time [samples]'}}, {displaylogo: false}); }" } ] }; @@ -66,8 +66,8 @@ var ext_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) { } }); - QUnit.test("app.creators", function (assert) { - assert.equal(ext_bottom_line.app.creators.length, 7, "seven creators"); + QUnit.test("_creators", function (assert) { + assert.equal(ext_bottom_line._creators.length, 9, "nine creators, 5 default ones, 4 from these tests."); }); QUnit.test("get_container - creation", function(assert) { @@ -120,4 +120,12 @@ var ext_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) { }); + QUnit.test("tiff converter", async function(assert) { + let entity_xml = `<Response><File path="../pics/saturn.tif"/></Response>`; + const entity = (await transformation.transformEntities(str2xml(entity_xml)))[0]; + const tiff_preview = await ext_bottom_line._creators.filter((c) => c.id == "_default_creators.tiff_images")[0].create(entity); + + assert.equal($(tiff_preview).find("img").attr("src").slice(0,21), "data:image/png;base64", "decoded tiff to png"); + }); + }($, ext_bottom_line, QUnit); diff --git a/test/core/js/modules/ext_file_download.js.js b/test/core/js/modules/ext_file_download.js.js new file mode 100644 index 0000000000000000000000000000000000000000..1103f61304c56259cc1a54a94c0c6b6b201d2aa5 --- /dev/null +++ b/test/core/js/modules/ext_file_download.js.js @@ -0,0 +1,65 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +QUnit.module("ext_file_download.js", { + before: function (assert) { + // setup before module + }, + beforeEach: function (assert) { + // setup before each test + }, + afterEach: function (assert) { + // teardown after each test + }, + after: function (assert) { + // teardown after module + } +}); + +QUnit.test("chunk_list ", function(assert) { + const li = [1,2,3,4,5,6,7]; + const res = ext_file_download.chunk_list(li, 3); + assert.equal(res.length, 3, "number of parts"); + assert.propEqual(res[2], [7], "number of parts"); +}); + +QUnit.test("collect_ids ", function (assert) { + const line = id => $(`<tr data-entity-id="${id}"/>`); + const prop_val = x => $(`<div class="caosdb-f-property-value"/>`); + const single_val =x => $(`<div class="caosdb-f-property-single-raw-value caosdb-id">${x}</div>`); + + + const line1 = line("34"); + line1.append([prop_val().append(single_val("5")),prop_val().append(single_val("6"))]) + $("body").append([line1]); + + const res = ext_file_download.collect_ids() + assert.ok(res.indexOf("5") > -1, "missing id"); + assert.ok(res.indexOf("6") > -1, "missing id"); + assert.ok(res.indexOf("34") > -1, "missing id"); + + line1.remove(); + +}); diff --git a/test/core/js/modules/ext_xls_download.js.js b/test/core/js/modules/ext_xls_download.js.js index 4f9dc6c59b156f1f2265acb4b315887536667194..997a89ec21d4a22f49746cbda1c46bb56268f80d 100644 --- a/test/core/js/modules/ext_xls_download.js.js +++ b/test/core/js/modules/ext_xls_download.js.js @@ -79,16 +79,14 @@ QUnit.module("ext_xls_download", { return str2xml('<response><script code="0" /><stdout>bla</stdout></response>'); } - caosdb_table_export._go_to_script_results = function(xls_link, filename) { - xls_link.setAttribute( - "href", - location.protocol + "//" +location.host + "/Shared/" + filename); + caosdb_table_export.go_to_script_results = function(filename) { assert.equal(filename, "bla", "filename correct"); done(); } var tsv_data = $('<a id="caosdb-f-query-select-data-tsv" />'); - $(document.body).append(tsv_data); + var modal = $('<div id="downloadModal"><div>'); + $(document.body).append([tsv_data, modal]); var xsl_link = $("<a/>"); @@ -96,12 +94,15 @@ QUnit.module("ext_xls_download", { await sleep(500); - assert.ok(xsl_link.attr("href").endsWith("Shared/bla"), xsl_link.attr("href") + " ends with Shared/bla"); - tsv_data.remove(); + modal.remove(); }); } +QUnit.test("_clean_cell", function(assert) { + assert.equal(caosdb_table_export._clean_cell("\n\t\n\t"), " ", "No valid content"); +}); + QUnit.test("_get_property_value", function(assert) { var f = caosdb_table_export._get_property_value; @@ -116,9 +117,15 @@ QUnit.test("_get_tsv_string", function(assert) { const entities = $(table).find("tbody tr").toArray(); assert.equal(entities.length, 2, "two example entities"); - var f = caosdb_table_export._get_tsv_string + var f = caosdb_table_export._create_tsv_string var tsv_string = f(entities, ["Bag", "Number"], true); - assert.equal(tsv_string, "data:text/csv;charset=utf-8,ID%09Bag%09Number%0A242%096366, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6413%090284%0A2112%09%091101", "tsv generated"); + var prefix = "data:text/csv;charset=utf-8," + assert.equal(tsv_string, + "ID\tBag\tNumber\n242\t6366, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6413\t02 8 4aaa a\n2112\t\t1101", "tsv generated"); + tsv_string = caosdb_table_export._encode_tsv_string(tsv_string); + assert.equal(tsv_string.slice(0,prefix.length), prefix); + assert.equal(decodeURIComponent(tsv_string.slice(prefix.length, tsv_string.length)), + "ID\tBag\tNumber\n242\t6366, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6413\t02 8 4aaa a\n2112\t\t1101", "tsv generated"); }); QUnit.test("_get_property_value", function (assert) { diff --git a/test/core/js/modules/form_elements.js.js b/test/core/js/modules/form_elements.js.js index 7f5930cd4253f408edf785045db10290697cd6c3..f8bf1ed1ac1495a8a3688aeb7b0cce387c8b69fb 100644 --- a/test/core/js/modules/form_elements.js.js +++ b/test/core/js/modules/form_elements.js.js @@ -541,3 +541,91 @@ QUnit.test("field_ready", function(assert) { }); }); +{ +const sleep = (ms) => { + return new Promise(res => setTimeout(res, ms)) +} + +QUnit.test("make_alert - cancel", async function(assert) { + var cancel_callback = assert.async() + var _alert = form_elements.make_alert({ + message: "message", + proceed_callback: () => {assert.ok(false, "this should not be called");}, + cancel_callback: cancel_callback, + }); + $("body").append(_alert); + + assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 1, "alert is there"); + $(_alert).find("button.caosdb-f-btn-alert-cancel")[0].click(); + await sleep(500); + + assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 0, "has been removed"); + + +}); + +QUnit.test("make_alert - proceed", async function(assert) { + var proceed_callback = assert.async(); + var _alert = form_elements.make_alert({ + message: "message", + proceed_callback: proceed_callback, + }); + assert.equal($(_alert).find("[type='checkbox']").length, 0, "no remember checkbox"); + + $("body").append(_alert); + assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 1, "alert is there"); + $(_alert).find("button.caosdb-f-btn-alert-proceed")[0].click(); + await sleep(500); + + assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 0, "has been removed"); + +}); + +QUnit.test("make_alert - remember", async function(assert) { + form_elements._set_alert_decision("unittests", ""); + + var proceed_callback = assert.async(3); + var _alert = form_elements.make_alert({ + message: "message", + proceed_callback: proceed_callback, + remember_my_decision_id: "unittests", + }); + assert.equal($(_alert).find("[type='checkbox']").length, 1, "has remember checkbox"); + + // append for the first time + $("body").append(_alert); + assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 1, "alert is there"); + + // click proceed without "don't ask me again". + $(_alert).find("button.caosdb-f-btn-alert-proceed")[0].click(); + await sleep(500); + + form_elements._set_alert_decision("unittests", ""); + + assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 0, "has been removed"); + + // append 2nd time + _alert = form_elements.make_alert({ + message: "message", + proceed_callback: proceed_callback, + remember_my_decision_id: "unittests", + }); + assert.equal($(_alert).find("[type='checkbox']").length, 1, "has remember checkbox"); + $("body").append(_alert); + assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 1, "alert is there"); + $(_alert).find("[type='checkbox']").prop("checked", true); + + $(_alert).find("button.caosdb-f-btn-alert-proceed")[0].click(); + await sleep(500); + form_elements._set_alert_decision("unittests", "proceed"); + + // try 3rd time + _alert = form_elements.make_alert({ + message: "message", + proceed_callback: proceed_callback, + remember_my_decision_id: "unittests", + }); + assert.equal(typeof _alert, "undefined", "alert was not created, proceed callback was called third time"); +}); + +} diff --git a/test/core/js/modules/webcaosdb.js.js b/test/core/js/modules/webcaosdb.js.js index 239758161b8419a8a5e15799c67c909977f1fbf0..5b43d2c1dcd2be0a9cac710b5749d13c631c76ed 100644 --- a/test/core/js/modules/webcaosdb.js.js +++ b/test/core/js/modules/webcaosdb.js.js @@ -324,7 +324,7 @@ QUnit.test("createUpdateForm", function(assert) { QUnit.test("createUpdateEntityHeading", function(assert) { let cueh = transaction.update.createUpdateEntityHeading; assert.ok(cueh, "function available"); - let eh = $('<div class="panel-heading"><div class="1strow"/><div class="2ndrow"/></div>')[0]; + let eh = $('<div class="panel-heading"><div class="1strow"></div><div class="2ndrow"></div></div>')[0]; assert.equal($(eh).children('.1strow').length, 1, "eh has 1st row"); assert.equal($(eh).children('.2ndrow').length, 1, "eh has 2nd row"); let uh = cueh(eh); diff --git a/test/core/pics/saturn.tif b/test/core/pics/saturn.tif new file mode 100644 index 0000000000000000000000000000000000000000..f226ebab7fca3c9caa306f78d80eaa7b4068f994 Binary files /dev/null and b/test/core/pics/saturn.tif differ diff --git a/test/core/xml/table_export/test_case_select_table_1.xml b/test/core/xml/table_export/test_case_select_table_1.xml index 12f26180b89bdf3f2ba3280d4546783f54bfa8ea..ae0a856f106557be3712f303b06a99f6220ef827 100644 --- a/test/core/xml/table_export/test_case_select_table_1.xml +++ b/test/core/xml/table_export/test_case_select_table_1.xml @@ -10,7 +10,7 @@ </Query> <Record id="242"> <Property id="117" name="Number" datatype="TEXT" importance="FIX"> - 0284 + 02	8


4aaa	a </Property> <Property id="104" name="Bag" datatype="Bag" importance="FIX"> 6366 diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index a19843fb3de11bbedf1c01f71619470bcc99f75c..bccd94fe14f49a79d8a43b559b3b857ed1d7d07d 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -1,7 +1,8 @@ FROM debian:latest RUN apt-get update && \ apt-get install firefox-esr gettext-base pylint3 python3-pip \ - python3-httpbin git curl x11-apps xvfb unzip -y -RUN git clone -b dev https://gitlab.com/caosdb/caosdb-pylib.git && \ - cd caosdb-pylib && pip3 install . + python3-httpbin git curl x11-apps xvfb unzip -y python3-pytest +RUN pip3 install caosdb +RUN pip3 install pandas xlrd +RUN pip3 install git+https://gitlab.com/caosdb/caosdb-advanced-user-tools.git@dev diff --git a/test/server_side_scripting/ext_table_preview/__pycache__/pandas_table_preview.cpython-37.pyc b/test/server_side_scripting/ext_table_preview/__pycache__/pandas_table_preview.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..613d9dce64b94c3b4c66891f22cd02a6c337dff6 Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/__pycache__/pandas_table_preview.cpython-37.pyc differ diff --git a/test/server_side_scripting/ext_table_preview/__pycache__/test_pandas_table_preview.cpython-37-pytest-6.0.2.pyc b/test/server_side_scripting/ext_table_preview/__pycache__/test_pandas_table_preview.cpython-37-pytest-6.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3563a411e0c836d2613ab7189dc6833be735e00 Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/__pycache__/test_pandas_table_preview.cpython-37-pytest-6.0.2.pyc differ diff --git a/test/server_side_scripting/ext_table_preview/data/bad.csv b/test/server_side_scripting/ext_table_preview/data/bad.csv new file mode 100644 index 0000000000000000000000000000000000000000..d29a9312a387186beb8bf4f77a8ec0e4b0ab80fa Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/bad.csv differ diff --git a/test/server_side_scripting/ext_table_preview/data/bad.tsv b/test/server_side_scripting/ext_table_preview/data/bad.tsv new file mode 100644 index 0000000000000000000000000000000000000000..d29a9312a387186beb8bf4f77a8ec0e4b0ab80fa Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/bad.tsv differ diff --git a/test/server_side_scripting/ext_table_preview/data/bad.xls b/test/server_side_scripting/ext_table_preview/data/bad.xls new file mode 100644 index 0000000000000000000000000000000000000000..1f31bf2754258e3d07f88fd1e6bdee4d7b11bee1 Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/bad.xls differ diff --git a/test/server_side_scripting/ext_table_preview/data/bad.xlsx b/test/server_side_scripting/ext_table_preview/data/bad.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1f31bf2754258e3d07f88fd1e6bdee4d7b11bee1 Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/bad.xlsx differ diff --git a/test/server_side_scripting/ext_table_preview/data/server_error.csv b/test/server_side_scripting/ext_table_preview/data/server_error.csv new file mode 100644 index 0000000000000000000000000000000000000000..3e770df012f65d73ce4721a5f65d7e3f39959519 --- /dev/null +++ b/test/server_side_scripting/ext_table_preview/data/server_error.csv @@ -0,0 +1 @@ +Hi, this line contains a unicode backspace. This causes a server error, when pandas_table_preview.py's output is serialized into XML. \ No newline at end of file diff --git a/test/server_side_scripting/ext_table_preview/data/test.csv b/test/server_side_scripting/ext_table_preview/data/test.csv new file mode 100644 index 0000000000000000000000000000000000000000..7c9bfd1354393439f551021cfe340577433ce2aa --- /dev/null +++ b/test/server_side_scripting/ext_table_preview/data/test.csv @@ -0,0 +1,12 @@ +# test header +# two lines +A1,B1,C1,D1,E1,F1,G1,H1,I1,J1,K1,L1,M1,N1,O1,P1,Q1,R1,S1,T1 +A2,B2,C2,D2,E2,F2,G2,H2,I2,J2,K2,L2,M2,N2,O2,P2,Q2,R2,S2,T2 +A3,B3,csvfile,D3,E3,F3,G3,H3,I3,J3,K3,L3,M3,N3,O3,P3,Q3,R3,S3,T3 +A5,B5,C5,D5,E5,F5,G5,H5,I5,J5,K5,L5,M5,N5,O5,P5,Q5,R5,S5,T5 +A6,B6,C6,D6,E6,F6,G6,H6,I6,J6,K6,L6,M6,N6,O6,P6,Q6,R6,S6,T6 +A7,B7,csvfile,D7,E7,F7,G7,H7,I7,J7,K7,L7,M7,N7,O7,P7,Q7,R7,S7,T7 +A8,B8,C8,D8,E8,F8,G8,H8,I8,J8,K8,L8,M8,N8,O8,P8,Q8,R8,S8,T8 +A9,B9,C9,D9,E9,F9,G9,H9,I9,J9,K9,L9,M9,N9,O9,P9,Q9,R9,S9,T9 +A10,B10,C10,D10,E10,F10,G10,H10,I10,J10,K10,L10,M10,N10,O10,P10,Q10,R10,S10,T10 +A11,B11,C11,D11,E11,F11,G11,H11,I11,J11,K11,L11,M11,N11,O11,P11,Q11,R11,S11,T11 diff --git a/test/server_side_scripting/ext_table_preview/data/test.tsv b/test/server_side_scripting/ext_table_preview/data/test.tsv new file mode 100644 index 0000000000000000000000000000000000000000..863f692bf64e7dcabf74703587a93e37adf27e67 --- /dev/null +++ b/test/server_side_scripting/ext_table_preview/data/test.tsv @@ -0,0 +1,12 @@ +# test header +# two lines +A1 B1 C1 D1 E1 F1 G1 H1 I1 J1 K1 L1 M1 N1 O1 P1 Q1 R1 S1 T1 +A2 B2 C2 D2 E2 F2 G2 H2 I2 J2 K2 L2 M2 N2 O2 P2 Q2 R2 S2 T2 +A3 B3 csvfile D3 E3 F3 G3 H3 I3 J3 K3 L3 M3 N3 O3 P3 Q3 R3 S3 T3 +A5 B5 C5 D5 E5 F5 G5 H5 I5 J5 K5 L5 M5 N5 O5 P5 Q5 R5 S5 T5 +A6 B6 C6 D6 E6 F6 G6 H6 I6 J6 K6 L6 M6 N6 O6 P6 Q6 R6 S6 T6 +A7 B7 tsvfile D7 E7 F7 G7 H7 I7 J7 K7 L7 M7 N7 O7 P7 Q7 R7 S7 T7 +A8 B8 C8 D8 E8 F8 G8 H8 I8 J8 K8 L8 M8 N8 O8 P8 Q8 R8 S8 T8 +A9 B9 C9 D9 E9 F9 G9 H9 I9 J9 K9 L9 M9 N9 O9 P9 Q9 R9 S9 T9 +A10 B10 C10 D10 E10 F10 G10 H10 I10 J10 K10 L10 M10 N10 O10 P10 Q10 R10 S10 T10 +A11 B11 C11 D11 E11 F11 G11 H11 I11 J11 K11 L11 M11 N11 O11 P11 Q11 R11 S11 T11 diff --git a/test/server_side_scripting/ext_table_preview/data/test.xls b/test/server_side_scripting/ext_table_preview/data/test.xls new file mode 100644 index 0000000000000000000000000000000000000000..a355756b9ab72f9035246c5303800a2076d9bfc0 Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/test.xls differ diff --git a/test/server_side_scripting/ext_table_preview/data/test.xlsx b/test/server_side_scripting/ext_table_preview/data/test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bc291f1aa86cd6d550320f07a7ce69cf813b8116 Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/test.xlsx differ diff --git a/test/server_side_scripting/ext_table_preview/data/xss_attack.csv b/test/server_side_scripting/ext_table_preview/data/xss_attack.csv new file mode 100644 index 0000000000000000000000000000000000000000..e7d43505aef42c397f1859805bc87aab8b6da1a2 --- /dev/null +++ b/test/server_side_scripting/ext_table_preview/data/xss_attack.csv @@ -0,0 +1,8 @@ +# as it seems all these characters are escaped correctly. +"","%3C","<","<","<","<","<","<","<","<" +"<","<","<","<","<","<","<","<","<","<" +"<","<","<","<","<","<","<","<","<","<" +"<","<","<","<","<","<","<","<","<","<" +"<","<","<","<","<","<","<","<","<","<" +"<","<","<","<","<","<","<","<","<","<" +"<","<","<","<","<","<","\x3c","\x3C","\u003c","\u003C" diff --git a/test/server_side_scripting/ext_table_preview/pandas_table_preview.py b/test/server_side_scripting/ext_table_preview/pandas_table_preview.py new file mode 120000 index 0000000000000000000000000000000000000000..f24b3901ce8610fd02fc28468b747b7171834307 --- /dev/null +++ b/test/server_side_scripting/ext_table_preview/pandas_table_preview.py @@ -0,0 +1 @@ +../../../src/server_side_scripting/ext_table_preview/pandas_table_preview.py \ No newline at end of file diff --git a/test/server_side_scripting/ext_table_preview/requirements.txt b/test/server_side_scripting/ext_table_preview/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..4628529ba9dce50a08d574e21d3b4a71b50af2b1 --- /dev/null +++ b/test/server_side_scripting/ext_table_preview/requirements.txt @@ -0,0 +1,3 @@ +caosdb +caosadvancedtools +pandas diff --git a/test/server_side_scripting/ext_table_preview/test_pandas_table_preview.py b/test/server_side_scripting/ext_table_preview/test_pandas_table_preview.py new file mode 100644 index 0000000000000000000000000000000000000000..00d1c7f38746abe437abc76cd51b29600adcd049 --- /dev/null +++ b/test/server_side_scripting/ext_table_preview/test_pandas_table_preview.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# + +import os +import unittest + +import caosdb as db +from caosdb.common.models import _parse_single_xml_element +from lxml import etree +from pandas_table_preview import (MAXIMUMFILESIZE, create_table_preview, + ending_is_valid, read_file, size_is_ok) + + +class PreviewTest(unittest.TestCase): + def test_file_ending(self): + self.assertFalse(ending_is_valid("/this/is/no/xls.lol")) + self.assertFalse(ending_is_valid("xls.lol")) + self.assertFalse(ending_is_valid("ag.xls.lol")) + assert ending_is_valid("/this/is/a/lol.xls") + assert ending_is_valid("/this/is/a/lol.csv") + assert ending_is_valid("/this/is/a/lol.cSv") + assert ending_is_valid("/this/is/a/lol.CSV") + assert ending_is_valid("lol.CSV") + + def test_file_size(self): + entity_xml = ('<File id="1234" name="SomeFile" ' + 'path="/this/path.tsv" size="{size}"></File>') + small = _parse_single_xml_element( + etree.fromstring(entity_xml.format(size="20000"))) + + assert size_is_ok(small) + large = _parse_single_xml_element( + etree.fromstring(entity_xml.format( + size=str(int(MAXIMUMFILESIZE+1))))) + assert not size_is_ok(large) + + def test_output(self): + files = [os.path.join(os.path.dirname(__file__), "data", f) + for f in ["test.csv", "test.tsv", "test.xls", "test.xlsx"]] + + for fi in files: + table = read_file(fi, ftype="."+fi.split(".")[-1]) + searchkey = fi.split(".")[-1]+"file" + print(table) + assert (table == searchkey).any(axis=None) + + badfiles = [os.path.join(os.path.dirname(__file__), "data", f) + for f in ["bad.csv", "bad.tsv", "bad.xls", "bad.xlsx"]] + + for bfi in badfiles: + self.assertRaises(ValueError, read_file, + bfi, "."+bfi.split(".")[-1])