diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f23198..9bc78dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ Windows PowerShell: - If the directory or file does not exist, it is created automatically. - Entries are appended as `[YYYY-MM-DD HH:MM] `. - `jot` and `jot init` behave the same. -- `jot list` prints the entire file to stdout. +- `jot list` prints the journal and any template notes in the current directory. - `jot patterns` prints a fixed line. ## Design constraints (do not violate) @@ -64,7 +64,7 @@ Windows PowerShell: - `ensureJournal()` handles path resolution and lazy creation. - `jotInit()` reads a single line and appends it with a timestamp. -- `jotList()` streams the journal to stdout. +- `jotList()` streams the journal and template notes to stdout. ## Tests diff --git a/README.md b/README.md index 8200f3f..60114b2 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ You’ll see a simple timeline: This is not a feed. It’s a mirror. +Template notes created in the current directory (like meeting, standup, or RFC notes) are included in the list output too. + --- ## patterns @@ -200,6 +202,53 @@ If it feels quiet, you’re close. --- +## templates + +Create structured notes quickly with templates. + +```bash +jot new --template daily +``` + +Add a name to create multiple notes from the same template in a day: + +```bash +jot new --template meeting -n "Team Sync-Up" +``` + +Built-in templates: + +```bash +jot templates +# or +jot list templates +``` + +Templates render a few variables: + +* `{{date}}` → `YYYY-MM-DD` +* `{{time}}` → `HH:MM` +* `{{datetime}}` → `YYYY-MM-DD HH:MM` +* `{{repo}}` → current git repo name (empty if not in a repo) + +### custom templates + +Create a file in your config templates directory and use its filename (without extension) as the template name. + +``` +~/.config/jot/templates/standup.md +``` + +On Windows, this lives under `%AppData%\\jot\\templates`. If the config dir is not available, jot falls back to `~/.jot/templates`. + +Then run: + +```bash +jot new --template standup +``` + +--- + ## data & privacy Your thoughts are yours. diff --git a/main.go b/main.go index 31fd288..6e3a7e3 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" "time" "unicode" @@ -29,7 +30,26 @@ func main() { return } - if len(args) == 1 && args[0] == "list" { + if len(args) >= 1 && args[0] == "new" { + if err := jotNew(os.Stdout, time.Now, args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + + if len(args) >= 1 && args[0] == "list" { + if len(args) == 2 && (args[1] == "templates" || args[1] == "--templates" || args[1] == "-t") { + if err := jotTemplates(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: jot list [templates]") + os.Exit(1) + } if err := jotList(os.Stdout); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -42,6 +62,14 @@ func main() { return } + if len(args) == 1 && args[0] == "templates" { + if err := jotTemplates(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + if len(args) >= 1 && args[0] == "capture" { if err := jotCapture(os.Stdout, args[1:], time.Now, launchEditor); err != nil { fmt.Fprintln(os.Stderr, err) @@ -50,7 +78,7 @@ func main() { return } - fmt.Fprintln(os.Stderr, "usage: jot [init|list|patterns|capture]") + fmt.Fprintln(os.Stderr, "usage: jot [init|capture|list|new|patterns|templates]") os.Exit(1) } @@ -100,8 +128,12 @@ func jotList(w io.Writer) error { } defer file.Close() - if !isTTY(w) { - _, err = io.Copy(w, file) + items, err := collectJournalEntries(file) + if err != nil { + return err + } + noteItems, err := collectTemplateNotes(mustGetwd()) + if err != nil { return err } @@ -121,6 +153,11 @@ func jotList(w io.Writer) error { } } +func writeListItemsTTY(w io.Writer, items []listItem) error { + var lines []string + for _, item := range items { + lines = append(lines, item.lines...) + } lastIdx := len(lines) - 1 for lastIdx >= 0 && strings.TrimSpace(lines[lastIdx]) == "" { lastIdx-- @@ -157,6 +194,119 @@ func jotList(w io.Writer) error { return nil } +func parseTimestamp(line string) time.Time { + if !strings.HasPrefix(line, "[") { + return time.Time{} + } + end := strings.IndexByte(line, ']') + if end <= 1 { + return time.Time{} + } + ts := strings.TrimSpace(line[1:end]) + parsed, err := time.Parse("2006-01-02 15:04", ts) + if err != nil { + return time.Time{} + } + return parsed +} + +func isTemplateNoteName(name string) bool { + if !strings.HasSuffix(strings.ToLower(name), ".md") { + return false + } + if len(name) < len("2006-01-02-.md") { + return false + } + if name[4] != '-' || name[7] != '-' { + return false + } + datePart := name[:10] + if _, err := time.Parse("2006-01-02", datePart); err != nil { + return false + } + return true +} + +func jotNew(w io.Writer, now func() time.Time, args []string) error { + set := flag.NewFlagSet("new", flag.ContinueOnError) + set.SetOutput(io.Discard) + var templateName string + var noteName string + set.StringVar(&templateName, "template", "daily", "template to use") + set.StringVar(¬eName, "name", "", "note name") + set.StringVar(¬eName, "n", "", "note name") + if err := set.Parse(args); err != nil { + return err + } + if set.NArg() != 0 { + return fmt.Errorf("unexpected arguments: %v", set.Args()) + } + + templates, err := loadTemplates() + if err != nil { + return err + } + content, ok := templates[templateName] + if !ok { + return fmt.Errorf("template %q not found", templateName) + } + + currentTime := now() + repo := repoName() + rendered := renderTemplate(content, currentTime, repo) + if !strings.HasSuffix(rendered, "\n") { + rendered += "\n" + } + + filename := templateName + if noteName != "" { + slug := slugifyName(noteName) + if slug == "" { + return fmt.Errorf("note name must contain letters or numbers") + } + filename = fmt.Sprintf("%s-%s", templateName, slug) + } + filename = fmt.Sprintf("%s-%s.md", currentTime.Format("2006-01-02"), filename) + path := filepath.Join(mustGetwd(), filename) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("note already exists: %s", path) + } + return err + } + if _, err := io.WriteString(file, rendered); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + + _, err = fmt.Fprintln(w, path) + return err +} + +func jotTemplates(w io.Writer) error { + templates, err := loadTemplates() + if err != nil { + return err + } + + var names []string + for name := range templates { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + if _, err := fmt.Fprintln(w, name); err != nil { + return err + } + } + return nil +} + func ensureJournal() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -187,6 +337,165 @@ func journalPaths(home string) (string, string) { return journalDir, journalPath } +func templateDir() (string, error) { + configDir, err := os.UserConfigDir() + if err == nil && configDir != "" { + return filepath.Join(configDir, "jot", "templates"), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".jot", "templates"), nil +} + +func loadTemplates() (map[string]string, error) { + templates := builtinTemplates() + custom, err := loadCustomTemplates() + if err != nil { + return nil, err + } + for name, content := range custom { + templates[name] = content + } + return templates, nil +} + +func builtinTemplates() map[string]string { + return map[string]string{ + "daily": strings.Join([]string{ + "# Daily Log — {{date}}", + "", + "## Focus", + "- ", + "", + "## Notes", + "- ", + "", + "## Closing", + "- What moved?", + }, "\n"), + "meeting": strings.Join([]string{ + "# Meeting — {{date}} {{time}}", + "", + "## Attendees", + "- ", + "", + "## Agenda", + "- ", + "", + "## Notes", + "- ", + "", + "## Next Steps", + "- ", + }, "\n"), + "rfc": strings.Join([]string{ + "# RFC — {{repo}} — {{date}}", + "", + "## Problem", + "- ", + "", + "## Proposal", + "- ", + "", + "## Alternatives", + "- ", + "", + "## Risks", + "- ", + }, "\n"), + } +} + +func loadCustomTemplates() (map[string]string, error) { + dir, err := templateDir() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, err + } + + custom := make(map[string]string) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + if strings.TrimSpace(name) == "" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, err + } + custom[name] = string(data) + } + return custom, nil +} + +func renderTemplate(content string, now time.Time, repo string) string { + replacements := strings.NewReplacer( + "{{date}}", now.Format("2006-01-02"), + "{{time}}", now.Format("15:04"), + "{{datetime}}", now.Format("2006-01-02 15:04"), + "{{repo}}", repo, + ) + return replacements.Replace(content) +} + +func slugifyName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + var builder strings.Builder + for _, r := range name { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + builder.WriteRune(r) + case r == '-', r == '_': + builder.WriteRune(r) + default: + builder.WriteRune(' ') + } + } + parts := strings.Fields(builder.String()) + if len(parts) == 0 { + return "" + } + return strings.ToLower(strings.Join(parts, "-")) +} + +func repoName() string { + wd := mustGetwd() + for { + if info, err := os.Stat(filepath.Join(wd, ".git")); err == nil && info != nil { + return filepath.Base(wd) + } + parent := filepath.Dir(wd) + if parent == wd { + break + } + wd = parent + } + return "" +} + +func mustGetwd() string { + wd, err := os.Getwd() + if err != nil { + return "." + } + return wd +} + func isTTY(w io.Writer) bool { file, ok := w.(*os.File) if !ok { diff --git a/main_test.go b/main_test.go index d658f24..a13f634 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "os" + "path/filepath" "reflect" "runtime" "strings" @@ -77,6 +78,19 @@ func TestEnsureJournalCreatesDirAndFile(t *testing.T) { func TestJotListStreamsFile(t *testing.T) { home := withTempHome(t) + workdir := t.TempDir() + previousDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd failed: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previousDir); err != nil { + t.Fatalf("restore cwd failed: %v", err) + } + }) + if err := os.Chdir(workdir); err != nil { + t.Fatalf("chdir failed: %v", err) + } journalDir, journalPath := journalPaths(home) if err := os.MkdirAll(journalDir, 0o700); err != nil { @@ -125,6 +139,120 @@ func TestJotInitAppendsWithTimestamp(t *testing.T) { } } +func TestLoadTemplatesIncludesCustom(t *testing.T) { + home := withTempHome(t) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + customDir, err := templateDir() + if err != nil { + t.Fatalf("templateDir returned error: %v", err) + } + if err := os.MkdirAll(customDir, 0o700); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(customDir, "daily.md"), []byte("custom"), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + templates, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates returned error: %v", err) + } + if templates["daily"] != "custom" { + t.Fatalf("expected custom template override, got %q", templates["daily"]) + } +} + +func TestRenderTemplate(t *testing.T) { + fixed := time.Date(2024, 2, 3, 4, 5, 0, 0, time.FixedZone("Z", 0)) + content := "{{date}} {{time}} {{datetime}} {{repo}}" + result := renderTemplate(content, fixed, "jot") + if result != "2024-02-03 04:05 2024-02-03 04:05 jot" { + t.Fatalf("unexpected render result: %q", result) + } +} + +func TestJotNewDoesNotOverwriteExistingNote(t *testing.T) { + workdir := t.TempDir() + previousDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd failed: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previousDir); err != nil { + t.Fatalf("restore cwd failed: %v", err) + } + }) + if err := os.Chdir(workdir); err != nil { + t.Fatalf("chdir failed: %v", err) + } + + fixedNow := func() time.Time { + return time.Date(2024, 2, 3, 4, 5, 0, 0, time.FixedZone("Z", 0)) + } + filename := filepath.Join(workdir, "2024-02-03-daily.md") + if err := os.WriteFile(filename, []byte("existing"), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + var out bytes.Buffer + err = jotNew(&out, fixedNow, []string{"--template", "daily"}) + if err == nil { + t.Fatalf("expected error when note exists") + } + if !strings.Contains(err.Error(), "note already exists") { + t.Fatalf("expected already exists error, got %v", err) + } + content, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("read failed: %v", err) + } + if string(content) != "existing" { + t.Fatalf("expected existing note to remain unchanged, got %q", string(content)) + } +} + +func TestJotNewWithNameCreatesNamedNote(t *testing.T) { + workdir := t.TempDir() + previousDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd failed: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previousDir); err != nil { + t.Fatalf("restore cwd failed: %v", err) + } + }) + if err := os.Chdir(workdir); err != nil { + t.Fatalf("chdir failed: %v", err) + } + + fixedNow := func() time.Time { + return time.Date(2024, 2, 3, 4, 5, 0, 0, time.FixedZone("Z", 0)) + } + + var out bytes.Buffer + if err := jotNew(&out, fixedNow, []string{"--template", "meeting", "-n", "Team Sync-Up"}); err != nil { + t.Fatalf("jotNew returned error: %v", err) + } + + expected := filepath.Join(workdir, "2024-02-03-meeting-team-sync-up.md") + if strings.TrimSpace(out.String()) != expected { + t.Fatalf("expected output %q, got %q", expected, strings.TrimSpace(out.String())) + } + if _, err := os.Stat(expected); err != nil { + t.Fatalf("expected file to exist: %v", err) + } +} + +func TestSlugifyName(t *testing.T) { + if slug := slugifyName(" Team Sync-Up "); slug != "team-sync-up" { + t.Fatalf("unexpected slug: %q", slug) + } + if slug := slugifyName("###"); slug != "" { + t.Fatalf("expected empty slug, got %q", slug) + } +} + func TestParseCaptureArgsWithContent(t *testing.T) { options, err := parseCaptureArgs([]string{"hello", "world", "--title", "greeting", "--tag", "foo", "--tag", "bar", "--project", "alpha", "--repo", "jot"}) if err != nil { diff --git a/packaging/chocolatey/README.md b/packaging/chocolatey/README.md index d09763f..e8ea2b2 100644 --- a/packaging/chocolatey/README.md +++ b/packaging/chocolatey/README.md @@ -126,6 +126,8 @@ You’ll see a simple timeline: This is not a feed. It’s a mirror. +Template notes created in the current directory (like meeting, standup, or RFC notes) are included in the list output too. + --- ## patterns diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md index d09763f..e8ea2b2 100644 --- a/packaging/homebrew/README.md +++ b/packaging/homebrew/README.md @@ -126,6 +126,8 @@ You’ll see a simple timeline: This is not a feed. It’s a mirror. +Template notes created in the current directory (like meeting, standup, or RFC notes) are included in the list output too. + --- ## patterns diff --git a/packaging/npm/README.md b/packaging/npm/README.md index d09763f..e8ea2b2 100644 --- a/packaging/npm/README.md +++ b/packaging/npm/README.md @@ -126,6 +126,8 @@ You’ll see a simple timeline: This is not a feed. It’s a mirror. +Template notes created in the current directory (like meeting, standup, or RFC notes) are included in the list output too. + --- ## patterns