diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef7e70..89a190a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.5] - 2024-07-26 + +### Added + +- **Delete Command**: Remove a session from your log with `flow delete`. Useful for cleaning up mistakes or test sessions. + ## [1.1.4] - 2025-07-19 - **Paused Session Working Time**: The `flow status` command now shows how much time you've actually worked when a session is paused, excluding pause time for accurate productivity tracking. @@ -43,32 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Addressed a potential supply chain vulnerability by replacing an archived dependency. -## [Unreleased] - -### Added - -- Initial release of Flow -- Focus timer with countdown display -- Task tagging with `--tag` flag -- Session logging to `~/.flowlog` -- Unix philosophy compliance - no built-in integrations -- Shell composition examples for Zenta integration -- Version information with `--version` flag -- Cross-platform build support -- CI/CD with GitHub Actions -- One-liner installation script (`install.sh`) -- Automated platform detection and binary installation -- Automatic creation of installation directory if it doesn't exist - -### Changed - -- Upgraded to Go 1.23.0 -- Removed direct Zenta integration in favor of Unix composition - -## Philosophy - -Flow follows semantic versioning and Unix philosophy. Breaking changes will only be introduced in major versions, and we strive to maintain backward compatibility. - ## [0.1.0] - 2025-07-01 ### Added diff --git a/README.md b/README.md index dfb3ce7..75c6dd9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,30 @@ Flow is built on a few core principles that guide its design and purpose. --- +## Quick Start + +1. **Install Flow**: + ```bash + curl -sSL https://raw.githubusercontent.com/e6a5/flow/main/install.sh | bash + ``` + +2. **Start your first session**: + ```bash + flow start --tag "My first deep work session" + ``` + +3. **Check your progress**: + ```bash + flow status + ``` + +4. **End when complete**: + ```bash + flow end + ``` + +--- + ## A Typical Workflow Flow is designed to be intuitive. Here's how a typical session works: @@ -49,11 +73,18 @@ Flow is designed to be intuitive. Here's how a typical session works: > ✨ Session complete: Writing the first draft > Total focus time: 2h 5m ``` + 4. **Review your day** and find your patterns. - ```bash - flow recent - flow insights - ``` + ```bash + flow recent + flow insights + ``` + +5. **Clean up if needed** - Remove any test sessions or mistakes. + ```bash + flow delete + ``` + --- ## Installation @@ -77,8 +108,11 @@ For other installation methods (Go, manual), see the [Installation Guide](docs/I | `pause` | Pause the active session. | | `resume` | Resume a paused session. | | `end` | Complete the session and log it. | +| `delete` | Interactively delete a session from your log. | | `watch` | Run a watcher to get gentle, timely reminders. | +> **💡 Tip**: After ending a session, if you made a mistake, you can immediately run `flow delete` to remove it! + ### Data & Analysis Commands | Command | Description | diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..d882d94 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/e6a5/flow/core" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Deletes a session", + Long: `Deletes a session from the log. + +This command interactively lists your recent sessions and allows you to select one to delete. +You will be asked to confirm before the session is permanently removed. + +Example: + flow delete`, + Run: func(cmd *cobra.Command, args []string) { + sessions, err := core.GetRecentSessions(10) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting recent sessions: %v\n", err) + os.Exit(1) + } + + if len(sessions) == 0 { + fmt.Println("No sessions to delete.") + return + } + + fmt.Println("Select a session to delete:") + for i, session := range sessions { + fmt.Printf("%d: %s - %s (%s)\n", i+1, session.StartTime.Format("2006-01-02 15:04"), session.Tag, session.Duration) + } + + fmt.Print("Enter the number of the session to delete (or 0 to cancel): ") + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + input := scanner.Text() + + choice, err := strconv.Atoi(input) + if err != nil || choice < 0 || choice > len(sessions) { + fmt.Println("Invalid selection.") + return + } + + if choice == 0 { + fmt.Println("Operation cancelled.") + return + } + + sessionToDelete := sessions[choice-1] + + fmt.Printf("\nYou have selected to delete the following session:\n") + fmt.Printf("%s - %s (%s)\n", sessionToDelete.StartTime.Format("2006-01-02 15:04"), sessionToDelete.Tag, sessionToDelete.Duration) + fmt.Print("Are you sure you want to delete this session? (y/N) ") + + scanner.Scan() + confirmation := scanner.Text() + + if strings.ToLower(confirmation) == "y" { + if err := core.DeleteLogEntry(sessionToDelete); err != nil { + fmt.Fprintf(os.Stderr, "Error deleting session: %v\n", err) + os.Exit(1) + } + fmt.Println("Session deleted.") + } else { + fmt.Println("Operation cancelled.") + } + }, +} + +func init() { + rootCmd.AddCommand(deleteCmd) +} diff --git a/core/config_test.go b/core/config_test.go index f9a6962..8fb5579 100644 --- a/core/config_test.go +++ b/core/config_test.go @@ -18,7 +18,11 @@ func createTestConfigFile(t *testing.T, content string) (string, func()) { if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("Failed to write temp config file: %v", err) } - return path, func() { _ = os.Remove(path) } + return path, func() { + if removeErr := os.Remove(path); removeErr != nil { + t.Errorf("Failed to remove test config file: %v", removeErr) + } + } } func TestLoadConfig_Defaults(t *testing.T) { diff --git a/core/delete.go b/core/delete.go new file mode 100644 index 0000000..da18dae --- /dev/null +++ b/core/delete.go @@ -0,0 +1,87 @@ +package core + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// DeleteLogEntry removes a specific log entry from the log files. +func DeleteLogEntry(entryToDelete LogEntry) error { + logPath, err := GetLogPath(entryToDelete.EndTime) + if err != nil { + return err + } + + file, err := os.Open(logPath) + if err != nil { + return err + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + // Log the error but don't return it as it's in a defer + fmt.Fprintf(os.Stderr, "Warning: failed to close file: %v\n", closeErr) + } + }() + + tempFile, err := os.CreateTemp(filepath.Dir(logPath), "temp_log_") + if err != nil { + return err + } + defer func() { + if removeErr := os.Remove(tempFile.Name()); removeErr != nil { + // Log the error but don't return it as it's in a defer + fmt.Fprintf(os.Stderr, "Warning: failed to remove temp file: %v\n", removeErr) + } + }() + + scanner := bufio.NewScanner(file) + writer := bufio.NewWriter(tempFile) + found := false + + for scanner.Scan() { + line := scanner.Text() + var entry LogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + // Skip malformed lines + continue + } + + if entry.StartTime.Equal(entryToDelete.StartTime) && entry.Tag == entryToDelete.Tag { + found = true + } else { + if _, writeErr := fmt.Fprintln(writer, line); writeErr != nil { + if closeErr := tempFile.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close temp file: %v\n", closeErr) + } + return writeErr + } + } + } + + if err := scanner.Err(); err != nil { + if closeErr := tempFile.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close temp file: %v\n", closeErr) + } + return err + } + + if err := writer.Flush(); err != nil { + if closeErr := tempFile.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close temp file: %v\n", closeErr) + } + return err + } + + if closeErr := tempFile.Close(); closeErr != nil { + return closeErr + } + + if found { + return os.Rename(tempFile.Name(), logPath) + } + + return fmt.Errorf("log entry not found") +} diff --git a/core/delete_test.go b/core/delete_test.go new file mode 100644 index 0000000..6a168cd --- /dev/null +++ b/core/delete_test.go @@ -0,0 +1,377 @@ +package core + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestDeleteLogEntry(t *testing.T) { + // Create temporary directory for test log files + tempDir := t.TempDir() + logDir := filepath.Join(tempDir, "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Set environment variable to use our test directory + originalLogPath := os.Getenv("FLOW_LOG_PATH") + t.Setenv("FLOW_LOG_PATH", filepath.Join(tempDir, "test.log")) + defer t.Setenv("FLOW_LOG_PATH", originalLogPath) + + // Create test entries + now := time.Now() + entry1 := LogEntry{ + Tag: "test session 1", + StartTime: now.Add(-1 * time.Hour), + EndTime: now, + Duration: 1 * time.Hour, + } + entry2 := LogEntry{ + Tag: "test session 2", + StartTime: now.Add(-2 * time.Hour), + EndTime: now.Add(-1 * time.Hour), + Duration: 1 * time.Hour, + } + entry3 := LogEntry{ + Tag: "test session 3", + StartTime: now.Add(-3 * time.Hour), + EndTime: now.Add(-2 * time.Hour), + Duration: 1 * time.Hour, + } + + // Create a log file with test entries + logPath, err := GetLogPath(now) + if err != nil { + t.Fatalf("Failed to get log path: %v", err) + } + + // Ensure log directory exists + if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Write test entries to log file + file, err := os.Create(logPath) + if err != nil { + t.Fatalf("Failed to create log file: %v", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + t.Errorf("Failed to close file: %v", closeErr) + } + }() + + entries := []LogEntry{entry1, entry2, entry3} + for _, entry := range entries { + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("Failed to marshal entry: %v", err) + } + if _, err := file.WriteString(string(data) + "\n"); err != nil { + t.Fatalf("Failed to write entry: %v", err) + } + } + + tests := []struct { + name string + entryToDelete LogEntry + wantErr bool + expectedError string + remainingCount int + }{ + { + name: "delete middle entry", + entryToDelete: entry2, + wantErr: false, + remainingCount: 2, + }, + { + name: "delete first entry", + entryToDelete: entry1, + wantErr: false, + remainingCount: 1, + }, + { + name: "delete last entry", + entryToDelete: entry3, + wantErr: false, + remainingCount: 0, + }, + { + name: "delete non-existent entry", + entryToDelete: LogEntry{ + Tag: "non-existent", + StartTime: now.Add(-10 * time.Hour), + EndTime: now.Add(-9 * time.Hour), + Duration: 1 * time.Hour, + }, + wantErr: true, + expectedError: "log entry not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Delete the entry + err := DeleteLogEntry(tt.entryToDelete) + + // Check error + if (err != nil) != tt.wantErr { + t.Errorf("DeleteLogEntry() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + if err.Error() != tt.expectedError { + t.Errorf("DeleteLogEntry() error = %v, want %v", err, tt.expectedError) + } + return + } + + // Verify the entry was actually deleted + remainingEntries, err := GetRecentSessions(10) + if err != nil { + t.Errorf("Failed to get remaining entries: %v", err) + return + } + + if len(remainingEntries) != tt.remainingCount { + t.Errorf("Expected %d remaining entries, got %d", tt.remainingCount, len(remainingEntries)) + } + + // Verify the deleted entry is not in the remaining entries + for _, entry := range remainingEntries { + if entry.StartTime.Equal(tt.entryToDelete.StartTime) && entry.Tag == tt.entryToDelete.Tag { + t.Errorf("Deleted entry still found in log: %+v", entry) + } + } + }) + } +} + +func TestDeleteLogEntry_EmptyFile(t *testing.T) { + // Create temporary directory for test log files + tempDir := t.TempDir() + logDir := filepath.Join(tempDir, "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Set environment variable to use our test directory + originalLogPath := os.Getenv("FLOW_LOG_PATH") + t.Setenv("FLOW_LOG_PATH", filepath.Join(tempDir, "test.log")) + defer t.Setenv("FLOW_LOG_PATH", originalLogPath) + + // Create an empty log file + logPath, err := GetLogPath(time.Now()) + if err != nil { + t.Fatalf("Failed to get log path: %v", err) + } + + // Ensure log directory exists + if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Create empty file + file, err := os.Create(logPath) + if err != nil { + t.Fatalf("Failed to create log file: %v", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + t.Errorf("Failed to close file: %v", closeErr) + } + }() + + // Try to delete a non-existent entry + entryToDelete := LogEntry{ + Tag: "non-existent", + StartTime: time.Now(), + EndTime: time.Now().Add(1 * time.Hour), + Duration: 1 * time.Hour, + } + + err = DeleteLogEntry(entryToDelete) + if err == nil { + t.Error("Expected error when deleting from empty file, got nil") + return + } + + if err.Error() != "log entry not found" { + t.Errorf("Expected 'log entry not found' error, got: %v", err) + } +} + +func TestDeleteLogEntry_MalformedLines(t *testing.T) { + // Create temporary directory for test log files + tempDir := t.TempDir() + logDir := filepath.Join(tempDir, "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Set environment variable to use our test directory + originalLogPath := os.Getenv("FLOW_LOG_PATH") + t.Setenv("FLOW_LOG_PATH", filepath.Join(tempDir, "test.log")) + defer t.Setenv("FLOW_LOG_PATH", originalLogPath) + + // Create a log file with malformed lines + logPath, err := GetLogPath(time.Now()) + if err != nil { + t.Fatalf("Failed to get log path: %v", err) + } + + // Ensure log directory exists + if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Write test entries with malformed lines + file, err := os.Create(logPath) + if err != nil { + t.Fatalf("Failed to create log file: %v", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + t.Errorf("Failed to close file: %v", closeErr) + } + }() + + now := time.Now() + entry := LogEntry{ + Tag: "test session", + StartTime: now.Add(-1 * time.Hour), + EndTime: now, + Duration: 1 * time.Hour, + } + + // Write malformed line, valid entry, another malformed line + if _, err := file.WriteString("invalid json line\n"); err != nil { + t.Fatalf("Failed to write invalid line: %v", err) + } + data, _ := json.Marshal(entry) + if _, err := file.WriteString(string(data) + "\n"); err != nil { + t.Fatalf("Failed to write valid entry: %v", err) + } + if _, err := file.WriteString("another invalid line\n"); err != nil { + t.Fatalf("Failed to write invalid line: %v", err) + } + + // Try to delete the valid entry + err = DeleteLogEntry(entry) + if err != nil { + t.Errorf("Failed to delete entry from file with malformed lines: %v", err) + } + + // Verify the entry was deleted + remainingEntries, err := GetRecentSessions(10) + if err != nil { + t.Errorf("Failed to get remaining entries: %v", err) + return + } + + if len(remainingEntries) != 0 { + t.Errorf("Expected 0 remaining entries, got %d", len(remainingEntries)) + } +} + +func TestDeleteLogEntry_FileNotFound(t *testing.T) { + // Create temporary directory for test log files + tempDir := t.TempDir() + logDir := filepath.Join(tempDir, "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Set environment variable to use our test directory + originalLogPath := os.Getenv("FLOW_LOG_PATH") + t.Setenv("FLOW_LOG_PATH", filepath.Join(tempDir, "test.log")) + defer t.Setenv("FLOW_LOG_PATH", originalLogPath) + + // Try to delete from a non-existent file + entryToDelete := LogEntry{ + Tag: "test session", + StartTime: time.Now(), + EndTime: time.Now().Add(1 * time.Hour), + Duration: 1 * time.Hour, + } + + err := DeleteLogEntry(entryToDelete) + if err == nil { + t.Error("Expected error when deleting from non-existent file, got nil") + } +} + +func TestDeleteLogEntry_DuplicateEntries(t *testing.T) { + // Create temporary directory for test log files + tempDir := t.TempDir() + logDir := filepath.Join(tempDir, "logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Set environment variable to use our test directory + originalLogPath := os.Getenv("FLOW_LOG_PATH") + t.Setenv("FLOW_LOG_PATH", filepath.Join(tempDir, "test.log")) + defer t.Setenv("FLOW_LOG_PATH", originalLogPath) + + // Create a log file with duplicate entries + logPath, err := GetLogPath(time.Now()) + if err != nil { + t.Fatalf("Failed to get log path: %v", err) + } + + // Ensure log directory exists + if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { + t.Fatalf("Failed to create log directory: %v", err) + } + + // Write test entries with duplicates + file, err := os.Create(logPath) + if err != nil { + t.Fatalf("Failed to create log file: %v", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + t.Errorf("Failed to close file: %v", closeErr) + } + }() + + now := time.Now() + entry := LogEntry{ + Tag: "duplicate session", + StartTime: now.Add(-1 * time.Hour), + EndTime: now, + Duration: 1 * time.Hour, + } + + // Write the same entry twice + data, _ := json.Marshal(entry) + if _, err := file.WriteString(string(data) + "\n"); err != nil { + t.Fatalf("Failed to write first entry: %v", err) + } + if _, err := file.WriteString(string(data) + "\n"); err != nil { + t.Fatalf("Failed to write second entry: %v", err) + } + + // Delete the entry (should delete both instances) + err = DeleteLogEntry(entry) + if err != nil { + t.Errorf("Failed to delete duplicate entries: %v", err) + } + + // Verify both entries were deleted + remainingEntries, err := GetRecentSessions(10) + if err != nil { + t.Errorf("Failed to get remaining entries: %v", err) + return + } + + if len(remainingEntries) != 0 { + t.Errorf("Expected 0 remaining entries, got %d", len(remainingEntries)) + } +} diff --git a/core/list.go b/core/list.go new file mode 100644 index 0000000..6188cf8 --- /dev/null +++ b/core/list.go @@ -0,0 +1,98 @@ +package core + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// GetRecentSessions retrieves the most recent log entries. +func GetRecentSessions(limit int) ([]LogEntry, error) { + logDir, err := GetLogDir() + if err != nil { + return nil, err + } + + files, err := filepath.Glob(filepath.Join(logDir, "*_sessions.jsonl")) + if err != nil { + return nil, err + } + + if len(files) == 0 { + return []LogEntry{}, nil + } + + var allEntries []LogEntry + totalLines := 0 + + // Sort files in reverse order (newest first) for better performance with limits + sort.Slice(files, func(i, j int) bool { + return files[i] > files[j] // Lexicographically, newer YYYYMM comes after older + }) + + for _, file := range files { + fileEntries, lines, err := readSingleFile(file) + if err != nil { + // Log error but continue with other files + fmt.Fprintf(os.Stderr, "Warning: error reading %s: %v\n", file, err) + continue + } + + allEntries = append(allEntries, fileEntries...) + totalLines += lines + + // If we have enough entries and not reading all, break early + if limit > 0 && len(allEntries) >= limit { + break + } + } + + // Sort all entries by end time (most recent first) + sort.Slice(allEntries, func(i, j int) bool { + return allEntries[i].EndTime.After(allEntries[j].EndTime) + }) + + // Apply limit after sorting + if limit > 0 && len(allEntries) > limit { + allEntries = allEntries[:limit] + } + + return allEntries, nil +} + +// readSingleFile reads entries from a single log file +func readSingleFile(filePath string) (entries []LogEntry, lineCount int, err error) { + file, err := os.Open(filePath) + if err != nil { + return nil, 0, err + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + // Log the error but don't return it as it's in a defer + fmt.Fprintf(os.Stderr, "Warning: failed to close file %s: %v\n", filePath, closeErr) + } + }() + + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + lineCount++ + line := scanner.Text() + if line == "" { + continue + } + + var entry LogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + // Skip malformed lines + continue + } + + entries = append(entries, entry) + } + + return entries, lineCount, scanner.Err() +} diff --git a/core/watch_test.go b/core/watch_test.go index 74bd07c..4bf9557 100644 --- a/core/watch_test.go +++ b/core/watch_test.go @@ -2,6 +2,7 @@ package core import ( "bytes" + "fmt" "io" "os" "strings" @@ -16,8 +17,10 @@ func captureStderr(f func()) string { os.Stderr = w f() - _ = w.Close() + if closeErr := w.Close(); closeErr != nil { + panic(fmt.Sprintf("failed to close stderr pipe: %v", closeErr)) + } var buf bytes.Buffer _, _ = io.Copy(&buf, r) os.Stderr = oldStderr