discourse/script/import_scripts/nodebb/nodebb.rb
2023-01-09 11:13:22 +00:00

569 lines
15 KiB
Ruby

# frozen_string_literal: true
require_relative "../base"
require_relative "./redis"
require_relative "./mongo"
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::Mongo
# @client = adapter.new('mongodb://127.0.0.1:27017/nodebb')
adapter = NodeBB::Redis
@client = adapter.new(host: "localhost", port: "6379", db: 14)
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) { |group| { id: group["name"], name: group["slug"] } }
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" && c["disabled"] != "1" }
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" && c["disabled"] != "1" }
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
categories.each do |source_category|
cid = category_id_from_imported_category_id(source_category["cid"])
begin
Permalink.create(url: "/category/#{source_category["slug"]}", category_id: cid)
rescue StandardError
nil
end
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"],
bio_raw: user["aboutme"],
active: true,
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 = uri.open(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/" and "/uploads" from attachment
picture = picture.gsub("/assets/uploads", "")
picture = picture.gsub("/uploads", "")
filepath = File.join(ATTACHMENT_DIR, picture)
filename = File.basename(picture)
unless File.exist?(filepath)
puts "Avatar file doesn't exist: #{filepath}"
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
begin
string_io.close
rescue StandardError
nil
end
begin
file.close
rescue StandardError
nil
end
begin
file.unlind
rescue StandardError
nil
end
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 = uri.open(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/" and "/uploads" from attachment
picture = picture.gsub("/assets/uploads", "")
picture = picture.gsub("/uploads", "")
filepath = File.join(ATTACHMENT_DIR, picture)
filename = File.basename(picture)
unless File.exist?(filepath)
puts "Background file doesn't exist: #{filepath}"
return nil
end
upload = create_upload(imported_user.id, filepath, filename)
end
return if !upload.persisted?
imported_user.user_profile.upload_profile_background(upload)
ensure
begin
string_io.close
rescue StandardError
nil
end
begin
file.close
rescue StandardError
nil
end
begin
file.unlink
rescue StandardError
nil
end
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
topics.each do |import_topic|
topic = topic_lookup_from_imported_post_id("t#{import_topic["tid"]}")
begin
Permalink.create(url: "/topic/#{import_topic["slug"]}", topic_id: topic[:topic_id])
rescue StandardError
nil
end
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
PostActionCreator.like(user, p)
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!(%r{\[(.*)\]\((/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.exist?(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(%r{\[(.*)\]\(/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(%r{\[(.*)\]\(/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
raw
end
end
ImportScripts::NodeBB.new.perform