Created
August 6, 2026 15:59
-
-
Save mojoaxel/7e239dcf47f2c6d364c57d8547f5acbd to your computer and use it in GitHub Desktop.
Import a task list to a clockify project
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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