Skip to content

Commit

Permalink
Misc changes before 2.0.0 release (#19)
Browse files Browse the repository at this point in the history
* add back static-dir flag

* staticDir

* Implement --write-template

* Add general keyboard shortcuts
  • Loading branch information
danvk authored Dec 8, 2017
1 parent a6383b9 commit 143e827
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 61 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ to each image on your local file system. The results will go in `output.csv`.

For more details, run `classify-images --help`.

Tips & Tricks
-------------

It can be hard to remember the exact format for template files. localturk can help! Run it with
the `--write-template` argument to generate a template file for your input that you can edit:

localturk --write-template tasks.csv > template.html

When you're going through many tasks, keyboard shortcuts can speed things up tremendously.
localturk supports these via the `data-key` attribute on form elements. For example, make yourer
submit button look like this:

<input type="submit" name="result" value="Good" data-key="d">

Now, when you press `d`, it'll automatically click the "Good" button for you. _Note that this
feature is not available on mechanical turk itself!_

Development
-----------

Expand Down
55 changes: 19 additions & 36 deletions classify-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import * as escape from 'escape-html';
import * as fs from 'fs';
import * as program from 'commander';

import {dedent} from './utils';

const temp = require('temp').track();

function list(val) {
Expand All @@ -41,10 +43,11 @@ if (program.args.length == 0) {
}

if (fs.existsSync(program.output)) {
console.warn(`Output file ${program.output} already exists.
Its contents will be assumed to be previously-generated labels.
If you want to start from scratch, either delete this file,
rename it or specify a different output via --output`);
console.warn(dedent`
Output file ${program.output} already exists.
Its contents will be assumed to be previously-generated labels.
If you want to start from scratch, either delete this file,
rename it or specify a different output via --output`);
}

const csvInfo = temp.openSync({suffix: '.csv'});
Expand All @@ -55,47 +58,27 @@ fs.closeSync(csvInfo.fd);

const buttonsHtml = program.labels.map((label, idx) => {
const buttonText = `${label} (${1 + idx})`;
return `<button type="submit" id='${1+idx}' name="label" value="${label}">${escape(buttonText)}</button>`;
return `<button type="submit" data-key='${1+idx}' name="label" value="${label}">${escape(buttonText)}</button>`;
}).join('&nbsp;');

const widthHtml = program.max_width ? ` width="${program.max_width}"` : '';
const undoHtml = `
</form>
<form action="/delete-last" method="POST" style="display: inline-block">
<input type="submit" id="undo-button" value="Undo Last (z)">
</form>`;
const undoHtml = dedent`
</form>
<form action="/delete-last" method="POST" style="display: inline-block">
<input type="submit" id="undo-button" data-key="z" value="Undo Last (z)">
</form>`;
let html = buttonsHtml + undoHtml + '\n<p><img src="${path}" ' + widthHtml + '></p>';

// Add keyboard shortcuts. 1=first button, etc.
html += `
<script>
window.addEventListener("keydown", function(e) {
var code = e.keyCode;
if (code == 90) {
var el = document.getElementById("undo-button");
e.preventDefault();
el.click();
return;
}
var num = null;
if (code >= 48 && code <= 57) num = code - 48; // numbers above keyboard
if (code >= 96 && code <= 105) num = code - 96; // numpad
if (num === null) return;
var el = document.getElementById(num);
if (el) {
e.preventDefault();
el.click();
}
});
</script>
<style>
form { display: inline-block; }
#undo-button { margin-left: 20px; }
</style>`;
html += dedent`
<style>
form { display: inline-block; }
#undo-button { margin-left: 20px; }
</style>`;

fs.writeSync(templateInfo.fd, html);
fs.closeSync(templateInfo.fd);

const args = ['localturk', '-q', '--static_dir', '.', templateInfo.path, csvInfo.path, program.output];
const args = ['localturk', '--static-dir', '.', templateInfo.path, csvInfo.path, program.output];
console.log('Running ', args.join(' '));
child_process.spawn(args[0], args.slice(1), {stdio: 'inherit'});
80 changes: 55 additions & 25 deletions localturk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,35 @@ import open = require('open');
import * as _ from 'lodash';

import * as csv from './csv';
import {makeTemplate} from './sample-template';
import * as utils from './utils';
import { outputFile } from 'fs-extra';

program
.version('2.0.0')
.usage('[options] template.html tasks.csv outputs.csv')
.option('-p, --port <n>', 'Run on this port (default 4321)', parseInt)
.option('-s, --static-dir <dir>',
'Serve static content from this directory. Default is same directory as template file.')
.option('-w, --write-template', 'Generate a stub template file based on the input CSV.')
.parse(process.argv);

const {args} = program;
if (3 !== args.length) {
const {args, writeTemplate} = program;
if (!((3 === args.length && !writeTemplate) ||
(1 === args.length && writeTemplate))) {
program.help();
}
if (writeTemplate) {
// tasks.csv is the only input with --write-template.
args.unshift('');
args.push('');
}

const [templateFile, tasksFile, outputsFile] = args;
const port = program.port || 4321;
// --static-dir is particularly useful for classify-images, where the template file is in a
// temporary directory but the image files could be anywhere.
const staticDir = program['staticDir'] || path.dirname(templateFile);

type Task = {[key: string]: string};
let flash = ''; // this is used to show warnings in the web UI.
Expand All @@ -45,6 +57,7 @@ async function renderTemplate({task, numCompleted, numTotal}: TaskStats) {
for (const k in task) {
fullDict[k] = utils.htmlEntities(task[k]);
}
// Note: these two fields are not available in mechanical turk.
fullDict['ALL_JSON'] = utils.htmlEntities(JSON.stringify(task, null, 2));
fullDict['ALL_JSON_RAW'] = JSON.stringify(task);
const userHtml = utils.renderTemplate(template, fullDict);
Expand All @@ -56,19 +69,31 @@ async function renderTemplate({task, numCompleted, numTotal}: TaskStats) {
`<input type=hidden name="${k}" value="${utils.htmlEntities(v)}">`
).join('\n');

return `
<!doctype html>
<html>
<title>${numCompleted} / ${numTotal} - localturk</title>
<body><form action=/submit method=post>
<p>${numCompleted} / ${numTotal} <span style="background: yellow">${thisFlash}</span></p>
${sourceInputs}
${userHtml}
<hr/><input type=submit />
</form>
</body>
</html>
`;
return utils.dedent`
<!doctype html>
<html>
<title>${numCompleted} / ${numTotal} - localturk</title>
<body><form action=/submit method=post>
<p>${numCompleted} / ${numTotal} <span style="background: yellow">${thisFlash}</span></p>
${sourceInputs}
${userHtml}
<hr/><input type=submit />
</form>
<script>
// Support keyboard shortcuts via, e.g. <.. data-key="1" />
window.addEventListener("keydown", function(e) {
if (document.activeElement !== document.body) return;
var key = e.key;
const el = document.querySelector('[data-key="' + key + '"]');
if (el) {
e.preventDefault();
el.click();
}
});
</script>
</body>
</html>
`;
}

async function readCompletedTasks(): Promise<Task[]> {
Expand Down Expand Up @@ -119,15 +144,10 @@ async function getNextTask(): Promise<TaskStats> {
}
}

if (program['write-template']) {
// TODO(danvk): implement.
process.exit(0);
}

const app = express();
app.use(bodyParser.urlencoded({extended: false}));
app.use(errorhandler());
app.use(express.static(path.resolve(path.dirname(templateFile))));
app.use(express.static(path.resolve(staticDir)));

app.get('/', utils.wrapPromise(async (req, res) => {
const nextTask = await getNextTask();
Expand Down Expand Up @@ -155,7 +175,17 @@ app.post('/delete-last', utils.wrapPromise(async (req, res) => {
res.redirect('/');
}));

app.listen(port);
const url = `http://localhost:${port}`;
console.log('Running local turk on', url);
open(url);

if (writeTemplate) {
(async () => {
const columns = await csv.readHeaders(tasksFile);
console.log(makeTemplate(columns));
})().catch(e => {
console.error(e);
});
} else {
app.listen(port);
const url = `http://localhost:${port}`;
console.log('Running local turk on', url);
open(url);
}
18 changes: 18 additions & 0 deletions sample-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {dedent, htmlEntities} from './utils';

/**
* Write out a sample template file for a given input CSV.
*/
export function makeTemplate(columnNames: string[]) {
const inputs = columnNames.map(column => column + ': ${' + column + '}');
return dedent`
${inputs.join('<br>\n ')}
<!--
Use named form elements to generate output as desired.
Use data-key="x" to set a keyboard shortcut for buttons.
-->
<input type="text" size="80" name="notes" placeholder="Notes go here">
<input type="submit" name="result" data-key="a" value="Class A">
<input type="submit" name="result" data-key="b" value="Class B">`;
}
21 changes: 21 additions & 0 deletions utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,24 @@ export function wrapPromise(
});
};
}

/**
* Removes leading indents from a template string without removing all leading whitespace.
* Taken from tslint.
*/
export function dedent(strings: TemplateStringsArray, ...values: any[]) {
let fullString = strings.reduce((accumulator, str, i) => accumulator + values[i - 1] + str);

// match all leading spaces/tabs at the start of each line
const match = fullString.match(/^[ \t]*(?=\S)/gm);
if (!match) {
// e.g. if the string is empty or all whitespace.
return fullString;
}

// find the smallest indent, we don't want to remove all leading whitespace
const indent = Math.min(...match.map(el => el.length));
const regexp = new RegExp('^[ \\t]{' + indent + '}', 'gm');
fullString = indent > 0 ? fullString.replace(regexp, '') : fullString;
return fullString;
}

0 comments on commit 143e827

Please sign in to comment.