diff --git a/app/assets/images/sparql/schema.png b/app/assets/images/sparql/schema.png new file mode 100644 index 0000000000..5638fcdb6b Binary files /dev/null and b/app/assets/images/sparql/schema.png differ diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 0d927c5214..6a06a2df14 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -71,3 +71,4 @@ //= require institution-ror-typeahead //= require fair_data_station //= require DataTables-2.3.1/datatables.min +//= require sparql diff --git a/app/assets/javascripts/sparql.js b/app/assets/javascripts/sparql.js new file mode 100644 index 0000000000..efc9759767 --- /dev/null +++ b/app/assets/javascripts/sparql.js @@ -0,0 +1,151 @@ +document.addEventListener('DOMContentLoaded', function() { + // Get DOM elements + const clearBtn = document.getElementById('clear-query'); + const queryTextarea = document.getElementById('sparql_query'); + + if (clearBtn && queryTextarea) { + clearBtn.addEventListener('click', function() { + queryTextarea.value = ''; + queryTextarea.focus(); + }); + } + + + // Use example query buttons + const useQueryButtons = document.querySelectorAll('.use-query'); + useQueryButtons.forEach(button => { + button.addEventListener('click', function() { + const queryText = this.dataset.query; + if (queryTextarea) { + queryTextarea.value = queryText; + queryTextarea.focus(); + + // Scroll to the query form + queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + // Auto-resize textarea + if (queryTextarea) { + function autoResize() { + queryTextarea.style.height = 'auto'; + queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px'; + } + + queryTextarea.addEventListener('input', autoResize); + autoResize(); // Initial resize + } + + // Handle clicks on URI links to execute DESCRIBE queries + document.addEventListener('click', function(e) { + if (e.target.classList.contains('external-link')) { + e.preventDefault(); // Prevent default link behavior + + const uri = e.target.href; + const describeQuery = `DESCRIBE <${uri}>`; + + if (queryTextarea) { + queryTextarea.value = describeQuery; + queryTextarea.focus(); + + // Auto-resize after setting the query + queryTextarea.style.height = 'auto'; + queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px'; + + // Scroll to the query form + queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // Auto-execute the DESCRIBE query + const form = queryTextarea.closest('form'); + if (form) { + form.submit(); + } + } + } + }); + + // Handle example query dropdown + const exampleQuerySelect = document.getElementById('example-queries'); + + if (exampleQuerySelect && queryTextarea) { + exampleQuerySelect.addEventListener('change', function() { + const selectedOption = this.options[this.selectedIndex]; + + if (selectedOption.value) { + const rawQuery = selectedOption.dataset.query; + + // Decode HTML entities + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = rawQuery; + const query = tempDiv.textContent || tempDiv.innerText || ''; + + // Set the decoded query in the textarea + queryTextarea.value = query; + queryTextarea.focus(); + + // Auto-resize after setting the query + queryTextarea.style.height = 'auto'; + queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px'; + + // Reset dropdown to default + this.selectedIndex = 0; + + // Scroll to the query form + queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + } + + // Example query filter functionality + const queryFilter = document.getElementById('query-filter'); + const queriesContainer = document.getElementById('example-queries-container'); + const noQueriesMessage = document.getElementById('no-queries-message'); + + if (queryFilter && queriesContainer) { + queryFilter.addEventListener('input', function() { + const filterText = this.value.toLowerCase().trim(); + const exampleQueries = queriesContainer.querySelectorAll('.example-query'); + let visibleCount = 0; + + exampleQueries.forEach(function(query) { + const title = query.querySelector('.query-title strong'); + const description = query.querySelector('.text-muted'); + const queryCode = query.querySelector('.query-code'); + + const titleText = title ? title.textContent.toLowerCase() : ''; + const descriptionText = description ? description.textContent.toLowerCase() : ''; + const codeText = queryCode ? queryCode.textContent.toLowerCase() : ''; + + const matchesFilter = filterText === '' || + titleText.includes(filterText) || + descriptionText.includes(filterText) || + codeText.includes(filterText); + + if (matchesFilter) { + query.parentElement.style.display = ''; + visibleCount++; + } else { + query.parentElement.style.display = 'none'; + } + }); + + // Show/hide "no results" message + if (noQueriesMessage) { + if (visibleCount === 0 && filterText !== '') { + noQueriesMessage.style.display = 'block'; + } else { + noQueriesMessage.style.display = 'none'; + } + } + }); + + // Clear filter when escape key is pressed + queryFilter.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + this.value = ''; + this.dispatchEvent(new Event('input')); + } + }); + } +}); \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7138293595..20beee2ff2 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -42,4 +42,5 @@ *= require linked_extended_metadata *= require ror-widget *= require DataTables-2.3.1/datatables.min +*= require sparql */ diff --git a/app/assets/stylesheets/sparql.scss b/app/assets/stylesheets/sparql.scss new file mode 100644 index 0000000000..8e0e08737d --- /dev/null +++ b/app/assets/stylesheets/sparql.scss @@ -0,0 +1,122 @@ +.sparql-interface { + .sparql-textarea { + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + background-color: #f8f9fa; + border: 2px solid #dee2e6; + + &:focus { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + } + } + + .sparql-results { + margin-top: 2rem; + + .table-responsive { + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; // Smooth scrolling on mobile + } + + table { + font-size: 12px; + min-width: 100%; + white-space: nowrap; // Prevent cell content from wrapping + + th { + background-color: #f8f9fa; + font-weight: 600; + min-width: 150px; // Minimum column width + } + + td { + min-width: 150px; // Minimum column width + } + + .external-link { + word-break: break-all; + max-width: 300px; // Limit URI column width + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .code-block { + max-height: 500px; + overflow-y: auto; + border: 1px solid #dee2e6; + border-radius: 4px; + + pre { + background: #f8f9fa; + border: none; + border-radius: 0; + margin: 0; + padding: 1rem; + font-size: 11px; + white-space: pre-wrap; + } + } + } + + .sparql-examples { + margin-top: 2rem; + + .example-query-filter { + margin-bottom: 1.5rem; + + .input-group { + max-width: 400px; + } + } + + .example-query { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; + + .query-title { + margin-bottom: 0.5rem; + + .label { + margin-left: 0.5rem; + } + } + + .query-code { + background: white; + border: 1px solid #ddd; + border-radius: 3px; + padding: 0.75rem; + font-size: 11px; + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + margin: 0.5rem 0; + white-space: pre-wrap; + word-wrap: break-word; + } + + .use-query { + margin-top: 0.5rem; + } + } + } + + .sparql-format-select { + width: auto; + display: inline-block; + } + + .form-actions { + margin-top: 25px; + + .btn + .btn { + margin-left: 0.5rem; + } + } +} \ No newline at end of file diff --git a/app/controllers/sparql_controller.rb b/app/controllers/sparql_controller.rb new file mode 100644 index 0000000000..73d703a7f8 --- /dev/null +++ b/app/controllers/sparql_controller.rb @@ -0,0 +1,101 @@ +require 'sparql/client' +require 'net/http' + +class SparqlController < ApplicationController + layout 'application' + + before_action :rdf_repository_configured? + + def index + # Main SPARQL interface page + flash.now[:error] = 'SPARQL endpoint is configured, but not currently available.' unless rdf_repository_available? + + @example_queries = load_example_queries + + respond_to(&:html) + end + + def query + @sparql_query = params[:sparql_query] || '' + + if rdf_repository_available? + begin + @results = execute_sparql_query(@sparql_query) + rescue StandardError => e + @error = e.message + Rails.logger.error("SPARQL Query Error: #{e.message}") + end + else + @error = 'SPARQL endpoint is configured, but not currently available.' + flash[:error] = @error + end + + status = @error ? :unprocessable_entity : nil # can't use :success but is the default if nil + @results ||= [] + + respond_to do |format| + format.html do + @example_queries = load_example_queries + render :index, status: status + end + format.json { render json: { 'results': @results, 'error': @error }.compact, status: status } + format.xml { render xml: @results.to_xml, status: status } + end + end + + private + + def execute_sparql_query(query) + public_graph = Seek::Rdf::RdfRepository.instance.get_configuration.public_graph + endpoint = Seek::Rdf::RdfRepository.instance.get_configuration.uri + sparql_client = SPARQL::Client.new(endpoint, graph: public_graph) + results = sparql_client.query(query) + convert_sparql_results(results) + end + + def convert_sparql_results(results) + return [] if results.nil? + + # Handle empty collections + return [] if results.respond_to?(:empty?) && results.empty? + + # Handle different result formats + if results.respond_to?(:map) + results.map do |solution| + if solution.respond_to?(:to_h) + solution.to_h.transform_values { |v| v.respond_to?(:to_s) ? v.to_s : v } + elsif solution.respond_to?(:bindings) + solution.bindings.transform_values { |v| v.respond_to?(:to_s) ? v.to_s : v } + else + solution.to_s + end + end + else + # This handles ASK queries (boolean) and any other single values + [{ 'result' => results.to_s }] + end + end + + def rdf_repository_available? + Seek::Rdf::RdfRepository.instance.available? + end + + def rdf_repository_configured? + unless Seek::Rdf::RdfRepository.instance&.configured? + flash[:error] = 'SPARQL endpoint is not configured.' + redirect_to main_app.root_path + end + end + + def load_example_queries + queries_file = Rails.root.join('config', 'sparql_queries.yml') + if File.exist?(queries_file) + YAML.safe_load(File.read(queries_file)) || {} + else + {} + end + rescue StandardError => e + Rails.logger.error("Failed to load SPARQL queries: #{e.message}") + {} + end +end diff --git a/app/helpers/sparql_helper.rb b/app/helpers/sparql_helper.rb new file mode 100644 index 0000000000..ef2ded9f32 --- /dev/null +++ b/app/helpers/sparql_helper.rb @@ -0,0 +1,11 @@ +module SparqlHelper + + def sparql_results_panel_title + " Query Results (#{@results.length} results)" + end + + def sparql_examples_panel_title + ' Example Queries' + end + +end \ No newline at end of file diff --git a/app/views/layouts/navbar/_navbar.html.erb b/app/views/layouts/navbar/_navbar.html.erb index c5d82e32c8..d2d41b0063 100644 --- a/app/views/layouts/navbar/_navbar.html.erb +++ b/app/views/layouts/navbar/_navbar.html.erb @@ -25,6 +25,9 @@ <%= render :partial => 'layouts/navbar/search_bar' if Seek::Config.solr_enabled %>