package lib import ( "encoding/json" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strconv" "time" ) // Time entity as defined by the specification. type Time struct { ID string `json:"id"` ProjectID string `json:"project"` ActivityID string `json:"activity"` Start time.Time `json:"start"` End time.Time `json:"end"` } // SetTime persists a time entry. func SetTime(entry *Time) error { // Traverse range by day (in case is spans multiple days for date := entry.Start.Truncate(24 * time.Hour); !date.After(entry.End); date = date.AddDate(0, 0, 1) { start := entry.Start if date.After(start) { start = date } end := entry.End if date.AddDate(0, 0, 1).Add(-1 * time.Second).Before(end) { end = date.AddDate(0, 0, 1).Add(-1 * time.Second) } dayEntry := Time{ ProjectID: entry.ProjectID, ActivityID: entry.ActivityID, Start: start, End: end, } uuid, uuidErr := newUUID() if uuidErr != nil { return uuidErr } year, month, day := date.UTC().Date() file, fileErr := getTimeFile(year, int(month), day, uuid) if fileErr != nil { return fileErr } writeErr := writeTime(&dayEntry, file) if writeErr != nil { return writeErr } } return nil } // readTimeRange reads time entries for a given time range. func readTimeRange(start, end time.Time) ([]*Time, error) { entries := []*Time{} // Traverse range by day for date := start; !date.After(end); date = date.AddDate(0, 0, 1) { // Traverse files year, month, day := date.UTC().Date() folder, folderErr := getTimeFolder(year, int(month), day) if folderErr != nil { return nil, folderErr } slog.Debug(fmt.Sprintf("Read time files from folder %s", folder)) files, filesErr := os.ReadDir(folder) if errors.Is(filesErr, fs.ErrNotExist) { return entries, nil } if filesErr != nil { return nil, filesErr } for _, file := range files { entry, entryErr := readTime(filepath.Join(folder, file.Name())) if entryErr != nil { return nil, entryErr } if (entry.Start.After(start) && entry.Start.Before(end)) || (entry.End.After(start) && entry.End.Before(end)) { entries = append(entries, entry) } } } return entries, nil } // readTime reads a time entry from a file and converts it into an entity instance. func readTime(filename string) (*Time, error) { file, err := os.Open(filename) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } defer file.Close() time := Time{} if err := json.NewDecoder(file).Decode(&time); err != nil { return nil, err } return &time, nil } // writeTime writes a time instance to a file. func writeTime(time *Time, filename string) error { dirErr := os.MkdirAll(filepath.Dir(filename), 0o750) if dirErr != nil { return dirErr } file, fileErr := os.Create(filename) if fileErr != nil { return fileErr } defer file.Close() encoder := json.NewEncoder(file) encoder.SetIndent("", " ") jsonErr := encoder.Encode(time) if jsonErr != nil { return jsonErr } return nil } // getTimeFolder returns the folder to store time entity files in. func getTimeFolder(year int, month int, day int) (string, error) { folder, err := getDataDir("times") if err != nil { return "", err } return filepath.Join(folder, strconv.Itoa(year), strconv.Itoa(month), strconv.Itoa(day)), nil } // getTimeFile returns the file name for a time entity instance. func getTimeFile(year int, month int, day int, name string) (string, error) { folder, err := getTimeFolder(year, month, day) if err != nil { return "", err } return filepath.Join(folder, name+".json"), nil }