diff --git a/.DS_Store b/.DS_Store deleted file mode 100755 index 0728ab12..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.dockerignore b/.dockerignore index 0fa10937..d075d5a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,4 @@ -# * -# !backend -# !qlever -# !manage.py -db -!db/master.sqlite3 -!tests -!requirements.txt -!LICENSE -!README.md +!db +.git !.git/HEAD !.git/refs/heads -!venv diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..2b8cd813 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,63 @@ +name: Docker build and publish + +on: + push: + branches: [ master ] + +concurrency: + # When this is not a pull request, then we want all the docker containers to be pushed, we therefore + # directly fall back to the commit hash which will be distinct for each push to master. + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.sha}}' + cancel-in-progress: true + +jobs: + docker: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Get short sha + id: sha + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + - name: Get PR number + id: pr + run: echo "pr_num=$(git log --format=%s -n 1 | sed -nr 's/.*\(\#([0-9]+)\)/\1/p')" >> $GITHUB_OUTPUT + + - name: Generate image metadata + id: meta + uses: docker/metadata-action@v5 + env: + # We build multiplatform images which have an image index above the + # image manifests. Attach the annotations directly to the image index. + DOCKER_METADATA_ANNOTATIONS_LEVELS: "index" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + + # Push to dockerhub, reuse the cached steps from the previous build. + push: true + + # If this is a push on master, publish with short commit sha + # else use the ref name, which has to be the tag in this case. + # We have to explicitly add the "qlever:latest" tag for it to work correctly, + # see e.g. https://stackoverflow.com/questions/27643017/do-i-need-to-manually-tag-latest-when-pushing-to-docker-public-repository + tags: > + adfreiburg/qlever-ui:latest, + + # Set Annotations and Labels that conform to the OpenContainers + # Annotations Spec + annotations: ${{ steps.meta.outputs.annotations }} + labels: ${{ steps.meta.outputs.labels }} + diff --git a/Dockerfile b/Dockerfile index 08d01846..673b10be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,4 @@ -FROM docker.io/python:3.12.2-alpine3.19 - -LABEL "org.opencontainers.image.url"="https://github.com/ad-freiburg/qlever-ui" -LABEL "org.opencontainers.image.documentation"="https://github.com/ad-freiburg/qlever-ui" -LABEL "org.opencontainers.image.source"="https://github.com/ad-freiburg/qlever-ui" -LABEL "org.opencontainers.image.licenses"="Apache-2.0" -LABEL "org.opencontainers.image.title"="QLever UI" -LABEL "org.opencontainers.image.description"="A user interface for QLever" -LABEL "org.opencontainers.image.base"="docker.io/python:3.10.2-alpine3.15" +FROM index.docker.io/library/python:3.12.4-alpine3.20 ADD requirements.txt /app/requirements.txt @@ -24,17 +16,16 @@ RUN set -ex \ && apk add bash bash-completion make sqlite COPY . /app -# ADD . /app WORKDIR /app -ENV VIRTUAL_ENV /env -ENV PATH /env/bin:$PATH -ENV PYTHONUNBUFFERED 1 +ENV VIRTUAL_ENV="/env" +ENV PATH="/env/bin:${PATH}" +ENV PYTHONUNBUFFERED="1" -# collect static resources +# Collect static resources RUN ./manage.py collectstatic -CMD ["gunicorn", "--bind", ":7000", "--workers", "3", "--limit-request-line", "10000", "qlever.wsgi:application"] +CMD [ "gunicorn", "--bind", ":7000", "--workers", "3", "--limit-request-line", "10000", "qlever.wsgi:application" ] # QLever UI on port 7000 for QLever instance listening on port 7001 # diff --git a/backend/models.py b/backend/models.py index c5efa7ad..30b687c9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -395,7 +395,7 @@ def entityNameQueries(self): def replacePredicatesList(self): data = {} for line in self.replacePredicates.split("\n"): - match = re.search("([\S]+)[\s]+([\S]+)", line) + match = re.search(r"([\S]+)[\s]+([\S]+)", line) if match: predicate, replacement = match.groups() data[predicate] = replacement diff --git a/backend/static/css/style.css b/backend/static/css/style.css index 365319a3..11046d66 100644 --- a/backend/static/css/style.css +++ b/backend/static/css/style.css @@ -59,8 +59,20 @@ Custom Elements } .tooltip .tooltip-inner { background-color: #82B36F; max-width: 600px; word-wrap: break-word; } -.tooltip .tooltip-arrow { border-top-color: #82B36F !important; } +.tooltip .tooltip-arrow { border-top-color: #82B36F !important; border-bottom-color: #82B36F !important; } +.keyword-search-highlight { + text-decoration: underline; + text-decoration-color: #82B36F; + text-underline-position: under; + text-decoration-thickness: 3px; +} + +.keyword-search-hover { + color: #262626; + text-decoration: none; + background-color: #f5f5f5; +} .table > tbody > tr > td:first-child { color: #d3d3d3; } th { color: #82B36F; white-space: nowrap; } diff --git a/backend/static/js/codemirror/modes/sparql/sparql-hint.js b/backend/static/js/codemirror/modes/sparql/sparql-hint.js index e4483461..f456afdb 100755 --- a/backend/static/js/codemirror/modes/sparql/sparql-hint.js +++ b/backend/static/js/codemirror/modes/sparql/sparql-hint.js @@ -420,8 +420,11 @@ function getDynamicSuggestions(context) { } } + // Do not launch AC query when current word starts with ? or for subject AC + // queries when the prefix has length < 3. + var sendSparql = !(word.startsWith('?')) + && !(words.length == 1 && words[0].length < 3); sparqlQuery = ""; - var sendSparql = !(word.startsWith('?')); var sparqlLines = ""; var mode1Query = ""; // mode 1 is context-insensitive var mode2Query = ""; // mode 2 is context-sensitive diff --git a/backend/static/js/keywordSearch.js b/backend/static/js/keywordSearch.js new file mode 100644 index 00000000..5d54899f --- /dev/null +++ b/backend/static/js/keywordSearch.js @@ -0,0 +1,182 @@ +// This variable contains the actual example spans that match the query. +let exampleSpans = {}; +// This variable keeps track of the selected example. +let selectedExample = -1; + +// This removes highlighting from the input string by replacing all occurrences +// of elements with the class `keyword-search-highlight` +// with their inner content. +function unHighlight(input_str) { + const regex = /\(.*?)\<\/span\>/gi; + return input_str.replaceAll(regex, "$1"); +} + +// This highlights specified words or patterns within an input string +// by wrapping them with a element +// with the class `keyword-search-highlight`. +// +// Algorithm: +// 1. Remove any existing highlighting. +// 2. Iterate over each `regex` in the list of `regexes` +// to find matching sections in the input string. +// 3. Consolidate overlapping sections if any. +// 4. Replace the matching sections with HTML tags for highlighting. +// 5. Return the modified string with highlighted words. +function highlightWords(input_str, regexps) { + let return_str = unHighlight(input_str); + // find matching sections + let matching_sections = []; + for (regexp of regexps) { + const matches = input_str.matchAll(regexp); + for (const match of matches) { + matching_sections.push([match.index, match.index + match[0].length]); + } + } + if (matching_sections.length === 0) { + return return_str; + } + // consolidate overlapping sections + matching_sections.sort((a, b) => a[0] - b[0]); + matching_sections = matching_sections.reduce( + (accu, elem) => { + const [last, ...rest] = accu; + if (elem[0] <= last[1]) { + return [[last[0], Math.max(elem[1], last[1])], ...rest]; + } + return [elem].concat(accu); + }, + [matching_sections[0]], + ); + // replace matching sections with highlighting span + matching_sections.forEach(([from, to]) => { + return_str = `${return_str.substring(0, from)}\ +${return_str.substring(from, to)}\ +${return_str.substring(to)}`; + }); + return return_str; +} + +// This filters the list of examples given a string +// containing a space sperated list of regexes. +// The filtering is achieved by hiding non-matching examples +// and highlighting matching ones. +function filterExamples(regexes_str) { + const keywords = regexes_str + .trim() + .split(" ") + .filter((keyword) => { + if (keyword === "") { + return false; + } + try { + new RegExp(keyword); + } catch (error) { + if (error instanceof SyntaxError) { + return false; + } + throw error; + } + return true; + }) + .map((word) => new RegExp(word, "gi")); + let hits = 0; + exampleSpans.each(function (idx) { + const exampleText = $(this).text().trim(); + if (keywords.every((keyword) => exampleText.match(keyword) != null)) { + $(this).addClass("keyword-search-match"); + $(this).parent().parent().show(); + $(this).html(highlightWords(exampleText, keywords)); + hits++; + } else { + $(this).parent().parent().hide(); + } + }); + exampleSpans = $(".keyword-search-match"); + if (hits === 0) { + $("#empty-examples-excuse").show(); + } else { + $("#empty-examples-excuse").hide(); + } +} + +// This creates a debounced version of a function. +// The "debouncing" is implemented by delaying the execution +// for a certain amount of time since the last call. +function debounce(fn, delay = 500) { + let timerId = null; + return (...args) => { + clearTimeout(timerId); + timerId = setTimeout(() => fn(...args), delay); + }; +} + +const filterExamplesDebounced = debounce(filterExamples, 200); + +function cleanup() { + // Calculate the example spans. + exampleSpans = $("ul#exampleList .example-name"); + // Reset the selected example to nothing. + selectedExample = -1; + // Remove artifacts from previous usage. + exampleSpans.each(function (idx) { + $(this).removeClass("keyword-search-match"); + $(this).parent().parent().show(); + $(this).parent().removeClass("keyword-search-hover"); + $(this).text(unHighlight($(this).text())); + }); +} + +$("#exampleKeywordSearchInput").on("keydown", function (event) { + const hover_class = "keyword-search-hover"; + // The down key was pressed. + if (exampleSpans.length > 0) { + if (event.which === 40) { + if (exampleSpans.length > 0) { + $(exampleSpans[selectedExample]).parent().removeClass(hover_class); + selectedExample = (selectedExample + 1) % exampleSpans.length; + $(exampleSpans[selectedExample]).parent().addClass(hover_class); + } + } + // The up key was pressed. + else if (event.which === 38) { + $(exampleSpans[selectedExample]).parent().removeClass(hover_class); + selectedExample = selectedExample - 1; + if (selectedExample == -1) { + selectedExample = exampleSpans.length - 1; + } + $(exampleSpans[selectedExample]).parent().addClass(hover_class); + } + // The enter key was pressed. + else if (event.which === 13 && selectedExample >= 0) { + // The timeout of 50ms is used to prevent the keydown event + // to reach the editor. This is a bit of a hack. + setTimeout(() => { + $(exampleSpans[selectedExample]).parent().click(); + }, 50); + } + } + // The escape key was pressed. + if (event.which === 27) { + $("#examplesDropdownToggle").click(); + } +}); + +$("#exampleKeywordSearchInput").on( + "input", + debounce(function (event) { + cleanup(); + filterExamples(event.target.value); + }, 200), +); + +// This initializes the keyword search when the dropdown has loaded. +$("#exampleList") + .parent() + .on("shown.bs.dropdown", function () { + // Clear value of the input field. + $("#exampleKeywordSearchInput").val(""); + // Focus the input field. + $("#exampleKeywordSearchInput").focus(); + // Cleanup keyword search. + cleanup(); + }); diff --git a/backend/static/js/qleverUI.js b/backend/static/js/qleverUI.js index ac26d369..4c101330 100755 --- a/backend/static/js/qleverUI.js +++ b/backend/static/js/qleverUI.js @@ -831,11 +831,28 @@ function renderRuntimeInformationToDom(entry = undefined) { $("#visualisation").scrollTop(scrollTop); $("#result-tree").scrollLeft(scrollLeft); - // For each node, on mouseover show the details. - $("div.node").hover(function () { - $(this).children(".node-details").show(); - }, function () { - $(this).children(".node-details").hide(); + $("div.node").each(function () { + const details_childs = $(this).children(".node-details"); + if (details_childs.length == 1) { + const top_pos = parseFloat($(this).css('top')); + $(this).attr("data-toggle", "tooltip" ); + $(this).attr("data-html", "true" ); + $(this).attr("data-placement",(top_pos>100?"top":"bottom")); + let detail_html = ''; + const details = JSON.parse(details_childs[0].textContent); + for (const key in details) { + detail_html += `${key}: ${details[key]}
` + } + $(this).attr("title", + `
+
Details
+
+
+ ${detail_html} +
+
`); + $(this).tooltip(); + } }); $("p.node-time"). diff --git a/backend/templates/index.html b/backend/templates/index.html index f359ac3f..7789c6b3 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -163,7 +163,6 @@

QLever UI Shortcuts:

Execute -
+ @@ -233,11 +232,6 @@

QLever UI Shortcuts:

-
- -
diff --git a/backend/templates/partials/head.html b/backend/templates/partials/head.html index 73a6f430..7a25717f 100644 --- a/backend/templates/partials/head.html +++ b/backend/templates/partials/head.html @@ -79,6 +79,7 @@ + diff --git a/db/qleverui.sqlite3 b/db/qleverui.sqlite3 index d7426627..579bf1e0 100644 Binary files a/db/qleverui.sqlite3 and b/db/qleverui.sqlite3 differ diff --git a/qlever/settings.py b/qlever/settings.py index 4432288a..a61fe524 100644 --- a/qlever/settings.py +++ b/qlever/settings.py @@ -126,7 +126,16 @@ STATIC_VERSION = "" -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +STORAGES = { + 'default': { + # Django's default + 'BACKEND': 'django.core.files.storage.FileSystemStorage' + }, + 'staticfiles': { + # Use WhiteNoise (https://whitenoise.readthedocs.io) for static file serving + 'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage' + }, +} try: # Get git info from files in .git diff --git a/qlever/settings_secret.py b/qlever/settings_secret.py index ca7d102d..540a881a 100644 --- a/qlever/settings_secret.py +++ b/qlever/settings_secret.py @@ -1,7 +1,7 @@ # Change these values and rename this file to "settings_secret.py" # SECURITY WARNING: If you are using this in production, do NOT use the default SECRET KEY! # See: https://docs.djangoproject.com/en/3.0/ref/settings/#std:setting-SECRET_KEY -SECRET_KEY = '!!super_secret!!' +SECRET_KEY = 'RlQNe1rnd6XbGoHilGusDD0NhhCktURy' # https://docs.djangoproject.com/en/3.0/ref/settings/#allowed-hosts ALLOWED_HOSTS = ['*'] diff --git a/requirements.txt b/requirements.txt index 960767ee..3c4df3e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -django==5.0.2 -requests==2.31.0 +django==5.0.3 +requests==2.32.0 django-import-export==3.3.7 -gunicorn==21.2.0 +gunicorn==22.0.0 whitenoise[brotli]==6.6.0