Skip to content

Instantly share code, notes, and snippets.

@mojoaxel
Created August 6, 2026 15:59
Show Gist options
  • Select an option

  • Save mojoaxel/7e239dcf47f2c6d364c57d8547f5acbd to your computer and use it in GitHub Desktop.

Select an option

Save mojoaxel/7e239dcf47f2c6d364c57d8547f5acbd to your computer and use it in GitHub Desktop.
Import a task list to a clockify project
#!/usr/bin/env node
'use strict';
/**
* clockify-import.js
*
* Reads a CSV file of tasks (title, estimated hours) and creates them as
* Clockify tasks inside a project you choose interactively.
*
* Usage:
* node clockify-import.js <path-to-csv> <API_KEY> [--dry-run]
*
* The CSV must have a header row (skipped) and two columns:
* 1. task title
* 2. estimated amount in hours (decimal, e.g. 2.5)
*
* With --dry-run the script still lets you pick a workspace/project and parses
* the CSV, but prints what it *would* create instead of calling the API.
*
* Uses only native Node.js functionality (no external npm libraries).
*
* This script was created by Claude Opus 4.8 (eu-central)
*/
const fs = require('node:fs');
const readline = require('node:readline/promises');
const { stdin, stdout, argv, exit } = require('node:process');
const API_BASE = 'https://api.clockify.me/api/v1';
/* ------------------------------------------------------------------ */
/* Argument parsing */
/* ------------------------------------------------------------------ */
function usageAndExit(message) {
if (message) console.error(`Error: ${message}\n`);
console.error('Usage: node clockify-import.js <path-to-csv> <API_KEY> [--dry-run]');
exit(1);
}
const rawArgs = argv.slice(2);
const dryRun = rawArgs.includes('--dry-run');
const positional = rawArgs.filter((a) => a !== '--dry-run');
const [csvPath, apiKey] = positional;
if (!csvPath || !apiKey) {
usageAndExit('Missing required arguments.');
}
if (!fs.existsSync(csvPath)) {
usageAndExit(`CSV file not found: ${csvPath}`);
}
/* ------------------------------------------------------------------ */
/* Clockify API helpers */
/* ------------------------------------------------------------------ */
async function apiRequest(path, options = {}) {
const url = `${API_BASE}${path}`;
let res;
try {
res = await fetch(url, {
...options,
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
} catch (err) {
throw new Error(`Network error calling ${url}: ${err.message}`);
}
const text = await res.text();
let body;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
if (!res.ok) {
const detail =
body && typeof body === 'object' && body.message
? body.message
: typeof body === 'string' && body
? body
: res.statusText;
const error = new Error(`${res.status} ${detail}`);
error.status = res.status;
throw error;
}
return body;
}
async function fetchWorkspaces() {
return apiRequest('/workspaces');
}
async function fetchProjects(workspaceId) {
// Basic pagination in case there are many projects.
const pageSize = 100;
let page = 1;
const all = [];
for (;;) {
const batch = await apiRequest(
`/workspaces/${workspaceId}/projects?page-size=${pageSize}&page=${page}&archived=false`,
);
if (!Array.isArray(batch) || batch.length === 0) break;
all.push(...batch);
if (batch.length < pageSize) break;
page += 1;
}
return all;
}
async function createTask(workspaceId, projectId, name, estimate) {
const payload = { name };
if (estimate) payload.estimate = estimate;
return apiRequest(
`/workspaces/${workspaceId}/projects/${projectId}/tasks`,
{ method: 'POST', body: JSON.stringify(payload) },
);
}
/* ------------------------------------------------------------------ */
/* CSV parsing */
/* ------------------------------------------------------------------ */
/**
* Parses CSV text into an array of rows, each row being an array of fields.
* Handles quoted fields, escaped quotes ("") and commas/newlines inside quotes.
*/
function parseCsv(text) {
const rows = [];
let field = '';
let row = [];
let inQuotes = false;
let i = 0;
const pushField = () => {
row.push(field);
field = '';
};
const pushRow = () => {
pushField();
rows.push(row);
row = [];
};
while (i < text.length) {
const char = text[i];
if (inQuotes) {
if (char === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i += 1;
continue;
}
field += char;
i += 1;
continue;
}
if (char === '"') {
inQuotes = true;
i += 1;
continue;
}
if (char === ',') {
pushField();
i += 1;
continue;
}
if (char === '\r') {
// Handle CRLF and lone CR.
if (text[i + 1] === '\n') i += 1;
pushRow();
i += 1;
continue;
}
if (char === '\n') {
pushRow();
i += 1;
continue;
}
field += char;
i += 1;
}
// Flush the last field/row if there is any trailing content.
if (field.length > 0 || row.length > 0) {
pushRow();
}
return rows;
}
/**
* Converts decimal hours into an ISO-8601 duration string (e.g. PT2H30M).
* Returns null for zero / empty / invalid values.
*/
function hoursToIso8601(hoursValue) {
const hours = Number(hoursValue);
if (!Number.isFinite(hours) || hours <= 0) return null;
const totalMinutes = Math.round(hours * 60);
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
let out = 'PT';
if (h > 0) out += `${h}H`;
if (m > 0) out += `${m}M`;
if (out === 'PT') return null;
return out;
}
/* ------------------------------------------------------------------ */
/* Interactive prompt */
/* ------------------------------------------------------------------ */
async function promptSelection(rl, items, label, render) {
if (items.length === 0) {
throw new Error(`No ${label}s available for this API key.`);
}
console.log(`\nAvailable ${label}s:`);
items.forEach((item, idx) => {
console.log(` ${idx + 1}) ${render(item)}`);
});
for (;;) {
const answer = (await rl.question(`\nSelect a ${label} (1-${items.length}): `)).trim();
const index = Number(answer);
if (Number.isInteger(index) && index >= 1 && index <= items.length) {
return items[index - 1];
}
console.log('Invalid selection, please try again.');
}
}
/* ------------------------------------------------------------------ */
/* Main */
/* ------------------------------------------------------------------ */
async function main() {
const rl = readline.createInterface({ input: stdin, output: stdout });
try {
if (dryRun) {
console.log('[DRY RUN] Workspace/project will still be fetched, but no tasks will be created.\n');
}
// 1. Select workspace.
console.log('Fetching workspaces...');
const workspaces = await fetchWorkspaces();
const workspace = await promptSelection(
rl,
workspaces,
'workspace',
(w) => `${w.name} (${w.id})`,
);
// 2. Select project.
console.log(`\nFetching projects for "${workspace.name}"...`);
const projects = await fetchProjects(workspace.id);
const project = await promptSelection(
rl,
projects,
'project',
(p) => `${p.name} (${p.id})`,
);
// 3. Parse the CSV.
const raw = fs.readFileSync(csvPath, 'utf8');
const allRows = parseCsv(raw);
// Skip the header row.
const dataRows = allRows.slice(1);
const tasks = [];
let skipped = 0;
dataRows.forEach((cols, idx) => {
const lineNo = idx + 2; // +1 for header, +1 for 1-based numbering
const title = (cols[0] || '').trim();
const hoursRaw = (cols[1] || '').trim();
// Ignore completely empty lines silently.
if (title === '' && hoursRaw === '') return;
if (title === '') {
console.warn(` ! Line ${lineNo}: missing title, skipping.`);
skipped += 1;
return;
}
const estimate = hoursToIso8601(hoursRaw);
if (hoursRaw !== '' && estimate === null) {
console.warn(
` ! Line ${lineNo}: invalid hours "${hoursRaw}" for "${title}", creating task without estimate.`,
);
}
tasks.push({ title, estimate, lineNo });
});
if (tasks.length === 0) {
console.log('\nNo valid tasks found in the CSV. Nothing to do.');
return;
}
console.log(
`\nReady to create ${tasks.length} task(s) in project "${project.name}".`,
);
if (dryRun) {
console.log('\n[DRY RUN] No tasks will be created. Preview:\n');
for (const task of tasks) {
const estLabel = task.estimate ? ` [${task.estimate}]` : ' [no estimate]';
console.log(` - "${task.title}"${estLabel}`);
}
console.log('\n--- Summary (dry run) ---');
console.log(` Would create: ${tasks.length}`);
console.log(` Skipped: ${skipped}`);
return;
}
const confirm = (await rl.question('Proceed? (y/N): ')).trim().toLowerCase();
if (confirm !== 'y' && confirm !== 'yes') {
console.log('Aborted.');
return;
}
// 4. Create tasks sequentially.
console.log('');
let created = 0;
let failed = 0;
for (const task of tasks) {
const estLabel = task.estimate ? ` [${task.estimate}]` : '';
try {
await createTask(workspace.id, project.id, task.title, task.estimate);
created += 1;
console.log(` \u2713 created "${task.title}"${estLabel}`);
} catch (err) {
failed += 1;
console.error(` \u2717 failed "${task.title}"${estLabel}: ${err.message}`);
}
}
// 5. Summary.
console.log('\n--- Summary ---');
console.log(` Created: ${created}`);
console.log(` Failed: ${failed}`);
console.log(` Skipped: ${skipped}`);
if (failed > 0) {
exit(1);
}
} finally {
rl.close();
}
}
main().catch((err) => {
console.error(`\nFatal: ${err.message}`);
exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment