Skip to content

Commit

Permalink
Created a simple search page as a proof-of-concept. (#4)
Browse files Browse the repository at this point in the history
This requires addition of CORS support for the /query endpoint.
  • Loading branch information
LTLA authored Mar 5, 2024
1 parent 65f2281 commit 806883a
Show file tree
Hide file tree
Showing 6 changed files with 471 additions and 2 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/search.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
on:
push:
branches:
- master

name: Demonstration search

jobs:
search:
name: Publish demonstration search
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to GitHub pages 🚀
uses: JamesIves/github-pages-deploy-action@v4
with:
clean: false
branch: gh-pages
folder: html
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ If a SQLite file at `DBPATH` already exists, it will be used directly, so a Sewe
SewerRat will periodically (by default, daily) create a back-up of the index at `DBPATH.backup`.
This can be used to manually recover from problems with the SQLite database by copying the backup to `DBPATH` and restarting the SewerRat instance.

The [`html/`](html) subdirectory contains a minimal search page that queries a local SewerRat instance, also accessible from [GitHub Pages](https://artifactdb.github.com/SewerRat),
Developers can copy this page and change the `base_url` to point to their production instance.

## Registration in more detail

We previously glossed over the registration process by presenting users with the `registerSewerRat` function.
Expand Down
11 changes: 10 additions & 1 deletion handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ func dumpJsonResponse(w http.ResponseWriter, status int, v interface{}) {
contents = []byte("unknown")
}

w.Header()["Content-Type"] = []string { "application/json" }
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(status)
_, err = w.Write(contents)
if err != nil {
Expand Down Expand Up @@ -274,6 +275,14 @@ func newDeregisterFinishHandler(db *sql.DB, verifier *verificationRegistry) func

func newQueryHandler(db *sql.DB, tokenizer *unicodeTokenizer, wild_tokenizer *unicodeTokenizer, endpoint string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.WriteHeader(http.StatusNoContent)
return
}

if r.Method != "POST" {
dumpJsonResponse(w, http.StatusMethodNotAllowed, map[string]string{ "status": "ERROR", "reason": "expected a POST request" })
return
Expand Down
304 changes: 304 additions & 0 deletions html/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="description" content="SewerRat search">
<meta name="keywords" content="SeweRat, search">
<meta name="author" content="Aaron Lun">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<title>SewerRat search</title>

<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/renderjson@1.4.0/renderjson.min.js"></script>
<style>
.renderjson {
margin: 0.1em 0px;
}

.renderjson a {
text-decoration: none;
color: grey;
}
.renderjson .disclosure {
color: grey;
font-size: 150%;
}
</style>

<script type="text/javascript" src="parseQuery.js"></script>
<script type="text/javascript">
var query_body = null;

function populateQueryBody() {
const all_clauses = [];

const errdiv = self.document.getElementById("parse-error");
errdiv.replaceChildren();

const metadiv = self.document.getElementById('metadata');
try {
let parsed = parseQuery(metadiv.value);
all_clauses.push(parsed.metadata);
} catch (e) {
errdiv.appendChild(self.document.createTextNode(e.message));
return false;
}

const user = self.document.getElementById('user');
if (user.value != "") {
all_clauses.push({ "type": "user", "user": user.value });
}

const path = self.document.getElementById('path');
if (path.value != "") {
all_clauses.push({ "type": "path", "path": path.value });
}

const date_from = self.document.getElementById('date-from');
if (date_from.valueAsDate != null) {
all_clauses.push({ "type": "time", "time": date_from.valueAsDate.getTime() / 1000, "after": true });
}

const date_to = self.document.getElementById('date-to');
if (date_to.valueAsDate != null) {
all_clauses.push({ "type": "time", "time": date_to.valueAsDate.getTime() / 1000 });
}

let query;
if (all_clauses.length == 1) {
query = all_clauses[0];
} else {
query = { "type": "and", "children": all_clauses };
}

query_body = JSON.stringify(query);
return;
}

const base_url = "http://0.0.0.0:8080";
var query_next = null;
var sofar = 0;

async function populateSearchResults(request_endpoint, clear_existing) {
let resp = await fetch(base_url + request_endpoint, {
method: "POST",
body: query_body,
headers: {
"Content-Type": "application/json"
}
})

const resdiv = self.document.getElementById('search');
if (clear_existing) {
resdiv.replaceChildren();
sofar = 0;
}
function populateResultError(message) {
let errmsg = self.document.createElement("p");
errmsg.style["color"] = "red";
errmsg.textContent = "Oops, looks like something went wrong (" + message + ")";
resdiv.appendChild(errmsg);
return;
}

if (!resp.ok) {
let message = String(resp.status) + " " + resp.statusText;
try {
let body = await resp.json();
if ("reason" in body) {
message += ": " + body.reason;
}
} catch (e) {}
populateResultError(message);
return;
}

let output;
try {
output = await resp.json();
} catch (e) {
populateResultError(e.message);
return;
}

renderjson.set_icons("⊕", "⊖");
for (var i = 0; i < output.results.length; ++i) {
const x = output.results[i];
const child = self.document.createElement("div");

let counter = self.document.createElement("span");
let global_i = sofar + i + 1;
var col, bg;
if (global_i % 2 === 1) {
col = "black";
bg = "lightgrey";
} else {
col = "white";
bg = "#4D4D4D";
}
counter.textContent = String(global_i);
counter.style = "display: inline-block; min-width: 2em; background-color: " + bg + "; color: " + col + "; margin-right: 5px; padding: 5px; text-align: center";
child.appendChild(counter);

let pchild = self.document.createElement("code");
pchild.textContent = x.path;
child.appendChild(pchild);
child.append(self.document.createTextNode(", created by "));
let uchild = self.document.createElement("code");
uchild.textContent = x.user;
child.appendChild(uchild);
child.append(self.document.createTextNode(" at " + (new Date(x.time * 1000)).toString()));

child.appendChild(renderjson(x.metadata));
resdiv.appendChild(child);
resdiv.appendChild(self.document.createElement("hr"));
}

const nextdiv = self.document.getElementById('search-next');
if ("next" in output) {
query_next = output.next;
sofar += output.results.length;

let newform = document.createElement("form");
newform.onsubmit = continueSearch;
let newbutton = document.createElement("input");
newbutton.type = "submit";
newbutton.value = "More results";
newform.appendChild(newbutton);
nextdiv.appendChild(newform);
} else {
query_next = null;
nextdiv.replaceChildren();
}
return;
}

function startNewSearch() {
populateQueryBody();
populateSearchResults("/query", true)
return false;
}

function continueSearch() {
populateSearchResults(query_next, false)
return false;
}
</script>

<style>
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black;
}

.tooltip .tooltiptext {
visibility: hidden;
background-color: lightgrey;
width: max-content;
max-width: 50vw; /* (as much as you want) */
border-radius: 6px;
padding: 5px 5px;

/* Position the tooltip */
position: absolute;
z-index: 1;
}

.tooltip:hover .tooltiptext {
visibility: visible;
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
</head>

<body>
<h1>SewerRat search</h1>

<form onsubmit="return startNewSearch();">
<div style="display: flex; align-items: center; margin-bottom: 0.8em">
<label for="metadata" style="margin-right: 0.2em">Metadata:</label>
<textarea id="metadata" name="metadata" rows="1" style="width:40vw"></textarea>
<div class="tooltip" style="margin-left: 0.2em">
<i class="fa fa-question-circle" aria-hidden="true"></i>
<span class="tooltiptext">
Free text search on the metadata. Leave empty to skip this filter.
For simple use cases, just enter one or more search terms, and we'll search for metadata files that match all the terms.
<br><br>
Advanced users can use the <code>AND</code> and <code>OR</code> keywords to assemble complex queries.
(Make sure to use all-caps for these keywords.)
This can be combined with parentheses to control precedence, e.g., <code>(a b OR c d) AND (e f)</code>;
otherwise, <code>AND</code> takes precedence over <code>OR</code>.
Note that any sequence of adjacent search terms are implicitly <code>AND</code>,
i.e., the query above can be expanded as <code>((a AND b) OR (c AND d)) AND (e AND f))</code>.
<br><br>
Even more advanced users can prefix any sequence of search terms with the name of a metadata field,
to only search for matches within that field of the metadata file, e.g.,
<code>(title: prostate cancer) AND (genome: GRCh38 OR genome: GRCm38)</code>.
Note that this does not extend to the <code>AND</code> and <code>OR</code> keywords,
i.e., <code>title:foo OR bar</code> will not limit the search for <code>bar</code> to the <code>title</code> field.
<br><br>
Extremely advanced users can attach a <code>%</code> wildcard to any term to enable a partial search,
e.g., <code>neur%</code> will match files with <code>neuron</code>, <code>neural</code>, <code>neurological</code>, etc.
</span>
</div>
<div id="parse-error" style="color:red; margin-left:0.2em"></div>
</div>

<label for="user">User:</label>
<input type="text" id="user" name="user" placeholder="user123">
<div class="tooltip">
<i class="fa fa-question-circle" aria-hidden="true"></i>
<span class="tooltiptext">
Unix ID of the user who created the file.
Leave empty to skip this filter.
</span>
</div>
<br><br>

<label for="path">Path:</label>
<input type="text" id="path" name="path" placeholder="/some/location/on/the/file-system">
<div class="tooltip">
<i class="fa fa-question-circle" aria-hidden="true"></i>
<span class="tooltiptext">
Any substring of the absolute path to the file, e.g.,
<code>/home/user/user1234/foo/bar</code>,
<code>user1234/foo</code>,
<code>foo/bar</code>,
<code>1234/foo/b</code>.
Leave empty to skip this filter.
</span>
</div>
<br><br>

<label for="date-from">Date (from):</label>
<input type="date" id="date-from" name="date-from">
<div class="tooltip">
<i class="fa fa-question-circle" aria-hidden="true"></i>
<span class="tooltiptext">
Only files with modification times after this date are considered.
Leave empty to skip this filter.
</span>
</div>
<br><br>

<label for="date-to">Date (to):</label>
<input type="date" id="date-to" name="date-to">
<div class="tooltip">
<i class="fa fa-question-circle" aria-hidden="true"></i>
<span class="tooltiptext">
Only files with modification times before this date are considered.
Leave empty to skip this filter.
</span>
</div>
<br><br>

<input type="submit" value="Search">
</form>
<hr style="border-width:3px;">

<div id="search"></div>
<div id="search-next"></div>

</body>
</html>
Loading

0 comments on commit 806883a

Please sign in to comment.