ace-support-items 0.15.3

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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +29 -0
  4. data/Rakefile +13 -0
  5. data/lib/ace/support/items/atoms/ansi_colors.rb +37 -0
  6. data/lib/ace/support/items/atoms/date_partition_path.rb +24 -0
  7. data/lib/ace/support/items/atoms/field_argument_parser.rb +107 -0
  8. data/lib/ace/support/items/atoms/filter_parser.rb +79 -0
  9. data/lib/ace/support/items/atoms/folder_completion_detector.rb +40 -0
  10. data/lib/ace/support/items/atoms/frontmatter_parser.rb +57 -0
  11. data/lib/ace/support/items/atoms/frontmatter_serializer.rb +81 -0
  12. data/lib/ace/support/items/atoms/item_id_formatter.rb +81 -0
  13. data/lib/ace/support/items/atoms/item_id_parser.rb +89 -0
  14. data/lib/ace/support/items/atoms/item_statistics.rb +26 -0
  15. data/lib/ace/support/items/atoms/position_generator.rb +64 -0
  16. data/lib/ace/support/items/atoms/relative_time_formatter.rb +48 -0
  17. data/lib/ace/support/items/atoms/slug_sanitizer.rb +67 -0
  18. data/lib/ace/support/items/atoms/sort_score_calculator.rb +54 -0
  19. data/lib/ace/support/items/atoms/special_folder_detector.rb +101 -0
  20. data/lib/ace/support/items/atoms/stats_line_formatter.rb +85 -0
  21. data/lib/ace/support/items/atoms/title_extractor.rb +22 -0
  22. data/lib/ace/support/items/models/item_id.rb +55 -0
  23. data/lib/ace/support/items/models/loaded_document.rb +28 -0
  24. data/lib/ace/support/items/models/scan_result.rb +32 -0
  25. data/lib/ace/support/items/molecules/base_formatter.rb +51 -0
  26. data/lib/ace/support/items/molecules/directory_scanner.rb +106 -0
  27. data/lib/ace/support/items/molecules/document_loader.rb +77 -0
  28. data/lib/ace/support/items/molecules/field_updater.rb +120 -0
  29. data/lib/ace/support/items/molecules/filter_applier.rb +117 -0
  30. data/lib/ace/support/items/molecules/folder_mover.rb +91 -0
  31. data/lib/ace/support/items/molecules/git_committer.rb +21 -0
  32. data/lib/ace/support/items/molecules/item_sorter.rb +73 -0
  33. data/lib/ace/support/items/molecules/llm_slug_generator.rb +264 -0
  34. data/lib/ace/support/items/molecules/shortcut_resolver.rb +87 -0
  35. data/lib/ace/support/items/molecules/smart_sorter.rb +39 -0
  36. data/lib/ace/support/items/molecules/status_categorizer.rb +60 -0
  37. data/lib/ace/support/items/version.rb +9 -0
  38. data/lib/ace/support/items.rb +50 -0
  39. metadata +111 -0
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/item_id"
4
+
5
+ module Ace
6
+ module Support
7
+ module Items
8
+ module Atoms
9
+ # Splits and reconstructs 6-char b36ts IDs with type markers.
10
+ #
11
+ # A raw 6-char b36ts ID "8ppq7w" becomes "8pp.t.q7w" with type marker "t".
12
+ # Subtasks append a single character: "8pp.t.q7w.a"
13
+ #
14
+ # @example Split a raw ID
15
+ # ItemIdFormatter.split("8ppq7w", type_marker: "t")
16
+ # # => ItemId(prefix: "8pp", type_marker: "t", suffix: "q7w")
17
+ #
18
+ # @example Reconstruct from formatted ID
19
+ # ItemIdFormatter.reconstruct("8pp.t.q7w")
20
+ # # => "8ppq7w"
21
+ class ItemIdFormatter
22
+ # Split a 6-char b36ts ID into prefix.marker.suffix format
23
+ # @param raw_b36ts [String] 6-character b36ts ID
24
+ # @param type_marker [String] Type marker (e.g., "t" for task, "i" for idea)
25
+ # @return [Models::ItemId] Parsed item ID
26
+ # @raise [ArgumentError] If raw_b36ts is not exactly 6 characters
27
+ def self.split(raw_b36ts, type_marker:)
28
+ raise ArgumentError, "Expected 6-char b36ts ID, got #{raw_b36ts.inspect}" unless raw_b36ts.is_a?(String) && raw_b36ts.length == 6
29
+
30
+ Models::ItemId.new(
31
+ raw_b36ts: raw_b36ts,
32
+ prefix: raw_b36ts[0..2],
33
+ type_marker: type_marker,
34
+ suffix: raw_b36ts[3..5],
35
+ subtask_char: nil
36
+ )
37
+ end
38
+
39
+ # Create an ItemId with a subtask character
40
+ # @param raw_b36ts [String] 6-character b36ts ID
41
+ # @param type_marker [String] Type marker
42
+ # @param subtask_char [String] Single subtask character (e.g., "a")
43
+ # @return [Models::ItemId] Parsed item ID with subtask
44
+ def self.split_subtask(raw_b36ts, type_marker:, subtask_char:)
45
+ item_id = split(raw_b36ts, type_marker: type_marker)
46
+ Models::ItemId.new(
47
+ raw_b36ts: item_id.raw_b36ts,
48
+ prefix: item_id.prefix,
49
+ type_marker: item_id.type_marker,
50
+ suffix: item_id.suffix,
51
+ subtask_char: subtask_char
52
+ )
53
+ end
54
+
55
+ # Reconstruct a raw 6-char b36ts ID from a formatted ID string
56
+ # @param formatted_id [String] Formatted ID (e.g., "8pp.t.q7w" or "8pp.t.q7w.a")
57
+ # @return [String] Raw 6-char b36ts ID (e.g., "8ppq7w")
58
+ # @raise [ArgumentError] If format is invalid
59
+ def self.reconstruct(formatted_id)
60
+ match = formatted_id.match(/^([0-9a-z]{3})\.([a-z])\.([0-9a-z]{3})(?:\.([0-9a-z]))?$/)
61
+ raise ArgumentError, "Invalid formatted ID: #{formatted_id.inspect}" unless match
62
+
63
+ "#{match[1]}#{match[3]}"
64
+ end
65
+
66
+ # Build a folder name from formatted ID and slug
67
+ # @param formatted_id [String] e.g., "8pp.t.q7w"
68
+ # @param slug [String] e.g., "fix-login"
69
+ # @return [String] e.g., "8pp.t.q7w-fix-login"
70
+ def self.folder_name(formatted_id, slug)
71
+ if slug.nil? || slug.empty?
72
+ formatted_id
73
+ else
74
+ "#{formatted_id}-#{slug}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/item_id"
4
+
5
+ module Ace
6
+ module Support
7
+ module Items
8
+ module Atoms
9
+ # Parses various reference forms into an ItemId model.
10
+ #
11
+ # Supported formats:
12
+ # - Full: "8pp.t.q7w" → prefix=8pp, marker=t, suffix=q7w
13
+ # - Short: "t.q7w" → prefix=nil, marker=t, suffix=q7w
14
+ # - Suffix: "q7w" → prefix=nil, marker=nil, suffix=q7w
15
+ # - Subtask: "8pp.t.q7w.a" → prefix=8pp, marker=t, suffix=q7w, subtask=a
16
+ # - Raw: "8ppq7w" → prefix=8pp, marker=nil, suffix=q7w (6-char raw b36ts)
17
+ class ItemIdParser
18
+ # Full format: prefix.marker.suffix (e.g., "8pp.t.q7w")
19
+ FULL_PATTERN = /^([0-9a-z]{3})\.([a-z])\.([0-9a-z]{3})$/
20
+
21
+ # Full with subtask: prefix.marker.suffix.subtask (e.g., "8pp.t.q7w.a")
22
+ SUBTASK_PATTERN = /^([0-9a-z]{3})\.([a-z])\.([0-9a-z]{3})\.([0-9a-z])$/
23
+
24
+ # Short format: marker.suffix (e.g., "t.q7w")
25
+ SHORT_PATTERN = /^([a-z])\.([0-9a-z]{3})$/
26
+
27
+ # Suffix-only: 3 chars (e.g., "q7w")
28
+ SUFFIX_PATTERN = /^[0-9a-z]{3}$/
29
+
30
+ # Raw 6-char b36ts (e.g., "8ppq7w")
31
+ RAW_PATTERN = /^[0-9a-z]{6}$/
32
+
33
+ # Parse a reference string into an ItemId
34
+ # @param ref [String] Reference in any supported format
35
+ # @param default_marker [String, nil] Default type marker for ambiguous refs
36
+ # @return [ItemId, nil] Parsed item ID or nil if unparseable
37
+ def self.parse(ref, default_marker: nil)
38
+ return nil if ref.nil? || ref.empty?
39
+
40
+ ref = ref.strip.downcase
41
+
42
+ # Try each pattern in order of specificity
43
+ if (match = ref.match(SUBTASK_PATTERN))
44
+ Models::ItemId.new(
45
+ raw_b36ts: "#{match[1]}#{match[3]}",
46
+ prefix: match[1],
47
+ type_marker: match[2],
48
+ suffix: match[3],
49
+ subtask_char: match[4]
50
+ )
51
+ elsif (match = ref.match(FULL_PATTERN))
52
+ Models::ItemId.new(
53
+ raw_b36ts: "#{match[1]}#{match[3]}",
54
+ prefix: match[1],
55
+ type_marker: match[2],
56
+ suffix: match[3],
57
+ subtask_char: nil
58
+ )
59
+ elsif (match = ref.match(SHORT_PATTERN))
60
+ Models::ItemId.new(
61
+ raw_b36ts: nil,
62
+ prefix: nil,
63
+ type_marker: match[1],
64
+ suffix: match[2],
65
+ subtask_char: nil
66
+ )
67
+ elsif ref.match?(SUFFIX_PATTERN)
68
+ Models::ItemId.new(
69
+ raw_b36ts: nil,
70
+ prefix: nil,
71
+ type_marker: default_marker,
72
+ suffix: ref,
73
+ subtask_char: nil
74
+ )
75
+ elsif ref.match?(RAW_PATTERN)
76
+ Models::ItemId.new(
77
+ raw_b36ts: ref,
78
+ prefix: ref[0..2],
79
+ type_marker: default_marker,
80
+ suffix: ref[3..5],
81
+ subtask_char: nil
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Pure counting logic for item collections.
8
+ # Groups items by a field and computes completion rates.
9
+ class ItemStatistics
10
+ # @param items [Array] Items responding to a field method
11
+ # @param field [Symbol] Field to group by (e.g., :status, :priority, :type)
12
+ # @return [Hash] { total:, by_field: { "value" => count } }
13
+ def self.count_by(items, field)
14
+ result = {total: items.size, by_field: {}}
15
+ items.each do |item|
16
+ value = item.public_send(field).to_s
17
+ result[:by_field][value] ||= 0
18
+ result[:by_field][value] += 1
19
+ end
20
+ result
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Generates B36TS position values for pinning items in sort order.
8
+ # Position values are 6-char B36TS strings that sort lexicographically = chronologically.
9
+ module PositionGenerator
10
+ # Increment/decrement in seconds for before/after positioning.
11
+ # ~2 seconds precision in B36TS, so 4 seconds ensures a distinct value.
12
+ OFFSET_SECONDS = 4
13
+
14
+ # Generate a very early position (sorts before all normal timestamps).
15
+ # Uses a fixed early time to ensure it sorts first.
16
+ # @return [String] 6-char B36TS position
17
+ def self.first
18
+ require "ace/b36ts"
19
+ # Year 2020, Jan 1 — well before any real item creation
20
+ early_time = Time.utc(2020, 1, 1)
21
+ Ace::B36ts.encode(early_time)
22
+ end
23
+
24
+ # Generate a position at current time (sorts after all existing items).
25
+ # @return [String] 6-char B36TS position
26
+ def self.last
27
+ require "ace/b36ts"
28
+ Ace::B36ts.encode(Time.now.utc)
29
+ end
30
+
31
+ # Generate a position just after the given position.
32
+ # @param pos [String] Existing 6-char B36TS position
33
+ # @return [String] 6-char B36TS position slightly after pos
34
+ def self.after(pos)
35
+ require "ace/b36ts"
36
+ time = Ace::B36ts.decode(pos)
37
+ Ace::B36ts.encode(time + OFFSET_SECONDS)
38
+ end
39
+
40
+ # Generate a position just before the given position.
41
+ # @param pos [String] Existing 6-char B36TS position
42
+ # @return [String] 6-char B36TS position slightly before pos
43
+ def self.before(pos)
44
+ require "ace/b36ts"
45
+ time = Ace::B36ts.decode(pos)
46
+ Ace::B36ts.encode(time - OFFSET_SECONDS)
47
+ end
48
+
49
+ # Generate a position between two existing positions.
50
+ # @param a [String] Lower 6-char B36TS position
51
+ # @param b [String] Upper 6-char B36TS position
52
+ # @return [String] 6-char B36TS position between a and b
53
+ def self.between(a, b)
54
+ require "ace/b36ts"
55
+ time_a = Ace::B36ts.decode(a)
56
+ time_b = Ace::B36ts.decode(b)
57
+ midpoint = Time.at((time_a.to_f + time_b.to_f) / 2).utc
58
+ Ace::B36ts.encode(midpoint)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Formats a Time into a human-readable relative string like "2h ago".
8
+ # Pure function — no I/O, fully testable with injected reference time.
9
+ class RelativeTimeFormatter
10
+ SECONDS_PER_MINUTE = 60
11
+ MINUTES_PER_HOUR = 60
12
+ HOURS_PER_DAY = 24
13
+ DAYS_PER_WEEK = 7
14
+ DAYS_PER_MONTH = 30
15
+ MONTHS_PER_YEAR = 12
16
+
17
+ # @param time [Time] The timestamp to format
18
+ # @param reference [Time] The "now" reference point (default: Time.now)
19
+ # @return [String] e.g. "just now", "5m ago", "2h ago", "3d ago", "2w ago", "1mo ago", "1y ago"
20
+ def self.format(time, reference: Time.now)
21
+ return "unknown" if time.nil?
22
+ return "unknown" unless time.is_a?(Time)
23
+
24
+ seconds = (reference - time).to_i
25
+ return "just now" if seconds < SECONDS_PER_MINUTE
26
+
27
+ minutes = seconds / SECONDS_PER_MINUTE
28
+ return "#{minutes}m ago" if minutes < MINUTES_PER_HOUR
29
+
30
+ hours = minutes / MINUTES_PER_HOUR
31
+ return "#{hours}h ago" if hours < HOURS_PER_DAY
32
+
33
+ days = hours / HOURS_PER_DAY
34
+ return "#{days}d ago" if days < DAYS_PER_WEEK
35
+
36
+ return "#{days / DAYS_PER_WEEK}w ago" if days < DAYS_PER_MONTH
37
+
38
+ months = days / DAYS_PER_MONTH
39
+ return "#{months}mo ago" if months < MONTHS_PER_YEAR
40
+
41
+ years = months / MONTHS_PER_YEAR
42
+ "#{years}y ago"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # SlugSanitizer provides strict kebab-case slug sanitization for filesystem safety.
8
+ # Ensures consistent slug handling across the codebase.
9
+ #
10
+ # Features:
11
+ # - Removes path traversal characters (dots, slashes, backslashes)
12
+ # - Enforces lowercase, numbers, and hyphens only
13
+ # - Collapses multiple hyphens and trims leading/trailing hyphens
14
+ # - Returns empty string for entirely invalid input (caller should handle fallback)
15
+ class SlugSanitizer
16
+ MAX_LENGTH = 55
17
+
18
+ # Sanitize a slug string to strict kebab-case.
19
+ #
20
+ # @param slug [String, nil] The slug to sanitize
21
+ # @param max_length [Integer] Maximum length for the slug (default: MAX_LENGTH)
22
+ # @return [String] Sanitized slug (empty string if input is nil or entirely invalid)
23
+ #
24
+ # @example
25
+ # SlugSanitizer.sanitize("My Topic-Slug")
26
+ # # => "my-topic-slug"
27
+ #
28
+ # SlugSanitizer.sanitize("../../etc/passwd")
29
+ # # => "etc-passwd"
30
+ #
31
+ # SlugSanitizer.sanitize("../")
32
+ # # => "" (empty - caller should use fallback)
33
+ def self.sanitize(slug, max_length: MAX_LENGTH)
34
+ return "" if slug.nil? || slug.empty?
35
+
36
+ # Remove any characters that could enable path traversal: dots, slashes, backslashes
37
+ # Then validate against allowed pattern (lowercase, numbers, hyphens only)
38
+ cleaned = slug.to_s.gsub(/[.\\\/]/, "").strip
39
+ # Further sanitize to only allowed characters (lowercase letters, numbers, hyphens)
40
+ result = cleaned.downcase.gsub(/[^a-z0-9-]/, "-").squeeze("-").gsub(/^-|-$/, "")
41
+ truncate_at_word_boundary(result, max_length)
42
+ end
43
+
44
+ # Truncate at word boundary (last hyphen before max_length) to avoid mid-word cuts.
45
+ #
46
+ # @param result [String] The sanitized slug
47
+ # @param max_length [Integer] Maximum allowed length
48
+ # @return [String] Truncated slug
49
+ def self.truncate_at_word_boundary(result, max_length)
50
+ return result if result.length <= max_length
51
+
52
+ truncated = result[0...max_length]
53
+ # Find last hyphen to avoid cutting mid-word
54
+ last_hyphen = truncated.rindex("-")
55
+ if last_hyphen && last_hyphen > 0
56
+ truncated[0...last_hyphen]
57
+ else
58
+ truncated
59
+ end
60
+ end
61
+
62
+ private_class_method :truncate_at_word_boundary
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Pure function computing sort scores for smart auto-sort.
8
+ # Score formula: priority_weight × 100 + age_days (capped)
9
+ # with status-based modifiers for in-progress boost and blocked penalty.
10
+ module SortScoreCalculator
11
+ DEFAULT_PRIORITY_WEIGHTS = {
12
+ "critical" => 4,
13
+ "high" => 3,
14
+ "medium" => 2,
15
+ "low" => 1
16
+ }.freeze
17
+
18
+ # Compute a sort score for an item.
19
+ # @param priority_weight [Numeric] Weight for the item's priority level
20
+ # @param age_days [Numeric] Days since creation
21
+ # @param status [String, nil] Item status for boost/penalty
22
+ # @param in_progress_boost [Numeric] Added to score for in-progress items
23
+ # @param blocked_factor [Numeric] Score multiplied by this for blocked items
24
+ # @param age_cap [Numeric] Maximum age_days value used in calculation
25
+ # @return [Float] Computed sort score
26
+ def self.compute(priority_weight:, age_days:, status: nil,
27
+ in_progress_boost: 1000, blocked_factor: 0.1, age_cap: 90)
28
+ capped_age = [age_days.to_f, age_cap.to_f].min
29
+ score = priority_weight.to_f * 100 + capped_age
30
+
31
+ case status
32
+ when "in-progress"
33
+ score + in_progress_boost
34
+ when "blocked"
35
+ score * blocked_factor
36
+ else
37
+ score
38
+ end
39
+ end
40
+
41
+ # Look up the priority weight for a named priority.
42
+ # @param priority [String, nil] Priority name (critical, high, medium, low)
43
+ # @param weights [Hash] Custom weight mapping
44
+ # @return [Numeric] Weight value (defaults to 0 for unknown priorities)
45
+ def self.priority_weight(priority, weights: DEFAULT_PRIORITY_WEIGHTS)
46
+ return 0 unless priority
47
+
48
+ weights[priority.to_s.downcase] || 0
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ace
6
+ module Support
7
+ module Items
8
+ module Atoms
9
+ # Detects and normalizes "special" folder names used for item organization.
10
+ # Special folders use a configurable prefix convention (default: "_").
11
+ # Short names (without prefix) are auto-expanded: "archive" => "_archive".
12
+ class SpecialFolderDetector
13
+ # Default prefix for special folders (single source of truth)
14
+ DEFAULT_PREFIX = "_"
15
+
16
+ # Virtual filters — not physical folders, used for list filtering
17
+ VIRTUAL_FILTERS = {"next" => :next, "all" => :all}.freeze
18
+
19
+ # Aliases that mean "move back to root" (no special folder).
20
+ # "next" is the primary label; "root" and "/" are convenience aliases.
21
+ MOVE_TO_ROOT_ALIASES = %w[next root /].freeze
22
+
23
+ # Check if a name means "move to root" (out of any special folder).
24
+ # @param name [String] The name to check
25
+ # @return [Boolean] True if the name is a move-to-root alias
26
+ def self.move_to_root?(name)
27
+ return false if name.nil? || name.empty?
28
+
29
+ MOVE_TO_ROOT_ALIASES.include?(name.downcase)
30
+ end
31
+
32
+ # Check if a name is a virtual filter
33
+ # @param name [String] The name to check
34
+ # @return [Symbol, nil] :next, :all, or nil
35
+ def self.virtual_filter?(name)
36
+ return nil if name.nil? || name.empty?
37
+
38
+ VIRTUAL_FILTERS[name.downcase]
39
+ end
40
+
41
+ # Detect if a folder name is a special folder
42
+ # @param folder_name [String] The folder name to check
43
+ # @param prefix [String] The prefix that marks special folders
44
+ # @return [Boolean] True if it's a special folder
45
+ def self.special?(folder_name, prefix: DEFAULT_PREFIX)
46
+ return false if folder_name.nil? || folder_name.empty?
47
+
48
+ folder_name.start_with?(prefix)
49
+ end
50
+
51
+ # Normalize a folder name to its canonical form
52
+ # Short names are expanded: "archive" => "_archive"
53
+ # Already-prefixed names are returned as-is
54
+ # Virtual filters are returned as-is (not expanded)
55
+ # @param folder_name [String] The folder name to normalize
56
+ # @param prefix [String] The prefix to prepend for expansion
57
+ # @return [String] Canonical folder name
58
+ def self.normalize(folder_name, prefix: DEFAULT_PREFIX)
59
+ return folder_name if folder_name.nil? || folder_name.empty?
60
+ return folder_name if folder_name.start_with?(prefix)
61
+ return folder_name if VIRTUAL_FILTERS.key?(folder_name.downcase)
62
+ return folder_name if folder_name.include?(File::SEPARATOR) || folder_name.include?("..")
63
+
64
+ "#{prefix}#{folder_name}"
65
+ end
66
+
67
+ # Strip the special folder prefix to get the short display name
68
+ # @param folder_name [String] The folder name (e.g. "_archive")
69
+ # @param prefix [String] The prefix to strip
70
+ # @return [String] Short name (e.g. "archive")
71
+ def self.short_name(folder_name, prefix: DEFAULT_PREFIX)
72
+ return folder_name if folder_name.nil? || folder_name.empty?
73
+
74
+ folder_name.delete_prefix(prefix)
75
+ end
76
+
77
+ # Extract the special folder from a path (if any)
78
+ # Returns the first path component that is a special folder
79
+ # @param path [String] File path to inspect
80
+ # @param root [String] Root path to make path relative
81
+ # @param prefix [String] The prefix that marks special folders
82
+ # @return [String, nil] Special folder name or nil
83
+ def self.detect_in_path(path, root: nil, prefix: DEFAULT_PREFIX)
84
+ check_path = if root
85
+ begin
86
+ Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
87
+ rescue ArgumentError
88
+ path
89
+ end
90
+ else
91
+ path
92
+ end
93
+
94
+ parts = check_path.split(File::SEPARATOR).reject(&:empty?)
95
+ parts.find { |part| special?(part, prefix: prefix) }
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ansi_colors"
4
+
5
+ module Ace
6
+ module Support
7
+ module Items
8
+ module Atoms
9
+ # Generic stats line builder for item lists.
10
+ # Produces a summary like: "Tasks: ○ 3 | ▶ 1 | ✓ 5 • 3 of 660"
11
+ class StatsLineFormatter
12
+ # @param label [String] e.g. "Tasks", "Ideas", "Retros"
13
+ # @param stats [Hash] Output of ItemStatistics.count_by (by :status)
14
+ # @param status_order [Array<String>] Ordered status keys to display
15
+ # @param status_icons [Hash<String,String>] Status → icon mapping
16
+ # @param folder_stats [Hash, nil] Output of ItemStatistics.count_by (by :special_folder)
17
+ # @param total_count [Integer, nil] Total items before folder filtering (enables "X of Y" display)
18
+ # @param global_folder_stats [Hash, nil] Folder name → count hash from full scan (always shown)
19
+ # @return [String] e.g. "Tasks: ○ 3 | ▶ 1 | ✓ 5 • 3 of 660"
20
+ def self.format(label:, stats:, status_order:, status_icons:, folder_stats: nil, total_count: nil, global_folder_stats: nil)
21
+ parts = []
22
+ status_order.each do |status|
23
+ count = stats[:by_field][status] || 0
24
+ next if count == 0
25
+
26
+ icon = status_icons[status] || status
27
+ parts << "#{icon} #{count}"
28
+ end
29
+
30
+ # Catch any statuses not in status_order (unknown/unexpected)
31
+ stats[:by_field].each do |status, count|
32
+ next if count == 0 || status_order.include?(status)
33
+
34
+ icon = status_icons[status] || status
35
+ parts << "#{icon} #{count}"
36
+ end
37
+
38
+ line = "#{label}: #{parts.join(" | ")}"
39
+
40
+ shown = stats[:total]
41
+ total = total_count || shown
42
+
43
+ if shown < total
44
+ # Filtered view: show ratio
45
+ line += " \u2022 #{shown} of #{total}"
46
+ else
47
+ # Full view: show total
48
+ line += " \u2022 #{shown} total"
49
+ # Inline folder breakdown from current results (only when unfiltered
50
+ # and global_folder_stats not provided — global takes precedence)
51
+ if !global_folder_stats && folder_stats && folder_stats[:by_field].size > 1
52
+ folder_parts = folder_stats[:by_field]
53
+ .sort_by { |_, count| -count }
54
+ .map { |folder, count| "#{folder_label(folder)} #{count}" }
55
+ suffix = " \u2014 #{folder_parts.join(" | ")}"
56
+ line += AnsiColors.colorize(suffix, AnsiColors::DIM)
57
+ end
58
+ end
59
+
60
+ # Global folder breakdown (always shown when provided and multi-folder)
61
+ if global_folder_stats && global_folder_stats.size > 1
62
+ global_parts = global_folder_stats
63
+ .sort_by { |_, count| -count }
64
+ .map { |folder, count| "#{folder_label(folder)} #{count}" }
65
+ suffix = " \u2014 #{global_parts.join(" | ")}"
66
+ line += AnsiColors.colorize(suffix, AnsiColors::DIM)
67
+ end
68
+
69
+ line
70
+ end
71
+
72
+ # Map special_folder values to display labels.
73
+ # nil (root items) renders as "next".
74
+ def self.folder_label(folder)
75
+ return "next" if folder.nil? || folder.empty? || folder == ""
76
+
77
+ SpecialFolderDetector.short_name(folder)
78
+ end
79
+
80
+ private_class_method :folder_label
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Atoms
7
+ # Extracts the first H1 heading from markdown body content.
8
+ class TitleExtractor
9
+ # Extract title from the first `# H1` heading in body content
10
+ # @param body [String] Markdown body content
11
+ # @return [String, nil] Extracted title or nil if none found
12
+ def self.extract(body)
13
+ return nil if body.nil? || body.empty?
14
+
15
+ match = body.match(/^#\s+(.+)$/)
16
+ match ? match[1].strip : nil
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end