diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f0415a0899b0a71c86894e99dfe526cf8285e35f..c1511756a525f6865835dae77d1ef757afb9fc22 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -40,6 +40,7 @@ linting: stage: test script: - make pylint + allow_failure: true # run qunit tests test: @@ -91,7 +92,7 @@ pages_prepare: &pages_prepare stage: deploy only: refs: - - /^release-.*$/i + - /^releas-e.*$/i script: - npm install -g jsdoc - npm install @indiscale/jsdoc-sphinx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a88702586ec5ba88e3f3702dc343444db5e3e51..ecafb40e7e9b8ace7fc5de494c1331094c842ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +- Button for XLSX export from query. + ### Changed ### ### Deprecated ### diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index 6b15cbf2a696d4faa751ea5eb996fe760995ba0c..ce6a6427cff92ebc00f21e38aa309276dd377eaa 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -69,6 +69,9 @@ BUILD_EXT_REFERENCES_CUSTOM_RESOLVER=caosdb_default_person_reference BUILD_MODULE_EXT_EDITMODE_WYSIWYG_TEXT=DISABLED BUILD_MODULE_EXT_PROPERTY_DISPLAY=DISABLED +# A button that allows to download query results as XLSX. +BUILD_MODULE_EXT_EXPORT_TO_XLSX=ENABLED + ### Put long text property values into a details tag. "DISABLED" means disabled. BUILD_LONG_TEXT_PROPERTY_THRESHOLD_LIST=40 BUILD_LONG_TEXT_PROPERTY_THRESHOLD_SINGLE=140 @@ -173,7 +176,7 @@ JS_DIST_BUNDLE=TRUE ############################################################################## # TRUE means that all javascript sources which are no mentioned in the # MODULE_DEPENDENCIES array will be added in no particular order into the -# build. If you need to guarantee a specific order (in which the are loaded or +# build. If you need to guarantee a specific order (in which they are loaded or # appear in the dit file) you need to add them to the MODULE_DEPENDENCIES. ############################################################################## AUTO_DISCOVER_MODULES=TRUE @@ -182,6 +185,7 @@ AUTO_DISCOVER_MODULES=TRUE # Module dependencies # Override or extend to specify the order of js files in the resulting # bundled js file +# Extend it with `MODULE_DEPENDENCIES+=("a.js", "b.js");` ############################################################################## MODULE_DEPENDENCIES=( jquery.js diff --git a/install-sss.sh b/install-sss.sh index bb2db57649000ba1e701786f56dba575753110eb..a35b43d2a3574e5b05328ed84a12ce6c1f29ea2d 100755 --- a/install-sss.sh +++ b/install-sss.sh @@ -1,9 +1,41 @@ +#!/bin/bash + SRC_DIR=$1 INSTALL_DIR=$2 mkdir -p $INSTALL_DIR -# from here on do your module-wise installing + +# Install always ############################################################## + +# Scripts for which the whole directory shall be installed +INSTALL_FULL_DIR=" + ext_query_to_xlsx +" + +# Scripts for which only *.py files shall be installed +INSTALL_PY_FILES=" + ext_file_download +" + + +# ############################################################################# +# Default implementation follows ############################################## + +echo "Installing to $INSTALL_DIR" + +for src in $INSTALL_FULL_DIR; do + cp -r "$SRC_DIR/$src" "$INSTALL_DIR/" + echo "Installed $src" +done + +for src in $INSTALL_PY_FILES; do + mkdir -p "$INSTALL_DIR/$src" + cp "$SRC_DIR/$src/"*.py "$INSTALL_DIR/$src/" + echo "Installed $src" +done + +# Module specific installations ############################################### # ext_table_preview if [ "${BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW}" == "ENABLED" ]; then @@ -11,7 +43,4 @@ if [ "${BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW}" == "ENABLED" ]; then 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/src/core/js/ext_export_to_xlsx.js b/src/core/js/ext_export_to_xlsx.js new file mode 100644 index 0000000000000000000000000000000000000000..0e471decf1e3831f7ba465c154ecfd7cbb087365 --- /dev/null +++ b/src/core/js/ext_export_to_xlsx.js @@ -0,0 +1,117 @@ +/* + * This file is a part of the LinkAhead Project. + * + * Copyright (C) 2025 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2025 Henrik tom Wörden <h.tomwoerden@indiscale.com> + * Copyright (C) 2025 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/>. + * + */ + +'use strict'; + + +/** + * Export all entites that are in the result set of a query to an xlsx file + * + * @module ext_export_to_xlsx + * @version 0.1 + * + */ +var ext_export_to_xlsx = function ($, logger) { + /** + * Add a button to add all query results to bookmarks. + */ + const add_add_query_results_button = function () { + const row_id = "caosdb-f-export-entities-row" + + // do nothing if already existing + if ($("#" + row_id).length > 0) { + return; + } + + // do nothing if no results + if ($(".caosdb-query-response-results").text().trim() == "0") { + return; + } + + const button_html = $(`<div class="text-end" id=${row_id}> + <button class="btn btn-link" onclick="ext_export_to_xlsx.export_query_results();">Export entities</button> +</div>`)[0]; + const waiting_notification = $(`<p style="display:none">Exporting query results. Please wait and do not reload the page.</p>`)[0]; + + // Add to query results box + $(".caosdb-query-response-heading").append(button_html); + $("#" + row_id).append(waiting_notification); + } + + /** + * Return the SELECT query created from the contents of the query response field + */ + const get_query_from_response = function () { + const orig_query = $(".caosdb-f-query-response-string")[0].innerText; + return orig_query.trim(); + } + + /** + * Execute select query and add all new ids to bookmarks. + */ + const export_query_results = async function () { + const query_string = get_query_from_response(); + const bookmarks_row = $("#caosdb-f-add-query-to-bookmarks-row"); + bookmarks_row.find("button").hide(); + bookmarks_row.find("p").show(); + + const xls_result = await connection.runScript("ext_query_to_xlsx/query_to_xlsx.py", + {"-Oquery": query_string}); + + 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" + + xls_result.getElementsByTagName("script")[0].outerHTML); + } + const filename = xls_result.getElementsByTagName("stdout")[0].textContent; + if (filename.length == 0) { + throw("Server-side script produced no file or did not return the file name: \n" + + xls_result.getElementsByTagName("script")[0].outerHTML); + } + + window.location.href = connection.getBasePath() + "Shared/" + filename; + + + bookmarks_row.find("button").prop("disabled", true).show(); + bookmarks_row.find("p").hide(); + } + + /** + * Initialize this module. + */ + const init = async function (scope) { + add_add_query_results_button(); + } + + return { + init: init, + export_query_results: export_query_results, + } +}($, log.getLogger("ext_export_to_xlsx")); + +$(document).ready(function () { + if ("${BUILD_MODULE_EXT_EXPORT_TO_XLSX}" == "ENABLED") { + // The following is the configuration for the LinkAhead WebUI. + const get_context_root = (() => connection.getBasePath() + "Entity/"); + caosdb_modules.register(ext_export_to_xlsx); + } +}); diff --git a/src/server_side_scripting/ext_query_to_xlsx/query_to_xlsx.py b/src/server_side_scripting/ext_query_to_xlsx/query_to_xlsx.py new file mode 100755 index 0000000000000000000000000000000000000000..6f1f8d1891927d0db8d7b54ce7f3e769a4043d33 --- /dev/null +++ b/src/server_side_scripting/ext_query_to_xlsx/query_to_xlsx.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +# This file is a part of the LinkAhead project. +# +# Copyright (C) 2025 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2025 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/>. + +"""Export query results as an xslx file. +""" + +import argparse +import os +from pathlib import Path +from tempfile import NamedTemporaryFile + +import linkahead as db +from caosadvancedtools.table_json_conversion import export_import_xlsx + + +def _parse_arguments(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('-a', '--auth-token', required=False, + help=("An authentication token. If not provided LinkAhead's " + "pylib will search for other methods of " + "authentication if necessary.")) + parser.add_argument('--query', help="The query for which the results shall be exported.", + type=str) + return parser.parse_args() + + +def main(): + args = _parse_arguments() + + if hasattr(args, "auth_token") and args.auth_token: + try: + db.configure_connection(auth_token=args.auth_token) + except db.LinkAheadConnectionError: + # Try good defaults + db.configure_connection(auth_token=args.auth_token, + url="https://localhost:10443", + ssl_insecure=True, + ) + + tempdir = os.environ["SHARED_DIR"] + outfile = NamedTemporaryFile(delete=False, suffix=".xlsx", dir=tempdir) + outfile_last_two = os.path.join(*(Path(outfile.name).parts[-2:])) # Just the last two parts. + + entities = db.execute_query(args.query, unique=False) + export_import_xlsx.export_container_to_xlsx(records=entities, + include_referenced_entities=True, + xlsx_data_filepath=outfile.name, + ) + + print(outfile_last_two) + + +if __name__ == "__main__": + main() diff --git a/tools/copy_into_docker.py b/tools/copy_into_docker.py new file mode 100755 index 0000000000000000000000000000000000000000..840bba364ffcf74b7807b81f1a5689fa0db30ce4 --- /dev/null +++ b/tools/copy_into_docker.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 + +# This file is a part of the LinkAhead project. +# +# Copyright (C) 2025 IndiScale GmbH <www.indiscale.com> +# Copyright (C) 2025 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/>. + +""" +""" + +import argparse +import os +from pathlib import Path +from subprocess import run +from typing import Optional, Union + + +class DockerManager: + def __init__(self, container: Optional[str] = None, + webui_root: Optional[str] = None, + basename: Optional[str] = None, + ): + """Manages a Docker container. + + Parameters + ---------- + container : str, default="linkahead" + The container name + + webui_root : str, default="/opt/caosdb/git/caosdb-server/caosdb-webui" + The root directory for the webui on the server. + + basename : str, default="caosdb-webui" + Base name of this repository. + """ + if not container: + container = "linkahead" + if not webui_root: + webui_root = "/opt/caosdb/git/caosdb-server/caosdb-webui" + if not basename: + basename = "caosdb-webui" + self.container_name = container + self.webui_root = webui_root + self.basename = basename + + def copy(self, src: Union[str, Path]): + """Copy ``src`` into the Docker container. + + This method tries to guess the correct target inside the webui's root dir. + + Parameters + ---------- + src : Union[str, Path] + The directory or file to copy. + """ + if isinstance(src, str): + src = Path(src) + src = src.resolve() + relative_path = Path(*src.parts[src.parts.index(self.basename) + 1:]) + + target = Path(self.webui_root) / relative_path + + if src.is_dir(): + target = target.parent + command = ["docker", "cp", str(src), f"{self.container_name}:{target}"] + run(command, check=True) + + def make(self): + """Run ``make`` inside the container. + """ + + command = ["docker", "exec", "-u0:0", "-ti", + "-w", self.webui_root, self.container_name, "make"] + run(command) + + +def _mangle_sources(sources: list[str]) -> list[str]: + """Handle special cases of the sources given on the commandline. + + Parameters + ---------- + sources : list[str] + The sources to mangle + + Returns + ------- + out : list[str] + The mangled list. + """ + if "ALL" in sources and len(sources) > 1: + raise ValueError("If ALL is given, this must be the only source.") + if "ALL" in sources: + base = Path(__file__).parents[1] + result = [] + for default in ( + "build.properties.d", + "conf", + "libs", + "node_modules", + "src", + ): + result.append(str(base / default)) + + return result + + return sources.copy() + + +def _parse_arguments(): + """Parse the arguments.""" + parser = argparse.ArgumentParser( + description="Copy stufff into the Docker container, then run the Makefile.") + parser.add_argument("source", type=str, nargs="+", + help="""The thing(s) to copy. + Special case: "ALL" copies everything that may be useful.""" + ) + parser.add_argument('--container', type=str, required=False, + help="Container name. Default is 'linkahead'." + ) + parser.add_argument('--webui-root', type=str, required=False, + help="Webui root in the container. Default is " + "'/opt/caosdb/git/caosdb-server/caosdb-webui'." + ) + parser.add_argument('--repo-basename', type=str, required=False, + help="Basename of this repository. Default is " + "'caosdb-webui'." + ) + return parser.parse_args() + + +def main(): + """The main function of this script.""" + args = _parse_arguments() + mgr = DockerManager(container=args.container, + webui_root=args.webui_root, + basename=args.repo_basename) + sources = args.source + sources = _mangle_sources(sources) + for src in sources: + mgr.copy(src) + mgr.make() + + +if __name__ == "__main__": + main() diff --git a/tools/copy_into_docker.sh b/tools/copy_into_docker.sh index d58a75123b1f9a1c2292df8db9efa1ea6d73a09a..cf1f8f1c4f5d3cb53f90f2ce917a32c623011829 100755 --- a/tools/copy_into_docker.sh +++ b/tools/copy_into_docker.sh @@ -28,8 +28,10 @@ set -e # Copy just the publicly accessible files core_dir="$(dirname $0)/../src/core" container="linkahead" -docker_webui_root="/opt/caosdb/git/linkahead-server/linkahead-webui" +docker_webui_root="/opt/caosdb/git/caosdb-server/caosdb-webui" docker_dir="${docker_webui_root}/src/core/" docker cp "${core_dir}/." "$container:$docker_dir" docker exec -ti -w "$docker_webui_root" "$container" make + +echo -e "\nThis script is deprecated, use the Python script instead."