diff --git a/.gitignore b/.gitignore index cc9336b6256771f79eb104a8b1325a55352657e1..f69db87ad5a5226535559b6965e771d975ded103 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +# -*- mode:conf; -*- + # dot files .* !/.git* @@ -8,13 +10,19 @@ # the build dir /public +/sss_bin +/node_modules/ +/build +__pycache__ + +# auto-generated sources +/src/doc/api # screen logs screenlog.* xerr.log # extensions - conf/ext test/ext src/ext diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 548f86457c3216f5eed7b75b4b81d7f048351248..bcbcfcda9df7fa823ecd1454990bb8d6ff28ff79 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,7 @@ # Copyright (C) 2019 Henrik tom Wörden # Copyright (C) 2020 Timm Fitschen (t.fitschen@indiscale.com) # Copyright (C) 2020 IndiScale GmbH (info@indiscale.com) +# Copyright (C) 2020 Daniel Hornung <d.hornung@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 @@ -58,6 +59,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 @@ -78,13 +87,33 @@ build-testenv: tags: [ cached-dind ] image: docker:19.03 stage: setup + timeout: 3 h script: - cd test/docker - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY # use here general latest or specific branch latest... - - docker pull $CI_REGISTRY_IMAGE:latest || true - docker build --pull - --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:latest . - docker push $CI_REGISTRY_IMAGE:latest + +# Build the sphinx documentation and make it ready for deployment by Gitlab Pages +# documentation: +# stage: deploy + +# Special job for serving a static website. See https://docs.gitlab.com/ee/ci/yaml/README.html#pages +pages: + tags: [ docker ] + stage: deploy + only: + - dev + script: + # TODO is this a good location here? + - npm install jsdoc + - npm install jsdoc-sphinx + - echo "Deploying" + - make doc + - rm -r public || true ; cp -r build/doc/html public + artifacts: + paths: + - public diff --git a/CHANGELOG.md b/CHANGELOG.md index 28fdb59c94469b05ada76e904b72e56b92413f64..b79c4b37a6c80bc921e2957e45a9eb7f01b20ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added (for new features, dependecies etc.) +* two new field types for the form_elements module, `file` and `select`. See + the module documentation for more information. + +### Changed (for changes in existing functionality) + +### Deprecated (for soon-to-be removed features) + +### Removed (for now removed features) + +### Fixed + +- Fixed edit mode for Safari 11. + +### Security (in case of vulnerabilities) + +## [0.3.0] - 2021-02-10 + +### Added (for new features, dependecies etc.) + +- The versioning model has a new styling and can show and tsv-export the full + version history now. +- 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 +- The map can now show entities that have no geo location but are related to + entities that have one. This also effects the search from the map. +* `getPropertyValues` function which generates a table of property values from + a xml representation of entities. +* After a SELECT statement now also all referenced files can be downloaded. +* Automated documentation builds: `make doc` +- documentation on queries + +### Changed (for changes in existing functionality) + +* ext_map version bumped to 0.4 +- enabled and enhanced autocompletion +* Login form is hidden behind another button. + +### Deprecated (for soon-to-be removed features) + +### Removed (for now removed features) + +### Fixed + +- #144 (Select with ANY VERSION OF). +- #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) + +## [v0.2.1] - 2020-09-07 + +### Added (for new features, dependecies etc.) + +* `ext_jupyterdrag` (v0.1) module for dragging entities into jupyter notebooks. + +### Changed (for changes in existing functionality) + +### Deprecated (for soon-to-be removed features) + +### Removed (for now removed features) + +### Fixed + +### Security (in case of vulnerabilities) + +## [v0.2] - 2020-09-02 + +### Added (for new features, dependecies etc.) + +* Build variable `EXT_REFERENCES_CUSTOM_REFERENCE_RESOLVER`. The value of this + variable must be module which has at least a `resolve(id)` function, which + returns a `reference_info` object for further processing by the + `resolve_references` module. * `ext_sss_markdown` module for pretty display of server-side scripting stdout. See module docstring for more information. * `ext_trigger_crawler_form` module which generates a form for triggering the @@ -46,7 +130,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security (in case of vulnerabilities) -## v0.2-rc.1 (2020-04-10) +## [v0.2-rc.1] - 2020-04-10 ### Added (for new features, dependecies etc.) diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md new file mode 100644 index 0000000000000000000000000000000000000000..037dc9973705005ea2ee656e8381444c38ba5c60 --- /dev/null +++ b/DEPENDENCIES.md @@ -0,0 +1,2 @@ +* CaosDB Server == 0.3 +* Make 4.2.0 diff --git a/makefile b/Makefile similarity index 81% rename from makefile rename to Makefile index 50dd72b500724bbe40801292ab39c3c5cf7ce70f..1f182e7864a2252e4c1263cde7fa4238d31a4269 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; \ @@ -64,10 +67,12 @@ test: clean cp-src cp-ext cp-ext-test cp-conf $(addprefix $(PUBLIC_DIR)/, $(TEST merge_xsl: misc/merge_xsl.sh +EXCLUDE_EXPR = %~ %.backup +BUILDFILELIST = $(filter-out $(EXCLUDE_EXPR),$(wildcard build.properties.d/*)) build_properties: @set -a -e ; \ pushd build.properties.files ; \ - for f in ../build.properties.d/* ; do source "$$f" ; done ; \ + for f in ${BUILDFILELIST} ; do echo "processing ../$$f" && source "../$$f" ; done ; \ popd ; \ BUILD_NUMBER=$(BUILD_NUMBER) ; \ PROPS=$$(printenv | grep -e "^BUILD_") ; \ @@ -127,18 +132,33 @@ 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 +PIP ?= pip3 +test-sss: install-sss + $(PIP) freeze + $(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"; \ @@ -146,26 +166,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 @@ -191,13 +211,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 $@ @@ -206,7 +226,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 $@ @@ -257,17 +277,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 @@ -278,11 +305,13 @@ unzip: for f in $(LIBS_ZIP); do unzip -q -o -d libs $$f; done -PYLINT = pylint3 -d all -e E,F +PYLINT ?= pylint 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 + for f in $(PYTHON_FILES); do $(PYLINT) -d all -e E,F $$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 +# Compile the standalone documentation +.PHONY: doc +doc: + $(MAKE) -C src/doc html diff --git a/README_SETUP.md b/README_SETUP.md index c027fd7d9af49c33492122fc575254b8d6b00845..485835fdae10239cb4329b0f9b6708c1b020fa2a 100644 --- a/README_SETUP.md +++ b/README_SETUP.md @@ -21,12 +21,16 @@ * ** end header --> -# Folder Structure +# Getting Started with the Web Interface +Here, we document how to install and build the CaosDB Web Interface. If you are +only interested in how to use it, please continue [here](tutorials/first_steps.html) -* The `src` folder contains all source code for the webinterface. +## Folder Structure + +* The `src` folder contains all source code for the web interface. * The `libs` folder contains all necessary third-party libraries as zip files. -* The `test` folder contains the unittests for the webinterface. -* The `ext` folder contains extension for the webinterface. The make file will +* The `test` folder contains the unittests for the web interface. +* The `ext` folder contains extension for the web interface. The make file will copy all javascript files from `ext/js/` into the public folder and links the javascript in the `public/xsl/main.xsl`. * The `misc` folder contains a simple http server which is used for running the @@ -34,33 +38,49 @@ * The `build.properties.d/` folder contains configuration files for the build. -# Build Configuration +## Build Configuration The default configuration is defined in `build.properties.d/00_default.properties`. -This file defines default variables which will be replaced in the source files -during the build. +This file defines default variables which can be used in source files and +will be replaced with the defined values during the build. All files in that directory will be sourced during `make install` and `make test`. Thus any customized configuration can also be added to that folder by just placing files in there which override the default values from `00_default.properties`. -See `build.properties.d/00_default.properties` for more -information. +See `build.properties.d/00_default.properties` for more information. -# Setup +## Setup -* Run `make install` to compile/copy the webinterface to a newly created +* Run `make install` to compile/copy the web interface 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 +## Test -* Run `make test` to compile/copy the webinterface and the tests to a newly +* Run `make test` to compile/copy the web interface and the tests to a newly created `public` folder. * Run `make run-test-server` to start a python http server. * The test suite can be started with `firefox http://localhost:8000/`. -# Clean +## Clean * Run `make clean` to clean up everything. + +## Documentation # + +Build documentation in `build/` with `make doc`. + +### Requirements ## + +- sphinx +- sphinx-autoapi +- jsdoc (`npm install jsdoc`) +- jsdoc-sphinx (`npm install jsdoc-sphinx`) +- sphinx-js +- recommonmark diff --git a/References_button.png b/References_button.png new file mode 100644 index 0000000000000000000000000000000000000000..998ef42f7ccb17e32b0c88b8395c249b4529c97f Binary files /dev/null and b/References_button.png differ diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index 23b1e9ff929b030750f6cab0014f22cd0d6b7cf4..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 @@ -63,7 +68,7 @@ BUILD_FAVICON=pics/caosdb_logo_42.png ############################################################################## # Link to the data policy statement document. -BUILD_FOOTER_DATA_POLICY_HREF=https://indiscale.com/?page_id=156 +BUILD_FOOTER_DATA_POLICY_HREF=https://missing-domain.com/missing-page # Custom footer elements can be placed here (will be placed inside a <div> # element). 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/misc/versioning_test_data.py b/misc/versioning_test_data.py index eaa83e46f61ea2f20263b487e4bb42c37678c94f..5ec7073aeaffc894916ee8a6c4cfdc82bc25a4f1 100755 --- a/misc/versioning_test_data.py +++ b/misc/versioning_test_data.py @@ -91,3 +91,8 @@ else: str(rec1.id), str(rec1.id)]) rec4.insert() + +for i in range(4,11): + rec1.name = f"TestRecord1-{i}thVersion" + rec1.description = f"This is the {i}th version." + rec1.update() diff --git a/misc/yaml_to_json.py b/misc/yaml_to_json.py index a7d5bd62a7a1ccc50766b797ef6710466e9bee11..e77e5efc56b2cea39b0a7b6f90236fb5b39da24e 100755 --- a/misc/yaml_to_json.py +++ b/misc/yaml_to_json.py @@ -6,4 +6,4 @@ import json import yaml with open(sys.argv[1], 'r') as infile: - print(json.dumps(yaml.load(infile))) + print(json.dumps(yaml.safe_load(infile))) diff --git a/model.svg b/model.svg new file mode 100644 index 0000000000000000000000000000000000000000..2602cb43f15976305d48e6f2d5efeb3821e1d669 --- /dev/null +++ b/model.svg @@ -0,0 +1,632 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + contentScriptType="application/ecmascript" + contentStyleType="text/css" + height="502" + preserveAspectRatio="none" + version="1.1" + viewBox="0 0 407 502" + width="407" + zoomAndPan="magnify" + id="svg233" + sodipodi:docname="model.svg" + inkscape:version="0.92.4 5da689c313, 2019-01-14"> + <metadata + id="metadata237"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1920" + inkscape:window-height="1043" + id="namedview235" + showgrid="false" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:zoom="1.1817368" + inkscape:cx="112.55875" + inkscape:cy="257" + inkscape:window-x="1920" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="svg233" /> + <defs + id="defs11"> + <filter + height="3" + id="f64vrt8w3qxjw" + width="3" + x="-1" + y="-1"> + <feGaussianBlur + result="blurOut" + stdDeviation="2.0" + id="feGaussianBlur2" /> + <feColorMatrix + in="blurOut" + result="blurOut2" + type="matrix" + values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .4 0" + id="feColorMatrix4" /> + <feOffset + dx="4.0" + dy="4.0" + in="blurOut2" + result="blurOut3" + id="feOffset6" /> + <feBlend + in="SourceGraphic" + in2="blurOut3" + mode="normal" + id="feBlend8" /> + </filter> + </defs> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.13385832;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="rect4867" + width="407" + height="502" + x="0" + y="0" /> + <polygon + id="polygon13" + style="fill:#dddddd;stroke:#000000;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + points="533.5,526 126.5,526 126.5,24 236.5,24 243.5,46.2969 533.5,46.2969 " + transform="translate(-126.5,-24)" /> + <line + id="line15" + y2="22.296902" + y1="22.296902" + x2="117" + x1="0" + style="stroke:#000000;stroke-width:1.5" /> + <text + style="font-weight:bold;font-size:14px;font-family:sans-serif;fill:#000000" + id="text17" + y="38.995098" + x="130.5" + textLength="104" + lengthAdjust="spacingAndGlyphs" + font-weight="bold" + font-size="14" + transform="translate(-126.5,-24)">RecordTypes</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text42" + y="144.7104" + x="461" + textLength="0" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)" /> + <rect + y="411" + x="16" + width="116" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="Manufacturer" + height="60.804699" /> + <circle + r="11" + id="ellipse47" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="427" + cx="31" /> + <path + inkscape:connector-curvature="0" + id="path49" + d="m 33.9688,432.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text51" + y="455.1543" + x="171.5" + textLength="84" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">Manufacturer</text> + <line + id="line53" + y2="443" + y1="443" + x2="131" + x1="17" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text55" + y="481.21039" + x="152.5" + textLength="0" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)" /> + <line + id="line57" + y2="463.80469" + y1="463.80469" + x2="131" + x1="17" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="235" + x="16" + width="174" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="MusicalInstrument" + height="101.6211" /> + <circle + r="11" + id="ellipse60" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="251" + cx="43.600006" /> + <path + inkscape:connector-curvature="0" + id="path62" + d="m 46.5688,256.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text64" + y="279.1543" + x="186.89999" + textLength="114" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">MusicalInstrument</text> + <line + id="line66" + y2="267" + y1="267" + x2="189" + x1="17" + style="stroke:#a80036;stroke-width:1.5" /> + <line + id="line68" + y2="281.40231" + y1="281.40231" + x2="73.5" + x1="17" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text70" + y="308.71039" + x="200" + textLength="59" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">Properties</text> + <line + id="line72" + y2="281.40231" + y1="281.40231" + x2="189" + x1="132.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text74" + y="341.2222" + x="148.5" + textLength="86" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">price (DOUBLE)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text76" + y="354.02689" + x="148.5" + textLength="162" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">Manufacturer (Manufacturer)</text> + <line + id="line78" + y2="300.60941" + y1="300.60941" + x2="62" + x1="17" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text80" + y="327.91751" + x="188.5" + textLength="82" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">recommended</text> + <line + id="line82" + y2="300.60941" + y1="300.60941" + x2="189" + x1="144" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="411" + x="167.5" + width="65" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="Violin" + height="60.804699" /> + <circle + r="11" + id="ellipse85" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="427" + cx="182.5" /> + <path + inkscape:connector-curvature="0" + id="path87" + d="m 185.4688,432.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text89" + y="455.1543" + x="323" + textLength="33" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">Violin</text> + <line + id="line91" + y2="443" + y1="443" + x2="231.5" + x1="168.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text93" + y="481.21039" + x="304" + textLength="0" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)" /> + <line + id="line95" + y2="463.80469" + y1="463.80469" + x2="231.5" + x1="168.5" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="397" + x="267.5" + width="119" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="Guitar" + height="88.816399" /> + <circle + r="11" + id="ellipse98" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="413" + cx="304.54999" /> + <path + inkscape:connector-curvature="0" + id="path100" + d="m 307.5188,418.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text102" + y="441.1543" + x="449.95001" + textLength="38" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">Guitar</text> + <line + id="line104" + y2="429" + y1="429" + x2="385.5" + x1="268.5" + style="stroke:#a80036;stroke-width:1.5" /> + <line + id="line106" + y2="443.40231" + y1="443.40231" + x2="297.5" + x1="268.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text108" + y="470.71039" + x="424" + textLength="59" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">Properties</text> + <line + id="line110" + y2="443.40231" + y1="443.40231" + x2="385.5" + x1="356.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text112" + y="503.2222" + x="400" + textLength="107" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">electric (BOOLEAN)</text> + <line + id="line114" + y2="462.60941" + y1="462.60941" + x2="286" + x1="268.5" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text116" + y="489.91751" + x="412.5" + textLength="82" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">recommended</text> + <line + id="line118" + y2="462.60941" + y1="462.60941" + x2="385.5" + x1="368" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="255.5" + x="225.5" + width="165" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="SoundQualityAnalyzer" + height="60.804699" /> + <circle + r="11" + id="ellipse121" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="271.5" + cx="240.5" /> + <path + inkscape:connector-curvature="0" + id="path123" + d="m 243.4688,277.1406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text125" + y="299.6543" + x="381" + textLength="133" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">SoundQualityAnalyzer</text> + <line + id="line127" + y2="287.5" + y1="287.5" + x2="389.5" + x1="226.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text129" + y="325.71039" + x="362" + textLength="0" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)" /> + <line + id="line131" + y2="308.30469" + y1="308.30469" + x2="389.5" + x1="226.5" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="35" + x="20" + width="268" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="Analysis" + height="140.0352" /> + <circle + r="11" + id="ellipse134" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="51" + cx="124.75" /> + <path + inkscape:connector-curvature="0" + id="path136" + d="m 127.7188,56.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text138" + y="79.154297" + x="271.75" + textLength="50" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">Analysis</text> + <line + id="line140" + y2="67" + y1="67" + x2="287" + x1="21" + style="stroke:#a80036;stroke-width:1.5" /> + <line + id="line142" + y2="81.402298" + y1="81.402298" + x2="124.5" + x1="21" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text144" + y="108.7104" + x="251" + textLength="59" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">Properties</text> + <line + id="line146" + y2="81.402298" + y1="81.402298" + x2="287" + x1="183.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text148" + y="141.2222" + x="152.5" + textLength="134" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">quality_factor (DOUBLE)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text150" + y="154.0269" + x="152.5" + textLength="92" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">date (DATETIME)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text152" + y="166.8315" + x="152.5" + textLength="111" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">report (REFERENCE)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text154" + y="179.6362" + x="152.5" + textLength="256" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">SoundQualityAnalyzer (SoundQualityAnalyzer)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text156" + y="192.4409" + x="152.5" + textLength="220" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">MusicalInstrument (MusicalInstrument)</text> + <line + id="line158" + y2="100.6094" + y1="100.6094" + x2="113" + x1="21" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text160" + y="127.9175" + x="239.5" + textLength="82" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">recommended</text> + <line + id="line162" + y2="100.6094" + y1="100.6094" + x2="287" + x1="195" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="MusicalInstrument-Violin" + d="m 145.51,354.27 c 12.48,19.76 25.51,40.37 35.69,56.48" /> + <polygon + id="polygon211" + style="fill:none;stroke:#a80036;stroke-width:1" + points="261.26,361.26 277.86,374.43 266.03,381.91 " + transform="translate(-126.5,-24)" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="MusicalInstrument-Guitar" + d="m 192.64,348.42 c 25.04,17.17 51.64,35.39 74.51,51.06" /> + <polygon + id="polygon214" + style="fill:none;stroke:#a80036;stroke-width:1" + points="302.54,361.05 322.99,366.58 315.08,378.13 " + transform="translate(-126.5,-24)" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="MusicalInstrument-Manufacturer" + d="m 91.08,350.09 c -3.97,21 -8.2,43.41 -11.46,60.66" /> + <polygon + id="polygon217" + style="fill:#a80036;stroke:#a80036;stroke-width:1" + points="220,361.26 214.9551,366.4126 217.771,373.0512 222.8159,367.8986 " + transform="translate(-126.5,-24)" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="Analysis-SoundQualityAnalyzer" + d="m 222.3,185.39 c 21.35,24.82 43.61,50.69 60.09,69.84" /> + <polygon + id="polygon220" + style="fill:#a80036;stroke:#a80036;stroke-width:1" + points="340.04,199.21 340.9231,206.3668 347.869,208.3043 346.9859,201.1475 " + transform="translate(-126.5,-24)" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="Analysis-MusicalInstrument" + d="m 130.66,187.93 c -4.56,16 -9.21,32.33 -13.37,46.92" /> + <polygon + id="polygon223" + style="fill:#a80036;stroke:#a80036;stroke-width:1" + points="260.78,199.21 255.287,203.8819 257.4868,210.7493 262.9798,206.0774 " + transform="translate(-126.5,-24)" /> +</svg> diff --git a/src/core/css/webcaosdb.css b/src/core/css/webcaosdb.css index 05e36429b7dfe6144915b522de2fa6c36375c484..69a700376423a44bcb28a9920f1f3d15ef9a3b90 100644 --- a/src/core/css/webcaosdb.css +++ b/src/core/css/webcaosdb.css @@ -27,6 +27,55 @@ body { flex-direction: column; } + +div.export-data { + display: none; +} + +tr:not(:hover) .caosdb-v-entity-version-hint-cur { + color: #DDD; +} + +tr:hover .caosdb-v-entity-version-hint { + color: unset; +} + +.caosdb-v-entity-version-hint { + color: #DDD; +} + +tbody:not(:hover) tr .caosdb-v-entity-version-hint-cur { + color: unset; +} + +.caosdb-v-entity-version-no-related { + color: #DDD; +} + +.caosdb-v-entity-version-no-related:hover { + color: unset; +} + +#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/caosdb.js b/src/core/js/caosdb.js index 7fe4e9441822bf6c706a09c95aa65f0a2fef06a8..0c63fbe038908903dcf426d1c711d4fad6d3bdd2 100644 --- a/src/core/js/caosdb.js +++ b/src/core/js/caosdb.js @@ -1,30 +1,32 @@ /* -* ** 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 -*/ + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2018-2020 Alexander Schlemmer + * Copyright (C) 2018 Research Group Biomedical Physics, + * Max-Planck-Institute for Dynamics and Self-Organization Göttingen + * Copyright (C) 2019-2020 IndiScale GmbH (info@indiscale.com) + * Copyright (C) 2019-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'; /** * JavaScript client for CaosDB - * A. Schlemmer, 08/2018 - * T. Fitschen, 02/2019 * * Dependency: jquery * Dependency: webcaosdb @@ -69,8 +71,7 @@ function getUserRealm() { * @return Array containing the roles of the user. */ function getUserRoles() { - return Array.from(document.getElementsByClassName("caosdb-user-role") - ).map(el => el.innerText); + return Array.from(document.getElementsByClassName("caosdb-user-role")).map(el => el.innerText); } /** @@ -189,9 +190,9 @@ function getPropertyDatatype(element) { x => x.classList.contains("caosdb-property-datatype"), x => x.classList.contains("caosdb-preview-container")); - if(dt_elem.length == 1){ + if (dt_elem.length == 1) { return $(dt_elem[0]).text(); - } else if (dt_elem.length > 1){ + } else if (dt_elem.length > 1) { throw new Error("The datatype of this property could not uniquely be determined."); } @@ -219,14 +220,63 @@ function getEntityName(element) { } /** - * Return the path of element. + * Return the path of an entity. + * + * This attribute is always set for file entities. + * * If the corresponding label can not be found or the label is ambigious undefined is returned. - * @return A string containing the name of the element. + * + * @param {HTMLElement} entity - entity in HTML representation. + * @return A string containing the path of the entity. */ function getEntityPath(element) { + const path = $(element).find('.caosdb-f-entity-path').val(); + if (typeof path !== 'undefined') { + return path; + } + return getEntityHeadingAttribute(element, "path"); } +/** + * Return the checksum of an entity. + * + * This attribute is always set for file entities. + * + * If the corresponding label can not be found or the label is ambigious undefined is returned. + * + * @param {HTMLElement} entity - entity in HTML representation. + * @return A string containing the checksum of the entity. + */ +function getEntityChecksum(element) { + const checksum = $(element).find('.caosdb-f-entity-checksum').val(); + if (typeof checksum !== 'undefined') { + return checksum; + } + + return getEntityHeadingAttribute(element, "checksum"); +} + +/** + * Return the size of element. This attribute is always set for file entities. + * If the corresponding label can not be found or the label is ambigious undefined is returned. + * @return A string containing the size of the element. + */ +function getEntitySize(element) { + // TODO: check if this if block is needed + // it is analogous to getEntityDescription + // if ($(element).find('[data-entity-size]').length == 1) { + // return $(element).find('[data-entity-size]')[0].dataset.entitySize; + // } + + if (typeof $(element).find('.caosdb-f-entity-size').val() !== 'undefined') { + // This is needed for the edit mode to work properly: + return $(element).find('.caosdb-f-entity-size').val(); + } + + return getEntityHeadingAttribute(element, "size"); +} + /** * Return the id of an entity. * @param element The element holding the entity. @@ -286,6 +336,32 @@ function input2caosdbDate(date, time) { return date + "T" + time; } +/** + * Return true if the current user has the given permission for the given + * entity. + * + * @param {HTMLElement} entity + * @return {boolean} + */ +var hasEntityPermission = function (entity, permission) { + if (userHasRole("administration")) { + // administration is a special role. It has * permissions. + return true; + } + const permissions = getAllEntityPermissions(entity); + return permissions.indexOf(permission.toUpperCase()) > -1; +} + +/** + * Get all permissions the current user has for this entity. + * @param {HTMLElement} entity + * @return {string[]} array of permissions. + */ +var getAllEntityPermissions = function (entity) { + const permissions = $(entity).find("[data-permission]").toArray().map(x => x.getAttribute("data-permission")); + return permissions; +} + /** * Take a datetime from caosdb and return a date and a time * suitable for html inputs. @@ -355,6 +431,7 @@ function getEntityDescription(element) { if ($(element).find('[data-entity-description]').length == 1) { return $(element).find('[data-entity-description]')[0].dataset.entityDescription; } else if (typeof $(element).find('.caosdb-f-entity-description').val() !== 'undefined') { + // This is needed for the edit mode to work properly: return $(element).find('.caosdb-f-entity-description').val(); } @@ -423,7 +500,7 @@ function getEntityXML(ent_element) { function getPropertyName(element) { var name_element = element.getElementsByClassName("caosdb-property-name"); - if(name_element.length > 0) { + if (name_element.length > 0) { return name_element[0].textContent; } else if ($(element).is("[data-property-name]")) { return $(element).attr("data-property-name"); @@ -522,7 +599,7 @@ function getPropertyFromElement(propertyelement, names = undefined) { var value_string = undefined; if (valel && valel.textContent.length > 0) { value_string = valel.textContent; - } else if (valel && valel.value && valel.value.length > 0 ) { + } else if (valel && valel.value && valel.value.length > 0) { value_string = valel.value; } @@ -539,7 +616,7 @@ function getPropertyFromElement(propertyelement, names = undefined) { if (typeof value_string !== "undefined") { // This is set to true, when there is a reference or a list of references: - if(typeof property.reference === "undefined") { + if (typeof property.reference === "undefined") { property.reference = (valel.getElementsByClassName("caosdb-id").length > 0); } @@ -620,6 +697,91 @@ function getProperties(element) { return list; } + +/** + * Construct XPath expression from selectors. + * + * Used by getPropertyValues. + * + * @param {String[][]} selectors + * @return {String[]} XPath expressions. + */ +var _constructXpaths = function (selectors) { + const xpaths = []; + for (let sel of selectors) { + var expr = "Property"; + if (sel[0] == "id") { + expr = ""; + } + for (let i = 0; i < sel.length; i++) { + const segment = sel[i]; + if (segment == "id") { + expr += `@id`; + } else if (segment) { + expr += `[@name='${segment}']`; + } + + if (i+1 < sel.length) { + expr += "//Property" + } + } + xpaths.push(expr); + } + return xpaths; +} + +/** + * Return a table where each row represents an entity and each column a property. + * + * This also works for entities from select queries, where the properties + * are deeply nested, e.g. when each entity references a "Geo Location" + * record which have latitude and longitude properties: + * + * `getPropertyValues(entities, [["Geo Location", "latitude"], ["Geo Location", "longitude"]])` + * + * Use empty strings for selector elements when the property name is irrelevant: + * + * `getPropertyValues(entities, [["", "latitude"], ["", "longitude"]])` + * + * Limitations: + * + * 1. Currently, this implementation assumes that properties (and subproperties + * for that matter) have unique names, entity-wide and do have a LIST + * datatype. + * + * 2. It only handles one of the many special cases, which is "id". Other + * special cases ("name", "description", "unit", etc.) are to be added when + * needed. + * + * @param {XMLElement[]) entities + * @param {String[][]} selectors + * @return {String[][]} A table of the property values for each entity. + */ +var getPropertyValues = function (entities, selectors) { + const entity_iter = entities.evaluate("/Response/Record", entities); + + const table = []; + const xpaths = _constructXpaths(selectors) + + var current_entity = entity_iter.iterateNext(); + while (current_entity) { + const row = []; + for (let expr of xpaths) { + const property = entities.evaluate(expr, current_entity).iterateNext(); + if (typeof property != "undefined" && property != null) { + row.push(property.textContent.trim()); + } else { + row.push(undefined) + } + + } + table.push(row); + current_entity = entity_iter.iterateNext(); + } + + return table; +} + /** * Sets a property with some basic type checking. * @@ -663,8 +825,8 @@ function setPropertySafe(valueelement, property, propold) { } } else { /* DEPRECATED css class .caosdb-property-text-value - Use - * .caosdb-f-property-single-raw-value or introduce new - * .caosdb-v-property-text-value */ + * .caosdb-f-property-single-raw-value or introduce new + * .caosdb-v-property-text-value */ valueelement.innerHTML = "<span class='caosdb-property-text-value'>" + property.value + "</span>"; } } @@ -711,7 +873,7 @@ function setProperty(element, property) { * equivalent). * @returns {string} The value of the the property with property_name or `undefined` when this property is not available for this entity. */ -function getProperty(element, property_name, case_sensitive=true) { +function getProperty(element, property_name, case_sensitive = true) { var props; if (case_sensitive) { props = getProperties(element).filter(el => el.name == property_name); @@ -808,7 +970,7 @@ function appendProperty(doc, element, property, append_datatype = false) { * * @param {string} root - the new root element. * @returns {(Document|DocumentFragement)} the new document. - */ + */ function _createDocument(root) { var doc = undefined; if (window.DocumentFragment) { @@ -827,17 +989,20 @@ function _createDocument(root) { * This function uses the object notation. * @see getProperties * @see getParents - * @param role Record, RecordType or Property + * @param role Record, RecordType, Property or File (in case of files the three file arguments must be used!) * @param name The name of the entity. Can be undefined. * @param id The id of the entity. Can be undefined. * @param properties A list of properties. * @param parents A list of parents. + * @param description A description for this entity. * @return {Document|DocumentFragment} - An xml document holding the newly * created entity. * */ function createEntityXML(role, name, id, properties, parents, - append_datatypes = false, datatype = undefined, description = undefined, unit = undefined) { + append_datatypes = false, datatype = undefined, description = undefined, + unit = undefined, + file_path = undefined, file_checksum = undefined, file_size = undefined) { var doc = _createDocument(role); var nelnode = doc.children[0]; @@ -869,9 +1034,49 @@ function createEntityXML(role, name, id, properties, parents, appendProperty(doc, nelnode, properties[i], append_datatypes); } } + + if (role.toLowerCase() == "file") { + /* + File path, checksum and size are needed for File entities. + + An error is raised when these arguments are not set. + */ + if (file_path === undefined || file_checksum === undefined || file_size === undefined) { + throw "Path, checksum and size must not be undefined in case of file entities."; + } + + $(nelnode).attr("path", file_path); + $(nelnode).attr("checksum", file_checksum); + $(nelnode).attr("size", file_size); + } return doc; } +/** + * Create an XML for a file entity. + * This is a convenience function for creating XML from file entities. + * This function uses the object notation. + * @see getProperties + * @see getParents + * @param name The name of the entity. Can be undefined. + * @param id The id of the entity. Can be undefined. + * @param parents A list of parents. + * @param file_path The path of the file in the CaosDB file system. + * @param file_checksum The checksum of the file. + * @param file_size The size of the file in bytes. + * @param description A description for this entity. + * @return {Document|DocumentFragment} - An xml document holding the newly + * created entity. + * + */ +function createFileXML(name, id, parents, + file_path, file_checksum, file_size, + description = undefined) { + return createEntityXML("File", name, id, {}, parents, + false, undefined, description, undefined, + file_path, file_checksum, file_size); +} + /** * Helper function to wrap xml documents into another node which could e.g. be * Update, Response, Delete. @@ -885,7 +1090,7 @@ function wrapXML(root, xmls) { caosdb_utils.assert_string(root, "param `root`"); var doc = _createDocument(root); - for (var i=0; i < xmls.length; i++) { + for (var i = 0; i < xmls.length; i++) { doc.firstElementChild.appendChild(xmls[i].firstElementChild); } diff --git a/src/core/js/edit_mode.js b/src/core/js/edit_mode.js index 0c52f403d5d9b2f0b2feb60a9dc21403c12dc0b2..5da4303fb7227e12b38ca2d23628e21a4c996c63 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. */ @@ -406,9 +411,21 @@ var edit_mode = new function() { * entity in XML representation. */ this.form_to_xml = function(entity_form) { - const obj = form_elements.form_to_object($(entity_form).find("form")[0]); + const obj = form_elements.form_to_object($(entity_form).find("form")[0])[0]; + var entityRole = getEntityRole(entity_form); + var file_path = undefined; + var file_checksum = undefined; + var file_size = undefined; + if (entityRole.toLowerCase() == "file") { + file_path = getEntityPath(entity_form); + file_checksum = getEntityChecksum(entity_form); + file_size = getEntitySize(entity_form); + console.log(file_path); + console.log(file_checksum); + console.log(file_size); + } return createEntityXML( - getEntityRole(entity_form), + entityRole, getEntityName(entity_form), getEntityID(entity_form), edit_mode.getProperties(entity_form), @@ -417,6 +434,7 @@ var edit_mode = new function() { edit_mode.get_datatype_str(obj), getEntityDescription(entity_form), obj.unit, + file_path, file_checksum, file_size ); } @@ -625,6 +643,8 @@ var edit_mode = new function() { header.attr("title", ""); } else if (getEntityRole(roleElem[0]) == "File") { inputs.push(this.make_input("path", getEntityPath(entity))); + inputs.push(this.make_input("checksum", getEntityChecksum(entity))); + inputs.push(this.make_input("size", getEntitySize(entity))); } // remove other stuff header.children().remove(); @@ -1188,7 +1208,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 +1553,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 +1801,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..9d99241485245ca6232a7626c04f7998579174e8 --- /dev/null +++ b/src/core/js/ext_autocomplete.js @@ -0,0 +1,161 @@ +/* + * ** 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", + "ANY VERSION OF", + ]; + 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..d99ff362d8a26ee4818a76a624c80f55f757c419 --- /dev/null +++ b/src/core/js/ext_bookmarks.js @@ -0,0 +1,730 @@ +/* + * ** 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.2 + * + * @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. + * + * TODO merge with caosdb_utils.create_tsv_table. + * + * @param {string[]} bookmarks - array of ids. + * @param {string} [preamble="data:text/csv;charset=utf-8,"] - the preamble + * which is used for generating tables which can be downloaded by + * browsers. + * @param {string} [tab="%09"] - the tab string. + * @param {string} [newline="%0A"] - the newline string. + * @param {string[]} [leading_comments] - comment lines which are to be put + * even before the header line. They should be appropriately escaped (e.g. + * with "%23"). + */ + const get_export_table = async function (bookmarks, preamble, tab, newline, leading_comments) { + // 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"; + leading_comments = (leading_comments ? leading_comments.join(newline) + newline : ""); + const header = leading_comments + 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)}`; + } + + /** + * Download the table with a given filename. + * + * This method adds a temporay <A> element to the dom tree and triggers + * "click" because otherwise the filename cannot be set. + * + * See also: + * https://stackoverflow.com/questions/21177078/javascript-download-csv-as-file + */ + const download = function (table, filename) { + console.log("download"); + const link = $(`<a style="display: none" download="${filename}" href="${table}"/>`); + $("body").append(link); + link[0].click(); + link.remove(); + } + + /** + * 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 versioned_bookmarks = [] + for (let id of ids) { + if (id.indexOf("@") > -1) { + versioned_bookmarks.push(id); + } else { + versioned_bookmarks.push(id + "@" + (await get_bookmark_data(id, "Version"))); + } + } + const uri = get_collection_link(ids); + const leading_comments = [encodeURIComponent(`#Link to all entities: ${uri}`)]; + const export_table = await get_export_table(ids, undefined, undefined, undefined, leading_comments); + download(export_table, "bookmarked_entities.tsv"); + } + + /** + * 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) { + if (data_no_cache.indexOf(data_key) == -1) { + // 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) { + if (id.indexOf("@") > -1) { + const entity = $(`[data-bmval='${id}']`); + if (entity.length > 0) { + return getEntityPath(entity[0]) || ""; + } + } + return $(await transaction.retrieveEntityById(id)).attr("path"); + } + + // This retrieves the head version id + const get_version = async function (id) { + return $(await transaction.retrieveEntityById(id)).find("Version").attr("id"); + } + + const get_name = async function (id) { + if (id.indexOf("@") > -1) { + const entity = $(`[data-bmval='${id}']`); + if (entity.length > 0) { + return getEntityName(entity[0]) || ""; + } + } + return $(await transaction.retrieveEntityById(id)).attr("name"); + } + + const get_rt = async function (id) { + if (id.indexOf("@") > -1) { + const entity = $(`[data-bmval='${id}']`); + if (entity.length > 0) { + return getParents(entity[0]).join("/"); + } + } + const parent_names = $(await transaction.retrieveEntityById(id)) + .find("Parent").toArray().map(x => x.getAttribute("name")) + return parent_names.join("/"); + } + + // these columns will be in the export + const tsv_columns = ["ID", "Version", "URI", "Path", "Name", "RecordType"]; + // functions for collecting the export data for a particular bookmarked id. + const data_getters = { + "ID": (id) => id.indexOf("@") > -1 ? id.split("@")[0] : id, + "Version": async (id) => id.indexOf("@") > -1 ? id.split("@")[1] : await get_version(id), + "Path": get_path, + "URI": async (id) => get_context_root() + id + (id.indexOf("@") > -1 ? "" : ("@" + await get_version(id))), + "Name": get_name, + "RecordType": get_rt, + }; + + // we cannot cache these because the the values might change unnoticed + // when the head moves to a newer version. + const data_no_cache = ["ID", "Version", "URI", "Path", "Name", "RecordType"]; + + 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 0573be0f2448ab1decc4156b48f584874d64c0c1..ca95598e6411fb4ffd056c593d7adbb297896321 100644 --- a/src/core/js/ext_bottom_line.js +++ b/src/core/js/ext_bottom_line.js @@ -23,6 +23,28 @@ 'use strict'; +/** + * @typedef {BottomLineConfig} + * @property {string|HTMLElement} fallback - Fallback content if none of + * the creators are applicable. + * @property {string} version - the version of the configuration which must + * match this module's version. + * @property {CreatorConfig[]} creators - an array of creators. + */ + +/** + * @typedef {CreatorConfig} + * @property {string} [id] - a unique id for the creator. optional, for + * debuggin purposes. + * @property {function|string} is_applicable - If this is a string this has + * to be valid javascript! An asynchronous function which accepts one + * parameter, an entity in html representation, and which returns true + * iff this creator is applicable for the given entity. + * @property {string} create - This has to be valid javascript! An + * asynchronous function which accepts one parameter, an entity in html + * representation. It returns a HTMLElement or text node which will be + * shown in the bottom line container iff the creator is applicable. + */ /** * Add a special section to each entity one the current page where a thumbnail, @@ -42,36 +64,17 @@ * @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, ext_applicable, UTIF, ext_table_preview) { /** * @property {string|function} create - a function with one parameter * (entity) Note: This property can as well be a * javascript string which evaluates to a function. */ - /** - * @type {BottomLineConfig} - * @property {string|HTMLElement} fallback - Fallback content if none of - * the creators are applicable. - * @property {string} version - the version of the configuration which must - * match this module's version. - * @property {CreatorConfig[]} creators - an array of creators. - */ - /** - * @type {CreatorConfig} - * @property {string} [id] - a unique id for the creator. optional, for - * debuggin purposes. - * @property {function|string} is_applicable - If this is a string this has - * to be valid javascript! An asynchronous function which accepts one - * parameter, an entity in html representation, and which returns true - * iff this creator is applicable for the given entity. - * @property {string} create - This has to be valid javascript! An - * asynchronous function which accepts one parameter, an entity in html - * representation. It returns a HTMLElement or text node which will be - * shown in the bottom line container iff the creator is applicable. - */ /** * Check if an entity has a path attribute and one of a set of extensions. @@ -103,7 +106,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 | 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(); + }); } /** @@ -119,6 +206,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. * @@ -128,17 +217,26 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit id: "_default_creators.pictures", is_applicable: (entity) => _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) => _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), }, { // fallback id: "_default_creators.fallback", is_applicable: (entity) => true, create: (entity) => fallback_preview, - }, + } ]; @@ -165,6 +263,106 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit _css_class_preview_container}`)[0]; } + /** + * Add the element to the entity's preview container and remove all other + * content. + * + * This method appends the element (usually a preview, a waiting + * notification or an error message) to the HTMLElement with class + * `caosdb-f-ext_bottom_line-container` which was added to the entity by the + * {@link root_preview_handler}. + * + * If the preview container cannot be found an error is logged, but not + * thrown. + * + * @param {HTMLElement|String} element - A preview, a waiting notification, + * an error message or similar. + * @param {HTMLElement} entity - An entity in HTML Representation which + * must have a (deep) child with class `caosdb-f-ext_bottom_line-container`. + */ + const set_preview_container = function(entity, element) { + const preview_container = $(get_preview_container(entity)); + if (preview_container[0]) { + preview_container.empty(); + var buttons = preview_container.siblings(`.${_css_class_preview_container_button}`); + if (element) { + buttons.css({ + "visibility": "initial" + }); + preview_container.append(element); + } else { + buttons.css({ + "visibility": "hidden" + }); + } + } else { + logger.error(new Error("Could not find the preview container.")); + } + } + + /** + * 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. * @@ -304,6 +502,9 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit } + /** + * @exports ext_bottom_line + */ return { previewShownEvent: previewShownEvent, init: init, @@ -313,7 +514,7 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit root_preview_handler: root_preview_handler, _creators: _creators, } -}($, 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, ext_applicable, UTIF, ext_table_preview); /** @@ -336,20 +537,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; } @@ -377,7 +581,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_jupyterdrag.js b/src/core/js/ext_jupyterdrag.js new file mode 100644 index 0000000000000000000000000000000000000000..87d2f3a964ced1411df66da04ff4ddd4d7db2f15 --- /dev/null +++ b/src/core/js/ext_jupyterdrag.js @@ -0,0 +1,76 @@ +/* + * ** 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> + * Copyright (C) 2020 Alexander Schlemmer <alexander.schlemmer@ds.mpg.de> + * + * 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_jupyterdrag module adds dragstart listeners to the WebUI which allow + * dragging single entities into Jupyter notebooks and other text editors (for + * python code) which implement drop gestures for text data. + * + * @module ext_jupyterdrag + * @version 0.1 + * + * @requires jQuery + * @requires log + * @requires getEntityRole + * @requires getEntityID + */ +var ext_jupyterdrag = function($, logger, getEntityRole, getEntityID) { + + + /** + * Initialize the ext_jupyterdrag module. + * + */ + var init = async function() { + $(".caosdb-entity-panel").find( + ".caosdb-entity-panel-heading").attr("draggable", + "true"); + $(".caosdb-entity-panel").find( + ".caosdb-entity-panel-heading").on("dragstart", + function(ev) { + logger.trace("dragstart", ev); + var eel = this.parentElement; + var role = getEntityRole(eel); + var entid = getEntityID(eel); + ev.originalEvent.dataTransfer.setData( + "text/plain", + "db." + role + "(id=" + entid + ")"); + }); + } + + + + return { + // public members, part of the API + init: init, + _logger: logger, + } +}($, log.getLogger("ext_jupyterdrag"), getEntityRole, getEntityID); + + +$(document).ready(function() { + caosdb_modules.register(ext_jupyterdrag); +}); diff --git a/src/core/js/ext_map.js b/src/core/js/ext_map.js index 67c7bfe799c8c96eb945ec9334ad87fa3f7d70f9..3b89f72be05d2307cdcc2e624e4b8a69684d591f 100644 --- a/src/core/js/ext_map.js +++ b/src/core/js/ext_map.js @@ -25,7 +25,7 @@ /** * @module caosdb_map - * @version 0.3 + * @version 0.4 * * For displaying a geographical map which shows entities at their associated * geolocation. @@ -34,7 +34,7 @@ * `conf/ext/json/ext_map.json` and comply with the {@link MapConfig} type * which is described below. * - * The current version is 0.3. It is not considered to be stable because + * The current version is 0.4. It is not considered to be stable because * implementation of the graticule still is not satisfactory. * * Apart from that the specification of the configuration and the @@ -43,7 +43,7 @@ var caosdb_map = new function () { var logger = log.getLogger("caosdb_map"); - this.version = "0.3"; + this.version = "0.4"; this.dependencies = ["log", { "L": ["latlngGraticule", "Proj"] }, "navbar", "caosdb_utils"]; @@ -84,10 +84,14 @@ var caosdb_map = new function () { /** * The SelectConfig object configures the custom {@link select_handler} * plugin for the Leaflet.js module, especially the query generation (for - * searching for Entities in the selected area). + * searching for Entities in the selected area) and when retrieving + * entities to be shown. * - * The generated query has the pattern <code>FIND {@link query.role} {@link - * query.entity} WITH ...<code>. The dots stand for the area filter here. + * The generated query for a selected area has the pattern + * <code>FIND {@link query.role} {@link query.entity} WITH PATH AREA<code>. + * PATH can be empty or represent a configured path to some entity, e.g. + * `WITH RT1 WITH RT2`. + * AREA stand for the area filter here. * * The default values of the {@link query} result in queries for any Record * in the selected map area. @@ -98,6 +102,8 @@ var caosdb_map = new function () { * are to be searched in the selected ares. * @property {string} [query.entity] The (parent) entity to be searched * for in the area. Defaults to empty string. + * @property {object} [paths] - A dictionary of paths that define from + * which entities the geographic location shall be taken. */ /** @@ -332,6 +338,7 @@ var caosdb_map = new function () { "role": "RECORD", "entity": "", }, + "paths": {}, }, } @@ -373,21 +380,190 @@ var caosdb_map = new function () { */ /** - * Implements {@link mapEntityGetter}. + * Generates a Property Operator Value (POV) expression by chaining the + * provided arguments with "WITH". + * + * @param {string[]} props - array with the names of RecordTypes + * @returns {string} string with the the filter + */ + this._get_with_POV = function (props) { + var pov = "" + for (let p of props) { + pov = pov + ` WITH ${p} `; + } + return pov; + } + + /** + * Generates a Property Operator Value (POV) by joining ids with OR. + * + * @param {number[]} ids - array of ids for the filter + * @returns {string} string with the the filter + */ + this._get_id_POV = function (ids) { + ids = ids.map(x => "id=" + x); + return "WITH " + ids.join(" or ") + } + + /** + * Generates a SELECT query string that applies the provided path of + * properties as POV and as selector + * + * If ids is provided, the condition is not created from the path, but + * from ids. + * + * @param {DataModelConfig} datamodel - datamodel of the entities to be returned. + * @param {string[]} path - array with the names of RecordTypes + * @param {number[]} ids - array of ids for the filter + * @returns {string} query string */ - this._get_current_page_entities = function ( - datamodel, north, south, west, east) { - const container = $(".caosdb-f-main-entities")[0]; + this._get_select_with_path = function (datamodel, path, ids) { + if (typeof datamodel === "undefined") { + throw new Error("Supply the datamodel.") + } + if (typeof path === "undefined" || path.length == 0) { + throw new Error("Supply at least a RecordType.") + } + const recordtype = path[0]; + const props = path.slice(1, path.length) + var selector = props.join(".") + if (selector != "") { + selector = selector + "." + } + var pov = undefined; + if (typeof ids === "undefined") { + pov = (caosdb_map._get_with_POV(props) + + ` WITH ${datamodel.lat} AND ${datamodel.lng}`); + + } else { + pov = caosdb_map._get_id_POV(ids); + } + return `SELECT parent,${selector}${datamodel.lat},${selector}${datamodel.lng} FROM ENTITY ${recordtype} ${pov} `; + + } + + + /** + * Returns a dictionary where for each top level record in the xmldoc + * The long and lat is assigned. + * + * depth: the depth of the tree including the top level record type: + * e.g. RT1->prop1->prop2->lat/long would be a depth=3 + * + * @param {XMLDocument} xmldoc - xml document containing the entities + * @param {number} depth - the depth at which the properties shall be taken + * @param {DataModelConfig} datamodel - datamodel of the entities to be returned. + * @returns {Object} a dictionary where for each id as key the location ist + * stored as [lat, lng] + */ + this._get_leaf_prop = function (xmldoc, depth, datamodel) { + const paths = [ + ["id"], + // The following creates a list: ["", "", ... (depth times), lat/long] + ("__split__".repeat(depth) + datamodel.lat).split("__split__"), + ("__split__".repeat(depth) + datamodel.lng).split("__split__"), + ]; + const propertyValues = getPropertyValues(xmldoc, paths); + + const leaves = {}; + for (let row of propertyValues) { + leaves[row[0]] = [row[1], row[2]]; + } + return leaves; + } + + /** + * Template for {@link mapEntityGetter}. + * + * This implementation has a single additional parameter which is not + * defined by {@link mapEntityGetter}: + * + * @param {string[]} path - array of strings defining the path to the + * related entity + */ + this._generic_get_current_page_entities = async function ( + datamodel, north, south, west, east, path) { + var container = $(".caosdb-f-main-entities")[0]; + + if (typeof path !== "undefined" && path.length) { + var ids = [] + for (let rec of getEntities(container)) { + ids.push(getEntityID(rec)) + } + if (ids.length) { + const qs = caosdb_map._get_select_with_path(datamodel, path, ids); + let entities = await connection.get("Entity/?query=" + qs); + caosdb_map._set_subprops_at_top(entities, path.length - 1, datamodel); + let results = await transformation.transformEntities(entities); + container = $('<div>').append(results)[0]; + } else { + return []; + } + } + // it is possible, the the page contains entities which do not have + // lat/lng and there doesn't exist any related entity with lat/lng. return caosdb_map.get_map_entities(container, datamodel); } /** - * Implements {@link mapEntityGetter}. + * Returns a top level record entity from xml. + * + * @param {XMLDocument} entities - xml document containing the entities + * @param {number} rec_id - id of the record to be returned + * @returns {XMLDocument} the corresponding record + */ + this._get_toplvl_rec_with_id = function (entities, rec_id) { + let tmp = $(entities).find(`Response >[id='${rec_id}']`); + if (tmp.length != 1) { + throw new Error("There should be exactly one result record. Not " + + tmp.length) + } + return tmp[0]; + } + + /** + * Set the longitude/latitude from subproperties to the top level + * records in the xml and convert everything to html. + * + * @param {XMLDocument} entities - xml document containing the entities + * @param {number} depth - the depth of the path (full: including the first + * recordtype) + */ + this._set_subprops_at_top = function (entities, depth, datamodel) { + var latlong = caosdb_map._get_leaf_prop(entities, depth, datamodel); + + for (let rec_id in latlong) { + let tmp_rec = caosdb_map._get_toplvl_rec_with_id(entities, rec_id); + tmp_rec.append(str2xml(`<Property name="${datamodel.lat}">${latlong[rec_id][0]}</Property>`).firstElementChild); + tmp_rec.append(str2xml(`<Property name="${datamodel.lng}">${latlong[rec_id][1]}</Property>`).firstElementChild); + } + } + + /** + * Template for {@link mapEntityGetter}. + * + * This implementation has a single additional parameter which is not + * defined by {@link mapEntityGetter}: + * + * @param {string[]} path - array of strings defining the path to the + * related entity */ - this._query_all_entities = async function ( - datamodel, north, south, west, east) { - const results = await caosdb_map.query(`FIND ENTITY WITH ${datamodel.lat} AND ${datamodel.lng}`); + this._generic_query_all_entities = async function ( + datamodel, north, south, west, east, path) { + var results = undefined; + if (typeof path !== "undefined" && path.length) { + const qs = caosdb_map._get_select_with_path(datamodel, path); + let entities = await connection.get("Entity/?query=" + qs); + caosdb_map._set_subprops_at_top(entities, path.length - 1, datamodel); + results = await transformation.transformEntities(entities); + } else { + results = await caosdb_map.query( + `FIND ENTITY WITH ${datamodel.lat} AND ${datamodel.lng}`); + } const container = $('<div>').append(results)[0]; + + // As soon as the SELECT query can handle subtyping, the results don't + // have to filtered anymore. return caosdb_map.get_map_entities(container, datamodel); } @@ -410,7 +586,12 @@ var caosdb_map = new function () { const name = caosdb_map.make_entity_name_label(entity); const dms_lat = L.NumberFormatter.toDMS(lat); const dms_lng = L.NumberFormatter.toDMS(lng); - const loc = $(`<div class="small text-muted"> + let extra_loc_hint = ""; + let path = caosdb_map._get_current_path(); + if (path && path.length > 1) { + extra_loc_hint = `<div>Location of related ${path[path.length-1]}<div>`; + } + const loc = $(`<div class="small text-muted">${extra_loc_hint} Lat: ${dms_lat} Lng: ${dms_lng} </div>`); const ret = $('<div/>') @@ -421,6 +602,18 @@ var caosdb_map = new function () { return ret[0]; } + /** + * Returns the path from the config corresponding to the value stored in + * the session storage (i.e. the storage should be updated before calling + * this method if the value changes). + * + * @returns {string[]} path - array of strings defining the path to the + * related entity + */ + this._get_current_path = function () { + return caosdb_map.config.select.paths[sessionStorage["caosdb_map.display_path"]]; + } + /** * Default entities layers configuration with two layers: @@ -430,43 +623,52 @@ var caosdb_map = new function () { * * @type {EntityLayerConfig[]} */ - this._default_entity_layer_config = [{ - "id": "current_page_entities", - "name": "Entities on the current page.", - "description": "Show all entities on the current page.", - "icon": { - html: '<span style="color: #00F; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>', - iconAnchor: [10, 19], - className: "", - }, - "zIndexOffset": 1000, - "datamodel": { - "lat": "latitude", - "lng": "longitude", - "role": "ENTITY", - "entity": "", - }, - "get_entities": this._get_current_page_entities, - "make_popup": this._make_map_popup, - }, { - "id": "all_map_entities", - "name": "All entities", - "description": "Show all entities with coordinates.", - "icon": { - html: '<span style="color: #F00; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>', - iconAnchor: [10, 19], - className: "", + this._default_entity_layer_config = { + "current_page_entities": { + "name": "Entities on the current page.", + "description": "Show all entities on the current page.", + "icon": { + html: '<span style="color: #00F; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>', + iconAnchor: [10, 19], + className: "", + }, + "zIndexOffset": 1000, + "datamodel": { + "lat": "latitude", + "lng": "longitude", + "role": "ENTITY", + "entity": "", + }, + "get_entities": (datamodel, north, south, west, east) => { + let path = caosdb_map._get_current_path() + return caosdb_map._generic_get_current_page_entities( + datamodel, north, south, west, east, path) + }, + "make_popup": this._make_map_popup, }, - "zIndexOffset": 0, - "datamodel": { - "lat": "latitude", - "lng": "longitude", - "role": "ENTITY", - "entity": "", + "all_map_entities": { + "name": "All entities", + "description": "Show all entities with coordinates.", + "icon": { + html: '<span style="color: #F00; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>', + iconAnchor: [10, 19], + className: "", + }, + "zIndexOffset": 0, + "datamodel": { + "lat": "latitude", + "lng": "longitude", + "role": "ENTITY", + "entity": "", + }, + "get_entities": (datamodel, north, south, west, east) => { + let path = caosdb_map._get_current_path() + return caosdb_map._generic_query_all_entities( + datamodel, north, south, west, east, path) + }, + "make_popup": this._make_map_popup, }, - "get_entities": this._query_all_entities, - "make_popup": this._make_map_popup, - }, ]; + }; /** @@ -730,6 +932,22 @@ var caosdb_map = new function () { throw new Error("Could not find view " + id); } + /** + * Reload layers. + */ + this._reload_layers = function () { + caosdb_map._show_load_info() + const promises = [] + for (const layer of caosdb_map.layers) { + promises.push(caosdb_map._fill_layer(layer.layer_group, + caosdb_map._default_entity_layer_config[layer.id])); + } + Promise.all(promises).then((val) => { + caosdb_map._hide_load_info() + }) + } + + /** Initialize the caosdb_map module. * @@ -807,16 +1025,36 @@ var caosdb_map = new function () { view_config); // init entity layers - var layers = this.init_entity_layers(this._default_entity_layer_config); + this.layers = this.init_entity_layers(this._default_entity_layer_config); var layerControl = L.control.layers(); - for (const layer of layers) { + + const promises = [] + for (const layer of this.layers) { + + promises.push(caosdb_map._fill_layer(layer.layer_group, + this._default_entity_layer_config[layer.id])); layerControl.addOverlay(layer.layer_group, layer.chooser_html.outerHTML); layer.layer_group.addTo(this._map); } + Promise.all(promises).then((val) => { + caosdb_map._hide_load_info() + }) layerControl.addTo(this._map); // initialize handlers this.add_select_handler(this._map); + + + this.path_ddm = this._get_path_ddm( + (event) => { + sessionStorage["caosdb_map.display_path"] = event.target.value; + caosdb_map._reload_layers(); + }, + this.config.select.paths + ); + this._map.addControl(this.path_ddm); + + this.add_view_change_handler( this._map, config.views, @@ -858,12 +1096,33 @@ var caosdb_map = new function () { */ this.init_entity_layers = function (configs) { var ret = [] - for (const conf of configs) { - ret.push(this.init_entity_layer(conf)); + for (let name in configs) { + configs[name]["id"] = name; + ret.push(this._init_single_entity_layer(configs[name])); } return ret; } + /** + * Initialize an entity layer. + * + * @param {EntityLayerConfig} config + * @return {_EntityLayer} + */ + this._fill_layer = async function (layer_group, config) { + // in case load is called on a filled layer: clear first + layer_group.clearLayers(); + + var entities = await config.get_entities(config.datamodel); + layer_group.entities = entities; + var markers = caosdb_map.create_entity_markers( + entities, config.datamodel, config.make_popup, + config.zIndexOffset, config.icon); + + for (const marker of markers) { + layer_group.addLayer(marker); + } + }; /** * Initialize an entity layer. @@ -871,24 +1130,11 @@ var caosdb_map = new function () { * @param {EntityLayerConfig} config * @return {_EntityLayer} */ - this.init_entity_layer = function (config) { - logger.trace("enter init_entity_layer", config); + this._init_single_entity_layer = function (config) { + logger.trace("enter _init_single_entity_layer", config); var layer_group = L.layerGroup(); - // load all entities into layer group - var _load = async function (layer_group, config) { - var entities = await config.get_entities(config.datamodel); - var markers = caosdb_map.create_entitiy_markers( - entities, config.datamodel, config.make_popup, - config.zIndexOffset, config.icon); - - for (const marker of markers) { - layer_group.addLayer(marker); - } - }; - _load(layer_group, config); - var ret = { "id": config.id, "active": typeof config.active === "undefined" || config.active, @@ -975,7 +1221,6 @@ var caosdb_map = new function () { L.Handler.extend(this.select_handler); } - /** * Show the query panel if not visible, collapse the query shortcuts * if visible and fill the query string into the text input of the @@ -1048,9 +1293,18 @@ var caosdb_map = new function () { this.generate_query_from_bounds = function (north, south, west, east) { const role = this.config.select.query.role; - const entity = this.config.select.query.entity; + var entity = this.config.select.query.entity; const lat = this.config.datamodel.lat; const lng = this.config.datamodel.lng; + let path = caosdb_map._get_current_path(); + if (path && path.length > 0 && entity == "") { + entity = path[0]; + } + var additional_path = "" + if (path && path.length > 1) { + additional_path = caosdb_map._get_with_POV( + path.slice(1, path.length)) + } const query_filter = " ( " + lat + " < '" + north + "' AND " + lat + @@ -1058,7 +1312,7 @@ var caosdb_map = new function () { "' AND " + lng + " < '" + east + "' ) "; - const query = "FIND " + role + " " + entity + + const query = "FIND " + role + " " + entity + additional_path + " WITH " + query_filter; return query } @@ -1201,7 +1455,7 @@ var caosdb_map = new function () { const entity_on_page = $(`#${id}`).length > 0; const href = entity_on_page ? `#${id}` : connection.getBasePath() + `Entity/${id}` - const link_title = entity_on_page ? "Jump to this entitiy." : "Browse to this entity."; + const link_title = entity_on_page ? "Jump to this entity." : "Browse to this entity."; const link = $(`<a title="${link_title}" href="${href}"/>`) .addClass("pull-right") .append(`<span class="glyphicon glyphicon-share-alt"/></a>`); @@ -1239,8 +1493,8 @@ var caosdb_map = new function () { * @param {DivIcon_options} icon_options * @returns {L.Marker[]} an array of markers for the map. */ - this.create_entitiy_markers = function (entities, datamodel, make_popup, zIndexOffset, icon_options) { - logger.trace("enter create_entitiy_markers", entities, datamodel, zIndexOffset, icon_options); + this.create_entity_markers = function (entities, datamodel, make_popup, zIndexOffset, icon_options) { + logger.trace("enter create_entity_markers", entities, datamodel, zIndexOffset, icon_options); var ret = [] for (const map_entity of entities) { @@ -1440,6 +1694,83 @@ var caosdb_map = new function () { }, } + /** + * Shows the loading information div of the map + */ + this._show_load_info = function () { + $(".caosdb-f-map-loading").attr("style", "display:inherit"); + } + + /** + * Hides the loading information div of the map + */ + this._hide_load_info = function () { + $(".caosdb-f-map-loading").attr("style", "display:None"); + } + + /** + * Return a new leaflet control for setting paths to use for geo location + * + * @param {function} callback - a callback applies the effect of a + * changed path + * @returns {L.Control} the drop down menu button. + */ + this._get_path_ddm = function (callback, paths) { + + // TODO flatten the structure of the code and possibly merge it with the query_button code. + var path_ddm = L.Control.extend({ + options: { + position: "bottomright" + }, + + onAdd: function (m) { + return this.button; + }, + + button: function () { + // TODO refactor to make_map_control function + var button = L.DomUtil + .create("div", + "leaflet-bar leaflet-control leaflet-control-custom" + ); + button.title = `Show the location of related entities. +By default ('Same Entity') entities are shown that have +a geographic location. The other options allow to show +entities on the map using the location of a related +entity.`; + button.style.backgroundColor = "white"; + button.style.textAlign = "center"; + // Distance to zoom buttons: + button.style.marginTop = "10px"; + // TODO implement helper for pictures + let tmp_html = ('<div class="caosdb-f-map-loading" style="display:inherit">Loading Entities...</div><select><option value="same">Same Entity</option>'); + for (let pa in paths) { + tmp_html += `<option value="${pa}">${pa}</option>`; + } + tmp_html += '</select>'; + button.innerHTML = tmp_html; + const select = $(button).find('select'); + select.on("change", callback); + + const current_path = sessionStorage["caosdb_map.display_path"] || "same"; + sessionStorage["caosdb_map.display_path"] = current_path; + select[0].value = current_path + + $(button).on("mousedown", ( + event) => { + event + .stopPropagation(); + }); + $(button).on("mouseup", ( + event) => { + event + .stopPropagation(); + }); + return button; + }(), + }); + return new path_ddm(); + } /** * Plug-in for leaflet which lets the user select an area in the map diff --git a/src/core/js/ext_references.js b/src/core/js/ext_references.js index 39e16d27817f721d9933bfff8fa37ca06bae321b..7cd597e128e8c09da9134f42f542898fb84a4e53 100644 --- a/src/core/js/ext_references.js +++ b/src/core/js/ext_references.js @@ -54,144 +54,6 @@ var isOutOfViewport = function (elem) { }; -/** - * @module awi_references - * @version 0.1 - * - * Special functionality for AWI. Should be removed from the main repository in - * the future. - * - * @author Timm Fitschen - */ -var awi_references = new function () { - - var logger = log.getLogger("awi_references"); - - - this.find_bag_of_sample = async function (id) { - return await - this._find_ice_sample_back_ref(id, "Bag"); - } - - - this.find_ice_core_of_sample = async function (id) { - return await - this._find_ice_sample_back_ref(id, "IceCore"); - } - - - this._find_ice_sample_back_ref = async function (id, rt, oldcounter) { - var counter = oldcounter + 1 || 1 - if (counter > 5) { - return null; - } - var - referencing_samples = await query( - "FIND IceSample WHICH REFERENCES " + - id); - for (const sample of referencing_samples) { - if (resolve_references.is_child(sample, rt)) { - return sample; - } else { - var ret = - await - this._find_ice_sample_back_ref(getEntityID(sample), - rt, - counter); - if (ret) { - return ret; - } - } - } - return undefined; - } - - const _stripe_re = /Stripe$/i; - this.isStripe = function (el) { - return _stripe_re.test(el.name) - } - - - this.get_icecore = async function (bag) { - var id = getEntityID(bag); - var icecore = (await query( - "SELECT name FROM IceCore WHICH REFERENCES " + - id))[0]; - var bag_number = getProperty(bag, "Number", false); - var ret = { - "data": { - "bag": bag_number - } - }; - if (!icecore) { - ret["text"] = - `${id} (Bag ${bag_number}, no Ice Core)`; - } else { - ret["text"] = `${id} (Ice Core ${getEntityName(icecore)}, Bag ${bag_number})`; - ret["data"]["icecore"] = getEntityName(icecore); - } - return ret; - } - - - this.get_bag_and_icecore = async function (sample) { - var id = - getEntityID(sample); - var bag = await awi_references.find_bag_of_sample(id); - var ret = {}; - if (!bag) { - var icecore = await awi_references.find_ice_core_of_sample(id); - if (!icecore) { - ret["text"] = `${id} (Sample w/o Bag or Ice Core)`; - } else { - ret["text"] = `${id} (Ice Core ${getEntityName(icecore)}, no Bag)`; - ret["data"] = { - "icecore": getEntityName(icecore) - }; - } - } else { - return await awi_references.get_icecore(bag); - } - return ret; - } - - - this.summarize_subsamples = function (ref_infos) { - logger.trace("enter summarize_subsamples ", ref_infos); - var icecores = {}; - for (const ref_info of ref_infos) { - const icecore_name = ref_info.data.icecore || "none"; - if (!icecores[icecore_name]) { - icecores[icecore_name] = []; - } - const bagnumber = parseInt(ref_info.data.bag, 10); - icecores[icecore_name].push(bagnumber); - } - var ret = ""; - var last = ""; - const pretty_bag_numbers = reference_list_summary - .simplify_integer_numbers; - for (const icecore_name of - Object.keys(icecores)) { - if (icecore_name === "none") { - last = - `<div class="casodb-f-resolve-reference-summary-plain">Bags without IceCore: ${pretty_bag_numbers(icecores[icecore_name])}</div>`; - } else { - ret += - `<div class="caosdb-f-resolve-reference-summary-plain">IceCore: ${icecore_name} (Bags: ${pretty_bag_numbers(icecores[icecore_name])})</div>`; - } - } - return ret.length + last.length > 0 ? - '<b>Summary</b>' + ret + last : ""; - } - - - this.summarize_box_content = function (ref_infos) { - logger.trace("enter summarize_box_content ", ref_infos); - return awi_references.summarize_subsamples(ref_infos); - } -} - /** * @module reference_list_summary * @version 0.1 @@ -328,6 +190,10 @@ var resolve_references = new function () { var _scroll_timeout = undefined; + // bind global function to local context for unit testing + this.retrieve = retrieve; + this.getParents = getParents; + /** * This event is dispatched on the summary container after the summary has * been generated and appended to the container. @@ -430,18 +296,15 @@ var resolve_references = new function () { * {string} par - parent name. @return {boolean} */ this.is_child = function (entity, par) { - var pars = getParents(entity); - for (const par of pars) { - if (par.name === par) { + var pars = resolve_references.getParents(entity); + for (const thispar of pars) { + if (thispar.name === par) { return true; } } return false; } - - this.retrieve = retrieve; - /** * @typedef {reference_info} * @property {string} text @@ -459,10 +322,19 @@ var resolve_references = new function () { * the entity which is to be resolved. @return {reference_info} */ this.resolve_reference = async function (id) { + const custom_reference_resolver = window["${BUILD_EXT_REFERENCES_CUSTOM_RESOLVER}"]; + if (custom_reference_resolver && typeof custom_reference_resolver.resolve === "function") { + // try custom_reference_resolver and fall-back to standard implementation + var ret = await custom_reference_resolver.resolve(id); + if (ret) { + return ret; + } + } + const entity = (await resolve_references.retrieve(id))[0]; // TODO handle multiple parents - const par = getParents(entity)[0] || {}; + const par = resolve_references.getParents(entity)[0] || {}; var ret = { "text": id @@ -475,25 +347,6 @@ var resolve_references = new function () { ret["text"] = pths[pths.length - 1]; } else if (par.name === "Person") { ret["text"] = this.get_person_str(entity); - } else if (par.name === "ExperimentSeries") { - ret["text"] = - getEntityName(entity); - } else if (par.name === "BoxType") { - ret["text"] = getEntityName(entity); - } else if (par.name === "Loan") { - var borrower = await this.retrieve(getProperty(entity, "Borrower")); - var loan_state = awi_demo.get_loan_state_string(getProperties(entity)); - ret["text"] = "Borrowed by " + this.get_person_str(borrower[0]) + " (" + loan_state.replace("_", " ") + ")"; - } else if (par.name === "SubSample" || par.name === "BagMean" || awi_references.isStripe(par)) { - ret = await awi_references.get_bag_and_icecore(entity); - ret["callback"] = awi_references.summarize_subsamples; - } else if (par.name === "Bag") { - ret = await awi_references.get_icecore(entity); - ret["callback"] = awi_references.summarize_box_content; - } else if (par.name === "Box") { - ret["text"] = getProperty(entity, "Number"); - } else if (par.name === "Palette") { - ret["text"] = getProperty(entity, "Number"); } else if (par.name === "TestReferenced" && typeof resolve_references.test_resolver === "function") { // this is a test case, initialized by the test suite. ret = resolve_references.test_resolver(entity); @@ -539,8 +392,8 @@ var resolve_references = new function () { const target = resolve_references.add_target(rs); const id = getEntityID(rs); target.textContent = id; - const resolved_entity_info = await resolve_references - .resolve_reference(id); + const resolved_entity_info = ( + await resolve_references.resolve_reference(id)); target.textContent = resolved_entity_info.text; return resolved_entity_info; } @@ -597,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); @@ -672,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..d80bf4efa539f483811ca1d3b7f85b817489a572 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,64 @@ 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"," ").replaceAll("\r"," ").replaceAll("\x1E"," ").replaceAll("\x15"," ") + } + + /** + * 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'); + return table.find("th").toArray() + .map(e => caosdb_table_export._clean_cell(e.textContent)) + .filter(e => e.length > 0 && e.toLowerCase() != "id" && e.toLowerCase() != "version"); + } + + /** + * 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'); - const columns = table.find("th").toArray() - .map(e => e.textContent) - .filter(e => e.length > 0); // 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. + * + * TODO merge with caosdb_utils.create_tsv_table. * * @param {HTMLElement[]} entities - entities which are converted to rows * of the tsv string. @@ -83,36 +122,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\tVersion\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), getEntityVersion(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 +246,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 +265,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 +280,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 +309,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 +327,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/footer.js b/src/core/js/footer.js index 79f3ed0ecb382534d7c997ce0bc230178792f677..c48c5cf19c405aea326b36aba2868008466a8329 100644 --- a/src/core/js/footer.js +++ b/src/core/js/footer.js @@ -26,7 +26,7 @@ * Call initially. * * TODO refactor to async function for better readability. - * @return + * @return something */ function footer_initOnDocumentReady() { diff --git a/src/core/js/form_elements.js b/src/core/js/form_elements.js index 0014f4db452a55839edaa860d7fa49f98529434e..8fe0af4ba633aaf257c48dd3e40ee22f4a8fc3c5 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. * @@ -26,8 +25,6 @@ * form_elements module for reusable form elemenst which already have a basic * css styling. * - * @version 0.2 - * * IMPORTANCE CONCEPTS * * FIELD - an HTMLElement which wraps a LABEL element (the fields name) and the @@ -49,31 +46,62 @@ * SUBFORM - an HTMLElement which contains FIELDS and other SUBFORMS. SUBFORMS * can be used to nest FIELDS, which is not supported by HTML5 but allows only * for flat key-value pairs. - */ - -/** - * The configuration for double, integer, date input elements. - * - * @typedef {object} input_config - * @property {string} name - * @property {string} type - * @property {string} label - */ - -/** - * The configuration for reference_select input fields - * - * TODO * + * @version 0.2 + * @exports form_elements */ var form_elements = new function () { + /** + * Config for an alert + * + * @typedef {object} 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. + */ + + + /** + * The configuration for double, integer, date input elements. + * + * There are several specializations of this configuration object. + * {@link ReferenceDropDownConfig}, {@link RangeFieldConfig}, {@link SelectFieldConfig}, {@link FileFieldConfig} + * + * @typedef {object} FieldConfig + * + * @property {string} name + * @property {string} type + * @property {string} [label] + * @property {string} [help] + * @property {boolean} [required=false] + * @property {boolean} [cached=false] + */ this.version = "0.1"; this.dependencies = ["log", "caosdb_utils", "markdown"]; this.logger = log.getLogger("form_elements"); this.cancel_form_event = new Event("caosdb.form.cancel"); this.submit_form_event = new Event("caosdb.form.submit"); - this.form_ready_event = new Event("caosdb.form.ready"); this.field_changed_event = new Event("caosdb.field.changed"); this.field_enabled_event = new Event("caosdb.field.enabled"); this.field_disabled_event = new Event("caosdb.field.disabled"); @@ -164,37 +192,132 @@ 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; + } + /** - * (Re-)set this module's functions to standard implementation. + * 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._init_functions = function () { + 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; + } + } - this.init = function () { - this.logger.trace("enter init"); + // 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); } - /** - * Return an OPTION element with entity reference. - * - * The OPTION element for a SELECT form input shows a short - * summary/description of an entity and has the entity's id as value. - * - * If the `desc` parameter is undefined, the entity_id is shown - * instead. - * - * @param {string} entity_id - the entity's id. - * @param {string} [desc] - the description for the entity. - * @returns {HTMLElement} OPTION element. - */ - this.make_reference_option = function (entity_id, desc) { - caosdb_utils.assert_string(entity_id, "param `entity_id`"); - if (typeof desc == "undefined") { - desc = entity_id; + + // 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"); } - var opt_str = '<option value="' + entity_id + '">' + desc + - "</option>"; - return $(opt_str)[0]; + $(_alert).alert('close'); + config.proceed_callback(); + }); + + return _alert[0]; + } + + + this.init = function () { + this.logger.trace("enter init"); + } + + /** + * Return an OPTION element with entity reference. + * + * The OPTION element for a SELECT form input shows a short + * summary/description of an entity and has the entity's id as value. + * + * If the `desc` parameter is undefined, the entity_id is shown + * instead. + * + * @param {string} entity_id - the entity's id. + * @param {string} [desc] - the description for the entity. + * @returns {HTMLElement} OPTION element. + */ + this.make_reference_option = function (entity_id, desc) { + caosdb_utils.assert_string(entity_id, "param `entity_id`"); + if (typeof desc == "undefined") { + desc = entity_id; } + return form_elements._make_option(entity_id, desc); + } + + /** + * Return an `option` element for a `select`. + * + * @param {string} value - the actual value of the option element. + * @param {string} label - the string which is shown for this option in the + * drop-down menu of the select input. + * @return {HTMLElement} + */ + this._make_option = function (value, label) { + const opt_str = '<option value="' + value + '">' + label + + "</option>"; + return $(opt_str)[0]; + } + + /** + * (Re-)set this module's functions to standard implementation. + */ + this._init_functions = function () { /** * Return SELECT form element with entity references. @@ -212,10 +335,11 @@ var form_elements = new function () { * parameter which is an entity in HTML representation. * @param {boolean} [multiple] - whether the select allows multiple * options to be selected. + * @param {string} name - the name of the select element * @returns {HTMLElement} SELECT element with entity options. */ this.make_reference_select = async function (entities, make_desc, - make_value, multiple) { + make_value, name, multiple) { caosdb_utils.assert_array(entities, "param `entities`", false); if (typeof make_desc !== "undefined") { caosdb_utils.assert_type(make_desc, "function", @@ -225,12 +349,7 @@ var form_elements = new function () { caosdb_utils.assert_type(make_value, "function", "param `make_value`"); } - const ret = $('<select class="selectpicker form-control" title="Nothing selected"/>'); - if (multiple) { - ret.attr("multiple", ""); - } else { - ret.append('<option style="display: none" selected="selected" value="" disabled="disabled"></option>'); - } + const ret = $(form_elements._make_select(multiple, name)); for (let entity of entities) { this.logger.trace("add option", entity); let entity_id = getEntityID(entity); @@ -244,8 +363,30 @@ var form_elements = new function () { } /** - * @typedef {option} ReferenceDropDownConfig + * Return a new select element. * + * This function is mainly used by other factory functions, e.g. {@link + * make_reference_select} and {@link make_select_input}. + * + * @param {boolean} multiple - the `multiple` attribute of the select element. + * @param {string} name - the name of the select element. + * @return {HTMLElement} + */ + this._make_select = function (multiple, name) { + const ret = $(`<select class="selectpicker form-control" name="${name}" title="Nothing selected"/>`); + if (typeof name !== "undefined") { + caosdb_utils.assert_string(name, "param `name`"); + ret.attr("name", name); + } + if (multiple) { + ret.attr("multiple", ""); + } else { + ret.append('<option style="display: none" selected="selected" value="" disabled="disabled"></option>'); + } + return ret[0]; + } + + /** * Configuration object for a drop down menu for selecting references. * `make_reference_drop_down` generates such a drop down menu using a * SELECT input with the references as its OPTION elements. @@ -263,6 +404,9 @@ var form_elements = new function () { * defined by `label`. If the `label` property is undefined, the `name` * is shown instead. * + * @typedef {option} ReferenceDropDownConfig + * + * @augments FieldConfig * @property {string} name - The name of the select input. * @property {string} query - Query for entities. * @property {function} [make_value] - Call-back for the generation of @@ -276,129 +420,7 @@ var form_elements = new function () { * @property {string} [type] - This should be "reference_drop_down" or * undefined. This property is used by `make_form_field` to decide * which type of field is to be generated. - * - */ - - /** - * Search and retrieve entities and create a SELECT from element. - * - * @param {ReferenceDropDownConfig} config - all necessary parameters - * for the configuration. - * @returns {HTMLElement} SELECT element. - */ - this.make_reference_drop_down = function (config) { - let ret = $(this._make_field_wrapper(config.name)); - let label = this._make_input_label_str(config); - let loading = $('<div class="caosdb-f-field-not-ready">loading...</div>'); - let input_col = $('<div class="col-sm-9"/>'); - - input_col.append(loading); - this._query(config.query).then(async function (entities) { - let select = $(await form_elements.make_reference_select( - entities, config.make_desc, config.make_value, config.multiple, - config.value)); - select.attr("name", config.name); - loading.remove(); - input_col.append(select); - form_elements.init_select_picker(ret[0], config.value); - ret[0].dispatchEvent(form_elements.field_ready_event); - select.change(function () { - ret[0].dispatchEvent(form_elements.field_changed_event); - }); - }).catch(err => { - form_elements.logger.error(err); - loading.remove(); - input_col.append(err); - ret[0].dispatchEvent(form_elements.field_error_event); - }); - - return ret.append(label, input_col)[0]; - } - - - this.init_select_picker = function (field, value) { - caosdb_utils.assert_html_element(field, "parameter `field`"); - const select = $(field).find("select")[0]; - const select_picker_options = {}; - if ($(select).prop("multiple")) { - select_picker_options["actionsBox"] = true; - } - if ($(select).find("option").length > 8) { - select_picker_options["liveSearch"] = true; - select_picker_options["liveSearchNormalize"] = true; - select_picker_options["liveSearchPlaceholder"] = "search..."; - } - $(select).selectpicker(select_picker_options); - $(select).selectpicker("val", value); - this.init_actions_box(field); - } - - - this.init_actions_box = function (field) { - this.logger.trace("enter init_actions_box", field); - caosdb_utils.assert_html_element(field, "parameter `field`"); - const select = $(field).find("select"); - var actions_box = select.siblings().find(".bs-actionsbox"); - if (actions_box.length === 0) { - actions_box = $(`<div class="bs-actionsbox"> - <div class="btn-group btn-group-sm btn-block"> - <button type="button" class="actions-btn btn-default bs-deselect-all btn btn-light">None</button> - </div> - </div>`) - .hide(); - - select - .siblings(".dropdown-menu") - .prepend(actions_box); - - field.addEventListener( - form_elements.field_changed_event.type, - (e) => { - if (form_elements.is_set(field)) { - actions_box.show(); - } else { - actions_box.hide(); - } - }, true); - - actions_box - .find(".bs-deselect-all") - .click((e) => { - select.val(null) - .selectpicker("render") - .parent().toggleClass("open", false); - select[0].dispatchEvent(form_elements.field_changed_event); - }); - } - } - - /** - * Return a promise which resolves with the field when the field is ready. - * - * This function is especially useful if the caller can not be sure if - * the field_ready_event has been dispatched already and the field is - * ready or if the fields creation is still pending. - * - * @param {HTMLElement} field - * @return {Promise} the field-ready promise */ - this.field_ready = function (field) { - // TODO add support for field name (string) as field parameter - // TODO check type of param field (not an array!) - caosdb_utils.assert_html_element(field, "parameter `field`"); - return new Promise(function (resolve, reject) { - try { - if (!$(field).hasClass("caosdb-f-field-not-ready") && $(field).find(".caosdb-f-field-not-ready").length === 0) { - resolve(field); - } else { - field.addEventListener(form_elements.field_ready_event.type, - (e) => resolve(e.target), true); - } - } catch (err) { - reject(err); - } - }); - } this._query = async function (q) { const result = await query(q); @@ -406,8 +428,23 @@ var form_elements = new function () { return result; } + /** + * Call a server-side script with the content of the given form and + * return the results. + * + * Note that the form should be one generated by this form_elements + * module. Otherwise it cannot be guaranteed that the form will be + * serialized (to json) correctly. + * + * @param {string} script - the path of the script + * @param {HTMLElements} form - a form generated by this module. + * @return {ScriptingResult} the results of the call. + */ this._run_script = async function (script, form) { - const json_str = JSON.stringify(form_elements.form_to_object(form[0])); + const form_objects = form_elements.form_to_object(form[0]); + const json_str = JSON.stringify(form_objects[0]); + + // append non-file form fields to the request const params = { "-p0": { "filename": "form.json", @@ -416,807 +453,1149 @@ var form_elements = new function () { }) } }; + + // append files to the request + const files = form_objects[1]; + for (let i = 0; i < files.length; i++) { + params[`file_${i}`] = { + "filename": `${files[i]["fieldname"]}_${files[i]["filename"]}`, + "blob": files[i]["blob"] + }; + } + const result = await connection.runScript(script, params); this.logger.debug("server-side script returned", result); return this.parse_script_result(result); } - this.parse_script_result = function (result) { - console.log(result); - const scriptNode = result.evaluate("/Response/script", result, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; - const code = result.evaluate("@code", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + } - const call = result.evaluate("call", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; - const stderr = result.evaluate("stderr", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; - const stdout = result.evaluate("stdout", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + /** + * @typedef {object} ScriptingResult + * @property {string} code + * @property {string} call + * @property {string} stdout + * @property {string} stderr + */ - return { - "code": code, - "call": call, - "stdout": stdout, - "stderr": stderr - }; + /** + * Convert the reponse of a server-side scripting call into a {@link + * ScriptingResult} object. + * + * @param {XMLDocument} result + * @return {ScriptingResult} + */ + this.parse_script_result = function (result) { + console.log(result); + const scriptNode = result.evaluate("/Response/script", result, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + const code = result.evaluate("@code", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + + const call = result.evaluate("call", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + const stderr = result.evaluate("stderr", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + const stdout = result.evaluate("stdout", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + + const ret = { + "code": code, + "call": call, + "stdout": stdout, + "stderr": stderr + }; + + return ret; + } + + /** + * Search and retrieve entities and create a SELECT from element. + * + * @param {ReferenceDropDownConfig} config - all necessary parameters + * for the configuration. + * @returns {HTMLElement} SELECT element. + */ + this.make_reference_drop_down = function (config) { + let ret = $(this._make_field_wrapper(config.name)); + let label = this._make_input_label_str(config); + let loading = $(createWaitingNotification("loading...")) + .addClass("caosdb-f-field-not-ready"); + let input_col = $('<div class="col-sm-9"/>'); + + input_col.append(loading); + this._query(config.query).then(async function (entities) { + let select = $(await form_elements.make_reference_select( + entities, config.make_desc, config.make_value, config.name, config.multiple, + config.value)); + loading.remove(); + input_col.append(select); + form_elements.init_select_picker(ret[0], config.value); + ret[0].dispatchEvent(form_elements.field_ready_event); + select.change(function () { + ret[0].dispatchEvent(form_elements.field_changed_event); + }); + }).catch(err => { + form_elements.logger.error(err); + loading.remove(); + input_col.append(err); + ret[0].dispatchEvent(form_elements.field_error_event); + }); + + return ret.append(label, input_col)[0]; + } + + + /** + * Test 16 + */ + this.init_select_picker = function (field, value) { + caosdb_utils.assert_html_element(field, "parameter `field`"); + const select = $(field).find("select")[0]; + const select_picker_options = {}; + if ($(select).prop("multiple")) { + select_picker_options["actionsBox"] = true; } + if ($(select).find("option").length > 8) { + select_picker_options["liveSearch"] = true; + select_picker_options["liveSearchNormalize"] = true; + select_picker_options["liveSearchPlaceholder"] = "search..."; + } + $(select).selectpicker(select_picker_options); + $(select).selectpicker("val", value); + this.init_actions_box(field); + } - /** - * generate a java script object representation of a form - */ - this.form_to_object = function (form) { - this.logger.trace("entity form_to_json", form); - caosdb_utils.assert_html_element(form, "parameter `form`"); - const _to_json = (element, data) => { - this.logger.trace("enter element_to_json", element, data); + /** + * Test 17 + */ + this.init_actions_box = function (field) { + this.logger.trace("enter init_actions_box", field); + caosdb_utils.assert_html_element(field, "parameter `field`"); + const select = $(field).find("select"); + var actions_box = select.siblings().find(".bs-actionsbox"); + if (actions_box.length === 0) { + actions_box = $(`<div class="bs-actionsbox"> + <div class="btn-group btn-group-sm btn-block"> + <button type="button" class="actions-btn btn-default bs-deselect-all btn btn-light">None</button> + </div> + </div>`) + .hide(); + + select + .siblings(".dropdown-menu") + .prepend(actions_box); + + field.addEventListener( + form_elements.field_changed_event.type, + (e) => { + if (form_elements.is_set(field)) { + actions_box.show(); + } else { + actions_box.hide(); + } + }, true); + + actions_box + .find(".bs-deselect-all") + .click((e) => { + select.val(null) + .selectpicker("render") + .parent().toggleClass("open", false); + select[0].dispatchEvent(form_elements.field_changed_event); + }); + } + } + + /** + * Return a promise which resolves with the field when the field is ready. + * + * This function is especially useful if the caller can not be sure if + * the field_ready_event has been dispatched already and the field is + * ready or if the fields creation is still pending. + * + * @param {HTMLElement} field + * @return {Promise} the field-ready promise + */ + this.field_ready = function (field) { + // TODO add support for field name (string) as field parameter + // TODO check type of param field (not an array!) + caosdb_utils.assert_html_element(field, "parameter `field`"); + return new Promise(function (resolve, reject) { + try { + if (!$(field).hasClass("caosdb-f-field-not-ready") && $(field).find(".caosdb-f-field-not-ready").length === 0) { + resolve(field); + } else { + field.addEventListener(form_elements.field_ready_event.type, + (e) => resolve(e.target), true); + } + } catch (err) { + reject(err); + } + }); + } + + /** + * Generate a java script object representation of a form and extract the + * files from the form. + * + * The property names (aka keys) are the names of the form fields and + * subforms. The values are single strings or arrays of strings. If the + * field was had a file-type input, the value is a string identifying the + * file blob which belongs to this key. + * + * Subforms lead to nested objects of the same structure. + * + * @param {HTMLElement} form - a form generated by this module. + * @return {object[]} - an array of length 2. The first element is an + * object representing the fields of the form. The second contains a + * list of file blobs resulting from file inputs in the form. + */ + this.form_to_object = function (form) { + this.logger.trace("entity form_to_json", form); + caosdb_utils.assert_html_element(form, "parameter `form`"); + + const _to_json = (element, data, files) => { + this.logger.trace("enter element_to_json", element, data, files); - for (const child of element.children) { - // ignore disabled fields and subforms - if ($(child).hasClass("caosdb-f-field-disabled")) { - continue; + for (const child of element.children) { + // ignore disabled fields and subforms + if ($(child).hasClass("caosdb-f-field-disabled")) { + continue; + } + const name = $(child).attr("name"); + const is_subform = $(child).hasClass("caosdb-f-form-elements-subform"); + if (is_subform) { + const subform = $(child).data("subform-name"); + // recursive + var subform_obj = _to_json(child, {}, files)[0]; + if (typeof data[subform] === "undefined") { + data[subform] = subform_obj; + } else if (Array.isArray(data[subform])) { + data[subform].push(subform_obj); + } else { + data[subform] = [data[subform], subform_obj] } - const name = $(child).attr("name"); - const is_subform = $(child).hasClass("caosdb-f-form-elements-subform"); - if (is_subform) { - const subform = $(child).data("subform-name"); - // recursive - var subform_obj = _to_json(child, {}); - if (typeof data[subform] === "undefined") { - data[subform] = subform_obj; - } else if (Array.isArray(data[subform])) { - data[subform].push(subform_obj); - } else { - data[subform] = [data[subform], subform_obj] - } - } else if (name && name !== "") { - // input elements - const not_checkbox = !$(child).is(":checkbox"); - if (not_checkbox || $(child).is(":checked")) { - // checked or not a checkbox - var value = $(child).val(); - if (typeof data[name] === "undefined") { - data[name] = value; - } else if (Array.isArray(data[name])) { - data[name].push(value); - } else { - data[name] = [data[name], value] + } else if (name && name !== "") { + // input elements + const not_checkbox = !$(child).is(":checkbox"); + const is_file = $(child).is("input:file"); + if (is_file) { + var fileList = child.files; + if (fileList.length > 0) { + for (let i = 0; i < fileList.length; i++) { + // generate an identifyer for the file(s) of this input + value = name + "_" + fileList[i].name; + if (typeof data[name] === "undefined") { + // first and possibly only value + data[name] = value + } else if (Array.isArray(data[name])) { + data[name].push(value); + } else { + // there is a value present yet - convert to array. + data[name] = [data[name], value] + } + files.push({ + "fieldname": name, + "filename": fileList[i].name, + "blob": fileList[i] + }); } + } + } else if (not_checkbox || $(child).is(":checked")) { + // checked or not a checkbox + var value = $(child).val(); + if (typeof data[name] === "undefined") { + data[name] = value; + } else if (Array.isArray(data[name])) { + data[name].push(value); } else { - // TODO checkbox + data[name] = [data[name], value] } - } else if (child.children.length > 0) { - // recursive - _to_json(child, data); + } else { + // TODO checkbox } + } else if (child.children.length > 0) { + // recursive + _to_json(child, data, files); } + } - this.logger.trace("leave element_to_json", element, data); - return data; - }; + this.logger.trace("leave element_to_json", element, data, files); + return [data, files]; + }; - const ret = _to_json(form, {}); - this.logger.trace("leave form_to_json", ret); - return ret; - } - - this.make_submit_button = function () { - var ret = $('<button class="caosdb-f-form-elements-submit-button btn btn-primary" type="submit">Submit</button>'); - return ret[0]; - } + const ret = _to_json(form, {}, []); + this.logger.trace("leave form_to_json", ret); + return ret; + } - this.make_cancel_button = function (form) { - var ret = $('<button class="caosdb-f-form-elements-cancel-button btn btn-primary" type="button">Cancel</button>'); - ret.on("click", e => { - this.logger.debug("cancel form", e, form); - form.dispatchEvent(this.cancel_form_event); - }); - return ret[0]; - } + this.make_submit_button = function () { + var ret = $('<button class="caosdb-f-form-elements-submit-button btn btn-primary" type="submit">Submit</button>'); + return ret[0]; + } - /** - * TODO make syncronous - */ - this.make_form_field = async function (config) { - caosdb_utils.assert_type(config, "object", "param `config`"); - caosdb_utils.assert_string(config.type, "`config.type` of param `config`"); - - var field = undefined; - const type = config.type; - if (type === "date") { - field = this.make_date_input(config); - } else if (type === "checkbox") { - field = this.make_checkbox_input(config); - } else if (type === "text") { - field = this.make_text_input(config); - } else if (type === "double") { - field = this.make_double_input(config); - } else if (type === "integer") { - field = this.make_integer_input(config); - } else if (type === "range") { - field = await this.make_range_input(config); - } else if (type === "reference_drop_down") { - field = this.make_reference_drop_down(config); - } else if (type === "subform") { - // TODO handle cache and required for subforms - return await this.make_subform(config); - } else { - throw new TypeError("undefined field type `" + type + "`"); - } + this.make_cancel_button = function (form) { + var ret = $('<button class="caosdb-f-form-elements-cancel-button btn btn-primary" type="button">Cancel</button>'); + ret.on("click", e => { + this.logger.debug("cancel form", e, form); + form.dispatchEvent(this.cancel_form_event); + }); + return ret[0]; + } - if (config.required) { - this.set_required(field); - } - if (config.cached) { - this.set_cached(field); - } - if (config.help) { - this.add_help(field, config.help); - } + /** + * Return a new form field (or a subform). + * + * This function is intended to be called by make_form and recursively by + * other make_* functions which create subforms or other deeper structured + * form fields. + * + * This function also configures the caching, whether a form field is + * 'required' or not, and the help for each field. + * + * @param {FieldConfig} config - the configuration of the form field + * @return {HTMLElement} + */ + this.make_form_field = function (config) { + caosdb_utils.assert_type(config, "object", "param `config`"); + caosdb_utils.assert_string(config.type, "`config.type` of param `config`"); + + var field = undefined; + const type = config.type; + if (type === "date") { + field = this.make_date_input(config); + } else if (type === "file") { + field = this.make_file_input(config); + } else if (type === "checkbox") { + field = this.make_checkbox_input(config); + } else if (type === "text") { + field = this.make_text_input(config); + } else if (type === "double") { + field = this.make_double_input(config); + } else if (type === "integer") { + field = this.make_integer_input(config); + } else if (type === "range") { + field = this.make_range_input(config); + } else if (type === "reference_drop_down") { + field = this.make_reference_drop_down(config); + } else if (type === "select") { + field = this.make_select_input(config); + } else if (type === "subform") { + return this.make_subform(config); + } else { + throw new TypeError("undefined field type `" + type + "`"); + } - return field; + if (config.required) { + this.set_required(field); + } + if (config.cached) { + this.set_cached(field); + } + if (config.help) { + this.add_help(field, config.help); } + return field; + } - this.add_help = function (field, config) { - var help_button = $('<span data-trigger="click focus" data-toggle="popover" class="caosdb-f-form-help pull-right glyphicon glyphicon-info-sign"/>') - .css({ - "cursor": "pointer" - }); - if (typeof config === "string" || config instanceof String) { - help_button.attr("data-content", config); - help_button.popover(); - } else { - help_button.popover(config); - } + this.add_help = function (field, config) { + var help_button = $('<span data-trigger="click focus" data-toggle="popover" class="caosdb-f-form-help pull-right glyphicon glyphicon-info-sign"/>') + .css({ + "cursor": "pointer" + }); + if (typeof config === "string" || config instanceof String) { + help_button.attr("data-content", config); + help_button.popover(); + } else { + help_button.popover(config); + } - var label = $(field).children("label"); - if (label.length > 0) { - help_button.css({ - "margin-left": "4px" - }); - label.first().append(help_button); - } else { - $(field).append(help_button); - } + + var label = $(field).children("label"); + if (label.length > 0) { + help_button.css({ + "margin-left": "4px" + }); + label.first().append(help_button); + } else { + $(field).append(help_button); } + } - this.make_heading = function (config) { - if (typeof config.header === "undefined") { - return; - } else if (typeof config.header === "string" || config.header instanceof String) { - return $('<div class="caosdb-f-form-header h3">' + config.header + '</div>')[0]; - } - caosdb_utils.assert_html_element(config.header, "member `header` of parameter `config`"); - return config.header; + this.make_heading = function (config) { + if (typeof config.header === "undefined") { + return; + } else if (typeof config.header === "string" || config.header instanceof String) { + return $('<div class="caosdb-f-form-header h3">' + config.header + '</div>')[0]; } + caosdb_utils.assert_html_element(config.header, "member `header` of parameter `config`"); + return config.header; + } - this.make_form_wrapper = function (form, config) { - var wrapper = $('<div class="caosdb-f-form-wrapper"/>'); + this.make_form_wrapper = function (form, config) { + var wrapper = $('<div class="caosdb-f-form-wrapper"/>'); - var header = this.make_heading(config); - wrapper.append(header); - var loading = $('<div>loading...</div>'); - var logger = this.logger; - var cancel = (e) => { - logger.trace("cancel form", e); - wrapper.remove(); - }; + var cancel = (e) => { + form_elements.logger.trace("cancel form", e); + wrapper.remove(); + }; - wrapper.append(loading); + wrapper[0].addEventListener(this.cancel_form_event.type, cancel, true); - Promise.resolve(form).then(form => { - // form ready - loading.remove(); - wrapper.append(form); - wrapper[0].dispatchEvent(this.form_ready_event); - }).catch(err => { - logger.error("form loading error", err); - loading.remove(); - wrapper.append(err); - }); + var header = this.make_heading(config); + wrapper.append(header); + wrapper.append(form); - wrapper[0].addEventListener(this.cancel_form_event.type, cancel, true); + return wrapper[0]; + } - return wrapper[0]; - } + /** + * Configuration objects which are passed to {@link make_form}. + * + * Note: either the `script` or the `name` property must be defined. If the former is defined, the latter will be overriden. + * + * @typedef {object} FormConfig + * + * @property {FieldConfig[]} fields - array of fields. The order is the + * order in which they appear in the resulting form. + * @property {string} [script] - if present the form will call a + * server-side script on submission. + * @property {string} [name] - The name of the form. This is being + * overridden by the `script` parameter if present. + * @property {function} [submit] - a callback which handles the submission + * of the form. This parameter is being overridden if the `script` + * parameter is present. + */ - this.make_form = function (config) { - var form = undefined; + /** + * Create a form. + * + * The returned element is a container which contains a HTML form element. + * The fields are ready or they will emit a {@link field_ready_event} when + * they are. + * + * @param {FormConfig} config + * @return {HTMLElement} + */ + this.make_form = function (config) { + var form = undefined; - if (config.script) { - form = this.make_script_form(config, config.script); - } else { - form = this.make_generic_form(config); - } - var wrapper = this.make_form_wrapper(form, config); - return wrapper; + if (config.script) { + form = this.make_script_form(config, config.script); + } else { + form = this.make_generic_form(config); } + var wrapper = this.make_form_wrapper(form, config); + return wrapper; + } - /** - * TODO make syncronous - */ - this.make_subform = async function (config) { - this.logger.trace("enter make_subform"); - caosdb_utils.assert_type(config, "object", "param `config`"); - caosdb_utils.assert_string(config.name, "`config.name` of param `config`"); - caosdb_utils.assert_array(config.fields, "`config.fields` of param `config`"); - - const name = config.name; - var form = $('<div data-subform-name="' + name + '" class="caosdb-f-form-elements-subform"/>'); - - for (let field of config.fields) { - this.logger.trace("add subform field", field); - let elem = await this.make_form_field(field); - form.append(elem); - } + /** + * @typedef {object} SubFormConfig + * + * @augments FieldConfig + * @property {FieldConfig[]} fields - array of fields. The order is the + * order in which they appear in the resulting subform. + */ - this.logger.trace("leave make_subform", form[0]); - return form[0]; + /** + * Return a new subform. + * + * @param {SubFormConfig} config - the configuration of the subform. + * @return {HTMLElement} + */ + this.make_subform = function (config) { + this.logger.trace("enter make_subform"); + caosdb_utils.assert_type(config, "object", "param `config`"); + caosdb_utils.assert_string(config.name, "`config.name` of param `config`"); + caosdb_utils.assert_array(config.fields, "`config.fields` of param `config`"); + + const name = config.name; + var form = $('<div data-subform-name="' + name + '" class="caosdb-f-form-elements-subform"/>'); + + for (let field of config.fields) { + this.logger.trace("add subform field", field); + let elem = this.make_form_field(field); + form.append(elem); } - this.dismiss_form = function (form) { - if (form.tagName === "FORM") { - form.dispatchEvent(this.cancel_form_event); - } - var _form = $(form).find("form"); - if (_form.length > 0) { - _form[0].dispatchEvent(this.cancel_form_event); - } - } + this.logger.trace("leave make_subform", form[0]); + return form[0]; + } - this.enable_group = function (form, group) { - this.enable_fields(this.get_group_fields(form, group)); + this.dismiss_form = function (form) { + if (form.tagName === "FORM") { + form.dispatchEvent(this.cancel_form_event); } - - this.disable_group = function (form, group) { - this.disable_fields(this.get_group_fields(form, group)); + var _form = $(form).find("form"); + if (_form.length > 0) { + _form[0].dispatchEvent(this.cancel_form_event); } + } - this.get_group_fields = function (form, group) { - return $(form).find(".caosdb-f-field[data-groups*='(" + group + ")']").toArray(); - } + this.enable_group = function (form, group) { + this.enable_fields(this.get_group_fields(form, group)); + } - /** - * Return an array of field with name - * - * @param {string} name - the field name - * @return {HTMLElement[]} array of fields - */ - this.get_fields = function (form, name) { - caosdb_utils.assert_html_element(form, "parameter `form`"); - caosdb_utils.assert_string(name, "parameter `name`"); - return $(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray(); - } + this.disable_group = function (form, group) { + this.disable_fields(this.get_group_fields(form, group)); + } - this.add_field_to_group = function (field, group) { - this.logger.trace("enter add_field_to_group", field, group); - var groups = ($(field).attr("data-groups") ? $(field).attr("data-groups") : "") + "(" + group + ")"; - $(field).attr("data-groups", groups); - } + this.get_group_fields = function (form, group) { + return $(form).find(".caosdb-f-field[data-groups*='(" + group + ")']").toArray(); + } - this.disable_fields = function (fields) { - $(fields).toggleClass("caosdb-f-field-disabled", true).hide(); - for (const field of $(fields)) { - field.dispatchEvent(this.field_disabled_event); - } - } + /** + * Return an array of field with name + * + * @param {string} name - the field name + * @return {HTMLElement[]} array of fields + */ + this.get_fields = function (form, name) { + caosdb_utils.assert_html_element(form, "parameter `form`"); + caosdb_utils.assert_string(name, "parameter `name`"); + return $(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray(); + } - this.enable_fields = function (fields) { - $(fields).toggleClass("caosdb-f-field-disabled", false).show(); - for (const field of $(fields)) { - field.dispatchEvent(this.field_enabled_event); - } - } + this.add_field_to_group = function (field, group) { + this.logger.trace("enter add_field_to_group", field, group); + var groups = ($(field).attr("data-groups") ? $(field).attr("data-groups") : "") + "(" + group + ")"; + $(field).attr("data-groups", groups); + } - this.enable_name = function (form, name) { - this.enable_fields($(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray()); + this.disable_fields = function (fields) { + $(fields).toggleClass("caosdb-f-field-disabled", true).hide(); + $(fields).find(":input").prop("required", false); + for (const field of $(fields)) { + field.dispatchEvent(this.field_disabled_event); } + } - this.disable_name = function (form, name) { - this.disable_fields($(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray()); + this.enable_fields = function (fields) { + $(fields).toggleClass("caosdb-f-field-disabled", false).show(); + $(fields).filter(".caosdb-f-form-field-required").find("input.caosdb-f-property-single-raw-value, select.selectpicker").prop("required", true); + for (const field of $(fields)) { + field.dispatchEvent(this.field_enabled_event); } + } + + this.enable_name = function (form, name) { + this.enable_fields($(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray()); + } - this.make_script_form = async function (config, script) { - this.logger.trace("enter make_script_form"); + this.disable_name = function (form, name) { + this.disable_fields($(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray()); + } - const submit_callback = async function (form) { - form = $(form); + this.make_script_form = function (config, script) { + this.logger.trace("enter make_script_form"); + const submit_callback = async function (form) { + form = $(form); - // actually submit the form - var response = await form_elements._run_script(script, form); - var result = []; - if (response.code === "0") { - // handle success - result.push(form_elements.make_success_message(response.stdout)); - return result; + // actually submit the form + var response = await form_elements._run_script(script, form); + var result = []; - } else { - // handle scripting error - result.push(form_elements.make_error_message(response.call)); - result.push(form_elements.make_error_message(response.stderr)); - throw result; - } - }; + if (response.code === "0") { + // handle success + result.push(form_elements.make_success_message(response.stdout)); + return result; - this.logger.trace("leave make_script_form"); - const new_config = $.extend({}, { - name: script, - submit: submit_callback - }, config); - return await this.make_generic_form(new_config); - } + } else { + // handle scripting error + result.push(form_elements.make_error_message(response.call)); + result.push(form_elements.make_error_message(response.stderr)); + throw result; + } + }; + + this.logger.trace("leave make_script_form"); + const new_config = $.extend({}, { + name: script, + submit: submit_callback + }, config); + return this.make_generic_form(new_config); + } - /** - * Return a generic form, bind the config.submit to the submit event - * of the form. - * - * The `config.fields` array may contain `form_elements.field_config` - * objects or HTMLElements. - * - * TODO - */ - this.make_generic_form = async function (config) { - this.logger.trace("enter make_generic_form"); + /** + * Return a generic form, bind the config.submit to the submit event + * of the form. + * + * The `config.fields` array may contain `form_elements.field_config` + * objects or HTMLElements. + * + * @return {HTMLElement} + */ + this.make_generic_form = function (config) { + this.logger.trace("enter make_generic_form"); - caosdb_utils.assert_type(config, "object", "param `config`"); - caosdb_utils.assert_string(config.name, "`config.name` of param `config`", true); - caosdb_utils.assert_array(config.fields, "`config.fields` of param `config`"); + caosdb_utils.assert_type(config, "object", "param `config`"); + caosdb_utils.assert_string(config.name, "`config.name` of param `config`", true); + caosdb_utils.assert_array(config.fields, "`config.fields` of param `config`"); - const form = $('<form class="form-horizontal" action="#" method="post" />'); + const form = $('<form class="form-horizontal" action="#" method="post" />'); - // set name - if (config.name) { - form.attr("name", config.name); - } + // set name + if (config.name) { + form.attr("name", config.name); + } - // add fields - for (let field of config.fields) { - this.logger.trace("add field", field); - if (field instanceof HTMLElement) { - form.append(field); - } else { - let elem = await this.make_form_field(field); - form.append(elem); - } + // add fields + for (let field of config.fields) { + this.logger.trace("add field", field); + if (field instanceof HTMLElement) { + form.append(field); + } else { + let elem = this.make_form_field(field); + form.append(elem); } + } - // set groups - if (config.groups) { - for (let group of config.groups) { - this.logger.trace("add group", group); - for (let fieldname of group.fields) { - let field = form.find(".caosdb-f-field[data-field-name='" + fieldname + "']"); - this.logger.trace("set group", field, group); - this.add_field_to_group(field, group.name) + // set groups + if (config.groups) { + for (let group of config.groups) { + this.logger.trace("add group", group); + for (let fieldname of group.fields) { + let field = form.find(".caosdb-f-field[data-field-name='" + fieldname + "']"); + this.logger.trace("set group", field, group); + this.add_field_to_group(field, group.name) - } - // disable if necessary - if (typeof group.enabled === "undefined" || group.enabled) { - this.enable_group(form, group.name); - } else { - this.disable_group(form, group.name); - } + } + // disable if necessary + if (typeof group.enabled === "undefined" || group.enabled) { + this.enable_group(form, group.name); + } else { + this.disable_group(form, group.name); } } + } - const footer = this.make_footer(); - form.append(footer); + const footer = this.make_footer(); + form.append(footer); - if (!(typeof config.submit === 'boolean' && config.submit === false)) { - // add submit button unless config.submit is false - footer.append(this.make_submit_button()); + if (!(typeof config.submit === 'boolean' && config.submit === false)) { + // add submit button unless config.submit is false + footer.append(this.make_submit_button()); + } + form[0].addEventListener("submit", (e) => { + e.preventDefault(); + e.stopPropagation(); + if (form.find(".caosdb-f-form-submitting").length > 0) { + // do not submit twice + return; } - form[0].addEventListener("submit", (e) => { - e.preventDefault(); - e.stopPropagation(); - if (form.find(".caosdb-f-form-submitting").length > 0) { - // do not submit twice - return; - } - this.logger.debug("submit form", e); + this.logger.debug("submit form", e); - form[0].dispatchEvent(this.submit_form_event); + form[0].dispatchEvent(this.submit_form_event); - form.find(":input").prop("disabled", true); - var submitting = form_elements.make_submitting_info(); - form.find(".caosdb-f-form-elements-footer").before(submitting); + form.find(":input").prop("disabled", true); + var submitting = form_elements.make_submitting_info(); + form.find(".caosdb-f-form-elements-footer").before(submitting); - form[0].addEventListener(this.form_success_event.type, (e) => { - submitting.remove(); - }, true); - form[0].addEventListener(this.form_error_event.type, (e) => { - submitting.remove(); - }, true); + form[0].addEventListener(this.form_success_event.type, (e) => { + submitting.remove(); + }, true); + form[0].addEventListener(this.form_error_event.type, (e) => { + submitting.remove(); + }, true); - // remove old messages - const error_handler = config.error; - const success_handler = config.success; - const submit_callback = config.submit; - form.find(".caosdb-f-form-elements-message").remove(); - if (typeof config.submit === "function") { - // wrap callback in async function - const _wrap_callback = async function () { - try { - var results = await submit_callback(form[0]); - - // success_handler - if (typeof success_handler === "function") { - var processed = await success_handler(form[0], results); - if (typeof processed !== "undefined") { - form_elements.show_results(form[0], processed); - } - } else { - form_elements.show_results(form[0], results); + // remove old messages + const error_handler = config.error; + const success_handler = config.success; + const submit_callback = config.submit; + form.find(".caosdb-f-form-elements-message").remove(); + if (typeof config.submit === "function") { + // wrap callback in async function + const _wrap_callback = async function () { + try { + var results = await submit_callback(form[0]); + + // success_handler + if (typeof success_handler === "function") { + var processed = await success_handler(form[0], results); + if (typeof processed !== "undefined") { + form_elements.show_results(form[0], processed); } + } else { + form_elements.show_results(form[0], results); + } - form[0].dispatchEvent(form_elements.form_success_event); - } catch (err) { + form[0].dispatchEvent(form_elements.form_success_event); + } catch (err) { - // error_handler - if (typeof error_handler === "function") { - var processed = await error_handler(form[0], err); - if (typeof processed !== "undefined") { - form_elements.show_results(form[0], processed); - } - } else { - form_elements.show_errors(form[0], err); + // error_handler + if (typeof error_handler === "function") { + var processed = await error_handler(form[0], err); + if (typeof processed !== "undefined") { + form_elements.show_results(form[0], processed); } - - form[0].dispatchEvent(form_elements.form_error_event); + } else { + form_elements.show_errors(form[0], err); } - }(); - } - return false; + form[0].dispatchEvent(form_elements.form_error_event); + } + }(); + } + return false; - }, true); - form[0].addEventListener(this.form_success_event.type, function (e) { - // remove submit button, show ok button - form.find("button[type='submit']").remove(); - form.find("button:contains('Cancel')").text("Ok").prop("disabled", false); - }, true); - form[0].addEventListener(this.form_error_event.type, function (e) { - // reenable inputs - form.find(":input").prop("disabled", false); - }, true); + }, true); - // add cancel button - $(footer).append(this.make_cancel_button(form[0])); + form[0].addEventListener(this.form_success_event.type, function (e) { + // remove submit button, show ok button + form.find("button[type='submit']").remove(); + form.find("button:contains('Cancel')").text("Ok").prop("disabled", false); + }, true); + form[0].addEventListener(this.form_error_event.type, function (e) { + // reenable inputs + form.find(":input").prop("disabled", false); + }, true); - // init caching for this form - form_elements.init_form_caching(config, form[0]); + // add cancel button + $(footer).append(this.make_cancel_button(form[0])); - // init validation - form_elements.init_validator(form[0]); + // init caching for this form + form_elements.init_form_caching(config, form[0]); - this.logger.trace("leave make_generic_form"); - return form[0]; - } + // init validation + form_elements.init_validator(form[0]); - this.init_form_caching = function (config, form) { - var default_config = { - "cache_event": form_elements.submit_form_event.type, - "cache_storage": localStorage - }; - var lconfig = $.extend({}, default_config, config); + this.logger.trace("leave make_generic_form"); + return form[0]; + } - this.logger.trace("init_form_caching", lconfig, form); + this.init_form_caching = function (config, form) { + var default_config = { + "cache_event": form_elements.submit_form_event.type, + "cache_storage": localStorage + }; + var lconfig = $.extend({}, default_config, config); - form.addEventListener(lconfig.cache_event, (e) => { - form_elements.cache_form(lconfig.cache_storage, form); - }, true); - form_elements.load_cached(lconfig.cache_storage, form); - } + this.logger.trace("init_form_caching", lconfig, form); - this.show_results = function (form, results) { - $(form).append(results); - } + form.addEventListener(lconfig.cache_event, (e) => { + form_elements.cache_form(lconfig.cache_storage, form); + }, true); + form_elements.load_cached(lconfig.cache_storage, form); + } - this.show_errors = function (form, errors) { - $(form).append(errors); - } + this.show_results = function (form, results) { + $(form).append(results); + } - this.make_footer = function () { - return $('<div class="text-right caosdb-f-form-elements-footer"/>') - .css({ - "margin": "20px", - }).append(this.make_required_marker()) - .append('<span style="margin-right: 4px; font-size: 11px">required field</span>')[0]; - } + this.show_errors = function (form, errors) { + $(form).append(errors); + } - this.make_error_message = function (message) { - return this.make_message(message, "error"); - } + this.make_footer = function () { + return $('<div class="text-right caosdb-f-form-elements-footer"/>') + .css({ + "margin": "20px", + }).append(this.make_required_marker()) + .append('<span style="margin-right: 4px; font-size: 11px">required field</span>')[0]; + } - this.make_success_message = function (message) { - return this.make_message(message, "success"); - } + this.make_error_message = function (message) { + return this.make_message(message, "error"); + } + + this.make_success_message = function (message) { + return this.make_message(message, "success"); + } - this.make_submitting_info = function () { - // TODO styling - return $(this.make_message("Submitting... please wait. This might take some time.", "info")) - .toggleClass("h3", true) - .toggleClass("caosdb-f-form-submitting", true) - .toggleClass("text-right", true)[0]; + this.make_submitting_info = function () { + // TODO styling + return $(this.make_message("Submitting... please wait. This might take some time.", "info")) + .toggleClass("h3", true) + .toggleClass("caosdb-f-form-submitting", true) + .toggleClass("text-right", true)[0]; + } + + this.make_message = function (message, type) { + var ret = $('<div class="caosdb-f-form-elements-message"/>'); + if (type) { + ret.addClass("caosdb-f-form-elements-message-" + type); } + return ret.append(markdown.textToHtml(message))[0]; + } - this.make_message = function (message, type) { - var ret = $('<div class="caosdb-f-form-elements-message"/>'); - if (type) { - ret.addClass("caosdb-f-form-elements-message-" + type); - } - return ret.append(markdown.textToHtml(message))[0]; + /** + * @typedef {object} RangeFieldConfig + * + * @augments FieldConfig + * @property {FieldConfig} from - the start point of the range. This is + * usually an integer or double input field. + * @property {FieldConfig] to - the end point of the range. This is + * usually an integer or a double input field. + */ + + /** + * Return a new form field representing a range of numbers. + * + * @param {RangeFieldConfig} config + * @return {HTMLElement} + */ + this.make_range_input = function (config) { + + // TODO + // 1. wrapp both inputs to separate it from the label into a container + // 2. make two rows for each input + // 3. make inline-block for all included elements + const from_config = $.extend({}, { + cached: config.cached, + required: config.required, + type: "double" + }, config.from); + const to_config = $.extend({}, { + cached: config.cached, + required: config.required, + type: "double" + }, config.to); + + const from_input = this.make_form_field(from_config); + const to_input = this.make_form_field(to_config); + + const ret = $(this._make_field_wrapper(config.name)); + if (config.label) { + ret.append(this._make_input_label_str(config)); } - /** - * TODO make syncronous - */ - this.make_range_input = async function (config) { - - // TODO - // 1. wrapp both inputs to separate it from the label into a container - // 2. make two rows for each input - // 3. make inline-block for all included elements - const from_config = $.extend({}, { - cached: config.cached, - required: config.required, - type: "double" - }, config.from); - const to_config = $.extend({}, { - cached: config.cached, - required: config.required, - type: "double" - }, config.to); - - const from_input = await this.make_form_field(from_config); - const to_input = await this.make_form_field(to_config); - - const ret = $(this._make_field_wrapper(config.name)); - if (config.label) { - ret.append(this._make_input_label_str(config)); - } + ret.append(from_input); + ret.append(to_input); - ret.append(from_input); - ret.append(to_input); + // styling + $(from_input).toggleClass("form-group", false); + $(from_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1"); + $(from_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); + $(to_input).toggleClass("form-group", false); + $(to_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1").toggleClass("col-sm-offset-1"); + $(to_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); - // styling - $(from_input).toggleClass("form-group", false); - $(from_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1"); - $(from_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); - $(to_input).toggleClass("form-group", false); - $(to_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1").toggleClass("col-sm-offset-1"); - $(to_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); + return ret[0]; + } - return ret[0]; - } + /** + * Return a DIV with class `caosdb-f-field` and a data attribute + * `data-field-name` which contains the name. + * + * The DIV is used to wrap LABEL and INPUT elements of a form together. + * + * @param {string} name - the name of the field. + * @returns {HTMLElement} a DIV. + */ + this._make_field_wrapper = function (name) { + caosdb_utils.assert_string(name, "param `name`"); + return $('<div class="form-group caosdb-f-field" data-field-name="' + name + '" />') + .css({ + "padding": "0" + })[0]; + } - /** - * Return a DIV with class `caosdb-f-field` and a data attribute - * `data-field-name` which contains the name. - * - * The DIV is used to wrap LABEL and INPUT elements of a form together. - * - * @param {string} name - the name of the field. - * @returns {HTMLElement} a DIV. - */ - this._make_field_wrapper = function (name) { - caosdb_utils.assert_string(name, "param `name`"); - return $('<div class="form-group caosdb-f-field" data-field-name="' + name + '" />') - .css({"padding": "0"})[0]; - } + /** + * Return a new date field. + * + * @param {FieldConfig} config + * @return {HTMLElement} + */ + this.make_date_input = function (config) { + return this._make_input(config); + } - this.make_date_input = function (config) { - return this._make_input(config); - } + /** + * Return a new text field. + * + * @param {FieldConfig} config + * @return {HTMLElement} + */ + this.make_text_input = function (config) { + return this._make_input(config); + } - this.make_text_input = function (config) { - return this._make_input(config); + + /** + * Return an input field which accepts double values. + * + * `config.type` is set to "number" and overrides any other type. + * + * @param {FieldConfig} config. + * @returns {HTMLElement} a double form field. + */ + this.make_double_input = function (config) { + const _config = $.extend({}, config, { + type: "number" + }); + const ret = $(this._make_input(_config)) + if (typeof config.min !== "undefined") { + ret.find("input").attr("min", config.min); + } + if (typeof config.max !== "undefined") { + ret.find("input").attr("max", config.max); } + ret.find("input").attr("step", "any"); + return ret[0]; + } - /** - * Return an input field which accepts double values. - * - * `config.type` is set to "number" and overrides any other type. - * - * @param {form_elements.input_config} config. - * @returns {HTMLElement} a double form field. - */ - this.make_double_input = function (config) { - var clone = $.extend({}, config, { - type: "number" + /** + * Return an input field which accepts integers. + * + * `config.type` is set to "number" and overrides any other type. + * + * @param {FieldConfig} config. + * @returns {HTMLElement} an integer form field. + */ + this.make_integer_input = function (config) { + var ret = $(this.make_double_input(config)); + ret.find("input").attr("step", "1"); + return ret[0]; + } + + /** + * @typedef {object} FileFieldConfig + * + * @augments FieldConfig + * @property {boolean} [multiple=false] - whether to accept multiple files. + * @property {string} [accept] - a comma separated list of file extensions + * which are accepted (exclusively). + */ + + /** + * Return a new form field for a file upload. + * + * @param {FileFieldConfig} config - configuration for this form field. + * @return {HTMLElement} + */ + this.make_file_input = function (config) { + const ret = this._make_input(config); + $(ret) + .find(":input") + .prop("multiple", !!config.multiple) + .css({ + "display": "block" }); - var ret = $(this._make_input(clone)) - ret.find("input").attr("step", "any"); - return ret[0]; + if (config.accept) { + $(ret) + .find(":input") + .attr("accept", config.accept); } + return ret; + } - /** - * Return an input field which accepts integers. - * - * `config.type` is set to "number" and overrides any other type. - * - * @param {form_elements.input_config} config. - * @returns {HTMLElement} an integer form field. - */ - this.make_integer_input = function (config) { - var ret = $(this.make_double_input(config)); - ret.find("input").attr("step", "1"); - return ret[0]; - } + /** + * @typedef {object} SelectOptionConfig + * + * @property {string} value - the value of the select option. + * @property {string} [label] - a visible representation (think: + * description) of the value of the select option. defaults to the + * value itself. + */ + /** + * @typedef {object} SelectFieldConfig + * + * @augments {FieldConfig} + * @property {SelectOptionConfig} - options + */ - /** - * Return a checkbox input field. - * - * @param {form_elements.checkbox_config} config. - * @returns {HTMLElement} a checkbox form field. - */ - this.make_checkbox_input = function (config) { - var clone = $.extend({}, config, { - type: "checkbox" - }); - var ret = $(this._make_input(clone)); - ret.find("input:checkbox").prop("checked", false); - ret.find("input:checkbox").toggleClass("form-control", false); - if (config.checked) { - ret.find("input:checkbox").prop("checked", true); - ret.find("input:checkbox").attr("checked", "checked"); - } - if (config.value) { - ret.find("input:checkbox").attr("value", config.value); - } - return ret[0]; + /** + * Return a select field. + * + * @param {SelectFieldConfig} config + * @returns {HTMLElement} a select field. + */ + this.make_select_input = function (config) { + const options = config.options; + const select = $(form_elements._make_select(config.multiple, config.name)); + + for (let option of options) { + select.append(form_elements._make_option(option.value, option.label)); } + const ret = form_elements._make_input(config, select[0]); + // Here, the bootstrap-select features should be activated for the new + // select element. However, up until now, this only works when the + // select element is already a part of the dom tree - which is not the + // case when this method is called and is controlled by the client. So + // there is currently no other work-around than to call + // init_select_picker after the form creation explicitely :( + //form_elements.init_select_picker(select[0], config.value); + + return ret; + } - /** - * Add `caosdb-f-form-field-required` class to form field. - * - * @param {HTMLElement} field - the required form field. - */ - this.set_required = function (field) { - $(field).toggleClass("caosdb-f-form-field-required", true); - $(field).find(":input").prop("required", true); - $(field).find("label").prepend(this.make_required_marker()); + /** + * Return a checkbox input field. + * + * @param {form_elements.checkbox_config} config. + * @returns {HTMLElement} a checkbox form field. + */ + this.make_checkbox_input = function (config) { + var clone = $.extend({}, config, { + type: "checkbox" + }); + var ret = $(this._make_input(clone)); + ret.find("input:checkbox").prop("checked", false); + ret.find("input:checkbox").toggleClass("form-control", false); + if (config.checked) { + ret.find("input:checkbox").prop("checked", true); + ret.find("input:checkbox").attr("checked", "checked"); } - - /** - * Return a span which is to be inserted before a field's label text - * and which marks that field as required. - * - * @returns {HTMLElement} span element. - */ - this.make_required_marker = function () { - // TODO create class and move to css file - return $('<span>*</span>') - .css({ - "font-size": "10px", - "color": "red", - "margin-right": "4px", - "font-weight": "100", - })[0]; + if (config.value) { + ret.find("input:checkbox").attr("value", config.value); } + return ret[0]; + } - this.get_enabled_required_fields = function (form) { - return $(this.get_enabled_fields(form)) - .filter(".caosdb-f-form-field-required") - .toArray(); - } + /** + * Add `caosdb-f-form-field-required` class to form field. + * + * @param {HTMLElement} field - the required form field. + */ + this.set_required = function (field) { + $(field).toggleClass("caosdb-f-form-field-required", true); + $(field).find(":input").prop("required", true); + $(field).find("label").prepend(this.make_required_marker()); + } + /** + * Return a span which is to be inserted before a field's label text + * and which marks that field as required. + * + * @returns {HTMLElement} span element. + */ + this.make_required_marker = function () { + // TODO create class and move to css file + return $('<span>*</span>') + .css({ + "font-size": "10px", + "color": "red", + "margin-right": "4px", + "font-weight": "100", + })[0]; + } - this.get_enabled_fields = function (form) { - return $(form) - .find(".caosdb-f-field") - .filter(function (idx) { - // remove disabled fields from results - return !$(this).hasClass("caosdb-f-field-disabled"); - }) - .toArray(); - } + this.get_enabled_required_fields = function (form) { + return $(this.get_enabled_fields(form)) + .filter(".caosdb-f-form-field-required") + .toArray(); + } - this.all_required_fields_set = function (form) { - const req = form_elements.get_enabled_required_fields(form); - for (const field of req) { - if (!form_elements.is_set(field)) { - return false; - } - } - return true; - } - /** - * @param {HTMLElement} form - the form be validated. - */ - this.is_valid = function (form) { - return form_elements.all_required_fields_set(form); - } + this.get_enabled_fields = function (form) { + return $(form) + .find(".caosdb-f-field") + .filter(function (idx) { + // remove disabled fields from results + return !$(this).hasClass("caosdb-f-field-disabled"); + }) + .toArray(); + } - this.toggle_submit_button_form_valid = function (form, submit) { - // TODO do not change the submit button directly. change the - // `submittable` state of the form and handle the case where a form - // is submitting when this function is called. - if (form_elements.is_valid(form)) { - $(submit).prop("disabled", false); - } else { - $(submit).prop("disabled", true); + this.all_required_fields_set = function (form) { + const req = form_elements.get_enabled_required_fields(form); + for (const field of req) { + if (!form_elements.is_set(field)) { + return false; } } + return true; + } + /** + * @param {HTMLElement} form - the form be validated. + */ + this.is_valid = function (form) { + return form_elements.all_required_fields_set(form); + } - this.init_validator = function (form) { - const submit = $(form).find(":input[type='submit']")[0]; - if (submit) { - form.addEventListener("caosdb.field.changed", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); - form.addEventListener("caosdb.field.enabled", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); - form.addEventListener("input", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); - } + + this.toggle_submit_button_form_valid = function (form, submit) { + // TODO do not change the submit button directly. change the + // `submittable` state of the form and handle the case where a form + // is submitting when this function is called. + if (form_elements.is_valid(form)) { + $(submit).prop("disabled", false); + } else { + $(submit).prop("disabled", true); } + } - /** - * Return an input and a label, wrapped in a div with class - * `caosdb-f-field`. - * - * @param {object} config - config object with `name`, `type` and - * optional `label` - * @returns {HTMLElement} a form field. - */ - this._make_input = function (config) { - caosdb_utils.assert_string(config.name, "the name of a form field"); - let ret = $(this._make_field_wrapper(config.name)); - let name = config.name; - let label = this._make_input_label_str(config); - let type = config.type || "text"; - let value = config.value; - let input = $('<input class="form-control caosdb-f-property-single-raw-value" type="' + type + - '" name="' + name + - '" />'); - input.change(function () { - ret[0].dispatchEvent(form_elements.field_changed_event); - }); - let input_col = $('<div class="caosdb-f-property-value col-sm-9"/>'); - input_col.append(input); - if (value) { - input.val(value); - } - return ret.append(label, input_col)[0]; + this.init_validator = function (form) { + const submit = $(form).find(":input[type='submit']")[0]; + if (submit) { + form.addEventListener("caosdb.field.changed", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); + form.addEventListener("caosdb.field.enabled", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); + form.addEventListener("input", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); } + } - /** - * Return a string representation of a LABEL element, ready for parsing. - * - * This function is used by other functions to generate a LABEL element. - * - * The config's `name` goes to the `for` attribute, the `label` is the - * text node of the resulting LABEL element. - * - * @param {object} config - a config object with `name` and `label`. - * @returns {string} a html string for a LABEL element. - */ - this._make_input_label_str = function (config) { - let name = config.name; - let label = config.label; - return label ? '<label for="' + name + - '" data-property-name="' + name + - '" class="control-label col-sm-3">' + label + - '</label>' : ""; + + /** + * Return an input and a label, wrapped in a div with class + * `caosdb-f-field`. + * + * @param {object} config - config object with `name`, `type` and + * optional `label` + * @param {string} input - optional specification of the HTML input element. + * `<input class="form-control caosdb-f-property-single-raw-value" type="' + type + '" name="' + name + '" />` + * is used as default where `name` and `type` stem from the config + * object. + * @returns {HTMLElement} a form field. + */ + this._make_input = function (config, input) { + caosdb_utils.assert_string(config.name, "the name of a form field"); + let ret = $(this._make_field_wrapper(config.name)); + let name = config.name; + let label = this._make_input_label_str(config); + let type = config.type || "text"; + let value = config.value; + const _input = $(input || + '<input class="form-control caosdb-f-property-single-raw-value" type="' + + type + '" name="' + name + '" />'); + _input.change(function () { + ret[0].dispatchEvent(form_elements.field_changed_event); + }); + let input_col = $('<div class="caosdb-f-property-value col-sm-9"/>'); + input_col.append(_input); + if (value) { + _input.val(value); } + return ret.append(label, input_col)[0]; + } + /** + * Return a string representation of a LABEL element, ready for parsing. + * + * This function is used by other functions to generate a LABEL element. + * + * The config's `name` goes to the `for` attribute, the `label` is the + * text node of the resulting LABEL element. + * + * @param {object} config - a config object with `name` and `label`. + * @returns {string} a html string for a LABEL element. + */ + this._make_input_label_str = function (config) { + let name = config.name; + let label = config.label; + return label ? '<label for="' + name + + '" data-property-name="' + name + + '" class="control-label col-sm-3">' + label + + '</label>' : ""; } + this._init_functions(); } diff --git a/src/core/js/preview.js b/src/core/js/preview.js index f74fd13ffd2fe373543c025866e91cfe6b6fd9eb..b85f7b56dc13ff94aa2c3de90eb1b464d7f1e9d6 100644 --- a/src/core/js/preview.js +++ b/src/core/js/preview.js @@ -211,8 +211,8 @@ var preview = new function() { /** * Transform the raw xml response of the server into an array of entities for preview. * - * @param {Promise for XMLDocument} xml - A Promise for the servers xml response. - * @return {Promise for HTMLElement[]} A Promise for an array of entities. + * @param {Promise | XMLDocument} xml - A Promise for the servers xml response. + * @return {Promise | HTMLElement[]} A Promise for an array of entities. */ this.processPreviewResponse = function(xml) { let xsl = preview.getEntityXsl(); @@ -222,7 +222,7 @@ var preview = new function() { /** * Retrieve the XSL script for entities from the server. * - * @return {Promise for XMLDocument} A Promise for the XSL script. + * @return {Promise | XMLDocument} A Promise for the XSL script. */ this.getEntityXsl = async function _getEntityXsl() { return transformation.retrieveEntityXsl(); @@ -679,7 +679,7 @@ var preview = new function() { * Retrieve a list of entities from the server. * * @param {String[]} entityIds - The ids of the entities which are to be retrieved. - * @return {Promise for HTMLElement[]} A Promise for an array of entities. + * @return {Promise | HTMLElement[]} A Promise for an array of entities. */ this.retrievePreviewEntities = async function _rPE(entityIds) { try { @@ -711,9 +711,9 @@ var preview = new function() { /** * Transform the xml to an array of entities. * - * @param {Promise XMLDocument} xml - The server response. - * @param {Promise XMLDocument} xsl - The xsl script. - * @return {Promise HTMLElement[]} A promise for an Array of HTMLElements. + * @param {Promise | XMLDocument} xml - The server response. + * @param {Promise | XMLDocument} xsl - The xsl script. + * @return {Promise | HTMLElement[]} A promise for an Array of HTMLElements. */ this.transformXmlToPreviews = async function _tXTP(xml, xsl) { let html = await asyncXslt(xml, xsl); diff --git a/src/core/js/tour.js b/src/core/js/tour.js index 7b47dd37239c29b5c5e7edb67f1d16fe43bf8ad6..cfe65519eef0f34f708461bca3658e76f5530eff 100644 --- a/src/core/js/tour.js +++ b/src/core/js/tour.js @@ -630,6 +630,7 @@ var tour = new function() { content: markdown_content, placement: placement, html: true, + sanitize: false, trigger: 'manual', template: popover_template, }); @@ -934,8 +935,6 @@ var tour = new function() { tour_overview.append(next); } - panel.hover(undefined, ()=>{panel.collapse('hide');}); - panel.append(tour_overview); this.leave_tour_button.on("click", () => {this.deactivate();}); @@ -981,6 +980,9 @@ var tour = new function() { tour._instance.set_tour_button_text("Tour"); } $('#caosdb-query-panel').before(tour._instance.panel); + // hide, when the mouse leaves the navbar + $('nav.navbar').hover(undefined, ()=>{$(tour._instance.panel).collapse('hide');}); + } diff --git a/src/core/js/webcaosdb.js b/src/core/js/webcaosdb.js index df93703d83fafde15908efb40c3ae578824980d9..b7ad95f4d907104210eb04771b95f03d3f41d5bd 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); + } + }); } @@ -269,6 +327,30 @@ this.caosdb_utils = new function () { } throw new TypeError(name + " is expected to be an array, was " + typeof obj); } + + /** + * Create a tsv table as string. + * + * The data must be appropriately encoded (e.g. urlencoded). + * + * With `tab=","` it is also possible to create csv tables. + * + * @param {string[][]} data - An array of rows which contain arrays of + * cells. + * @param {string} [preamble="data:text/csv;charset=utf-8,"] - a prefix for + * the the resulting string. The default is suitable for creating + * downloadable href attributes of links. + * @param {string} [tab="%09"] - the cell separator. + * @param {string} [newline="%0A"] - the row separator. + * @return {string} a tsv table as a string. + */ + this.create_tsv_table = function(data, preamble, tab, newline) { + preamble = ((typeof preamble == 'undefined') ? "data:text/csv;charset=utf-8,": preamble); + tab = tab || "%09"; + newline = newline || "%0A"; + const rows = data.map(x => x.join(tab)) + return `${preamble}${rows.join(newline)}`; + } } /** @@ -592,7 +674,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 @@ -905,6 +988,117 @@ this.transaction = new function () { } } +/** + * This module provides the functionality to load the full version history (for + * privileged users) and export it to tsv. + */ +var version_history = new function () { + + this._get = connection.get; + /** + * Retrieve the version history of an entity and return a table with the + * history. + * + * @param {string} entity - the entity id with or without version id. + * @return {HTMLElement} A table with the version history. + */ + this.retrieve_history = async function(entity) { + const xml = this._get(transaction + .generateEntitiesUri([entity]) + "?H"); + const html = (await transformation.transformEntities(xml))[0]; + const history_table = $(html).find(".caosdb-f-entity-version-history"); + return history_table[0]; + } + + /** + * Initalize the buttons for loading the version history. + * + * The buttons are visible when the entity has only the normal version info + * attached and the current user has the permissions to retrieve the + * version history. + * + * The buttons trigger the retrieval of the version history and append the + * version history to the version info modal. + */ + this.init_load_history_buttons = function () { + for (let entity of $(".caosdb-entity-panel")) { + const is_permitted = hasEntityPermission(entity, "RETRIEVE:HISTORY"); + if (!is_permitted) { + continue; + } + const entity_id_version = getEntityIdVersion(entity); + const version_info = $(entity) + .find(".caosdb-f-entity-version-info"); + const button = $(version_info) + .find(".caosdb-f-entity-version-load-history-btn"); + button.show(); + button + .click(async () => { + button.prop("disabled", true); + const wait = createWaitingNotification("Retrieving full history. Please wait."); + const sparse = $(version_info) + .find(".caosdb-f-entity-version-history"); + sparse.find(".modal-body *").replaceWith(wait); + + const history_table = await version_history + .retrieve_history(entity_id_version); + sparse.replaceWith(history_table); + version_history.init_export_history_buttons(entity); + }); + } + } + + /** + * Transform the HTML table with the version history to tsv. + * + * @param {HTMLElement} history_table - the HTML representation of the + * version history. + * @return {string} the version history as downloadable tsv string, + * suitable for the href attribute of a link or window.location. + */ + this.get_history_tsv = function (history_table) { + const rows = []; + for (let row of $(history_table).find("tr")) { + const cells = $(row).find(".export-data").toArray().map(x => x.textContent); + rows.push(cells); + } + return caosdb_utils.create_tsv_table(rows); + } + + /** + * Initialize the export buttons of `entity`. + * + * The buttons are only visible when the version history is visible and + * trigger a download of a tsv file which contains the version history. + * + * The buttons trigger the download of a tsv file with the version history. + * + * @param {HTMLElement} [entity] - if undefined, the export buttons of all + * page entities are initialized. + */ + this.init_export_history_buttons = function (entity) { + entity = entity || $(".caosdb-entity-panel"); + for (let version_info of $(entity) + .find(".caosdb-f-entity-version-info")) { + $(version_info).find(".caosdb-f-entity-version-export-history-btn") + .click(async () => { + const html_table = $(version_info).find("table")[0]; + const history_tsv = this.get_history_tsv(html_table); + version_history._download_tsv(history_tsv); + }); + } + } + + this._download_tsv = function(tsv_link) { + window.location.href = tsv_link; + } + + + this.init = function () { + this.init_load_history_buttons(); + this.init_export_history_buttons(); + } +} var paging = new function () { @@ -979,6 +1173,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 +1200,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 +1318,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 +1337,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) { @@ -1480,11 +1683,17 @@ function getXSLScriptClone(source) { /** * TODO */ -function injectTemplate(orig_xsl, template) { +function injectTemplate(orig_xsl, templateStr) { var xsl = getXSLScriptClone(orig_xsl); - var entry_t = xsl.createElement("xsl:template"); - xsl.firstElementChild.appendChild(entry_t); - entry_t.outerHTML = template; + // var entry_t = xsl.createElement("xsl:template"); + // xsl.firstElementChild.appendChild(entry_t); + // entry_t.outerHTML = template; // Does not work in templates in Safari 11 + // Workaround follows, remove after Safari also has the behaviour of Firefox and current WebKit + var temp = xsl.documentElement.cloneNode(false) + temp.innerHTML = templateStr; + var entry_t = temp.firstChild; + xsl.documentElement.appendChild(entry_t); + // End of workaround return xsl; } @@ -1503,13 +1712,13 @@ function insertParam(xsl, name, value = null) { /** * When the page is scrolled down 100 pixels, the scroll-back button appears. * - * @return + * @return FIXME */ /** * Every initial function calling is done here. * - * @return + * @return TODO */ function initOnDocumentReady() { hintMessages.init(); @@ -1534,6 +1743,7 @@ function initOnDocumentReady() { } caosdb_modules.init(); navbar.init(); + version_history.init(); } diff --git a/src/core/xsl/entity.xsl b/src/core/xsl/entity.xsl index 299b1dac37a1029e5bc7c48008efa3735d61037b..cac9e87ea40bf6a687a19a1942b5534276c8faf7 100644 --- a/src/core/xsl/entity.xsl +++ b/src/core/xsl/entity.xsl @@ -103,6 +103,7 @@ <xsl:attribute name="data-entity-id"> <xsl:value-of select="@id"/> </xsl:attribute> + <xsl:apply-templates mode="entity-permissions" select="Permissions"/> <!-- A page-unique ID for this entity --> <xsl:variable name="entityid" select="concat('entity_',generate-id())"/> <div class="panel-heading caosdb-entity-panel-heading"> @@ -156,6 +157,16 @@ <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:if test="Version/Successor"> + <!-- this is not the head --> + <xsl:value-of select="concat('@', Version/@id)"/> + </xsl:if> + </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 +403,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'"/> @@ -518,78 +529,197 @@ </xsl:attribute> <span class="glyphicon glyphicon-time"/> </button> + <!-- the following div.modal is the window that pops up when the user clicks on the clock button --> <div class="caosdb-f-entity-version-info modal fade" tabindex="-1" role="dialog"> <xsl:attribute name="id"><xsl:value-of select="$versionModalId"/></xsl:attribute> + <xsl:attribute name="data-entity-versioned-id"><xsl:value-of select="concat($entityId, '@', @id)"/></xsl:attribute> <div class="modal-dialog modal-lg" role="document"> <div class="modal-content text-left"> + <!-- modal-header start --> <div> <xsl:attribute name="class"> modal-header - <xsl:if test="Successor"> + <xsl:if test="not(@head='true')"> <!-- indicate old version by color --> <xsl:value-of select="' bg-danger'"/> </xsl:if> </xsl:attribute> <button type="button" class="close" data-dismiss="modal" aria-label="Close" title="Close"><span aria-hidden="true">×</span></button> <h4 class="modal-title">Version Info</h4> - <p class="caosdb-entity-heading-attr"> + <p class="caosdb-entity-heading-attr"> <em class="caosdb-entity-heading-attr-name"> This is - <xsl:if test="Successor"><b>not</b></xsl:if> + <xsl:if test="not(@head='true')"><b>not</b></xsl:if> the latest version of this entity. + <xsl:apply-templates mode="entity-version-modal-head" select="Successor"> + <xsl:with-param name="entityId" select="$entityId"/> + </xsl:apply-templates> </em> </p> </div> - <div class="modal-body"> - <xsl:apply-templates mode="entity-version-modal-head" select="Successor"> + <!-- modal-header end --> + <div class="caosdb-f-entity-version-history"> + <!-- modal-body and modal-footer are added by this template --> + <xsl:apply-templates select="." mode="entity-version-history-table"> <xsl:with-param name="entityId" select="$entityId"/> </xsl:apply-templates> - <xsl:apply-templates mode="entity-version-modal-successor" select="Successor"> + </div> + </div> + </div> + </div> + </xsl:template> + + <xsl:template match="Version[@completeHistory='true']" mode="entity-version-history-table"> + <!-- contains the table of the full version history --> + <xsl:param name="entityId"/> + <div class="modal-body"> + <table class="table table-hover"> + <thead> + <tr><div class="export-data">Entity ID</div><th/> + <th class="export-data">Version ID</th> + <th class="export-data">Date</th> + <th class="export-data">User</th> + <div class="export-data">URI</div> + </tr></thead> + <tbody> + <xsl:apply-templates mode="entity-version-modal-successor" select="Successor"> + <xsl:with-param name="entityId" select="$entityId"/> + </xsl:apply-templates> + <tr> + <div class="export-data"><xsl:value-of select="$entityId"/></div> + <td class="caosdb-v-entity-version-hint caosdb-v-entity-version-hint-cur">This Version</td> + <td><xsl:apply-templates select="@id" mode="entity-version-id"/> + </td><td> + <xsl:apply-templates select="@date" mode="entity-version-date"/> + </td><td class="export-data"> + <xsl:value-of select="@username"/>@<xsl:value-of select="@realm"/> + </td> + <div class="export-data"><xsl:value-of select="concat($entitypath, $entityId, '@', @id)"/></div> + </tr> + <xsl:apply-templates mode="entity-version-modal-predecessor" select="Predecessor"> + <xsl:with-param name="entityId" select="$entityId"/> + </xsl:apply-templates> + </tbody> + </table> + </div> + <div class="modal-footer"> + <button type="button" class="caosdb-f-entity-version-export-history-btn btn btn-default">Export history</button> + </div> + </xsl:template> + + <xsl:template match="Version[not(@completeHistory='true')]" mode="entity-version-history-table"> + <!-- contains the table of the simple version info (not the full history)--> + <xsl:param name="entityId"/> + <div class="modal-body"> + <table class="table"> + <thead><tr><th>Previous Version</th><th>This Version</th><th>Next Version</th></tr></thead> + <tbody> + <tr> + <td> + <xsl:if test="not(Predecessor)"> + <div class="caosdb-v-entity-version-no-related">No predecessor</div> + </xsl:if> + <xsl:apply-templates select="Predecessor/@id" mode="entity-version-link-to-other-version"> <xsl:with-param name="entityId" select="$entityId"/> </xsl:apply-templates> - <p class="caosdb-entity-heading-attr"> - <em class="caosdb-entity-heading-attr-name">This version:</em> - <xsl:value-of select="@id"/> (<xsl:value-of select="@date"/>) - </p> - <xsl:apply-templates mode="entity-version-modal-predecessor" select="Predecessor"> + </td> + <td> + <xsl:apply-templates select="@id" mode="entity-version-id"/> + </td> + <td> + <xsl:if test="not(Successor)"> + <div class="caosdb-v-entity-version-no-related">No successor</div> + </xsl:if> + <xsl:apply-templates select="Successor/@id" mode="entity-version-link-to-other-version"> <xsl:with-param name="entityId" select="$entityId"/> </xsl:apply-templates> - </div> - </div> - </div> + </td> + </tr> + </tbody> + </table> + </div> + <div class="modal-footer"> + <button type="button" style="display: none" class="caosdb-f-entity-version-load-history-btn btn btn-default">Load full history</button> </div> </xsl:template> + + <xsl:template match="@id" mode="entity-version-id"> + <!-- a versions'id (abbreviated) --> + <xsl:attribute name="title">Full Version ID: <xsl:value-of select="."/></xsl:attribute> + <xsl:value-of select="substring(.,1,8)"/> + <div class="export-data"><xsl:value-of select="."/></div> + </xsl:template> + + <xsl:template match="@date" mode="entity-version-date"> + <!-- a version's date (abbreviated)--> + <xsl:attribute name="title"><xsl:value-of select="."/></xsl:attribute> + <xsl:value-of select="substring(.,0,11)"/> + <xsl:value-of select="' '"/> + <xsl:value-of select="substring(.,12,8)"/> + <div class="export-data"><xsl:value-of select="."/></div> + </xsl:template> + + <xsl:template match="Predecessor|Successor" mode="entity-version-modal-single-history-item"> + <!-- a single row of the version history table --> + <xsl:param name="entityId"/> + <xsl:param name="hint"/> + <tr> + <div class="export-data"><xsl:value-of select="$entityId"/></div> + <td class="caosdb-v-entity-version-hint"><xsl:value-of select="$hint"/></td> + <td> + <xsl:apply-templates select="@id" mode="entity-version-link-to-other-version"> + <xsl:with-param name="entityId" select="$entityId"/> + </xsl:apply-templates> + </td><td> + <xsl:apply-templates select="@date" mode="entity-version-date"/> + </td><td class="export-data"> + <xsl:value-of select="@username"/>@<xsl:value-of select="@realm"/> + </td> + <div class="export-data"><xsl:value-of select="concat($entitypath, $entityId, '@', @id)"/></div> + </tr> + </xsl:template> + + <xsl:template match="@id" mode="entity-version-link-to-other-version"> + <!-- link to other version (used by both version tables)--> + <xsl:param name="entityId"/> + <a><xsl:attribute name="href"><xsl:value-of select="$entityId"/>@<xsl:value-of select="."/></xsl:attribute> + <xsl:apply-templates select="." mode="entity-version-id"/></a> + </xsl:template> + <xsl:template match="Predecessor" mode="entity-version-modal-predecessor"> - <!-- content of the versioning window --> + <!-- content of the versioning info (not the full history) --> <xsl:param name="entityId"/> - <p class="caosdb-entity-heading-attr"> - <em class="caosdb-entity-heading-attr-name">Previous version:</em> - <a><xsl:attribute name="href"><xsl:value-of select="$entityId"/>@<xsl:value-of select="@id"/></xsl:attribute> - <xsl:value-of select="@id"/> (<xsl:value-of select="@date"/>) - </a> - </p> + <xsl:apply-templates mode="entity-version-modal-single-history-item" select="."> + <xsl:with-param name="entityId" select="$entityId"/> + <xsl:with-param name="hint" select="'Older Version'"/> + </xsl:apply-templates> + <xsl:apply-templates mode="entity-version-modal-predecessor" select="Predecessor"> + <xsl:with-param name="entityId" select="$entityId"/> + </xsl:apply-templates> </xsl:template> + <xsl:template match="Successor" mode="entity-version-modal-head"> - <!-- content of the versioning window --> + <!-- content of the versioning modal's header (if a newer version exists) --> <xsl:param name="entityId"/> - <p class="caosdb-entity-heading-attr"> - <em class="caosdb-entity-heading-attr-name">Newest version:</em> - <a><xsl:attribute name="href"><xsl:value-of select="$entityId"/>@HEAD</xsl:attribute> - <xsl:value-of select="$entityId"/>@HEAD - </a> - </p> + View the newest version here: + <a><xsl:attribute name="href"><xsl:value-of select="$entityId"/>@HEAD</xsl:attribute> + <xsl:value-of select="$entityId"/>@HEAD + </a> </xsl:template> + <xsl:template match="Successor" mode="entity-version-modal-successor"> - <!-- content of the versioning window --> + <!-- content of the versioning info (not the full history) --> <xsl:param name="entityId"/> - <p class="caosdb-entity-heading-attr"> - <em class="caosdb-entity-heading-attr-name">Next version:</em> - <a><xsl:attribute name="href"><xsl:value-of select="$entityId"/>@<xsl:value-of select="@id"/></xsl:attribute> - <xsl:value-of select="@id"/> (<xsl:value-of select="@date"/>) - </a> - </p> + <xsl:apply-templates mode="entity-version-modal-successor" select="Successor"> + <xsl:with-param name="entityId" select="$entityId"/> + </xsl:apply-templates> + <xsl:apply-templates mode="entity-version-modal-single-history-item" select="."> + <xsl:with-param name="entityId" select="$entityId"/> + <xsl:with-param name="hint" select="'Newer Version'"/> + </xsl:apply-templates> </xsl:template> + <xsl:template match="Version/Successor" mode="entity-action-panel-version"> <!-- clickable warning message in the entity actions panel when there exists a newer version --> <xsl:param name="entityId"/> @@ -598,13 +728,15 @@ <strong>Warning</strong> A newer version exists! </a> </xsl:template> + <xsl:template match="Version" mode="entity-version-marker"> <!-- content of the data-version-id attribute --> <xsl:attribute name="data-version-id"> - <xsl:value-of select="@id"/> + <xsl:value-of select="@id"/> </xsl:attribute> <xsl:apply-templates select="Successor" mode="entity-version-marker"/> </xsl:template> + <xsl:template match="Successor" mode="entity-version-marker"> <!-- content of the data-version-successor attribute This data-attribute marks entities which have a newer version. @@ -613,4 +745,17 @@ <xsl:value-of select="@id"/> </xsl:attribute> </xsl:template> + + <!-- PERMISSIONS --> + <xsl:template match="Permissions" mode="entity-permissions"> + <div style="display: none"> + <xsl:apply-templates select="Permission" mode="entity-permissions"/> + </div> + </xsl:template> + + <xsl:template match="Permission" mode="entity-permissions"> + <div> + <xsl:attribute name="data-permission"><xsl:value-of select="@name"/></xsl:attribute> + </div> + </xsl:template> </xsl:stylesheet> diff --git a/src/core/xsl/main.xsl b/src/core/xsl/main.xsl index 35bc2d53a366530c63f1ff24fe620a4f37b5fce1..a6c8d7296d03474199b5706def1254c967dacf45 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')"/> @@ -180,6 +195,11 @@ <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/query_shortcuts.js')"/> </xsl:attribute> </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_jupyterdrag.js')"/> + </xsl:attribute> + </xsl:element> <xsl:element name="script"> <xsl:attribute name="src"> <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/annotation.js')"/> @@ -190,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 +285,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..6ce69e638efda10dbb95a066e8b59508854a4435 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. The exported file is a spread sheet with columns for the id, the version, the complete URI of the bookmarked entities and the path, if the entity is 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..44d1c1bd6cff8f4b22138c1287af15713069ed79 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> @@ -138,7 +149,7 @@ <thead> <tr> <th></th> - <xsl:for-each select="Selector[@name!='id']"> + <xsl:for-each select="Selector"> <th> <xsl:value-of select="@name"/> </th> @@ -149,6 +160,8 @@ <xsl:for-each select="/Response/*[@id]"> <xsl:call-template name="select-table-row"> <xsl:with-param name="entity-id" select="@id"/> + <xsl:with-param name="version-id" select="Version/@id"/> + <xsl:with-param name="ishead" select="Version/@head"/> </xsl:call-template> </xsl:for-each> </tbody> @@ -159,9 +172,14 @@ </xsl:template> <xsl:template name="entity-link"> <xsl:param name="entity-id"/> + <xsl:param name="version-id"/> + <xsl:param name="ishead"/> <a class="btn btn-default btn-sm caosdb-select-id"> <xsl:attribute name="href"> <xsl:value-of select="concat($entitypath, $entity-id)"/> + <xsl:if test="$version-id and not($ishead)"> + <xsl:value-of select="concat('@', $version-id)"/> + </xsl:if> </xsl:attribute> <!-- <xsl:value-of select="$entity-id" /> --> <span class="caosdb-select-id-target"> @@ -172,18 +190,26 @@ <xsl:template name="select-table-row"> <xsl:param name="entity-id"/> + <xsl:param name="version-id"/> + <xsl:param name="ishead"/> <tr> <xsl:attribute name="data-entity-id"> <xsl:value-of select="$entity-id"/> </xsl:attribute> + <xsl:attribute name="data-version-id"> + <xsl:value-of select="$version-id"/> + </xsl:attribute> <td> <xsl:call-template name="entity-link"> <xsl:with-param name="entity-id" select="$entity-id"/> + <xsl:with-param name="version-id" select="$version-id"/> + <xsl:with-param name="ishead" select="$ishead"/> </xsl:call-template> </td> <xsl:for-each select="/Response/Query/Selection/Selector"> <xsl:call-template name="select-table-cell"> <xsl:with-param name="entity-id" select="$entity-id"/> + <xsl:with-param name="version-id" select="$version-id"/> <xsl:with-param name="field-name" select="translate(@name, $uppercase, $lowercase)"/> </xsl:call-template> </xsl:for-each> @@ -192,13 +218,15 @@ <xsl:template name="select-table-cell"> <xsl:param name="entity-id"/> + <xsl:param name="version-id"/> <xsl:param name="field-name"/> <td class="caosdb-f-entity-property"> <xsl:attribute name="data-property-name"> <xsl:value-of select="$field-name"/> </xsl:attribute> <div class="caosdb-f-property-value caosdb-v-property-value"> - <xsl:apply-templates select="/Response/*[@id=$entity-id]" mode="walk-select-segments"> + <xsl:apply-templates select="/Response/*[@id=$entity-id and Version/@id=$version-id]" mode="walk-select-segments"> + <!--<xsl:apply-templates select="/Response/*[@id=$entity-id]" mode="walk-select-segments">--> <xsl:with-param name="first-segment"> <xsl:value-of select="substring-before(concat($field-name, '.'), '.')"/> </xsl:with-param> @@ -212,9 +240,28 @@ <xsl:template match="Property" mode="walk-select-segments"> <!-- handle properties --> + <xsl:param name="first-segment"/> <xsl:param name="next-segments"/> <xsl:choose> + <xsl:when test="@*[translate($first-segment, $uppercase, $lowercase)=name()]"> + <!--handle attributes--> + <xsl:call-template name="single-value"> + <xsl:with-param name="value"> + <xsl:value-of select="@*[translate(name(), $uppercase, $lowercase)=$first-segment]"/> + </xsl:with-param> + </xsl:call-template> + </xsl:when> + + <xsl:when test="translate($first-segment, $uppercase, $lowercase)='version'"> + <!--handle version--> + <xsl:call-template name="single-value"> + <xsl:with-param name="value"> + <xsl:value-of select="Version/@id"/> + </xsl:with-param> + </xsl:call-template> + </xsl:when> + <xsl:when test="$next-segments='value'"> <!--handle value--> <xsl:apply-templates mode="property-value" select="."/> @@ -263,6 +310,15 @@ </xsl:call-template> </xsl:when> + <xsl:when test="translate($first-segment, $uppercase, $lowercase)='version'"> + <!--handle version--> + <xsl:call-template name="single-value"> + <xsl:with-param name="value"> + <xsl:value-of select="Version/@id"/> + </xsl:with-param> + </xsl:call-template> + </xsl:when> + <xsl:when test="$next-segments"> <!-- when there is a next-segmenst --> <xsl:apply-templates select="Property[translate(@name, $uppercase, $lowercase)=$first-segment]" mode="walk-select-segments"> diff --git a/src/doc/Makefile b/src/doc/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..f3519f277badaf083c7f3512c64b18911ddf1f11 --- /dev/null +++ b/src/doc/Makefile @@ -0,0 +1,51 @@ +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 Daniel Hornung <d.hornung@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 Makefile is a wrapper for sphinx scripts. +# +# It is based upon the autocreated makefile for Sphinx documentation. + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= -a +SPHINXBUILD ?= sphinx-build +# SPHINXAPIDOC ?= javasphinx-apidoc +SOURCEDIR = . +BUILDDIR = ../../build/doc + +# npm is not always in the global PATH +NPM_PATH = $(shell npm bin) +NPM_PREFIX = $(shell npm prefix) + +.PHONY: doc-help Makefile api + +# Put it first so that "make" without argument is like "make help". +doc-help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile api + PATH=$(NPM_PATH):$$PATH $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) +# sphinx-build -M html . ../../build/doc + +api: + PATH=$(NPM_PATH):$$PATH jsdoc -t $(NPM_PREFIX)/node_modules/jsdoc-sphinx/template -d $@ -r "../../src/core" diff --git a/src/doc/administration/comments.rst b/src/doc/administration/comments.rst new file mode 100644 index 0000000000000000000000000000000000000000..f44561d12c247ae5c1c42d9ac1c912d13ab768e2 --- /dev/null +++ b/src/doc/administration/comments.rst @@ -0,0 +1,85 @@ +The comments feature of the caosdb webui +======================================== + +WebUI contains a feature that allows users to add comments to existing +records. + +The feature is not enabled by default. + +You can manually activate it using the following steps: - Add a new +RecordType (e.g. using the Edit Mode) called “Annotation” - Add a new +RecordType called “CommentAnnotation” with parent “Annotation” - Add a +new TEXT Property called “comment” - Add a new REFERENCE Property called +“annotationOf” + +or using the following XML: + +.. code:: xml + + <Property id="-1" name="comment" description="A comment on something." datatype="TEXT"> + </Property> + + <Property id="-2" name="annotationOf" description="The core property of the [Annotation] denoting which entity the annotation is annotating." datatype="REFERENCE"> + </Property> + + <RecordType id="-3" name="Annotation" description="Annotations annotate other entities in order to represent information about these entities without changing them. Mostly this will be comments by users on these entities or flags used by third party programs."> + <Property id="-2" name="annotationOf" description="The core property of the [Annotation] denoting which entity the annotation is annotating." datatype="REFERENCE" importance="OBLIGATORY"> + </Property> + </RecordType> + + <RecordType name="CommentAnnotation" description="CommentAnnotations represent user comments on other entities. As they are entities themselves they can be 'responded' by just annotating them with another CommentAnnotation."> + <Parent id="-3" name="Annotation" description="Annotations annotate other entities in order to represent information about these entities without changing them. Mostly this will be comments by users on these entities or flags used by third party programs." /> + <Property id="-1" name="comment" description="A comment on something." datatype="TEXT" importance="OBLIGATORY"> + </Property> + </RecordType> + +Additionally, on some servers the comment button might be disabled using +CSS. + +E.g. on the demo server you would have to comment out the following +lines in ``demoserver.css``: + +.. code:: css + + .caosdb-new-comment-button { + visibility: hidden; + } + +Using the YAML-Datamodel-Interface +---------------------------------- + +It’s even easier to add the model using the yaml interface. Use the +following yaml file: + +.. code:: yaml + + + Annotation: + description: Annotations annotate other entities in order to represent information about these entities without changing them. Mostly this will be comments by users on these entities or flags used by third party programs. + obligatory_properties: + annotationOf: + description: The core property of the [Annotation] denoting which entity the annotation is annotating. + datatype: REFERENCE + + CommentAnnotation: + description: CommentAnnotations represent user comments on other entities. As they are entities themselves they can be 'responded' by just annotating them with another CommentAnnotation. + inherit_from_obligatory: + - Annotation + obligatory_properties: + comment: + description: A comment on something. + datatype: TEXT + +Save this file under “datamodel.yaml”. + +Make sure you have installed caosdb-models. + +Then sync the model: + +.. code:: python + + import caosdb as db + from caosmodels.parser import parse_model_from_yaml + + model = parse_model_from_yaml("datamodel.yaml") + model.sync_data_model(noquestion=True) diff --git a/src/doc/administration/index.rst b/src/doc/administration/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..e04e1dea523f1d9d8aa83231429a9347ecbfb4de --- /dev/null +++ b/src/doc/administration/index.rst @@ -0,0 +1,10 @@ +Administration +============== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + :hidden: + + comments + static-snapshots diff --git a/src/doc/administration/static-snapshots.md b/src/doc/administration/static-snapshots.md new file mode 100644 index 0000000000000000000000000000000000000000..b7f292ad37d4a35f63aed406600190e988bf84a6 --- /dev/null +++ b/src/doc/administration/static-snapshots.md @@ -0,0 +1,50 @@ +# Creating Static WebUI Snapshots + +It can be helpful to generate static snapshots of WebUI contents, e.g. for reviewing layouts or for presentation purposes. This is possible with a little bit of effort. Excitingly not only the layout can be exported, but also a lot of the javascript functionality can be maintained in the static pages. + +**NOTE: This manual page is currently work in progress.** + +## Create the static webui folder in the docker container + +We need a static version of the caosdb-webui. In principle it can be simply copied from e.g. a running docker container or from the public-directory. As it contains self-referencing (cyclic) symlinks a little bit of care has to be taken. + +### Using Docker + +Login to the caosdb/linkahead docker container as root: +```bash +docker exec -u 0 -ti linkahead /bin/bash +``` + +We need to be root (`-u 0`) in order to be able to create a copy of caosdb-webui within the container. + +Create the copy using `cp` and the option for following symlinks `-L`: + +```bash +cp -L git/caosdb-server/caosdb-webui/public/ webui-copy +``` + +It will warn you that two symlinks (which are cyclic) cannot be created. That's fine, we will create these two symlinks later. + +``` +cp: cannot copy cyclic symbolic link 'git/caosdb-server/caosdb-webui/public/1602145811' <- The number here is a "unique" build number +cp: cannot copy cyclic symbolic link 'git/caosdb-server/caosdb-webui/public/webinterface' +``` + +**Please copy the build number somewhere, or make sure your terminal history does not get wiped.** + +Copy webui-copy from the docker container to the location where you want to store the snapshots: +`docker cp linkahead:/opt/caosdb/webui-copy/ .` + +Create the two missing symlinks in webui-copy/public: +``` +ln -s webui-copy/public webui-copy/public/1602145811 +ln -s webui-copy/public webui-copy/public/webinterface +``` + +You can now use the included xslt stylesheet to convert xml files to html using: +```bash +xsltproc webui-copy/public/webcaosdb.xsl test.xml > test.html +``` + +As the generated html file still contains invalid references to `/webinterface/1602145811` +you have to replace all occurences of `/webinterface` with webui-copy/public`. diff --git a/src/doc/concepts.rst b/src/doc/concepts.rst new file mode 100644 index 0000000000000000000000000000000000000000..23e5fc4f6ddb666757fb9c79e192e07ffed8fb44 --- /dev/null +++ b/src/doc/concepts.rst @@ -0,0 +1,6 @@ +======================== +The concepts of pycaosdb +======================== + +Some text... + diff --git a/src/doc/conf.py b/src/doc/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..8e627dd0c26f9d760c549abfab4cbc824baa9912 --- /dev/null +++ b/src/doc/conf.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('../caosdb')) + + +# -- Project information ----------------------------------------------------- + +import sphinx_rtd_theme + +project = 'caosdb-webui' +copyright = '2020, IndiScale GmbH' +author = 'Daniel Hornung' + +# The short X.Y version +version = '0.X.Y' +# The full version, including alpha/beta/rc tags +release = '0.x.y-beta-rc2' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ +# 'sphinx_js', + 'sphinx.ext.todo', + "sphinx.ext.autodoc", +# 'autoapi.extension', + "recommonmark", # For markdown files. + "sphinx_rtd_theme", + 'sphinx.ext.intersphinx', + 'sphinx.ext.mathjax', + 'sphinx.ext.ifconfig', + # 'sphinx.ext.napoleon', # For Google style docstrings +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'caosdb-webuidoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'caosdb-webui.tex', 'caosdb-webui Documentation', + 'IndiScale GmbH', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'caosdb-webui', 'caosdb-webui Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'caosdb-webui', 'caosdb-webui Documentation', + author, 'caosdb-webui', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for sphinx-js --------------------------------------------------- +# See also https://pypi.org/project/sphinx-js/ + +js_source_path = '../core/js/' +primary_domain = 'js' # Not strictly necessary? + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} + +# TODO Which options do we want? +autodoc_default_options = { + 'members': None, + 'undoc-members': None, +} + +# -- Options for sphinx-autoapi ---------------------------------------------- +# See also https://pypi.org/project/sphinx-js/ + +autoapi_type = 'javascript' +autoapi_dirs = ['../core/js/'] +autoapi_add_toctree_entry = False diff --git a/src/doc/extension.rst b/src/doc/extension.rst new file mode 100644 index 0000000000000000000000000000000000000000..18fd0f25a8ce75cd19edfa845b06f5c45bd3fd20 --- /dev/null +++ b/src/doc/extension.rst @@ -0,0 +1,13 @@ + +Extending the CaosDB Web Interface +================================== + +Here we collect information on how to extend the web interface as a developer. + +.. toctree:: + :maxdepth: 1 + :glob: + + extension/* + + diff --git a/src/doc/extension/forms.rst b/src/doc/extension/forms.rst new file mode 100644 index 0000000000000000000000000000000000000000..1bced612b5f9517c5ec149871cbf53321b4671d4 --- /dev/null +++ b/src/doc/extension/forms.rst @@ -0,0 +1,80 @@ + +Creating forms for the CaosDB Web Interface +=========================================== + +The ``form_elements`` module provides a library for generating forms from simple config objects. The forms are styled for the seamless integration into the CaosDB web interface and are especially useful for calling server side scripts. + +See also the :doc:`API documentation <../api/module-form_elements>` + +Examples +-------- + +Generating a generic form +^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following code snippet adds a form to the body of the HTML document. + +.. code-block:: javascript + + function my_special_submit_handler (form) { + // handle form submision + }; + const config = { + name: "my_form", + fields: [ + { type: "reference_drop_down", name: "experiment_id", label: "Experiment", query: "FIND Record Experiment", required: true }, + { type: "integer", name: "number", label: "A Number", required: true }, + { type: "date", name: "date", label: "A Date", required: false }, + { type: "text", name: "comment", label: "A Comment", required: false }, + ], + submit: my_special_submit_handler + }; + const form = form_elements.make_form(config); + $("body").append(form); + +The form has four fields: + + 1. A drop-down menu which contains all Records of type "Experiment" as options, + 2. an integer field, labeled "A Number", + 3. a date field, labeled "A Date", and + 4. a text field, labeled "A Comment". + +The first two fields are required and the form cannot be submitted without it. The latter are optional. + +On submission, the function ``my_special_submit_handler`` is being called with the form element as only parameter. + +As the generated form is a plain HTML form, the javascript form API can be used. However, there are special methods in the ``form_elements`` module e.g. :doc:`get_fields <../api/module-form_elements>` which are especially designed to interact with the forms generated by the ``make_form`` factory. + +Calling a server-side script +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you intend to call a server-side script, the config has to be changed a litte bit and the script calling is done by the ``form_elements`` module. There is no need to define the submit_hander anymore. Instead, just name the script which is to be called. + +.. code-block:: javascript + + const config = { + script: "process.py", + fields: [ + { type: "reference_drop_down", name: "experiment_id", label: "Experiment", query: "FIND Record Experiment", required: true }, + { type: "integer", name: "number", label: "A Number", required: true }, + { type: "date", name: "date", label: "A Date", required: false }, + { type: "text", name: "comment", label: "A Comment", required: false }, + ], + }; + const form = form_elements.make_form(config); + $("body").append(form); + +On submission, the form data will be send as a json file to the script and passed as the first parameter. The call would look like ``./process.py form.json`` and the file would contain, for example, + +.. code-block:: json + + { + "experiment_id": "234234", + "number": "400", + "date": "2020-12-24", + "comment": "This is a comment", + } + +For more and advanced options for the form see the :doc:`API documentation <../api/module-form_elements>` + + diff --git a/src/doc/extension/module.md b/src/doc/extension/module.md new file mode 100644 index 0000000000000000000000000000000000000000..d4f9b56db508d7c3be9dc0a52953de5c82bf310d --- /dev/null +++ b/src/doc/extension/module.md @@ -0,0 +1,72 @@ +# How to add a module to CaosDB WebUI +The CaosDB WebUI is organized in modules which can easily be added and on a module basis enabled or disabled. + +There are a few steps necessary to create a new module. + +## Create the module file +Create a new file in `src/core/js` starting with `ext_`. E.g. `ext_flight_preview.js`. This file should define one function that wraps every thing and which is enabled at the bottom of the file: + +```js +/* + * ** header with license infoc + * ... + */ + +'use strict'; + +/** + * description of the module ... + * + * @module ext_flight_preview + * @version 0.1 + * + * @requires somelibrary + * (pass the dependencies as arguments) + */ +var ext_flight_preview = function (somelibrary) { + + var init = function (toolbox) { + /* initialization of the module */ + } + + /** + * doc string + */ + var some_function = function (arg1, arg2) { + } + + /* the main function must return the initialization of the module */ + return { + init: init, + }; +//pass the dependencies as arguments here as well +}(somelibrary); + +// this will be replaced by require.js in the future. +$(document).ready(function() { + // use a variable starting with `BUILD_MODULE_` to enable your module + // the build variable has to be enabled in the `build.properties.d/` directory. + // Otherwise the module will not be activated. + if ("${BUILD_MODULE_EXT_BOTTOM_LINE}" === "ENABLED") { + caosdb_modules.register(ext_flight_preview); + } +}); +``` +## Update xml +Add a section to `src/core/xsl/main.xsl` to include your new file. + +```xsl +<xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_sss_markdown.js')"/> + </xsl:attribute> +</xsl:element> +``` + +## Add to index.html in test +If you have unittests (and you should), you need to add a line in : +`test/core/index.html`. + +## Update the changelog + +## Create a merge request \ No newline at end of file diff --git a/src/doc/extension/xslt-debugging.md b/src/doc/extension/xslt-debugging.md new file mode 100644 index 0000000000000000000000000000000000000000..80443dd8656da5645a5315f83f5ce26dfbbebce0 --- /dev/null +++ b/src/doc/extension/xslt-debugging.md @@ -0,0 +1,33 @@ +# XSLT Debugging + +The CaosDB WebUI uses [XSLT](https://en.wikipedia.org/wiki/XSLT) to transform the servers response into a web page. +In the webui-repository these XSLT stylesheets can be found in `src/core/` and `src/core/xsl`. + +The XSLT stylesheet is typically interpreted on the client side, e.g. in Mozilla Firefox. Error output of the browser regarding XSLT problems are typically hard to debug. For example, Firefox typically does not print detailed information about the location of an exception in the sourcecode. + +So what options do we have to debug xslt stylesheets? + +* So called "printf-style" debugging +* Using a different xslt processor + +I found this thread on Stack Overflow very helpful: +https://stackoverflow.com/questions/218522/tools-for-debugging-xslt + +# "printf-style" debugging + +As mentioned in the Stack Overflow thread referenced above, `<xsl:message>` can be used to output debugging messages during XSLT processing. + +# Using different XSLT processors + +## xsltproc from libxslt + +`xsltproc` is a tool from libxslt that allows transforming XML using XSLT stylesheets on the command line. It is called using: +```bash +xsltproc <stylesheet> <xmlfile> +``` + +So a possible workflow for debugging an xslt script could be: +* Save the test response from the server as `test.xml`. +* Run `make` in repository `caosdb-webui` +* Go to folder `public` in `caosdb-webui` +* Run: `xsltproc webcaosdb.xsl test.xml` diff --git a/src/doc/genindex.rst b/src/doc/genindex.rst new file mode 100644 index 0000000000000000000000000000000000000000..48ab71fd283bb48564ac30e4c69e62bbd463cd77 --- /dev/null +++ b/src/doc/genindex.rst @@ -0,0 +1,4 @@ +.. This file is a placeholder and will be replaced. + +Index +===== diff --git a/src/doc/getting_started.md b/src/doc/getting_started.md new file mode 120000 index 0000000000000000000000000000000000000000..88332e357f5e06f3de522768ccdcd9e513c15f62 --- /dev/null +++ b/src/doc/getting_started.md @@ -0,0 +1 @@ +../../README_SETUP.md \ No newline at end of file diff --git a/src/doc/index.rst b/src/doc/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..24c394349a045bf276c4252a2fde47feae6f533c --- /dev/null +++ b/src/doc/index.rst @@ -0,0 +1,26 @@ + +Welcome to the documentation of CaosDB's web UI! +================================================ + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + :hidden: + + Getting started <getting_started> + Tutorials <tutorials/index> + Concepts <concepts> + administration/index.rst + Extending the UI <extension> + API <api/index> + + +This documentation helps you to :doc:`get started<getting_started>`, explains the most important +:doc:`concepts<concepts>` and offers a range of :doc:`tutorials<tutorials>`. + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`search` diff --git a/src/doc/tutorials/first_steps.rst b/src/doc/tutorials/first_steps.rst new file mode 100644 index 0000000000000000000000000000000000000000..48126b2aab6175da557919133493585eda59bbfa --- /dev/null +++ b/src/doc/tutorials/first_steps.rst @@ -0,0 +1,87 @@ +First Steps +=========== + +Before using or even manipulating data stored in CaosDB, it is important to +understand the way data is structured. Here, we will briefly look at this +structure. You can find more details here_. In CaosDB data is stored in objects called +`Records`. A `Record` can have multiple `Properties`, like numbers, text or references +to other `Records`. `RecordTypes` are kind of blue prints for `Records` and +provide a structure to the data. Let's look at an example: + +.. image:: model.svg + +.. The image is not good yet. Children should have properties of parents. + +This illustrates a simple data model used in the `demo instance`_ provided by `IndiScale`_. +It shows that the `RecordType` Analysis has among others the `Properties` +`quality_factor`, a number, and `date`, you guessed it... a date. The `Property` +`MusicalInstrumet` illustrates that a `Record` that has `Analysis` as a parent +`RecordType` should reference a `Record` that has the `MusicalInstrumet` `RecordType` as a parent. + +We recommend that you connect to the demo instance in order to try out the following +examples (see :doc:`Getting Started secton</getting_started>`.). However, you +can also translate the examples to the data model that you have at hand. + + + +Main Menu (WIP) +--------------- + + +.. note:: + By default only 10 Entities are shown on one page. You can get to + other pages with the “Next Page” and “Previous Page” buttons. + +:math:`\Rightarrow` What are the differences between the options of the +“Entities” menu? + +Entities, Records, Properties…What? + + +- semantic data modeling + +- entries in LinkAhead are like Objects + +- RecordType: blue print for data + +- Record: actual data + + +See also the +`wiki <https://gitlab.com/caosdb/caosdb/wikis/Concepts/Data%20Model>`__ +or the `paper <https://www.mdpi.com/2306-5729/4/2/83>`__ + +|image| + +References in two directions + +- | References in LinkAhead are directed: + | A Record A references another Record B + +- The referencing Record A has a corresponding Property. + +- The referenced Record B does not. + +- In order to get referencing Records in the Web Interface, click on the following button + (or “Backref” on older systems). + +|image1| + +File System +----------- + +- Clicking on “File System” in the main menu allows you to browse files + that LinkAhead knows about. + +- Typically, most files will be mounted from some file server. + +.. note:: You will not find any Records in this view (that are not Files). + + + +.. _here: https://gitlabio.something +.. _`demo instance`: https://demo.indiscale.com +.. _`IndiScale`: https://indiscale.com +.. |image| image:: model.svg +.. |image1| image:: References_button.png + :width: 4em diff --git a/src/doc/tutorials/index.rst b/src/doc/tutorials/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..dbfeafed2a09d587ebd538a62ed9002943b52aa0 --- /dev/null +++ b/src/doc/tutorials/index.rst @@ -0,0 +1,11 @@ + +CaosDB Web Interface Tutorials +============================== + +This chapter contains the following tutorials: + +.. toctree:: + :maxdepth: 2 + :glob: + + * diff --git a/src/doc/tutorials/model.svg b/src/doc/tutorials/model.svg new file mode 100644 index 0000000000000000000000000000000000000000..2602cb43f15976305d48e6f2d5efeb3821e1d669 --- /dev/null +++ b/src/doc/tutorials/model.svg @@ -0,0 +1,632 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + contentScriptType="application/ecmascript" + contentStyleType="text/css" + height="502" + preserveAspectRatio="none" + version="1.1" + viewBox="0 0 407 502" + width="407" + zoomAndPan="magnify" + id="svg233" + sodipodi:docname="model.svg" + inkscape:version="0.92.4 5da689c313, 2019-01-14"> + <metadata + id="metadata237"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1920" + inkscape:window-height="1043" + id="namedview235" + showgrid="false" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:zoom="1.1817368" + inkscape:cx="112.55875" + inkscape:cy="257" + inkscape:window-x="1920" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:current-layer="svg233" /> + <defs + id="defs11"> + <filter + height="3" + id="f64vrt8w3qxjw" + width="3" + x="-1" + y="-1"> + <feGaussianBlur + result="blurOut" + stdDeviation="2.0" + id="feGaussianBlur2" /> + <feColorMatrix + in="blurOut" + result="blurOut2" + type="matrix" + values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .4 0" + id="feColorMatrix4" /> + <feOffset + dx="4.0" + dy="4.0" + in="blurOut2" + result="blurOut3" + id="feOffset6" /> + <feBlend + in="SourceGraphic" + in2="blurOut3" + mode="normal" + id="feBlend8" /> + </filter> + </defs> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.13385832;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="rect4867" + width="407" + height="502" + x="0" + y="0" /> + <polygon + id="polygon13" + style="fill:#dddddd;stroke:#000000;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + points="533.5,526 126.5,526 126.5,24 236.5,24 243.5,46.2969 533.5,46.2969 " + transform="translate(-126.5,-24)" /> + <line + id="line15" + y2="22.296902" + y1="22.296902" + x2="117" + x1="0" + style="stroke:#000000;stroke-width:1.5" /> + <text + style="font-weight:bold;font-size:14px;font-family:sans-serif;fill:#000000" + id="text17" + y="38.995098" + x="130.5" + textLength="104" + lengthAdjust="spacingAndGlyphs" + font-weight="bold" + font-size="14" + transform="translate(-126.5,-24)">RecordTypes</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text42" + y="144.7104" + x="461" + textLength="0" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)" /> + <rect + y="411" + x="16" + width="116" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="Manufacturer" + height="60.804699" /> + <circle + r="11" + id="ellipse47" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="427" + cx="31" /> + <path + inkscape:connector-curvature="0" + id="path49" + d="m 33.9688,432.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text51" + y="455.1543" + x="171.5" + textLength="84" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">Manufacturer</text> + <line + id="line53" + y2="443" + y1="443" + x2="131" + x1="17" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text55" + y="481.21039" + x="152.5" + textLength="0" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)" /> + <line + id="line57" + y2="463.80469" + y1="463.80469" + x2="131" + x1="17" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="235" + x="16" + width="174" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="MusicalInstrument" + height="101.6211" /> + <circle + r="11" + id="ellipse60" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="251" + cx="43.600006" /> + <path + inkscape:connector-curvature="0" + id="path62" + d="m 46.5688,256.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text64" + y="279.1543" + x="186.89999" + textLength="114" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">MusicalInstrument</text> + <line + id="line66" + y2="267" + y1="267" + x2="189" + x1="17" + style="stroke:#a80036;stroke-width:1.5" /> + <line + id="line68" + y2="281.40231" + y1="281.40231" + x2="73.5" + x1="17" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text70" + y="308.71039" + x="200" + textLength="59" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">Properties</text> + <line + id="line72" + y2="281.40231" + y1="281.40231" + x2="189" + x1="132.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text74" + y="341.2222" + x="148.5" + textLength="86" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">price (DOUBLE)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text76" + y="354.02689" + x="148.5" + textLength="162" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">Manufacturer (Manufacturer)</text> + <line + id="line78" + y2="300.60941" + y1="300.60941" + x2="62" + x1="17" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text80" + y="327.91751" + x="188.5" + textLength="82" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">recommended</text> + <line + id="line82" + y2="300.60941" + y1="300.60941" + x2="189" + x1="144" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="411" + x="167.5" + width="65" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="Violin" + height="60.804699" /> + <circle + r="11" + id="ellipse85" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="427" + cx="182.5" /> + <path + inkscape:connector-curvature="0" + id="path87" + d="m 185.4688,432.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text89" + y="455.1543" + x="323" + textLength="33" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">Violin</text> + <line + id="line91" + y2="443" + y1="443" + x2="231.5" + x1="168.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text93" + y="481.21039" + x="304" + textLength="0" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)" /> + <line + id="line95" + y2="463.80469" + y1="463.80469" + x2="231.5" + x1="168.5" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="397" + x="267.5" + width="119" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="Guitar" + height="88.816399" /> + <circle + r="11" + id="ellipse98" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="413" + cx="304.54999" /> + <path + inkscape:connector-curvature="0" + id="path100" + d="m 307.5188,418.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text102" + y="441.1543" + x="449.95001" + textLength="38" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">Guitar</text> + <line + id="line104" + y2="429" + y1="429" + x2="385.5" + x1="268.5" + style="stroke:#a80036;stroke-width:1.5" /> + <line + id="line106" + y2="443.40231" + y1="443.40231" + x2="297.5" + x1="268.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text108" + y="470.71039" + x="424" + textLength="59" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">Properties</text> + <line + id="line110" + y2="443.40231" + y1="443.40231" + x2="385.5" + x1="356.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text112" + y="503.2222" + x="400" + textLength="107" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">electric (BOOLEAN)</text> + <line + id="line114" + y2="462.60941" + y1="462.60941" + x2="286" + x1="268.5" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text116" + y="489.91751" + x="412.5" + textLength="82" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">recommended</text> + <line + id="line118" + y2="462.60941" + y1="462.60941" + x2="385.5" + x1="368" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="255.5" + x="225.5" + width="165" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="SoundQualityAnalyzer" + height="60.804699" /> + <circle + r="11" + id="ellipse121" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="271.5" + cx="240.5" /> + <path + inkscape:connector-curvature="0" + id="path123" + d="m 243.4688,277.1406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text125" + y="299.6543" + x="381" + textLength="133" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">SoundQualityAnalyzer</text> + <line + id="line127" + y2="287.5" + y1="287.5" + x2="389.5" + x1="226.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text129" + y="325.71039" + x="362" + textLength="0" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)" /> + <line + id="line131" + y2="308.30469" + y1="308.30469" + x2="389.5" + x1="226.5" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <rect + y="35" + x="20" + width="268" + style="fill:#fefece;stroke:#a80036;stroke-width:1.5;filter:url(#f64vrt8w3qxjw)" + id="Analysis" + height="140.0352" /> + <circle + r="11" + id="ellipse134" + style="fill:#ff1111;stroke:#a80036;stroke-width:1" + cy="51" + cx="124.75" /> + <path + inkscape:connector-curvature="0" + id="path136" + d="m 127.7188,56.6406 q -0.5782,0.2969 -1.2188,0.4375 -0.6406,0.1563 -1.3437,0.1563 -2.5,0 -3.8282,-1.6406 -1.3125,-1.6563 -1.3125,-4.7813 0,-3.125 1.3125,-4.7812 1.3282,-1.6563 3.8282,-1.6563 0.7031,0 1.3437,0.1563 0.6563,0.1562 1.2188,0.4531 v 2.7187 q -0.625,-0.5781 -1.2188,-0.8437 -0.5937,-0.2813 -1.2187,-0.2813 -1.3438,0 -2.0313,1.0782 -0.6875,1.0625 -0.6875,3.1562 0,2.0938 0.6875,3.1719 0.6875,1.0625 2.0313,1.0625 0.625,0 1.2187,-0.2656 0.5938,-0.2813 1.2188,-0.8594 z" /> + <text + style="font-size:12px;font-family:sans-serif;fill:#000000" + id="text138" + y="79.154297" + x="271.75" + textLength="50" + lengthAdjust="spacingAndGlyphs" + font-size="12" + transform="translate(-126.5,-24)">Analysis</text> + <line + id="line140" + y2="67" + y1="67" + x2="287" + x1="21" + style="stroke:#a80036;stroke-width:1.5" /> + <line + id="line142" + y2="81.402298" + y1="81.402298" + x2="124.5" + x1="21" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text144" + y="108.7104" + x="251" + textLength="59" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">Properties</text> + <line + id="line146" + y2="81.402298" + y1="81.402298" + x2="287" + x1="183.5" + style="stroke:#a80036;stroke-width:1.5" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text148" + y="141.2222" + x="152.5" + textLength="134" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">quality_factor (DOUBLE)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text150" + y="154.0269" + x="152.5" + textLength="92" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">date (DATETIME)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text152" + y="166.8315" + x="152.5" + textLength="111" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">report (REFERENCE)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text154" + y="179.6362" + x="152.5" + textLength="256" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">SoundQualityAnalyzer (SoundQualityAnalyzer)</text> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text156" + y="192.4409" + x="152.5" + textLength="220" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">MusicalInstrument (MusicalInstrument)</text> + <line + id="line158" + y2="100.6094" + y1="100.6094" + x2="113" + x1="21" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <text + style="font-size:11px;font-family:sans-serif;fill:#000000" + id="text160" + y="127.9175" + x="239.5" + textLength="82" + lengthAdjust="spacingAndGlyphs" + font-size="11" + transform="translate(-126.5,-24)">recommended</text> + <line + id="line162" + y2="100.6094" + y1="100.6094" + x2="287" + x1="195" + style="stroke:#a80036;stroke-width:1;stroke-dasharray:1, 2" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="MusicalInstrument-Violin" + d="m 145.51,354.27 c 12.48,19.76 25.51,40.37 35.69,56.48" /> + <polygon + id="polygon211" + style="fill:none;stroke:#a80036;stroke-width:1" + points="261.26,361.26 277.86,374.43 266.03,381.91 " + transform="translate(-126.5,-24)" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="MusicalInstrument-Guitar" + d="m 192.64,348.42 c 25.04,17.17 51.64,35.39 74.51,51.06" /> + <polygon + id="polygon214" + style="fill:none;stroke:#a80036;stroke-width:1" + points="302.54,361.05 322.99,366.58 315.08,378.13 " + transform="translate(-126.5,-24)" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="MusicalInstrument-Manufacturer" + d="m 91.08,350.09 c -3.97,21 -8.2,43.41 -11.46,60.66" /> + <polygon + id="polygon217" + style="fill:#a80036;stroke:#a80036;stroke-width:1" + points="220,361.26 214.9551,366.4126 217.771,373.0512 222.8159,367.8986 " + transform="translate(-126.5,-24)" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="Analysis-SoundQualityAnalyzer" + d="m 222.3,185.39 c 21.35,24.82 43.61,50.69 60.09,69.84" /> + <polygon + id="polygon220" + style="fill:#a80036;stroke:#a80036;stroke-width:1" + points="340.04,199.21 340.9231,206.3668 347.869,208.3043 346.9859,201.1475 " + transform="translate(-126.5,-24)" /> + <path + inkscape:connector-curvature="0" + style="fill:none;stroke:#a80036;stroke-width:1" + id="Analysis-MusicalInstrument" + d="m 130.66,187.93 c -4.56,16 -9.21,32.33 -13.37,46.92" /> + <polygon + id="polygon223" + style="fill:#a80036;stroke:#a80036;stroke-width:1" + points="260.78,199.21 255.287,203.8819 257.4868,210.7493 262.9798,206.0774 " + transform="translate(-126.5,-24)" /> +</svg> diff --git a/src/doc/tutorials/query.rst b/src/doc/tutorials/query.rst new file mode 100644 index 0000000000000000000000000000000000000000..29d998cc1dbce5c5e17ae4cf9f60176b9f88861a --- /dev/null +++ b/src/doc/tutorials/query.rst @@ -0,0 +1,143 @@ + +Querying CaosDB +=============== + +You should have the web interface of a CaosDB instance at hand. If you do not +have one, you can visit https://demo.indiscale.com + +Introduction +------------ + +The semantic data model of CaosDB allows efficient data access. The +CaosDB Query Language (CQL) is used to search data. Queries can be entered in +the webinterface under the respective menu entry. + +Let's start with a simple one:: + + FIND RECORD MusicalInstrument + +Most queries simply start with the ``FIND`` keyword and describe what we are +looking for behind that. The ``RECORD`` keyword denotes that we are only looking +for Records (and not Files, Properties or RecordTypes). Finally, we provided +a RecordType name: MusicalInstrument. This means that we will get all Records +that have this RecordType as parent. Try it out! + +Let's look at:: + + FIND Guitar + +When we leave out the ``RECORD`` keyword, we will get every entity that is a +Guitar. When you submit this query you should find also a RecordType Guitar +in the results. Using ``FIND RecordType Guitar`` would restrict the result to +only that RecordType. + +Note, that you cannot only provide RecordType names after the ``FIND``, but names +in general: ``FIND RECORD Nice Guitar``. This will give you a Record with the +name "Nice Guitar" (if one exists... and there should be one in the demo instance). + +While it does not matter whether you use capital letters or not, the names have to +be exact. There are two features that make it easy to use names for querying +in spite of this: +- You can use "*" to match any string. E.g. ``FIND RECORD Nice*`` +- After typing three letters, names that start with those three are +suggested by the auto completion. + +.. note:: + + Train yourself by trying to guess what the result will be before + actually executing the query. + + +Searching Data Using Properties +-------------------------------- + +Looking for entities with certain names or such that have certain parents is +nice. However, the queries become really useful if we can impose further conditions +on the results. Let's start with an example again:: + + FIND Guitar with price > 10000 + +This should list expensive guitars where are in the demo instance. Thus, +we are using a property (the price) of the Guitar Records to restrict the +result set. In general this looks like:: + + FIND <Name> <Property Filter> + +Typically, the filter has the form ``<Property> <Operator> <Value>``, +for example ``length >= 0.7mm``. +There are many filters available. You can check the specification for a comprehensive description of +those. Here, we will only look at the most common examples. + + +If you only want to assure that Records have a certain Property, without imposing +constrains on the value, you can use:: + + FIND RECORD MusicalInstrument WITH Manufacturer + + +Similarly, to what we saw above when using incomplete names, you can use a "*" +to match parts of text properties:: + + FIND RECORD WITH serialNumber like KN* + +There is large number of operators that can be used together with dates or +timestamps. One of the most useful is probably:: + + FIND RECORD WITH date in 2019 + +A lot of valuable information is often stored in the relations among data, i.e. in +the references of entities. So how can we use those?:: + + FIND RECORD WHICH REFERENCES A Guitar + +This should be pretty self explanatory. And it is also possible to check for +references in the other direction:: + + FIND RECORD WHICH IS REFERENCED BY A Analysis + +You can also simply provide the ID of the entity:: + + FIND RECORD WHICH IS REFERENCED BY 123`` + + +Using Multiple Filters +---------------------- + +Often, one condition is not sufficient. Thus multiple filters/conditions can be combined. +This can for example be done using the following structure:: + + FIND <Name> <Property Filter> (AND|OR) <Property Filter> + +An example would be:: + + FIND Guitar WITH price>48 AND electric=TRUE + +Furthermore, reference conditions can be nested:: + + FIND <Name> WHICH REFERENCES <Name> WHICH REFERENCES <Name> + + +For example:: + + FIND Manufacturer WHICH IS REFERENCED BY Guitar WHICH IS REFERENCED BY Analysis + + +Restricting Result Information +------------------------------ + +Using ``COUNT`` instead of ``FIND`` will only return the number of +entities in the result set. + +.. note:: This is often useful when experimenting with queries. + +Using ``SELECT ... FROM`` instead of ``FIND`` returns specific +information in a table. A comma separated list of Property names can be provided behind the +``SELECT`` keyword:: + + SELECT price, electric FROM Guitar + +Or:: + + SELECT quality_factor, report, date FROM Analysis WHICH REFERENCES A Guitar WITH electric=TRUE + + diff --git a/src/ext/js/fileupload.js b/src/ext/js/fileupload.js index fac11efadc3d1b6d364333d68b27904c37c3aa42..1c069f2a8fdded69a2b7cc93f27601226e1d149c 100644 --- a/src/ext/js/fileupload.js +++ b/src/ext/js/fileupload.js @@ -93,7 +93,7 @@ var fileupload = new function() { formData.append("FileRepresentation", request); - // add the success and error handlers which put the + // add the success and error handlers xhr.addEventListener("load", success_handler); xhr.addEventListener("error", error_handler); }); @@ -235,8 +235,6 @@ var fileupload = new function() { } this.init = function() { - fileupload.debug("init"); - // add global listener for start_edit event document.body.addEventListener(edit_mode.start_edit.type, function(e) { $(e.target).find(".caosdb-properties .caosdb-f-entity-property").each(function(idx) { 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 8d8a583cce9f2c429681dc99a8010c8b7b72d7c4..1e1db1095bc0d17decbd24895b6badb286d7e5b3 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,12 +68,14 @@ <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_entity_action_panel.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> @@ -82,6 +87,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> @@ -90,8 +96,9 @@ <script src="js/modules/ext_bottom_line.js.js"></script> <script src="js/modules/ext_entity_action_panel.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/caosdb.js.js b/test/core/js/modules/caosdb.js.js index 76141117ffc845917c0e5ff50a165e7718663a31..20dc4b4eee0116fecf57b0a178f622c4385f08b2 100644 --- a/test/core/js/modules/caosdb.js.js +++ b/test/core/js/modules/caosdb.js.js @@ -17,7 +17,7 @@ QUnit.module("caosdb.js", { }, err => {console.log(err);}); }, - + before: function(assert) { var done = assert.async(3); this.setTestDocument("x", done, ` @@ -474,3 +474,110 @@ QUnit.test("unset_entity_references", function(assert) { assert.equal(getProperties(r)[0].reference, true); } }); + + +QUnit.test("_constructXpaths", function (assert) { + assert.propEqual( + _constructXpaths([["id"], ["longitude"], ["latitude"]]), + ["@id", "Property[@name='longitude']", "Property[@name='latitude']"] + ); + assert.propEqual( + _constructXpaths([["Geo Location", "longitude"], ["latitude"]]), + ["Property[@name='Geo Location']//Property[@name='longitude']", "Property[@name='latitude']"] + ); + assert.propEqual( + _constructXpaths([["", "longitude"], ["latitude"]]), + ["Property//Property[@name='longitude']", "Property[@name='latitude']"] + ); + assert.propEqual( + _constructXpaths([["", "Geo Location", "", "longitude"]]), + ["Property//Property[@name='Geo Location']//Property//Property[@name='longitude']"] + ); +}); + + +QUnit.test("getPropertyValues", function (assert) { + const test_response = str2xml(` +<Response srid="851d063d-121b-4b67-98d4-6e5ad4d31e72" timestamp="1606214042274" baseuri="https://localhost:10443" count="8"> + <Query string="select Campaign.responsible.firstname from icecore" results="8"> + <ParseTree>(cq select (prop_sel (prop_subsel (selector_txt C a m p a i g n) . (prop_subsel (selector_txt r e s p o n s i b l e) . (prop_subsel (selector_txt f i r s t n a m e ))))) from (entity icecore) <EOF>)</ParseTree> + <Role/> + <Entity>icecore</Entity> + <Selection> + <Selector name="Campaign.responsible.firstname"/> + </Selection> + </Query> + <Record id="6525" name="Test_IceCore_1"> + <Permissions/> + <Property datatype="Campaign" id="6430" name="Campaign"> + <Record id="6516" name="Test-2020_Camp1"> + <Permissions/> + <Property datatype="REFERENCE" id="168" name="responsible"> + <Record id="6515" name="Test_Scientist"> + <Permissions/> + <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX"> + 1.34 + <Permissions/> + </Property> + <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX"> + 2 + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Record id="6526" name="Test_IceCore_2"> + <Permissions/> + <Property datatype="Campaign" id="6430" name="Campaign"> + <Record id="6516" name="Test-2020_Camp1"> + <Permissions/> + <Property datatype="REFERENCE" id="168" name="responsible"> + <Record id="6515" name="Test_Scientist"> + <Permissions/> + <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX"> + 3 + <Permissions/> + </Property> + <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX"> + 4.8345 + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> +</Response>`); + + assert.propEqual( + getPropertyValues(test_response, [["id"], ["", "latitude"],["", "longitude"]]), + [["6525" ,"1.34", "2"], ["6526", "3", "4.8345"]]); +}); + +// Test for bug 103 +// If role is File when creating XML for entities, checksum, path and size must be given. +QUnit.test("unset_file_attributes", function(assert) { + // This should run: + var res1 = createEntityXML("Record", "test", 103, {}, {}); + assert.equal(xml2str(res1), "<Record id=\"103\" name=\"test\"/>"); + // This must throw an exception: + assert.throws(function () { + createEntityXML("File", "test", 103, {}, {}); + }); + // This should produce a valid XML. + var res2 = createEntityXML("File", "test", 103, {}, {}, + false, undefined, undefined, undefined, + "testfile.txt", "blablabla", 0); + assert.equal(xml2str(res2), "<File id=\"103\" name=\"test\" path=\"testfile.txt\" checksum=\"blablabla\" size=\"0\"/>"); + + var res3 = createFileXML("test", 103, {}, + "testfile.txt", "blablabla", 0, + undefined); + assert.equal(xml2str(res3), "<File id=\"103\" name=\"test\" path=\"testfile.txt\" checksum=\"blablabla\" size=\"0\"/>"); +}); diff --git a/test/core/js/modules/edit_mode.js.js b/test/core/js/modules/edit_mode.js.js index ae11b04a380f162018de70a53409e34b4e6990c6..2d7a605fd29788d189d2af0d828266e7e3a35f84 100644 --- a/test/core/js/modules/edit_mode.js.js +++ b/test/core/js/modules/edit_mode.js.js @@ -255,7 +255,7 @@ QUnit.test("make_datatype_input", function (assert) { const no_dt_input = edit_mode.make_datatype_input(undefined); no_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(no_dt_input)[0]); + .form_to_object($(form_wrapper).append(no_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "TEXT", "reference_scope": null, @@ -266,7 +266,7 @@ QUnit.test("make_datatype_input", function (assert) { const text_dt_input = edit_mode.make_datatype_input("TEXT"); text_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(text_dt_input)[0]); + .form_to_object($(form_wrapper).append(text_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "TEXT", "reference_scope": null, @@ -277,7 +277,7 @@ QUnit.test("make_datatype_input", function (assert) { const ref_dt_input = edit_mode.make_datatype_input("REFERENCE"); ref_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(ref_dt_input)[0]); + .form_to_object($(form_wrapper).append(ref_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "REFERENCE", "reference_scope": null, @@ -288,7 +288,7 @@ QUnit.test("make_datatype_input", function (assert) { const file_dt_input = edit_mode.make_datatype_input("FILE"); file_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(file_dt_input)[0]); + .form_to_object($(form_wrapper).append(file_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "FILE", "reference_scope": null, @@ -299,7 +299,7 @@ QUnit.test("make_datatype_input", function (assert) { const person_dt_input = edit_mode.make_datatype_input("Person"); person_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(person_dt_input)[0]); + .form_to_object($(form_wrapper).append(person_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "REFERENCE", "reference_scope": "Person", @@ -310,7 +310,7 @@ QUnit.test("make_datatype_input", function (assert) { const list_text_dt_input = edit_mode.make_datatype_input("LIST<TEXT>"); list_text_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(list_text_dt_input)[0]); + .form_to_object($(form_wrapper).append(list_text_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "TEXT", "reference_scope": null, @@ -322,7 +322,7 @@ QUnit.test("make_datatype_input", function (assert) { const list_ref_dt_input = edit_mode.make_datatype_input("LIST<REFERENCE>"); list_ref_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(list_ref_dt_input)[0]); + .form_to_object($(form_wrapper).append(list_ref_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "REFERENCE", "reference_scope": null, @@ -334,7 +334,7 @@ QUnit.test("make_datatype_input", function (assert) { const list_file_dt_input = edit_mode.make_datatype_input("LIST<FILE>"); list_file_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(list_file_dt_input)[0]); + .form_to_object($(form_wrapper).append(list_file_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "FILE", "reference_scope": null, @@ -346,7 +346,7 @@ QUnit.test("make_datatype_input", function (assert) { const list_per_dt_input = edit_mode.make_datatype_input("LIST<Person>"); list_per_dt_input.addEventListener("caosdb.field.ready", function (e) { var obj = form_elements - .form_to_object($(form_wrapper).append(list_per_dt_input)[0]); + .form_to_object($(form_wrapper).append(list_per_dt_input)[0])[0]; assert.propEqual(obj, { "atomic_datatype": "REFERENCE", "reference_scope": "Person", @@ -486,9 +486,6 @@ QUnit.test("remove_delete_button", function (assert) { { - const sleep = function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } const datamodel = ` <div><div class=\"btn-group-vertical\"><button type=\"button\" class=\"btn btn-default caosdb-f-edit-panel-new-button new-property\">Create new Property</button><button type=\"button\" class=\"btn btn-default caosdb-f-edit-panel-new-button new-recordtype\">Create new RecordType</button></div><div title=\"Drag and drop Properties from this panel to the Entities on the left.\" class=\"panel panel-default\"><div class=\"panel-heading\"><h5>Existing Properties</h5></div><div class=\"panel-body\"><div class=\"input-group\" style=\"width: 100%;\"><input class=\"form-control\" placeholder=\"filter...\" title=\"Type a name (full or partial).\" oninput=\"edit_mode.filter('properties');\" id=\"caosdb-f-filter-properties\" type=\"text\" /><span class=\"input-group-btn\"><button class=\"btn btn-default caosdb-f-edit-panel-new-button new-property caosdb-f-hide-on-empty-input\" title=\"Create this Property.\"><span class=\"glyphicon glyphicon-plus\"></span></button></span></div><ul class=\"caosdb-v-edit-list\"><li class=\"caosdb-f-edit-drag list-group-item caosdb-v-edit-drag\" id=\"caosdb-f-edit-p-20\">name</li><li class=\"caosdb-f-edit-drag list-group-item caosdb-v-edit-drag\" id=\"caosdb-f-edit-p-21\">unit</li><li class=\"caosdb-f-edit-drag list-group-item caosdb-v-edit-drag\" id=\"caosdb-f-edit-p-24\">description</li></ul></div></div><div title=\"Drag and drop RecordTypes from this panel to the Entities on the left.\" class=\"panel panel-default\"><div class=\"panel-heading\"><h5>Existing RecordTypes</h5></div><div class=\"panel-body\"><div class=\"input-group\" style=\"width: 100%;\"><input class=\"form-control\" placeholder=\"filter...\" title=\"Type a name (full or partial).\" oninput=\"edit_mode.filter('recordtypes');\" id=\"caosdb-f-filter-recordtypes\" type=\"text\" /><span class=\"input-group-btn\"><button class=\"btn btn-default caosdb-f-edit-panel-new-button new-recordtype caosdb-f-hide-on-empty-input\" title=\"Create this RecordType\"><span class=\"glyphicon glyphicon-plus\"></span></button></span></div><ul class=\"caosdb-v-edit-list\"><li class=\"caosdb-f-edit-drag list-group-item caosdb-v-edit-drag\" id=\"caosdb-f-edit-rt-30992\">Test</li><li class=\"caosdb-f-edit-drag list-group-item caosdb-v-edit-drag\" id=\"caosdb-f-edit-rt-31015\">Test2</li></ul></div></div></div>`; diff --git a/test/core/js/modules/entity.xsl.js b/test/core/js/modules/entity.xsl.js index c607ee28bf7888f94e3086abf8e033e1532d0d09..59a2f8a6f9a03cf4990fe11bfd751d437a3ffc3e 100644 --- a/test/core/js/modules/entity.xsl.js +++ b/test/core/js/modules/entity.xsl.js @@ -264,6 +264,67 @@ 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("version full history", function (assert) { + var xmlstr = ` + <Response username="user1" realm="Realm1" srid="31ce8ea1-6c9b-4a82-82ec-9f6f3edd2622" timestamp="1606225647516" baseuri="https://localhost:10443" count="1"> + <Record id="3373" name="TestRecord1-10thVersion" description="This is the 10th version."> + <Permissions> + <Permission name="RETRIEVE:HISTORY" /> + </Permissions> + <Version id="vid6" username="user1" realm="Realm1" date="date6" completeHistory="true"> + <Predecessor id="vid5" username="user1" realm="Realm1" date="date5"> + <Predecessor id="vid4" username="user1" realm="Realm1" date="date4"> + <Predecessor id="vid3" username="user1" realm="Realm1" date="date3"> + <Predecessor id="vid2" username="user1" realm="Realm1" date="date2"> + <Predecessor id="vid1" username="user1" realm="Realm1" date="date1" /> + </Predecessor> + </Predecessor> + </Predecessor> + </Predecessor> + <Successor id="vid7" username="user1" realm="Realm1" date="date7"> + <Successor id="vid8" username="user1" realm="Realm1" date="date8"> + <Successor id="vid9" username="user1" realm="Realm1" date="date9"> + <Successor id="vid10" username="user1" realm="Realm1" date="date10" /> + </Successor> + </Successor> + </Successor> + </Version> + <Parent id="3372" name="TestRT" /> + </Record> +</Response> +`; + var xml = str2xml(xmlstr); + var html = applyTemplates(xml, this.entityXSL, "entities", "*"); + var version_info = $(html).find(".caosdb-f-entity-version-info"); + var table_elem = $(version_info).find("table"); + + var TAB = "%09", NEWL = "%0A", usr = "user1@Realm1", + path = "/entitypath/3373"; + var export_table = `data:text/csv;charset=utf-8,Entity ID${TAB}Version ID${TAB}Date${TAB}User${TAB}URI${NEWL}3373${TAB}vid10${TAB}date10${TAB}${usr}${TAB}${path}@vid10${NEWL}3373${TAB}vid9${TAB}date9${TAB}${usr}${TAB}${path}@vid9${NEWL}3373${TAB}vid8${TAB}date8${TAB}${usr}${TAB}${path}@vid8${NEWL}3373${TAB}vid7${TAB}date7${TAB}${usr}${TAB}${path}@vid7${NEWL}3373${TAB}vid6${TAB}date6${TAB}${usr}${TAB}${path}@vid6${NEWL}3373${TAB}vid5${TAB}date5${TAB}${usr}${TAB}${path}@vid5${NEWL}3373${TAB}vid4${TAB}date4${TAB}${usr}${TAB}${path}@vid4${NEWL}3373${TAB}vid3${TAB}date3${TAB}${usr}${TAB}${path}@vid3${NEWL}3373${TAB}vid2${TAB}date2${TAB}${usr}${TAB}${path}@vid2${NEWL}3373${TAB}vid1${TAB}date1${TAB}${usr}${TAB}${path}@vid1` + assert.equal(version_info.length, 1); + assert.equal(table_elem.length, 1); + assert.equal(version_history.get_history_tsv(table_elem[0]), export_table); +}); + +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..831df74231e479d5b4550524f6bc0da617c98fb3 --- /dev/null +++ b/test/core/js/modules/ext_bookmarks.js.js @@ -0,0 +1,181 @@ +/* + * ** 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]}"><Version id="abcHead"/></File></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${TAB}Name${TAB}RecordType${NEWL}123${TAB}ver1${TAB}${context_root}123@ver1${TAB}testpath_123@ver1${TAB}${TAB}${NEWL}456${TAB}ver2${TAB}${context_root}456@ver2${TAB}testpath_456@ver2${TAB}${TAB}${NEWL}789${TAB}ver3${TAB}${context_root}789@ver3${TAB}testpath_789@ver3${TAB}${TAB}${NEWL}101112${TAB}abcHead${TAB}${context_root}101112@abcHead${TAB}testpath_101112${TAB}${TAB}${NEWL}${TAB}131415${TAB}${context_root}@131415${TAB}testpath_@131415${TAB}${TAB}`); + +}); + +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 b7e825f7b469c12a58b73ecdb1ecd126390f9c98..54e76ae0861550a71c80d7f321af1053c9a82d25 100644 --- a/test/core/js/modules/ext_bottom_line.js.js +++ b/test/core/js/modules/ext_bottom_line.js.js @@ -24,10 +24,6 @@ var ext_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) { - const sleep = (ms) => { - return new Promise(res => setTimeout(res, ms)) - } - var test_config = { "version": 0.1, "fallback": "blablabla", "creators": [ @@ -45,7 +41,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}); }" } ] }; @@ -67,7 +63,7 @@ var ext_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) { }); QUnit.test("_creators", function (assert) { - assert.equal(ext_bottom_line._creators.length, 7, "seven creators"); + assert.equal(ext_bottom_line._creators.length, 9, "nine creators, 5 default ones, 4 from these tests."); }); QUnit.test("add_preview_container", function(assert) { @@ -121,4 +117,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_map.js.js b/test/core/js/modules/ext_map.js.js index 9d068ad1c688c7a8643c18846103ea33a10c8874..5afe434e375b40e3f141141b120ed5497939211f 100644 --- a/test/core/js/modules/ext_map.js.js +++ b/test/core/js/modules/ext_map.js.js @@ -23,10 +23,13 @@ 'use strict'; QUnit.module("ext_map.js", { - before: function(assert) { + before: function (assert) { var lat = "latitude"; var lng = "longitude"; - this.datamodel = { lat: lat, lng: lng }; + this.datamodel = { + lat: lat, + lng: lng + }; this.test_map_entity = ` <div class="caosdb-entity-panel caosdb-properties"> <div class="caosdb-id">1234</div> @@ -42,22 +45,22 @@ QUnit.module("ext_map.js", { </div> </div>`; }, - beforeEach: function(assert) { + beforeEach: function (assert) { sessionStorage.removeItem("caosdb_map.view"); } }); -QUnit.test("availability", function(assert) { - assert.equal(caosdb_map.version, "0.3", "test version"); +QUnit.test("availability", function (assert) { + assert.equal(caosdb_map.version, "0.4", "test version"); assert.ok(caosdb_map.init, "init available"); }); -QUnit.test("default config", function(assert) { +QUnit.test("default config", function (assert) { assert.ok(caosdb_map._default_config); assert.equal(caosdb_map._default_config.version, caosdb_map.version, "version"); }); -QUnit.test("load_config", async function(assert) { +QUnit.test("load_config", async function (assert) { assert.ok(caosdb_map.load_config, "available"); var config = await caosdb_map.load_config("non_existing.json"); assert.ok(config, "returns something"); @@ -65,39 +68,43 @@ QUnit.test("load_config", async function(assert) { assert.equal(config.views[0].id, "UNCONFIGURED", "view has id 'UNCONFIGURED'."); }); -QUnit.test("check_config", function(assert) { +QUnit.test("check_config", function (assert) { assert.ok(caosdb_map.check_config(caosdb_map._default_config), "default config ok"); - assert.throws(()=>caosdb_map.check_config({"version": "wrong version",}), /The version of the configuration does not match the version of this implementation of the caosdb_map module. Should be '.*', was 'wrong version'./, "wrong version"); + assert.throws(() => caosdb_map.check_config({ + "version": "wrong version", + }), /The version of the configuration does not match the version of this implementation of the caosdb_map module. Should be '.*', was 'wrong version'./, "wrong version"); }); -QUnit.test("check dependencies", function(assert) { +QUnit.test("check dependencies", function (assert) { assert.ok(caosdb_map.check_dependencies, "available"); - assert.propEqual(caosdb_map.dependencies, ["log", {"L": ["latlngGraticule", "Proj"]}, "navbar", "caosdb_utils"]); + assert.propEqual(caosdb_map.dependencies, ["log", { + "L": ["latlngGraticule", "Proj"] + }, "navbar", "caosdb_utils"]); assert.ok(caosdb_map.check_dependencies(), "deps available"); }); -QUnit.test("create_toggle_map_button", function(assert) { +QUnit.test("create_toggle_map_button", function (assert) { assert.ok(caosdb_map.create_toggle_map_button, "available"); var button = caosdb_map.create_toggle_map_button(); assert.equal(button.tagName, "BUTTON", "is button"); - assert.ok($(button).hasClass("caosdb-f-toggle-map-button"),"has caosdb-f-toggle-map-button class"); + assert.ok($(button).hasClass("caosdb-f-toggle-map-button"), "has caosdb-f-toggle-map-button class"); assert.equal($(button).text(), "Map", "button says 'Map'"); // set other content: button = caosdb_map.create_toggle_map_button("Karte"); assert.equal(button.tagName, "BUTTON", "is button"); - assert.ok($(button).hasClass("caosdb-f-toggle-map-button"),"has caosdb-f-toggle-map-button class"); + assert.ok($(button).hasClass("caosdb-f-toggle-map-button"), "has caosdb-f-toggle-map-button class"); assert.equal($(button).text(), "Karte", "button says 'Karte'"); }); -QUnit.test("bind_toggle_map", function(assert) { +QUnit.test("bind_toggle_map", function (assert) { let button = $("<button/>")[0]; let done = assert.async(); assert.ok(caosdb_map.bind_toggle_map, "available"); - assert.throws(()=>caosdb_map.bind_toggle_map(button), /parameter 'toggle_cb'.* was undefined/, "no function throws"); - assert.throws(()=>caosdb_map.bind_toggle_map("test", ()=>{}), /parameter 'button'.* was string/, "string button throws"); + assert.throws(() => caosdb_map.bind_toggle_map(button), /parameter 'toggle_cb'.* was undefined/, "no function throws"); + assert.throws(() => caosdb_map.bind_toggle_map("test", () => {}), /parameter 'button'.* was string/, "string button throws"); assert.equal(caosdb_map.bind_toggle_map(button, done), button, "call returns button"); // button click calls 'done' @@ -105,12 +112,12 @@ QUnit.test("bind_toggle_map", function(assert) { }); -QUnit.test("create_map", function(assert) { +QUnit.test("create_map", function (assert) { assert.equal(typeof caosdb_map.create_map_view, "function", "function available"); }); -QUnit.test("create_map_panel", function(assert) { +QUnit.test("create_map_panel", function (assert) { assert.ok(caosdb_map.create_map_panel, "available"); let panel = caosdb_map.create_map_panel(); assert.equal(panel.tagName, "DIV", "is div"); @@ -118,9 +125,11 @@ QUnit.test("create_map_panel", function(assert) { assert.ok($(panel).hasClass("container"), "has class container"); }); -QUnit.test("create_map_view", function(assert) { - var view_config = $.extend(true, {}, caosdb_map._unconfigured_views[0], - {"select": true, "view_change": true}); +QUnit.test("create_map_view", function (assert) { + var view_config = $.extend(true, {}, caosdb_map._unconfigured_views[0], { + "select": true, + "view_change": true + }); var map_panel = $("<div/>"); var map = caosdb_map.create_map_view(map_panel[0], view_config); @@ -144,7 +153,7 @@ QUnit.test("create_map_view", function(assert) { map.remove(); // test with special crs: - view_config["crs"] = { + view_config["crs"] = { "code": "EPSG:3995", "proj4def": "+proj=stere +lat_0=90 +lat_ts=71 +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs", "options": { @@ -163,7 +172,7 @@ QUnit.test("create_map_view", function(assert) { }); -QUnit.test("get_map_entities", function(assert) { +QUnit.test("get_map_entities", function (assert) { var datamodel = this.datamodel; var container = $('<div/>').append(this.test_map_entity); var map_objects = caosdb_map.get_map_entities(container[0], datamodel); @@ -171,12 +180,12 @@ QUnit.test("get_map_entities", function(assert) { }); -QUnit.test("create_entitiy_markers", function(assert) { +QUnit.test("create_entity_markers", function (assert) { var datamodel = this.datamodel; var entities = $(this.test_map_entity).toArray(); // w/o popup - var markers = caosdb_map.create_entitiy_markers(entities, datamodel); + var markers = caosdb_map.create_entity_markers(entities, datamodel); assert.equal(markers.length, 1, "has one marker"); assert.ok(markers[0] instanceof L.Marker, "is marker"); var latlng = markers[0]._latlng; @@ -185,30 +194,32 @@ QUnit.test("create_entitiy_markers", function(assert) { assert.notOk(markers[0].getPopup(), "no popup"); // with popup - var markers = caosdb_map.create_entitiy_markers(entities, datamodel, ()=>"popup"); + var markers = caosdb_map.create_entity_markers(entities, datamodel, () => "popup"); assert.ok(markers[0].getPopup(), "has popup"); }); -QUnit.test("_add_current_page_entities", function(assert) { +QUnit.test("_add_current_page_entities", async function (assert) { var datamodel = this.datamodel; var layerGroup = L.layerGroup(); var container = $('<div class="caosdb-f-main-entities"/>').append(this.test_map_entity); $("body").append(container); assert.equal(layerGroup.getLayers().length, 0, "no layer"); - var cpe = caosdb_map._get_current_page_entities(datamodel, undefined, undefined, undefined, undefined); + var cpe = await caosdb_map._generic_get_current_page_entities(datamodel, undefined, undefined, undefined, undefined, undefined); assert.equal(cpe.length, 1, "has one entity"); container.remove(); }); -QUnit.test("make_layer_chooser_html", function(assert) { - var test_conf = { "id": "test_id", +QUnit.test("make_layer_chooser_html", function (assert) { + var test_conf = { + "id": "test_id", "name": "test name", "description": "test description", - "icon": { "html": "<span>ICON</span>", + "icon": { + "html": "<span>ICON</span>", }, }; @@ -217,19 +228,139 @@ QUnit.test("make_layer_chooser_html", function(assert) { assert.equal($(layer_chooser).attr("title"), "test description", "description set as title"); }); -QUnit.test("init_entity_layer", function(assert) { - var done = assert.async(); - var test_conf = { "id": "test_id", +QUnit.test("_init_single_entity_layer", function (assert) { + var test_conf = { + "id": "test_id", "name": "test name", "description": "test description", - "get_entities": async function() {done(); return []}, - "icon": { "html": "<span>ICON</span>", + "icon": { + "html": "<span>ICON</span>", }, } - var entityLayer= caosdb_map.init_entity_layer(test_conf); + var entityLayer = caosdb_map._init_single_entity_layer(test_conf); assert.equal(entityLayer.id, test_conf.id, "id"); assert.equal(entityLayer.active, true, "is active"); assert.ok(entityLayer.chooser_html instanceof HTMLElement, "chooser_html is HTMLElement"); - assert.equal(entityLayer.layer_group.getLayers().length, 0 , "empty layergroup"); + assert.equal(entityLayer.layer_group.getLayers().length, 0, "empty layergroup"); +}); + +QUnit.test("_get_with_POV ", function (assert) { + assert.equal(caosdb_map._get_with_POV( + []), "", "no POV"); + assert.equal(caosdb_map._get_with_POV( + ["lol"]), " WITH lol ", "single POV"); + assert.equal(caosdb_map._get_with_POV( + ["lol", "hi"]), " WITH lol WITH hi ", "with two POV"); +}); + + +QUnit.test("_get_select_with_path ", function (assert) { + assert.throws(() => caosdb_map._get_select_with_path(), /Supply the datamodel./, "missing datamodel"); + assert.throws(() => caosdb_map._get_select_with_path(this.datamodel, []), /Supply at least a RecordType./, "missing value"); + assert.equal(caosdb_map._get_select_with_path( + this.datamodel, + ["RealRT"]), "SELECT parent,latitude,longitude FROM ENTITY RealRT WITH latitude AND longitude ", "RT only"); + assert.equal(caosdb_map._get_select_with_path( + this.datamodel, + ["RealRT", "prop1"]), "SELECT parent,prop1.latitude,prop1.longitude FROM ENTITY RealRT WITH prop1 WITH latitude AND longitude ", "RT with one prop"); + assert.equal(caosdb_map._get_select_with_path( + this.datamodel, + ["RealRT", "prop1", "prop2"]), "SELECT parent,prop1.prop2.latitude,prop1.prop2.longitude FROM ENTITY RealRT WITH prop1 WITH prop2 WITH latitude AND longitude ", "RT with two props"); +}); + + +QUnit.test("_get_leaf_prop", async function (assert) { + const test_response = str2xml(` +<Response srid="851d063d-121b-4b67-98d4-6e5ad4d31e72" timestamp="1606214042274" baseuri="https://localhost:10443" count="8"> + <Query string="select Campaign.responsible.firstname from icecore" results="8"> + <ParseTree>(cq select (prop_sel (prop_subsel (selector_txt C a m p a i g n) . (prop_subsel (selector_txt r e s p o n s i b l e) . (prop_subsel (selector_txt f i r s t n a m e ))))) from (entity icecore) <EOF>)</ParseTree> + <Role/> + <Entity>icecore</Entity> + <Selection> + <Selector name="Campaign.responsible.firstname"/> + </Selection> + </Query> + <Record id="6525" name="Test_IceCore_1"> + <Permissions/> + <Property datatype="Campaign" id="6430" name="Campaign"> + <Record id="6516" name="Test-2020_Camp1"> + <Permissions/> + <Property datatype="REFERENCE" id="168" name="responsible"> + <Record id="6515" name="Test_Scientist"> + <Permissions/> + <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX"> + 1.34 + <Permissions/> + </Property> + <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX"> + 2 + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Record id="6526" name="Test_IceCore_2"> + <Permissions/> + <Property datatype="Campaign" id="6430" name="Campaign"> + <Record id="6516" name="Test-2020_Camp1"> + <Permissions/> + <Property datatype="REFERENCE" id="168" name="responsible"> + <Record id="6515" name="Test_Scientist"> + <Permissions/> + <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX"> + 3 + <Permissions/> + </Property> + <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX"> + 4.8345 + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> +</Response>`); + var leaves = caosdb_map._get_leaf_prop(test_response, 2, this.datamodel) + + assert.equal(Object.keys(leaves).length, 2, "number of records"); + assert.notEqual(typeof leaves["6525"], "undefined", "has entity id"); + assert.deepEqual(leaves["6525"], ["1.34", "2"]); + assert.deepEqual(leaves["6526"], ["3", "4.8345"], "long/lat in second rec"); + + assert.equal( + caosdb_map._get_toplvl_rec_with_id(test_response, "6526")["id"], + "6526", + "number of records"); + + caosdb_map._set_subprops_at_top( + test_response, 2, this.datamodel, { + "6526": [1.234, 5.67] + }) + assert.equal($(test_response).find(`[name='longitude']`).length, + 4, + "number lng props"); + assert.equal($(test_response).find(`[name='latitude']`).length, + 4, + "number lat props"); + // after transforming, the long/lat props should be accessible + var html_ents = await transformation.transformEntities(test_response); + assert.equal( + getProperty(html_ents[0], "longitude"), + "2", + "longitude of first rec"); + +}); + +QUnit.test("_get_id_POV", function (assert) { + assert.equal(caosdb_map._get_id_POV([]), "WITH ", "no POV"); + assert.equal(caosdb_map._get_id_POV([5]), "WITH id=5", "one id"); + assert.equal(caosdb_map._get_id_POV([5, 6]), "WITH id=5 or id=6", "two ids"); }); diff --git a/test/core/js/modules/ext_references.js.js b/test/core/js/modules/ext_references.js.js index 38d61a9ac18d1bccaddd5cc7cdad1d1c9d3b5498..43cc1ddd742d6702232b740bbfd96411f41b08f5 100644 --- a/test/core/js/modules/ext_references.js.js +++ b/test/core/js/modules/ext_references.js.js @@ -73,6 +73,7 @@ QUnit.module("ext_references.js", { }, beforeEach: function(assert) { + resolve_references.getParents = getParents; resolve_references.test_resolver = this.test_resolver(true); resolve_references.is_in_viewport_horizontally = () => true; resolve_references.is_in_viewport_vertically = () => true; @@ -87,6 +88,21 @@ QUnit.test("init", function(assert){ assert.ok(resolve_references.init); }); +QUnit.test("is_child", function(assert){ + resolve_references.getParents = (entity) => [{"name": "par1"}, {"name": "par2"}]; + const ent = transformation.transformEntities(str2xml( + `<Response> + <Record id="50023"> + <Parent name="TestReferenced"/> + </Record> + </Response>` + )); + + assert.ok(resolve_references.is_child(ent, "par1")); + assert.ok(resolve_references.is_child(ent, "par2")); + assert.ok(!resolve_references.is_child(ent, "par3")); +}); + QUnit.test("get_person_str", function(assert){ assert.ok(resolve_references.get_person_str); }); diff --git a/test/core/js/modules/ext_xls_download.js.js b/test/core/js/modules/ext_xls_download.js.js index 4f9dc6c59b156f1f2265acb4b315887536667194..195e9c825d9601dc08f29c8f1b2171ff13eeef47 100644 --- a/test/core/js/modules/ext_xls_download.js.js +++ b/test/core/js/modules/ext_xls_download.js.js @@ -64,43 +64,39 @@ QUnit.module("ext_xls_download", { }); -{ - const sleep = function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - QUnit.test("call downloadXLS", async function(assert) { - var done = assert.async(2); +QUnit.test("call downloadXLS", async function(assert) { + var done = assert.async(2); - // mock server response (successful) - connection.runScript = async function(exec, param){ - assert.equal(exec, "xls_from_csv.py", "call xls_from_csv.py"); - done(); - return str2xml('<response><script code="0" /><stdout>bla</stdout></response>'); - } + // mock server response (successful) + connection.runScript = async function(exec, param){ + assert.equal(exec, "xls_from_csv.py", "call xls_from_csv.py"); + done(); + 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); - assert.equal(filename, "bla", "filename correct"); - done(); - } + 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 tsv_data = $('<a id="caosdb-f-query-select-data-tsv" />'); + var modal = $('<div id="downloadModal"><div>'); + $(document.body).append([tsv_data, modal]); - var xsl_link = $("<a/>"); - downloadXLS(xsl_link[0]); + var xsl_link = $("<a/>"); + downloadXLS(xsl_link[0]); - await sleep(500); + 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(); +}); - tsv_data.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; @@ -114,11 +110,17 @@ QUnit.test("_get_property_value", function(assert) { QUnit.test("_get_tsv_string", function(assert) { const table = this.test_case_1; const entities = $(table).find("tbody tr").toArray(); - assert.equal(entities.length, 2, "two example entities"); + assert.equal(entities.length, 3, "three 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\tVersion\tBag\tNumber\n242\tabc123\t6366, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6413\t02 8 4aaa a\n2112\tabc124\t\t1101\n2112\tabc125\t\t1102", "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\tVersion\tBag\tNumber\n242\tabc123\t6366, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6413\t02 8 4aaa a\n2112\tabc124\t\t1101\n2112\tabc125\t\t1102", "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..aa2e281c7d25f03ce8ed109fb5728919635abec6 100644 --- a/test/core/js/modules/form_elements.js.js +++ b/test/core/js/modules/form_elements.js.js @@ -23,7 +23,7 @@ 'use strict'; QUnit.module("form_elements.js", { - before: function(assert) { + before: function (assert) { markdown.init(); var entities = [ $('<div><div class="caosdb-id" data-entity-name="name12">id12</div></div>')[0], @@ -32,27 +32,27 @@ QUnit.module("form_elements.js", { $('<div><div class="caosdb-id" data-entity-name="name15">id15</div></div>')[0], ]; - form_elements._query = async function(query) { + form_elements._query = async function (query) { return entities; }; - this.get_example_1 = async function() { + this.get_example_1 = async function () { return $(await $.ajax("html/form_elements_example_1.html"))[0]; }; }, - after: function(assert) { + after: function (assert) { form_elements._init_functions(); } }); -QUnit.test("availability", function(assert) { +QUnit.test("availability", function (assert) { assert.equal(form_elements.version, "0.1", "test version"); assert.ok(form_elements.init, "init available"); assert.ok(form_elements.version, "version available"); }); -QUnit.test("make_reference_option", function(assert) { +QUnit.test("make_reference_option", function (assert) { assert.equal(typeof form_elements.make_reference_option, "function", "function available"); - assert.throws(()=>form_elements.make_reference_option(), /is expected to be a string/, "noargs throws"); + assert.throws(() => form_elements.make_reference_option(), /is expected to be a string/, "noargs throws"); var option = form_elements.make_reference_option("id15"); assert.equal($(option).val(), "id15", "value"); assert.equal($(option).text(), "id15", "text"); @@ -62,14 +62,17 @@ QUnit.test("make_reference_option", function(assert) { }); -QUnit.test("make_reference_select", async function(assert) { +QUnit.test("make_reference_select", async function (assert) { assert.equal(typeof form_elements.make_reference_select, "function", "function available"); - //assert.throws(()=> unasync(form_elements.make_reference_select), /param `entities` is expected to be an array/, "undefined entities throws"); - //assert.throws(()=> unasync(form_elements.make_reference_select, "test"), /param `entities` is expected to be an array/, "string entities throws"); - var select = await form_elements.make_reference_select([ - {dataset: {entityId : "id17"}}, - {dataset: {entityId : "id18"}}, - ]); + var select = await form_elements.make_reference_select([{ + dataset: { + entityId: "id17" + } + }, { + dataset: { + entityId: "id18" + } + }, ]); assert.ok($(select).hasClass("selectpicker"), "selectpicker class from bootstrap-select"); assert.notOk($(select).val(), "unselected"); $(select).val(["id18"]); @@ -86,7 +89,7 @@ QUnit.test("make_reference_select", async function(assert) { }); -QUnit.test("make_script_form", async function(assert) { +QUnit.test("make_script_form", async function (assert) { assert.equal(typeof form_elements.make_script_form, "function", "function available"); // TODO @@ -96,12 +99,31 @@ QUnit.test("make_script_form", async function(assert) { var done = assert.async(3); var config = { - groups: [ - { name: "group1", fields: ["date"], enabled: false }, - ], - fields: [ - {type: "date", name: "baldate"}, - ], + groups: [{ + name: "group1", + fields: ["date"], + enabled: false + }, ], + fields: [{ + type: "date", + name: "baldate" + }, { + type: "select", + name: "Sex", + label: "Sex", + value: "female", + required: true, + options: [{ + value: "female", + label: "female" + }, { + value: "diverse", + label: "diverse" + }, { + value: "male", + label: "male" + }] + }], }; var script_form = await form_elements.make_script_form(config, "test_script"); @@ -115,24 +137,29 @@ QUnit.test("make_script_form", async function(assert) { assert.equal(cancel_button.length, 1, "has cancel button"); var field = $(script_form).find(".caosdb-f-field"); - assert.equal(field.length, 1, "has one field"); + assert.equal(field.length, 2, "has two field"); assert.equal(field.find("input[type='date']").length, 1, "has date input"); + assert.equal(field.find("select").length, 1, "has select input"); - script_form.addEventListener("caosdb.form.cancel", function(e) { + script_form.addEventListener("caosdb.form.cancel", function (e) { done(); }, true); cancel_button.click(); - script_form.addEventListener("caosdb.form.error", function(e) { + script_form.addEventListener("caosdb.form.error", function (e) { assert.equal($(script_form).find(".caosdb-f-form-elements-message-error").length, 2, "error message there (call and stderr)"); done(); script_form.remove(); }); - form_elements._run_script = async function(script, params) { + form_elements._run_script = async function (script, params) { done(); - return {code: "1", stderr: "Autsch!", call: "none"}; + return { + code: "1", + stderr: "Autsch!", + call: "none" + }; }; assert.equal($(script_form).find(".caosdb-f-form-error-message").length, 0, "no error message"); @@ -144,7 +171,7 @@ QUnit.test("make_script_form", async function(assert) { }); -QUnit.test("make_date_input", function(assert) { +QUnit.test("make_date_input", function (assert) { assert.equal(typeof form_elements.make_date_input, "function", "function available"); var config = { @@ -164,7 +191,7 @@ QUnit.test("make_date_input", function(assert) { }); -QUnit.test("make_range_input", async function(assert) { +QUnit.test("make_range_input", async function (assert) { assert.equal(typeof form_elements.make_range_input, "function", "function available"); var config = { @@ -192,20 +219,27 @@ QUnit.test("make_range_input", async function(assert) { }); -QUnit.test("make_form_field", async function(assert) { +QUnit.test("make_form_field", async function (assert) { assert.equal(typeof form_elements.make_form_field, "function", "function available"); - var cached = false; - for ( var t of ["date", "range", "reference_drop_down", "subform", "checkbox", "double", "integer"] ) { + var cached = false; + for (var t of ["date", "range", "reference_drop_down", "subform", "checkbox", "double", "integer"]) { cached = !cached; var config = { - help: {title: "HELP", content: "help me, help me, help me-e-e!"}, + help: { + title: "HELP", + content: "help me, help me, help me-e-e!" + }, type: t, cached: cached, name: "a name", label: "a label", - from: {name: "from_bla"}, - to: {name: "to_bla"}, + from: { + name: "from_bla" + }, + to: { + name: "to_bla" + }, query: "FIND something", make_desc: getEntityName, fields: [], @@ -220,8 +254,8 @@ QUnit.test("make_form_field", async function(assert) { }); -QUnit.test("make_subform", async function(assert) { - assert.equal(typeof form_elements.make_reference_drop_down, "function", "function available"); +QUnit.test("make_subform", async function (assert) { + assert.equal(typeof form_elements.make_subform, "function", "function available"); const config = { type: "subform", @@ -239,7 +273,7 @@ QUnit.test("make_subform", async function(assert) { }); -QUnit.test("make_reference_drop_down", async function(assert) { +QUnit.test("make_reference_drop_down", async function (assert) { assert.equal(typeof form_elements.make_reference_drop_down, "function", "function available"); var config = { @@ -257,7 +291,7 @@ QUnit.test("make_reference_drop_down", async function(assert) { assert.equal(label.text(), "IceCore", "label has text"); }); -QUnit.test("make_checkbox_input", function(assert) { +QUnit.test("make_checkbox_input", function (assert) { assert.equal(typeof form_elements.make_checkbox_input, "function", "function available"); @@ -274,7 +308,7 @@ QUnit.test("make_checkbox_input", function(assert) { assert.equal(input.attr("name"), "approved", "input has name"); assert.ok(input.is(":checked"), "input is checked"); - var obj = form_elements.form_to_object(field); + var obj = form_elements.form_to_object(field)[0]; assert.equal(obj["approved"], "yes!!!", "checked value"); @@ -286,36 +320,72 @@ QUnit.test("make_checkbox_input", function(assert) { assert.equal(input.attr("name"), "approved", "input has name"); assert.notOk(input.is(":checked"), "input is not checked"); - obj = form_elements.form_to_object(field); + obj = form_elements.form_to_object(field)[0]; assert.equal(typeof obj["approved"], "undefined", "no checked value"); }); -QUnit.test("form_to_object", async function(assert) { +QUnit.test("form_to_object", async function (assert) { assert.equal(typeof form_elements.form_to_object, "function", "function available"); var config = { - fields: [ - { type: "date", name: "the-date" }, - { type: "reference_drop_down", name: "icecore", query: "FIND Record IceCore"}, - { type: "range", name: "the-range", from: {name: "fromblla"}, to: {name: "toblla"}}, - { type: "subform", name: "subform1", fields: [ - { type: "date", name: "the-other-date", }, - { type: "checkbox", name: "rectangular", }, - ],}, - ], + fields: [{ + type: "date", + name: "the-date" + }, { + type: "reference_drop_down", + name: "icecore", + query: "FIND Record IceCore" + }, { + type: "range", + name: "the-range", + from: { + name: "fromblla" + }, + to: { + name: "toblla" + } + }, { + type: "subform", + name: "subform1", + fields: [{ + type: "date", + name: "the-other-date", + }, { + type: "checkbox", + name: "rectangular", + }, ], + }, { + type: "select", + required: true, + cached: true, + name: "sex", + label: "Sex", + value: "d", + options: [{ + "value": "f", + "label": "female" + }, { + "value": "d", + "label": "diverse" + }, { + "value": "m", + "label": "male" + }, ], + }, ], }; var form = await form_elements.make_script_form(config, "bla"); - var json = form_elements.form_to_object(form); + var json = form_elements.form_to_object(form)[0]; assert.equal(typeof json["cancel"], "undefined", "cancel button not serialized"); assert.equal(json["the-date"], "", "date"); assert.equal(json["icecore"], null, "reference_drop_down"); assert.equal(json["fromblla"], "", "range from"); assert.equal(json["toblla"], "", "range to"); + assert.equal(json["sex"], "d", "select"); assert.equal(typeof json["the-other-date"], "undefined", "subform element not on root level"); var subform = json["subform1"]; @@ -327,10 +397,13 @@ QUnit.test("form_to_object", async function(assert) { }); -QUnit.test("make_double_input", function(assert) { +QUnit.test("make_double_input", function (assert) { assert.equal(typeof form_elements.make_double_input, "function", "function available"); - var config = {type: "double", name: "d"}; + var config = { + type: "double", + name: "d" + }; var input = $(form_elements.make_double_input(config)).find("input"); assert.ok(input.is("[type='number'][step='any']"), "double input"); @@ -340,10 +413,13 @@ QUnit.test("make_double_input", function(assert) { }); -QUnit.test("make_integer_input", function(assert) { +QUnit.test("make_integer_input", function (assert) { assert.equal(typeof form_elements.make_integer_input, "function", "function available"); - var config = {type: "integer", name: "i"}; + var config = { + type: "integer", + name: "i" + }; var input = $(form_elements.make_integer_input(config)).find("input"); assert.ok(input.is("[type='number'][step='1']"), "integer input"); @@ -352,21 +428,26 @@ QUnit.test("make_integer_input", function(assert) { assert.equal(input.val("abc").val(), "", "abc not valid"); }); -QUnit.test("make_form", function(assert) { +QUnit.test("make_form", function (assert) { assert.equal(typeof form_elements.make_form, "function", "function available"); - var form1 = form_elements.make_form({fields: []}); + var form1 = form_elements.make_form({ + fields: [] + }); assert.equal(form1.tagName, "DIV", "wrapper is div"); assert.ok($(form1).hasClass("caosdb-f-form-wrapper"), "div has caosdb-f-form-wrapper class"); assert.equal($(form1).find(".h3").length, 0, "no header"); - var form2 = form_elements.make_form({fields: [], header: "bla"}); + var form2 = form_elements.make_form({ + fields: [], + header: "bla" + }); assert.equal(form2.tagName, "DIV", "wrapper is div"); assert.equal($(form2).find(".h3").length, 1, "one header"); assert.equal($(form2).find(".h3").text(), "bla", "header text set"); }); -QUnit.test("enable/disable_group", function(assert) { +QUnit.test("enable/disable_group", function (assert) { assert.equal(typeof form_elements.disable_group, "function", "function available"); assert.equal(typeof form_elements.enable_group, "function", "function available"); @@ -413,11 +494,11 @@ QUnit.test("enable/disable_group", function(assert) { }); -QUnit.test("parse_script_result", function(assert) { +QUnit.test("parse_script_result", function (assert) { assert.equal(typeof form_elements.parse_script_result, "function", "function available"); var result = str2xml( -`<?xml version="1.0" encoding="UTF-8"?> + `<?xml version="1.0" encoding="UTF-8"?> <?xml-stylesheet type="text/xsl" href="https://localhost:10443/webinterface/webcaosdb.xsl" ?> <Response username="admin" realm="PAM" srid="256c14970dac2b2b5649973d52e4c06a" timestamp="1570785591824" baseuri="https://localhost:10443"> <UserInfo username="admin" realm="PAM"> @@ -443,15 +524,15 @@ QUnit.test("parse_script_result", function(assert) { }); -QUnit.test("disable_name", function(assert) { +QUnit.test("disable_name", function (assert) { assert.equal(typeof form_elements.disable_name, "function", "function available"); }); -QUnit.test("enable_name", function(assert) { +QUnit.test("enable_name", function (assert) { assert.equal(typeof form_elements.enable_name, "function", "function available"); }); -QUnit.test("add_field_to_group", function(assert) { +QUnit.test("add_field_to_group", function (assert) { assert.equal(typeof form_elements.add_field_to_group, "function", "function available"); var field = $(form_elements._make_field_wrapper("field1"))[0]; @@ -462,7 +543,7 @@ QUnit.test("add_field_to_group", function(assert) { }); -QUnit.test("cache_form", async function(assert) { +QUnit.test("cache_form", async function (assert) { var form = await this.get_example_1(); assert.equal($(form).find("form").length, 1, "example form available"); @@ -476,7 +557,7 @@ QUnit.test("cache_form", async function(assert) { }); -QUnit.test("load_cached", async function(assert) { +QUnit.test("load_cached", async function (assert) { var done = assert.async(); var form = await this.get_example_1(); assert.equal($(form).find("form").length, 1, "example form available"); @@ -498,7 +579,7 @@ QUnit.test("load_cached", async function(assert) { }); -QUnit.test("field_ready", function(assert) { +QUnit.test("field_ready", function (assert) { var done = assert.async(3); var field1 = $('<div id="f1"><div class="caosdb-f-field-not-ready"/></div>')[0]; var field2 = $('<div id="f2" class="caosdb-f-field-not-ready"/>')[0]; @@ -541,3 +622,167 @@ QUnit.test("field_ready", function(assert) { }); }); +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"); +}); + +QUnit.test("make_select_input", function (assert) { + const config = { + name: "sex", + label: "Sex", + multiple: true, + options: [{ + "value": "f", + "label": "female" + }, { + "value": "d", + "label": "diverse" + }, { + "value": "m", + "label": "male" + }, ], + } + const select = $(form_elements.make_select_input(config)); + assert.equal(select.find("select").length, 1, "select input there"); + assert.equal(select.find("select").attr("name"), "sex", "has select with correct name"); + assert.equal(select.find("select option").length, 3, "three options there"); +}); + +QUnit.test("select_input caching", async function (assert) { + const config = { + "name": "test-form", + "fields": [{ + type: "select", + required: true, + cached: true, + name: "sex", + label: "Sex", + options: [{ + "value": "f", + "label": "female" + }, { + "value": "d", + "label": "diverse" + }, { + "value": "m", + "label": "male" + }, ], + }, ], + } + const form_wrapper = $(form_elements.make_form(config)); + await sleep(200); + const form = form_wrapper.find("form"); + assert.equal(form.find("select").length, 1); + + + // write to cache + const cache = {}; + const field = $(form_elements.get_fields(form[0], "sex")); + field.find("select").val("f"); + assert.equal(form_elements.get_cache_value(field[0]), "f", "initial value set"); + assert.equal(form_elements.get_cache_key(form[0], field[0]), "form_elements.cache.test-form.sex", "cache key correct"); + + form_elements.cache_form(cache, form[0]); + assert.equal(cache[form_elements.get_cache_key(form[0], field[0])], "f"); + + // read from cache and set the value + field.find("select").val("m"); + assert.equal(form_elements.get_cache_value(field[0]), "m", "different value set"); + + form_elements.load_cached(cache, form[0]); + await sleep(200); + assert.equal(form_elements.get_cache_value(field[0]), "f", "value back to value from cache"); +}); + +QUnit.test("make_file_input", function (assert) { + const config = { + name: "some_file", + multiple: true, + accept: ".tsv, .csv", + } + const file_input = $(form_elements.make_file_input(config)); + assert.equal(file_input.find(":input").length, 1, "file input there"); + assert.equal(file_input.find(":input").attr("name"), "some_file", "has file input with correct name"); + assert.ok(file_input.find(":input").prop("multiple"), "is multiple"); + assert.equal(file_input.find(":input").attr("accept"), ".tsv, .csv", "accept there"); +}); \ No newline at end of file diff --git a/test/core/js/modules/query_shortcuts.js.js b/test/core/js/modules/query_shortcuts.js.js index f088b729001daac8a42ed276507e2370f3e08679..3798c5fe81ea6e860cd176797a9a6959a9a64895 100644 --- a/test/core/js/modules/query_shortcuts.js.js +++ b/test/core/js/modules/query_shortcuts.js.js @@ -141,7 +141,9 @@ QUnit.test("init_delete_shortcut_form", function(assert) { assert.equal(panel.find(".caosdb-f-form-wrapper").length, 1, "panel has form after"); // test cancel button - panel[0].addEventListener("caosdb.form.cancel", function(e) { + var done = assert.async(); + panel[0].addEventListener("caosdb.form.cancel", async function(e) { + await sleep(200); assert.equal(panel.find("form").length, 0, "form is gone"); done(); }, true); @@ -187,23 +189,17 @@ QUnit.test("make_delete_form", function(assert) { } var form = query_shortcuts.make_delete_form(panel[0], delete_callback); + $('body').append(form); - // wait for form - form.addEventListener("caosdb.form.ready", function(e) { - - assert.equal($(form).hasClass("caosdb-f-form-wrapper"), true, "is form"); - assert.equal($(form).find("input[type='checkbox']").length, 3, "three checkboxes (for three user-defined shortcuts)"); - - // check two - $(form).find(":checkbox[name='id28']").click(); - $(form).find(":checkbox[name='id29']").click(); - $(form).find("[type='submit']").click(); - - $(form).find("button.caosdb-f-form-elements-cancel-button").click(); + assert.equal($(form).hasClass("caosdb-f-form-wrapper"), true, "is form"); + assert.equal($(form).find("input[type='checkbox']").length, 3, "three checkboxes (for three user-defined shortcuts)"); - }, true); + // check two + $(form).find(":checkbox[name='id28']").click(); + $(form).find(":checkbox[name='id29']").click(); + $(form).find("[type='submit']").click(); - $('body').append(form); + $(form).find("button.caosdb-f-form-elements-cancel-button").click(); }); @@ -228,31 +224,24 @@ QUnit.test("transform_entities", async function(assert) { }); QUnit.test("make_create_form", function(assert) { - - var done = assert.async(); var panel = $('<div/>'); var form = query_shortcuts.make_create_form(panel[0], () => {}); assert.ok($(form).hasClass("caosdb-f-form-wrapper"), "form created"); $('body').append(form); - form.addEventListener(form_elements.form_ready_event.type, function(e) { - $(form).find(":input[name='templateDescription']").val("NEW DESC"); - $(form).find(":input[name='Query']").val("NEW QUERY"); + $(form).find(":input[name='templateDescription']").val("NEW DESC"); + $(form).find(":input[name='Query']").val("NEW QUERY"); - var entity = getEntityXML(form); - assert.equal(xml2str(entity), "<Record><Parent name=\"UserTemplate\"/><Property name=\"templateDescription\">NEW DESC</Property><Property name=\"Query\">NEW QUERY</Property></Record>", "entity extracted"); + var entity = getEntityXML(form); + assert.equal(xml2str(entity), "<Record><Parent name=\"UserTemplate\"/><Property name=\"templateDescription\">NEW DESC</Property><Property name=\"Query\">NEW QUERY</Property></Record>", "entity extracted"); - form_elements.dismiss_form(form); - done(); + form_elements.dismiss_form(form); - }, true); }); QUnit.test("make_update_form", function(assert) { - - var done = assert.async(); var panel = $('<div/>'); var header = $('<span class="h3">Shortcuts</span>'); var userTemplate1 = query_shortcuts.generate_user_shortcut("the_description", "FIND nothing", "id28"); @@ -271,16 +260,12 @@ QUnit.test("make_update_form", function(assert) { $('body').append(form); - form.addEventListener(form_elements.form_ready_event.type, function(e) { - $(form).find(":input[name='templateDescription']").val("NEW DESC"); - $(form).find(":input[name='Query']").val("NEW QUERY"); - - var entity = getEntityXML(form); - assert.equal(xml2str(entity), "<Record id=\"id28\"><Parent name=\"UserTemplate\"/><Property name=\"templateDescription\">NEW DESC</Property><Property name=\"Query\">NEW QUERY</Property></Record>", "entity extracted"); + $(form).find(":input[name='templateDescription']").val("NEW DESC"); + $(form).find(":input[name='Query']").val("NEW QUERY"); - form_elements.dismiss_form(form); - done(); + var entity = getEntityXML(form); + assert.equal(xml2str(entity), "<Record id=\"id28\"><Parent name=\"UserTemplate\"/><Property name=\"templateDescription\">NEW DESC</Property><Property name=\"Query\">NEW QUERY</Property></Record>", "entity extracted"); - }, true); + form_elements.dismiss_form(form); }); diff --git a/test/core/js/modules/webcaosdb.js.js b/test/core/js/modules/webcaosdb.js.js index 239758161b8419a8a5e15799c67c909977f1fbf0..95c4f62d983077374343a099d4d4bcb2680e25d5 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); @@ -957,10 +957,6 @@ QUnit.test("createCarouselNav", function(assert) { let original_get = connection.get; ref_property_elem.find('div').append(refLinks); - const sleep = function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - QUnit.test("initProperty", async function(assert) { var done = assert.async(2); assert.ok(preview.initProperty, "function available"); @@ -1939,3 +1935,76 @@ QUnit.test("toolbox example", function(assert) { assert.equal($('.caosdb-f-navbar-toolbox[data-toolbox-name="Tools"]').length, 1, "one 'Tools' toolbox"); assert.equal($('.caosdb-f-navbar-toolbox[data-toolbox-name="Tools"] button').length, 3, "three 'Tools' buttons"); }); + +QUnit.module("webcaosdb.js - version_history", { + before: function(assert) { + connection._init(); + }, + after: function(assert) { + connection._init(); + }, +}); + +QUnit.test("available", function (assert) { + assert.equal(typeof version_history.init, "function"); + assert.equal(typeof version_history.get_history_tsv, "function"); + assert.equal(typeof version_history.init_export_history_buttons, "function"); + assert.equal(typeof version_history.init_load_history_buttons, "function"); + assert.equal(typeof version_history.retrieve_history, "function"); +}) + +QUnit.test("init_load_history_buttons and init_load_history_buttons", async function (assert) { + var xml_str = `<Response username="user1" realm="Realm1" srid="bc2f8f6b-71d6-49ca-890c-eebea3e38e18" timestamp="1606253365632" baseuri="https://localhost:10443" count="1"> + <UserInfo username="user1" realm="Realm1"> + <Roles> + <Role>role1</Role> + </Roles> + </UserInfo> + <Record id="8610" name="TestRecord1-6thVersion" description="This is the 6th version."> + <Permissions> + <Permission name="RETRIEVE:HISTORY" /> + </Permissions> + <Version id="efa5ac7126c722b3f43284e150d070d6deac0ba6"> + <Predecessor id="f09114b227d88f23d4e23645ae471d688b1e82f7" /> + <Successor id="5759d2bccec3662424db5bb005acea4456a299ef" /> + </Version> + <Parent id="8609" name="TestRT" /> + </Record> +</Response> +`; + var done = assert.async(2); + var xml = str2xml(xml_str); + version_history._get = async function (entity) { + assert.equal(entity, "Entity/8610@efa5ac7126c722b3f43284e150d070d6deac0ba6?H"); + done(); + $(xml).find("Version").attr("completeHistory", "true"); + return xml; + } + var html = await transformation.transformEntities(xml); + var load_button = $(html).find(".caosdb-f-entity-version-load-history-btn"); + $("body").append(html); + + assert.notOk(load_button.is(":visible"), "load_button hidden"); + load_button.click(); // nothing happens + + version_history.init_load_history_buttons(); + assert.ok(load_button.is(":visible"), "load_button is not hidden anymore"); + + // load_button triggers retrieval of history + load_button.click(); + await sleep(200); + + var gone_button = $(html).find(".caosdb-f-entity-version-load-history-btn"); + assert.equal(gone_button.length, 0, "button is gone"); + + export_button = $(html).find(".caosdb-f-entity-version-export-history-btn"); + assert.ok(export_button.is(":visible"), "export_button is visible"); + + version_history._download_tsv = function (tsv) { + assert.equal(tsv.indexOf("data:text/csv;charset=utf-8,Entity ID%09"), 0); + done(); + } + export_button.click(); + + $(html).remove(); +}); diff --git a/test/core/js/setup.js b/test/core/js/setup.js index 9894827988999e89a6388206f10bdd815098e81e..fac4fd78d0c867cf213917b16d874513d1871a09 100644 --- a/test/core/js/setup.js +++ b/test/core/js/setup.js @@ -46,3 +46,6 @@ QUnit.done(function( details ) { $.post("/done", report); }); +const sleep = function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} 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..be07fd42df8bc1275ab16802c0e82c19ece417ad 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 @@ -9,8 +9,9 @@ </Selection> </Query> <Record id="242"> + <Version id="abc123" head="true"/> <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 @@ -35,10 +36,19 @@ </Property> </Record> <Record id="2112"> + <Version id="abc124" head="true"/> <Property id="117" name="Number" datatype="TEXT" importance="FIX"> 1101 </Property> <Property id="104" name="Bag" datatype="LIST<Bag>" importance="FIX"> </Property> </Record> + <Record id="2112"> + <Version id="abc125" head="true"/> + <Property id="117" name="Number" datatype="TEXT" importance="FIX"> + 1102 + </Property> + <Property id="104" name="Bag" datatype="LIST<Bag>" importance="FIX"> + </Property> + </Record> </Response> diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index a19843fb3de11bbedf1c01f71619470bcc99f75c..92889dc526229475463ddcdfe6a2080a669b9d25 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -1,7 +1,22 @@ -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 . +FROM debian:10 +ADD node_gpg.asc /etc/apt/ +RUN apt-get update \ + && apt-get install -y gnupg ca-certificates\ + && apt-key add /etc/apt/node_gpg.asc \ + && echo "deb http://deb.debian.org/debian buster-backports main" >> /etc/apt/sources.list \ + && echo "deb https://deb.nodesource.com/node_14.x buster main" >> /etc/apt/sources.list \ + && apt-get update \ + && apt-get install -y \ + firefox-esr gettext-base python3-pip \ + python3-httpbin git curl x11-apps xvfb unzip \ + nodejs # Don't install `npm` (Debian), it conflicts with the `nodejs` (Node) package \ + && apt-get install -f +RUN pip3 install pylint pytest +RUN pip3 install caosdb==0.5.1 +RUN pip3 install pandas xlrd==1.2.0 +RUN pip3 install git+https://gitlab.com/caosdb/caosdb-advanced-user-tools.git@dev +# For automatic documentation +#RUN npm install -g jsdoc +#RUN npm install -g jsdoc-sphinx +RUN pip3 install sphinx-js sphinx-autoapi recommonmark sphinx-rtd-theme diff --git a/test/docker/node_gpg.asc b/test/docker/node_gpg.asc new file mode 100644 index 0000000000000000000000000000000000000000..ed458a24e9132e0f22b6081d5e2b68926d00c725 --- /dev/null +++ b/test/docker/node_gpg.asc @@ -0,0 +1,54 @@ +Downloaded on 2020-12-18 + +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1 +Comment: GPGTools - https://gpgtools.org + +mQINBFObJLYBEADkFW8HMjsoYRJQ4nCYC/6Eh0yLWHWfCh+/9ZSIj4w/pOe2V6V+ +W6DHY3kK3a+2bxrax9EqKe7uxkSKf95gfns+I9+R+RJfRpb1qvljURr54y35IZgs +fMG22Np+TmM2RLgdFCZa18h0+RbH9i0b+ZrB9XPZmLb/h9ou7SowGqQ3wwOtT3Vy +qmif0A2GCcjFTqWW6TXaY8eZJ9BCEqW3k/0Cjw7K/mSy/utxYiUIvZNKgaG/P8U7 +89QyvxeRxAf93YFAVzMXhoKxu12IuH4VnSwAfb8gQyxKRyiGOUwk0YoBPpqRnMmD +Dl7SdmY3oQHEJzBelTMjTM8AjbB9mWoPBX5G8t4u47/FZ6PgdfmRg9hsKXhkLJc7 +C1btblOHNgDx19fzASWX+xOjZiKpP6MkEEzq1bilUFul6RDtxkTWsTa5TGixgCB/ +G2fK8I9JL/yQhDc6OGY9mjPOxMb5PgUlT8ox3v8wt25erWj9z30QoEBwfSg4tzLc +Jq6N/iepQemNfo6Is+TG+JzI6vhXjlsBm/Xmz0ZiFPPObAH/vGCY5I6886vXQ7ft +qWHYHT8jz/R4tigMGC+tvZ/kcmYBsLCCI5uSEP6JJRQQhHrCvOX0UaytItfsQfLm +EYRd2F72o1yGh3yvWWfDIBXRmaBuIGXGpajC0JyBGSOWb9UxMNZY/2LJEwARAQAB +tB9Ob2RlU291cmNlIDxncGdAbm9kZXNvdXJjZS5jb20+iQI4BBMBAgAiBQJTmyS2 +AhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRAWVaCraFdigHTmD/9OKhUy +jJ+h8gMRg6ri5EQxOExccSRU0i7UHktecSs0DVC4lZG9AOzBe+Q36cym5Z1di6JQ +kHl69q3zBdV3KTW+H1pdmnZlebYGz8paG9iQ/wS9gpnSeEyx0Enyi167Bzm0O4A1 +GK0prkLnz/yROHHEfHjsTgMvFwAnf9uaxwWgE1d1RitIWgJpAnp1DZ5O0uVlsPPm +XAhuBJ32mU8S5BezPTuJJICwBlLYECGb1Y65Cil4OALU7T7sbUqfLCuaRKxuPtcU +VnJ6/qiyPygvKZWhV6Od0Yxlyed1kftMJyYoL8kPHfeHJ+vIyt0s7cropfiwXoka +1iJB5nKyt/eqMnPQ9aRpqkm9ABS/r7AauMA/9RALudQRHBdWIzfIg0Mlqb52yyTI +IgQJHNGNX1T3z1XgZhI+Vi8SLFFSh8x9FeUZC6YJu0VXXj5iz+eZmk/nYjUt4Mtc +pVsVYIB7oIDIbImODm8ggsgrIzqxOzQVP1zsCGek5U6QFc9GYrQ+Wv3/fG8hfkDn +xXLww0OGaEQxfodm8cLFZ5b8JaG3+Yxfe7JkNclwvRimvlAjqIiW5OK0vvfHco+Y +gANhQrlMnTx//IdZssaxvYytSHpPZTYw+qPEjbBJOLpoLrz8ZafN1uekpAqQjffI +AOqW9SdIzq/kSHgl0bzWbPJPw86XzzftewjKNbkCDQRTmyS2ARAAxSSdQi+WpPQZ +fOflkx9sYJa0cWzLl2w++FQnZ1Pn5F09D/kPMNh4qOsyvXWlekaV/SseDZtVziHJ +Km6V8TBG3flmFlC3DWQfNNFwn5+pWSB8WHG4bTA5RyYEEYfpbekMtdoWW/Ro8Kmh +41nuxZDSuBJhDeFIp0ccnN2Lp1o6XfIeDYPegyEPSSZqrudfqLrSZhStDlJgXjea +JjW6UP6txPtYaaila9/Hn6vF87AQ5bR2dEWB/xRJzgNwRiax7KSU0xca6xAuf+TD +xCjZ5pp2JwdCjquXLTmUnbIZ9LGV54UZ/MeiG8yVu6pxbiGnXo4Ekbk6xgi1ewLi +vGmz4QRfVklV0dba3Zj0fRozfZ22qUHxCfDM7ad0eBXMFmHiN8hg3IUHTO+UdlX/ +aH3gADFAvSVDv0v8t6dGc6XE9Dr7mGEFnQMHO4zhM1HaS2Nh0TiL2tFLttLbfG5o +QlxCfXX9/nasj3K9qnlEg9G3+4T7lpdPmZRRe1O8cHCI5imVg6cLIiBLPO16e0fK +yHIgYswLdrJFfaHNYM/SWJxHpX795zn+iCwyvZSlLfH9mlegOeVmj9cyhN/VOmS3 +QRhlYXoA2z7WZTNoC6iAIlyIpMTcZr+ntaGVtFOLS6fwdBqDXjmSQu66mDKwU5Ek +fNlbyrpzZMyFCDWEYo4AIR/18aGZBYUAEQEAAYkCHwQYAQIACQUCU5sktgIbDAAK +CRAWVaCraFdigIPQEACcYh8rR19wMZZ/hgYv5so6Y1HcJNARuzmffQKozS/rxqec +0xM3wceL1AIMuGhlXFeGd0wRv/RVzeZjnTGwhN1DnCDy1I66hUTgehONsfVanuP1 +PZKoL38EAxsMzdYgkYH6T9a4wJH/IPt+uuFTFFy3o8TKMvKaJk98+Jsp2X/QuNxh +qpcIGaVbtQ1bn7m+k5Qe/fz+bFuUeXPivafLLlGc6KbdgMvSW9EVMO7yBy/2JE15 +ZJgl7lXKLQ31VQPAHT3an5IV2C/ie12eEqZWlnCiHV/wT+zhOkSpWdrheWfBT+ac +hR4jDH80AS3F8jo3byQATJb3RoCYUCVc3u1ouhNZa5yLgYZ/iZkpk5gKjxHPudFb +DdWjbGflN9k17VCf4Z9yAb9QMqHzHwIGXrb7ryFcuROMCLLVUp07PrTrRxnO9A/4 +xxECi0l/BzNxeU1gK88hEaNjIfviPR/h6Gq6KOcNKZ8rVFdwFpjbvwHMQBWhrqfu +G3KaePvbnObKHXpfIKoAM7X2qfO+IFnLGTPyhFTcrl6vZBTMZTfZiC1XDQLuGUnd +sckuXINIU3DFWzZGr0QrqkuE/jyr7FXeUJj9B7cLo+s/TXo+RaVfi3kOc9BoxIvy +/qiNGs/TKy2/Ujqp/affmIMoMXSozKmga81JSwkADO1JMgUy6dApXz9kP4EE3g== +=CLGF +-----END PGP PUBLIC KEY BLOCK----- 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..c7c0cd3bc22c62ad4f1a214d4ed777718cdbf74a --- /dev/null +++ b/test/server_side_scripting/ext_table_preview/test_pandas_table_preview.py @@ -0,0 +1,75 @@ +#!/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.xls", "bad.xlsx"]] + + for bfi in badfiles: + print("bfi: ", bfi) + self.assertRaises(ValueError, read_file, + bfi, "."+bfi.split(".")[-1]) diff --git a/tools/copy_into_docker.sh b/tools/copy_into_docker.sh index 41c9dfb9f29beb7ef61855b0702655455ffbe119..efce266a66cd215eb5db3b7d2cc5d1c0cf13298f 100755 --- a/tools/copy_into_docker.sh +++ b/tools/copy_into_docker.sh @@ -26,7 +26,7 @@ set -e # Copy just the publicly accessible files core_dir="$(dirname $0)/../src/core" -container="compose_caosdb-server_1" +container="linkahead" docker_webui_root="/opt/caosdb/git/caosdb-server/caosdb-webui" docker_dir="${docker_webui_root}/src/core/"