# frozen_string_literal: true if ARGV.include?("bbcode-to-md") # Replace (most) bbcode with markdown before creating posts. # This will dramatically clean up the final posts in Discourse. # # In a temp dir: # # git clone https://github.com/nlalonde/ruby-bbcode-to-md.git # cd ruby-bbcode-to-md # gem build ruby-bbcode-to-md.gemspec # gem install ruby-bbcode-to-md-*.gem require "ruby-bbcode-to-md" end require_relative "../../config/environment" require_relative "base/lookup_container" require_relative "base/uploader" module ImportScripts end class ImportScripts::Base def initialize preload_i18n @lookup = ImportScripts::LookupContainer.new @uploader = ImportScripts::Uploader.new @bbcode_to_md = true if use_bbcode_to_md? @site_settings_during_import = {} @old_site_settings = {} @start_times = { import: Time.now } @skip_updates = false @next_category_color_index = {} end def preload_i18n I18n.t("test") ActiveSupport::Inflector.transliterate("test") end def perform Rails.logger.level = 3 # :error, so that we don't create log files that are many GB change_site_settings execute puts "" unless @skip_updates update_topic_status update_bumped_at update_last_posted_at update_last_seen_at update_user_stats update_topic_users update_post_timings update_feature_topic_users update_category_featured_topics reset_topic_counters end elapsed = Time.now - @start_times[:import] puts "", "", "Done (%02dh %02dmin %02dsec)" % [elapsed / 3600, elapsed / 60 % 60, elapsed % 60] ensure reset_site_settings end def get_site_settings_for_import { blocked_email_domains: "", min_topic_title_length: 1, min_post_length: 1, min_first_post_length: 1, min_personal_message_post_length: 1, min_personal_message_title_length: 1, allow_duplicate_topic_titles: true, allow_duplicate_topic_titles_category: false, disable_emails: "yes", max_attachment_size_kb: 102_400, max_image_size_kb: 102_400, authorized_extensions: "*", clean_up_inactive_users_after_days: 0, clean_up_unused_staged_users_after_days: 0, clean_up_uploads: false, clean_orphan_uploads_grace_period_hours: 168, } end def change_site_settings if SiteSetting.bootstrap_mode_enabled SiteSetting.default_trust_level = TrustLevel[0] if SiteSetting.default_trust_level == TrustLevel[1] SiteSetting.default_email_digest_frequency = 10_080 if SiteSetting.default_email_digest_frequency == 1440 SiteSetting.bootstrap_mode_enabled = false end @site_settings_during_import = get_site_settings_for_import @site_settings_during_import.each do |key, value| @old_site_settings[key] = SiteSetting.get(key) SiteSetting.set(key, value) end # Some changes that should not be rolled back after the script is done if SiteSetting.purge_unactivated_users_grace_period_days > 0 SiteSetting.purge_unactivated_users_grace_period_days = 60 end SiteSetting.purge_deleted_uploads_grace_period_days = 90 RateLimiter.disable end def reset_site_settings @old_site_settings.each do |key, value| current_value = SiteSetting.get(key) SiteSetting.set(key, value) if current_value == @site_settings_during_import[key] end RateLimiter.enable end def use_bbcode_to_md? ARGV.include?("bbcode-to-md") end # Implementation will do most of its work in its execute method. # It will need to call create_users, create_categories, and create_posts. def execute raise NotImplementedError end %i[ add_category add_group add_post add_topic add_user category_id_from_imported_category_id find_group_by_import_id find_user_by_import_id group_id_from_imported_group_id post_already_imported? post_id_from_imported_post_id topic_lookup_from_imported_post_id user_already_imported? user_id_from_imported_user_id ].each { |method_name| delegate method_name, to: :@lookup } def create_admin(opts = {}) admin = User.new admin.email = opts[:email] || "sam.saffron@gmail.com" admin.username = opts[:username] || "sam" admin.password = SecureRandom.uuid admin.save! admin.grant_admin! admin.change_trust_level!(TrustLevel[4]) admin.email_tokens.update_all(confirmed: true) admin end def created_group(group) # override if needed end # Iterate through a list of groups to be imported. # Takes a collection and yields to the block for each element. # Block should return a hash with the attributes for each element. # Required fields are :id and :name, where :id is the id of the # group in the original datasource. The given id will not be used # to create the Discourse group record. def create_groups(results, opts = {}) created = 0 skipped = 0 failed = 0 total = opts[:total] || results.count results.each do |result| g = yield(result) if g.nil? || group_id_from_imported_group_id(g[:id]) skipped += 1 else new_group = create_group(g, g[:id]) created_group(new_group) if new_group.valid? add_group(g[:id].to_s, new_group) created += 1 else failed += 1 puts "Failed to create group id #{g[:id]} #{new_group.name}: #{new_group.errors.full_messages}" end end print_status( created + skipped + failed + (opts[:offset] || 0), total, get_start_time("groups"), ) end [created, skipped] end def create_group(opts, import_id) opts = opts.dup.tap { |o| o.delete(:id) } import_name = opts[:name].presence || opts[:full_name] opts[:name] = UserNameSuggester.suggest(import_name) existing = Group.find_by(name: opts[:name]) return existing if existing && existing.custom_fields["import_id"].to_s == import_id.to_s g = existing || Group.new(opts) g.custom_fields["import_id"] = import_id g.custom_fields["import_name"] = import_name g.tap(&:save) end def all_records_exist?(type, import_ids) return false if import_ids.empty? ActiveRecord::Base.transaction do begin connection = ActiveRecord::Base.connection.raw_connection connection.exec("CREATE TEMP TABLE import_ids(val text PRIMARY KEY)") import_id_clause = import_ids.map { |id| "('#{PG::Connection.escape_string(id.to_s)}')" }.join(",") connection.exec("INSERT INTO import_ids VALUES #{import_id_clause}") existing = "#{type.to_s.classify}CustomField".constantize existing = existing.where(name: "import_id").joins("JOIN import_ids ON val = value").count if existing == import_ids.length puts "Skipping #{import_ids.length} already imported #{type}" true end ensure connection.exec("DROP TABLE import_ids") unless connection.nil? end end end def created_user(user) # override if needed end # Iterate through a list of user records to be imported. # Takes a collection, and yields to the block for each element. # Block should return a hash with the attributes for the User model. # Required fields are :id and :email, where :id is the id of the # user in the original datasource. The given id will not be used to # create the Discourse user record. def create_users(results, opts = {}) created = 0 skipped = 0 failed = 0 total = opts[:total] || results.count results.each do |result| u = yield(result) # block returns nil to skip a user if u.nil? skipped += 1 else import_id = u[:id] if user_id_from_imported_user_id(import_id) skipped += 1 else new_user = create_user(u, import_id) created_user(new_user) if new_user && new_user.valid? && new_user.user_profile && new_user.user_profile.valid? add_user(import_id.to_s, new_user) created += 1 else failed += 1 puts "Failed to create user id: #{import_id}, username: #{new_user.try(:username)}, email: #{new_user.try(:email)}" if new_user.try(:errors) puts "user errors: #{new_user.errors.full_messages}" if new_user.try(:user_profile).try(:errors) puts "user_profile errors: #{new_user.user_profile.errors.full_messages}" end end end end end print_status( created + skipped + failed + (opts[:offset] || 0), total, get_start_time("users"), ) end [created, skipped] end def create_user(opts, import_id) original_opts = opts.dup opts.delete(:id) merge = opts.delete(:merge) post_create_action = opts.delete(:post_create_action) existing = find_existing_user(opts[:email], opts[:username]) if existing && (merge || existing.custom_fields["import_id"].to_s == import_id.to_s) return existing end bio_raw = opts.delete(:bio_raw) website = opts.delete(:website) location = opts.delete(:location) avatar_url = opts.delete(:avatar_url) original_username = opts[:username] original_name = opts[:name] original_email = opts[:email] = opts[:email].downcase if !UsernameValidator.new(opts[:username]).valid_format? || !User.username_available?(opts[:username]) opts[:username] = UserNameSuggester.suggest( opts[:username].presence || opts[:name].presence || opts[:email], ) end if !EmailAddressValidator.valid_value?(opts[:email]) opts[:email] = fake_email puts "Invalid email '#{original_email}' for '#{opts[:username]}'. Using '#{opts[:email]}'" end opts[:name] = original_username if original_name.blank? && opts[:username] != original_username opts[:trust_level] = TrustLevel[1] unless opts[:trust_level] opts[:active] = opts.fetch(:active, true) opts[:import_mode] = true opts[:last_emailed_at] = opts.fetch(:last_emailed_at, Time.now) if (date_of_birth = opts[:date_of_birth]).is_a?(Date) && date_of_birth.year != 1904 opts[:date_of_birth] = Date.new(1904, date_of_birth.month, date_of_birth.day) end u = User.new(opts) (opts[:custom_fields] || {}).each { |k, v| u.custom_fields[k] = v } u.custom_fields["import_id"] = import_id u.custom_fields["import_username"] = original_username if original_username.present? && original_username != opts[:username] u.custom_fields["import_avatar_url"] = avatar_url if avatar_url.present? u.custom_fields["import_pass"] = opts[:password] if opts[:password].present? u.custom_fields["import_email"] = original_email if original_email != opts[:email] begin User.transaction do u.save! if bio_raw.present? || website.present? || location.present? if website.present? u.user_profile.website = website u.user_profile.website = nil unless u.user_profile.valid? end u.user_profile.bio_raw = bio_raw[0..2999] if bio_raw.present? u.user_profile.location = location if location.present? u.user_profile.save! end end u.activate if opts[:active] && opts[:password].present? rescue => e # try based on email if e.try(:record).try(:errors).try(:messages).try(:[], :primary_email).present? if existing = User.find_by_email(opts[:email].downcase) existing.created_at = opts[:created_at] if opts[:created_at] existing.custom_fields["import_id"] = import_id existing.save! u = existing end else puts "Error on record: #{original_opts.inspect}" raise e end end if u.custom_fields["import_email"] u.suspended_at = Time.zone.at(Time.now) u.suspended_till = 200.years.from_now u.save! user_option = u.user_option user_option.email_digests = false user_option.email_level = UserOption.email_level_types[:never] user_option.email_messages_level = UserOption.email_level_types[:never] user_option.save! if u.save StaffActionLogger.new(Discourse.system_user).log_user_suspend( u, "Invalid email address on import", ) else Rails.logger.error( "Failed to suspend user #{u.username}. #{u.errors.try(:full_messages).try(:inspect)}", ) end end post_create_action.try(:call, u) if u.persisted? u # If there was an error creating the user, u.errors has the messages end def find_existing_user(email, username) # Force the use of the index on the 'user_emails' table UserEmail.where("lower(email) = ?", email.downcase).first&.user || User.where(username: username).first end def created_category(category) # override if needed end # Iterates through a collection to create categories. # The block should return a hash with attributes for the new category. # Required fields are :id and :name, where :id is the id of the # category in the original datasource. The given id will not be used to # create the Discourse category record. # Optional attributes are position, description, and parent_category_id. def create_categories(results) created = 0 skipped = 0 total = results.count results.each do |c| params = yield(c) # block returns nil to skip if params.nil? || category_id_from_imported_category_id(params[:id]) skipped += 1 else # Basic massaging on the category name params[:name] = "Blank" if params[:name].blank? params[:name].strip! params[:name] = params[:name][0..49] # make sure categories don't go more than 2 levels deep if params[:parent_category_id] top = Category.find_by_id(params[:parent_category_id]) top = top.parent_category while (top&.height_of_ancestors || -1) + 1 >= SiteSetting.max_category_nesting params[:parent_category_id] = top.id if top end new_category = create_category(params, params[:id]) created_category(new_category) created += 1 end print_status(created + skipped, total, get_start_time("categories")) end [created, skipped] end def create_category(opts, import_id) existing = Category .where(parent_category_id: opts[:parent_category_id]) .where("LOWER(name) = ?", opts[:name].downcase.strip) .first if existing if import_id && existing.custom_fields["import_id"] != import_id existing.custom_fields["import_id"] = import_id existing.save! add_category(import_id, existing) end return existing end post_create_action = opts.delete(:post_create_action) new_category = Category.new( name: opts[:name], user_id: opts[:user_id] || opts[:user].try(:id) || Discourse::SYSTEM_USER_ID, position: opts[:position], parent_category_id: opts[:parent_category_id], color: opts[:color] || category_color(opts[:parent_category_id]), text_color: opts[:text_color] || "FFF", read_restricted: opts[:read_restricted] || false, ) new_category.custom_fields["import_id"] = import_id if import_id new_category.save! if opts[:description].present? changes = { raw: opts[:description] } opts = { skip_revision: true, skip_validations: true, bypass_bump: true } new_category.topic.first_post.revise(Discourse.system_user, changes, opts) end add_category(import_id, new_category) post_create_action.try(:call, new_category) new_category end def category_color(parent_category_id) @category_colors ||= SiteSetting.category_colors.split("|") index = @next_category_color_index[parent_category_id].presence || 0 @next_category_color_index[parent_category_id] = ( if index + 1 >= @category_colors.count 0 else index + 1 end ) @category_colors[index] end def created_post(post) # override if needed end # Iterates through a collection of posts to be imported. # It can create topics and replies. # Attributes will be passed to the PostCreator. # Topics should give attributes title and category. # Replies should provide topic_id. Use topic_lookup_from_imported_post_id to find the topic. def create_posts(results, opts = {}) skipped = 0 created = 0 total = opts[:total] || results.count start_time = get_start_time("posts-#{total}") # the post count should be unique enough to differentiate between posts and PMs results.each do |r| params = yield(r) # block returns nil to skip a post if params.nil? skipped += 1 else import_id = params.delete(:id).to_s if post_id_from_imported_post_id(import_id) skipped += 1 else begin new_post = create_post(params, import_id) if new_post.is_a?(Post) add_post(import_id, new_post) add_topic(new_post) created_post(new_post) created += 1 else skipped += 1 puts "Error creating post #{import_id}. Skipping." p new_post end rescue Discourse::InvalidAccess => e skipped += 1 puts "InvalidAccess creating post #{import_id}. Topic is closed? #{e.message}" rescue => e skipped += 1 puts "Exception while creating post #{import_id}. Skipping." puts e.message puts e.backtrace.join("\n") end end end print_status(created + skipped + (opts[:offset] || 0), total, start_time) end [created, skipped] end STAFF_GUARDIAN ||= Guardian.new(Discourse.system_user) def create_post(opts, import_id) user = User.find(opts[:user_id]) post_create_action = opts.delete(:post_create_action) opts = opts.merge(skip_validations: true) opts[:import_mode] = true opts[:custom_fields] ||= {} opts[:custom_fields]["import_id"] = import_id unless opts[:topic_id] opts[:meta_data] = meta_data = {} meta_data["import_closed"] = true if opts[:closed] meta_data["import_archived"] = true if opts[:archived] meta_data["import_topic_id"] = opts[:import_topic_id] if opts[:import_topic_id] end opts[:guardian] = STAFF_GUARDIAN if @bbcode_to_md opts[:raw] = begin opts[:raw].bbcode_to_md(false, {}, :disable, :quote) rescue StandardError opts[:raw] end end post_creator = PostCreator.new(user, opts) post = post_creator.create post_create_action.try(:call, post) if post post && post_creator.errors.empty? ? post : post_creator.errors.full_messages end def create_upload(user_id, path, source_filename) @uploader.create_upload(user_id, path, source_filename) end # Iterate through a list of bookmark records to be imported. # Takes a collection, and yields to the block for each element. # Block should return a hash with the attributes for the bookmark. # Required fields are :user_id and :post_id, where both ids are # the values in the original datasource. def create_bookmarks(results, opts = {}) created = 0 skipped = 0 total = opts[:total] || results.count user = User.new post = Post.new results.each do |result| params = yield(result) # only the IDs are needed, so this should be enough if params.nil? skipped += 1 else user.id = user_id_from_imported_user_id(params[:user_id]) post.id = post_id_from_imported_post_id(params[:post_id]) if user.id.nil? || post.id.nil? skipped += 1 puts "Skipping bookmark for user id #{params[:user_id]} and post id #{params[:post_id]}" else begin manager = BookmarkManager.new(user) bookmark = manager.create_for(bookmarkable_id: post.id, bookmarkable_type: "Post") created += 1 if manager.errors.none? skipped += 1 if manager.errors.any? rescue StandardError skipped += 1 end end end print_status(created + skipped + (opts[:offset] || 0), total, get_start_time("bookmarks")) end [created, skipped] end def create_likes(results, opts = {}) created = 0 skipped = 0 total = opts[:total] || results.count results.each do |result| params = yield(result) if params.nil? skipped += 1 else created_by = User.find_by(id: user_id_from_imported_user_id(params[:user_id])) post = Post.find_by(id: post_id_from_imported_post_id(params[:post_id])) if created_by && post PostActionCreator.create(created_by, post, :like, created_at: params[:created_at]) created += 1 else skipped += 1 puts "Skipping like for user id #{params[:user_id]} and post id #{params[:post_id]}" end end print_status(created + skipped + (opts[:offset] || 0), total, get_start_time("likes")) end [created, skipped] end def close_inactive_topics(opts = {}) num_days = opts[:days] || 30 puts "", "Closing topics that have been inactive for more than #{num_days} days." query = Topic.where("last_posted_at < ?", num_days.days.ago).where(closed: false) total_count = query.count closed_count = 0 query.find_each do |topic| topic.update_status("closed", true, Discourse.system_user) closed_count += 1 print_status(closed_count, total_count, get_start_time("close_inactive_topics")) end end def update_topic_status puts "", "Updating topic status" DB.exec <<~SQL UPDATE topics AS t SET closed = TRUE WHERE EXISTS( SELECT 1 FROM topic_custom_fields AS f WHERE f.topic_id = t.id AND f.name = 'import_closed' AND f.value = 't' ) SQL DB.exec <<~SQL UPDATE topics AS t SET archived = TRUE WHERE EXISTS( SELECT 1 FROM topic_custom_fields AS f WHERE f.topic_id = t.id AND f.name = 'import_archived' AND f.value = 't' ) SQL DB.exec <<~SQL DELETE FROM topic_custom_fields WHERE name IN ('import_closed', 'import_archived') SQL end def update_bumped_at puts "", "Updating bumped_at on topics" DB.exec <<~SQL UPDATE topics t SET bumped_at = COALESCE((SELECT MAX(created_at) FROM posts WHERE topic_id = t.id AND post_type = #{Post.types[:regular]}), bumped_at) SQL end def update_last_posted_at puts "", "Updating last posted at on users" DB.exec <<~SQL WITH lpa AS ( SELECT user_id, MAX(posts.created_at) AS last_posted_at FROM posts GROUP BY user_id ) UPDATE users SET last_posted_at = lpa.last_posted_at FROM users u1 JOIN lpa ON lpa.user_id = u1.id WHERE u1.id = users.id AND users.last_posted_at IS DISTINCT FROM lpa.last_posted_at SQL end def update_user_stats puts "", "Updating first_post_created_at..." DB.exec <<~SQL WITH sub AS ( SELECT user_id, MIN(posts.created_at) AS first_post_created_at FROM posts GROUP BY user_id ) UPDATE user_stats SET first_post_created_at = sub.first_post_created_at FROM user_stats u1 JOIN sub ON sub.user_id = u1.user_id WHERE u1.user_id = user_stats.user_id AND user_stats.first_post_created_at IS DISTINCT FROM sub.first_post_created_at SQL puts "", "Updating user post_count..." DB.exec <<~SQL WITH sub AS ( SELECT user_id, COUNT(*) AS post_count FROM posts GROUP BY user_id ) UPDATE user_stats SET post_count = sub.post_count FROM user_stats u1 JOIN sub ON sub.user_id = u1.user_id WHERE u1.user_id = user_stats.user_id AND user_stats.post_count <> sub.post_count SQL puts "", "Updating user topic_count..." DB.exec <<~SQL WITH sub AS ( SELECT user_id, COUNT(*) AS topic_count FROM topics GROUP BY user_id ) UPDATE user_stats SET topic_count = sub.topic_count FROM user_stats u1 JOIN sub ON sub.user_id = u1.user_id WHERE u1.user_id = user_stats.user_id AND user_stats.topic_count <> sub.topic_count SQL puts "", "Updating user digest_attempted_at..." DB.exec( "UPDATE user_stats SET digest_attempted_at = now() - random() * interval '1 week' WHERE digest_attempted_at IS NULL", ) end # scripts that are able to import last_seen_at from the source data should override this method def update_last_seen_at puts "", "Updating last seen at on users" DB.exec("UPDATE users SET last_seen_at = created_at WHERE last_seen_at IS NULL") DB.exec("UPDATE users SET last_seen_at = last_posted_at WHERE last_posted_at IS NOT NULL") end def update_topic_users puts "", "Updating topic users" DB.exec <<~SQL INSERT INTO topic_users (user_id, topic_id, posted, last_read_post_number, first_visited_at, last_visited_at, total_msecs_viewed) SELECT user_id, topic_id, 't' , MAX(post_number), MIN(created_at), MAX(created_at), COUNT(id) * 5000 FROM posts WHERE user_id > 0 GROUP BY user_id, topic_id ON CONFLICT DO NOTHING SQL end def update_post_timings puts "", "Updating post timings" DB.exec <<~SQL INSERT INTO post_timings (topic_id, post_number, user_id, msecs) SELECT topic_id, post_number, user_id, 5000 FROM posts WHERE user_id > 0 ON CONFLICT DO NOTHING SQL end def update_feature_topic_users puts "", "Updating featured topic users" TopicFeaturedUsers.ensure_consistency! end def reset_topic_counters puts "", "Resetting topic counters" Topic.reset_all_highest! end def update_category_featured_topics puts "", "Updating featured topics in categories" count = 0 total = Category.count Category.find_each do |category| CategoryFeaturedTopic.feature_topics_for(category) print_status(count += 1, total, get_start_time("category_featured_topics")) end end def update_tl0 puts "", "Setting users with no posts to trust level 0" count = 0 total = User.count User .includes(:user_stat) .find_each do |user| begin user.update_columns(trust_level: 0) if user.trust_level > 0 && user.post_count == 0 rescue Discourse::InvalidAccess end print_status(count += 1, total, get_start_time("update_tl0")) end end def update_user_signup_date_based_on_first_post puts "", "Setting users' signup date based on the date of their first post" count = 0 total = User.count User.find_each do |user| if first = user.posts.order("created_at ASC").first user.created_at = first.created_at user.save! end print_status(count += 1, total, get_start_time("user_signup")) end end def html_for_upload(upload, display_filename) @uploader.html_for_upload(upload, display_filename) end def embedded_image_html(upload) @uploader.embedded_image_html(upload) end def attachment_html(upload, display_filename) @uploader.attachment_html(upload, display_filename) end def print_status(current, max, start_time = nil) if start_time.present? elapsed_seconds = Time.now - start_time elements_per_minute = "[%.0f items/min] " % [current / elapsed_seconds.to_f * 60] else elements_per_minute = "" end print "\r%9d / %d (%5.1f%%) %s" % [current, max, current / max.to_f * 100, elements_per_minute] end def print_spinner @spinner_chars ||= %w[| / - \\] @spinner_chars.push @spinner_chars.shift print "\b#{@spinner_chars[0]}" end def get_start_time(key) @start_times.fetch(key) { |k| @start_times[k] = Time.now } end def batches(batch_size = 1000) offset = 0 loop do yield offset offset += batch_size end end def fake_email SecureRandom.hex << "@email.invalid" end end