Compare commits

...

9 Commits

Author SHA1 Message Date
bfb1bb3433 chore: update version to v2.0.0 in main.go
To make `go install` happy :-|
2025-11-26 16:39:10 +01:00
676ef83e50 chore: bump version to v2 in main.go 2025-11-26 16:24:55 +01:00
adbc2cf364 fix(parser): improve priority calculation accuracy using math.Round
Refactored PriorityAsRemind to use floating point division and rounding
for more precise priority mapping.
2025-11-26 16:24:28 +01:00
32cb827d26 docs: add README and LICENSE files 2025-11-25 17:35:19 +01:00
6022248a2f feat: add cobra CLI with input/output flags and debug option
- Add --input/-i and --output/-o flags for file selection
- Add --debug flag to print intermediate JSON to stderr
- Print errors during parsing
2025-11-25 17:28:49 +01:00
389c1c3d25 feat(parser): add ToRemind method to Task for Remind-compatible string output
Implements Task.ToRemind(), which generates a Remind-formatted string
including due date, metadata, projects, contexts, completion status, and
priority.
2025-11-25 17:15:28 +01:00
4b44c012e6 fix(parser): exclude @todo from contexts and support quoted location metadata
- Prevents '@todo' from being added to task contexts.
- Adds parsing for location metadata with quoted values, allowing
  spaces.
2025-11-25 17:14:48 +01:00
5ef3057d98 refactor(parser): simplify ParseLine logic for completed, priority, and date parsing
- Refactor ParseLine to use token-based parsing for completed status,
  priority, and dates.
- Improve handling of priority and creation date for both completed and
  incomplete tasks.
2025-11-25 00:41:36 +01:00
57ac41ff72 fix(parser): correct priority calculation for 'Z' to return 0
Previously, the priority calculation did not handle 'Z' correctly,
resulting in a non-zero value. Now, 'Z' returns 0 as intended, and the
step size is calculated for a linear scale.
2025-11-25 00:40:16 +01:00
6 changed files with 313 additions and 60 deletions

15
LICENSE.md Normal file
View File

@@ -0,0 +1,15 @@
ISC License
Copyright (c) 2025 Paolo Donadeo
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# todotxt2remind
`todotxt2remind` is a command-line tool that converts tasks from the [todo.txt](https://github.com/todotxt/todo.txt) format into [Remind](https://dianne.skoll.ca/projects/remind/) calendar entries. It helps you integrate your plain-text task list with Remind for calendar-based reminders.
## Features
- Reads tasks from a todo.txt file or standard input
- Outputs Remind-compatible entries to a file or standard output
- Supports projects, contexts, priorities, due dates, and custom metadata
- Optional debug output in JSON format
## Installation
You need [Go](https://golang.org/dl/) installed (version 1.18 or newer recommended).
Clone the repository and build:
```sh
git clone https://git.donadeo.net/pdonadeo/todotxt2remind.git
cd todotxt2remind
go build -o todotxt2remind
```
## Usage
Basic usage:
```sh
./todotxt2remind -i todo.txt -o remind.txt
```
Read from stdin and write to stdout:
```sh
cat todo.txt | ./todotxt2remind
```
Show debug output (parsed tasks as JSON):
```sh
./todotxt2remind -i todo.txt --debug
```
Show version:
```sh
./todotxt2remind --version
```
## Example
Given a `todo.txt` file:
```
(A) 2025-11-25 Call Mom +Family @phone due:2025-11-26
x 2025-11-24 2025-11-20 Submit report +Work due:2025-11-25
```
The output will be Remind entries for each task with a due date.
## Contributing
Contributions are welcome! Please open issues or submit pull requests.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/my-feature`)
3. Commit your changes (`git commit -am 'Add new feature'`)
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a pull request
For questions or suggestions, feel free to open an issue.
## License
See `LICENSE` file for details.

9
go.mod
View File

@@ -1,3 +1,10 @@
module git.donadeo.net/pdonadeo/todotxt2remind
module git.donadeo.net/pdonadeo/todotxt2remind/v2
go 1.24.1
require github.com/spf13/cobra v1.10.1
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)

10
go.sum Normal file
View File

@@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"math"
"regexp"
"strings"
"time"
@@ -25,6 +26,75 @@ type Task struct {
Metadata map[string]string `json:"metadata"`
}
func (t Task) ToRemind() string {
var sb strings.Builder
sb.WriteString("REM TODO ")
if t.DueDate == nil {
return ""
}
sb.WriteString(t.DueDate.Format("2006-01-02"))
sb.WriteString(" ++5 INFO \"Calendar: TODO.TX\" ")
if len(t.Metadata) > 0 {
for k, v := range t.Metadata {
if k == "due" {
continue
}
// k must have the first letter uppercase for Remind
k = strings.ToUpper(k[:1]) + k[1:]
sb.WriteString(fmt.Sprintf("INFO \"%s: %s\" ", k, v))
}
}
if len(t.Projects) > 0 {
for _, p := range t.Projects {
sb.WriteString(fmt.Sprintf("INFO \"List: %s\" ", p))
}
}
if len(t.Contexts) > 0 {
for _, c := range t.Contexts {
sb.WriteString(fmt.Sprintf("INFO \"Tag: %s\" ", c))
}
}
if t.Completed {
sb.WriteString(fmt.Sprintf("COMPLETE-THROUGH %s ", t.DueDate.Format("2006-01-02")))
}
if t.Priority != nil {
sb.WriteString(fmt.Sprintf("PRIORITY %d ", t.PriorityAsRemind()))
}
sb.WriteString("MSG")
if len(t.Projects) > 0 {
sb.WriteString(" %<List>:")
}
sb.WriteString(fmt.Sprintf(" %s", t.Description))
if t.Completed {
sb.WriteString("%:")
} else {
sb.WriteString(" (%b)")
}
if len(t.Metadata) > 0 {
for k := range t.Metadata {
if k == "due" {
continue
}
// uppercase first letter for Remind
k = strings.ToUpper(k[:1]) + k[1:]
sb.WriteString(fmt.Sprintf("%%_%s: %%<%s>", k, k))
}
}
return sb.String()
}
// PriorityAsRemind returns an integer representing the priority for sorting.
// A = 9999 (highest), Z = 0 (lowest), absent = 5000.
func (t Task) PriorityAsRemind() int {
@@ -35,8 +105,10 @@ func (t Task) PriorityAsRemind() int {
if p < 'A' || p > 'Z' {
return 5000
}
// A=9999, Z=0, linear scale
return 9999 - int(p-'A')*400
pInt := int(p) - 65 // 'A' = 65
value := 9999 - (float64(pInt) * (float64(9999) / float64(25)))
value = math.Round(value)
return int(value)
}
func (t Task) MarshalJSON() ([]byte, error) {
@@ -60,11 +132,9 @@ func (t Task) MarshalJSON() ([]byte, error) {
}
var (
dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
priorityHead = regexp.MustCompile(`^\([A-Z]\)\s+`)
completedHead = regexp.MustCompile(`^x\s+`)
spaceRe = regexp.MustCompile(`\s+`)
dateLayout = "2006-01-02"
dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
spaceRe = regexp.MustCompile(`\s+`)
dateLayout = "2006-01-02"
// Protocols to exclude as metadata keys
protocols = map[string]struct{}{
"http": {},
@@ -115,54 +185,84 @@ func ParseLine(line string) (Task, error) {
}
working := strings.TrimSpace(line)
toks := splitTokens(working)
i := 0
// 1) Completed?
if completedHead.MatchString(working) {
if i < len(toks) && toks[i] == "x" {
t.Completed = true
working = completedHead.ReplaceAllString(working, "")
toks := splitTokens(working)
consumed := 0
if len(toks) > 0 && dateRe.MatchString(toks[0]) {
if dt, err := time.Parse(dateLayout, toks[0]); err == nil {
i++
// 2) Completion date
if i < len(toks) && dateRe.MatchString(toks[i]) {
if dt, err := time.Parse(dateLayout, toks[i]); err == nil {
t.CompletionDate = &dt
consumed = 1
i++
}
}
if consumed < len(toks) && dateRe.MatchString(toks[consumed]) {
if dt, err := time.Parse(dateLayout, toks[consumed]); err == nil {
// 3) Creation date
if i < len(toks) && dateRe.MatchString(toks[i]) {
if dt, err := time.Parse(dateLayout, toks[i]); err == nil {
t.CreationDate = &dt
consumed++
}
}
if consumed > 0 {
working = strings.TrimSpace(strings.Join(toks[consumed:], " "))
}
} else {
if priorityHead.MatchString(working) {
if len(working) >= 3 {
r := rune(working[1])
t.Priority = &r
}
working = priorityHead.ReplaceAllString(working, "")
}
toks := splitTokens(working)
if len(toks) > 0 && dateRe.MatchString(toks[0]) {
if dt, err := time.Parse(dateLayout, toks[0]); err == nil {
t.CreationDate = &dt
working = strings.TrimSpace(strings.Join(toks[1:], " "))
i++
}
}
}
tokens := splitTokens(working)
descParts := make([]string, 0, len(tokens))
for _, tok := range tokens {
// 4) Priority (can appear after x, dates)
if i < len(toks) && len(toks[i]) == 3 && toks[i][0] == '(' && toks[i][2] == ')' && toks[i][1] >= 'A' && toks[i][1] <= 'Z' {
r := rune(toks[i][1])
t.Priority = &r
i++
}
// 5) If not completed, check for priority at start
if !t.Completed {
if i < len(toks) && len(toks[i]) == 3 && toks[i][0] == '(' && toks[i][2] == ')' && toks[i][1] >= 'A' && toks[i][1] <= 'Z' {
r := rune(toks[i][1])
t.Priority = &r
i++
}
// Creation date for incomplete tasks
if i < len(toks) && dateRe.MatchString(toks[i]) {
if dt, err := time.Parse(dateLayout, toks[i]); err == nil {
t.CreationDate = &dt
i++
}
}
}
// 6) Parse remaining tokens
descParts := make([]string, 0)
for ; i < len(toks); i++ {
tok := toks[i]
if strings.HasPrefix(tok, "+") && len(tok) > 1 {
t.Projects = append(t.Projects, tok[1:])
continue
}
if strings.HasPrefix(tok, "@") && len(tok) > 1 {
t.Contexts = append(t.Contexts, tok[1:])
if tok != "@todo" { // exclude @todo context
t.Contexts = append(t.Contexts, tok[1:])
}
continue
}
// Special case: location:"value with spaces"
if strings.HasPrefix(tok, "location:\"") {
val := tok[len("location:\""):]
for {
// If token ends with a quote, we've reached the end
if strings.HasSuffix(val, "\"") {
val = val[:len(val)-1]
break
}
// Otherwise, append next token
i++
if i >= len(toks) {
// Unterminated quote, treat as is
break
}
val += " " + toks[i]
}
t.Metadata["location"] = val
continue
}
if !strings.ContainsAny(tok, " \t") && strings.Contains(tok, ":") {
@@ -170,7 +270,6 @@ func ParseLine(line string) (Task, error) {
k, v := parts[0], parts[1]
if k != "" && v != "" && !isProtocolKey(k) {
t.Metadata[k] = v
// If the field is due:YYYY-MM-DD, also parse it as DueDate
if k == "due" && dateRe.MatchString(v) {
if dt, err := time.Parse(dateLayout, v); err == nil {
t.DueDate = &dt
@@ -179,7 +278,6 @@ func ParseLine(line string) (Task, error) {
continue
}
}
// else part of description
descParts = append(descParts, tok)
}
t.Description = strings.TrimSpace(strings.Join(descParts, " "))

84
main.go
View File

@@ -3,29 +3,77 @@ package main
import (
"encoding/json"
"fmt"
"io"
"os"
"git.donadeo.net/pdonadeo/todotxt2remind/internal/parser"
"git.donadeo.net/pdonadeo/todotxt2remind/v2/internal/parser"
"github.com/spf13/cobra"
)
var (
inputFile string
outputFile string
debug bool
version = "v2.0.0"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "usage: example /path/to/todo.txt\n")
os.Exit(2)
}
f, err := os.Open(os.Args[1])
if err != nil {
panic(err)
}
defer func() { _ = f.Close() }()
rootCmd := &cobra.Command{
Use: "todotxt2remind",
Short: "Convert a todo.txt file to Remind format",
Long: "Reads tasks from todo.txt format and outputs Remind-compatible entries.",
Run: func(cmd *cobra.Command, args []string) {
var in io.Reader = os.Stdin
if inputFile != "" {
f, err := os.Open(inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening input file: %v\n", err)
os.Exit(2)
}
defer func() { _ = f.Close() }()
in = f
}
tasks, errors := parser.ParseReader(f)
if len(errors) > 0 {
fmt.Fprintf(os.Stderr, "errors while parsing:\n")
for _, e := range errors {
fmt.Fprintf(os.Stderr, " - %v\n", e)
}
tasks, errors := parser.ParseReader(in)
if len(errors) > 0 {
fmt.Fprintf(os.Stderr, "errors while parsing:\n")
for _, e := range errors {
fmt.Fprintf(os.Stderr, " - %v\n", e)
}
}
if debug {
b, _ := json.MarshalIndent(tasks, "", " ")
fmt.Fprintf(os.Stderr, "%s\n", string(b))
}
var out io.Writer = os.Stdout
if outputFile != "" {
f, err := os.Create(outputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening output file: %v\n", err)
os.Exit(2)
}
defer func() { _ = f.Close() }()
out = f
}
for _, t := range tasks {
rem := t.ToRemind()
if rem == "" {
continue
}
_, _ = fmt.Fprintf(out, "%s\n", rem)
}
},
}
b, _ := json.MarshalIndent(tasks, "", " ")
fmt.Println(string(b))
rootCmd.Flags().StringVarP(&inputFile, "input", "i", "", "Input file (default: stdin)")
rootCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file (default: stdout)")
rootCmd.Flags().BoolVar(&debug, "debug", false, "Print intermediate JSON to stderr")
rootCmd.Version = version
rootCmd.Flags().BoolP("version", "v", false, "Show version and exit")
rootCmd.SetVersionTemplate(fmt.Sprintf("todotxt2remind version %s\n", version))
_ = rootCmd.Execute()
}