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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +174 -0
- data/README.md +29 -0
- data/Rakefile +13 -0
- data/lib/ace/support/items/atoms/ansi_colors.rb +37 -0
- data/lib/ace/support/items/atoms/date_partition_path.rb +24 -0
- data/lib/ace/support/items/atoms/field_argument_parser.rb +107 -0
- data/lib/ace/support/items/atoms/filter_parser.rb +79 -0
- data/lib/ace/support/items/atoms/folder_completion_detector.rb +40 -0
- data/lib/ace/support/items/atoms/frontmatter_parser.rb +57 -0
- data/lib/ace/support/items/atoms/frontmatter_serializer.rb +81 -0
- data/lib/ace/support/items/atoms/item_id_formatter.rb +81 -0
- data/lib/ace/support/items/atoms/item_id_parser.rb +89 -0
- data/lib/ace/support/items/atoms/item_statistics.rb +26 -0
- data/lib/ace/support/items/atoms/position_generator.rb +64 -0
- data/lib/ace/support/items/atoms/relative_time_formatter.rb +48 -0
- data/lib/ace/support/items/atoms/slug_sanitizer.rb +67 -0
- data/lib/ace/support/items/atoms/sort_score_calculator.rb +54 -0
- data/lib/ace/support/items/atoms/special_folder_detector.rb +101 -0
- data/lib/ace/support/items/atoms/stats_line_formatter.rb +85 -0
- data/lib/ace/support/items/atoms/title_extractor.rb +22 -0
- data/lib/ace/support/items/models/item_id.rb +55 -0
- data/lib/ace/support/items/models/loaded_document.rb +28 -0
- data/lib/ace/support/items/models/scan_result.rb +32 -0
- data/lib/ace/support/items/molecules/base_formatter.rb +51 -0
- data/lib/ace/support/items/molecules/directory_scanner.rb +106 -0
- data/lib/ace/support/items/molecules/document_loader.rb +77 -0
- data/lib/ace/support/items/molecules/field_updater.rb +120 -0
- data/lib/ace/support/items/molecules/filter_applier.rb +117 -0
- data/lib/ace/support/items/molecules/folder_mover.rb +91 -0
- data/lib/ace/support/items/molecules/git_committer.rb +21 -0
- data/lib/ace/support/items/molecules/item_sorter.rb +73 -0
- data/lib/ace/support/items/molecules/llm_slug_generator.rb +264 -0
- data/lib/ace/support/items/molecules/shortcut_resolver.rb +87 -0
- data/lib/ace/support/items/molecules/smart_sorter.rb +39 -0
- data/lib/ace/support/items/molecules/status_categorizer.rb +60 -0
- data/lib/ace/support/items/version.rb +9 -0
- data/lib/ace/support/items.rb +50 -0
- 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
|