Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set up prettier, knip and jest #47

Merged
merged 5 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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