7 Commits
v3 ... v5

Author SHA1 Message Date
f8bc389ae5 ci: derive binary version from git describe instead of a hardcoded string
All checks were successful
Release Binaries / build (amd64, golang:1.24-alpine, linux/amd64, linux-amd64, apk add --no-cache upx) (release) Successful in 13s
Release Binaries / build (arm64, golang:1.24-alpine, linux/arm64, linux-arm64, apk add --no-cache upx zstd-static) (release) Successful in 17s
main.go's version var now defaults to "dev" for local builds and is
overridden at build time via -ldflags "-X main.version=...". The
release workflow computes VERSION with `git describe --tags --always
--dirty` (fetch-depth: 0 so tags are available) and injects it into
the Go build inside the container.
2026-07-10 00:14:06 +02:00
72efcb13ba refactor(parser): remove unreachable duplicate priority check in step 5
All checks were successful
Release Binaries / build (amd64, golang:1.24-alpine, linux/amd64, linux-amd64, apk add --no-cache upx) (release) Successful in 13s
Release Binaries / build (arm64, golang:1.24-alpine, linux/arm64, linux-arm64, apk add --no-cache upx zstd-static) (release) Successful in 16s
Step 4 already consumes the (X) priority token before step 5 runs, so
the identical check inside the `!t.Completed` block could never match.
No behavior change (verified against test.todo.txt).
2026-07-09 23:57:32 +02:00
096216579d fix(parser): warn instead of silently dropping tasks with invalid due dates
A malformed due: value (e.g. due:2024-13-99) left DueDate nil, causing
ToRemind to silently emit nothing for that task. Now ParseLine reports
an error so the problem surfaces on stderr instead of vanishing.

Also fixes staticcheck QF1012 (WriteString(Sprintf(...)) -> Fprintf)
flagged by the IDE in ToRemind.
2026-07-09 23:55:18 +02:00
d692fa5a4c ci: add Gitea release workflow for AMD64/ARM64 binaries
All checks were successful
Release Binaries / build (amd64, golang:1.24-alpine, linux/amd64, linux-amd64, apk add --no-cache upx) (release) Successful in 35s
Release Binaries / build (arm64, golang:1.24-alpine, linux/arm64, linux-arm64, apk add --no-cache upx zstd-static) (release) Successful in 44s
Builds the Go binary on dedicated amd64/arm64 runners via Docker and
uploads both artifacts to the Gitea release.
2026-07-09 23:33:46 +02:00
5e5999676d feat: add --indent flag to control line indentation in remind output
- Introduces an 'indent' integer flag to the CLI for specifying the
  number of spaces to indent lines in the remind output.
- Updates Task.ToRemind to accept an indent parameter and apply heading
  spaces accordingly.
- Adjusts main.go to pass the indent value from the flag to ToRemind.
2026-01-15 00:13:24 +01:00
1099f4ab97 Revert "fix(parser): exclude "url" metadata from Remind output (since version 06.02.02 of remind)"
This reverts commit ec9df87d68.
2026-01-14 23:30:45 +01:00
ec9df87d68 fix(parser): exclude "url" metadata from Remind output (since version 06.02.02 of remind)
Previously, only "due" metadata was excluded when generating Remind
output. This change also skips "url" metadata, preventing it from
appearing in the Remind string.

Version 06.02.02 of remind supports "url" metadata.
2026-01-11 21:54:39 +01:00
4 changed files with 104 additions and 21 deletions

View File

@@ -0,0 +1,74 @@
name: Release Binaries
on:
release:
types: [published]
jobs:
build:
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
image: golang:1.24-alpine
runner: linux-amd64
upx_install: "apk add --no-cache upx"
- arch: arm64
platform: linux/arm64
image: golang:1.24-alpine
runner: linux-arm64
upx_install: "apk add --no-cache upx zstd-static"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build (${{ matrix.arch }})
run: |
set -o pipefail
VERSION="$(git describe --tags --always --dirty)"
echo "Version: $VERSION"
# Crea il container senza avviarlo
CID=$(docker create \
--platform ${{ matrix.platform }} \
-w /src \
${{ matrix.image }} \
sleep infinity)
# Copia i sorgenti dentro con docker cp (funziona anche in DinD)
docker cp "${{ github.workspace }}/." "$CID:/src"
# Avvia ed esegui la build
docker start "$CID"
docker exec "$CID" sh -c "
${{ matrix.upx_install }}
export CGO_ENABLED=0
go build -trimpath -ldflags='-s -w -X main.version=$VERSION' -o /src/dist/todotxt2remind .
upx /src/dist/todotxt2remind
" 2>&1 | cat
# Copia il binario fuori
docker cp "$CID:/src/dist/todotxt2remind" \
"${{ github.workspace }}/todotxt2remind-linux-${{ matrix.arch }}"
docker rm -f "$CID"
- name: Carica artefatto sulla release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ github.event.release.id }}
run: |
FILENAME="todotxt2remind-linux-${{ matrix.arch }}"
curl -s -X POST \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${FILENAME}"

View File

@@ -26,7 +26,7 @@ type Task struct {
Metadata map[string][]string `json:"metadata"` Metadata map[string][]string `json:"metadata"`
} }
func (t Task) ToRemind() string { func (t Task) ToRemind(indent int) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString("REM TODO ") sb.WriteString("REM TODO ")
if t.DueDate == nil { if t.DueDate == nil {
@@ -44,10 +44,10 @@ func (t Task) ToRemind() string {
// k must have the first letter uppercase for Remind // k must have the first letter uppercase for Remind
kUpper := strings.ToUpper(k[:1]) + k[1:] kUpper := strings.ToUpper(k[:1]) + k[1:]
if len(values) == 1 { if len(values) == 1 {
sb.WriteString(fmt.Sprintf("INFO \"%s: %s\" ", kUpper, values[0])) fmt.Fprintf(&sb, "INFO \"%s: %s\" ", kUpper, values[0])
} else { } else {
for i, v := range values { for i, v := range values {
sb.WriteString(fmt.Sprintf("INFO \"%s%d: %s\" ", kUpper, i+1, v)) fmt.Fprintf(&sb, "INFO \"%s%d: %s\" ", kUpper, i+1, v)
} }
} }
} }
@@ -55,22 +55,22 @@ func (t Task) ToRemind() string {
if len(t.Projects) > 0 { if len(t.Projects) > 0 {
for _, p := range t.Projects { for _, p := range t.Projects {
sb.WriteString(fmt.Sprintf("INFO \"List: %s\" ", p)) fmt.Fprintf(&sb, "INFO \"List: %s\" ", p)
} }
} }
if len(t.Contexts) > 0 { if len(t.Contexts) > 0 {
for _, c := range t.Contexts { for _, c := range t.Contexts {
sb.WriteString(fmt.Sprintf("INFO \"Tag: %s\" ", c)) fmt.Fprintf(&sb, "INFO \"Tag: %s\" ", c)
} }
} }
if t.Completed { if t.Completed {
sb.WriteString(fmt.Sprintf("COMPLETE-THROUGH %s ", t.DueDate.Format("2006-01-02"))) fmt.Fprintf(&sb, "COMPLETE-THROUGH %s ", t.DueDate.Format("2006-01-02"))
} }
if t.Priority != nil { if t.Priority != nil {
sb.WriteString(fmt.Sprintf("PRIORITY %d ", t.PriorityAsRemind())) fmt.Fprintf(&sb, "PRIORITY %d ", t.PriorityAsRemind())
} }
sb.WriteString("MSG") sb.WriteString("MSG")
@@ -79,7 +79,7 @@ func (t Task) ToRemind() string {
sb.WriteString(" %<List>:") sb.WriteString(" %<List>:")
} }
sb.WriteString(fmt.Sprintf(" %s", t.Description)) fmt.Fprintf(&sb, " %s", t.Description)
if t.Completed { if t.Completed {
sb.WriteString("%:") sb.WriteString("%:")
@@ -87,6 +87,8 @@ func (t Task) ToRemind() string {
sb.WriteString(" (%b)") sb.WriteString(" (%b)")
} }
headingSpaces := strings.Repeat(" ", indent)
if len(t.Metadata) > 0 { if len(t.Metadata) > 0 {
for k, values := range t.Metadata { for k, values := range t.Metadata {
if k == "due" { if k == "due" {
@@ -95,11 +97,11 @@ func (t Task) ToRemind() string {
// uppercase first letter for Remind // uppercase first letter for Remind
kUpper := strings.ToUpper(k[:1]) + k[1:] kUpper := strings.ToUpper(k[:1]) + k[1:]
if len(values) == 1 { if len(values) == 1 {
sb.WriteString(fmt.Sprintf("%%_%s: %%<%s>", kUpper, kUpper)) fmt.Fprintf(&sb, "%%_%s%s: %%<%s>", headingSpaces, kUpper, kUpper)
} else { } else {
for i := range values { for i := range values {
kNumbered := fmt.Sprintf("%s%d", kUpper, i+1) kNumbered := fmt.Sprintf("%s%d", kUpper, i+1)
sb.WriteString(fmt.Sprintf("%%_%s: %%<%s>", kNumbered, kNumbered)) fmt.Fprintf(&sb, "%%_%s%s: %%<%s>", headingSpaces, kNumbered, kNumbered)
} }
} }
} }
@@ -200,6 +202,7 @@ func ParseLine(line string) (Task, error) {
working := strings.TrimSpace(line) working := strings.TrimSpace(line)
toks := splitTokens(working) toks := splitTokens(working)
i := 0 i := 0
var invalidDue string
// 1) Completed? // 1) Completed?
if i < len(toks) && toks[i] == "x" { if i < len(toks) && toks[i] == "x" {
@@ -228,14 +231,8 @@ func ParseLine(line string) (Task, error) {
i++ i++
} }
// 5) If not completed, check for priority at start // 5) Creation date for incomplete tasks (priority, if any, was already consumed in step 4)
if !t.Completed { 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 i < len(toks) && dateRe.MatchString(toks[i]) {
if dt, err := time.Parse(dateLayout, toks[i]); err == nil { if dt, err := time.Parse(dateLayout, toks[i]); err == nil {
t.CreationDate = &dt t.CreationDate = &dt
@@ -283,9 +280,12 @@ func ParseLine(line string) (Task, error) {
k, v := parts[0], parts[1] k, v := parts[0], parts[1]
if k != "" && v != "" && !isProtocolKey(k) { if k != "" && v != "" && !isProtocolKey(k) {
t.Metadata[k] = append(t.Metadata[k], v) t.Metadata[k] = append(t.Metadata[k], v)
if k == "due" && dateRe.MatchString(v) { if k == "due" {
if dt, err := time.Parse(dateLayout, v); err == nil { dt, err := time.Parse(dateLayout, v)
if dateRe.MatchString(v) && err == nil {
t.DueDate = &dt t.DueDate = &dt
} else {
invalidDue = v
} }
} }
continue continue
@@ -300,6 +300,10 @@ func ParseLine(line string) (Task, error) {
return t, errors.New("completed task missing completion date (spec requires completion date directly after 'x')") return t, errors.New("completed task missing completion date (spec requires completion date directly after 'x')")
} }
if invalidDue != "" {
return t, fmt.Errorf("invalid due date %q: expected format YYYY-MM-DD (task will be dropped, no Remind entry generated)", invalidDue)
}
return t, nil return t, nil
} }

View File

@@ -14,7 +14,8 @@ var (
inputFile string inputFile string
outputFile string outputFile string
debug bool debug bool
version = "v3.0.0" indent int
version = "dev" // overridden at build time via -ldflags "-X main.version=..."
) )
func main() { func main() {
@@ -59,7 +60,7 @@ func main() {
} }
for _, t := range tasks { for _, t := range tasks {
rem := t.ToRemind() rem := t.ToRemind(indent)
if rem == "" { if rem == "" {
continue continue
} }
@@ -71,6 +72,7 @@ func main() {
rootCmd.Flags().StringVarP(&inputFile, "input", "i", "", "Input file (default: stdin)") rootCmd.Flags().StringVarP(&inputFile, "input", "i", "", "Input file (default: stdin)")
rootCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file (default: stdout)") rootCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file (default: stdout)")
rootCmd.Flags().BoolVar(&debug, "debug", false, "Print intermediate JSON to stderr") rootCmd.Flags().BoolVar(&debug, "debug", false, "Print intermediate JSON to stderr")
rootCmd.Flags().IntVar(&indent, "indent", 0, "Number of spaces to indent lines (from second line onward)")
rootCmd.Version = version rootCmd.Version = version
rootCmd.Flags().BoolP("version", "v", false, "Show version and exit") rootCmd.Flags().BoolP("version", "v", false, "Show version and exit")
rootCmd.SetVersionTemplate(fmt.Sprintf("todotxt2remind version %s\n", version)) rootCmd.SetVersionTemplate(fmt.Sprintf("todotxt2remind version %s\n", version))

View File

@@ -73,6 +73,9 @@ X 2012-01-01 Make resolutions
(A) 2024-13-99 Call Mom (A) 2024-13-99 Call Mom
x 2024-99-99 Call Mom x 2024-99-99 Call Mom
# Malformed: due date with invalid format (should error, task dropped from output)
(A) Call Mom due:2024-13-99
# Malformed: key:value with whitespace in key or value # Malformed: key:value with whitespace in key or value
foo bar:baz foo bar:baz
foo:bar baz foo:bar baz