Skip to content

Commit

Permalink
Set up prettier, knip and jest (#47)
Browse files Browse the repository at this point in the history
* add prettier dep

* prettier

* set up knip

* migrate to jest
  • Loading branch information
danvk authored Sep 22, 2024
1 parent 558a9f4 commit a67244d
Show file tree
Hide file tree
Showing 12 changed files with 2,694 additions and 266 deletions.
9 changes: 8 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,17 @@ jobs:
run: yarn

- name: Log Versions
run: yarn tsc --version && yarn mocha --version
run: yarn tsc --version && yarn jest --version

- name: Prettier
run: yarn format:check

- name: Type Check
run: yarn tsc

# knip runs after tsc so that files can reference the `dist` dir.
- name: Knip
run: yarn knip

- name: Unit tests
run: yarn test
40 changes: 25 additions & 15 deletions classify-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,29 @@ program
.version('2.1.1')
.usage('[options] /path/to/images/*.jpg | images.txt')
.option('-p, --port <n>', 'Run on this port (default 4321)', parseInt)
.option('-o, --output <file>',
'Path to output CSV file (default output.csv)', 'output.csv')
.option('-l, --labels <csv>',
'Comma-separated list of choices of labels', list, ['Yes', 'No'])
.option('--shortcuts <a,b,c>', 'Comma-separated list of keyboard shortcuts for labels. Default is 1, 2, etc.', list, null)
.option('-w, --max_width <pixels>',
'Make the images this width when displaying in-browser', parseInt)
.option('-r, --random-order',
.option('-o, --output <file>', 'Path to output CSV file (default output.csv)', 'output.csv')
.option('-l, --labels <csv>', 'Comma-separated list of choices of labels', list, ['Yes', 'No'])
.option(
'--shortcuts <a,b,c>',
'Comma-separated list of keyboard shortcuts for labels. Default is 1, 2, etc.',
list,
null,
)
.option(
'-w, --max_width <pixels>',
'Make the images this width when displaying in-browser',
parseInt,
)
.option(
'-r, --random-order',
'Serve images in random order, rather than sequentially. This is useful for ' +
'generating valid subsamples or for minimizing collisions during group localturking.')
.parse()
'generating valid subsamples or for minimizing collisions during group localturking.',
)
.parse();

if (program.args.length == 0) {
console.error('You must specify at least one image file!\n');
program.help(); // exits
program.help(); // exits
}
const options = program.opts<CLIArgs>();
let {shortcuts} = options;
Expand Down Expand Up @@ -95,10 +103,12 @@ if (images.length === 1 && images[0].endsWith('.txt')) {
fs.closeSync(csvInfo.fd);

// Add keyboard shortcuts. 1=first button, etc.
const buttonsHtml = options.labels.map((label, idx) => {
const buttonText = `${label} (${shortcuts[idx]})`;
return `<button type="submit" data-key='${shortcuts[idx]}' name="label" value="${label}">${escape(buttonText)}</button>`;
}).join('&nbsp;');
const buttonsHtml = options.labels
.map((label, idx) => {
const buttonText = `${label} (${shortcuts[idx]})`;
return `<button type="submit" data-key='${shortcuts[idx]}' name="label" value="${label}">${escape(buttonText)}</button>`;
})
.join('&nbsp;');

const widthHtml = options.max_width ? ` width="${options.max_width}"` : '';
const undoHtml = dedent`
Expand Down
25 changes: 13 additions & 12 deletions csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {stringify} from 'csv-stringify/sync';
import * as fs from 'fs-extra';

const csvOptions: csvParse.Options = {
skip_empty_lines: true
skip_empty_lines: true,
};

interface Row {
Expand All @@ -20,7 +20,7 @@ interface Done {
type RowResult = Row | Error | Done;

function isPromise(x: any): x is Promise<any> {
return ('then' in x);
return 'then' in x;
}

/** Read a CSV file line-by-line. */
Expand All @@ -29,15 +29,16 @@ export async function* readRows(file: string) {
const stream = fs.createReadStream(file, 'utf8');

let dataCallback: () => void | undefined;
const mkBarrier = () => new Promise<void>((resolve, reject) => {
dataCallback = resolve;
});
const mkBarrier = () =>
new Promise<void>((resolve, reject) => {
dataCallback = resolve;
});

// TODO(danvk): use a deque
const rows: (RowResult|Promise<void>)[] = [mkBarrier()];
const rows: (RowResult | Promise<void>)[] = [mkBarrier()];
parser.on('readable', () => {
let row;
while (row = parser.read()) {
while ((row = parser.read())) {
rows.push({type: 'row', value: row});
}
const oldCb = dataCallback;
Expand Down Expand Up @@ -78,7 +79,7 @@ export async function readHeaders(file: string) {
for await (const row of readRows(file)) {
return row;
}
throw new Error(`Unexpected empty file: ${file}`)
throw new Error(`Unexpected empty file: ${file}`);
}

/** Write a CSV file */
Expand All @@ -98,7 +99,7 @@ export async function appendRow(file: string, row: {[column: string]: string}) {
if (!exists) {
// Easy: write the whole file.
const header = Object.keys(row);
const rows = [header, header.map(k => row[k])]
const rows = [header, header.map(k => row[k])];
return writeCsv(file, rows);
}

Expand All @@ -111,7 +112,7 @@ export async function appendRow(file: string, row: {[column: string]: string}) {
const headerToIndex: {[header: string]: number} = {};
headers.forEach((header, i) => {
headerToIndex[header] = i;
})
});

// Check if there are any new headers in the row.
const newHeaders = [];
Expand All @@ -133,11 +134,11 @@ export async function appendRow(file: string, row: {[column: string]: string}) {
} else {
// write the new row
const newRow = headers.map(k => row[k] || '');
await lines.return(); // close the file for reading.
await lines.return(); // close the file for reading.
// Add a newline if the file doesn't end with one.
const f = fs.openSync(file, 'a+');
const {size} = fs.fstatSync(f);
const { buffer } = await fs.read(f, Buffer.alloc(1), 0, 1, size - 1);
const {buffer} = await fs.read(f, Buffer.alloc(1), 0, 1, size - 1);
const hasTrailingNewline = buffer[0] == '\n'.charCodeAt(0);
const lineStr = (hasTrailingNewline ? '' : '\n') + stringify([newRow]);
await fs.appendFile(f, lineStr);
Expand Down
7 changes: 7 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest",{}],
},
};
17 changes: 17 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"localturk.ts!",
"classify-images.ts!",
"test/**/*.ts"
],
"ignore": [],
"ignoreBinaries": [],
"ignoreDependencies": [
],
"ignoreExportsUsedInFile": true,
"project": [
"*.ts!",
"test/**/*.ts"
]
}
116 changes: 68 additions & 48 deletions localturk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,29 @@ program
.version('2.1.1')
.usage('[options] template.html tasks.csv outputs.csv')
.option('-p, --port <n>', 'Run on this port (default 4321)', parseInt)
.option('--var <items>', 'Provide additional varibles to the template. Maybe be specified multiple times.', collect, {})
.option('-s, --static-dir <dir>',
'Serve static content from this directory. Default is same directory as template file.')
.option('-r, --random-order',
'Serve images in random order, rather than sequentially. This is useful for ' +
'generating valid subsamples or for minimizing collisions during group localturking.')
.option(
'--var <items>',
'Provide additional varibles to the template. Maybe be specified multiple times.',
collect,
{},
)
.option(
'-s, --static-dir <dir>',
'Serve static content from this directory. Default is same directory as template file.',
)
.option(
'-r, --random-order',
'Serve images in random order, rather than sequentially. This is useful for ' +
'generating valid subsamples or for minimizing collisions during group localturking.',
)
.option('-w, --write-template', 'Generate a stub template file based on the input CSV.')
.parse();

const options = program.opts<CLIArgs>();

const {args} = program;
const {randomOrder, writeTemplate} = options;
if (!((3 === args.length && !writeTemplate) ||
(1 === args.length && writeTemplate))) {
if (!((3 === args.length && !writeTemplate) || (1 === args.length && writeTemplate))) {
program.help();
}
if (writeTemplate) {
Expand All @@ -78,7 +86,7 @@ const port = options.port || 4321;
const staticDir = options['staticDir'] || path.dirname(templateFile);

type Task = {[key: string]: string};
let flash = ''; // this is used to show warnings in the web UI.
let flash = ''; // this is used to show warnings in the web UI.

async function renderTemplate({task, numCompleted, rowNumber, numTotal}: TaskStats) {
const template = await fs.readFile(templateFile, {encoding: 'utf8'});
Expand All @@ -98,9 +106,10 @@ async function renderTemplate({task, numCompleted, rowNumber, numTotal}: TaskSta
const thisFlash = flash;
flash = '';

const sourceInputs = _.map(task, (v, k) =>
`<input type=hidden name="${k}" value="${utils.htmlEntities(v)}">`
).join('\n');
const sourceInputs = _.map(
task,
(v, k) => `<input type=hidden name="${k}" value="${utils.htmlEntities(v)}">`,
).join('\n');

return utils.dedent`
<!doctype html>
Expand Down Expand Up @@ -150,7 +159,7 @@ async function checkTaskOutput(task: Task) {
// your form elements.
const headers = await csv.readHeaders(tasksFile);
for (const k in task) {
if (headers.indexOf(k) === -1) return; // there's a new key.
if (headers.indexOf(k) === -1) return; // there's a new key.
}
flash = 'No new keys in output. Make sure your &lt;input&gt; elements have "name" attributes';
}
Expand All @@ -170,7 +179,7 @@ async function getNextTask(): Promise<TaskStats> {
for await (const task of csv.readRowObjects(tasksFile)) {
numTotal++;
if (!sampler && nextTask) {
continue; // we're only counting at this point.
continue; // we're only counting at this point.
}
if (isTaskCompleted(utils.normalizeValues(task), completedTasks)) {
continue;
Expand All @@ -195,11 +204,11 @@ async function getNextTask(): Promise<TaskStats> {
numCompleted: _.size(completedTasks),
rowNumber,
numTotal,
}
};
}

async function getTaskNum(n: number): Promise<TaskStats> {
const completedTasks = await readCompletedTasks(); // just getting the count.
const completedTasks = await readCompletedTasks(); // just getting the count.
let i = 0;
let numTotal = 0;
let taskN;
Expand All @@ -216,49 +225,60 @@ async function getTaskNum(n: number): Promise<TaskStats> {
numCompleted: _.size(completedTasks),
rowNumber: n,
numTotal,
}
};
}
throw new Error('Task not found');
}

const app = express();
app.use(errorhandler());
app.use(express.json({limit: "50mb"}));
app.use(express.urlencoded({limit: "50mb", extended: false, parameterLimit: 50_000}));
app.use(express.json({limit: '50mb'}));
app.use(express.urlencoded({limit: '50mb', extended: false, parameterLimit: 50_000}));
app.use(serveStatic(path.resolve(staticDir)));

app.get('/', utils.wrapPromise(async (req, res) => {
const nextTask = await getNextTask();
if (nextTask.task) {
console.log(nextTask.task);
const html = await renderTemplate(nextTask);
res.send(html);
} else {
res.send('DONE');
process.exit(0);
}
}));

app.get('/:num(\\d+)', utils.wrapPromise(async (req, res) => {
const task = await getTaskNum(parseInt(req.params.num));
const html = await renderTemplate(task);
res.send(html);
}));
app.get(
'/',
utils.wrapPromise(async (req, res) => {
const nextTask = await getNextTask();
if (nextTask.task) {
console.log(nextTask.task);
const html = await renderTemplate(nextTask);
res.send(html);
} else {
res.send('DONE');
process.exit(0);
}
}),
);

app.post('/submit', utils.wrapPromise(async (req, res) => {
const task: Task = req.body;
await csv.appendRow(outputsFile, task);
checkTaskOutput(task); // sets the "flash" variable with any errors.
console.log('Saved ' + JSON.stringify(task));
res.redirect('/');
}));
app.get(
'/:num(\\d+)',
utils.wrapPromise(async (req, res) => {
const task = await getTaskNum(parseInt(req.params.num));
const html = await renderTemplate(task);
res.send(html);
}),
);

app.post('/delete-last', utils.wrapPromise(async (req, res) => {
const row = await csv.deleteLastRow(outputsFile);
console.log('Deleting', row);
res.redirect('/');
}));
app.post(
'/submit',
utils.wrapPromise(async (req, res) => {
const task: Task = req.body;
await csv.appendRow(outputsFile, task);
checkTaskOutput(task); // sets the "flash" variable with any errors.
console.log('Saved ' + JSON.stringify(task));
res.redirect('/');
}),
);

app.post(
'/delete-last',
utils.wrapPromise(async (req, res) => {
const row = await csv.deleteLastRow(outputsFile);
console.log('Deleting', row);
res.redirect('/');
}),
);

if (writeTemplate) {
(async () => {
Expand Down
Loading

0 comments on commit a67244d

Please sign in to comment.