diff --git a/.gitignore b/.gitignore
index cc9336b6256771f79eb104a8b1325a55352657e1..ef0861267fc42504b306d158ecfd332a44b3f6ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+# -*- mode:conf; -*-
+
 # dot files
 .*
 !/.git*
@@ -8,6 +10,9 @@
 
 # the build dir
 /public
+/sss_bin
+/node_modules/
+/build
 
 # screen logs
 screenlog.*
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 548f86457c3216f5eed7b75b4b81d7f048351248..6caa815dba6c984cc36720866cf863bdd11e1512 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,30 @@ 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:
+    - 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 cdc0bbfb39e53824ff52ea968b8926f4eeb413b3..f2c54f430fd5e7f3ccd11294c8fe0daf5c7a23f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### 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
+* After a SELECT statement now also all referenced files can be downloaded.
+* Automated documentation builds: `make doc`
+
 ### Changed (for changes in existing functionality)
+- enabled and enhanced autocompletion
+
+* Login form is hidden behind another button.
 
 ### Deprecated (for soon-to-be removed features) 
 
@@ -16,6 +34,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Fixed
 
+- #136 (adding reference properties to entities in edit mode)
+- exclude configuration files when reading files from build.properties.d
+- summaries when opening preview
+- #125 special characters like "\t", \"n", "#" are replaced in table
+  download
+
 ### Security (in case of vulnerabilities)
 
 ## [v0.2.1] - 2020-09-07
diff --git a/makefile b/Makefile
similarity index 81%
rename from makefile
rename to Makefile
index 50dd72b500724bbe40801292ab39c3c5cf7ce70f..92f69dfb6234826b752a1c3355e1e812e5b2df61 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,31 @@ run-qunit: test
 		exit 1; \
 	fi
 
+install-sss:
+	@set -a -e ; \
+	pushd build.properties.files ; \
+	for f in ../build.properties.d/* ; do source "$$f" ; done ; \
+	popd ; \
+	./install-sss.sh $(SRC_SSS_DIR) $(SSS_BIN_DIR)
+
+PYTEST ?= pytest-3
+test-sss: install-sss
+	$(PYTEST) -vv $(TEST_SSS_DIR)
+
+
+CMD_COPY_EXT_FILES = cp -i -r -L
 cp-ext:
 	# TODO FIXME Base path for not-XSL-expanded files
 	mkdir -p $(PUBLIC_DIR)/html
 	for f in $(wildcard $(SRC_EXT_DIR)/html/*) ; do \
-		echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/html/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/html/ ; \
 	done
 	for f in $(wildcard $(SRC_EXT_DIR)/js/*) ; do \
-		echo "y" | cp -i -r "$$f" $(PUBLIC_DIR)/js/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$f" $(PUBLIC_DIR)/js/ ; \
 		sed -i "/JS_EXTENSIONS/a \<xsl:element name=\"script\"><xsl:attribute name=\"src\"><xsl:value-of select=\"concat\(\$$basepath, 'webinterface/${BUILD_NUMBER}$${f#$(SRC_EXT_DIR)}'\)\" /></xsl:attribute></xsl:element>" $(PUBLIC_DIR)/xsl/main.xsl ; \
 	done
 	for f in $(wildcard $(SRC_EXT_DIR)/css/*) ; do \
-		echo "y" | cp -i -r "$$f" $(PUBLIC_DIR)/css/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$f" $(PUBLIC_DIR)/css/ ; \
 		sed -i "/CSS_EXTENSIONS/a \<xsl:element name=\"link\"><xsl:attribute name=\"rel\">stylesheet</xsl:attribute><xsl:attribute name=\"href\"><xsl:value-of select=\"concat\(\$$basepath, 'webinterface/${BUILD_NUMBER}$${f#$(SRC_EXT_DIR)}'\)\" /></xsl:attribute></xsl:element>" $(PUBLIC_DIR)/xsl/main.xsl ; \
 		for html in $(PUBLIC_DIR)/html/* ; do \
 			echo "$$html"; \
@@ -146,26 +164,26 @@ cp-ext:
 		done \
 	done
 	for f in $(wildcard $(SRC_EXT_DIR)/pics/*) ; do \
-		echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/pics/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/pics/ ; \
 	done
 	for f in $(wildcard $(SRC_EXT_DIR)/xsl/*) ; do \
-		echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \
 	done
 
 cp-ext-test:
 	for f in $(wildcard $(TEST_EXT_DIR)/js/*) ; do \
-		echo "y" | cp -i -r "$$f" $(PUBLIC_DIR)/js/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$f" $(PUBLIC_DIR)/js/ ; \
 		sed -i "/JS_EXTENSIONS/a \<xsl:element name=\"script\"><xsl:attribute name=\"src\"><xsl:value-of select=\"concat\(\$$basepath, 'webinterface/${BUILD_NUMBER}$${f#$(SRC_EXT_DIR)}'\)\" /></xsl:attribute></xsl:element>" $(PUBLIC_DIR)/xsl/main.xsl ; \
 	done
 	mkdir -p $(PUBLIC_DIR)/html
 	for f in $(wildcard $(TEST_EXT_DIR)/html/*) ; do \
-		echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/html/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/html/ ; \
 	done
 	for f in $(wildcard $(TEST_EXT_DIR)/pics/*) ; do \
-		echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/pics/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/pics/ ; \
 	done
 	for f in $(wildcard $(TEST_EXT_DIR)/xsl/*) ; do \
-		echo "y" | cp -i -r "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \
+		echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \
 	done
 
 cp-conf: convert-yaml
@@ -191,13 +209,13 @@ $(PUBLIC_DIR)/%: $(TEST_EXT_DIR)/%
 	cp -r $< $@
 
 $(LIBS_DIR)/fonts: unzip
-	ln -s $(LIBS_DIR)/bootstrap-3.3.7-dist/fonts $@
+	ln -s $(LIBS_DIR)/bootstrap-3.4.1-dist/fonts $@
 
 $(LIBS_DIR)/js/bootstrap.js: unzip $(LIBS_DIR)/js
-	ln -s $(LIBS_DIR)/bootstrap-3.3.7-dist/js/bootstrap.min.js $@
+	ln -s $(LIBS_DIR)/bootstrap-3.4.1-dist/js/bootstrap.min.js $@
 
 $(LIBS_DIR)/css/bootstrap.css: unzip $(LIBS_DIR)/css
-	ln -s $(LIBS_DIR)/bootstrap-3.3.7-dist/css/bootstrap.min.css $@
+	ln -s $(LIBS_DIR)/bootstrap-3.4.1-dist/css/bootstrap.min.css $@
 
 $(LIBS_DIR)/js/bootstrap-select.js: unzip $(LIBS_DIR)/js
 	ln -s $(LIBS_DIR)/bootstrap-select-1.13.9/dist/js/bootstrap-select.min.js $@
@@ -206,7 +224,7 @@ $(LIBS_DIR)/css/bootstrap-select.css: unzip $(LIBS_DIR)/css
 	ln -s $(LIBS_DIR)/bootstrap-select-1.13.9/dist/css/bootstrap-select.min.css $@
 
 $(LIBS_DIR)/js/jquery.js: unzip $(LIBS_DIR)/js
-	ln -s $(LIBS_DIR)/jquery-3.3.1/jquery-3.3.1.min.js $@
+	ln -s $(LIBS_DIR)/jquery-3.5.1/jquery-3.5.1.min.js $@
 
 $(LIBS_DIR)/js/showdown.js: unzip $(LIBS_DIR)/js
 	ln -s $(LIBS_DIR)/showdown-1.8.6/dist/showdown.min.js $@
@@ -257,17 +275,24 @@ $(LIBS_DIR)/css/leaflet-coordinates.css: unzip $(LIBS_DIR)/css
 	ln -s $(LIBS_DIR)/Leaflet.Coordinates-0.1.5/dist/Leaflet.Coordinates-0.1.5.css $@
 
 $(LIBS_DIR)/js/bootstrap-autocomplete.min.js: unzip $(LIBS_DIR)/js
-	ln -s $(LIBS_DIR)/bootstrap-autocomplete-2.3.0/dist/latest/bootstrap-autocomplete.min.js $@
+	ln -s $(LIBS_DIR)/bootstrap-autocomplete-2.3.5/dist/latest/bootstrap-autocomplete.min.js $@
 
 $(LIBS_DIR)/js/plotly.js: unzip $(LIBS_DIR)/js
 	ln -s $(LIBS_DIR)/plotly.js-1.52.2/dist/plotly.min.js $@
 
+$(LIBS_DIR)/js/pako.js: unzip $(LIBS_DIR)/js
+	ln -s $(LIBS_DIR)/pako-dummy/pako.js $@
+
+$(LIBS_DIR)/js/utif.js: unzip $(LIBS_DIR)/js
+	ln -s $(LIBS_DIR)/UTIF-8205c1f/UTIF.js $@
+
 
 $(addprefix $(LIBS_DIR)/, js css):
 	mkdir $@ || true
 
 .PHONY: clean
 clean:
+	$(RM) -r $(SSS_BIN_DIR)
 	$(RM) -r $(PUBLIC_DIR)
 	for f in $(LIBS_SUBDIRS); do unlink $$f || $(RM) -r $$f || true; done
 	for f in $(patsubst %.zip,%/,$(LIBS_ZIP)); do $(RM) -r $$f; done
@@ -278,11 +303,13 @@ unzip:
 	for f in $(LIBS_ZIP); do unzip -q -o -d libs $$f; done
 
 
-PYLINT = pylint3 -d all -e E,F
+PYLINT ?= pylint3
 PYTHON_FILES = $(subst $(ROOT_DIR)/,,$(shell find $(ROOT_DIR)/ -iname "*.py"))
 pylint: $(PYTHON_FILES)
-	for f in $(PYTHON_FILES); do $(PYLINT) $$f || exit 1; done
+	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..8a2cd006f4e6963b9eb591268d0826b802229ae8 100644
--- a/README_SETUP.md
+++ b/README_SETUP.md
@@ -21,12 +21,14 @@
  * ** end header
 -->
 
-# Folder Structure
+# Getting Started with the Web Interface 
 
-* 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,13 +36,13 @@
 * 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
@@ -49,18 +51,34 @@ files in there which override the default values from `00_default.properties`.
 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`)
+- sphinx-js
+- recommonmark
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/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 2bbc16407ea883a0f6cd37306da3c3a8875fdf25..031c6da4bd8c57d3dda652b7aef62376e2a97b7d 100644
--- a/src/core/js/caosdb.js
+++ b/src/core/js/caosdb.js
@@ -337,6 +337,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.
diff --git a/src/core/js/edit_mode.js b/src/core/js/edit_mode.js
index a4773b247181c841a63ee0107fe75012fb4eed18..9df7505f1e81fd0234aaa4461444535846921885 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.
      */
@@ -1203,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
@@ -1548,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();
         }
@@ -1795,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 1bcf73dea17889bda9858ed78a2f6848e1dc21ee..bd7ec6a9938ff80afbd7dafdf0a734cd443678bc 100644
--- a/src/core/js/ext_bottom_line.js
+++ b/src/core/js/ext_bottom_line.js
@@ -42,8 +42,10 @@
  * @requires load_config (function from caosdb.js)
  * @requires getEntityPath (function from caosdb.js)
  * @requires connection (module from webcaosdb.js)
+ * @requires UTIF (from utif.js library)
+ * @requires ext_table_preview (module from ext_table_preview.js)
  */
-var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntityPath, connection) {
+var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntityPath, connection, UTIF, ext_table_preview) {
 
     /**
      * @property {string|function} create - a function with one parameter
@@ -103,7 +105,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 +205,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 +216,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,
-        },
+        }
 
     ];
 
@@ -231,8 +328,10 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit
             }
         } catch (err) {
             logger.error(err);
-            const err_msg = "An error occured while loading this preview";
-            set_preview_container(entity, err_msg);
+            if (!err._is_bottom_line_error) {
+              err = new BottomLineError(err);
+            }
+            set_preview_container(entity, err.to_html());
         }
     }
 
@@ -460,8 +559,12 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit
         _css_class_preview_container,
         _css_class_preview_container_button,
         _css_class_preview_container_resolvable,
+        BottomLineError: BottomLineError,
+        BottomLineWarning: BottomLineWarning,
     }
-}($, log.getLogger("ext_bottom_line"), resolve_references.is_in_viewport_vertically, load_config, getEntityPath, connection);
+}($, log.getLogger("ext_bottom_line"),
+  resolve_references.is_in_viewport_vertically, load_config, getEntityPath,
+  connection, UTIF, ext_table_preview);
 
 
 /**
@@ -484,20 +587,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;
     }
 
@@ -525,7 +631,7 @@ var plotly_preview = function(logger, ext_bottom_line, plotly) {
 
 // this will be replaced by require.js in the future.
 $(document).ready(function() {
-    if ("${BUILD_MODULE_EXT_BOTTOM_LINE}" === "ENABLED") {
+    if ("${BUILD_MODULE_EXT_BOTTOM_LINE}" == "ENABLED") {
         caosdb_modules.register(plotly_preview);
         caosdb_modules.register(ext_bottom_line);
     }
diff --git a/src/core/js/ext_file_download.js b/src/core/js/ext_file_download.js
new file mode 100644
index 0000000000000000000000000000000000000000..477349b421e03d74aa28fddf6b4748f64ffa9f7b
--- /dev/null
+++ b/src/core/js/ext_file_download.js
@@ -0,0 +1,186 @@
+/*
+ * ** header v3.0
+ * This file is a part of the CaosDB Project.
+ *
+ * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com>
+ * Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * ** end header
+ */
+
+'use strict';
+
+/**
+ * The ext_file_download module provides a very basic preview for table files.
+ *
+ * The preview is generated using a server side script.
+ *
+ * @module ext_file_download 
+ * @version 0.1
+ *
+ * @requires jQuery
+ * @requires log
+ */
+var ext_file_download  = function ($, logger) {
+
+    /**
+     * collect the file ids that will be passed to the zip script
+     *
+     * @return {string[]} array of entity ids
+     */
+    const collect_ids = function(){
+        var properties = $(".caosdb-f-property-value").find(
+            ".caosdb-id.caosdb-f-property-single-raw-value");
+        var id_list = properties.toArray().map(x=>x.textContent);
+        var entities = $("tr[data-entity-id]")
+        id_list = id_list.concat(entities.toArray().map(
+            x=>x.attributes['data-entity-id'].value)); 
+        return id_list
+    };
+
+    /**
+     * chunk list to smaller pieces.
+     *
+     * @param {string[]} list - array of (usually) ids
+     * @param {Number} size - chunk size (use integer)
+     * @return {string[][]} array of array of string from the original list.
+     */
+    const chunk_list = function(list, size){
+        size = size || 20;
+        var pieces = [];
+        var index = 0;
+        while (index < list.length){
+            pieces.push(list.slice(index,index+size));
+            index+=size;
+        }
+
+        return pieces
+    };
+
+
+    /**
+     * create select statement to find files.
+     *
+     * @param {string[]} id_list - array of ids
+     * @return {string} a query string
+     */
+    const query_files_str = function(id_list){
+        var povs = id_list.map(x=>` id=${x} `);
+        const query_str="SELECT ID FROM FILE WITH " + povs.join("or");
+        return query_str;
+    };
+
+    /**
+     * Reduce id list to files. Throw away all ids which do not belong to a
+     * file entity.
+     *
+     * @param {string[]} id_list - array of ids
+     * @return {string[]} array of file ids.
+     */
+    const reduce_ids = async function(id_list){
+        var file_ids = []
+        for (var part of chunk_list(id_list)) {
+            // query for files
+            var result = await query(query_files_str(part));
+            file_ids=file_ids.concat(result.map(x => getEntityID(x)));
+        }
+        return file_ids;
+    };
+
+
+    /**
+     * Callback function for the download files link.
+     *
+     * Collects all the file entities and sends them to a server-side script
+     * which then puts the files into a zip.
+     *
+     * @param {HTMLElement} zip_link - the link element which triggered this
+     *     call and which will be disabled during the execution of this
+     *     function in order to prevent the user from triggering the process
+     *     twice.
+     */
+    const download_files = async function (zip_link) {
+        const onClickValue = zip_link.getAttribute("onClick");
+        try {
+            // remove click event handler which called this function in the first place
+            zip_link.removeAttribute("onClick");
+
+            // add loading info. TODO make an animated one
+            $("#downloadModal").find(".caosdb-f-modal-footer-left").append(
+                createWaitingNotification("Collecting files...")
+            );
+
+            var ids = collect_ids();
+            ids = await reduce_ids(ids);
+            if (ids.length == 0){
+                alert ("There are no file entities in this table.");
+                return;
+            }
+
+            var table = await ext_bookmarks.get_export_table(ids, "", "\t", "\n");
+
+            const result = await connection.runScript(
+                "ext_file_download/zip_files.py",
+                {
+                  "-p0": ids,
+                  "-p1": table,
+                }
+            );
+            const code = result.getElementsByTagName("script")[0].getAttribute("code");
+            if (parseInt(code) > 0) {
+                throw ("An error occurred during execution of the server-side script:\n"
+                    + result.getElementsByTagName("script")[0].outerHTML);
+            }
+            const filename = result.getElementsByTagName("stdout")[0].textContent;
+            if (filename.length == 0) {
+                throw("Server-side script produced no file or did not return the file name: \n"
+                    + result.getElementsByTagName("script")[0].outerHTML);
+            }
+
+
+            // trigger download of generated file
+            caosdb_table_export.go_to_script_results(filename);
+
+            //close modal
+            $("#downloadModal").find(".modal-footer").find(".btn")[0].click();
+        } catch (e) {
+            globalError(e);
+        } finally {
+            removeAllWaitingNotifications($("#downloadModal")[0]);
+            // restore the old click handler - hence a new file is generated with each click.
+            zip_link.setAttribute("onClick", onClickValue);
+        }
+
+    };
+
+    const init = function () {
+        // only enable when init is being called
+        logger.info("init ext_file_download");
+        if (userIsAnonymous()) {
+            $("#caosdb-f-query-select-files").parent().hide();
+        }
+    };
+
+    return {
+        init: init,
+        download_files: download_files,
+        collect_ids: collect_ids,
+        chunk_list: chunk_list,
+    };
+
+}($, log.getLogger("ext_file_download"));
+
+// This module is registered by caosdb_table_export.
diff --git a/src/core/js/ext_references.js b/src/core/js/ext_references.js
index d9829cb9959c1249a4381bd45be9429f7dbef855..7cd597e128e8c09da9134f42f542898fb84a4e53 100644
--- a/src/core/js/ext_references.js
+++ b/src/core/js/ext_references.js
@@ -450,9 +450,11 @@ var resolve_references = new function () {
 
         // Loop over all property values in the container element. Note: each property_value can be a single reference or a list of references.
         for (const property_value of property_values) {
-            const lists = $(property_value).find(
-                ".caosdb-value-list").has(
-                `.${_unresolved_class_name}`);
+            var lists = findElementByConditions(
+                property_value, 
+                x => x.classList.contains("caosdb-value-list"), 
+                x => x.classList.contains("caosdb-preview-container"))
+            lists = $(lists).has(`.${_unresolved_class_name}`);
 
             if (lists.length > 0) {
                 logger.debug("processing list of references", lists);
@@ -525,8 +527,10 @@ var resolve_references = new function () {
 
             // Load all remaining references. These are single reference values
             // and those references from lists which are left for lazy loading.
-            const rs = $(property_value).find(
-                `.${_unresolved_class_name}`);
+            const rs = findElementByConditions(
+                property_value, 
+                x => x.classList.contains(`${_unresolved_class_name}`), 
+                x => x.classList.contains("caosdb-preview-container"));
             for (var i = 0; i < rs.length; i++) {
                 if (resolve_references.is_in_viewport_vertically(
                         rs[i]) &&
diff --git a/src/core/js/ext_table_preview.js b/src/core/js/ext_table_preview.js
new file mode 100644
index 0000000000000000000000000000000000000000..1d9da6fa9334a52de5eb39a66b585c7eb67cd120
--- /dev/null
+++ b/src/core/js/ext_table_preview.js
@@ -0,0 +1,97 @@
+/*
+ * ** header v3.0
+ * This file is a part of the CaosDB Project.
+ *
+ * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com>
+ * Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * ** end header
+ */
+
+'use strict';
+
+/**
+ * The ext_table_preview module provides a very basic preview for table files.
+ *
+ * The preview is generated using a server side script.
+ *
+ * @module ext_table_preview 
+ * @version 0.1
+ *
+ * @requires jQuery
+ * @requires log
+ * @requires getEntityPath
+ * @requires getEntityID
+ * @requires markdown
+ */
+var ext_table_preview  = function ($, logger, connection, getEntityPath, getEntityID, markdown) {
+
+    const get_preview = async function (entity) {
+        try {
+            const script_result = await connection.runScript("ext_table_preview/pandas_table_preview.py",
+                {"-p0": getEntityID(entity)}
+            );
+
+            const code = script_result.getElementsByTagName("script")[0].getAttribute("code");
+            if (parseInt(code) > 1) {
+                return script_result.getElementsByTagName("stderr")[0]
+            } else if (parseInt(code) != 0) {
+                throw ("An error occurred during execution of the server-side "
+                    + "script:\n"
+                    + script_result.getElementsByTagName("stderr")[0]);
+            } else {
+                const tablecontent = script_result.getElementsByTagName("stdout")[0];
+                const unformatted = markdown.textToHtml(tablecontent.textContent)
+                const formatted = $('<div class="table-responsive"/>').append(unformatted);
+                formatted.find("table").addClass("table table-bordered table-condensed").removeAttr("border");
+                return formatted[0];
+            }
+        } catch (err) {
+            if (err.message && err.message.indexOf && err.message.indexOf("HTTP status 403") > -1) {
+                throw new ext_bottom_line.BottomLineWarning("You are not allowed to generate the table preview. Please log in.");
+            } else {
+                throw err;
+            }
+        }
+    };
+
+    const is_table = function (entity) {
+        const path = getEntityPath(entity);
+        return path && (path.toLowerCase().endsWith('.xls') 
+            || path.toLowerCase().endsWith('.xlsx') 
+            || path.toLowerCase().endsWith('.csv') 
+            || path.toLowerCase().endsWith('.tsv'));
+    };
+
+    const init = function () {
+        // only enable when init is being called
+        ext_table_preview.is_table = is_table;
+    };
+
+    return {
+        init: init,
+        get_preview: get_preview,
+        is_table: () => false,
+    };
+
+}($, log.getLogger("ext_table_preview"), connection, getEntityPath, getEntityID, markdown);
+
+// this will be replaced by require.js in the future.
+$(document).ready(function () {
+    if ("${BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW}" == "ENABLED") {
+        caosdb_modules.register(ext_table_preview);
+    }
+});
diff --git a/src/core/js/ext_xls_download.js b/src/core/js/ext_xls_download.js
index 0f607a459b8e502e1d4c451b1636d99ceb01657c..ebeb2ac94ca7bd84cc41ffef7bf3d1abc1982875 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');
-        const columns = table.find("th").toArray()
-            .map(e => e.textContent)
+        return table.find("th").toArray()
+            .map(e => caosdb_table_export._clean_cell(e.textContent))
             .filter(e => e.length > 0);
+    }
+
+    /**
+     * Finds the table in the DOM tree and returns the table content as array
+     *
+     * @return {HTMLElement[]}
+     */
+    this._get_table_content = function() {
+        const table = $('.caosdb-select-table');
         // TODO use entity-panel class in table as well (change in query.xsl
         // and then here)
-        const entities = table.find("tbody tr").toArray();
-        const csv_string = this._get_tsv_string(entities, columns, raw);
-        return csv_string;
+        return table.find("tbody tr").toArray();
+    }
+
+    /**
+     * Encode tsv string
+     *
+     * @param {string} main - the tsv table string.
+     * @return {string} the same with prefix and URL encoded
+     */
+    this._encode_tsv_string = function(main) {
+        const preamble = "data:text/csv;charset=utf-8,";
+        main = encodeURIComponent(main);
+        return `${preamble}${main}`;
     }
 
     /**
-     * Convert all entities to a tsv string with the given columns.
+     * Convert all entities to an encoded tsv string with the given columns.
+     *
+     * 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\t" + columns.join("\t") + "\n"
+        var rows = [];
+        for (const table_row of entities) {
+            rows.push(caosdb_table_export._get_entity_row(table_row, columns, raw).join("\t"));
+        }
+        var rows = rows.join("\n");
+        logger.trace("leave _create_tsv_string ", rows);
+        return `${header}${rows}`;
     }
 
     /**
-     * Return an array of rows with the given columns of the tsv table, one per
-     * entity.
+     * Return an array of cells, one per column, which contain a string
+     * representation of the value of the properties with the same name (as the
+     * column).
      *
-     * @param {HTMLElement[]} entities - entities which are converted to rows
-     *     of the tsv string.
+     * @param {HTMLElement} entity - entity from which the cells are extracted.
      * @param {string[]} columns - array of property names.
      * @param {boolean} raw - if true, the raw entity ids are put into the
      *     cells. Otherwise, the displayed data is used instead.
      * @return {string[]}
      */
-    this._get_tsv_rows = function (entities, columns, raw) {
-        var rows = [];
-        for (const entity of entities) {
-            rows.push(this._get_entity_row(entity, columns, raw));
+    this._get_entity_row = function (entity, columns, raw) {
+        var cells = [getEntityID(entity)];
+        var properties = getProperties(entity);
+
+        for (const column of columns) {
+            var cell = "";
+            for (const property of properties) {
+                if(property.name.toLowerCase() === column.toLowerCase()) {
+                    var value = caosdb_table_export
+                        ._get_property_value(property.html);
+                    if (raw) {
+                        cell = value.raw;
+                    } else if (value.summary) {
+                        cell = value.summary;
+                    } else if (value.pretty) {
+                        cell = value.pretty;
+                    } else {
+                        cell = value.raw;
+                    }
+                }
+            }
+            cells.push(caosdb_table_export._clean_cell(cell));
         }
-        return rows;
-    }
 
+        logger.trace("leave _get_entity_row", cells);
+        return cells;
+    }
 
     /**
      * Return different string representations of the property's value.
@@ -187,73 +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..47c8e0ce0cf0df2599c6ea1f2afee941edfab4cc 100644
--- a/src/core/js/form_elements.js
+++ b/src/core/js/form_elements.js
@@ -9,8 +9,7 @@
  * published by the Free Software Foundation, either version 3 of the
  * License, or (at your option) any later version.
  *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  * GNU Affero General Public License for more details.
  *
@@ -164,6 +163,113 @@ var form_elements = new function () {
     }
 
 
+    this._get_alert_decision = function (key) {
+        return localStorage["form_elements.alert_decision." + key];
+    }
+
+    this._set_alert_decision = function (key, val) {
+        localStorage["form_elements.alert_decision." + key] = val;
+    }
+
+    /**
+     * @type {AlertConfig}
+     * @property {string} [title] - an optional title for the alert.
+     * @property {string} [severity="danger"] - a bootstrap class suffix. Other
+     *     examples: warning, info
+     * @property {string} message - informs the user what they are about to do.
+     * @property {function} proceed_callback - the function which is called
+     *     then the user hits the "Proceed" button.
+     * @property {function} [cancel_callback] - a callback which is called then
+     *     the cancel button is clicked. By default, only the alert is being
+     *     closed an nothing happens.
+     * @property {string} [proceed_text="Proceed"] - the text on the proceed button.
+     * @property {string} [cancel_text="Cancel"] - the text on the cancel button.
+     * @property {string} [remember_my_decision_id] - if this parameter is
+     *     present, a checkbox is appended to the alert ("Don't ask me
+     *     again."). If the checkbox is checked the next time the make_alert
+     *     function is called with the same remember_my_decision_id is created,
+     *     the alert won't show up and the proceed_callback is called without
+     *     any user interaction.
+     * @property {string} [remember_my_decision_text="Don't ask me again."] -
+     *     label text for the checkbox.
+     * @property {HTMLElement} [proceed_button] - an optional custom proceed
+     *     button.
+     * @property {HTMLElement] [cancel_button] - an optional custom cancel
+     *     button.
+     */
+
+    /**
+     * Make an alert, that is a dialog which can intercept a function call and
+     * asks the user to proceed or cancel.
+     *
+     * @param {AlertConfig} config
+     * @return {HTMLElement}
+     */
+    this.make_alert = function (config) {
+        caosdb_utils.assert_string(config.message, "config param `message`");
+        caosdb_utils.assert_type(config.proceed_callback, "function",
+          "config param `proceed_callback`");
+
+        // define some defaults.
+        const title = config.title ? `<h4>${config.title}</h4>` : "";
+        const proceed_text = config.proceed_text || "Proceed";
+        const cancel_text = config.cancel_text || "Cancel";
+        const severity = config.severity || "danger";
+        const remember = !!config.remember_my_decision_id || false;
+
+        // check if alert should be created at all
+        if (remember) {
+          var result = this._get_alert_decision(config.remember_my_decision_id);
+          if (result == "proceed") {
+            // call callback asyncronously and return
+            (async function(){ config.proceed_callback(); })();
+            return undefined;
+          }
+        }
+
+        // create the alert
+        const _alert = $(`<div class="alert alert-${severity}
+              alert-dismissible fade in caosdb-f-form-elements-alert" role="alert">${title}
+            <p>${config.message}</p>
+        </div>`);
+
+        // create the "Don't ask me again" checkbox
+        var checkbox = undefined;
+        if (remember) {
+            const remember_my_decision_text = config.remember_my_decision_text
+                || "Don't ask me again.";
+            checkbox = $(`<p class="checkbox"><label>
+              <input type="checkbox"/> ${remember_my_decision_text}</label></p>`);
+            _alert.append(checkbox);
+        }
+
+
+        // create buttons ...
+        const cancel_button = config.cancel_button || $(`<button type="button" class="btn btn-default caosdb-f-btn-alert-cancel">${cancel_text}</button>`);
+        const proceed_button = config.proceed_button || $(`<button type="button" class="btn btn-${severity} caosdb-f-btn-alert-proceed">${proceed_text}</button>`);
+        _alert.append($("<p/>").append([proceed_button, cancel_button]));
+
+
+        // ... and bind callbacks to the buttons.
+        cancel_button.click(() => {
+            $(_alert).alert('close');
+            if (typeof config.cancel_callback == "function") {
+                config.cancel_callback();
+            }
+        });
+        proceed_button.click(() => {
+            if (remember && checkbox.find("input[type='checkbox']").is(":checked")) {
+                // store this decision.
+                form_elements._set_alert_decision(config.remember_my_decision_id,
+                    "proceed");
+            }
+            $(_alert).alert('close');
+            config.proceed_callback();
+        });
+
+        return _alert[0];
+    }
+
     /**
      * (Re-)set this module's functions to standard implementation.
      */
diff --git a/src/core/js/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/webcaosdb.js b/src/core/js/webcaosdb.js
index df93703d83fafde15908efb40c3ae578824980d9..55337a36a97d6a7ad42aadfe673e429d6d942a0a 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) {
@@ -1503,13 +1706,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 +1737,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 efbf2e6b6e4db0ad7b14cb1c2483157bc79001f1..e414f9af4147714c5f6ff8a03b70309a02ec1446 100644
--- a/src/core/xsl/main.xsl
+++ b/src/core/xsl/main.xsl
@@ -142,6 +142,16 @@
         <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/webcaosdb.js')"/>
       </xsl:attribute>
     </xsl:element>
+    <xsl:element name="script">
+      <xsl:attribute name="src">
+        <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/pako.js')"/>
+      </xsl:attribute>
+    </xsl:element>
+    <xsl:element name="script">
+      <xsl:attribute name="src">
+        <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/utif.js')"/>
+      </xsl:attribute>
+    </xsl:element>
     <script>
       $(document).ready(() => paging.initPaging(window.location.href, <xsl:value-of select="/Response/@count"/> ));
     </script>
@@ -157,7 +167,7 @@
     </xsl:element>
     <xsl:element name="script">
       <xsl:attribute name="src">
-        <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/autocomplete.js')"/>
+        <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_autocomplete.js')"/>
       </xsl:attribute>
     </xsl:element>
     <xsl:element name="script">
@@ -170,6 +180,11 @@
         <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_references.js')"/>
       </xsl:attribute>
     </xsl:element>
+    <xsl:element name="script">
+      <xsl:attribute name="src">
+        <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_table_preview.js')"/>
+      </xsl:attribute>
+    </xsl:element>
     <xsl:element name="script">
       <xsl:attribute name="src">
         <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_xls_download.js')"/>
@@ -195,6 +210,11 @@
         <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/edit_mode.js')"/>
       </xsl:attribute>
     </xsl:element>
+    <xsl:element name="script">
+      <xsl:attribute name="src">
+        <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_file_download.js')"/>
+      </xsl:attribute>
+    </xsl:element>
     <xsl:element name="script">
       <xsl:attribute name="src">
         <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/leaflet.js')"/>
@@ -255,6 +275,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..ca1884aea16d59c7df92de304d3154698dc471bf 100644
--- a/src/core/xsl/query.xsl
+++ b/src/core/xsl/query.xsl
@@ -89,7 +89,7 @@
           </div>
           <div class="col-xs-6 text-right">
             <!-- Trigger the modal with a button -->
-            <button class="btn btn-info btn-sm" data-target="#downloadModal" data-toggle="modal" type="button">Download this table</button>
+            <button class="btn btn-info btn-sm caosdb-v-btn-select" data-target="#downloadModal" data-toggle="modal" type="button">Export</button>
             <!-- Modal -->
             <div class="modal fade text-left" id="downloadModal" role="dialog">
               <div class="modal-dialog">
@@ -102,16 +102,21 @@
                   <div class="modal-body">
                     <p>
                       <a id="caosdb-f-query-select-data-tsv" onclick="downloadTSV(this)" href="#selected_data.tsv" download="selected_data.tsv">
-                        Download TSV File
+                        Download table as TSV File
                       </a>
                       <span class="checkbox" style="margin-top: 0; display: inline; position: absolute; right: 10px"><label><input type="checkbox" name="raw" id="caosdb-table-export-raw-flag-tsv" title="Export raw entity ids instead of the visible page content."/>raw</label></span>
                     </p>
                     <p>
                       <a class="caosdb-v-query-select-data-xsl" onclick="downloadXLS(this)" href="#selected_data.xsl" download="">
-                        Download XLS File
+                        Download table as XLS File
                       </a>
                       <span class="checkbox" style="margin-top: 0; display: inline; position: absolute; right: 10px"><label><input type="checkbox" name="raw" id="caosdb-table-export-raw-flag-xls" title="Export raw entity ids instead of the visible page content."/>raw</label></span>
                     </p>
+                    <p>
+                      <a id="caosdb-f-query-select-files" onclick="ext_file_download.download_files(this)" href="#selected_data.tsv" download="files.zip" title="Collects file entities listed in the table in a zip file. If the entity belonging to a row is a file entity, it will be included.">
+                          Download files referenced in the table
+                      </a>
+                    </p>
                     <hr/>
                     <p>
                       <small>Download this dataset in Python with:</small>
@@ -123,7 +128,13 @@
                     </p>
                   </div>
                   <div class="modal-footer">
-                    <button class="btn btn-default" data-dismiss="modal" type="button">Close</button>
+                    <div class="row" style="margin:0px">
+                      <div class="col-xs-6 caosdb-f-modal-footer-left">
+                      </div>
+                      <div class="col-xs-6">
+                        <button class="btn btn-default" data-dismiss="modal" type="button">Close</button>
+                      </div>
+                    </div>
                   </div>
                 </div>
               </div>
diff --git a/src/doc/Makefile b/src/doc/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..b3c35b4ddbc2849eb6b3bdc223095e275669ad39
--- /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)
+
+.PHONY: doc-help Makefile apidoc
+
+# 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
+	PATH=$(NPM_PATH):$$PATH $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+#	sphinx-build -M html . ../../build/doc
+
+# Not necessary in this repository, apidoc is doone with the sphinx-autoapi extension
+# apidoc:
+# 	@$(SPHINXAPIDOC) -o _apidoc --update --title="CaosDB Server" ../main/
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..988df3c3a2340c6b6b6b7e34f808e0e0496f680e
--- /dev/null
+++ b/src/doc/conf.py
@@ -0,0 +1,209 @@
+# -*- 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.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/']
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..b3a26c96df4f13e22f4b01a6cb0a9f00a1d33981
--- /dev/null
+++ b/src/doc/index.rst
@@ -0,0 +1,34 @@
+
+Welcome to the documentation of CaosDB's web UI!
+================================================
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+   :hidden:
+
+   Getting started <getting_started>
+   Concepts <concepts>
+   tutorials
+   API Index<genindex>
+
+
+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>`.
+
+.. note::
+
+   TODO: Build the index (manually?) like here: https://github.com/mozilla/fathom/edit/master/docs/.
+   Note that :doc:`autoapi/index` still does not have any content.
+
+  .. js:autofunction:: input2caosdbDate
+
+  .. js:autofunction:: getEntityVersion
+
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`search`
diff --git a/src/ext/js/fileupload.js b/src/ext/js/fileupload.js
index fac11efadc3d1b6d364333d68b27904c37c3aa42..e21ccf7f6035a8170bd1a0f4c7f5868d56c83b37 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);
         });
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 50d8cbef8003d6fb9ab383f94405bcfa07270774..ea7b63b9e37943f0bdd4e32499c6c1ea9310a618 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>
@@ -64,11 +67,13 @@
   <script src="js/proj4.js"></script>
   <script src="js/proj4leaflet.js"></script>
   <script src="js/ext_map.js"></script>
+  <script src="js/ext_table_preview.js"></script>
   <script src="js/ext_bottom_line.js"></script>
   <script src="js/ext_revisions.js"></script>
-  <script src="js/autocomplete.js"></script>
+  <script src="js/ext_autocomplete.js"></script>
   <script src="js/ext_sss_markdown.js"></script>
   <script src="js/ext_trigger_crawler_form.js"></script>
+  <script src="js/ext_bookmarks.js"></script>
   <!--EXTENSIONS-->
   <script src="js/modules/webcaosdb.js.js"></script>
   <script src="js/modules/caosdb.js.js"></script>
@@ -80,14 +85,16 @@
   <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>
   <script src="js/modules/ext_map.js.js"></script>
   <script src="js/modules/ext_bottom_line.js.js"></script>
   <script src="js/modules/ext_revisions.js.js"></script>
-  <script src="js/modules/autocomplete.js.js"></script>
+  <script src="js/modules/ext_autocomplete.js.js"></script>
   <script src="js/modules/ext_sss_markdown.js.js"></script>
   <script src="js/modules/ext_trigger_crawler_form.js.js"></script>
+  <script src="js/modules/ext_bookmarks.js.js"></script>
 </body>
 </html>
diff --git a/test/core/js/modules/autocomplete.js.js b/test/core/js/modules/autocomplete.js.js
deleted file mode 100644
index b75acdcad8b08e548423513ccdf78d7ca0ea7359..0000000000000000000000000000000000000000
--- a/test/core/js/modules/autocomplete.js.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * ** header v3.0
- * This file is a part of the CaosDB Project.
- *
- * Copyright (C) 2019 IndiScale GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- *
- * ** end header
- */
-
-'use strict';
-
-QUnit.module("autocomplete.js", {
-	before: function (assert){
-		autocomplete.retrieve_names = async function () {
-			return ['IceCore', 'Bag', 'IceSample', 'IceCream', 'Palette'];
-		}
-	}
-});
-
-QUnit.test("availability", function(assert) {
-	//assert.ok(bootstrap..init, "init available");
-    assert.equal(autocomplete.version, "0.1", "test version");
-    assert.ok(autocomplete.init, "init available");
-});
-
-
-
-QUnit.test("filter", function(assert) {
-    assert.equal(autocomplete.filter('IceCore','Ice'), true, 'test filter')
-    assert.equal(autocomplete.filter('IceCore','iCe'), true, 'test filter')
-    assert.equal(autocomplete.filter('IceCore','Core'), false, 'test filter')
-    assert.equal(autocomplete.filter('Bag','Ice'), false, 'test filter')
-});
-
-QUnit.test("search", async function(assert) {
-
-	var done = assert.async(2);
-	var gcallback = function(expresults){
-		return function (results) {
-			assert.propEqual(
-				results, 
-				expresults, 
-				"test list filter");
-			done();
-		};
-	};
-
-	await autocomplete.search("Ice", gcallback(
-			['IceCore', 'IceSample', 'IceCream']
-	));
-
-	await autocomplete.search("Core", gcallback([]));
-});
-
-QUnit.test("class", function(assert) {
-    assert.ok(autocomplete.toggle_completion , "toggle available");
-    assert.ok(autocomplete.toggle_completion() , "toggle runs");
-});
diff --git a/test/core/js/modules/entity.xsl.js b/test/core/js/modules/entity.xsl.js
index c607ee28bf7888f94e3086abf8e033e1532d0d09..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 b/test/core/js/modules/ext_bottom_line.js.js
similarity index 88%
rename from test/core/js/modules/ext_bottom_line.js
rename to test/core/js/modules/ext_bottom_line.js.js
index 21c92167271f87554a9af881554d33db686d9194..610d95d1b00b3a8bf220a6d39e12ed71ecaa9090 100644
--- a/test/core/js/modules/ext_bottom_line.js
+++ b/test/core/js/modules/ext_bottom_line.js.js
@@ -45,7 +45,7 @@ var ext_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) {
         },
         { "id": "test.success-2",
           "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) !== 'TestPreviewRecord-fall-back'",
-          "create": "(entity) => { return plotly_preview.create_plot([{x: [1,2,3,4,5], y: [1,2,4,8,16]}], { 'xaxis': {'title': 'time [samples]'}}); }"
+          "create": "(entity) => { return plotly_preview.create_plot([{x: [1,2,3,4,5], y: [1,2,4,8,16]}], { 'xaxis': {'title': 'time [samples]'}}, {displaylogo: false}); }"
         }
       ]
     };
@@ -67,7 +67,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) {
@@ -105,7 +105,7 @@ var ext_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) {
                     assert.equal(container.text(), "blablabla");
                     break;
                 case "error":
-                    assert.equal(container.text(), "An error occured while loading this preview");
+                    assert.equal(container.text(), "An error occured while loading this preview.Test Error");
                     break;
                 case "load-forever":
                     assert.equal(container.text(), "Please wait...");
@@ -121,4 +121,12 @@ var ext_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) {
 
     });
 
+    QUnit.test("tiff converter", async function(assert) {
+        let entity_xml = `<Response><File path="../pics/saturn.tif"/></Response>`;
+        const entity = (await transformation.transformEntities(str2xml(entity_xml)))[0];
+        const tiff_preview = await ext_bottom_line._creators.filter((c) => c.id == "_default_creators.tiff_images")[0].create(entity);
+
+        assert.equal($(tiff_preview).find("img").attr("src").slice(0,21), "data:image/png;base64", "decoded tiff to png");
+    });
+
 }($, ext_bottom_line, QUnit);
diff --git a/test/core/js/modules/ext_file_download.js.js b/test/core/js/modules/ext_file_download.js.js
new file mode 100644
index 0000000000000000000000000000000000000000..1103f61304c56259cc1a54a94c0c6b6b201d2aa5
--- /dev/null
+++ b/test/core/js/modules/ext_file_download.js.js
@@ -0,0 +1,65 @@
+/*
+ * ** header v3.0
+ * This file is a part of the CaosDB Project.
+ *
+ * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com>
+ * Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ * ** end header
+ */
+
+'use strict';
+
+QUnit.module("ext_file_download.js", {
+    before: function (assert) {
+        // setup before module
+    },
+    beforeEach: function (assert) {
+        // setup before each test
+    },
+    afterEach: function (assert) {
+        // teardown after each test
+    },
+    after: function (assert) {
+        // teardown after module
+    }
+});
+
+QUnit.test("chunk_list ", function(assert) {
+    const li = [1,2,3,4,5,6,7];
+    const res = ext_file_download.chunk_list(li, 3);
+    assert.equal(res.length, 3, "number of parts");
+    assert.propEqual(res[2], [7], "number of parts");
+});
+
+QUnit.test("collect_ids ", function (assert) {
+    const line = id => $(`<tr data-entity-id="${id}"/>`);
+    const prop_val = x => $(`<div class="caosdb-f-property-value"/>`);
+    const single_val =x =>  $(`<div class="caosdb-f-property-single-raw-value caosdb-id">${x}</div>`);
+
+
+    const line1 = line("34");
+    line1.append([prop_val().append(single_val("5")),prop_val().append(single_val("6"))])
+    $("body").append([line1]);
+
+    const res = ext_file_download.collect_ids()
+    assert.ok(res.indexOf("5") > -1, "missing id");
+    assert.ok(res.indexOf("6") > -1, "missing id");
+    assert.ok(res.indexOf("34") > -1, "missing id");
+
+    line1.remove();
+
+});
diff --git a/test/core/js/modules/ext_xls_download.js.js b/test/core/js/modules/ext_xls_download.js.js
index 4f9dc6c59b156f1f2265acb4b315887536667194..997a89ec21d4a22f49746cbda1c46bb56268f80d 100644
--- a/test/core/js/modules/ext_xls_download.js.js
+++ b/test/core/js/modules/ext_xls_download.js.js
@@ -79,16 +79,14 @@ QUnit.module("ext_xls_download", {
         return str2xml('<response><script code="0" /><stdout>bla</stdout></response>');
     }
 
-    caosdb_table_export._go_to_script_results = function(xls_link, filename) {
-        xls_link.setAttribute(
-            "href",
-            location.protocol + "//" +location.host + "/Shared/" + filename);
+    caosdb_table_export.go_to_script_results = function(filename) {
         assert.equal(filename, "bla", "filename correct");
         done();
     }
 
     var tsv_data = $('<a id="caosdb-f-query-select-data-tsv" />');
-    $(document.body).append(tsv_data);
+    var modal = $('<div id="downloadModal"><div>');
+    $(document.body).append([tsv_data, modal]);
 
 
     var xsl_link = $("<a/>");
@@ -96,12 +94,15 @@ QUnit.module("ext_xls_download", {
 
     await sleep(500);
 
-    assert.ok(xsl_link.attr("href").endsWith("Shared/bla"), xsl_link.attr("href") + " ends with Shared/bla");
-
     tsv_data.remove();
+    modal.remove();
   });
 }
 
+QUnit.test("_clean_cell", function(assert) {
+    assert.equal(caosdb_table_export._clean_cell("\n\t\n\t"), "    ", "No valid content");
+});
+
 QUnit.test("_get_property_value", function(assert) {
     var f = caosdb_table_export._get_property_value;
 
@@ -116,9 +117,15 @@ QUnit.test("_get_tsv_string", function(assert) {
     const entities = $(table).find("tbody tr").toArray();
     assert.equal(entities.length, 2, "two example entities");
 
-    var f = caosdb_table_export._get_tsv_string
+    var f = caosdb_table_export._create_tsv_string 
     var tsv_string = f(entities, ["Bag", "Number"], true);
-    assert.equal(tsv_string, "data:text/csv;charset=utf-8,ID%09Bag%09Number%0A242%096366, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6413%090284%0A2112%09%091101", "tsv generated");
+    var prefix = "data:text/csv;charset=utf-8,"
+    assert.equal(tsv_string, 
+        "ID\tBag\tNumber\n242\t6366, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6413\t02 8   4aaa a\n2112\t\t1101", "tsv generated");
+    tsv_string = caosdb_table_export._encode_tsv_string(tsv_string);
+    assert.equal(tsv_string.slice(0,prefix.length), prefix);
+    assert.equal(decodeURIComponent(tsv_string.slice(prefix.length, tsv_string.length)), 
+        "ID\tBag\tNumber\n242\t6366, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6413\t02 8   4aaa a\n2112\t\t1101", "tsv generated");
 });
 
 QUnit.test("_get_property_value", function (assert) {
diff --git a/test/core/js/modules/form_elements.js.js b/test/core/js/modules/form_elements.js.js
index 7f5930cd4253f408edf785045db10290697cd6c3..f8bf1ed1ac1495a8a3688aeb7b0cce387c8b69fb 100644
--- a/test/core/js/modules/form_elements.js.js
+++ b/test/core/js/modules/form_elements.js.js
@@ -541,3 +541,91 @@ QUnit.test("field_ready", function(assert) {
     });
 });
 
+{
+const sleep = (ms) => {
+  return new Promise(res => setTimeout(res, ms))
+}
+
+QUnit.test("make_alert - cancel", async function(assert) {
+    var cancel_callback = assert.async()
+    var _alert = form_elements.make_alert({
+        message: "message",
+        proceed_callback: () => {assert.ok(false, "this should not be called");},
+        cancel_callback: cancel_callback,
+    });
+    $("body").append(_alert);
+
+    assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 1, "alert is there");
+    $(_alert).find("button.caosdb-f-btn-alert-cancel")[0].click();
+    await sleep(500);
+
+    assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 0, "has been removed");
+
+
+});
+
+QUnit.test("make_alert - proceed", async function(assert) {
+    var proceed_callback = assert.async();
+    var _alert = form_elements.make_alert({
+        message: "message",
+        proceed_callback: proceed_callback,
+    });
+    assert.equal($(_alert).find("[type='checkbox']").length, 0, "no remember checkbox");
+
+    $("body").append(_alert);
+    assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 1, "alert is there");
+    $(_alert).find("button.caosdb-f-btn-alert-proceed")[0].click();
+    await sleep(500);
+
+    assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 0, "has been removed");
+
+});
+
+QUnit.test("make_alert - remember", async function(assert) {
+    form_elements._set_alert_decision("unittests", "");
+
+    var proceed_callback = assert.async(3);
+    var _alert = form_elements.make_alert({
+        message: "message",
+        proceed_callback: proceed_callback,
+        remember_my_decision_id: "unittests",
+    });
+    assert.equal($(_alert).find("[type='checkbox']").length, 1, "has remember checkbox");
+
+    // append for the first time
+    $("body").append(_alert);
+    assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 1, "alert is there");
+
+    // click proceed without "don't ask me again".
+    $(_alert).find("button.caosdb-f-btn-alert-proceed")[0].click();
+    await sleep(500);
+
+    form_elements._set_alert_decision("unittests", "");
+
+    assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 0, "has been removed");
+
+    // append 2nd time
+    _alert = form_elements.make_alert({
+        message: "message",
+        proceed_callback: proceed_callback,
+        remember_my_decision_id: "unittests",
+    });
+    assert.equal($(_alert).find("[type='checkbox']").length, 1, "has remember checkbox");
+    $("body").append(_alert);
+    assert.equal($("body").find(".caosdb-f-form-elements-alert").length, 1, "alert is there");
+    $(_alert).find("[type='checkbox']").prop("checked", true);
+
+    $(_alert).find("button.caosdb-f-btn-alert-proceed")[0].click();
+    await sleep(500);
+    form_elements._set_alert_decision("unittests", "proceed");
+
+    // try 3rd time
+    _alert = form_elements.make_alert({
+        message: "message",
+        proceed_callback: proceed_callback,
+        remember_my_decision_id: "unittests",
+    });
+    assert.equal(typeof _alert, "undefined", "alert was not created, proceed callback was called third time");
+});
+
+}
diff --git a/test/core/js/modules/webcaosdb.js.js b/test/core/js/modules/webcaosdb.js.js
index 239758161b8419a8a5e15799c67c909977f1fbf0..7b3f7abf404f261668c18690df337b73794dd8bd 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,80 @@ 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();
+});
+
+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..ae0a856f106557be3712f303b06a99f6220ef827 100644
--- a/test/core/xml/table_export/test_case_select_table_1.xml
+++ b/test/core/xml/table_export/test_case_select_table_1.xml
@@ -10,7 +10,7 @@
   </Query>
   <Record id="242">
     <Property id="117" name="Number" datatype="TEXT" importance="FIX">
-      0284
+      02&#x9;8&#xA;&#xA;&#xA;4aaa&#x9;a
     </Property>
     <Property id="104" name="Bag" datatype="Bag" importance="FIX">
       6366
diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile
index a19843fb3de11bbedf1c01f71619470bcc99f75c..7340ee5a7bb94aacc13ff582f1d05221419a312b 100644
--- a/test/docker/Dockerfile
+++ b/test/docker/Dockerfile
@@ -1,7 +1,15 @@
-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
+RUN echo "deb http://deb.debian.org/debian buster-backports main" >> /etc/apt/sources.list \
+    && apt-get update \
+    && apt-get install -y \
+      firefox-esr gettext-base pylint3 python3-pip \
+      python3-httpbin git curl x11-apps xvfb unzip python3-pytest \
+    && apt-get install -y -t buster-backports \
+      npm
 
+RUN pip3 install caosdb
+RUN pip3 install pandas xlrd
+RUN pip3 install git+https://gitlab.com/caosdb/caosdb-advanced-user-tools.git@dev
+# For automatic documentation
+RUN npm install -g jsdoc
+RUN pip3 install sphinx-js sphinx-autoapi recommonmark sphinx-rtd-theme
diff --git a/test/server_side_scripting/ext_table_preview/__pycache__/pandas_table_preview.cpython-37.pyc b/test/server_side_scripting/ext_table_preview/__pycache__/pandas_table_preview.cpython-37.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..613d9dce64b94c3b4c66891f22cd02a6c337dff6
Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/__pycache__/pandas_table_preview.cpython-37.pyc differ
diff --git a/test/server_side_scripting/ext_table_preview/__pycache__/test_pandas_table_preview.cpython-37-pytest-6.0.2.pyc b/test/server_side_scripting/ext_table_preview/__pycache__/test_pandas_table_preview.cpython-37-pytest-6.0.2.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c3563a411e0c836d2613ab7189dc6833be735e00
Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/__pycache__/test_pandas_table_preview.cpython-37-pytest-6.0.2.pyc differ
diff --git a/test/server_side_scripting/ext_table_preview/data/bad.csv b/test/server_side_scripting/ext_table_preview/data/bad.csv
new file mode 100644
index 0000000000000000000000000000000000000000..d29a9312a387186beb8bf4f77a8ec0e4b0ab80fa
Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/bad.csv differ
diff --git a/test/server_side_scripting/ext_table_preview/data/bad.tsv b/test/server_side_scripting/ext_table_preview/data/bad.tsv
new file mode 100644
index 0000000000000000000000000000000000000000..d29a9312a387186beb8bf4f77a8ec0e4b0ab80fa
Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/bad.tsv differ
diff --git a/test/server_side_scripting/ext_table_preview/data/bad.xls b/test/server_side_scripting/ext_table_preview/data/bad.xls
new file mode 100644
index 0000000000000000000000000000000000000000..1f31bf2754258e3d07f88fd1e6bdee4d7b11bee1
Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/bad.xls differ
diff --git a/test/server_side_scripting/ext_table_preview/data/bad.xlsx b/test/server_side_scripting/ext_table_preview/data/bad.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..1f31bf2754258e3d07f88fd1e6bdee4d7b11bee1
Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/bad.xlsx differ
diff --git a/test/server_side_scripting/ext_table_preview/data/server_error.csv b/test/server_side_scripting/ext_table_preview/data/server_error.csv
new file mode 100644
index 0000000000000000000000000000000000000000..3e770df012f65d73ce4721a5f65d7e3f39959519
--- /dev/null
+++ b/test/server_side_scripting/ext_table_preview/data/server_error.csv
@@ -0,0 +1 @@
+Hi,  this line contains a unicode backspace. This causes a server error, when pandas_table_preview.py's output is serialized into XML.
\ No newline at end of file
diff --git a/test/server_side_scripting/ext_table_preview/data/test.csv b/test/server_side_scripting/ext_table_preview/data/test.csv
new file mode 100644
index 0000000000000000000000000000000000000000..7c9bfd1354393439f551021cfe340577433ce2aa
--- /dev/null
+++ b/test/server_side_scripting/ext_table_preview/data/test.csv
@@ -0,0 +1,12 @@
+# test header
+# two lines
+A1,B1,C1,D1,E1,F1,G1,H1,I1,J1,K1,L1,M1,N1,O1,P1,Q1,R1,S1,T1
+A2,B2,C2,D2,E2,F2,G2,H2,I2,J2,K2,L2,M2,N2,O2,P2,Q2,R2,S2,T2
+A3,B3,csvfile,D3,E3,F3,G3,H3,I3,J3,K3,L3,M3,N3,O3,P3,Q3,R3,S3,T3
+A5,B5,C5,D5,E5,F5,G5,H5,I5,J5,K5,L5,M5,N5,O5,P5,Q5,R5,S5,T5
+A6,B6,C6,D6,E6,F6,G6,H6,I6,J6,K6,L6,M6,N6,O6,P6,Q6,R6,S6,T6
+A7,B7,csvfile,D7,E7,F7,G7,H7,I7,J7,K7,L7,M7,N7,O7,P7,Q7,R7,S7,T7
+A8,B8,C8,D8,E8,F8,G8,H8,I8,J8,K8,L8,M8,N8,O8,P8,Q8,R8,S8,T8
+A9,B9,C9,D9,E9,F9,G9,H9,I9,J9,K9,L9,M9,N9,O9,P9,Q9,R9,S9,T9
+A10,B10,C10,D10,E10,F10,G10,H10,I10,J10,K10,L10,M10,N10,O10,P10,Q10,R10,S10,T10
+A11,B11,C11,D11,E11,F11,G11,H11,I11,J11,K11,L11,M11,N11,O11,P11,Q11,R11,S11,T11
diff --git a/test/server_side_scripting/ext_table_preview/data/test.tsv b/test/server_side_scripting/ext_table_preview/data/test.tsv
new file mode 100644
index 0000000000000000000000000000000000000000..863f692bf64e7dcabf74703587a93e37adf27e67
--- /dev/null
+++ b/test/server_side_scripting/ext_table_preview/data/test.tsv
@@ -0,0 +1,12 @@
+# test header
+# two lines
+A1	B1	C1	D1	E1	F1	G1	H1	I1	J1	K1	L1	M1	N1	O1	P1	Q1	R1	S1	T1
+A2	B2	C2	D2	E2	F2	G2	H2	I2	J2	K2	L2	M2	N2	O2	P2	Q2	R2	S2	T2
+A3	B3	csvfile	D3	E3	F3	G3	H3	I3	J3	K3	L3	M3	N3	O3	P3	Q3	R3	S3	T3
+A5	B5	C5	D5	E5	F5	G5	H5	I5	J5	K5	L5	M5	N5	O5	P5	Q5	R5	S5	T5
+A6	B6	C6	D6	E6	F6	G6	H6	I6	J6	K6	L6	M6	N6	O6	P6	Q6	R6	S6	T6
+A7	B7	tsvfile	D7	E7	F7	G7	H7	I7	J7	K7	L7	M7	N7	O7	P7	Q7	R7	S7	T7
+A8	B8	C8	D8	E8	F8	G8	H8	I8	J8	K8	L8	M8	N8	O8	P8	Q8	R8	S8	T8
+A9	B9	C9	D9	E9	F9	G9	H9	I9	J9	K9	L9	M9	N9	O9	P9	Q9	R9	S9	T9
+A10	B10	C10	D10	E10	F10	G10	H10	I10	J10	K10	L10	M10	N10	O10	P10	Q10	R10	S10	T10
+A11	B11	C11	D11	E11	F11	G11	H11	I11	J11	K11	L11	M11	N11	O11	P11	Q11	R11	S11	T11
diff --git a/test/server_side_scripting/ext_table_preview/data/test.xls b/test/server_side_scripting/ext_table_preview/data/test.xls
new file mode 100644
index 0000000000000000000000000000000000000000..a355756b9ab72f9035246c5303800a2076d9bfc0
Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/test.xls differ
diff --git a/test/server_side_scripting/ext_table_preview/data/test.xlsx b/test/server_side_scripting/ext_table_preview/data/test.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..bc291f1aa86cd6d550320f07a7ce69cf813b8116
Binary files /dev/null and b/test/server_side_scripting/ext_table_preview/data/test.xlsx differ
diff --git a/test/server_side_scripting/ext_table_preview/data/xss_attack.csv b/test/server_side_scripting/ext_table_preview/data/xss_attack.csv
new file mode 100644
index 0000000000000000000000000000000000000000..e7d43505aef42c397f1859805bc87aab8b6da1a2
--- /dev/null
+++ b/test/server_side_scripting/ext_table_preview/data/xss_attack.csv
@@ -0,0 +1,8 @@
+# as it seems all these characters are escaped correctly.
+"![Alt text](url/to/image)","%3C","&lt","&lt;","&LT","&LT;","&#60","&#060","&#0060","&#00060"
+"&#000060","&#0000060","&#60;","&#060;","&#0060;","&#00060;","&#000060;","&#0000060;","&#x3c","&#x03c"
+"&#x003c","&#x0003c","&#x00003c","&#x000003c","&#x3c;","&#x03c;","&#x003c;","&#x0003c;","&#x00003c;","&#x000003c;"
+"&#X3c","&#X03c","&#X003c","&#X0003c","&#X00003c","&#X000003c","&#X3c;","&#X03c;","&#X003c;","&#X0003c;"
+"&#X00003c;","&#X000003c;","&#x3C","&#x03C","&#x003C","&#x0003C","&#x00003C","&#x000003C","&#x3C;","&#x03C;"
+"&#x003C;","&#x0003C;","&#x00003C;","&#x000003C;","&#X3C","&#X03C","&#X003C","&#X0003C","&#X00003C","&#X000003C"
+"&#X3C;","&#X03C;","&#X003C;","&#X0003C;","&#X00003C;","&#X000003C;","\x3c","\x3C","\u003c","\u003C"
diff --git a/test/server_side_scripting/ext_table_preview/pandas_table_preview.py b/test/server_side_scripting/ext_table_preview/pandas_table_preview.py
new file mode 120000
index 0000000000000000000000000000000000000000..f24b3901ce8610fd02fc28468b747b7171834307
--- /dev/null
+++ b/test/server_side_scripting/ext_table_preview/pandas_table_preview.py
@@ -0,0 +1 @@
+../../../src/server_side_scripting/ext_table_preview/pandas_table_preview.py
\ No newline at end of file
diff --git a/test/server_side_scripting/ext_table_preview/requirements.txt b/test/server_side_scripting/ext_table_preview/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4628529ba9dce50a08d574e21d3b4a71b50af2b1
--- /dev/null
+++ b/test/server_side_scripting/ext_table_preview/requirements.txt
@@ -0,0 +1,3 @@
+caosdb
+caosadvancedtools
+pandas
diff --git a/test/server_side_scripting/ext_table_preview/test_pandas_table_preview.py b/test/server_side_scripting/ext_table_preview/test_pandas_table_preview.py
new file mode 100644
index 0000000000000000000000000000000000000000..00d1c7f38746abe437abc76cd51b29600adcd049
--- /dev/null
+++ b/test/server_side_scripting/ext_table_preview/test_pandas_table_preview.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+# encoding: utf-8
+#
+# ** header v3.0
+# This file is a part of the CaosDB Project.
+#
+# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com>
+# Copyright (C) 2020 Henrik tom Wörden <h.tomwoerden@indiscale.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+#
+# ** end header
+#
+
+import os
+import unittest
+
+import caosdb as db
+from caosdb.common.models import _parse_single_xml_element
+from lxml import etree
+from pandas_table_preview import (MAXIMUMFILESIZE, create_table_preview,
+                                  ending_is_valid, read_file, size_is_ok)
+
+
+class PreviewTest(unittest.TestCase):
+    def test_file_ending(self):
+        self.assertFalse(ending_is_valid("/this/is/no/xls.lol"))
+        self.assertFalse(ending_is_valid("xls.lol"))
+        self.assertFalse(ending_is_valid("ag.xls.lol"))
+        assert ending_is_valid("/this/is/a/lol.xls")
+        assert ending_is_valid("/this/is/a/lol.csv")
+        assert ending_is_valid("/this/is/a/lol.cSv")
+        assert ending_is_valid("/this/is/a/lol.CSV")
+        assert ending_is_valid("lol.CSV")
+
+    def test_file_size(self):
+        entity_xml = ('<File id="1234" name="SomeFile" '
+                      'path="/this/path.tsv" size="{size}"></File>')
+        small = _parse_single_xml_element(
+            etree.fromstring(entity_xml.format(size="20000")))
+
+        assert size_is_ok(small)
+        large = _parse_single_xml_element(
+            etree.fromstring(entity_xml.format(
+                size=str(int(MAXIMUMFILESIZE+1)))))
+        assert not size_is_ok(large)
+
+    def test_output(self):
+        files = [os.path.join(os.path.dirname(__file__), "data", f)
+                 for f in ["test.csv", "test.tsv", "test.xls", "test.xlsx"]]
+
+        for fi in files:
+            table = read_file(fi, ftype="."+fi.split(".")[-1])
+            searchkey = fi.split(".")[-1]+"file"
+            print(table)
+            assert (table == searchkey).any(axis=None)
+
+        badfiles = [os.path.join(os.path.dirname(__file__), "data", f)
+                    for f in ["bad.csv", "bad.tsv", "bad.xls", "bad.xlsx"]]
+
+        for bfi in badfiles:
+            self.assertRaises(ValueError, read_file,
+                              bfi, "."+bfi.split(".")[-1])