agent-tome 1.0.0 → 1.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ea76c93f40a8d4e0a643df1ed73eef19954938d1e2b28977564a82a58284c93
4
- data.tar.gz: 801d26d865bbe49e1975dd4f8039d008c37de5a12822850a8e32041c8e912a99
3
+ metadata.gz: 63a6785130bb342f557a1af651a7c774563b860b00c81c2f65669ae6d009885e
4
+ data.tar.gz: 7d164737f2babe237791b3d31c0f3e1693fb06933ced0aaa9f6a619dd29a1ff5
5
5
  SHA512:
6
- metadata.gz: 81fc0b5eedfc6126e23d553abbf8a2359c5e98a118bbba065b4c847be56eb9303d6b3f4885ee2649d11ef02f2c1fae6370f78c205e4ecd884bdbfb1ecb79a27e
7
- data.tar.gz: 62c870a453dde2d2d06b8e26b2361267f211691a3962a6e477a30e96cec749a69f438f2f4dbd44fe5cdd3e3bf6e0ad4c8df0a2b46449c66c3dc20bbc2489d652
6
+ metadata.gz: 7b318e4534379a174ab7a06a784f72ba816ebf54ed86225619f27f99f7f43483e505129da5f3e7a0e2e1ea1c912e16a12b42627d5f8ae4242f0d21f14fd05ff4
7
+ data.tar.gz: 6fa6edcd247750d45f5e86e5bb0361597059e97cd6e3931c4f19d52f950a56a411280f236b3889c42795498f59fd086a7e44026728867224ef2177c274a9cc53
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.1] - 2026-04-05
4
+
5
+ ### Changed
6
+
7
+ - Extract KeywordNormalizer to eliminate triple duplication across Create, Addend, and Consolidate.
8
+ - Extract KeywordLinker, WebSourceLinker, FileSourceLinker, and RelatedArticleLinker, each with unit tests, removing duplicated logic from Create and Addend.
9
+ - Extract InputValidator with unit tests, wiring Create and Addend to share validation.
10
+ - Extract ArticleFormatter for shared article summary formatting.
11
+ - Refactor Consolidate.call into named private methods.
12
+ - Add test DSL helpers and migrate all acceptance tests to use them.
13
+
3
14
  ## [1.0.0] - 2026-04-03
4
15
 
5
16
  ### Added
@@ -18,5 +29,6 @@
18
29
  - **CLI**: `agent-tome` executable with JSON stdin/stdout interface, exit code 0 on success and non-zero on error.
19
30
  - **Claude Code skills**: Companion `tome-lookup` and `tome-capture` skills for AI agent integration.
20
31
 
21
- [Unreleased]: https://github.com/beatmadsen/agent-tome/compare/v1.0.0...HEAD
32
+ [Unreleased]: https://github.com/beatmadsen/agent-tome/compare/v1.0.1...HEAD
33
+ [1.0.1]: https://github.com/beatmadsen/agent-tome/compare/v1.0.0...v1.0.1
22
34
  [1.0.0]: https://github.com/beatmadsen/agent-tome/releases/tag/v1.0.0
@@ -0,0 +1,16 @@
1
+ module Agent
2
+ module Tome
3
+ module ArticleFormatter
4
+ def self.summary(article, extras = {})
5
+ result = {
6
+ "global_id" => article.global_id,
7
+ "description" => article.description,
8
+ "keywords" => article.keywords.pluck(:term).sort,
9
+ "created_at" => article.created_at.iso8601
10
+ }
11
+ result.merge!(extras) if extras.any?
12
+ result
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,5 +1,3 @@
1
- require "active_support/core_ext/string/inflections"
2
-
3
1
  module Agent
4
2
  module Tome
5
3
  module Commands
@@ -23,10 +21,10 @@ module Agent
23
21
  created_at: Time.now
24
22
  )
25
23
 
26
- process_keywords!(article, input["keywords"] || [])
27
- web_source_ids = process_web_sources!(entry, input["web_sources"] || [])
28
- file_source_ids = process_file_sources!(entry, input["file_sources"] || [])
29
- process_related_articles!(article, input["related_article_ids"] || [])
24
+ KeywordLinker.call(article, input["keywords"] || [])
25
+ web_source_ids = WebSourceLinker.call(entry, input["web_sources"] || [])
26
+ file_source_ids = FileSourceLinker.call(entry, input["file_sources"] || [])
27
+ RelatedArticleLinker.call(article, input["related_article_ids"] || [])
30
28
 
31
29
  result = {
32
30
  "entry_global_id" => entry.global_id,
@@ -59,99 +57,12 @@ module Agent
59
57
  raise ValidationError, "At least one field must be substantively present"
60
58
  end
61
59
 
62
- validate_keywords!(keywords) if keywords.any?
63
- validate_web_sources!(web_sources) if web_sources.any?
64
- validate_file_sources!(file_sources) if file_sources.any?
65
- validate_related_ids!(input["related_article_ids"]) if input.key?("related_article_ids")
66
- end
67
-
68
- def validate_keywords!(keywords)
69
- keywords.each do |kw|
70
- raise ValidationError, "keyword must be a non-empty string" unless kw.is_a?(String) && !kw.strip.empty?
71
- end
72
- end
73
-
74
- def validate_web_sources!(sources)
75
- sources.each do |src|
76
- raise ValidationError, "invalid URL: #{src["url"]}" unless UrlNormalizer.valid?(src["url"].to_s)
77
- end
78
- end
79
-
80
- def validate_file_sources!(sources)
81
- sources.each do |src|
82
- raise ValidationError, "file_source path cannot be empty" if src["path"].to_s.strip.empty?
83
- raise ValidationError, "file_source system_name cannot be empty" if src["system_name"].to_s.strip.empty?
84
- end
85
- end
86
-
87
- def validate_related_ids!(ids)
88
- return unless ids
89
-
90
- ids.each do |id|
91
- raise ValidationError, "Referenced article not found: #{id}" unless Article.exists?(global_id: id)
92
- end
93
- end
94
-
95
- def process_keywords!(article, keywords)
96
- keywords.each do |kw|
97
- normalized = normalize_keyword(kw)
98
- keyword = Keyword.find_or_create_by!(term: normalized) do |k|
99
- k.created_at = Time.now
100
- end
101
- ArticleKeyword.find_or_create_by!(article: article, keyword: keyword) do |ak|
102
- ak.created_at = Time.now
103
- end
104
- end
105
- end
106
-
107
- def process_web_sources!(entry, sources)
108
- sources.map do |src|
109
- normalized_url = UrlNormalizer.normalize(src["url"])
110
- ws = WebSource.find_or_create_by!(url: normalized_url) do |w|
111
- w.global_id = GlobalId.generate
112
- w.title = src["title"]
113
- w.fetched_at = src["fetched_at"] ? Time.parse(src["fetched_at"]) : nil
114
- w.created_at = Time.now
115
- end
116
- EntryWebSource.find_or_create_by!(entry: entry, web_source: ws) do |ews|
117
- ews.created_at = Time.now
118
- end
119
- ws.global_id
120
- end
121
- end
122
-
123
- def process_file_sources!(entry, sources)
124
- sources.map do |src|
125
- fs = FileSource.find_or_create_by!(path: src["path"], system_name: src["system_name"]) do |f|
126
- f.global_id = GlobalId.generate
127
- f.created_at = Time.now
128
- end
129
- EntryFileSource.find_or_create_by!(entry: entry, file_source: fs) do |efs|
130
- efs.created_at = Time.now
131
- end
132
- fs.global_id
133
- end
60
+ InputValidator.validate_keywords!(keywords) if keywords.any?
61
+ InputValidator.validate_web_sources!(web_sources) if web_sources.any?
62
+ InputValidator.validate_file_sources!(file_sources) if file_sources.any?
63
+ InputValidator.validate_related_ids!(input["related_article_ids"]) if input.key?("related_article_ids")
134
64
  end
135
65
 
136
- def process_related_articles!(article, related_ids)
137
- related_ids.each do |target_id|
138
- raise ValidationError, "An article cannot reference itself" if target_id == article.global_id
139
-
140
- target = Article.find_by!(global_id: target_id)
141
- ArticleReference.find_or_create_by!(
142
- source_article: article,
143
- target_article: target
144
- ) do |ref|
145
- ref.created_at = Time.now
146
- end
147
- end
148
- end
149
-
150
- def normalize_keyword(kw)
151
- words = kw.downcase.split("-")
152
- words[-1] = ActiveSupport::Inflector.singularize(words[-1])
153
- words.join("-")
154
- end
155
66
  end
156
67
  end
157
68
  end
@@ -12,61 +12,19 @@ module Agent
12
12
 
13
13
  validate!(input)
14
14
 
15
- result = {}
16
-
17
15
  ActiveRecord::Base.transaction do
18
- original_global_id = article.global_id
19
- new_description = input["description"] || article.description
20
-
21
- # Assign old article a new global_id
22
- old_global_id = GlobalId.generate
23
- article.update_columns(global_id: old_global_id)
24
-
25
- # Create the new consolidated article with the original global_id
26
- new_article = Article.create!(
27
- global_id: original_global_id,
28
- description: new_description,
29
- created_at: Time.now
30
- )
31
-
32
- # Create the first (and only) entry for the consolidated article
33
- new_entry = Entry.create!(
34
- article: new_article,
35
- body: input["body"],
36
- created_at: Time.now
37
- )
38
-
39
- # Copy all sources from old article's entries to the consolidated entry
40
- article.entries.each do |old_entry|
41
- old_entry.web_sources.each do |ws|
42
- EntryWebSource.find_or_create_by!(entry: new_entry, web_source: ws)
43
- end
44
- old_entry.file_sources.each do |fs|
45
- EntryFileSource.find_or_create_by!(entry: new_entry, file_source: fs)
46
- end
47
- end
48
-
49
- # Copy keywords from old article to new article
50
- article.keywords.each do |keyword|
51
- ArticleKeyword.find_or_create_by!(article: new_article, keyword: keyword) do |ak|
52
- ak.created_at = Time.now
53
- end
54
- end
55
-
56
- # Create consolidation link
57
- ConsolidationLink.create!(
58
- new_article: new_article,
59
- old_article: article,
60
- created_at: Time.now
61
- )
62
-
63
- result = {
16
+ original_global_id = swap_global_id!(article)
17
+ new_article = create_consolidated_article(original_global_id, article, input)
18
+ new_entry = create_consolidated_entry(new_article, input)
19
+ copy_sources!(article, new_entry)
20
+ copy_keywords!(article, new_article)
21
+ ConsolidationLink.create!(new_article: new_article, old_article: article, created_at: Time.now)
22
+
23
+ {
64
24
  "new_article_global_id" => new_article.global_id,
65
25
  "old_article_global_id" => article.global_id
66
26
  }
67
27
  end
68
-
69
- result
70
28
  end
71
29
 
72
30
  private
@@ -84,6 +42,43 @@ module Agent
84
42
  raise ValidationError, "description must be 350 characters or fewer" if desc.length > 350
85
43
  end
86
44
  end
45
+
46
+ def swap_global_id!(article)
47
+ original_global_id = article.global_id
48
+ article.update_columns(global_id: GlobalId.generate)
49
+ original_global_id
50
+ end
51
+
52
+ def create_consolidated_article(original_global_id, old_article, input)
53
+ Article.create!(
54
+ global_id: original_global_id,
55
+ description: input["description"] || old_article.description,
56
+ created_at: Time.now
57
+ )
58
+ end
59
+
60
+ def create_consolidated_entry(article, input)
61
+ Entry.create!(article: article, body: input["body"], created_at: Time.now)
62
+ end
63
+
64
+ def copy_sources!(old_article, new_entry)
65
+ old_article.entries.each do |old_entry|
66
+ old_entry.web_sources.each do |ws|
67
+ EntryWebSource.find_or_create_by!(entry: new_entry, web_source: ws)
68
+ end
69
+ old_entry.file_sources.each do |fs|
70
+ EntryFileSource.find_or_create_by!(entry: new_entry, file_source: fs)
71
+ end
72
+ end
73
+ end
74
+
75
+ def copy_keywords!(old_article, new_article)
76
+ old_article.keywords.each do |keyword|
77
+ ArticleKeyword.find_or_create_by!(article: new_article, keyword: keyword) do |ak|
78
+ ak.created_at = Time.now
79
+ end
80
+ end
81
+ end
87
82
  end
88
83
  end
89
84
  end
@@ -1,11 +1,7 @@
1
- require "active_support/core_ext/string/inflections"
2
-
3
1
  module Agent
4
2
  module Tome
5
3
  module Commands
6
4
  class Create
7
- TRACKING_PARAMS = %w[fbclid gclid fbid mc_cid mc_eid].freeze
8
-
9
5
  def call(input)
10
6
  validate!(input)
11
7
 
@@ -23,10 +19,10 @@ module Agent
23
19
  created_at: Time.now
24
20
  )
25
21
 
26
- process_keywords!(article, input["keywords"] || [])
27
- web_source_ids = process_web_sources!(entry, input["web_sources"] || [])
28
- file_source_ids = process_file_sources!(entry, input["file_sources"] || [])
29
- process_related_articles!(article, input["related_article_ids"] || [])
22
+ KeywordLinker.call(article, input["keywords"] || [])
23
+ web_source_ids = WebSourceLinker.call(entry, input["web_sources"] || [])
24
+ file_source_ids = FileSourceLinker.call(entry, input["file_sources"] || [])
25
+ RelatedArticleLinker.call(article, input["related_article_ids"] || [])
30
26
 
31
27
  result = {
32
28
  "article_global_id" => article.global_id,
@@ -54,114 +50,12 @@ module Agent
54
50
  raise ValidationError, "body must be a string" unless body.is_a?(String)
55
51
  raise ValidationError, "body cannot be blank" if body.strip.empty?
56
52
 
57
- validate_keywords!(input["keywords"]) if input.key?("keywords")
58
- validate_web_sources!(input["web_sources"]) if input.key?("web_sources")
59
- validate_file_sources!(input["file_sources"]) if input.key?("file_sources")
60
- validate_related_ids!(input["related_article_ids"]) if input.key?("related_article_ids")
61
- end
62
-
63
- def validate_keywords!(keywords)
64
- return unless keywords
65
-
66
- raise ValidationError, "keywords must be an array" unless keywords.is_a?(Array)
67
-
68
- keywords.each do |kw|
69
- raise ValidationError, "keyword must be a non-empty string" unless kw.is_a?(String) && !kw.strip.empty?
70
- end
71
- end
72
-
73
- def validate_web_sources!(sources)
74
- return unless sources
75
-
76
- raise ValidationError, "web_sources must be an array" unless sources.is_a?(Array)
77
-
78
- sources.each do |src|
79
- raise ValidationError, "web_source url is required" unless src.is_a?(Hash) && src["url"]
80
- raise ValidationError, "invalid URL: #{src["url"]}" unless UrlNormalizer.valid?(src["url"])
81
- end
82
- end
83
-
84
- def validate_file_sources!(sources)
85
- return unless sources
86
-
87
- raise ValidationError, "file_sources must be an array" unless sources.is_a?(Array)
88
-
89
- sources.each do |src|
90
- raise ValidationError, "file_source path cannot be empty" if src["path"].to_s.strip.empty?
91
- raise ValidationError, "file_source system_name cannot be empty" if src["system_name"].to_s.strip.empty?
92
- end
93
- end
94
-
95
- def validate_related_ids!(ids)
96
- return unless ids
97
-
98
- raise ValidationError, "related_article_ids must be an array" unless ids.is_a?(Array)
99
-
100
- ids.each do |id|
101
- raise ValidationError, "Referenced article not found: #{id}" unless Article.exists?(global_id: id)
102
- end
103
- end
104
-
105
- def process_keywords!(article, keywords)
106
- keywords.each do |kw|
107
- normalized = normalize_keyword(kw)
108
- keyword = Keyword.find_or_create_by!(term: normalized) do |k|
109
- k.created_at = Time.now
110
- end
111
- ArticleKeyword.find_or_create_by!(article: article, keyword: keyword) do |ak|
112
- ak.created_at = Time.now
113
- end
114
- end
115
- end
116
-
117
- def process_web_sources!(entry, sources)
118
- sources.map do |src|
119
- normalized_url = UrlNormalizer.normalize(src["url"])
120
- ws = WebSource.find_or_create_by!(url: normalized_url) do |w|
121
- w.global_id = GlobalId.generate
122
- w.title = src["title"]
123
- w.fetched_at = src["fetched_at"] ? Time.parse(src["fetched_at"]) : nil
124
- w.created_at = Time.now
125
- end
126
- EntryWebSource.find_or_create_by!(entry: entry, web_source: ws) do |ews|
127
- ews.created_at = Time.now
128
- end
129
- ws.global_id
130
- end
53
+ InputValidator.validate_keywords!(input["keywords"]) if input.key?("keywords")
54
+ InputValidator.validate_web_sources!(input["web_sources"]) if input.key?("web_sources")
55
+ InputValidator.validate_file_sources!(input["file_sources"]) if input.key?("file_sources")
56
+ InputValidator.validate_related_ids!(input["related_article_ids"]) if input.key?("related_article_ids")
131
57
  end
132
58
 
133
- def process_file_sources!(entry, sources)
134
- sources.map do |src|
135
- fs = FileSource.find_or_create_by!(path: src["path"], system_name: src["system_name"]) do |f|
136
- f.global_id = GlobalId.generate
137
- f.created_at = Time.now
138
- end
139
- EntryFileSource.find_or_create_by!(entry: entry, file_source: fs) do |efs|
140
- efs.created_at = Time.now
141
- end
142
- fs.global_id
143
- end
144
- end
145
-
146
- def process_related_articles!(article, related_ids)
147
- related_ids.each do |target_id|
148
- raise ValidationError, "An article cannot reference itself" if target_id == article.global_id
149
-
150
- target = Article.find_by!(global_id: target_id)
151
- ArticleReference.find_or_create_by!(
152
- source_article: article,
153
- target_article: target
154
- ) do |ref|
155
- ref.created_at = Time.now
156
- end
157
- end
158
- end
159
-
160
- def normalize_keyword(kw)
161
- words = kw.downcase.split("-")
162
- words[-1] = ActiveSupport::Inflector.singularize(words[-1])
163
- words.join("-")
164
- end
165
59
  end
166
60
  end
167
61
  end
@@ -10,13 +10,7 @@ module Agent
10
10
  article = Article.find_by(global_id: @global_id)
11
11
  raise NotFoundError, "Article not found: #{@global_id}" unless article
12
12
 
13
- result = {
14
- "global_id" => article.global_id,
15
- "description" => article.description,
16
- "keywords" => article.keywords.pluck(:term).sort,
17
- "created_at" => article.created_at.iso8601,
18
- "entries" => format_entries(article)
19
- }
13
+ result = ArticleFormatter.summary(article, "entries" => format_entries(article))
20
14
 
21
15
  if (link = article.consolidation_as_new)
22
16
  old = link.old_article
@@ -33,47 +33,37 @@ module Agent
33
33
  .select("articles.*, COUNT(DISTINCT article_keywords.keyword_id) AS shared_keyword_count")
34
34
  .order("shared_keyword_count DESC")
35
35
  .limit(100)
36
- .map { |a| format_article(a, shared_keyword_count: a.shared_keyword_count.to_i) }
36
+ .map { |a| ArticleFormatter.summary(a, "shared_keyword_count" => a.shared_keyword_count.to_i) }
37
37
  end
38
38
 
39
39
  def find_references_to(article)
40
40
  ArticleReference
41
41
  .where(source_article: article)
42
42
  .includes(:target_article)
43
- .map { |ref| format_article(ref.target_article) }
43
+ .map { |ref| ArticleFormatter.summary(ref.target_article) }
44
44
  end
45
45
 
46
46
  def find_referenced_by(article)
47
47
  ArticleReference
48
48
  .where(target_article: article)
49
49
  .includes(:source_article)
50
- .map { |ref| format_article(ref.source_article) }
50
+ .map { |ref| ArticleFormatter.summary(ref.source_article) }
51
51
  end
52
52
 
53
53
  def find_consolidated_from(article)
54
54
  ConsolidationLink
55
55
  .where(new_article: article)
56
56
  .includes(:old_article)
57
- .map { |link| format_article(link.old_article) }
57
+ .map { |link| ArticleFormatter.summary(link.old_article) }
58
58
  end
59
59
 
60
60
  def find_consolidated_into(article)
61
61
  ConsolidationLink
62
62
  .where(old_article: article)
63
63
  .includes(:new_article)
64
- .map { |link| format_article(link.new_article) }
64
+ .map { |link| ArticleFormatter.summary(link.new_article) }
65
65
  end
66
66
 
67
- def format_article(article, extra = {})
68
- base = {
69
- "global_id" => article.global_id,
70
- "description" => article.description,
71
- "keywords" => article.keywords.pluck(:term).sort,
72
- "created_at" => article.created_at.iso8601
73
- }
74
- base.merge!(extra.transform_keys(&:to_s)) if extra.any?
75
- base
76
- end
77
67
  end
78
68
  end
79
69
  end
@@ -1,5 +1,3 @@
1
- require "active_support/core_ext/string/inflections"
2
-
3
1
  module Agent
4
2
  module Tome
5
3
  module Commands
@@ -12,7 +10,7 @@ module Agent
12
10
  def call
13
11
  raise ValidationError, "At least one keyword is required" if @keywords.empty?
14
12
 
15
- normalized = @keywords.map { |kw| normalize_keyword(kw) }
13
+ normalized = @keywords.map { |kw| KeywordNormalizer.call(kw) }
16
14
  keyword_ids = Keyword.where(term: normalized).pluck(:id)
17
15
 
18
16
  return { "results" => [] } if keyword_ids.empty?
@@ -42,20 +40,9 @@ module Agent
42
40
  end
43
41
 
44
42
  def format_result(article)
45
- {
46
- "global_id" => article.global_id,
47
- "description" => article.description,
48
- "keywords" => article.keywords.pluck(:term).sort,
49
- "matching_keyword_count" => article.matching_keyword_count.to_i,
50
- "created_at" => article.created_at.iso8601
51
- }
43
+ ArticleFormatter.summary(article, "matching_keyword_count" => article.matching_keyword_count.to_i)
52
44
  end
53
45
 
54
- def normalize_keyword(kw)
55
- words = kw.downcase.split("-")
56
- words[-1] = ActiveSupport::Inflector.singularize(words[-1])
57
- words.join("-")
58
- end
59
46
  end
60
47
  end
61
48
  end
@@ -0,0 +1,18 @@
1
+ module Agent
2
+ module Tome
3
+ module FileSourceLinker
4
+ def self.call(entry, sources)
5
+ sources.map do |src|
6
+ fs = FileSource.find_or_create_by!(path: src["path"], system_name: src["system_name"]) do |f|
7
+ f.global_id = GlobalId.generate
8
+ f.created_at = Time.now
9
+ end
10
+ EntryFileSource.find_or_create_by!(entry: entry, file_source: fs) do |efs|
11
+ efs.created_at = Time.now
12
+ end
13
+ fs.global_id
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ module Agent
2
+ module Tome
3
+ module InputValidator
4
+ def self.validate_keywords!(keywords)
5
+ raise ValidationError, "keywords must be an array" unless keywords.is_a?(Array)
6
+
7
+ keywords.each do |kw|
8
+ raise ValidationError, "keyword must be a non-empty string" unless kw.is_a?(String) && !kw.strip.empty?
9
+ end
10
+ end
11
+
12
+ def self.validate_web_sources!(sources)
13
+ raise ValidationError, "web_sources must be an array" unless sources.is_a?(Array)
14
+
15
+ sources.each do |src|
16
+ raise ValidationError, "web_source url is required" unless src.is_a?(Hash) && src["url"]
17
+ raise ValidationError, "invalid URL: #{src["url"]}" unless UrlNormalizer.valid?(src["url"])
18
+ end
19
+ end
20
+
21
+ def self.validate_file_sources!(sources)
22
+ raise ValidationError, "file_sources must be an array" unless sources.is_a?(Array)
23
+
24
+ sources.each do |src|
25
+ raise ValidationError, "file_source path cannot be empty" if src["path"].to_s.strip.empty?
26
+ raise ValidationError, "file_source system_name cannot be empty" if src["system_name"].to_s.strip.empty?
27
+ end
28
+ end
29
+
30
+ def self.validate_related_ids!(ids)
31
+ raise ValidationError, "related_article_ids must be an array" unless ids.is_a?(Array)
32
+
33
+ ids.each do |id|
34
+ raise ValidationError, "Referenced article not found: #{id}" unless Article.exists?(global_id: id)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,17 @@
1
+ module Agent
2
+ module Tome
3
+ module KeywordLinker
4
+ def self.call(article, keywords)
5
+ keywords.each do |kw|
6
+ normalized = KeywordNormalizer.call(kw)
7
+ keyword = Keyword.find_or_create_by!(term: normalized) do |k|
8
+ k.created_at = Time.now
9
+ end
10
+ ArticleKeyword.find_or_create_by!(article: article, keyword: keyword) do |ak|
11
+ ak.created_at = Time.now
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module Agent
2
+ module Tome
3
+ module KeywordNormalizer
4
+ def self.call(keyword)
5
+ words = keyword.downcase.split("-")
6
+ words[-1] = ActiveSupport::Inflector.singularize(words[-1])
7
+ words.join("-")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,19 @@
1
+ module Agent
2
+ module Tome
3
+ module RelatedArticleLinker
4
+ def self.call(article, related_ids)
5
+ related_ids.each do |target_id|
6
+ raise ValidationError, "An article cannot reference itself" if target_id == article.global_id
7
+
8
+ target = Article.find_by!(global_id: target_id)
9
+ ArticleReference.find_or_create_by!(
10
+ source_article: article,
11
+ target_article: target
12
+ ) do |ref|
13
+ ref.created_at = Time.now
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Agent
4
4
  module Tome
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.1"
6
6
  end
7
7
  end
@@ -0,0 +1,21 @@
1
+ module Agent
2
+ module Tome
3
+ module WebSourceLinker
4
+ def self.call(entry, sources)
5
+ sources.map do |src|
6
+ normalized_url = UrlNormalizer.normalize(src["url"])
7
+ ws = WebSource.find_or_create_by!(url: normalized_url) do |w|
8
+ w.global_id = GlobalId.generate
9
+ w.title = src["title"]
10
+ w.fetched_at = src["fetched_at"] ? Time.parse(src["fetched_at"]) : nil
11
+ w.created_at = Time.now
12
+ end
13
+ EntryWebSource.find_or_create_by!(entry: entry, web_source: ws) do |ews|
14
+ ews.created_at = Time.now
15
+ end
16
+ ws.global_id
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/agent/tome.rb CHANGED
@@ -12,6 +12,13 @@ require_relative "tome/config"
12
12
  require_relative "tome/database"
13
13
  require_relative "tome/global_id"
14
14
  require_relative "tome/url_normalizer"
15
+ require_relative "tome/keyword_normalizer"
16
+ require_relative "tome/keyword_linker"
17
+ require_relative "tome/web_source_linker"
18
+ require_relative "tome/file_source_linker"
19
+ require_relative "tome/related_article_linker"
20
+ require_relative "tome/input_validator"
21
+ require_relative "tome/article_formatter"
15
22
 
16
23
  module Agent
17
24
  module Tome
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-tome
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik T. Madsen
@@ -69,6 +69,7 @@ files:
69
69
  - exe/agent-tome
70
70
  - lib/agent.rb
71
71
  - lib/agent/tome.rb
72
+ - lib/agent/tome/article_formatter.rb
72
73
  - lib/agent/tome/cli.rb
73
74
  - lib/agent/tome/commands/addend.rb
74
75
  - lib/agent/tome/commands/consolidate.rb
@@ -80,7 +81,11 @@ files:
80
81
  - lib/agent/tome/commands/source_search.rb
81
82
  - lib/agent/tome/config.rb
82
83
  - lib/agent/tome/database.rb
84
+ - lib/agent/tome/file_source_linker.rb
83
85
  - lib/agent/tome/global_id.rb
86
+ - lib/agent/tome/input_validator.rb
87
+ - lib/agent/tome/keyword_linker.rb
88
+ - lib/agent/tome/keyword_normalizer.rb
84
89
  - lib/agent/tome/models/application_record.rb
85
90
  - lib/agent/tome/models/article.rb
86
91
  - lib/agent/tome/models/article_keyword.rb
@@ -92,8 +97,10 @@ files:
92
97
  - lib/agent/tome/models/file_source.rb
93
98
  - lib/agent/tome/models/keyword.rb
94
99
  - lib/agent/tome/models/web_source.rb
100
+ - lib/agent/tome/related_article_linker.rb
95
101
  - lib/agent/tome/url_normalizer.rb
96
102
  - lib/agent/tome/version.rb
103
+ - lib/agent/tome/web_source_linker.rb
97
104
  homepage: https://github.com/beatmadsen/agent-tome
98
105
  licenses:
99
106
  - MIT