diff --git a/.memo-notes/note_1750279605.note b/.memo-notes/note_1750279605.note new file mode 100644 index 0000000..b65e346 --- /dev/null +++ b/.memo-notes/note_1750279605.note @@ -0,0 +1,10 @@ +--- +title: First Note +created: 2025-06-18T16:46:45.100499-04:00 +modified: 2025-06-18T16:46:45.101096-04:00 +tags: + - foo + - bar +--- + +this is a first note. How do I end it? just with a `return`? \ No newline at end of file diff --git a/.memo-notes/note_1750280302.note b/.memo-notes/note_1750280302.note new file mode 100644 index 0000000..19683d6 --- /dev/null +++ b/.memo-notes/note_1750280302.note @@ -0,0 +1,25 @@ +--- +title: Another Note +created: 2025-06-18T16:58:22.701219-04:00 +modified: 2025-06-18T16:58:22.70125-04:00 +--- + +Sonnet 120 - the Author. + +That you were once unkind befriends me now, +And for that sorrow, which I then did feel, +Needs must I under my transgression bow, +Unless my nerves were brass or hammer’d steel. + +For if you were by my unkindness shaken, +As I by yours, you’ve passed a hell of time; +And I, a tyrant, have no leisure taken +To weigh how once I suffered in your crime. + +O! that our night of woe might have remembered +My deepest sense, how hard true sorrow hits, +And soon to you, as you to me, then tendered +The humble salve, which wounded bosoms fits! + +But that your trespass now becomes a fee; +Mine ransoms yours, and yours must ransom me. diff --git a/README.md b/README.md index 17e73d2..329834a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ -# future-proof +# Memo - a future-proof Personal Notes Manager + +Written in Go, as an example. -# Build a Personal Notes Manager A multi-phase educational project for managing personal notes with structured metadata. + ## Overview Personal Notes Manager is a text-based note-taking system that stores notes as UTF-8 text files with YAML headers for metadata. This project serves as an educational tool for students to learn file manipulation, parsing, and progressively more advanced application architectures. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df9a40a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module memo + +go 1.24.1 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..84767b4 --- /dev/null +++ b/main.go @@ -0,0 +1,449 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Global variable to store current listing for number-based access +var currentListing []*Note + +func main() { + if len(os.Args) < 2 { + printHelp() + return + } + + command := os.Args[1] + + switch command { + case "create": + handleCreate() + case "list": + handleList() + case "read": + handleRead() + case "edit": + handleEdit() + case "delete": + handleDelete() + case "search": + handleSearch() + case "stats": + handleStats() + case "--help", "-h", "help": + printHelp() + default: + fmt.Printf("Unknown command: %s\n", command) + printHelp() + } +} + +func printHelp() { + fmt.Println("Memo - Personal Notes Manager") + fmt.Println("") + fmt.Println("Usage:") + fmt.Println(" memo create Create a new note") + fmt.Println(" memo list List all notes (with numbered references)") + fmt.Println(" memo list --tag List notes with specific tag") + fmt.Println(" memo read Display a specific note") + fmt.Println(" memo edit Edit a specific note") + fmt.Println(" memo delete Delete a specific note") + fmt.Println(" memo search Search notes for text") + fmt.Println(" memo stats Display statistics about your notes") + fmt.Println(" memo --help Display this help information") + fmt.Println("") + fmt.Println("Note: After running 'memo list', you can use numbers 1-N to reference notes") + fmt.Println(" instead of the full note ID (e.g., 'memo read 3' or 'memo edit 5')") +} + +func handleCreate() { + title := promptForInput("Enter note title: ") + if title == "" { + fmt.Println("Error: title is required") + return + } + + content := promptForInput("Enter note content: ") + + // Parse tags if provided + tagsInput := promptForInput("Enter tags (comma-separated, optional): ") + var tags []string + if tagsInput != "" { + for _, tag := range strings.Split(tagsInput, ",") { + tags = append(tags, strings.TrimSpace(tag)) + } + } + + noteID := generateNoteID() + note := &Note{ + Metadata: NoteMetadata{ + Title: title, + Created: time.Now(), + Modified: time.Now(), + Tags: tags, + }, + Content: content, + FilePath: generateNoteFilePath(noteID), + } + + err := note.Save() + if err != nil { + fmt.Printf("Error creating note: %v\n", err) + return + } + + fmt.Printf("Note created successfully: %s\n", noteID) +} + +func handleList() { + // Check for --tag flag + var tagFilter string + if len(os.Args) >= 3 && os.Args[2] == "--tag" { + if len(os.Args) < 4 { + fmt.Println("Error: tag value required") + fmt.Println("Usage: memo list --tag ") + return + } + tagFilter = os.Args[3] + } + + var notes []*Note + var err error + + if tagFilter != "" { + notes, err = filterNotesByTag(tagFilter) + if err != nil { + fmt.Printf("Error filtering notes by tag: %v\n", err) + return + } + fmt.Printf("Notes with tag '%s':\n", tagFilter) + } else { + notes, err = getAllNotes() + if err != nil { + fmt.Printf("Error listing notes: %v\n", err) + return + } + fmt.Println("All notes:") + } + + if len(notes) == 0 { + fmt.Println("No notes found.") + return + } + + // Store notes in global listing for number-based access + currentListing = notes + + // Display notes with pagination + displayNotesWithPagination(notes) +} + +func displayNotesWithPagination(notes []*Note) { + const pageSize = 10 + startIndex := 0 + + for { + endIndex := startIndex + pageSize + if endIndex > len(notes) { + endIndex = len(notes) + } + + // Display current page + fmt.Printf("\nShowing notes %d-%d of %d:\n", startIndex+1, endIndex, len(notes)) + fmt.Println("========================================") + + for i := startIndex; i < endIndex; i++ { + note := notes[i] + noteID := strings.TrimSuffix(filepath.Base(note.FilePath), NoteExtension) + listNumber := i + 1 + + fmt.Printf("%2d. %s | Created: %s\n", + listNumber, + note.Metadata.Title, + note.Metadata.Created.Format("2006-01-02 15:04")) + + if len(note.Metadata.Tags) > 0 { + fmt.Printf(" Tags: %s\n", strings.Join(note.Metadata.Tags, ", ")) + } + fmt.Printf(" ID: %s\n", noteID) + fmt.Println() + } + + // Check if there are more notes to display + if endIndex >= len(notes) { + fmt.Println("End of notes.") + break + } + + // Ask user if they want to see more + fmt.Printf("Show next %d notes? (y/N): ", pageSize) + response := promptForInput("") + + if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { + break + } + + startIndex = endIndex + } + + fmt.Println("\nTip: Use 'memo read ' or 'memo edit ' with numbers 1-" + strconv.Itoa(len(notes)) + " from this listing.") +} + +// resolveNoteID converts a note identifier (either actual ID or list number) to actual note ID +func resolveNoteID(identifier string) (string, error) { + // Try to parse as a number first + if num, err := strconv.Atoi(identifier); err == nil { + // It's a number, check if it's valid for current listing + if currentListing == nil || len(currentListing) == 0 { + return "", fmt.Errorf("no current note listing. Please run 'memo list' first") + } + + if num < 1 || num > len(currentListing) { + return "", fmt.Errorf("number %d is out of range. Valid range: 1-%d", num, len(currentListing)) + } + + // Convert to actual note ID + note := currentListing[num-1] + return strings.TrimSuffix(filepath.Base(note.FilePath), NoteExtension), nil + } + + // It's not a number, assume it's an actual note ID + return identifier, nil +} + +func handleRead() { + if len(os.Args) < 3 { + fmt.Println("Error: note-id or number required") + fmt.Println("Usage: memo read ") + return + } + identifier := os.Args[2] + + // Resolve the identifier to actual note ID + noteID, err := resolveNoteID(identifier) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + note, err := findNoteByID(noteID) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Title: %s\n", note.Metadata.Title) + fmt.Printf("Created: %s\n", note.Metadata.Created.Format("2006-01-02 15:04:05")) + fmt.Printf("Modified: %s\n", note.Metadata.Modified.Format("2006-01-02 15:04:05")) + + if len(note.Metadata.Tags) > 0 { + fmt.Printf("Tags: %s\n", strings.Join(note.Metadata.Tags, ", ")) + } + + if note.Metadata.Author != "" { + fmt.Printf("Author: %s\n", note.Metadata.Author) + } + + if note.Metadata.Status != "" { + fmt.Printf("Status: %s\n", note.Metadata.Status) + } + + if note.Metadata.Priority > 0 { + fmt.Printf("Priority: %d\n", note.Metadata.Priority) + } + + fmt.Println("\nContent:") + fmt.Println("--------") + fmt.Println(note.Content) +} + +func handleEdit() { + if len(os.Args) < 3 { + fmt.Println("Error: note-id or number required") + fmt.Println("Usage: memo edit ") + return + } + identifier := os.Args[2] + + // Resolve the identifier to actual note ID + noteID, err := resolveNoteID(identifier) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + note, err := findNoteByID(noteID) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Editing note: %s\n", note.Metadata.Title) + fmt.Printf("Current content:\n%s\n\n", note.Content) + + // Simple edit - just replace content + newContent := promptForInput("Enter new content (leave empty to keep current): ") + if newContent != "" { + note.Content = newContent + } + + // Update tags + currentTags := strings.Join(note.Metadata.Tags, ", ") + fmt.Printf("Current tags: %s\n", currentTags) + newTags := promptForInput("Enter new tags (comma-separated, leave empty to keep current): ") + if newTags != "" { + var tags []string + for _, tag := range strings.Split(newTags, ",") { + tags = append(tags, strings.TrimSpace(tag)) + } + note.Metadata.Tags = tags + } + + err = note.Save() + if err != nil { + fmt.Printf("Error saving note: %v\n", err) + return + } + + fmt.Println("Note updated successfully!") +} + +func handleDelete() { + if len(os.Args) < 3 { + fmt.Println("Error: note-id or number required") + fmt.Println("Usage: memo delete ") + return + } + identifier := os.Args[2] + + // Resolve the identifier to actual note ID + noteID, err := resolveNoteID(identifier) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + note, err := findNoteByID(noteID) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Are you sure you want to delete note '%s'? (y/N): ", note.Metadata.Title) + confirmation := promptForInput("") + + if strings.ToLower(confirmation) != "y" && strings.ToLower(confirmation) != "yes" { + fmt.Println("Deletion cancelled.") + return + } + + err = os.Remove(note.FilePath) + if err != nil { + fmt.Printf("Error deleting note: %v\n", err) + return + } + + fmt.Println("Note deleted successfully!") +} + +func handleSearch() { + if len(os.Args) < 3 { + fmt.Println("Error: search query required") + fmt.Println("Usage: memo search ") + return + } + query := os.Args[2] + + notes, err := searchNotes(query) + if err != nil { + fmt.Printf("Error searching notes: %v\n", err) + return + } + + if len(notes) == 0 { + fmt.Printf("No notes found matching '%s'\n", query) + return + } + + fmt.Printf("Found %d note(s) matching '%s':\n\n", len(notes), query) + + for _, note := range notes { + noteID := strings.TrimSuffix(filepath.Base(note.FilePath), NoteExtension) + fmt.Printf("ID: %s | Title: %s\n", noteID, note.Metadata.Title) + + // Show preview of content + preview := note.Content + if len(preview) > 100 { + preview = preview[:100] + "..." + } + fmt.Printf("Preview: %s\n", preview) + fmt.Println("--------") + } +} + +func handleStats() { + notes, err := getAllNotes() + if err != nil { + fmt.Printf("Error loading notes: %v\n", err) + return + } + + if len(notes) == 0 { + fmt.Println("No notes found.") + return + } + + fmt.Println("Note Statistics:") + fmt.Printf("Total notes: %d\n", len(notes)) + + // Count tags + tagCount := make(map[string]int) + var totalWords int + var oldestNote, newestNote *Note + + for i, note := range notes { + // Count words in content + words := strings.Fields(note.Content) + totalWords += len(words) + + // Track oldest and newest notes + if i == 0 { + oldestNote = note + newestNote = note + } else { + if note.Metadata.Created.Before(oldestNote.Metadata.Created) { + oldestNote = note + } + if note.Metadata.Created.After(newestNote.Metadata.Created) { + newestNote = note + } + } + + // Count tags + for _, tag := range note.Metadata.Tags { + tagCount[tag]++ + } + } + + fmt.Printf("Total words: %d\n", totalWords) + fmt.Printf("Average words per note: %.1f\n", float64(totalWords)/float64(len(notes))) + + if oldestNote != nil { + fmt.Printf("Oldest note: %s (%s)\n", oldestNote.Metadata.Title, oldestNote.Metadata.Created.Format("2006-01-02")) + } + if newestNote != nil { + fmt.Printf("Newest note: %s (%s)\n", newestNote.Metadata.Title, newestNote.Metadata.Created.Format("2006-01-02")) + } + + if len(tagCount) > 0 { + fmt.Printf("\nTag usage:\n") + for tag, count := range tagCount { + fmt.Printf(" %s: %d\n", tag, count) + } + } +} \ No newline at end of file diff --git a/memo b/memo new file mode 100755 index 0000000..bdebf04 Binary files /dev/null and b/memo differ diff --git a/note.go b/note.go new file mode 100644 index 0000000..d952893 --- /dev/null +++ b/note.go @@ -0,0 +1,203 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +type NoteMetadata struct { + Title string `yaml:"title"` + Created time.Time `yaml:"created"` + Modified time.Time `yaml:"modified"` + Tags []string `yaml:"tags,omitempty"` + Author string `yaml:"author,omitempty"` + Status string `yaml:"status,omitempty"` + Priority int `yaml:"priority,omitempty"` +} + +type Note struct { + Metadata NoteMetadata + Content string + FilePath string +} + +const ( + NotesDir = ".memo-notes" + NoteExtension = ".note" +) + +func ensureNotesDir() error { + if _, err := os.Stat(NotesDir); os.IsNotExist(err) { + return os.MkdirAll(NotesDir, 0755) + } + return nil +} + +func parseNote(filePath string) (*Note, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + contentStr := string(content) + + // Check if file starts with YAML front matter + if !strings.HasPrefix(contentStr, "---\n") { + return nil, fmt.Errorf("note file must start with YAML front matter") + } + + // Find the end of YAML front matter + parts := strings.Split(contentStr, "\n---\n") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid note format: missing YAML front matter delimiter") + } + + yamlContent := parts[0][4:] // Remove the first "---\n" + noteContent := strings.Join(parts[1:], "\n---\n") + + var metadata NoteMetadata + err = yaml.Unmarshal([]byte(yamlContent), &metadata) + if err != nil { + return nil, fmt.Errorf("error parsing YAML metadata: %v", err) + } + + return &Note{ + Metadata: metadata, + Content: strings.TrimSpace(noteContent), + FilePath: filePath, + }, nil +} + +func (n *Note) Save() error { + if err := ensureNotesDir(); err != nil { + return err + } + + // Update modified time + n.Metadata.Modified = time.Now() + + // Marshal YAML metadata + yamlData, err := yaml.Marshal(&n.Metadata) + if err != nil { + return err + } + + // Combine YAML header with content + fullContent := fmt.Sprintf("---\n%s---\n\n%s", string(yamlData), n.Content) + + return os.WriteFile(n.FilePath, []byte(fullContent), 0644) +} + +func generateNoteID() string { + return fmt.Sprintf("note_%d", time.Now().Unix()) +} + +func generateNoteFilePath(noteID string) string { + return filepath.Join(NotesDir, noteID+NoteExtension) +} + +func getAllNotes() ([]*Note, error) { + if err := ensureNotesDir(); err != nil { + return nil, err + } + + files, err := filepath.Glob(filepath.Join(NotesDir, "*"+NoteExtension)) + if err != nil { + return nil, err + } + + var notes []*Note + for _, file := range files { + note, err := parseNote(file) + if err != nil { + fmt.Printf("Warning: failed to parse note %s: %v\n", file, err) + continue + } + notes = append(notes, note) + } + + return notes, nil +} + +func findNoteByID(noteID string) (*Note, error) { + notePath := generateNoteFilePath(noteID) + if _, err := os.Stat(notePath); os.IsNotExist(err) { + return nil, fmt.Errorf("note with ID '%s' not found", noteID) + } + return parseNote(notePath) +} + +func searchNotes(query string) ([]*Note, error) { + notes, err := getAllNotes() + if err != nil { + return nil, err + } + + var matches []*Note + queryLower := strings.ToLower(query) + + for _, note := range notes { + // Search in title, content, and tags + if strings.Contains(strings.ToLower(note.Metadata.Title), queryLower) || + strings.Contains(strings.ToLower(note.Content), queryLower) { + matches = append(matches, note) + continue + } + + // Search in tags + for _, tag := range note.Metadata.Tags { + if strings.Contains(strings.ToLower(tag), queryLower) { + matches = append(matches, note) + break + } + } + } + + return matches, nil +} + +func filterNotesByTag(tag string) ([]*Note, error) { + notes, err := getAllNotes() + if err != nil { + return nil, err + } + + var matches []*Note + tagLower := strings.ToLower(tag) + + for _, note := range notes { + for _, noteTag := range note.Metadata.Tags { + if strings.ToLower(noteTag) == tagLower { + matches = append(matches, note) + break + } + } + } + + return matches, nil +} + +func openEditor(filePath string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "nano" // Default editor + } + + // For now, just return an error message + fmt.Printf("Please edit the file manually: %s\n", filePath) + fmt.Printf("You can set the EDITOR environment variable to use your preferred editor.\n") + return nil +} + +func promptForInput(prompt string) string { + fmt.Print(prompt) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + return scanner.Text() +} \ No newline at end of file