diff options
author | T <t@tjp.lol> | 2025-08-02 17:25:59 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-04 09:34:14 -0600 |
commit | 8be5f93f5b2d4b6f438ca84094937a0f7101c59b (patch) | |
tree | 3cedb6379818a28179e269477c12ae06dd57ca36 /internal/commands/import.go |
Initial commit of punchcard.
Contains working time tracking commands, and the stub of a command to
generate reports.
Diffstat (limited to 'internal/commands/import.go')
-rw-r--r-- | internal/commands/import.go | 275 |
1 files changed, 275 insertions, 0 deletions
diff --git a/internal/commands/import.go b/internal/commands/import.go new file mode 100644 index 0000000..a767923 --- /dev/null +++ b/internal/commands/import.go @@ -0,0 +1,275 @@ +package commands + +import ( + "context" + "database/sql" + "encoding/csv" + "fmt" + "os" + "strings" + "time" + + punchctx "punchcard/internal/context" + "punchcard/internal/database" + "punchcard/internal/queries" + + "github.com/spf13/cobra" +) + +func NewImportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "import [file]", + Short: "Import time entries from external sources", + Long: `Import time entries from various external time tracking tools and formats. Use --source to specify the format. + +For Clockify exports: +1. Go to REPORTS > DETAILED in the sidebar +2. Select your desired time range with the date range picker in the top right +3. Click Export > "Save as CSV" from the menu above the table header`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filepath := args[0] + + // Get flag values + source, err := cmd.Flags().GetString("source") + if err != nil { + return fmt.Errorf("failed to get source flag: %w", err) + } + + timezone, err := cmd.Flags().GetString("timezone") + if err != nil { + return fmt.Errorf("failed to get timezone flag: %w", err) + } + + // Get database from context (for tests) or create new connection + queries := punchctx.GetDB(cmd.Context()) + if queries == nil { + var err error + queries, err = database.GetDB() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + } + + // Select import function based on source + switch source { + case "clockify": + return importClockifyCSV(queries, filepath, timezone) + case "": + return fmt.Errorf("required flag \"source\" not set") + default: + return fmt.Errorf("unsupported source: %s", source) + } + }, + } + + cmd.Flags().StringP("timezone", "t", "Local", "Timezone of the CSV data (e.g., 'America/New_York', 'UTC', or 'Local')") + cmd.Flags().StringP("source", "s", "", "Source format of the import file (supported: clockify)") + cmd.MarkFlagRequired("source") + + return cmd +} + +type clockifyEntry struct { + Project string + Client string + Description string + StartDate string + StartTime string + EndDate string + EndTime string +} + +func importClockifyCSV(queries *queries.Queries, filepath, timezone string) error { + file, err := os.Open(filepath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + return fmt.Errorf("failed to read CSV: %w", err) + } + + if len(records) < 2 { + return fmt.Errorf("CSV file must have at least a header and one data row") + } + + header := records[0] + + // Find column indices for the fields we actually use + columnIndices := make(map[string]int) + for i, columnName := range header { + columnIndices[columnName] = i + } + + // Check for required columns (only the ones we actually use) + requiredColumns := []string{"Project", "Client", "Start Date", "Start Time", "End Date", "End Time"} + for _, required := range requiredColumns { + if _, exists := columnIndices[required]; !exists { + return fmt.Errorf("CSV file missing required column: %s", required) + } + } + + // Optional billable columns may be present (unused for now) + + var loc *time.Location + if timezone == "Local" { + loc = time.Local + } else { + loc, err = time.LoadLocation(timezone) + if err != nil { + return fmt.Errorf("invalid timezone '%s': %w", timezone, err) + } + } + + importedCount := 0 + for i, record := range records[1:] { + if len(record) < len(header) { + fmt.Printf("Warning: Row %d has insufficient columns, skipping\n", i+2) + continue + } + + // Extract values using column indices + entry := clockifyEntry{ + Project: getColumnValue(record, columnIndices, "Project"), + Client: getColumnValue(record, columnIndices, "Client"), + Description: getColumnValue(record, columnIndices, "Description"), + StartDate: getColumnValue(record, columnIndices, "Start Date"), + StartTime: getColumnValue(record, columnIndices, "Start Time"), + EndDate: getColumnValue(record, columnIndices, "End Date"), + EndTime: getColumnValue(record, columnIndices, "End Time"), + } + + if entry.Client == "" || entry.Project == "" { + fmt.Printf("Warning: Row %d missing client or project, skipping\n", i+2) + continue + } + + if err := importSingleEntry(queries, entry, loc); err != nil { + fmt.Printf("Warning: Row %d failed to import: %v\n", i+2, err) + continue + } + + importedCount++ + } + + fmt.Printf("Successfully imported %d time entries\n", importedCount) + return nil +} + +func importSingleEntry(q *queries.Queries, entry clockifyEntry, loc *time.Location) error { + client, err := getOrCreateClient(q, entry.Client) + if err != nil { + return fmt.Errorf("failed to get or create client: %w", err) + } + + project, err := getOrCreateProject(q, entry.Project, client.ID) + if err != nil { + return fmt.Errorf("failed to get or create project: %w", err) + } + + startTime, err := parseClockifyDateTime(entry.StartDate, entry.StartTime, loc) + if err != nil { + return fmt.Errorf("failed to parse start time: %w", err) + } + + endTime, err := parseClockifyDateTime(entry.EndDate, entry.EndTime, loc) + if err != nil { + return fmt.Errorf("failed to parse end time: %w", err) + } + + var projectID sql.NullInt64 + if project != nil { + projectID = sql.NullInt64{Int64: project.ID, Valid: true} + } + + _, err = q.CreateTimeEntryWithTimes(context.Background(), queries.CreateTimeEntryWithTimesParams{ + StartTime: startTime.UTC(), + EndTime: sql.NullTime{Time: endTime.UTC(), Valid: true}, + Description: sql.NullString{String: entry.Description, Valid: entry.Description != ""}, + ClientID: client.ID, + ProjectID: projectID, + BillableRate: sql.NullInt64{}, + }) + + return err +} + +func getOrCreateClient(q *queries.Queries, name string) (*queries.Client, error) { + client, err := q.GetClientByName(context.Background(), name) + if err == nil { + return &client, nil + } + + if err != sql.ErrNoRows { + return nil, err + } + + createdClient, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: name, + Email: sql.NullString{}, + BillableRate: sql.NullInt64{}, + }) + if err != nil { + return nil, err + } + + return &createdClient, nil +} + +func getOrCreateProject(q *queries.Queries, name string, clientID int64) (*queries.Project, error) { + project, err := q.GetProjectByNameAndClient(context.Background(), queries.GetProjectByNameAndClientParams{ + Name: name, + ClientID: clientID, + }) + if err == nil { + return &project, nil + } + + if err != sql.ErrNoRows { + return nil, err + } + + createdProject, err := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: name, + ClientID: clientID, + BillableRate: sql.NullInt64{}, + }) + if err != nil { + return nil, err + } + + return &createdProject, nil +} + +func parseClockifyDateTime(date, timeStr string, loc *time.Location) (time.Time, error) { + dateTimeStr := date + " " + timeStr + + layouts := []string{ + "01/02/2006 03:04:05 PM", + "1/2/2006 03:04:05 PM", + "01/02/2006 3:04:05 PM", + "1/2/2006 3:04:05 PM", + "01/02/2006 15:04:05", + "1/2/2006 15:04:05", + } + + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, dateTimeStr, loc); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse date/time: %s", dateTimeStr) +} + +// getColumnValue safely extracts a column value from a record using the column index map +func getColumnValue(record []string, columnIndices map[string]int, columnName string) string { + if index, exists := columnIndices[columnName]; exists && index < len(record) { + return strings.TrimSpace(record[index]) + } + return "" +} |