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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Models
7
+ # Value object representing a parsed item ID with type marker.
8
+ #
9
+ # A 6-char b36ts ID (e.g., "8ppq7w") can be split into a type-marked format:
10
+ # prefix (3 chars) + type_marker (e.g., ".t.") + suffix (3 chars)
11
+ # => "8pp.t.q7w"
12
+ #
13
+ # Subtasks append a single char: "8pp.t.q7w.a"
14
+ ItemId = Struct.new(
15
+ :raw_b36ts, # Original 6-char b36ts ID (e.g., "8ppq7w")
16
+ :prefix, # First 3 chars (e.g., "8pp")
17
+ :type_marker, # Type marker string (e.g., "t", "i")
18
+ :suffix, # Last 3 chars (e.g., "q7w")
19
+ :subtask_char, # Optional single subtask character (e.g., "a"), nil if none
20
+ keyword_init: true
21
+ ) do
22
+ # Full formatted ID with type marker (e.g., "8pp.t.q7w")
23
+ # @return [String]
24
+ def formatted_id
25
+ base = "#{prefix}.#{type_marker}.#{suffix}"
26
+ subtask_char ? "#{base}.#{subtask_char}" : base
27
+ end
28
+
29
+ # Full formatted ID (alias for formatted_id)
30
+ # @return [String]
31
+ def full_id
32
+ formatted_id
33
+ end
34
+
35
+ # Whether this represents a subtask
36
+ # @return [Boolean]
37
+ def subtask?
38
+ !subtask_char.nil?
39
+ end
40
+
41
+ def to_h
42
+ {
43
+ raw_b36ts: raw_b36ts,
44
+ prefix: prefix,
45
+ type_marker: type_marker,
46
+ suffix: suffix,
47
+ subtask_char: subtask_char,
48
+ formatted_id: formatted_id
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Models
7
+ # Value object representing a loaded document with parsed frontmatter,
8
+ # body content, title, and file metadata.
9
+ LoadedDocument = Struct.new(
10
+ :frontmatter, # Hash - parsed YAML frontmatter
11
+ :body, # String - document body after frontmatter
12
+ :title, # String - from frontmatter["title"], H1 heading, or folder name
13
+ :file_path, # String - path to spec file
14
+ :dir_path, # String - path to item directory
15
+ :attachments, # Array<String> - non-spec filenames in directory
16
+ keyword_init: true
17
+ ) do
18
+ # Access frontmatter values by key (string or symbol)
19
+ # @param key [String, Symbol] Frontmatter key
20
+ # @return [Object, nil] Value or nil
21
+ def [](key)
22
+ frontmatter[key.to_s] || frontmatter[key.to_sym]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Models
7
+ # Value object representing a scan result for an item directory
8
+ # Holds path information, raw ID, and folder metadata
9
+ ScanResult = Struct.new(
10
+ :id, # Raw 6-char b36ts ID (e.g., "8ppq7w")
11
+ :slug, # Folder slug without ID (e.g., "dark-mode-support")
12
+ :folder_name, # Full folder name (e.g., "8ppq7w-dark-mode-support")
13
+ :dir_path, # Full path to item directory
14
+ :file_path, # Full path to item spec file
15
+ :special_folder, # Special folder name (e.g., "_maybe", nil if none)
16
+ keyword_init: true
17
+ ) do
18
+ def to_h
19
+ {
20
+ id: id,
21
+ slug: slug,
22
+ folder_name: folder_name,
23
+ dir_path: dir_path,
24
+ file_path: file_path,
25
+ special_folder: special_folder
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Molecules
7
+ # Minimal default formatter for item display.
8
+ # Gems can override with their own formatter for richer output.
9
+ class BaseFormatter
10
+ # Format a single item for display
11
+ # @param item [Object] Item with id/title (LoadedDocument, ScanResult, etc.)
12
+ # @param scan_result [ScanResult, nil] Optional scan result for ID fallback
13
+ # @return [String] Formatted line
14
+ def self.format_item(item, scan_result: nil)
15
+ id = resolve_id(item, scan_result)
16
+ title = resolve_title(item)
17
+
18
+ "#{id} #{title}"
19
+ end
20
+
21
+ # Format a list of items
22
+ # @param items [Array] Items to format
23
+ # @return [String] Formatted list
24
+ def self.format_list(items)
25
+ return "No items found." if items.nil? || items.empty?
26
+
27
+ items.map { |item| format_item(item) }.join("\n")
28
+ end
29
+
30
+ private_class_method def self.resolve_id(item, scan_result)
31
+ return scan_result.id if scan_result&.id
32
+ return item.frontmatter["id"] if item.respond_to?(:frontmatter) && item.frontmatter.is_a?(Hash)
33
+ return item[:id] || item["id"] if item.respond_to?(:[])
34
+ return item.id if item.respond_to?(:id)
35
+ "?"
36
+ rescue
37
+ "?"
38
+ end
39
+
40
+ private_class_method def self.resolve_title(item)
41
+ return item.title if item.respond_to?(:title) && item.title
42
+ return item[:title] || item["title"] if item.respond_to?(:[])
43
+ "Untitled"
44
+ rescue
45
+ "Untitled"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../models/scan_result"
5
+ require_relative "../atoms/special_folder_detector"
6
+
7
+ module Ace
8
+ module Support
9
+ module Items
10
+ module Molecules
11
+ # Recursively scans an item root directory for item spec files.
12
+ # Returns ScanResult objects with folder and ID metadata.
13
+ #
14
+ # Item directories follow the convention: {id}-{slug}/
15
+ # Item spec files match a configurable glob pattern within those directories.
16
+ #
17
+ # The id_extractor proc allows customizing how IDs are extracted from folder names.
18
+ # Default: matches 6-char b36ts IDs (e.g., "8ppq7w-dark-mode")
19
+ # Custom: can match type-marked IDs (e.g., "8pp.t.q7w-fix-login")
20
+ class DirectoryScanner
21
+ # Default extractor for 6-char raw b36ts IDs
22
+ DEFAULT_ID_EXTRACTOR = ->(folder_name) {
23
+ match = folder_name.match(/^([0-9a-z]{6})-?(.*)$/)
24
+ return nil unless match
25
+
26
+ id = match[1]
27
+ slug = match[2].empty? ? folder_name : match[2]
28
+ [id, slug]
29
+ }
30
+
31
+ # @param root_dir [String] Root directory to scan
32
+ # @param file_pattern [String] Glob pattern for spec files (e.g., "*.idea.s.md")
33
+ # @param id_extractor [Proc, nil] Custom proc to extract [id, slug] from folder name.
34
+ # Receives folder_name string, returns [id, slug] array or nil if no match.
35
+ def initialize(root_dir, file_pattern:, id_extractor: nil)
36
+ @root_dir = root_dir
37
+ @file_pattern = file_pattern
38
+ @id_extractor = id_extractor || DEFAULT_ID_EXTRACTOR
39
+ end
40
+
41
+ # Scan root directory recursively for items
42
+ # @return [Array<ScanResult>] List of scan results, sorted by ID (chronological)
43
+ def scan
44
+ return [] unless Dir.exist?(@root_dir)
45
+
46
+ results = []
47
+ scan_directory(@root_dir, results)
48
+ results.sort_by(&:id)
49
+ end
50
+
51
+ private
52
+
53
+ def scan_directory(dir, results)
54
+ Dir.entries(dir).sort.each do |entry|
55
+ next if entry.start_with?(".")
56
+
57
+ full_path = File.join(dir, entry)
58
+ next unless File.directory?(full_path)
59
+
60
+ # Check if this directory contains spec files
61
+ spec_files = Dir.glob(File.join(full_path, @file_pattern))
62
+ if spec_files.any?
63
+ result = build_result(full_path, spec_files.first)
64
+ if result
65
+ results << result
66
+ else
67
+ # Folder has spec files but isn't an item (e.g., _maybe with orphan files) — recurse
68
+ scan_directory(full_path, results)
69
+ end
70
+ else
71
+ # Recurse into subdirectory (for special folders like _maybe/)
72
+ scan_directory(full_path, results)
73
+ end
74
+ end
75
+ end
76
+
77
+ def build_result(dir_path, file_path)
78
+ folder_name = File.basename(dir_path)
79
+
80
+ # Extract ID from folder name: "8ppq7w-dark-mode" => id="8ppq7w", slug="dark-mode"
81
+ id, slug = extract_id_and_slug(folder_name)
82
+ return nil unless id
83
+
84
+ # Detect special folder (e.g., _maybe, _archive)
85
+ special_folder = Atoms::SpecialFolderDetector.detect_in_path(dir_path, root: @root_dir)
86
+
87
+ Models::ScanResult.new(
88
+ id: id,
89
+ slug: slug,
90
+ folder_name: folder_name,
91
+ dir_path: dir_path,
92
+ file_path: file_path,
93
+ special_folder: special_folder
94
+ )
95
+ end
96
+
97
+ # Extract ID and slug from folder name using the configured extractor
98
+ # @return [Array<String, String>, nil] [id, slug] or nil if pattern doesn't match
99
+ def extract_id_and_slug(folder_name)
100
+ @id_extractor.call(folder_name)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/frontmatter_parser"
4
+ require_relative "../atoms/title_extractor"
5
+ require_relative "../models/loaded_document"
6
+
7
+ module Ace
8
+ module Support
9
+ module Items
10
+ module Molecules
11
+ # Loads a document from an item directory: finds the spec file,
12
+ # parses frontmatter/body, extracts title, and enumerates attachments.
13
+ class DocumentLoader
14
+ # Load a document from a directory path
15
+ # @param dir_path [String] Path to item directory
16
+ # @param file_pattern [String] Glob pattern for spec files (e.g., "*.idea.s.md")
17
+ # @param spec_extension [String] Extension to exclude from attachments (e.g., ".idea.s.md")
18
+ # @return [LoadedDocument, nil] Loaded document or nil if not found
19
+ def self.load(dir_path, file_pattern:, spec_extension:)
20
+ return nil unless Dir.exist?(dir_path)
21
+
22
+ spec_file = Dir.glob(File.join(dir_path, file_pattern)).first
23
+ return nil unless spec_file
24
+
25
+ build_document(dir_path, spec_file, spec_extension)
26
+ end
27
+
28
+ # Load a document from a ScanResult
29
+ # @param scan_result [ScanResult] Scan result with dir_path and file_path
30
+ # @param file_pattern [String] Glob pattern (unused when file_path present, kept for API consistency)
31
+ # @param spec_extension [String] Extension to exclude from attachments
32
+ # @return [LoadedDocument, nil] Loaded document or nil
33
+ def self.from_scan_result(scan_result, spec_extension:, file_pattern: nil)
34
+ return nil unless scan_result&.dir_path && Dir.exist?(scan_result.dir_path)
35
+
36
+ spec_file = scan_result.file_path || Dir.glob(File.join(scan_result.dir_path, file_pattern)).first
37
+ return nil unless spec_file
38
+
39
+ build_document(scan_result.dir_path, spec_file, spec_extension)
40
+ end
41
+
42
+ # Build a LoadedDocument from directory and spec file
43
+ private_class_method def self.build_document(dir_path, spec_file, spec_extension)
44
+ content = File.read(spec_file)
45
+ frontmatter, body = Atoms::FrontmatterParser.parse(content)
46
+
47
+ folder_name = File.basename(dir_path)
48
+ title = frontmatter["title"] ||
49
+ Atoms::TitleExtractor.extract(body) ||
50
+ folder_name
51
+
52
+ attachments = enumerate_attachments(dir_path, spec_extension)
53
+
54
+ Models::LoadedDocument.new(
55
+ frontmatter: frontmatter,
56
+ body: body,
57
+ title: title,
58
+ file_path: spec_file,
59
+ dir_path: dir_path,
60
+ attachments: attachments
61
+ )
62
+ end
63
+
64
+ # List non-spec files in directory (excluding hidden files)
65
+ private_class_method def self.enumerate_attachments(dir_path, spec_extension)
66
+ Dir.glob(File.join(dir_path, "*"))
67
+ .select { |f| File.file?(f) }
68
+ .reject { |f| f.end_with?(spec_extension) }
69
+ .map { |f| File.basename(f) }
70
+ .reject { |name| name.start_with?(".") }
71
+ .sort
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../atoms/frontmatter_parser"
5
+ require_relative "../atoms/frontmatter_serializer"
6
+
7
+ module Ace
8
+ module Support
9
+ module Items
10
+ module Molecules
11
+ # Orchestrates --set/--add/--remove field updates on frontmatter files.
12
+ # Handles nested dot-key paths for --set operations.
13
+ # Writes atomically using temp file + rename.
14
+ class FieldUpdater
15
+ # Update frontmatter fields in a spec file.
16
+ #
17
+ # @param file_path [String] Path to the spec file
18
+ # @param set [Hash] Fields to set (key => value). Supports dot-notation for nested keys.
19
+ # @param add [Hash] Fields to append to arrays (key => value or array of values).
20
+ # @param remove [Hash] Fields to remove from arrays (key => value or array of values).
21
+ # @return [Hash] Updated frontmatter hash
22
+ def self.update(file_path, set: {}, add: {}, remove: {})
23
+ content = File.read(file_path)
24
+ frontmatter, body = Atoms::FrontmatterParser.parse(content)
25
+ # Strip leading newline from body so rebuild doesn't double-space
26
+ body = body.sub(/\A\n/, "")
27
+
28
+ apply_set(frontmatter, set)
29
+ apply_add(frontmatter, add)
30
+ apply_remove(frontmatter, remove)
31
+
32
+ new_content = Atoms::FrontmatterSerializer.rebuild(frontmatter, body)
33
+ atomic_write(file_path, new_content)
34
+
35
+ frontmatter
36
+ end
37
+
38
+ # Apply --set operations (supports nested dot-key paths).
39
+ # @param frontmatter [Hash] Frontmatter hash (mutated in place)
40
+ # @param set [Hash] Key-value pairs to set
41
+ def self.apply_set(frontmatter, set)
42
+ return if set.nil? || set.empty?
43
+
44
+ set.each do |key, value|
45
+ key_str = key.to_s
46
+ if key_str.include?(".")
47
+ apply_nested_set(frontmatter, key_str, value)
48
+ else
49
+ frontmatter[key_str] = value
50
+ end
51
+ end
52
+ end
53
+
54
+ # Apply --add operations (append to arrays).
55
+ # If the existing value is a scalar, coerces it to an array first.
56
+ # @param frontmatter [Hash] Frontmatter hash (mutated in place)
57
+ # @param add [Hash] Key-value pairs to add
58
+ def self.apply_add(frontmatter, add)
59
+ return if add.nil? || add.empty?
60
+
61
+ add.each do |key, value|
62
+ key_str = key.to_s
63
+ current = frontmatter[key_str]
64
+ values_to_add = Array(value)
65
+
66
+ frontmatter[key_str] = (Array(current) + values_to_add).uniq
67
+ end
68
+ end
69
+
70
+ # Apply --remove operations (remove from arrays).
71
+ # @param frontmatter [Hash] Frontmatter hash (mutated in place)
72
+ # @param remove [Hash] Key-value pairs to remove
73
+ # @raise [ArgumentError] If target field is not an array
74
+ def self.apply_remove(frontmatter, remove)
75
+ return if remove.nil? || remove.empty?
76
+
77
+ remove.each do |key, value|
78
+ key_str = key.to_s
79
+ current = frontmatter[key_str]
80
+ next if current.nil?
81
+
82
+ unless current.is_a?(Array)
83
+ raise ArgumentError, "Cannot remove from non-array field '#{key_str}' (is #{current.class})"
84
+ end
85
+
86
+ values_to_remove = Array(value)
87
+ frontmatter[key_str] = current - values_to_remove
88
+ end
89
+ end
90
+
91
+ # Navigate nested dot-key path and set value.
92
+ # "update.last-updated" => frontmatter["update"]["last-updated"] = value
93
+ def self.apply_nested_set(frontmatter, key_path, value)
94
+ parts = key_path.split(".")
95
+ target = frontmatter
96
+
97
+ parts[0...-1].each do |part|
98
+ target[part] ||= {}
99
+ target = target[part]
100
+ end
101
+
102
+ target[parts.last] = value
103
+ end
104
+
105
+ # Write content atomically using temp file + rename.
106
+ def self.atomic_write(file_path, content)
107
+ tmp_path = "#{file_path}.tmp.#{Process.pid}"
108
+ File.write(tmp_path, content)
109
+ File.rename(tmp_path, file_path)
110
+ rescue
111
+ File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
112
+ raise
113
+ end
114
+
115
+ private_class_method :apply_nested_set, :atomic_write
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Items
6
+ module Molecules
7
+ # Applies parsed filter specifications to collections of items.
8
+ # Supports AND/OR operations, negation, array matching, and
9
+ # a configurable value accessor for any object type.
10
+ class FilterApplier
11
+ # Apply filter specifications to items
12
+ # @param items [Array] Items to filter
13
+ # @param filter_specs [Array<Hash>] Filter specifications from FilterParser
14
+ # @param value_accessor [Proc, nil] Custom accessor: ->(item, key) { value }
15
+ # @return [Array] Filtered items
16
+ def self.apply(items, filter_specs, value_accessor: nil)
17
+ return items if filter_specs.nil? || filter_specs.empty?
18
+ return [] if items.nil? || items.empty?
19
+
20
+ accessor = value_accessor || method(:default_value_accessor)
21
+
22
+ items.select do |item|
23
+ filter_specs.all? { |spec| matches_filter?(item, spec, accessor) }
24
+ end
25
+ end
26
+
27
+ # Check if item matches a single filter specification
28
+ private_class_method def self.matches_filter?(item, filter_spec, accessor)
29
+ key = filter_spec[:key]
30
+ values = filter_spec[:values]
31
+ negated = filter_spec[:negated]
32
+ or_mode = filter_spec[:or_mode]
33
+
34
+ item_value = accessor.call(item, key)
35
+
36
+ match = if item_value.is_a?(Array)
37
+ matches_array?(item_value, values, or_mode)
38
+ else
39
+ matches_value?(item_value, values, or_mode)
40
+ end
41
+
42
+ negated ? !match : match
43
+ end
44
+
45
+ # Check if array contains any of the filter values
46
+ private_class_method def self.matches_array?(item_array, filter_values, or_mode)
47
+ array_strings = item_array.map { |v| normalize_value(v) }
48
+
49
+ if or_mode
50
+ filter_values.any? { |fv| array_strings.include?(normalize_value(fv)) }
51
+ else
52
+ array_strings.include?(normalize_value(filter_values.first))
53
+ end
54
+ end
55
+
56
+ # Check if value matches any of the filter values
57
+ private_class_method def self.matches_value?(item_value, filter_values, or_mode)
58
+ normalized_item = normalize_value(item_value)
59
+
60
+ if or_mode
61
+ filter_values.any? { |fv| normalized_item == normalize_value(fv) }
62
+ else
63
+ normalized_item == normalize_value(filter_values.first)
64
+ end
65
+ end
66
+
67
+ # Normalize value for comparison (case-insensitive, trimmed)
68
+ private_class_method def self.normalize_value(value)
69
+ return "" if value.nil?
70
+ value.to_s.strip.downcase
71
+ end
72
+
73
+ # Default value accessor: tries hash keys, methods, frontmatter, metadata
74
+ private_class_method def self.default_value_accessor(item, key)
75
+ # Hash-like access (symbol then string)
76
+ if item.respond_to?(:[])
77
+ val = begin
78
+ item[key.to_sym]
79
+ rescue
80
+ nil
81
+ end
82
+ return val unless val.nil?
83
+
84
+ val = begin
85
+ item[key.to_s]
86
+ rescue
87
+ nil
88
+ end
89
+ return val unless val.nil?
90
+ end
91
+
92
+ # Method access
93
+ if item.respond_to?(key.to_sym)
94
+ return item.send(key.to_sym)
95
+ end
96
+
97
+ # Frontmatter access
98
+ if item.respond_to?(:frontmatter) && item.frontmatter.is_a?(Hash)
99
+ val = item.frontmatter[key.to_s] || item.frontmatter[key.to_sym]
100
+ return val unless val.nil?
101
+ end
102
+
103
+ # Metadata access (for taskflow-style items)
104
+ if item.respond_to?(:metadata) && item.metadata.is_a?(Hash)
105
+ return item.metadata[key.to_s] || item.metadata[key.to_sym]
106
+ end
107
+
108
+ # Nested metadata in hash
109
+ if item.respond_to?(:dig)
110
+ item.dig(:metadata, key) || item.dig(:metadata, key.to_sym)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../atoms/special_folder_detector"
5
+ require_relative "../atoms/date_partition_path"
6
+
7
+ module Ace
8
+ module Support
9
+ module Items
10
+ module Molecules
11
+ # Generic folder mover for item directories.
12
+ # Handles special folder name normalization, archive date partitioning,
13
+ # and cross-filesystem atomic moves.
14
+ class FolderMover
15
+ # @param root_dir [String] Root directory for items
16
+ def initialize(root_dir)
17
+ @root_dir = root_dir
18
+ end
19
+
20
+ # Move an item folder to a target location.
21
+ #
22
+ # @param item [#path] Item with a path attribute (directory to move)
23
+ # @param to [String] Target folder name (short or full, e.g., "maybe", "_archive")
24
+ # @param date [Time, nil] Date used to compute archive partition (default: Time.now)
25
+ # @return [String] New path of the item directory
26
+ def move(item, to:, date: nil)
27
+ if Atoms::SpecialFolderDetector.virtual_filter?(to)
28
+ raise ArgumentError, "Cannot move to virtual filter '#{to}' — it is not a physical folder"
29
+ end
30
+
31
+ normalized = Atoms::SpecialFolderDetector.normalize(to)
32
+
33
+ target_parent = if normalized == "_archive"
34
+ partition = Atoms::DatePartitionPath.compute(date || Time.now)
35
+ File.expand_path(File.join(@root_dir, normalized, partition))
36
+ else
37
+ File.expand_path(File.join(@root_dir, normalized))
38
+ end
39
+
40
+ validate_path_traversal!(target_parent)
41
+ FileUtils.mkdir_p(target_parent)
42
+
43
+ folder_name = File.basename(item.path)
44
+ new_path = File.join(target_parent, folder_name)
45
+
46
+ # Same-location no-op check
47
+ return item.path if File.expand_path(item.path) == File.expand_path(new_path)
48
+
49
+ atomic_move(item.path, new_path)
50
+ end
51
+
52
+ # Move an item back to root (remove from special folder).
53
+ #
54
+ # @param item [#path] Item with a path attribute
55
+ # @return [String] New path of the item directory
56
+ def move_to_root(item)
57
+ folder_name = File.basename(item.path)
58
+ new_path = File.join(@root_dir, folder_name)
59
+
60
+ # Same-location no-op check
61
+ return item.path if File.expand_path(item.path) == File.expand_path(new_path)
62
+
63
+ atomic_move(item.path, new_path)
64
+ end
65
+
66
+ private
67
+
68
+ def validate_path_traversal!(target_parent)
69
+ root_real = File.expand_path(@root_dir)
70
+ unless target_parent.start_with?(root_real + File::SEPARATOR)
71
+ raise ArgumentError, "Path traversal detected in target folder"
72
+ end
73
+ end
74
+
75
+ # Move src to dest atomically, handling cross-device moves.
76
+ def atomic_move(src, dest)
77
+ raise ArgumentError, "Destination already exists: #{dest}" if File.exist?(dest)
78
+
79
+ begin
80
+ File.rename(src, dest)
81
+ rescue Errno::EXDEV
82
+ FileUtils.cp_r(src, dest)
83
+ FileUtils.rm_rf(src)
84
+ end
85
+ dest
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end