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,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
|