Class: HTM::Models::Tag

Inherits:
Object
  • Object
show all
Defined in:
lib/htm/models/tag.rb

Overview

Tag model - represents unique tag names Tags have a many-to-many relationship with nodes through node_tags

Constant Summary collapse

TAG_FORMAT =

Tag name format regex

/\A[a-z0-9-]+(:[a-z0-9-]+)*\z/

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.calculate_node_positions(node, depth, positions, y_offset, h_spacing, v_spacing, parent_path = nil) ⇒ Object

Calculate node positions for SVG layout (internal helper)



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/htm/models/tag.rb', line 286

def self.calculate_node_positions(node, depth, positions, y_offset, h_spacing, v_spacing, parent_path = nil)
  node.keys.sort.each do |key|
    current_path = parent_path ? "#{parent_path}:#{key}" : key

    positions[current_path] = {
      x: depth,
      y: y_offset[0],
      label: key
    }
    y_offset[0] += 1

    children = node[key]
    calculate_node_positions(children, depth + 1, positions, y_offset, h_spacing, v_spacing, current_path) unless children.empty?
  end
end

.calculate_tree_stats(node, depth = 0) ⇒ Object

Calculate tree statistics (internal helper)



270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/htm/models/tag.rb', line 270

def self.calculate_tree_stats(node, depth = 0)
  return { total_nodes: 0, max_depth: depth } if node.empty?

  total = node.keys.size
  max = depth + 1

  node.each_value do |children|
    child_stats = calculate_tree_stats(children, depth + 1)
    total += child_stats[:total_nodes]
    max = [max, child_stats[:max_depth]].max
  end

  { total_nodes: total, max_depth: max }
end

.empty_tree_svg(title) ⇒ Object

Generate SVG for empty tree (internal helper)



303
304
305
306
307
308
309
310
311
# File 'lib/htm/models/tag.rb', line 303

def self.empty_tree_svg(title)
  <<~SVG
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 100">
      <rect width="100%" height="100%" fill="transparent"/>
      <text x="150" y="30" text-anchor="middle" fill="#9CA3AF" font-family="system-ui, sans-serif" font-size="14" font-weight="bold">#{title}</text>
      <text x="150" y="60" text-anchor="middle" fill="#6B7280" font-family="system-ui, sans-serif" font-size="12">No tags found</text>
    </svg>
  SVG
end

.exists?(conditions = {}) ⇒ Boolean

Check if a tag exists with the given conditions

Parameters:

  • conditions (Hash) (defaults to: {})

    Conditions to check

Returns:

  • (Boolean)

    true if a matching tag exists



92
93
94
# File 'lib/htm/models/tag.rb', line 92

def self.exists?(conditions = {})
  where(conditions).any?
end

.expand_hierarchy(tag_name) ⇒ Array<String>

Expand a hierarchical tag name into all ancestor paths

Parameters:

  • tag_name (String)

    Hierarchical tag (e.g., “a:b:c:d”)

Returns:

  • (Array<String>)

    All paths from root to leaf



134
135
136
137
138
139
# File 'lib/htm/models/tag.rb', line 134

def self.expand_hierarchy(tag_name)
  return [] if tag_name.nil? || tag_name.empty?

  levels = tag_name.split(':')
  (1..levels.size).map { |i| levels[0, i].join(':') }
end

.find_by_topic_prefix(prefix) ⇒ Sequel::Dataset

Find tags with a given prefix (hierarchical query)

Parameters:

  • prefix (String)

    Tag prefix to match (e.g., “database” matches “database:postgresql”)

Returns:

  • (Sequel::Dataset)

    Tags matching the prefix



101
102
103
# File 'lib/htm/models/tag.rb', line 101

def self.find_by_topic_prefix(prefix)
  dataset.with_prefix(prefix)
end

.find_or_create_by_name(name) ⇒ Tag

Find or create a tag by name

Parameters:

  • name (String)

    Hierarchical tag name (e.g., “database:postgresql”)

Returns:

  • (Tag)

    The found or created tag



125
126
127
# File 'lib/htm/models/tag.rb', line 125

def self.find_or_create_by_name(name)
  find_or_create(name: name)
end

.find_or_create_with_ancestors(name) ⇒ Array<Tag>

Find or create a tag and all its ancestor tags

Parameters:

  • name (String)

    Hierarchical tag name (e.g., “database:postgresql:extensions”)

Returns:

  • (Array<Tag>)

    All created/found tags from root to leaf



146
147
148
149
150
# File 'lib/htm/models/tag.rb', line 146

def self.find_or_create_with_ancestors(name)
  expand_hierarchy(name).map do |tag_name|
    find_or_create(name: tag_name)
  end
end

.format_tree_branch(node, is_last_array = []) ⇒ Object

Format a tree branch recursively (internal helper)



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/htm/models/tag.rb', line 227

def self.format_tree_branch(node, is_last_array = [])
  result = ''
  sorted_keys = node.keys.sort

  sorted_keys.each_with_index do |key, index|
    is_last = (index == sorted_keys.size - 1)

    line_prefix = is_last_array.map { |was_last| was_last ? '    ' : '|   ' }.join

    result += "#{line_prefix}+-- #{key}\n"

    children = node[key]
    unless children.empty?
      result += format_tree_branch(children, is_last_array + [is_last])
    end
  end

  result
end

.generate_mermaid_nodes(node, parent_path, lines, node_ids, counter) ⇒ Object

Generate Mermaid nodes recursively (internal helper)



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/htm/models/tag.rb', line 248

def self.generate_mermaid_nodes(node, parent_path, lines, node_ids, counter)
  node.keys.sort.each do |key|
    current_path = parent_path ? "#{parent_path}:#{key}" : key

    node_id = "n#{counter}"
    node_ids[current_path] = node_id
    counter += 1

    lines << "  #{node_id}[\"#{key}\"]"

    if parent_path && node_ids[parent_path]
      lines << "  #{node_ids[parent_path]} --> #{node_id}"
    end

    children = node[key]
    counter = generate_mermaid_nodes(children, current_path, lines, node_ids, counter) unless children.empty?
  end

  counter
end

.generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title) ⇒ Object

Generate SVG tree visualization (internal helper)



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/htm/models/tag.rb', line 314

def self.generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
  colors    = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#6366F1']
  svg_lines = []
  svg_lines << %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height + 40}">)
  svg_lines << '  <rect width="100%" height="100%" fill="transparent"/>'
  svg_lines << svg_title_element(width, title)

  positions.each do |path, pos|
    parent_path = path.include?(':') ? path.split(':')[0..-2].join(':') : nil
    next unless parent_path && positions[parent_path]
    svg_lines.concat svg_node_elements(path, pos, positions[parent_path], colors, padding, node_width, node_height)
  end

  svg_lines << '</svg>'
  svg_lines.join("\n")
end

Get the most frequently used tags

Parameters:

  • limit (Integer) (defaults to: 10)

    Maximum number of tags to return (default: 10)

Returns:

  • (Array<Tag>)

    Tags with usage_count attribute



110
111
112
113
114
115
116
117
118
# File 'lib/htm/models/tag.rb', line 110

def self.popular_tags(limit = 10)
  dataset
    .select_append { count(node_tags[:id]).as(usage_count) }
    .join(:node_tags, tag_id: :id)
    .group(:id)
    .order(Sequel.desc(:usage_count))
    .limit(limit)
    .all
end

.svg_edge_path(x1, y1, x2, y2) ⇒ Object



356
357
358
359
# File 'lib/htm/models/tag.rb', line 356

def self.svg_edge_path(x1, y1, x2, y2)
  mid_x = (x1 + x2) / 2
  %(  <path d="M#{x1},#{y1} C#{mid_x},#{y1} #{mid_x},#{y2} #{x2},#{y2}" stroke="#4B5563" stroke-width="2" fill="none"/>)
end

.svg_node_coords(pos, parent_pos, padding, node_width, node_height) ⇒ Object



345
346
347
348
349
350
351
352
353
354
# File 'lib/htm/models/tag.rb', line 345

def self.svg_node_coords(pos, parent_pos, padding, node_width, node_height)
  x2 = padding + (pos[:x] * (node_width + 40))
  y2 = 40 + padding + (pos[:y] * (node_height + 20)) + (node_height / 2)
  {
    x1: padding + (parent_pos[:x] * (node_width + 40)) + node_width,
    y1: 40 + padding + (parent_pos[:y] * (node_height + 20)) + (node_height / 2),
    x2: x2, y2: y2,
    x:  x2, y:  40 + padding + (pos[:y] * (node_height + 20))
  }
end

.svg_node_elements(path, pos, parent_pos, colors, padding, node_width, node_height) ⇒ Object



335
336
337
338
339
340
341
342
343
# File 'lib/htm/models/tag.rb', line 335

def self.svg_node_elements(path, pos, parent_pos, colors, padding, node_width, node_height)
  c     = svg_node_coords(pos, parent_pos, padding, node_width, node_height)
  color = colors[path.count(':') % colors.size]
  [
    svg_edge_path(c[:x1], c[:y1], c[:x2], c[:y2]),
    svg_node_rect(c[:x], c[:y], node_width, node_height, color),
    svg_node_label(c[:x], c[:y], node_width, node_height, pos[:label])
  ]
end

.svg_node_label(x, y, node_width, node_height, label) ⇒ Object



365
366
367
# File 'lib/htm/models/tag.rb', line 365

def self.svg_node_label(x, y, node_width, node_height, label)
  %(  <text x="#{x + (node_width / 2)}" y="#{y + (node_height / 2) + 4}" text-anchor="middle" fill="#FFFFFF" font-family="system-ui, sans-serif" font-size="11" font-weight="500">#{label}</text>) # rubocop:disable Layout/LineLength
end

.svg_node_rect(x, y, node_width, node_height, color) ⇒ Object



361
362
363
# File 'lib/htm/models/tag.rb', line 361

def self.svg_node_rect(x, y, node_width, node_height, color)
  %(  <rect x="#{x}" y="#{y}" width="#{node_width}" height="#{node_height}" rx="6" fill="#{color}" opacity="0.9"/>)
end

.svg_title_element(width, title) ⇒ Object



331
332
333
# File 'lib/htm/models/tag.rb', line 331

def self.svg_title_element(width, title)
  %(  <text x="#{width / 2}" y="25" text-anchor="middle" fill="#F3F4F6" font-family="system-ui, sans-serif" font-size="16" font-weight="bold">#{title}</text>) # rubocop:disable Layout/LineLength
end

.treeHash

Returns a nested hash tree structure from the current scope

Returns:

  • (Hash)

    Nested hash representing the tag hierarchy



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/htm/models/tag.rb', line 156

def self.tree
  tree = {}

  order(:name).select_map(:name).each do |tag_name|
    parts = tag_name.split(':')
    current = tree

    parts.each do |part|
      current[part] ||= {}
      current = current[part]
    end
  end

  tree
end

.tree_mermaid(direction: 'TD') ⇒ String

Returns a Mermaid flowchart representation of the tag tree

Parameters:

  • direction (String) (defaults to: 'TD')

    Flow direction: ‘TD’ (top-down), ‘LR’ (left-right), ‘BT’, ‘RL’

Returns:

  • (String)

    Mermaid flowchart syntax



185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/htm/models/tag.rb', line 185

def self.tree_mermaid(direction: 'TD')
  tree_data = tree
  return "flowchart #{direction}\n  empty[No tags]" if tree_data.empty?

  lines = ["flowchart #{direction}"]
  node_id = 0
  node_ids = {}

  generate_mermaid_nodes(tree_data, nil, lines, node_ids, node_id)

  lines.join("\n")
end

.tree_stringString

Returns a formatted string representation of the tag tree

Returns:

  • (String)

    Formatted tree string



176
177
178
# File 'lib/htm/models/tag.rb', line 176

def self.tree_string
  format_tree_branch(tree)
end

.tree_svg(title: 'HTM Tag Hierarchy') ⇒ String

Returns an SVG representation of the tag tree

Parameters:

  • title (String) (defaults to: 'HTM Tag Hierarchy')

    Optional title for the SVG

Returns:

  • (String)

    SVG markup



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/htm/models/tag.rb', line 203

def self.tree_svg(title: 'HTM Tag Hierarchy')
  tree_data = tree
  return empty_tree_svg(title) if tree_data.empty?

  stats = calculate_tree_stats(tree_data)
  max_depth = stats[:max_depth]

  node_width = 140
  node_height = 30
  h_spacing = 180
  v_spacing = 50
  padding = 40

  positions = {}
  y_offset = [0]
  calculate_node_positions(tree_data, 0, positions, y_offset, h_spacing, v_spacing)

  width = (max_depth * h_spacing) + node_width + (padding * 2)
  height = (y_offset[0] * v_spacing) + node_height + (padding * 2)

  generate_tree_svg(tree_data, positions, width, height, padding, node_width, node_height, title)
end

Instance Method Details

#before_createObject

Hooks



80
81
82
83
# File 'lib/htm/models/tag.rb', line 80

def before_create
  self.created_at ||= Time.now
  super
end

#deleted?Boolean

Check if tag is soft-deleted

Returns:

  • (Boolean)

    true if deleted_at is set



433
434
435
# File 'lib/htm/models/tag.rb', line 433

def deleted?
  !deleted_at.nil?
end

#depthInteger

Get the depth (number of levels) of this tag

Returns:

  • (Integer)

    Number of hierarchy levels



391
392
393
# File 'lib/htm/models/tag.rb', line 391

def depth
  topic_levels.length
end

#hierarchical?Boolean

Check if this tag is hierarchical (has child levels)

Returns:

  • (Boolean)

    True if tag contains colons (hierarchy separators)



399
400
401
# File 'lib/htm/models/tag.rb', line 399

def hierarchical?
  name.include?(':')
end

#restore!Boolean

Restore a soft-deleted tag

Returns:

  • (Boolean)

    true if restored successfully



424
425
426
427
# File 'lib/htm/models/tag.rb', line 424

def restore!
  update(deleted_at: nil)
  true
end

#root_topicString

Get the root (top-level) topic of this tag

Returns:

  • (String)

    The first segment of the hierarchical tag



375
376
377
# File 'lib/htm/models/tag.rb', line 375

def root_topic
  name.split(':').first
end

#soft_delete!Boolean

Soft delete - mark tag as deleted without removing from database

Returns:

  • (Boolean)

    true if soft deleted successfully



415
416
417
418
# File 'lib/htm/models/tag.rb', line 415

def soft_delete!
  update(deleted_at: Time.now)
  true
end

#topic_levelsArray<String>

Get all hierarchy levels of this tag

Returns:

  • (Array<String>)

    Array of topic segments



383
384
385
# File 'lib/htm/models/tag.rb', line 383

def topic_levels
  name.split(':')
end

#usage_countInteger

Get the number of nodes using this tag

Returns:

  • (Integer)

    Count of nodes with this tag



407
408
409
# File 'lib/htm/models/tag.rb', line 407

def usage_count
  node_tags_dataset.count
end

#validateObject

Validations



21
22
23
24
25
26
27
# File 'lib/htm/models/tag.rb', line 21

def validate
  super
  validates_presence :name
  validates_format TAG_FORMAT, :name,
                   message: "must be lowercase with hyphens, using colons for hierarchy (e.g., 'database:postgresql:performance')"
  validates_unique :name, message: "already exists"
end