Files
zeroclock/src/utils/import.ts
Your Name f4f964140b fix: close button and CSV import parsing for Clockify/Harvest
Close button did nothing when "close to tray" was disabled - the
onCloseRequested handler lacked an explicit destroy call for the
non-tray path.

Clockify CSV import threw RangeError because locale-dependent date
formats (MM/DD/YYYY, DD.MM.YYYY, 12h time) were passed straight
to the Date constructor. Added flexible date/time parsers that
handle all Clockify export variants without relying on Date parsing.

Added dedicated Clockify mapper that prefers Duration (decimal)
column and a new Harvest CSV importer (date + decimal hours, no
start/end times).

Bump version to 1.0.1.
2026-02-21 14:56:53 +02:00

211 lines
7.5 KiB
TypeScript

export interface ImportEntry {
project_name: string
client_name?: string
task_name?: string
description?: string
start_time: string
end_time?: string
duration: number
tags?: string[]
}
export function parseCSV(text: string): string[][] {
const lines = text.split('\n').filter(l => l.trim())
return lines.map(line => {
const cells: string[] = []
let current = ''
let inQuotes = false
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
cells.push(current.trim())
current = ''
} else {
current += char
}
}
cells.push(current.trim())
return cells
})
}
export function parseDurationString(dur: string): number {
if (!dur) return 0
// Handle HH:MM:SS, HH:MM, or decimal hours
if (dur.includes(':')) {
const parts = dur.split(':').map(Number)
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]
if (parts.length === 2) return parts[0] * 3600 + parts[1] * 60
}
const hours = parseFloat(dur)
if (!isNaN(hours)) return Math.round(hours * 3600)
return 0
}
// Parse locale-variable date strings into YYYY-MM-DD
function parseFlexDate(dateStr: string): string {
if (!dateStr) return new Date().toISOString().split('T')[0]
const s = dateStr.trim()
// YYYY-MM-DD (ISO)
if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(s)) {
const [y, m, d] = s.split('-')
return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`
}
// Slash or dot separated: could be MM/DD/YYYY, DD/MM/YYYY, DD.MM.YYYY
const match = s.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{2,4})$/)
if (match) {
let [, a, b, year] = match
if (year.length === 2) year = '20' + year
// If first part > 12, must be day-first (DD/MM/YYYY)
if (parseInt(a) > 12) return `${year}-${b.padStart(2, '0')}-${a.padStart(2, '0')}`
// If second part > 12, must be month-first (MM/DD/YYYY)
if (parseInt(b) > 12) return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
// Ambiguous - assume MM/DD/YYYY (US default, most common Clockify setting)
return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
}
// Last resort: try Date constructor
const d = new Date(s)
if (!isNaN(d.getTime())) return d.toISOString().split('T')[0]
return new Date().toISOString().split('T')[0]
}
// Parse 12h or 24h time strings into HH:MM:SS
function parseFlexTime(timeStr: string): string {
if (!timeStr) return '00:00:00'
const s = timeStr.trim()
// 12-hour: "1:00 PM", "11:30:00 AM"
const ampm = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$/i)
if (ampm) {
let hour = parseInt(ampm[1])
const min = ampm[2]
const sec = ampm[3] || '00'
const period = ampm[4].toUpperCase()
if (period === 'PM' && hour !== 12) hour += 12
if (period === 'AM' && hour === 12) hour = 0
return `${hour.toString().padStart(2, '0')}:${min}:${sec}`
}
// 24-hour: "13:00", "09:30:00"
const h24 = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (h24) {
return `${h24[1].padStart(2, '0')}:${h24[2]}:${h24[3] || '00'}`
}
return '00:00:00'
}
function safeDateTime(date: string, time?: string): string {
const d = parseFlexDate(date)
const t = time ? parseFlexTime(time) : '00:00:00'
return `${d}T${t}`
}
function findCol(header: string[], ...terms: string[]): number {
for (const term of terms) {
const idx = header.findIndex(h => h === term)
if (idx >= 0) return idx
}
for (const term of terms) {
const idx = header.findIndex(h => h.includes(term))
if (idx >= 0) return idx
}
return -1
}
function col(row: string[], idx: number): string {
return idx >= 0 ? (row[idx] || '') : ''
}
export function mapTogglCSV(rows: string[][]): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const descIdx = findCol(header, 'description')
const projIdx = findCol(header, 'project')
const clientIdx = findCol(header, 'client')
const taskIdx = findCol(header, 'task')
const tagIdx = findCol(header, 'tags', 'tag')
const startDateIdx = findCol(header, 'start date')
const startTimeIdx = findCol(header, 'start time')
const durationIdx = findCol(header, 'duration')
return rows.slice(1).map(row => ({
project_name: col(row, projIdx) || 'Imported',
client_name: col(row, clientIdx) || undefined,
task_name: col(row, taskIdx) || undefined,
description: col(row, descIdx) || undefined,
start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined),
duration: parseDurationString(col(row, durationIdx)),
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
}))
}
export function mapClockifyCSV(rows: string[][]): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const descIdx = findCol(header, 'description')
const projIdx = findCol(header, 'project')
const clientIdx = findCol(header, 'client')
const taskIdx = findCol(header, 'task')
const tagIdx = findCol(header, 'tags', 'tag')
const startDateIdx = findCol(header, 'start date')
const startTimeIdx = findCol(header, 'start time')
// Prefer decimal duration - always a simple number, no format ambiguity
const durDecIdx = header.findIndex(h => h.includes('duration (decimal)'))
const durHIdx = header.findIndex(h => h.includes('duration (h)'))
const durPlainIdx = header.findIndex(h => h === 'duration')
return rows.slice(1).map(row => {
let duration = 0
if (durDecIdx >= 0 && row[durDecIdx]) {
duration = Math.round(parseFloat(row[durDecIdx]) * 3600)
} else if (durHIdx >= 0 && row[durHIdx]) {
duration = parseDurationString(row[durHIdx])
} else if (durPlainIdx >= 0 && row[durPlainIdx]) {
duration = parseDurationString(row[durPlainIdx])
}
return {
project_name: col(row, projIdx) || 'Imported',
client_name: col(row, clientIdx) || undefined,
task_name: col(row, taskIdx) || undefined,
description: col(row, descIdx) || undefined,
start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined),
duration,
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
}
})
}
export function mapHarvestCSV(rows: string[][]): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const dateIdx = findCol(header, 'date')
const projIdx = header.findIndex(h => h === 'project')
const clientIdx = findCol(header, 'client')
const taskIdx = findCol(header, 'task')
const notesIdx = findCol(header, 'notes')
const hoursIdx = header.findIndex(h => h === 'hours')
return rows.slice(1).map(row => ({
project_name: col(row, projIdx) || 'Imported',
client_name: col(row, clientIdx) || undefined,
task_name: col(row, taskIdx) || undefined,
description: col(row, notesIdx) || undefined,
start_time: safeDateTime(col(row, dateIdx)),
duration: hoursIdx >= 0 && row[hoursIdx] ? Math.round(parseFloat(row[hoursIdx]) * 3600) : 0,
}))
}
export function mapGenericCSV(rows: string[][], mapping: Record<string, number>): ImportEntry[] {
return rows.slice(1).map(row => ({
project_name: row[mapping.project] || 'Imported',
client_name: mapping.client >= 0 ? row[mapping.client] : undefined,
task_name: mapping.task >= 0 ? row[mapping.task] : undefined,
description: mapping.description >= 0 ? row[mapping.description] : undefined,
start_time: row[mapping.start_time] || new Date().toISOString(),
duration: mapping.duration >= 0 ? parseDurationString(row[mapping.duration]) : 0,
}))
}