diff --git a/README.md b/README.md index 52e21c5..c68b345 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Files and folders are structured as follows: .json .json .json - timer.json + current.json Project files are stored in a “projects” folder using the UUID as file name and the following content: @@ -64,7 +64,7 @@ split. They have the following content: } ``` -Additionally, the file “timer.json” is stored in the “times” folder containing +Additionally, the file “current.json” is stored in the “times” folder containing the currently active time tracking. End `end` property is empty in that case. Both `project` and `activity` can be empty as well to allow both to be set after starting time tracking. diff --git a/application/application.go b/application/application.go deleted file mode 100644 index d8747d9..0000000 --- a/application/application.go +++ /dev/null @@ -1,334 +0,0 @@ -// Package application is the base of a stateful application, storing loaded data in memory. -package application - -import ( - "slices" - "strings" - "sync" - "time" - - "suruatoel.xyz/timefiles/lib" -) - -// Application represents a stateful time-tracker application. -// -// jobLock is a mutual-exclusive lock to limit the execution of asynchronous jobs. -// -// jobWait is a wait group to wait for asynchronous jobs to finished. -// -// ui represents the user interface allowing the application to call callbacks. -// -// projectsByID stores projects by their ID -// -// projectsByTitle stores projects by their title -// -// activitiesByID stores activities by the ID of the project they belong to and their ID -// -// activitiesByTitle stores activities by the ID of the project they belong to and their title -type Application struct { - jobLock sync.Mutex - jobWait sync.WaitGroup - ui UserInterface - projectsByID map[string]*lib.Project - projectsByTitle map[string]*lib.Project - activitiesByID map[string]map[string]*lib.Activity - activitiesByTitle map[string]map[string]*lib.Activity -} - -// UserInterface is the representation of a user interface with callback functions. -// -// SetTimerStart notifies the user interface that the update of the time has started. -// -// SetTimer sends an updated timer to the user interface. If the timer was stopped, nil will be passed. -// -// SetSummaryStart notifies the user interface that calculating the summary has started. -// -// SetSummary sends the calculated summary to the user interface. -// -// SetTotalsStart notifies the user interface that calculating the totals has started. -// -// SetTotals sends the calculated totals to the user interface. -type UserInterface interface { - SetTimerStart() - SetTimer(timer *lib.Time, err error) - SetSummaryStart() - SetSummary(summary []*lib.Time, err error) - SetTotalsStart() - SetTotals(totals lib.TotalTime, err error) -} - -// NewApplication creates a new application for a user interface. -func NewApplication(ui UserInterface) *Application { - app := Application{ - ui: ui, - projectsByID: make(map[string]*lib.Project), - projectsByTitle: make(map[string]*lib.Project), - activitiesByID: make(map[string]map[string]*lib.Activity), - activitiesByTitle: make(map[string]map[string]*lib.Activity), - } - - return &app -} - -// Load loads initial application data like projects and activities. -func (app *Application) Load() { - projects, err := lib.GetProjects() - if err != nil { - return - } - - for _, project := range projects { - app.projectsByID[project.ID] = project - app.projectsByTitle[project.Title] = project - - activities, activitiesErr := lib.GetActivities(project) - if activitiesErr != nil { - return - } - - activitiesByID := make(map[string]*lib.Activity) - activitiesByTitle := make(map[string]*lib.Activity) - for _, activity := range activities { - activitiesByID[activity.ID] = activity - activitiesByTitle[activity.Title] = activity - } - app.activitiesByID[project.ID] = activitiesByID - app.activitiesByTitle[project.ID] = activitiesByTitle - } -} - -// GetProject returns a project by its ID. -func (app *Application) GetProject(projectID string) *lib.Project { - return app.projectsByID[projectID] -} - -// GetActivity returns an activity by its ID. -func (app *Application) GetActivity(project *lib.Project, activityID string) *lib.Activity { - activities, found := app.activitiesByID[project.ID] - if !found { - return nil - } - - return activities[activityID] -} - -// SearchProjects searches for projects by comparing their title with a given query. The comparison is case-insensitive -// and finds any project which title contains the query string. -func (app *Application) SearchProjects(query string) (projects []*lib.Project) { - for _, project := range app.projectsByTitle { - if strings.Contains(strings.ToLower(project.Title), strings.ToLower(query)) { - projects = append(projects, project) - } - } - - return -} - -// SearchActivities searches for activities of a projects by comparing their title with a given query. The comparison is -// case-insensitive and finds any activity which title contains the query string. -func (app *Application) SearchActivities(projectTitle string, query string) (activities []*lib.Activity) { - project, projectFound := app.projectsByTitle[projectTitle] - if !projectFound { - return - } - projectActivities, found := app.activitiesByTitle[project.ID] - if !found { - return - } - for _, activity := range projectActivities { - if strings.Contains(strings.ToLower(activity.Title), strings.ToLower(query)) { - activities = append(activities, activity) - } - } - - return -} - -// GetTimer loads the current timer. This function queues the job and returns immediately. When the job is done, -// UserInterface.SetTimer is called. -func (app *Application) GetTimer() { - app.runJob(app.getTimer) -} - -// StartTimer starts a new timer. This function queues the job and returns immediately. When the job is done, -// UserInterface.SetTimer is called. -func (app *Application) StartTimer() { - app.runJob(app.startTimer) -} - -// UpdateTimer updates the current timer. This function queues the job and returns immediately. When the job is done, -// UserInterface.SetTimer is called. -func (app *Application) UpdateTimer(project string, activity string) { - app.runJob(func() { - app.updateTimer(project, activity) - }) -} - -// StopTimer stops the current timer. This function queues the job and returns immediately. When the job is done, -// UserInterface.SetTimer is called. -// Additionally, it gathers the time summary and calculates time totals, which will call UserInterface.SetSummary and -// UserInterface.SetTotals. -func (app *Application) StopTimer() { - app.runJob(app.stopTimer) - app.runJob(app.getTimeSummary) - app.runJob(app.getTimeTotals) -} - -// GetTimeSummary gathers a list of time entries for the current day. This function queues the job and returns -// immediately. When the job is done, UserInterface.SetSummary is called. -func (app *Application) GetTimeSummary() { - app.runJob(app.getTimeSummary) -} - -// GetTimeTotals collects the time spent per project and activity for the current day. This function queues the job and -// returns immediately. When the job is done, UserInterface.SetTotals is called. -func (app *Application) GetTimeTotals() { - app.runJob(app.getTimeTotals) -} - -// Wait blocks until all asynchronous jobs have been completed. -func (app *Application) Wait() { - app.jobWait.Wait() -} - -// runJob runs a job in a separate go-routine. -func (app *Application) runJob(job func()) { - app.jobWait.Add(1) - go func() { - job() - app.jobWait.Done() - }() -} - -// getTimer loads the current timer. It calls the corresponding callback functions on the application. -func (app *Application) getTimer() { - app.ui.SetTimerStart() - timer, err := lib.GetTimer() - app.ui.SetTimer(timer, err) -} - -// startTimer starts a new timer. It calls the corresponding callback functions on the application. -func (app *Application) startTimer() { - app.ui.SetTimerStart() - timer, err := lib.StartTimer() - app.ui.SetTimer(timer, err) -} - -// updateTimer updates the current timer. It calls the corresponding callback functions on the application. -func (app *Application) updateTimer(projectTitle string, activityTitle string) { - app.ui.SetTimerStart() - timer, err := app.updateTimerInternal(projectTitle, activityTitle) - app.ui.SetTimer(timer, err) -} - -// updateTimerInternal updates the current timer. -func (app *Application) updateTimerInternal(projectTitle string, activityTitle string) (*lib.Time, error) { - project, projectErr := app.getProjectByTitle(projectTitle) - if projectErr != nil { - return nil, projectErr - } - - activity, activityErr := app.getActivityByTitle(project, activityTitle) - if activityErr != nil { - return nil, activityErr - } - - return lib.UpdateTimer(project, activity) -} - -// stopTimer stops the current timer. -func (app *Application) stopTimer() { - app.ui.SetTimerStart() - timer, err := lib.StopTimer() - app.ui.SetTimer(timer, err) -} - -// getTimeSummary gathers a list of time entries for the current day. -func (app *Application) getTimeSummary() { - app.ui.SetSummaryStart() - summary, err := lib.GetTimeSummary() - - app.ui.SetSummary(summary, err) -} - -// getTimeTotals collects the time spent per project and activity for the current day. -func (app *Application) getTimeTotals() { - app.ui.SetTotalsStart() - totalTime, totalTimeErr := lib.GetTimeTotals( - time.Now().Truncate(24*time.Hour), - time.Now().Truncate(24*time.Hour).AddDate(0, 0, 1).Add(-1*time.Second), - ) - - // Resolve IDs to labels - for _, entry := range totalTime.Totals { - project := app.GetProject(entry.ProjectID) - if project != nil { - entry.Project = project.Title - activity := app.GetActivity(project, entry.ActivityID) - if activity != nil { - entry.Activity = activity.Title - } - } - } - - // Sort entries alphabetically - slices.SortFunc(totalTime.Totals, func(a, b *lib.TotalTimeEntry) int { - return strings.Compare(a.Project, b.Project) - }) - - app.ui.SetTotals(totalTime, totalTimeErr) -} - -// getProjectByTitle gets a project by its title. If no project with the given title exists, it creates a new project. -func (app *Application) getProjectByTitle(title string) (*lib.Project, error) { - project, found := app.projectsByTitle[title] - if found { - return project, nil - } - - // Create new project - project = lib.NewProject() - project.Title = title - err := lib.SetProject(project) - if err != nil { - return nil, err - } - - // Update in-memory storage - app.projectsByID[project.ID] = project - app.projectsByTitle[project.Title] = project - app.activitiesByID[project.ID] = make(map[string]*lib.Activity) - app.activitiesByTitle[project.ID] = make(map[string]*lib.Activity) - - return project, nil -} - -// getActivityByTitle gets an activity by its title. If no activity with the given title exists, it creates a new -// activity. -func (app *Application) getActivityByTitle(project *lib.Project, title string) (*lib.Activity, error) { - activitiesByTitle, activitiesFound := app.activitiesByTitle[project.ID] - if !activitiesFound { - // This should never happen - return nil, nil - } - - activity, found := activitiesByTitle[title] - if found { - return activity, nil - } - - // Create new activity - activity = &lib.Activity{ - Title: title, - } - err := lib.SetActivity(project, activity) - if err != nil { - return nil, err - } - - // Update in-memory storage - app.activitiesByID[project.ID][activity.ID] = activity - app.activitiesByTitle[project.ID][activity.Title] = activity - - return activity, nil -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 3f0e3c8..0000000 --- a/go.mod +++ /dev/null @@ -1,19 +0,0 @@ -module suruatoel.xyz/timefiles - -go 1.24.2 - -require ( - github.com/gdamore/tcell/v2 v2.9.0 - github.com/rivo/tview v0.42.0 -) - -require ( - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect - github.com/gdamore/encoding v1.0.1 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 9e81442..0000000 --- a/go.sum +++ /dev/null @@ -1,51 +0,0 @@ -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= -github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.9.0 h1:N6t+eqK7/xwtRPwxzs1PXeRWnm0H9l02CrgJ7DLn1ys= -github.com/gdamore/tcell/v2 v2.9.0/go.mod h1:8/ZoqM9rxzYphT9tH/9LnunhV9oPBqwS8WHGYm5nrmo= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= -github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/lib/activities.go b/lib/activities.go deleted file mode 100644 index 6ef4830..0000000 --- a/lib/activities.go +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index deb0a09..0000000 --- a/lib/common.go +++ /dev/null @@ -1,39 +0,0 @@ -// 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 deleted file mode 100644 index 853e6d9..0000000 --- a/lib/projects.go +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index e75b31f..0000000 --- a/lib/stats.go +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index f31cde2..0000000 --- a/lib/timer.go +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 4297499..0000000 --- a/lib/times.go +++ /dev/null @@ -1,160 +0,0 @@ -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 -} diff --git a/main.go b/main.go deleted file mode 100644 index 1393b25..0000000 --- a/main.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "fmt" - "log/slog" - "os" - - "suruatoel.xyz/timefiles/lib" - "suruatoel.xyz/timefiles/tui" -) - -func main() { - if len(os.Args) > 1 { - slog.Info(fmt.Sprintf("Set data directory to: %s", os.Args[1])) - lib.SetDataDir(os.Args[1]) - } - - tui, appErr := tui.NewTUI() - if appErr != nil { - slog.Error(fmt.Sprintf("Failed to initialize application: %s", appErr)) - os.Exit(2) - } - - runErr := tui.Run() - if runErr != nil { - slog.Error(fmt.Sprintf("Failed to run application: %s", runErr)) - os.Exit(2) - } -} diff --git a/tui/day.go b/tui/day.go deleted file mode 100644 index 5838921..0000000 --- a/tui/day.go +++ /dev/null @@ -1,275 +0,0 @@ -package tui - -import ( - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - "suruatoel.xyz/timefiles/lib" -) - -const ( - PageNameTimer = "set" -) - -type DayUI struct { - *tview.Pages - app *TUI - summaryList *tview.Table - totalsList *tview.Table - trackButtons *tview.Flex - startButton *tview.Button - stopButton *tview.Button - setButton *tview.Button - timerModal *ProActModal - currentTimer *lib.Time - summaryCount int -} - -func NewDayUI(app *TUI) *DayUI { - ui := DayUI{ - Pages: tview.NewPages(), - app: app, - } - - main := tview.NewFlex().SetDirection(tview.FlexRow) - ui.AddPage("main", main, true, true) - - summary := tview.NewFlex().SetDirection(tview.FlexColumn) - main.AddItem(summary, 0, 1, false) - - ui.summaryList = tview.NewTable() - ui.summaryList.SetBorder(true).SetTitle("List") - ui.summaryList.SetSelectable(true, false) - summary.AddItem(ui.summaryList, 0, 1, false) - - ui.totalsList = tview.NewTable() - ui.totalsList.SetBorder(true).SetTitle(" Totals ") - summary.AddItem(ui.totalsList, 0, 1, false) - - track := tview.NewFlex().SetDirection(tview.FlexRow) - track.SetBorder(true).SetTitle(" Track ") - main.AddItem(track, 3, 0, true) - - ui.trackButtons = tview.NewFlex().SetDirection(tview.FlexColumn) - track.AddItem(ui.trackButtons, 1, 0, true) - ui.startButton = tview.NewButton("start (n)") - ui.trackButtons.AddItem(ui.startButton, 0, 1, false) - ui.trackButtons.AddItem(nil, 1, 0, false) - ui.stopButton = tview.NewButton("stop (r)") - ui.trackButtons.AddItem(ui.stopButton, 0, 1, false) - ui.trackButtons.AddItem(nil, 1, 0, false) - ui.setButton = tview.NewButton("set (s)") - ui.trackButtons.AddItem(ui.setButton, 0, 1, false) - - ui.timerModal = NewProActModal(app, "Timer") - ui.AddPage(PageNameTimer, ui.timerModal, true, false) - - ui.assignHandlers() - - return &ui -} - -func (ui *DayUI) SetTimer(timer *lib.Time) { - if ui.currentTimer != nil { - ui.summaryList.RemoveRow(ui.summaryCount) - } - ui.currentTimer = timer - ui.setInputStates() - - if timer == nil { - ui.app.Focus(ui.startButton) - ui.timerModal.SetValues(nil, nil) - } else { - ui.timerModal.SetValues(ui.resolveTimerValues(timer.ProjectID, timer.ActivityID)) - - if timer.ProjectID == "" || timer.ActivityID == "" { - ui.openTimerModal() - } else { - ui.app.Focus(ui.stopButton) - } - - ui.includeTimerInSummary() - } -} - -func (ui *DayUI) SetSummary(summary []*lib.Time) { - ui.summaryCount = len(summary) - ui.summaryList.Clear() - for i, entry := range summary { - ui.setSummaryLine(entry, i) - } - - ui.includeTimerInSummary() -} - -func (ui *DayUI) SetTotals(totalTime lib.TotalTime) { - ui.totalsList.Clear() - for i, entry := range totalTime.Totals { - projectLabel := entry.Project - var projectAttrs tcell.AttrMask - activityLabel := entry.Activity - var activityAttrs tcell.AttrMask - - if entry.Project == "" { - projectLabel = "not set" - projectAttrs = tcell.AttrItalic - } - if entry.Activity == "" { - activityLabel = "not set" - activityAttrs = tcell.AttrItalic - } - - ui.totalsList.SetCell(i, 0, tview.NewTableCell(projectLabel).SetExpansion(1).SetAttributes(projectAttrs)) - ui.totalsList.SetCell(i, 1, tview.NewTableCell(activityLabel).SetExpansion(1)) - ui.totalsList.SetCell(i, 2, tview.NewTableCell(entry.Duration.Round(time.Second).String()).SetExpansion(0).SetAttributes(activityAttrs)) - } - - ui.totalsList.SetCell(len(totalTime.Totals), 0, tview.NewTableCell("Total").SetExpansion(1).SetAttributes(tcell.AttrBold)) - ui.totalsList.SetCell(len(totalTime.Totals), 2, tview.NewTableCell(totalTime.TotalSum.Round(time.Second).String()).SetExpansion(0).SetAttributes(tcell.AttrBold)) -} - -func (ui *DayUI) setSummaryLine(entry *lib.Time, line int) { - projectAttrs := tcell.AttrItalic - activityAttrs := tcell.AttrItalic - projectLabel := "not set" - activityLabel := "not set" - - project, activity := ui.resolveTimerValues(entry.ProjectID, entry.ActivityID) - if project != nil { - projectLabel = project.Title - projectAttrs = tcell.AttrNone - if activity != nil { - activityLabel = activity.Title - activityAttrs = tcell.AttrNone - } - } - - ui.summaryList.SetCell(line, 0, tview.NewTableCell(projectLabel).SetExpansion(1).SetAttributes(projectAttrs)) - ui.summaryList.SetCell(line, 1, tview.NewTableCell(activityLabel).SetExpansion(1).SetAttributes(activityAttrs)) - if !entry.Start.IsZero() { - ui.summaryList.SetCell(line, 2, tview.NewTableCell(formatTime(entry.Start)).SetExpansion(0)) - } - if !entry.End.IsZero() { - ui.summaryList.SetCell(line, 3, tview.NewTableCell(formatTime(entry.End)).SetExpansion(0)) - } -} - -func (ui *DayUI) includeTimerInSummary() { - if ui.currentTimer == nil { - return - } - ui.setSummaryLine(ui.currentTimer, ui.summaryCount) -} - -func (ui *DayUI) assignHandlers() { - ui.startButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyTAB: - ui.app.Focus(ui.stopButton) - return nil - } - - return event - }) - ui.startButton.SetSelectedFunc(func() { - ui.start() - }) - - ui.stopButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyTAB: - ui.app.Focus(ui.setButton) - return nil - } - - return event - }) - ui.stopButton.SetSelectedFunc(func() { - ui.stop() - }) - - ui.setButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyTAB: - ui.app.Focus(ui.summaryList) - return nil - } - - return event - }) - ui.setButton.SetSelectedFunc(func() { - ui.openTimerModal() - }) - - ui.timerModal.SetCloseFunc(func(project string, activity string, success bool) { - ui.HidePage(PageNameTimer) - - if success { - ui.setDetails(project, activity) - ui.app.Focus(ui.stopButton) - } else { - ui.app.Focus(ui.setButton) - } - }) - - ui.trackButtons.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Rune() { - case 'n': - ui.start() - return nil - case 'r': - ui.stop() - return nil - case 's': - ui.openTimerModal() - return nil - } - - return event - }) -} - -func (ui *DayUI) setInputStates() { - ui.stopButton.SetDisabled(ui.currentTimer == nil) - ui.setButton.SetDisabled(ui.currentTimer == nil) -} - -func (ui *DayUI) start() { - ui.app.timeApp.StartTimer() -} - -func (ui *DayUI) stop() { - if ui.currentTimer != nil { - ui.app.timeApp.StopTimer() - } -} - -func (ui *DayUI) openTimerModal() { - if ui.currentTimer != nil { - ui.ShowPage(PageNameTimer) - ui.app.Focus(ui.timerModal) - } -} - -func (ui *DayUI) setDetails(project, activity string) { - if ui.currentTimer == nil { - return - } - - ui.app.timeApp.UpdateTimer(project, activity) -} - -func (ui *DayUI) resolveTimerValues(projectID, activityID string) (*lib.Project, *lib.Activity) { - project := ui.app.timeApp.GetProject(projectID) - if project != nil { - activity := ui.app.timeApp.GetActivity(project, activityID) - return project, activity - } - - return project, nil -} - -func formatTime(t time.Time) string { - return t.Format("15:04:05") -} diff --git a/tui/mainscreen.go b/tui/mainscreen.go deleted file mode 100644 index c305ca5..0000000 --- a/tui/mainscreen.go +++ /dev/null @@ -1,78 +0,0 @@ -package tui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - "suruatoel.xyz/timefiles/lib" -) - -// MainScreen represents the main screen of the user interface. -type MainScreen struct { - app *TUI - main *tview.Flex - tabs *Tabs - timesPage *DayUI - statusBar *StatusBar - help *tview.TextView -} - -// NewMainScreen creates a new main screen of the user interface. -func NewMainScreen(app *TUI) (*MainScreen, error) { - ui := MainScreen{ - app: app, - } - - ui.tabs = NewTabs(true) - - ui.timesPage = NewDayUI(app) - ui.tabs.AddTab("times", "Times", ui.timesPage, true) - - ui.statusBar = NewStatusBar() - - ui.main = tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(ui.tabs, 0, 1, false). - AddItem(ui.statusBar, 1, 1, false) - - ui.tabs.Select("times") - app.uiApp.SetRoot(ui.main, true).EnableMouse(true).SetFocus(ui.timesPage) - ui.addShortcuts() - - return &ui, nil -} - -// SetStatus displays a message in the status bar. -func (ui *MainScreen) SetStatus(message string) { - ui.statusBar.SetStatus(message) -} - -// SetTimer displays the current timer on the times page. -func (ui *MainScreen) SetTimer(timer *lib.Time) { - ui.timesPage.SetTimer(timer) -} - -// SetSummary displays the time summary on the times page. -func (ui *MainScreen) SetSummary(summary []*lib.Time) { - ui.timesPage.SetSummary(summary) -} - -// SetTotals displays the time totals on the times page. -func (ui *MainScreen) SetTotals(totalTime lib.TotalTime) { - ui.timesPage.SetTotals(totalTime) -} - -// addShortcuts registers keyboard shortcuts for the main screen. -func (ui *MainScreen) addShortcuts() { - ui.main.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyCtrlN: - ui.app.Focus(ui.tabs.Next()) - return nil - case tcell.KeyCtrlP: - ui.app.Focus(ui.tabs.Prev()) - return nil - } - - return event - }) -} diff --git a/tui/modal.go b/tui/modal.go deleted file mode 100644 index b5bf1c3..0000000 --- a/tui/modal.go +++ /dev/null @@ -1,115 +0,0 @@ -package tui - -import ( - "github.com/rivo/tview" - "suruatoel.xyz/timefiles/lib" -) - -type ProActModal struct { - *tview.Flex - app *TUI - projectInput *tview.InputField - activityInput *tview.InputField - form *tview.Form - closeFunc func(project string, activity string, success bool) -} - -func NewProActModal(app *TUI, title string) *ProActModal { - modal := ProActModal{ - Flex: tview.NewFlex(), - app: app, - } - - modal.projectInput = tview.NewInputField().SetLabel("Project:") - modal.activityInput = tview.NewInputField().SetLabel("Activity: ") - modal.form = tview.NewForm(). - AddFormItem(modal.projectInput). - AddFormItem(modal.activityInput). - AddButton("set", func() { - if modal.closeFunc != nil { - modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), true) - } - }). - AddButton("cancel", func() { - if modal.closeFunc != nil { - modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), false) - } - }). - SetCancelFunc(func() { - if modal.closeFunc != nil { - modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), false) - } - }) - modal.form.SetTitle(title).SetBorder(true).SetBorderPadding(0, 0, 0, 0) - modal.form. - SetButtonsAlign(tview.AlignCenter). - SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor). - SetButtonTextColor(tview.Styles.PrimaryTextColor). - SetBackgroundColor(tview.Styles.ContrastBackgroundColor) - - inner := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(modal.form, 0, 1, true) - - width, height := 60, 7 - modal.AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(inner, height, 1, true). - AddItem(nil, 0, 1, false), width, 1, true). - AddItem(nil, 0, 1, false) - - modal.assignHandlers() - - return &modal -} - -func (modal *ProActModal) Focus(delegate func(p tview.Primitive)) { - modal.form.SetFocus(0) - delegate(modal.form) -} - -func (modal *ProActModal) SetCloseFunc(closeFunc func(project string, activity string, success bool)) *ProActModal { - modal.closeFunc = closeFunc - - return modal -} - -func (modal *ProActModal) SetValues(project *lib.Project, activity *lib.Activity) { - projectTitle := "" - if project != nil { - projectTitle = project.Title - } - modal.form.GetFormItem(0).(*tview.InputField).SetText(projectTitle) - - activityTitle := "" - if activity != nil { - activityTitle = activity.Title - } - modal.form.GetFormItem(1).(*tview.InputField).SetText(activityTitle) -} - -func (modal *ProActModal) assignHandlers() { - modal.projectInput.SetAutocompleteFunc(func(currentText string) (entries []string) { - if len(currentText) == 0 { - return - } - for _, project := range modal.app.timeApp.SearchProjects(currentText) { - entries = append(entries, project.Title) - } - - return - }) - - modal.activityInput.SetAutocompleteFunc(func(currentText string) (entries []string) { - project := modal.projectInput.GetText() - if project == "" || currentText == "" { - return - } - for _, activity := range modal.app.timeApp.SearchActivities(project, currentText) { - entries = append(entries, activity.Title) - } - - return - }) -} diff --git a/tui/statusbar.go b/tui/statusbar.go deleted file mode 100644 index c8274ed..0000000 --- a/tui/statusbar.go +++ /dev/null @@ -1,37 +0,0 @@ -package tui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -// StatusBar represents a status bar of the user interface. -// -// status is a text view showing a status message. -type StatusBar struct { - *tview.Flex - status *tview.TextView -} - -// NewStatusBar creates a new one-line status bar of the user interface. -func NewStatusBar() *StatusBar { - ui := StatusBar{ - Flex: tview.NewFlex(), - } - - statusLabel := tview.NewTextView().SetText("Status:") - statusLabel.SetTextColor(tcell.NewRGBColor(180, 180, 180)) - ui.AddItem(statusLabel, 8, 0, false) - - ui.status = tview.NewTextView() - ui.status.SetText("idle") - ui.status.SetTextColor(tcell.NewRGBColor(180, 180, 180)) - ui.AddItem(ui.status, 0, 1, false) - - return &ui -} - -// SetStatus displays a status message. -func (ui *StatusBar) SetStatus(text string) { - ui.status.SetText(text) -} diff --git a/tui/tabs.go b/tui/tabs.go deleted file mode 100644 index 2a2faf6..0000000 --- a/tui/tabs.go +++ /dev/null @@ -1,163 +0,0 @@ -package tui - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -type TabPrimitive interface { - tview.Primitive - ForceRedraw() -} - -type Tabs struct { - *tview.Flex - - names []string - items map[string]tview.Primitive - selector *tview.TextView - pages *tview.Pages - fallover bool - unlock bool -} - -func NewTabs(selectorAtTop bool) *Tabs { - widget := &Tabs{ - Flex: tview.NewFlex(), - names: []string{}, - items: make(map[string]tview.Primitive), - fallover: true, - } - widget.SetDirection(tview.FlexRow) - - // Selector - widget.selector = tview.NewTextView(). - SetDynamicColors(true). - SetRegions(true). - SetWrap(false). - SetTextAlign(tview.AlignLeft) - widget.selector.SetHighlightedFunc( - func(added, removed, remaining []string) { - if len(added) == 0 { - return - } - - widget.pages.SwitchToPage(added[0]) - }, - ) - if selectorAtTop { - widget.AddItem(widget.selector, 1, 0, false) - } - - // Pages - widget.pages = tview.NewPages() - widget.AddItem(widget.pages, 0, 1, false) - - if !selectorAtTop { - widget.AddItem(widget.selector, 1, 0, false) - } - - return widget -} - -func (widget *Tabs) AllowFallover(allow bool) { - widget.fallover = allow -} - -func (widget *Tabs) AddText(text string) { - fmt.Fprint(widget.selector, text) -} - -func (widget *Tabs) AddTab(name string, label string, item tview.Primitive, visible bool) { - widget.names = append(widget.names, name) - widget.items[name] = item - fmt.Fprintf(widget.selector, `["%s"][darkcyan]%s[white][""] `, name, label) - widget.pages.AddPage(name, item, true, false) - if visible { - widget.Select(name) - } -} - -func (widget *Tabs) Select(name string) { - widget.selector.Highlight(name) -} - -func (widget *Tabs) Next() tview.Primitive { - widget.unlock = true - if len(widget.names) == 0 { - return nil - } - - index := -1 - highlights := widget.selector.GetHighlights() - if len(highlights) > 0 { - currentName := highlights[0] - for currentIndex, name := range widget.names { - if name == currentName { - index = currentIndex - break - } - } - } - - index++ - if index >= len(widget.names) { - if widget.fallover { - index = 0 - } else { - index = len(widget.names) - 1 - } - } - - widget.selector.Highlight(widget.names[index]) - - return widget.items[widget.names[index]] -} - -func (widget *Tabs) Prev() tview.Primitive { - widget.unlock = true - if len(widget.names) == 0 { - return nil - } - - index := len(widget.names) - highlights := widget.selector.GetHighlights() - if len(highlights) > 0 { - currentName := highlights[0] - for currentIndex, name := range widget.names { - if name == currentName { - index = currentIndex - break - } - } - } - - index-- - if index < 0 { - if widget.fallover { - index = len(widget.names) - 1 - } else { - index = 0 - } - } - - widget.selector.Highlight(widget.names[index]) - - return widget.items[widget.names[index]] -} - -func (widget *Tabs) Focus(delegate func(p tview.Primitive)) { - delegate(widget.pages) -} - -func (widget *Tabs) Draw(screen tcell.Screen) { - if widget.unlock { - widgetX, widgetY, widgetWidth, widgetHeight := widget.GetInnerRect() - screen.LockRegion(widgetX, widgetY, widgetWidth, widgetHeight, false) - widget.unlock = false - } - - widget.Flex.Draw(screen) -} diff --git a/tui/tui.go b/tui/tui.go deleted file mode 100644 index 94a8082..0000000 --- a/tui/tui.go +++ /dev/null @@ -1,114 +0,0 @@ -// Package tui implements a terminal user interface. -package tui - -import ( - "fmt" - - "github.com/rivo/tview" - "suruatoel.xyz/timefiles/application" - "suruatoel.xyz/timefiles/lib" -) - -// TUI represents the terminal user interface. -// -// timeApp is the application to manage the time files. -// -// uiApp is the tview application. -// -// uiScreen is the main screen of the terminal application. -type TUI struct { - timeApp *application.Application - uiApp *tview.Application - uiScreen *MainScreen -} - -// NewTUI creates a new terminal user interface. -func NewTUI() (*TUI, error) { - tui := TUI{} - - tui.timeApp = application.NewApplication(&tui) - tui.uiApp = tview.NewApplication() - ui, uiErr := NewMainScreen(&tui) - if uiErr != nil { - return nil, uiErr - } - tui.uiScreen = ui - - return &tui, nil -} - -// Run loads the initial application data and runs the terminal user interface. It will wait for the UI to finish and -// for all asynchronous jobs to be completed before returning. -func (ui *TUI) Run() error { - ui.timeApp.Load() - ui.timeApp.GetTimer() - ui.timeApp.GetTimeSummary() - ui.timeApp.GetTimeTotals() - - err := ui.uiApp.Run() - ui.timeApp.Wait() - - return err -} - -// Focus delegates the focus request of the UI to TUI.uiApp. -func (ui *TUI) Focus(p tview.Primitive) { - ui.uiApp.SetFocus(p) -} - -// SetTimerStart sets the status for updating the timer. -func (ui *TUI) SetTimerStart() { - ui.uiApp.QueueUpdateDraw(func() { - ui.uiScreen.SetStatus("Updating timer") - }) -} - -// SetTimer updates the UI with the timer. -func (ui *TUI) SetTimer(timer *lib.Time, err error) { - ui.uiApp.QueueUpdateDraw(func() { - if err == nil { - ui.uiScreen.SetStatus("Timer updated") - } else { - ui.uiScreen.SetStatus(fmt.Sprintf("Failed to update timer: %s", err)) - } - ui.uiScreen.SetTimer(timer) - }) -} - -// SetSummaryStart sets the status for gathering the time summary. -func (ui *TUI) SetSummaryStart() { - ui.uiApp.QueueUpdateDraw(func() { - ui.uiScreen.SetStatus("Getting summary") - }) -} - -// SetSummary updates the UI with the time summary. -func (ui *TUI) SetSummary(summary []*lib.Time, err error) { - ui.uiApp.QueueUpdateDraw(func() { - if err == nil { - ui.uiScreen.SetStatus("Summary calculated") - } else { - ui.uiScreen.SetStatus(fmt.Sprintf("Failed get summary: %s", err)) - } - ui.uiScreen.SetSummary(summary) - }) -} - -// SetTotalsStart sets the status for calculating the time totals. -func (ui *TUI) SetTotalsStart() { - ui.uiApp.QueueUpdateDraw(func() { - ui.uiScreen.SetStatus("Calculated totals") - }) -} - -// SetTotals updates the UI with the time totals. -func (ui *TUI) SetTotals(totalTime lib.TotalTime, err error) { - ui.uiApp.QueueUpdateDraw(func() { - if err == nil { - ui.uiScreen.SetStatus("Totals calculated") - } else { - ui.uiScreen.SetStatus(fmt.Sprintf("Failed get totals: %s", err)) - } - ui.uiScreen.SetTotals(totalTime) - }) -}