require_relative '../base.rb' require_relative './redis' class ImportScripts::NodeBB < ImportScripts::Base # CHANGE THESE BEFORE RUNNING THE IMPORTER # ATTACHMENT_DIR needs to be absolute, not relative path ATTACHMENT_DIR = '/Users/orlando/www/orlando/NodeBB/public/uploads' BATCH_SIZE = 2000 def initialize super adapter = NodeBB::Redis @client = adapter.new( host: "localhost", port: "6379", db: 0 ) load_merged_posts end def load_merged_posts puts 'loading merged posts with topics...' # we keep here the posts that were merged # as topics # # { post_id: discourse_post_id } @merged_posts_map = {} PostCustomField.where(name: 'import_merged_post_id').pluck(:post_id, :value).each do |post_id, import_id| post = Post.find(post_id) topic_id = post.topic_id nodebb_post_id = post.custom_fields['import_merged_post_id'] @merged_posts_map[nodebb_post_id] = topic_id end end def execute import_groups import_categories import_users add_users_to_groups import_topics import_posts import_attachments post_process_posts end def import_groups puts '', 'importing groups' groups = @client.groups total_count = groups.count progress_count = 0 start_time = Time.now create_groups(groups) do |group| { id: group["name"], name: group["slug"] } end end def import_categories puts "", "importing top level categories..." category_map = @client.categories category_ids = category_map.keys categories = category_map.values top_level_categories = categories.select { |c| c["parentCid"] == "0" } create_categories(top_level_categories) do |category| { id: category["cid"], name: category["name"], position: category["order"], description: category["description"], } end puts "", "importing child categories..." children_categories = categories.select { |c| c["parentCid"] != "0" } top_level_category_ids = Set.new(top_level_categories.map { |c| c["cid"] }) # cut down the tree to only 2 levels of categories children_categories.each do |cc| while !top_level_category_ids.include?(cc["parentCid"]) cc["parentCid"] = categories.detect { |c| c["cid"] == cc["parentCid"] }["parentCid"] end end create_categories(children_categories) do |category| { id: category["cid"], name: category["name"], position: category["order"], description: category["description"], parent_category_id: category_id_from_imported_category_id(category["parentCid"]) } end end def import_users puts "", "importing users" users = @client.users user_count = users.count # we use this group to grant admin to users admin_group = @client.group("administrators") create_users(users, total: user_count) do |user| username = user["username"] email = user["email"] # skip users without username next unless username # fake email for users without email email = fake_email if email.blank? # use user.suspended to handle banned users if user["banned"] == "1" suspended_at = Time.now suspended_till = Time.now + 100.years end { id: user["uid"], name: user["fullname"], username: username, email: email, admin: admin_group["member_ids"].include?(user["uid"]), website: user["website"], location: user["location"], suspended_at: suspended_at, suspended_till: suspended_till, primary_group_id: group_id_from_imported_group_id(user["groupTitle"]), created_at: user["joindate"], custom_fields: { import_pass: user["password"] }, post_create_action: proc do |u| import_profile_picture(user, u) import_profile_background(user, u) end } end end def import_profile_picture(old_user, imported_user) picture = old_user["picture"] return if picture.blank? # URI.scheme returns nil for internal URLs uri = URI.parse(picture) is_external = uri.scheme if is_external # download external image begin string_io = open(picture, read_timeout: 5) rescue Net::ReadTimeout puts "timeout downloading avatar for user #{imported_user.id}" return nil end # continue if download failed return unless string_io # try to get filename from headers if string_io.meta["content-disposition"] filename = string_io.meta["content-disposition"].match(/filename=(\"?)(.+)\1/)[2] end # try to get it from path filename = File.basename(picture) unless filename # can't determine filename, skip upload if !filename puts "Can't determine filename, skipping avatar upload for user #{imported_user.id}" return end # write tmp file file = Tempfile.new(filename, encoding: 'ascii-8bit') file.write string_io.read file.rewind upload = UploadCreator.new(file, filename).create_for(imported_user.id) else # remove "/assets/uploads/" from attachment picture = picture.gsub("/assets/uploads", "") filepath = File.join(ATTACHMENT_DIR, picture) filename = File.basename(picture) unless File.exists?(filepath) puts "Avatar file doesn't exist: #{filename}" return nil end upload = create_upload(imported_user.id, filepath, filename) end return if !upload.persisted? imported_user.create_user_avatar imported_user.user_avatar.update(custom_upload_id: upload.id) imported_user.update(uploaded_avatar_id: upload.id) ensure string_io.close rescue nil file.close rescue nil file.unlind rescue nil end def import_profile_background(old_user, imported_user) picture = old_user["cover:url"] return if picture.blank? # URI returns nil for invalid URLs uri = URI.parse(picture) is_external = uri.scheme if is_external begin string_io = open(picture, read_timeout: 5) rescue Net::ReadTimeout return nil end if string_io.meta["content-disposition"] filename = string_io.meta["content-disposition"].match(/filename=(\"?)(.+)\1/)[2] end filename = File.basename(picture) unless filename # can't determine filename, skip upload if !filename puts "Can't determine filename, skipping background upload for user #{imported_user.id}" return end # write tmp file file = Tempfile.new(filename, encoding: 'ascii-8bit') file.write string_io.read file.rewind upload = UploadCreator.new(file, filename).create_for(imported_user.id) else # remove "/assets/uploads/" from attachment picture = picture.gsub("/assets/uploads", "") filepath = File.join(ATTACHMENT_DIR, picture) filename = File.basename(picture) unless File.exists?(filepath) puts "Background file doesn't exist: #{filename}" return nil end upload = create_upload(imported_user.id, filepath, filename) end return if !upload.persisted? imported_user.user_profile.update(profile_background: upload.url) ensure string_io.close rescue nil file.close rescue nil file.unlink rescue nil end def add_users_to_groups puts "", "adding users to groups..." groups = @client.groups total_count = groups.count progress_count = 0 start_time = Time.now @client.groups.each do |group| dgroup = find_group_by_import_id(group["name"]) # do thing if we migrated this group already next if dgroup.custom_fields['import_users_added'] group_member_ids = group["member_ids"].map { |uid| user_id_from_imported_user_id(uid) } group_owner_ids = group["owner_ids"].map { |uid| user_id_from_imported_user_id(uid) } # add members dgroup.bulk_add(group_member_ids) # reload group dgroup.reload # add owners owners = User.find(group_owner_ids) owners.each { |owner| dgroup.add_owner(owner) } dgroup.custom_fields['import_users_added'] = true dgroup.save progress_count += 1 print_status(progress_count, total_count, start_time) end end def import_topics puts "", "importing topics..." topic_count = @client.topic_count batches(BATCH_SIZE) do |offset| topics = @client.topics(offset, BATCH_SIZE) break if topics.size < 1 create_posts(topics, total: topic_count, offset: offset) do |topic| # skip if is deleted if topic["deleted"] == "1" puts "Topic with id #{topic["tid"]} was deleted, skipping" next end topic_id = "t#{topic["tid"]}" raw = topic["mainpost"]["content"] data = { id: topic_id, user_id: user_id_from_imported_user_id(topic["uid"]) || Discourse::SYSTEM_USER_ID, title: topic["title"], category: category_id_from_imported_category_id(topic["cid"]), raw: raw, created_at: topic["timestamp"], views: topic["viewcount"], closed: topic["locked"] == "1", post_create_action: proc do |p| # keep track of this to use in import_posts p.custom_fields["import_merged_post_id"] = topic["mainPid"] p.save @merged_posts_map[topic["mainPid"]] = p.id end } data[:pinned_at] = data[:created_at] if topic["pinned"] == "1" data end end end def import_posts puts "", "importing posts..." post_count = @client.post_count batches(BATCH_SIZE) do |offset| posts = @client.posts(offset, BATCH_SIZE) break if posts.size < 1 create_posts(posts, total: post_count, offset: offset) do |post| # skip if it's merged_post next if @merged_posts_map[post["pid"]] # skip if it's deleted next if post["deleted"] == "1" raw = post["content"] post_id = "p#{post["pid"]}" next if raw.blank? topic = topic_lookup_from_imported_post_id("t#{post["tid"]}") unless topic puts "Topic with id #{post["tid"]} not found, skipping" next end data = { id: post_id, user_id: user_id_from_imported_user_id(post["uid"]) || Discourse::SYSTEM_USER_ID, topic_id: topic[:topic_id], raw: raw, created_at: post["timestamp"], post_create_action: proc do |p| post["upvoted_by"].each do |upvoter_id| user = User.new user.id = user_id_from_imported_user_id(upvoter_id) || Discourse::SYSTEM_USER_ID begin PostAction.act(user, p, PostActionType.types[:like]) rescue PostAction::AlreadyActed end end end } if post['toPid'] # Look reply to topic parent_id = topic_lookup_from_imported_post_id("t#{post['toPid']}").try(:[], :post_number) # Look reply post if topic is missing parent_id ||= topic_lookup_from_imported_post_id("p#{post['toPid']}").try(:[], :post_number) if parent_id data[:reply_to_post_number] = parent_id else puts "Post with id #{post["toPid"]} not found for reply" end end data end end end def post_process_posts puts "", "Postprocessing posts..." current = 0 max = Post.count start_time = Time.now Post.find_each do |post| begin next if post.custom_fields['import_post_processing'] new_raw = postprocess_post(post) if new_raw != post.raw post.raw = new_raw post.custom_fields['import_post_processing'] = true post.save end ensure print_status(current += 1, max, start_time) end end end def import_attachments puts '', 'importing attachments...' current = 0 max = Post.count start_time = Time.now Post.find_each do |post| current += 1 print_status(current, max, start_time) new_raw = post.raw.dup new_raw.gsub!(/\[(.*)\]\((\/assets\/uploads\/files\/.*)\)/) do image_md = Regexp.last_match[0] text, filepath = $1, $2 filepath = filepath.gsub("/assets/uploads", ATTACHMENT_DIR) # if file exists # upload attachment and return html for it if File.exists?(filepath) filename = File.basename(filepath) upload = create_upload(post.user_id, filepath, filename) html_for_upload(upload, filename) else puts "File with path #{filepath} not found for post #{post.id}, upload will be broken" image_md end end if new_raw != post.raw PostRevisor.new(post).revise!(post.user, { raw: new_raw }, bypass_bump: true, edit_reason: 'Import attachments from NodeBB') end end end def postprocess_post(post) raw = post.raw # [link to post](/post/:id) raw = raw.gsub(/\[(.*)\]\(\/post\/(\d+).*\)/) do text, post_id = $1, $2 if topic_lookup = topic_lookup_from_imported_post_id("p#{post_id}") url = topic_lookup[:url] "[#{text}](#{url})" else "/404" end end # [link to topic](/topic/:id) raw = raw.gsub(/\[(.*)\]\(\/topic\/(\d+).*\)/) do text, topic_id = $1, $2 if topic_lookup = topic_lookup_from_imported_post_id("t#{topic_id}") url = topic_lookup[:url] "[#{text}](#{url})" else "/404" end end # @username with dash to underscore raw = raw.gsub(/@([a-zA-Z0-9-]+)/) do username = $1 username.gsub('-', '_') end raw end def fake_email SecureRandom.hex << "@domain.com" end end ImportScripts::NodeBB.new.perform