diff --git a/README.md b/README.md index cb10b95..f792076 100644 --- a/README.md +++ b/README.md @@ -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: + + + +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 ----------- diff --git a/classify-images.ts b/classify-images.ts index 198693c..4cd4620 100644 --- a/classify-images.ts +++ b/classify-images.ts @@ -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) { @@ -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'}); @@ -55,47 +58,27 @@ fs.closeSync(csvInfo.fd); const buttonsHtml = program.labels.map((label, idx) => { const buttonText = `${label} (${1 + idx})`; - return ``; + return ``; }).join(' '); const widthHtml = program.max_width ? ` width="${program.max_width}"` : ''; -const undoHtml = ` - -
- -
`; +const undoHtml = dedent` + +
+ +
`; let html = buttonsHtml + undoHtml + '\n

'; // Add keyboard shortcuts. 1=first button, etc. -html += ` - -`; +html += dedent` + `; 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'}); diff --git a/localturk.ts b/localturk.ts index 9bb6067..dbf5590 100644 --- a/localturk.ts +++ b/localturk.ts @@ -18,6 +18,7 @@ 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'; @@ -25,16 +26,27 @@ program .version('2.0.0') .usage('[options] template.html tasks.csv outputs.csv') .option('-p, --port ', 'Run on this port (default 4321)', parseInt) + .option('-s, --static-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. @@ -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); @@ -56,19 +69,31 @@ async function renderTemplate({task, numCompleted, numTotal}: TaskStats) { `` ).join('\n'); - return ` - - -${numCompleted} / ${numTotal} - localturk -
-

${numCompleted} / ${numTotal} ${thisFlash}

-${sourceInputs} -${userHtml} -
-
- - -`; + return utils.dedent` + + + ${numCompleted} / ${numTotal} - localturk +
+

${numCompleted} / ${numTotal} ${thisFlash}

+ ${sourceInputs} + ${userHtml} +
+
+ + + + `; } async function readCompletedTasks(): Promise { @@ -119,15 +144,10 @@ async function getNextTask(): Promise { } } -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(); @@ -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); +} diff --git a/sample-template.ts b/sample-template.ts new file mode 100644 index 0000000..5f068f4 --- /dev/null +++ b/sample-template.ts @@ -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('
\n ')} + + + + + `; +} diff --git a/utils.ts b/utils.ts index 606d55c..6b1e67e 100644 --- a/utils.ts +++ b/utils.ts @@ -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; +}