- Open the "[LimeSurvey]/survey/index.php?r=admin/responses/sa/index/surveyid/[survey-id]".
- Get the HTML of the table element containing the responses into a file.
- Right click the first response, Inspect Element
- Select "<table class="table-striped table">" in the new window/pane that opened.
- Copy the HTML for that element (w/ ctrl+c or cmd+c)
- Paste this into a text editor and save this file (anything works, I'm assuming it's
surveys.html
).
- Install jinja2 and BeautifulSoup4. (
pip install jinja2 beautifulsoup4
) - Run
python nicer-survey-ui.py surveys.html
. - Open the generated
out.html
file in any reasonably modern browser. - Go through the responses in a nicer UI than LimeSurvey. :)
Created
August 26, 2020 12:20
-
-
Save pradyunsg/91060eb4e6b9f424f78b5b990ef466fb to your computer and use it in GitHub Desktop.
Nicer LimeSurvey form UI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import sys | |
from pathlib import Path | |
import jinja2 | |
from bs4 import BeautifulSoup | |
here = Path(__file__).parent | |
file = Path(sys.argv[1]) | |
soup = BeautifulSoup(file.read_text(), "lxml") | |
BREAK_AT = 11 # Initial non response entries in the survey. | |
def _get_question(heading_cell): | |
tag = heading_cell.div | |
if not tag: | |
return (heading_cell.text, None) | |
return (tag.attrs["data-content"], tag.attrs["data-original-title"]) | |
heading_cells = soup.find("thead").find_all("th") | |
questions = [_get_question(cell) for cell in heading_cells[BREAK_AT:]] | |
entries = [] | |
for row in soup.find("tbody").find_all("tr"): | |
cells = row.find_all("td") | |
responses = [tag.text for tag in cells[BREAK_AT:]] | |
meta = {} | |
meta["Answers Given"] = str(len(list(filter(None, responses)))) | |
for key_cell, value_cell in zip(heading_cells[:BREAK_AT], cells[:BREAK_AT]): | |
key = _get_question(key_cell)[0] | |
if not key: | |
continue | |
if key == "completed": | |
value = "YES" if "text-success" in str(value_cell) else "NO" | |
else: | |
value = value_cell.text | |
meta[key] = value | |
entries.append({"responses": responses, "meta": meta}) | |
jt = jinja2.Template((here / "template.jinja-html").read_text()) | |
text = jt.render(questions=questions, entries=entries) | |
(here / "out.html").write_text(text) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>LimeSurvey Rendered</title> | |
<script> | |
var entries = {{ entries| tojson }}; | |
var current_index = 0; | |
// Use the id from the heading if possible. | |
var urlParams = new URLSearchParams(window.location.search); | |
var id_given = urlParams.get("id"); | |
if (id_given != null) { | |
for (let i = 0; i < entries.length; i++) { | |
if (entries[i].meta.id == id_given) { | |
current_index = i; | |
break; | |
} | |
} | |
} | |
// Helpers | |
function updateQueryStringParameter(uri, key, value) { | |
var re = new RegExp("([?&])" + key + "=.*?(&|$)", "i"); | |
var separator = uri.indexOf('?') !== -1 ? "&" : "?"; | |
if (uri.match(re)) { | |
return uri.replace(re, '$1' + key + "=" + value + '$2'); | |
} | |
else { | |
return uri + separator + key + "=" + value; | |
} | |
} | |
function hasClass(elem, className) { | |
return new RegExp(' ' + className + ' ').test(' ' + elem.className + ' '); | |
} | |
function addClass(elem, className) { | |
if (!hasClass(elem, className)) { | |
elem.className += ' ' + className; | |
} | |
} | |
function removeClass(elem, className) { | |
var newClass = ' ' + elem.className.replace(/[\t\r\n]/g, ' ') + ' '; | |
if (hasClass(elem, className)) { | |
while (newClass.indexOf(' ' + className + ' ') >= 0) { | |
newClass = newClass.replace(' ' + className + ' ', ' '); | |
} | |
elem.className = newClass.replace(/^\s+|\s+$/g, ''); | |
} | |
} | |
function escapeHtml(unsafe) { | |
return unsafe | |
.replace(/&/g, "&") | |
.replace(/</g, "<") | |
.replace(/>/g, ">") | |
.replace(/"/g, """) | |
.replace(/'/g, "'"); | |
} | |
// Actual logic | |
function updateContent() { | |
let entry = entries[current_index]; | |
let responses = entry["responses"]; | |
let meta = entry["meta"]; | |
let response_id = meta["id"]; | |
let completed = false; | |
let response_count_elem = document.getElementById("response-count"); | |
let meta_elem = document.getElementById("meta"); | |
let prev_elem = document.getElementById("prev"); | |
let next_elem = document.getElementById("next"); | |
// Fill the "meta" information about the user. | |
response_count_elem.innerHTML = response_id; | |
let metaHTML = ""; | |
for (const [key, value] of Object.entries(meta)) { | |
if (!key.trim()) continue; | |
if (key == "completed") { | |
completed = value == "YES" | |
} | |
metaHTML += ( | |
"<tr class='meta-entry'>" + | |
"<td class='meta-key'>" + key + "</td>" + | |
"<td class='meta-value'>" + escapeHtml(value) + "</td>" + | |
"</tr>" | |
); | |
} | |
meta_elem.innerHTML = metaHTML; | |
if (completed) { | |
addClass(document.body, "completed"); | |
} else { | |
removeClass(document.body, "completed"); | |
} | |
// Enable / Disable the navigation | |
if (current_index == 0) { | |
prev_elem.disabled = true; | |
next_elem.disabled = false; | |
} else if (current_index == (entries.length - 1)) { | |
prev_elem.disabled = false; | |
next_elem.disabled = true; | |
} else { | |
next_elem.disabled = prev_elem.disabled = false; | |
} | |
// Fill the responses into the document | |
for (let i = 0; i < responses.length; i++) { | |
let item = responses[i]; | |
let element = document.getElementById('response-' + i); | |
element.innerHTML = escapeHtml(item) || "<i class='nothing'></i>"; | |
} | |
let newurl = ( | |
window.location.protocol + "//" + | |
window.location.host + window.location.pathname + | |
"?id=" + response_id | |
); | |
window.history.pushState({path: newurl}, '', newurl); | |
} | |
function next() { | |
current_index += 1; | |
updateContent(); | |
} | |
function prev() { | |
current_index -= 1; | |
updateContent(); | |
} | |
</script> | |
<style> | |
body { | |
font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, | |
helvetica neue, helvetica, Ubuntu, roboto, noto, segoe ui, | |
arial, sans-serif; | |
margin: 0; | |
line-height: 1.4; | |
background: #fff0f0; | |
} | |
body.completed { | |
background: #f0fff0; | |
} | |
.container { | |
margin: 1rem auto; | |
padding: 1rem; | |
max-width: 80%; | |
width: 45rem; | |
background: white; | |
} | |
.header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
} | |
button { | |
appearance: none; | |
background: #fff; | |
color: royalblue; | |
border: 1px solid CornflowerBlue; | |
border-radius: 2px; | |
cursor: pointer; | |
display: inline-block; | |
font-size: .8rem; | |
height: 1.8rem; | |
line-height: 1.2rem; | |
outline: 0; | |
padding: .25rem .4rem; | |
text-align: center; | |
text-decoration: none; | |
user-select: none; | |
vertical-align: middle; | |
white-space: nowrap; | |
} | |
button:disabled { | |
opacity: 0.5; | |
cursor: initial; | |
} | |
.float-right { | |
float: right; | |
} | |
.item { | |
padding: 1rem 1rem; | |
} | |
.item-question { | |
font-weight: 500; | |
margin-bottom: 0.25rem; | |
} | |
.item-response { | |
font-weight: 300; | |
white-space: pre-wrap; | |
overflow-x: auto; | |
} | |
/* Indent, and look like a supporting comment. */ | |
.item--comment { | |
padding: 0; | |
} | |
.item--comment .item-response { | |
padding: 0 2rem; | |
} | |
.item--comment .item-question { | |
display: none; | |
} | |
/* Hide comments if there's none */ | |
.item--comment .item-response .nothing { | |
display: none; | |
} | |
#meta { | |
background: white; | |
white-space: nowrap; | |
border-collapse: collapse; | |
} | |
#meta-wrapper { | |
margin: 1rem 0.5rem; | |
padding: 0.5rem; | |
overflow-x: auto; | |
} | |
.container, | |
#meta-wrapper { | |
/* Pretty */ | |
border-radius: 4px; | |
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 1px rgba(0, 0, 0, 0.1); | |
} | |
.nothing::before { | |
display: block; | |
content: "no response."; | |
font-size: 0.75rem; | |
color: red; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="header"> | |
<button id="prev" onClick="prev()">prev</button> | |
<div>Response <span id="response-count">Either JS crashed or you have it disabled.</span></div> | |
<button id="next" onClick="next()">next</button> | |
</div> | |
<div id="meta-wrapper"> | |
<table id="meta"> | |
</table> | |
</div> | |
<div class="items"> | |
{% for question in questions %} | |
<div class="item{% if question[1].endswith("_comment") %} item--comment{% endif %}"> | |
<div class="item-question">{{ question[0] }}</div> | |
<div class="item-response" id="response-{{ loop.index - 1 }}"> | |
Enable javascript to see answers. | |
</div> | |
</div> | |
{%- endfor %} | |
</div> | |
</div> | |
<script> | |
updateContent(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment