|
| 1 | +module CoPlan |
| 2 | + module PlanTypes |
| 3 | + # Installs the plan types shipped with the engine |
| 4 | + # (engine/db/default_plan_types/*.md — YAML front matter for the |
| 5 | + # attributes, Markdown body as the template). |
| 6 | + # |
| 7 | + # Admin edits are data, defaults are code, and the defaults must never |
| 8 | + # silently clobber the data: without `force`, existing types only gain |
| 9 | + # values for fields that are currently blank (the common upgrade case — |
| 10 | + # a type created before templates existed gets the default template, |
| 11 | + # but a hand-written description survives). With `force`, the shipped |
| 12 | + # defaults win on every field — blank shipped values included, so a |
| 13 | + # custom template on a type that ships without one (Scratchpad, General) |
| 14 | + # is cleared. Types unknown to the defaults are never touched either way. |
| 15 | + class InstallDefaults |
| 16 | + DEFAULTS_DIR = CoPlan::Engine.root.join("db", "default_plan_types") |
| 17 | + FRONT_MATTER = /\A---\n(?<yaml>.*?)\n---\n?(?<body>.*)\z/m |
| 18 | + |
| 19 | + Result = Struct.new(:created, :updated, :skipped, keyword_init: true) |
| 20 | + |
| 21 | + def self.call(force: false, dir: DEFAULTS_DIR) |
| 22 | + new(force:, dir:).call |
| 23 | + end |
| 24 | + |
| 25 | + def initialize(force: false, dir: DEFAULTS_DIR) |
| 26 | + @force = force |
| 27 | + @dir = Pathname(dir) |
| 28 | + end |
| 29 | + |
| 30 | + def call |
| 31 | + result = Result.new(created: [], updated: [], skipped: []) |
| 32 | + |
| 33 | + @dir.glob("*.md").sort.each do |path| |
| 34 | + attrs = parse(path) |
| 35 | + type = PlanType.find_by_name(attrs[:name]) |
| 36 | + |
| 37 | + if type.nil? |
| 38 | + PlanType.create!(**attrs) |
| 39 | + result.created << attrs[:name] |
| 40 | + elsif apply(type, attrs) |
| 41 | + result.updated << attrs[:name] |
| 42 | + else |
| 43 | + result.skipped << attrs[:name] |
| 44 | + end |
| 45 | + end |
| 46 | + |
| 47 | + result |
| 48 | + end |
| 49 | + |
| 50 | + private |
| 51 | + |
| 52 | + def parse(path) |
| 53 | + match = FRONT_MATTER.match(path.read) |
| 54 | + raise ArgumentError, "#{path.basename}: missing YAML front matter" unless match |
| 55 | + |
| 56 | + meta = YAML.safe_load(match[:yaml]) || {} |
| 57 | + name = meta["name"].to_s.strip |
| 58 | + raise ArgumentError, "#{path.basename}: front matter needs a name" if name.empty? |
| 59 | + |
| 60 | + { |
| 61 | + name: name, |
| 62 | + description: meta["description"].to_s.strip.presence, |
| 63 | + icon: meta["icon"].to_s.strip.presence, |
| 64 | + default_tags: Array(meta["default_tags"]).map(&:to_s), |
| 65 | + template_content: match[:body].strip.presence |
| 66 | + } |
| 67 | + end |
| 68 | + |
| 69 | + # Assigns default values onto an existing type; returns whether |
| 70 | + # anything changed. Only blank fields are filled unless forcing; |
| 71 | + # forcing restores the shipped value even when it's blank. |
| 72 | + def apply(type, attrs) |
| 73 | + attrs.except(:name).each do |field, value| |
| 74 | + next if value.blank? && !@force |
| 75 | + next unless @force || type[field].blank? |
| 76 | + |
| 77 | + type[field] = value |
| 78 | + end |
| 79 | + return false unless type.changed? |
| 80 | + |
| 81 | + type.save! |
| 82 | + true |
| 83 | + end |
| 84 | + end |
| 85 | + end |
| 86 | +end |
0 commit comments