taf-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 429fcf82dcb51d4be79dd52ca058902436ad9df756e7361d43482df5243af033
4
+ data.tar.gz: 64d11156b021003106371ebaffca36ab0e44887a4b05758bebb91dd7ade57581
5
+ SHA512:
6
+ metadata.gz: ec40f282568d9937f5afebc5f816c0d98aae0925035df9bbe188fd86f8c042e5e62de61e1139473b8d84a9db23c6eb1b2341b13a36fbc704eb2a2550f6a47b84
7
+ data.tar.gz: cb3278413cca7a2eb654b4b8dabef3a7ee09aff292b7333daaf1300e28eaffef20207bd592fbea601b3f1056e51f1d292574a6f800be4ddaeebead1d838a738a
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2025-10-22
9
+
10
+ ### Changed
11
+ - Initial stable release (bumped to 1.0.0 to avoid conflicts with previously yanked versions)
12
+
13
+ ## [0.1.0] - 2025-10-22
14
+
15
+ ### Added
16
+ - Initial release of taf (Travail À Faire)
17
+ - Markdown-based todo list storage
18
+ - Tag-based organization of tasks
19
+ - Hierarchical task structure (parent-child relationships)
20
+ - Toggle task completion status
21
+ - Delete tasks
22
+ - Edit file directly in $EDITOR
23
+ - Undo functionality with automatic backups
24
+ - Cleanup command to sort todos before done items
25
+ - Purge command to remove all completed tasks
26
+ - Default file path to ~/taf.md (optional -f flag)
27
+
28
+ [0.1.0]: https://github.com/jmoniatte/taf/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jean Moniatte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # TAF - Travail À Faire
2
+
3
+ A simple CLI todo list manager written in Ruby.
4
+
5
+ `taf` is a lightweight command-line tool that helps you manage your todos in a markdown file. It supports hierarchical task organization with tags, parent-child relationships, and various management commands.
6
+
7
+ ## Features
8
+
9
+ * Store todos in a simple markdown format
10
+ * Organize tasks with tags
11
+ * Create hierarchical todos (parent-child relationships)
12
+ * Toggle task completion status
13
+ * Undo changes with automatic backups
14
+ * Cleanup and purge completed tasks
15
+
16
+ ## Installation
17
+
18
+ ### Prerequisites
19
+
20
+ Ensure you have Ruby 2.6 or later installed:
21
+
22
+ ```bash
23
+ ruby --version
24
+ ```
25
+
26
+ ### Install from RubyGems
27
+
28
+ ```bash
29
+ gem install taf-cli
30
+ ```
31
+
32
+ This installs the `taf` command.
33
+
34
+ ### Verify Installation
35
+
36
+ ```bash
37
+ taf --help
38
+ ```
39
+
40
+ ### Install from Source (Alternative)
41
+
42
+ If you want to install from source:
43
+
44
+ ```bash
45
+ git clone https://github.com/jmoniatte/taf.git
46
+ cd taf
47
+ gem build taf.gemspec
48
+ gem install ./taf-cli-1.0.0.gem
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ By default, `taf` uses `~/taf.md` as the todo file. You can specify a different file with the `-f` option.
54
+
55
+ ### Basic Commands
56
+
57
+ **View all todos:**
58
+ ```bash
59
+ taf
60
+ ```
61
+
62
+ **Add a todo with a tag:**
63
+ ```bash
64
+ taf Buy groceries @shopping
65
+ ```
66
+
67
+ **Add a todo (uses "Untagged" as default tag):**
68
+ ```bash
69
+ taf Fix the bug in authentication
70
+ ```
71
+
72
+ **View todos for a specific tag:**
73
+ ```bash
74
+ taf @shopping
75
+ ```
76
+
77
+ **Add a child todo under a parent:**
78
+ ```bash
79
+ taf Buy milk @12
80
+ # This adds "Buy milk" as a child of item @12
81
+ ```
82
+
83
+ **Toggle a todo's completion status:**
84
+ ```bash
85
+ taf -t @12
86
+ ```
87
+
88
+ **Delete a todo:**
89
+ ```bash
90
+ taf -D @12
91
+ ```
92
+
93
+ **Edit the file manually:**
94
+ ```bash
95
+ taf -e
96
+ # Opens the file $EDITOR (or vim by default)
97
+ ```
98
+
99
+ **Undo the last change:**
100
+ ```bash
101
+ taf -u
102
+ ```
103
+
104
+ **Cleanup (sort todos before done items):**
105
+ ```bash
106
+ taf -c
107
+ ```
108
+
109
+ **Purge all completed tasks:**
110
+ ```bash
111
+ taf -P
112
+ ```
113
+
114
+ ### Using a Custom File
115
+
116
+ ```bash
117
+ taf -f /path/to/my-todos.md "New task" @work
118
+ ```
119
+
120
+ ## Markdown Format
121
+
122
+ The todo file is stored in a simple markdown format:
123
+
124
+ ```markdown
125
+ # shopping
126
+ - [ ] Buy groceries
127
+ - [ ] Milk
128
+ - [x] Bread
129
+ - [x] Get coffee
130
+
131
+ # work
132
+ - [ ] Review pull request
133
+ - [ ] Write documentation
134
+ ```
135
+
136
+ - Tags are markdown headers (`# tagname`)
137
+ - Uncompleted todos use `- [ ]`
138
+ - Completed todos use `- [x]`
139
+ - Child items are indented with 2 spaces per level
140
+
141
+ ## Options
142
+
143
+ ```
144
+ Usage: taf [options] [message]
145
+ Options:
146
+ -f, --file FILE Path to the taf markdown file (default: ~/taf.md)
147
+ -h, --help Show help
148
+ -t @LINE_ID Toggle status for line id
149
+ -D @LINE_ID Delete the specified line id
150
+ -e, --edit Open the file in $EDITOR for manual edits
151
+ -u, --undo Undo the last change
152
+ -c, --cleanup Sort items (todo items before done)
153
+ -P, --purge Delete all done items
154
+
155
+ Message:
156
+ text @tag Records todo for @tag
157
+ text @ID Records todo as child of parent ID
158
+ @tag Displays todos for @tag
159
+ ```
160
+
161
+ ## Examples
162
+
163
+ ```bash
164
+ # Start fresh
165
+ taf "Plan vacation" @personal
166
+
167
+ # Add related subtasks
168
+ taf "Book flights" @1
169
+ taf "Reserve hotel" @1
170
+ taf "Research activities" @1
171
+
172
+ # Add work tasks
173
+ taf "Review code" @work
174
+ taf "Update documentation" @work
175
+
176
+ # Mark a task as done
177
+ taf -t @2
178
+
179
+ # View all tasks
180
+ taf
181
+
182
+ # View only personal tasks
183
+ taf @personal
184
+
185
+ # Clean up completed tasks
186
+ taf -P
187
+ ```
188
+
189
+ ## License
190
+
191
+ MIT
data/bin/taf ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/taf'
4
+
5
+ CLI.new(ARGV).run
@@ -0,0 +1,27 @@
1
+ module AnsiColors
2
+ ANSI_RESET = "\u001B[0m".freeze
3
+ ANSI_BLACK = "\u001B[30m".freeze
4
+ ANSI_RED = "\u001B[31m".freeze
5
+ ANSI_GREEN = "\u001B[32m".freeze
6
+ ANSI_YELLOW = "\u001B[33m".freeze
7
+ ANSI_BLUE = "\u001B[34m".freeze
8
+ ANSI_PURPLE = "\u001B[35m".freeze
9
+ ANSI_CYAN = "\u001B[36m".freeze
10
+ ANSI_WHITE = "\u001B[97m".freeze
11
+ ANSI_GREY = "\u001B[37m".freeze
12
+ ANSI_HIGHLIGHT = "\u001B[43;30m".freeze
13
+ ANSI_DEFAULT = "\u001B[39m".freeze
14
+ end
15
+
16
+ class String
17
+ AnsiColors.constants.each do |const_name|
18
+ next if const_name == :ANSI_RESET
19
+
20
+ color_name = const_name.to_s.sub(/^ANSI_/, '').downcase
21
+ color_code = AnsiColors.const_get(const_name)
22
+
23
+ define_method(color_name) do
24
+ "#{color_code}#{self}#{AnsiColors::ANSI_RESET}"
25
+ end
26
+ end
27
+ end
data/lib/taf/cli.rb ADDED
@@ -0,0 +1,207 @@
1
+ require 'optparse'
2
+ require_relative 'models/taf'
3
+ require_relative 'ansi_colors'
4
+ require_relative 'taf_helper'
5
+
6
+ # Handles CLI argument parsing and command execution
7
+ class CLI
8
+ def initialize(args)
9
+ @args = args
10
+ @mode = :default
11
+ @file = nil
12
+ @message = nil
13
+ @taf = nil
14
+ end
15
+
16
+ def run
17
+ parse_options
18
+ validate_options
19
+ execute_command
20
+ end
21
+
22
+ private
23
+
24
+ def parse_options
25
+ parser = create_option_parser
26
+
27
+ begin
28
+ parser.parse!(@args)
29
+ # Only set @message from remaining args if not already set by option handler
30
+ @message ||= @args.join(" ")
31
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
32
+ puts e.message.red
33
+ puts parser.help.red
34
+ exit 1
35
+ end
36
+ end
37
+
38
+ def create_option_parser
39
+ OptionParser.new do |opts|
40
+ opts.banner = "Usage: taf [options] [message]"
41
+ opts.separator "Options:"
42
+
43
+ opts.on("-f FILE", "--file FILE", "Path to the taf markdown file (default: ~/taf.md)") do |file|
44
+ @file = file
45
+ end
46
+
47
+ opts.on("-h", "--help", "Show help") do
48
+ @mode = :help
49
+ end
50
+
51
+ opts.on("-t @LINE_ID", "Toggle status for line id") do |line_id|
52
+ @mode = :toggle
53
+ @message = line_id
54
+ end
55
+
56
+ opts.on("-D @LINE_ID", "Delete the specified line id") do |line_id|
57
+ @mode = :delete
58
+ @message = line_id
59
+ end
60
+
61
+ opts.on("-e", "--edit", "Open the file in $EDITOR for manual edits") do
62
+ @mode = :edit
63
+ end
64
+
65
+ opts.on("-u", "--undo", "Undo the last change") do
66
+ @mode = :undo
67
+ end
68
+
69
+ opts.on("-c", "--cleanup", "Sort items (todo items before done)") do
70
+ @mode = :cleanup
71
+ end
72
+
73
+ opts.on("-P", "--purge", "Delete all done items") do
74
+ @mode = :purge
75
+ end
76
+
77
+ opts.separator "Message:"
78
+ opts.separator " text @tag Records todo for @tag"
79
+ opts.separator " text @ID Records todo as child of parent ID"
80
+ opts.separator " @tag Displays todos for @tag"
81
+ end
82
+ end
83
+
84
+ def validate_options
85
+ # Set default file if not specified
86
+ @file ||= File.expand_path("~/taf.md") unless @mode == :help
87
+
88
+ @taf = Taf.new(@file) if @file
89
+ end
90
+
91
+ def execute_command
92
+ case @mode
93
+ when :help
94
+ puts create_option_parser.help
95
+ when :toggle
96
+ toggle_command
97
+ when :delete
98
+ delete_command
99
+ when :edit
100
+ edit_command
101
+ when :undo
102
+ undo_command
103
+ when :cleanup
104
+ cleanup_command
105
+ when :purge
106
+ purge_command
107
+ when :default
108
+ default_command
109
+ end
110
+ end
111
+
112
+ def toggle_command
113
+ _text, _tag, line_id = TafHelper.parse_message(@message)
114
+ unless line_id
115
+ puts "Error: Invalid line ID format. Use @NUMBER (e.g., @21)".red
116
+ exit 1
117
+ end
118
+
119
+ item = @taf.find_item_by_id(line_id)
120
+ unless item
121
+ puts "Error: Item with id #{line_id} not found".red
122
+ exit 1
123
+ end
124
+ item_signatures = @taf.toggle(item)
125
+ @taf = Taf.new(@file)
126
+ system("clear")
127
+ @taf.show_all(highlight_signatures: item_signatures)
128
+ end
129
+
130
+ def delete_command
131
+ _text, _tag, line_id = TafHelper.parse_message(@message)
132
+ unless line_id
133
+ puts "Error: Invalid line ID format. Use @NUMBER (e.g., @21)".red
134
+ exit 1
135
+ end
136
+
137
+ item = @taf.find_item_by_id(line_id)
138
+ unless item
139
+ puts "Error: Item with id #{line_id} not found".red
140
+ exit 1
141
+ end
142
+ @taf.delete(item)
143
+ @taf = Taf.new(@file)
144
+ system("clear")
145
+ @taf.show_all
146
+ end
147
+
148
+ def edit_command
149
+ Taf.backup(@file)
150
+ system("#{ENV['EDITOR'] || 'vim'} #{@file}")
151
+ @taf = Taf.new(@file)
152
+ system("clear")
153
+ @taf.show_all
154
+ puts "Taf file saved".green
155
+ end
156
+
157
+ def undo_command
158
+ begin
159
+ Taf.restore(@file)
160
+ @taf = Taf.new(@file)
161
+ system("clear")
162
+ @taf.show_all
163
+ puts "Undo successful".green
164
+ rescue ArgumentError => e
165
+ puts "Error: #{e.message}".red
166
+ exit 1
167
+ end
168
+ end
169
+
170
+ def cleanup_command
171
+ @taf.cleanup
172
+ @taf = Taf.new(@file)
173
+ system("clear")
174
+ @taf.show_all
175
+ puts "Cleanup complete".green
176
+ end
177
+
178
+ def purge_command
179
+ @taf.purge
180
+ @taf = Taf.new(@file)
181
+ system("clear")
182
+ @taf.show_all
183
+ puts "Purge complete".green
184
+ end
185
+
186
+ def default_command
187
+ text, tag, parent_id = TafHelper.parse_message(@message)
188
+ if tag.nil? && text.nil? && parent_id.nil?
189
+ system("clear")
190
+ @taf.show_all
191
+ elsif text.nil?
192
+ system("clear")
193
+ @taf.show_tag(tag)
194
+ else
195
+ begin
196
+ todo_signatures = @taf.add_todo(text, tag: tag, parent_id: parent_id)
197
+
198
+ @taf = Taf.new(@file)
199
+ system("clear")
200
+ @taf.show_all(highlight_signatures: todo_signatures)
201
+ rescue ArgumentError => e
202
+ puts "Error: #{e.message}".red
203
+ exit 1
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,91 @@
1
+ module MarkdownAdapter
2
+ require_relative 'taf_helper'
3
+ require_relative 'models/todo'
4
+
5
+ # Markdown format constants
6
+ TAG_PREFIX = "#".freeze
7
+ TODO_PREFIX = "- [ ]".freeze
8
+ DONE_PREFIX = "- [x]".freeze
9
+ INDENT_SIZE = 2 # Number of spaces per indent level
10
+
11
+ # Reads markdown and returns data as a tree structure
12
+ def self.read(file)
13
+ return {} unless File.exist?(file)
14
+
15
+ rows = File
16
+ .readlines(file)
17
+ .map(&:rstrip)
18
+ .reject { |line| line.nil? || line.empty? }
19
+
20
+ {}.tap do |data|
21
+ tag = TafHelper::DEFAULT_TAG
22
+ # Stack to track parent at each indent level
23
+ stack = []
24
+
25
+ rows.each do |row|
26
+ if row.start_with?("#{TAG_PREFIX} ")
27
+ tag = row.delete_prefix("#{TAG_PREFIX} ")
28
+ data[tag] ||= []
29
+ stack = [] # Reset stack for new tag
30
+ elsif tag.nil?
31
+ next
32
+ else
33
+ # Calculate indent level from leading spaces
34
+ leading_spaces = row[/^\s*/].length
35
+ indent_level = leading_spaces / INDENT_SIZE
36
+ stripped_row = row.lstrip
37
+
38
+ # Trim stack to current indent level
39
+ stack = stack[0...indent_level]
40
+
41
+ # Determine parent
42
+ parent = indent_level > 0 ? stack[indent_level - 1] : nil
43
+
44
+ # Create the item
45
+ status = stripped_row.start_with?("#{TODO_PREFIX} ") ? "todo" : "done"
46
+ prefix = status == "todo" ? TODO_PREFIX : DONE_PREFIX
47
+ item = Todo.new(
48
+ status: status,
49
+ text: stripped_row.delete_prefix("#{prefix} "),
50
+ children: [],
51
+ parent: parent
52
+ )
53
+
54
+ # Add to parent's children or to root
55
+ if indent_level == 0
56
+ data[tag] << item
57
+ else
58
+ parent.children << item
59
+ end
60
+
61
+ # Push current item onto stack
62
+ stack[indent_level] = item
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # Writes data to markdown file (flattens tree structure)
69
+ def self.write(file, data)
70
+ rows = []
71
+ data.each do |tag, items|
72
+ rows << "#{TAG_PREFIX} #{tag}"
73
+ items.each do |item|
74
+ flatten_item(item, 0, rows)
75
+ end
76
+ end
77
+ File.write(file, rows.join("\n"))
78
+ end
79
+
80
+ # Recursively flattens a tree item and its children
81
+ def self.flatten_item(item, indent_level, rows)
82
+ prefix = item.status == "todo" ? TODO_PREFIX : DONE_PREFIX
83
+ indent = ' ' * (indent_level * INDENT_SIZE)
84
+ rows << "#{indent}#{prefix} #{item.text}"
85
+
86
+ # Recursively add children
87
+ item.children.each do |child|
88
+ flatten_item(child, indent_level + 1, rows)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,258 @@
1
+ require 'fileutils'
2
+ require_relative '../markdown_adapter'
3
+ require_relative '../taf_helper'
4
+ require_relative '../terminal_presenter'
5
+ require_relative 'todo'
6
+
7
+ class Taf
8
+ def initialize(file)
9
+ @file = file
10
+ @taf = MarkdownAdapter.read(file)
11
+ assign_indices
12
+ @presenter = TerminalPresenter.new
13
+ end
14
+
15
+ # Creates a backup of the file
16
+ def self.backup(file)
17
+ FileUtils.cp(file, "#{file}.backup") if File.exist?(file)
18
+ end
19
+
20
+ # Restores the backup file and deletes it
21
+ def self.restore(file)
22
+ backup_file = "#{file}.backup"
23
+ unless File.exist?(backup_file)
24
+ raise ArgumentError, "No backup file found"
25
+ end
26
+
27
+ FileUtils.cp(backup_file, file)
28
+ File.delete(backup_file)
29
+ end
30
+
31
+ # Adds a new item
32
+ def add_todo(text, tag: nil, parent_id: nil)
33
+ # Avoids flag typos
34
+ raise ArgumentError, "Text must be #{Todo::MIN_LENGTH} or more characters" unless text.size >= Todo::MIN_LENGTH
35
+
36
+ # Default tag if neither tag nor parent specified
37
+ if tag.nil? && parent_id.nil?
38
+ tag = TafHelper::DEFAULT_TAG
39
+ end
40
+
41
+ changed = []
42
+ if parent_id
43
+ parent_item = find_item_by_id(parent_id)
44
+ unless parent_item
45
+ raise ArgumentError, "Parent with id #{parent_id} not found"
46
+ end
47
+
48
+ new_item = Todo.new(
49
+ status: "todo",
50
+ text: text,
51
+ children: [],
52
+ parent: parent_item
53
+ )
54
+ parent_item.children << new_item
55
+ changed = mark_ancestors_todo(parent_item)
56
+ else
57
+ new_item = Todo.new(
58
+ status: "todo",
59
+ text: text,
60
+ children: []
61
+ )
62
+ if @taf[tag]
63
+ # Tag exists, append to root level
64
+ @taf[tag] << new_item
65
+ else
66
+ # Create new tag
67
+ @taf[tag] = [new_item]
68
+ end
69
+ end
70
+
71
+ changed << new_item
72
+ assign_indices
73
+ save
74
+
75
+ changed.map(&:signature)
76
+ end
77
+
78
+ # Shows given tag and its items
79
+ def show_tag(tag, highlight_signatures: nil)
80
+ @taf.each do |key, items|
81
+ next unless key == tag
82
+
83
+ @presenter.display_tag(key)
84
+ items.each do |item|
85
+ display_item_recursive(item, 0, highlight_signatures)
86
+ end
87
+ puts ""
88
+ end
89
+ end
90
+
91
+ # Shows all tags and items
92
+ def show_all(highlight_signatures: nil)
93
+ @taf.each do |key, items|
94
+ @presenter.display_tag(key)
95
+ items.each do |item|
96
+ display_item_recursive(item, 0, highlight_signatures)
97
+ end
98
+ puts ""
99
+ end
100
+ end
101
+
102
+ # Recursively displays an item and its children with proper indentation
103
+ def display_item_recursive(item, indent_level, highlight_signatures)
104
+ @presenter.display_todo(item, indent_level, highlighted: highlight_signatures&.include?(item.signature))
105
+ item.children.each do |child|
106
+ display_item_recursive(child, indent_level + 1, highlight_signatures)
107
+ end
108
+ end
109
+
110
+ # Toggles the status of the item
111
+ # Toggles ancestors and descendants accordingly
112
+ def toggle(item)
113
+ item.status = item.status == "done" ? "todo" : "done"
114
+
115
+ affected_items = [item]
116
+ if item.status == "done"
117
+ affected_items.concat(mark_descendants_done(item))
118
+ elsif item.parent
119
+ affected_items.concat(mark_ancestors_todo(item.parent))
120
+ end
121
+
122
+ save
123
+ affected_items.map(&:signature)
124
+ end
125
+
126
+ # Recursively marks all descendants as done and returns those that changed
127
+ def mark_descendants_done(item)
128
+ changed = []
129
+ item.children.each do |child|
130
+ changed << child if child.status != "done"
131
+ child.status = "done"
132
+ changed.concat(mark_descendants_done(child))
133
+ end
134
+ changed
135
+ end
136
+
137
+ # Recursively marks all ancestors as todo and returns those that changed
138
+ def mark_ancestors_todo(item)
139
+ changed = []
140
+ current = item
141
+ while current
142
+ changed << current if current.status != "todo"
143
+ current.status = "todo"
144
+ current = current.parent
145
+ end
146
+ changed
147
+ end
148
+
149
+ # Deletes the item (and all its children)
150
+ def delete(item)
151
+ if item.parent
152
+ # Remove from parent's children
153
+ item.parent.children.delete(item)
154
+ else
155
+ # Remove from root level
156
+ @taf.each_value do |items|
157
+ if items.delete(item)
158
+ break
159
+ end
160
+ end
161
+ end
162
+
163
+ save
164
+ end
165
+
166
+ # Finds a todo item by its index across all tags (searches recursively)
167
+ def find_item_by_id(id)
168
+ @taf.each_value do |items|
169
+ items.each do |item|
170
+ result = find_in_subtree(item, id)
171
+ return result if result
172
+ end
173
+ end
174
+ nil
175
+ end
176
+
177
+ # Sorts all items (todo items before done items) and saves
178
+ def cleanup
179
+ # Sort items: within each parent, todo items before done items
180
+ @taf.each_value do |items|
181
+ items.each do |item|
182
+ sort_children_recursive(item)
183
+ end
184
+ # Sort root level items
185
+ items.sort_by! { |item| item.status == "done" ? 1 : 0 }
186
+ end
187
+
188
+ save
189
+ end
190
+
191
+ # Deletes all done items (and their children) and saves
192
+ def purge
193
+ @taf.each_value do |items|
194
+ items.reject! { |item| item.status == "done" }
195
+ items.each do |item|
196
+ purge_done_children(item)
197
+ end
198
+ end
199
+
200
+ save
201
+ end
202
+
203
+ private
204
+
205
+ # Recursively removes done children from an item
206
+ def purge_done_children(item)
207
+ item.children.reject! { |child| child.status == "done" }
208
+ item.children.each do |child|
209
+ purge_done_children(child)
210
+ end
211
+ end
212
+
213
+ # Recursively searches for an item in a subtree
214
+ def find_in_subtree(item, id)
215
+ return item if item.idx == id
216
+
217
+ item.children.each do |child|
218
+ result = find_in_subtree(child, id)
219
+ return result if result
220
+ end
221
+ nil
222
+ end
223
+
224
+ # Removes empty tags and writes to file
225
+ def save
226
+ @taf.delete_if { |_tag, items| items.empty? }
227
+ self.class.backup(@file)
228
+ MarkdownAdapter.write(@file, @taf)
229
+ end
230
+
231
+ # Recursively sorts children: todo before done
232
+ def sort_children_recursive(item)
233
+ item.children.sort_by! { |child| child.status == "done" ? 1 : 0 }
234
+ item.children.each do |child|
235
+ sort_children_recursive(child)
236
+ end
237
+ end
238
+
239
+ # Assigns sequential indices to all todo items using depth-first traversal
240
+ def assign_indices
241
+ idx = 0
242
+ @taf.each_value do |items|
243
+ items.each do |item|
244
+ idx = assign_index_recursive(item, idx)
245
+ end
246
+ end
247
+ end
248
+
249
+ # Recursively assigns indices in depth-first order
250
+ def assign_index_recursive(item, idx)
251
+ idx += 1
252
+ item.idx = idx
253
+ item.children.each do |child|
254
+ idx = assign_index_recursive(child, idx)
255
+ end
256
+ idx
257
+ end
258
+ end
@@ -0,0 +1,28 @@
1
+ require 'digest'
2
+
3
+ # Represents a single todo item in a tree structure
4
+ class Todo
5
+ attr_accessor :idx, :status, :text, :children, :parent
6
+
7
+ MIN_LENGTH = 3
8
+
9
+ def initialize(status:, text:, idx: nil, children: [], parent: nil)
10
+ @idx = idx
11
+ @status = status
12
+ @text = text
13
+ @children = children
14
+ @parent = parent
15
+ end
16
+
17
+ def done?
18
+ @status == "done"
19
+ end
20
+
21
+ def todo?
22
+ @status == "todo"
23
+ end
24
+
25
+ def signature
26
+ Digest::MD5.hexdigest("#{text}|#{parent&.text}")
27
+ end
28
+ end
@@ -0,0 +1,47 @@
1
+ module TafHelper
2
+ DEFAULT_TAG = "Untagged".freeze
3
+
4
+ # Extracts text, tag, and parent_id from message
5
+ # Returns [text, tag, parent_id]
6
+ # - If @ID (numeric): parent_id is set, tag is nil
7
+ # - If @tag (alphanumeric): tag is set, parent_id is nil
8
+ # - Otherwise: both nil
9
+ def self.parse_message(message)
10
+ return [nil, nil, nil] if message.to_s.empty?
11
+
12
+ if (tag_only = message[/^@[A-Za-z0-9][A-Za-z0-9_-]*$/])
13
+ return parse_tag_or_parent(nil, tag_only)
14
+ end
15
+
16
+ tag = message[/ @[A-Za-z0-9][A-Za-z0-9_-]*$/]
17
+ if tag
18
+ text = message.sub(/ @[A-Za-z0-9][A-Za-z0-9_-]*$/, "")
19
+ text.strip!
20
+ parse_tag_or_parent(text, tag)
21
+ else
22
+ text = message.strip
23
+ [text, nil, nil]
24
+ end
25
+ end
26
+
27
+ # Determines if tag_string is a parent ID or a tag
28
+ def self.parse_tag_or_parent(text, tag_string)
29
+ cleaned = tag_string.strip.delete_prefix("@")
30
+
31
+ if cleaned.match?(/^\d+$/)
32
+ [text, nil, cleaned.to_i]
33
+ else
34
+ [text, tag_name(tag_string), nil]
35
+ end
36
+ end
37
+
38
+ # Transforms `@tag-name` to `Tag Name`
39
+ def self.tag_name(tag)
40
+ tag
41
+ .strip
42
+ .delete_prefix("@")
43
+ .split(/[-_]/)
44
+ .map(&:capitalize)
45
+ .join(" ")
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ require_relative 'ansi_colors'
2
+ require_relative 'markdown_adapter'
3
+
4
+ # Handles terminal display formatting for items
5
+ class TerminalPresenter
6
+ # Display format constants
7
+ TAG_PREFIX = "#".freeze
8
+ TODO_PREFIX = "- [ ]".freeze
9
+ DONE_PREFIX = "- [x]".freeze
10
+ INDENT_SIZE = 2
11
+
12
+ def display_tag(tag)
13
+ puts "#{TAG_PREFIX} #{tag}".red
14
+ end
15
+
16
+ def display_todo(todo, indent_level, highlighted: false)
17
+ # Add indentation based on tree depth
18
+ indent = ' ' * (indent_level * INDENT_SIZE)
19
+
20
+ prefix = case todo.status
21
+ when "done"
22
+ DONE_PREFIX.grey
23
+ when "todo"
24
+ TODO_PREFIX.default
25
+ end
26
+
27
+ text_colored = todo.status == "done" ? todo.text.grey : todo.text.default
28
+
29
+ line_number = "[#{todo.idx}]".cyan
30
+ indicator = highlighted ? " ✔".green : ""
31
+
32
+ puts indent + [prefix, text_colored, line_number + indicator].join(" ")
33
+ end
34
+ end
@@ -0,0 +1 @@
1
+ TAF_VERSION = "1.0.0".freeze
data/lib/taf.rb ADDED
@@ -0,0 +1,8 @@
1
+ require_relative 'taf/version'
2
+ require_relative 'taf/ansi_colors'
3
+ require_relative 'taf/taf_helper'
4
+ require_relative 'taf/models/todo'
5
+ require_relative 'taf/markdown_adapter'
6
+ require_relative 'taf/terminal_presenter'
7
+ require_relative 'taf/models/taf'
8
+ require_relative 'taf/cli'
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: taf-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jean Moniatte
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Travail À Faire (taf) is a lightweight command-line tool that helps you
42
+ manage your todos in a markdown file with support for tags and hierarchical organization.
43
+ email:
44
+ - jmoniatte@fastmail.com
45
+ executables:
46
+ - taf
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - bin/taf
54
+ - lib/taf.rb
55
+ - lib/taf/ansi_colors.rb
56
+ - lib/taf/cli.rb
57
+ - lib/taf/markdown_adapter.rb
58
+ - lib/taf/models/taf.rb
59
+ - lib/taf/models/todo.rb
60
+ - lib/taf/taf_helper.rb
61
+ - lib/taf/terminal_presenter.rb
62
+ - lib/taf/version.rb
63
+ homepage: https://github.com/jmoniatte/taf
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ homepage_uri: https://github.com/jmoniatte/taf
68
+ source_code_uri: https://github.com/jmoniatte/taf
69
+ changelog_uri: https://github.com/jmoniatte/taf/blob/master/CHANGELOG.md
70
+ post_install_message: |
71
+ Thanks for installing taf-cli!
72
+
73
+ Get started with:
74
+ taf --help
75
+
76
+ Your todos will be stored in ~/taf.md by default.
77
+
78
+ For more info: https://github.com/jmoniatte/taf
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 2.6.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.3.15
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: A simple CLI todo list manager
97
+ test_files: []