diff --git a/AGENTS.md b/AGENTS.md index 4b3c2e4e2df38f279ad15d7780a00542f8b741e1..67be37243c634d11b4fbb47b382722958b2a41db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,7 @@ case and return typed values or wrapped errors (`ErrInvalidStatus`, etc.). ### Date parsing -`dateutil.Parse()` accepts natural language ("yesterday", "2 days ago", "March 5") +`dateutil.Parse()` uses PHP strtotime syntax ("yesterday", "-2 days", "next Monday") and ISO dates ("2024-01-15"). Empty input returns today's date. Returns `lunatask.Date` type. diff --git a/README.md b/README.md index 35f224150a83fff8b8bee6ef1dbb60f8de876c4c..a62f1378d4f67b405a3a8d550ff0996416048341 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ lune habit track meditation # Track a habit notebooks, habits, and your access token - **Secure storage** — access token lives in the system keyring, not a file -- **Natural language dates** — "yesterday", "2 days ago", "March 5" all +- **Natural language dates** — "yesterday", "-2 days", "next Monday" all work - **Deep link support** — paste `lunatask://` URLs or plain IDs - **Stdin-friendly** — use `-` to pipe content into tasks, notes, and diff --git a/cmd/habit/track.go b/cmd/habit/track.go index c63e192f600b42946805a5ebce737f8f9ce65114..150d7e83775b52fc55b9626b08b8b26867876e4b 100644 --- a/cmd/habit/track.go +++ b/cmd/habit/track.go @@ -28,7 +28,7 @@ var TrackCmd = &cobra.Command{ KEY is the habit key from your config (not the raw Lunatask ID). Tracks for today by default. Use --date to specify another date -using natural language (e.g., "yesterday", "2 days ago", "March 5").`, +using strtotime syntax (e.g., "yesterday", "-2 days", "next Monday").`, Args: cobra.ExactArgs(1), ValidArgsFunction: completion.Habits, RunE: runTrack, diff --git a/go.mod b/go.mod index ccb94cac308b1708d901be9f2aacc8d1b5e88d03..668b2cf1f0f3cf2bb75f8fb04e13ea2578b1b3c2 100644 --- a/go.mod +++ b/go.mod @@ -9,16 +9,17 @@ go 1.25.5 require ( git.secluded.site/go-lunatask v0.1.0-rc9.2 github.com/BurntSushi/toml v1.6.0 + github.com/KarpelesLab/strtotime v0.0.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 github.com/charmbracelet/lipgloss v1.1.0 github.com/klauspost/lctime v0.1.0 - github.com/markusmobius/go-dateparser v1.2.4 github.com/mattn/go-isatty v0.0.20 github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.6 + golang.org/x/crypto v0.46.0 ) require ( @@ -45,14 +46,10 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/google/jsonschema-go v0.3.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hablullah/go-hijri v1.0.2 // indirect - github.com/hablullah/go-juliandays v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/magefile/mage v1.14.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect @@ -65,11 +62,8 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/tetratelabs/wazero v1.2.1 // indirect - github.com/wasilibs/go-re2 v1.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect diff --git a/go.sum b/go.sum index 932e1a5b19e864ea35889d6e07dd5fdda989e560..aea474a4808dce67314d9464c00dca8e338830bd 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ git.secluded.site/go-lunatask v0.1.0-rc9.2 h1:fk5fCGdHmKpwz5HPy/n/LURBNweJoRN2xS git.secluded.site/go-lunatask v0.1.0-rc9.2/go.mod h1:rxps7BBqF+BkY8VN5E7J9zSOzSbtZ1hDmLEOHxjTHZQ= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/KarpelesLab/strtotime v0.0.1 h1:Af8vDb5RzwHhLP7ctSs7Y4CeiE+qlMXWWMqNd8xGOWY= +github.com/KarpelesLab/strtotime v0.0.1/go.mod h1:egoQ8YSX0zVanpJAyWozDjVjlsnYRIDlRdDDiZZ131s= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -79,26 +81,18 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k= -github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ= -github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0= -github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE= -github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE= github.com/klauspost/lctime v0.1.0 h1:nINsuFc860M9cyYhT6vfg6U1USh7kiVBj/s/2b04U70= github.com/klauspost/lctime v0.1.0/go.mod h1:OwdMhr8tbQvusAsnilqkkgDQqivWlqyg0w5cfXkLiDk= 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/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= -github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= -github.com/markusmobius/go-dateparser v1.2.4 h1:2e8XJozaERVxGwsRg72coi51L2aiYqE2gukkdLc85ck= -github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFhtKaqnUcznY2cL7gNugY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -137,12 +131,6 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tetratelabs/wazero v1.2.1 h1:J4X2hrGzJvt+wqltuvcSjHQ7ujQxA9gb6PeMs4qlUWs= -github.com/tetratelabs/wazero v1.2.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ= -github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw= -github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg= -github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ= -github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/internal/dateutil/dateutil.go b/internal/dateutil/dateutil.go index da6aeaefeaf74c1de3a7a7e3bc8d148aa0e0b9b4..61d006943c0fe65e1410a54b5623d694c8208f4e 100644 --- a/internal/dateutil/dateutil.go +++ b/internal/dateutil/dateutil.go @@ -7,23 +7,49 @@ package dateutil import ( "fmt" + "time" "git.secluded.site/go-lunatask" - dps "github.com/markusmobius/go-dateparser" + "github.com/KarpelesLab/strtotime" ) // Parse parses a natural language date string into a lunatask.Date. -// Supports formats like "yesterday", "2 days ago", "March 5", "2024-01-15", etc. +// Uses PHP strtotime syntax. +// +// Supported formats include: +// - Relative words: "today", "tomorrow", "yesterday" +// - Relative weekdays: "next Monday", "last Friday" +// - Relative periods: "next week", "last month", "next year" +// - Relative offsets: "+3 days", "-1 week", "3 days" (positive assumed) +// - Compound expressions: "next Friday +2 weeks" +// - Named dates: "March 5", "January 15 2024" +// - ISO format: "2024-01-15" +// // Returns today's date if the input is empty. func Parse(input string) (lunatask.Date, error) { if input == "" { return lunatask.Today(), nil } - parsed, err := dps.Parse(nil, input) + t, err := strtotime.StrToTime(input) + if err != nil { + return lunatask.Date{}, fmt.Errorf("parsing date %q: %w", input, err) + } + + return lunatask.NewDate(t), nil +} + +// ParseInTZ parses a natural language date string with timezone awareness. +// The timezone affects interpretation of relative dates. +func ParseInTZ(input string, tz *time.Location) (lunatask.Date, error) { + if input == "" { + return lunatask.Today(), nil + } + + t, err := strtotime.StrToTime(input, strtotime.InTZ(tz)) if err != nil { return lunatask.Date{}, fmt.Errorf("parsing date %q: %w", input, err) } - return lunatask.NewDate(parsed.Time), nil + return lunatask.NewDate(t), nil } diff --git a/internal/mcp/tools/timestamp/handler.go b/internal/mcp/tools/timestamp/handler.go index 2102a1df3ce056f88c94f760694775a6967adeda..600787ce5facc37719071b9690074075750e2994 100644 --- a/internal/mcp/tools/timestamp/handler.go +++ b/internal/mcp/tools/timestamp/handler.go @@ -19,12 +19,16 @@ const ToolName = "get_timestamp" // ToolDescription describes the tool for LLMs. const ToolDescription = `Parses natural language date/time expressions into RFC3339 timestamps. +Uses PHP strtotime syntax. Accepts expressions like: - "today", "tomorrow", "yesterday" - "next Monday", "last Friday" -- "2 days ago", "in 3 weeks" -- "March 5", "2024-01-15" +- "next week", "last month", "next year" +- "+3 days", "-1 week", "3 days" (use +/- prefix, not "ago" or "in") +- Compound: "next Friday +2 weeks" +- "March 5", "January 15 2024" +- "2024-01-15" - "" (empty string returns today) Returns the timestamp in RFC3339 format (e.g., "2024-01-15T00:00:00Z"). @@ -62,7 +66,7 @@ func (h *Handler) Handle( _ *mcp.CallToolRequest, input Input, ) (*mcp.CallToolResult, Output, error) { - parsed, err := dateutil.Parse(input.Date) + parsed, err := dateutil.ParseInTZ(input.Date, h.timezone) if err != nil { return &mcp.CallToolResult{ IsError: true,