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 +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +191 -0
- data/bin/taf +5 -0
- data/lib/taf/ansi_colors.rb +27 -0
- data/lib/taf/cli.rb +207 -0
- data/lib/taf/markdown_adapter.rb +91 -0
- data/lib/taf/models/taf.rb +258 -0
- data/lib/taf/models/todo.rb +28 -0
- data/lib/taf/taf_helper.rb +47 -0
- data/lib/taf/terminal_presenter.rb +34 -0
- data/lib/taf/version.rb +1 -0
- data/lib/taf.rb +8 -0
- metadata +97 -0
    
        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,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
         | 
    
        data/lib/taf/version.rb
    ADDED
    
    | @@ -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: []
         |