FEATURE: Add tasks to export and import site structure (#12584)

FEATURE: Add tasks to export and import site structure (#12584)

diff --git a/lib/tasks/site.rake b/lib/tasks/site.rake
new file mode 100644
index 0000000..4f168ad
--- /dev/null
+++ b/lib/tasks/site.rake
@@ -0,0 +1,491 @@
+# frozen_string_literal: true
+
+require 'yaml'
+require 'zip'
+
+class ZippedSiteStructure
+  attr_reader :zip
+
+  def initialize(path, create: false)
+    @zip = Zip::File.open(path, create)
+  end
+
+  def close
+    @zip.close
+  end
+
+  def set(name, data)
+    @zip.get_output_stream("#{name}.json") do |file|
+      file.write(data.to_json)
+    end
+  end
+
+  def get(name)
+    data = @zip.get_input_stream("#{name}.json").read
+    JSON.parse(data)
+  end
+
+  def set_upload(upload_or_id_or_url)
+    return nil if upload_or_id_or_url.blank?
+
+    if Integer === upload_or_id_or_url
+      upload = Upload.find_by(id: upload_or_id_or_url)
+    elsif String === upload_or_id_or_url
+      upload = Upload.get_from_url(upload_or_id_or_url)
+    elsif Upload === upload_or_id_or_url
+      upload = upload_or_id_or_url
+    end
+
+    if !upload
+      STDERR.puts "ERROR: Could not find upload #{upload_or_id_or_url.inspect}"
+      return nil
+    end
+
+    local_path = upload.local? ? Discourse.store.path_for(upload) : Discourse.store.download(upload).path
+    zip_path = File.join('uploads', File.basename(local_path))
+
+    puts "  - Exporting upload #{upload_or_id_or_url} to #{zip_path}"
+    @zip.add(zip_path, local_path)
+
+    { filename: upload.original_filename, path: file_zip_path }
+  end
+
+  def get_upload(upload, opts = {})
+    return nil if upload.blank?
+
+    puts "  - Importing upload #{upload['filename']} from #{upload['path']}"
+
+    tempfile = Tempfile.new(upload['filename'], binmode: true)
+    tempfile.write(@zip.get_input_stream(upload['path']).read)
+    tempfile.rewind
+
+    UploadCreator.new(tempfile, upload['filename'], opts)
+      .create_for(Discourse::SYSTEM_USER_ID)
+  end
+end
+
+desc 'Exports site structure (settings, groups, categories, tags, themes, etc) to a ZIP file'
+task 'site:export_structure', [:zip_path] => :environment do |task, args|
+  if args[:zip_path].blank?
+    STDERR.puts "ERROR: rake site:export_structure[<path to ZIP file>]"
+    exit 1
+  elsif File.exists?(args[:zip_path])
+    STDERR.puts "ERROR: File '#{args[:zip_path]}' already exists"
+    exit 2
+  end
+
+  data = ZippedSiteStructure.new(args[:zip_path], create: true)
+
+  puts
+  puts "Exporting site settings"
+  puts
+
+  settings = {}
+
+  SiteSetting.all_settings(include_hidden: true).each do |site_setting|
+    next if site_setting[:default] == site_setting[:value]
+
+    puts "- Site setting #{site_setting[:setting]} -> #{site_setting[:value].inspect}"
+
+    settings[site_setting[:setting]] = if site_setting[:type] == 'upload'
+      data.set_upload(site_setting[:value])
+    else
+      site_setting[:value]
+    end
+  end
+
+  data.set('site_settings', settings)
+
+  puts
+  puts "Exporting users"
+  puts
+
+  users = []
+
+  User.real.where(admin: true).each do |u|
+    puts "- User #{u.username}"
+
+    users << {
+      username: u.username,
+      name: u.name,
+      email: u.email,
+      active: u.active,
+      admin: u.admin,
+    }
+  end
+
+  data.set('users', users)
+
+  puts
+  puts "Exporting groups"
+  puts
+
+  groups = []
+
+  Group.where(automatic: false).each do |g|
+    puts "- Group #{g.name}"
+
+    groups << {
+      name: g.name,
+      automatic_membership_email_domains: g.automatic_membership_email_domains,
+      primary_group: g.primary_group,
+      title: g.title,
+      grant_trust_level: g.grant_trust_level,
+      incoming_email: g.incoming_email,
+      has_messages: g.has_messages,
+      flair_bg_color: g.flair_bg_color,
+      flair_color: g.flair_color,
+      bio_raw: g.bio_raw,
+      allow_membership_requests: g.allow_membership_requests,
+      full_name: g.full_name,
+      default_notification_level: g.default_notification_level,
+      visibility_level: g.visibility_level,
+      public_exit: g.public_exit,
+      public_admission: g.public_admission,
+      membership_request_template: g.membership_request_template,
+      messageable_level: g.messageable_level,
+      mentionable_level: g.mentionable_level,
+      publish_read_state: g.publish_read_state,
+      members_visibility_level: g.members_visibility_level,
+      flair_icon: g.flair_icon,
+      flair_upload_id: data.set_upload(g.flair_upload_id),
+      allow_unknown_sender_topic_replies: g.allow_unknown_sender_topic_replies,
+    }
+  end
+
+  data.set('groups', groups)
+
+  puts
+  puts "Exporting categories"
+  puts
+
+  categories = []
+
+  Category.find_each do |c|
+    puts "- Category #{c.name} (#{c.slug})"
+
+    categories << {
+      name: c.name,
+      color: c.color,
+      slug: c.slug,
+      description: c.description,
+      text_color: c.text_color,
+      read_restricted: c.read_restricted,
+      auto_close_hours: c.auto_close_hours,
+      parent_category: c.parent_category&.slug,
+      position: c.position,
+      email_in: c.email_in,
+      email_in_allow_strangers: c.email_in_allow_strangers,
+      allow_badges: c.allow_badges,
+      auto_close_based_on_last_post: c.auto_close_based_on_last_post,
+      topic_template: c.topic_template,
+      sort_order: c.sort_order,
+      sort_ascending: c.sort_ascending,
+      uploaded_logo_id: data.set_upload(c.uploaded_logo_id),
+      uploaded_background_id: data.set_upload(c.uploaded_background_id),
+      topic_featured_link_allowed: c.topic_featured_link_allowed,
+      all_topics_wiki: c.all_topics_wiki,
+      show_subcategory_list: c.show_subcategory_list,
+      default_view: c.default_view,
+      subcategory_list_style: c.subcategory_list_style,
+      default_top_period: c.default_top_period,
+      mailinglist_mirror: c.mailinglist_mirror,
+      minimum_required_tags: c.minimum_required_tags,
+      navigate_to_first_post_after_read: c.navigate_to_first_post_after_read,
+      search_priority: c.search_priority,
+      allow_global_tags: c.allow_global_tags,
+      read_only_banner: c.read_only_banner,
+      default_list_filter: c.default_list_filter,
+      permissions: c.permissions_params,
+    }
+  end
+
+  data.set('categories', categories)
+
+  puts
+  puts "Exporting tag groups"
+  puts
+
+  tag_groups = []
+
+  TagGroup.all.each do |tg|
+    puts "- Tag group #{tg.name}"
+
+    tag_groups << {
+      name: tg.name,
+      tag_names: tg.tags.map(&:name)
+    }
+  end
+
+  data.set('tag_groups', tag_groups)
+
+  puts
+  puts "Exporting tags"
+  puts
+
+  tags = []
+
+  Tag.find_each do |t|
+    puts "- Tag #{t.name}"
+
+    tag = { name: t.name }
+    tag[:target_tag] = t.target_tag.name if t.target_tag.present?
+
+    tags << tag
+  end
+
+  data.set('tags', tags)
+
+  puts
+  puts "Exporting themes and theme components"
+  puts
+
+  themes = []
+
+  Theme.find_each do |theme|
+    puts "- Theme #{theme.name}"
+
+    if theme.remote_theme.present?
+      themes << {
+        name: theme.name,
+        url: theme.remote_theme.remote_url,
+        private_key: theme.remote_theme.private_key,
+        branch: theme.remote_theme.branch
+      }
+    else
+      exporter = ThemeStore::ZipExporter.new(theme)
+      file_path = exporter.package_filename
+      file_zip_path = File.join('themes', File.basename(file_path))
+      data.zip.add(file_zip_path, file_path)
+      themes << { name: theme.name, filename: File.basename(file_path), path: file_zip_path }
+    end
+  end
+
+  data.set('themes', themes)
+
+  puts
+  puts "Exporting theme settings"
+  puts
+
+  theme_settings = []
+
+  ThemeSetting.find_each do |theme_setting|
+    puts "- Theme setting #{theme_setting.name} -> #{theme_setting.value}"
+
+    value = if theme_setting.data_type == ThemeSetting.types[:upload]
+      data.set_upload(theme_setting.value)
+    else
+      theme_setting.value
+    end
+
+    theme_settings << {
+      name: theme_setting.name,
+      data_type: theme_setting.data_type,

[... diff too long, it was truncated ...]

GitHub sha: 7c29ff3d

1 Like

This commit appears in #12584 which was approved by SamSaffron. It was merged by SamSaffron.