Compare commits
9 Commits
20e327d9dd
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bfb1bb3433 | |||
| 676ef83e50 | |||
| adbc2cf364 | |||
| 32cb827d26 | |||
| 6022248a2f | |||
| 389c1c3d25 | |||
| 4b44c012e6 | |||
| 5ef3057d98 | |||
| 57ac41ff72 |
15
LICENSE.md
Normal file
15
LICENSE.md
Normal 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
75
README.md
Normal 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
9
go.mod
@@ -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
10
go.sum
Normal 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=
|
||||
@@ -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
84
main.go
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user