From fb576f3d5197fa1757e2c418bdbd27941ed209b4 Mon Sep 17 00:00:00 2001 From: Olli Date: Fri, 29 Aug 2025 11:56:45 +0200 Subject: [PATCH] Add basic library code to manage projects, activities and times. --- go.mod | 3 + lib/activities.go | 121 +++++++++++++++++++++++++++++++++++ lib/common.go | 39 +++++++++++ lib/projects.go | 142 ++++++++++++++++++++++++++++++++++++++++ lib/stats.go | 81 +++++++++++++++++++++++ lib/timer.go | 117 +++++++++++++++++++++++++++++++++ lib/times.go | 160 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 663 insertions(+) create mode 100644 go.mod create mode 100644 lib/activities.go create mode 100644 lib/common.go create mode 100644 lib/projects.go create mode 100644 lib/stats.go create mode 100644 lib/timer.go create mode 100644 lib/times.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8439921 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module suruatoel.xyz/timefiles + +go 1.24.2 diff --git a/lib/activities.go b/lib/activities.go new file mode 100644 index 0000000..6ef4830 --- /dev/null +++ b/lib/activities.go @@ -0,0 +1,121 @@ +package lib + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Activity entity as defined by the specification. +type Activity struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// GetActivities retrieves all stored activity instances of a project. +func GetActivities(project *Project) ([]*Activity, error) { + folder, folderErr := getActivitiesFolder(project.ID) + if folderErr != nil { + return nil, folderErr + } + + dirErr := os.MkdirAll(folder, 0o750) + if dirErr != nil { + return nil, dirErr + } + + files, filesErr := os.ReadDir(folder) + if filesErr != nil { + return nil, filesErr + } + + activities := []*Activity{} + for _, file := range files { + activity, fileErr := readActivity(filepath.Join(folder, file.Name())) + if fileErr != nil { + return nil, fileErr + } + activities = append(activities, activity) + } + + return activities, nil +} + +// SetActivity persists an activity. +func SetActivity(project *Project, activity *Activity) error { + uuid, uuidErr := newUUID() + if uuidErr != nil { + return uuidErr + } + + activity.ID = uuid + + file, fileErr := getActivityFile(project.ID, uuid) + if fileErr != nil { + return fileErr + } + + return writeActivity(file, activity) +} + +// readActivity reads an activity from a file and converts it into an entity instance. +func readActivity(filename string) (*Activity, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + project := Activity{} + if err := json.NewDecoder(file).Decode(&project); err != nil { + return nil, err + } + + return &project, nil +} + +// writeActivity writes an activity instance to a file. +func writeActivity(filename string, activity *Activity) error { + dirErr := os.MkdirAll(filepath.Dir(filename), 0o750) + if dirErr != nil { + return dirErr + } + fmt.Println("success") + + file, fileErr := os.Create(filename) + if fileErr != nil { + return fileErr + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + jsonErr := encoder.Encode(activity) + if jsonErr != nil { + return jsonErr + } + + return nil +} + +// getActivitiesFolder returns the folder to store activity entity files in. +func getActivitiesFolder(projectID string) (string, error) { + folder, err := getDataDir("activities") + if err != nil { + return "", err + } + + return filepath.Join(folder, projectID), nil +} + +// getActivityFile returns the file name for an activity entity instance. +func getActivityFile(projectID string, name string) (string, error) { + folder, err := getActivitiesFolder(projectID) + if err != nil { + return "", err + } + + return filepath.Join(folder, name+".json"), nil +} diff --git a/lib/common.go b/lib/common.go new file mode 100644 index 0000000..deb0a09 --- /dev/null +++ b/lib/common.go @@ -0,0 +1,39 @@ +// Package lib provides routines for creating and reading data files as defined by the specification. +package lib + +import ( + "os" + "os/exec" + "path/filepath" +) + +// dataDir holds the configured directory to read/write data from/to. +var dataDir string + +// SetDataDir sets the global directory to read/write data from/to. It is typically called once at application start. +func SetDataDir(directory string) { + dataDir = directory +} + +// getDataDir gets the global directory to read/write data from/to for a schema entity. +func getDataDir(entity string) (string, error) { + if dataDir != "" { + return filepath.Join(dataDir, entity), nil + } + + home, homeErr := os.UserHomeDir() + if homeErr != nil { + return "", homeErr + } + + return filepath.Join(home, ".protime", entity), nil +} + +// newUUID creates a new UUID for an entity instance using the OS command `uuidgen`. +func newUUID() (string, error) { + out, err := exec.Command("uuidgen").Output() + if err != nil { + return "", err + } + return string(out[:len(out)-1]), nil +} diff --git a/lib/projects.go b/lib/projects.go new file mode 100644 index 0000000..853e6d9 --- /dev/null +++ b/lib/projects.go @@ -0,0 +1,142 @@ +package lib + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Project entity. In addition to the JSON properties as defined by the specification, it holds activities of the +// project organized by title and by ID in a map. +type Project struct { + ID string `json:"id"` + Title string `json:"title"` + activitiesByTitle map[string]*Activity + activitiesByID map[string]*Activity + activitiesLoaded bool +} + +// NewProject creates a new project instance. +func NewProject() *Project { + return &Project{ + activitiesByID: make(map[string]*Activity), + activitiesByTitle: make(map[string]*Activity), + } +} + +// GetProjects retrieves all stored project instances. It does not load related data like activities or times. +func GetProjects() ([]*Project, error) { + folder, folderErr := getProjectFolder() + if folderErr != nil { + return nil, folderErr + } + + dirErr := os.MkdirAll(folder, 0o750) + if dirErr != nil { + return nil, dirErr + } + + files, filesErr := os.ReadDir(folder) + if filesErr != nil { + return nil, filesErr + } + + projects := []*Project{} + for _, file := range files { + project, fileErr := readProject(filepath.Join(folder, file.Name())) + if fileErr != nil { + return nil, fileErr + } + projects = append(projects, project) + } + + return projects, nil +} + +// GetProject retrieves a single project by its ID. It does not load related data like activities or times. +func GetProject(id string) (*Project, error) { + file, fileErr := getProjectFile(id) + if fileErr != nil { + return nil, fileErr + } + project, err := readProject(file) + if err != nil { + return nil, err + } + + project.ID = id + + return project, nil +} + +// SetProject persists a project. +func SetProject(project *Project) error { + uuid, uuidErr := newUUID() + if uuidErr != nil { + return uuidErr + } + + project.ID = uuid + + file, fileErr := getProjectFile(uuid) + if fileErr != nil { + return fileErr + } + + return writeProject(file, project) +} + +// readProject reads a project from a file and converts it into an entity instance. +func readProject(filename string) (*Project, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + project := NewProject() + if err := json.NewDecoder(file).Decode(&project); err != nil { + return nil, err + } + + return project, nil +} + +// writeProject writes a project instance to a file. +func writeProject(filename string, project *Project) 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(project) + if jsonErr != nil { + return jsonErr + } + + return nil +} + +// getProjectFolder returns the folder to store project entity files in. +func getProjectFolder() (string, error) { + return getDataDir("projects") +} + +// getProjectFile returns the file name for a project entity instance. +func getProjectFile(name string) (string, error) { + folder, err := getProjectFolder() + if err != nil { + return "", err + } + + return filepath.Join(folder, name+".json"), nil +} diff --git a/lib/stats.go b/lib/stats.go new file mode 100644 index 0000000..e75b31f --- /dev/null +++ b/lib/stats.go @@ -0,0 +1,81 @@ +package lib + +import ( + "sort" + "time" +) + +// TotalTime represents the time spent per project and activity including a total summary. +type TotalTime struct { + Totals []*TotalTimeEntry + TotalSum time.Duration +} + +// TotalTimeEntry represents the time spent for a project and an activity. +type TotalTimeEntry struct { + ProjectID string + Project string + ActivityID string + Activity string + Duration time.Duration +} + +// GetTimeTotals collects the time spent per project and activity for a time range. +func GetTimeTotals(start, end time.Time) (TotalTime, error) { + totalTime := TotalTime{ + Totals: []*TotalTimeEntry{}, + } + + entries, entriesErr := readTimeRange(start, end) + if entriesErr != nil { + return totalTime, entriesErr + } + if len(entries) == 0 { + return totalTime, nil + } + + totalsMap := make(map[string]map[string]time.Duration) + for _, entry := range entries { + project, projectFound := totalsMap[entry.ProjectID] + if !projectFound { + project = make(map[string]time.Duration) + totalsMap[entry.ProjectID] = project + } + + activity := project[entry.ActivityID] + project[entry.ActivityID] = activity + entry.End.Sub(entry.Start) + } + if len(totalsMap) == 0 { + return totalTime, nil + } + + for projectID, activities := range totalsMap { + for activity, duration := range activities { + totalEntry := TotalTimeEntry{ + ProjectID: projectID, + ActivityID: activity, + Duration: duration, + } + totalTime.Totals = append(totalTime.Totals, &totalEntry) + totalTime.TotalSum = totalTime.TotalSum + duration + } + } + + return totalTime, nil +} + +// GetTimeSummary gathers time entries for a given day. The entries are sorted by start time ascending. +func GetTimeSummary() ([]*Time, error) { + dayStart := time.Now().Truncate(24 * time.Hour) + dayEnd := time.Now().Truncate(24*time.Hour).AddDate(0, 0, 1).Add(-1 * time.Second) + entries, entriesErr := readTimeRange(dayStart, dayEnd) + if entriesErr != nil { + return nil, entriesErr + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Start.Before(entries[j].Start) + }) + + return entries, nil +} diff --git a/lib/timer.go b/lib/timer.go new file mode 100644 index 0000000..f31cde2 --- /dev/null +++ b/lib/timer.go @@ -0,0 +1,117 @@ +package lib + +import ( + "os" + "path/filepath" + "time" +) + +type NoTimerActiveError struct{} + +func (e *NoTimerActiveError) Error() string { + return "No timer is currently active." +} + +// StartTimer starts a new timer. +func StartTimer() (*Time, error) { + // Stop active timer + activeTimer, _ := GetTimer() + if activeTimer != nil { + timer, stopErr := StopTimer() + if stopErr != nil { + return timer, stopErr + } + } + + // Start new timer + newTimer := Time{ + Start: time.Now(), + } + + setErr := setTimer(&newTimer) + if setErr != nil { + return nil, setErr + } + + return &newTimer, nil +} + +// UpdateTimer sets the project and activity on the current timer. +func UpdateTimer(project *Project, activity *Activity) (*Time, error) { + timer, timerErr := GetTimer() + if timerErr != nil { + return timer, timerErr + } + if timer == nil { + return nil, &NoTimerActiveError{} + } + + timer.ProjectID = project.ID + timer.ActivityID = activity.ID + + setError := setTimer(timer) + if setError != nil { + return timer, setError + } + + return timer, nil +} + +// StopTimer stops the current timer and records the resulting time entry. +func StopTimer() (*Time, error) { + timer, timerErr := GetTimer() + if timerErr != nil { + return timer, timerErr + } + if timer == nil { + return nil, &NoTimerActiveError{} + } + + timer.End = time.Now() + recordErr := SetTime(timer) + if recordErr != nil { + return timer, recordErr + } + + file, fileErr := getTimerFile() + if fileErr != nil { + return timer, fileErr + } + + removeErr := os.Remove(file) + if removeErr != nil { + return timer, removeErr + } + + return nil, nil +} + +// GetTimer returns the active timer. +func GetTimer() (*Time, error) { + file, fileErr := getTimerFile() + if fileErr != nil { + return nil, fileErr + } + + return readTime(file) +} + +// setTimer persists a time entry as current timer. If there is already a current timer, it will be overwritten. +func setTimer(entry *Time) error { + file, fileErr := getTimerFile() + if fileErr != nil { + return fileErr + } + + return writeTime(entry, file) +} + +// getTimerFile returns the file name for the timer. +func getTimerFile() (string, error) { + folder, err := getDataDir("time") + if err != nil { + return "", err + } + + return filepath.Join(folder, "timer.json"), nil +} diff --git a/lib/times.go b/lib/times.go new file mode 100644 index 0000000..4297499 --- /dev/null +++ b/lib/times.go @@ -0,0 +1,160 @@ +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 +}