feat: add data import from CSV and JSON
Import utility with CSV parser, Toggl/Clockify format mapping, and generic CSV column mapping. Settings Data tab has import UI with file picker, format selector, preview table, and import execution.
This commit is contained in:
83
src/utils/import.ts
Normal file
83
src/utils/import.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
export function mapTogglCSV(rows: string[][]): ImportEntry[] {
|
||||
// Toggl CSV format: Description, Project, Client, Task, Tag, Start date, Start time, End date, End time, Duration
|
||||
const header = rows[0].map(h => h.toLowerCase())
|
||||
const descIdx = header.findIndex(h => h.includes('description'))
|
||||
const projIdx = header.findIndex(h => h.includes('project'))
|
||||
const clientIdx = header.findIndex(h => h.includes('client'))
|
||||
const taskIdx = header.findIndex(h => h.includes('task'))
|
||||
const tagIdx = header.findIndex(h => h.includes('tag'))
|
||||
const startDateIdx = header.findIndex(h => h.includes('start date'))
|
||||
const startTimeIdx = header.findIndex(h => h.includes('start time'))
|
||||
const durationIdx = header.findIndex(h => h.includes('duration'))
|
||||
|
||||
return rows.slice(1).map(row => ({
|
||||
project_name: row[projIdx] || 'Imported',
|
||||
client_name: clientIdx >= 0 ? row[clientIdx] : undefined,
|
||||
task_name: taskIdx >= 0 ? row[taskIdx] : undefined,
|
||||
description: descIdx >= 0 ? row[descIdx] : undefined,
|
||||
start_time: combineDateTime(row[startDateIdx], row[startTimeIdx]),
|
||||
duration: durationIdx >= 0 ? parseDurationString(row[durationIdx]) : 0,
|
||||
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
function combineDateTime(date: string, time: string): string {
|
||||
if (!date) return new Date().toISOString()
|
||||
if (!time) return new Date(date).toISOString()
|
||||
return new Date(`${date} ${time}`).toISOString()
|
||||
}
|
||||
Reference in New Issue
Block a user