From 66aaa9329ee70bc1ae78dca4355be83b61a98219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 18 Aug 2014 13:04:08 +0200 Subject: [PATCH] FEATURE: vBulletin importer --- app/models/concerns/has_custom_fields.rb | 3 +- app/models/group.rb | 27 +- script/import_scripts/base.rb | 48 +- script/import_scripts/vbulletin.rb | 592 ++++++++++++++++++++++- 4 files changed, 639 insertions(+), 31 deletions(-) diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index e4029458d7e..cd9349539bd 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -1,6 +1,8 @@ module HasCustomFields extend ActiveSupport::Concern + module Helpers + def self.append_field(target, key, value, types) if target.has_key?(key) target[key] = [target[key]] if !target[key].is_a? Array @@ -48,7 +50,6 @@ module HasCustomFields HasCustomFields::Helpers.append_field(target,key,value,@custom_field_types) end - def self.register_custom_field_type(name, type) @custom_field_types ||= {} @custom_field_types[name] = type diff --git a/app/models/group.rb b/app/models/group.rb index 6fa81ac2797..9d00deb9025 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -269,22 +269,23 @@ class Group < ActiveRecord::Base def add(user) self.users.push(user) end + protected - def name_format_validator - UsernameValidator.perform_validation(self, 'name') - end - - # hack around AR - def destroy_deletions - if @deletions - @deletions.each do |gu| - gu.destroy - User.where('id = ? AND primary_group_id = ?', gu.user_id, gu.group_id).update_all 'primary_group_id = NULL' - end + def name_format_validator + UsernameValidator.perform_validation(self, 'name') + end + + # hack around AR + def destroy_deletions + if @deletions + @deletions.each do |gu| + gu.destroy + User.where('id = ? AND primary_group_id = ?', gu.user_id, gu.group_id).update_all 'primary_group_id = NULL' + end + end + @deletions = nil end - @deletions = nil - end end diff --git a/script/import_scripts/base.rb b/script/import_scripts/base.rb index 7d454a7f6ac..849c175eba7 100644 --- a/script/import_scripts/base.rb +++ b/script/import_scripts/base.rb @@ -44,8 +44,12 @@ class ImportScripts::Base @existing_posts[import_id] = post_id end - Post.pluck(:id, :topic_id, :post_number).each do |post_id,t,n| - @topic_lookup[post_id] = {topic_id: t, post_number: n} + Post.joins(:topic).select("posts.id, posts.topic_id, posts.post_number, topics.slug").each do |post| + @topic_lookup[post.id] = { + topic_id: post.topic_id, + post_number: post.post_number, + url: post.url, + } end end @@ -202,21 +206,23 @@ class ImportScripts::Base next # block returns nil to skip a post end - if user_id_from_imported_user_id(u[:id]) + import_id = u[:id] + + if user_id_from_imported_user_id(import_id) users_skipped += 1 elsif u[:email].present? - new_user = create_user(u, u[:id]) + new_user = create_user(u, import_id) if new_user.valid? - @existing_users[u[:id].to_s] = new_user.id + @existing_users[import_id.to_s] = new_user.id users_created += 1 else @failed_users << u - puts "Failed to create user id: #{u[:id]}, username: #{new_user.username}, email: #{new_user.email}: #{new_user.errors.full_messages}" + puts "Failed to create user id: #{import_id}, username: #{new_user.username}, email: #{new_user.email}: #{new_user.errors.full_messages}" end else @failed_users << u - puts "Skipping user id #{u[:id]} because email is blank" + puts "Skipping user id #{import_id} because email is blank" end print_status users_created + users_skipped + @failed_users.length + (opts[:offset] || 0), total @@ -232,6 +238,7 @@ class ImportScripts::Base return existing if existing && existing.custom_fields["import_id"].to_i == import_id.to_i bio_raw = opts.delete(:bio_raw) + website = opts.delete(:website) avatar_url = opts.delete(:avatar_url) opts[:name] = User.suggest_name(opts[:email]) unless opts[:name] @@ -256,8 +263,9 @@ class ImportScripts::Base begin User.transaction do u.save! - if bio_raw.present? - u.user_profile.bio_raw = bio_raw + if bio_raw.present? || website.present? + u.user_profile.bio_raw = bio_raw if bio_raw.present? + u.user_profile.website = website if website.present? u.user_profile.save! end end @@ -284,7 +292,7 @@ class ImportScripts::Base def create_categories(results) results.each do |c| params = yield(c) - puts " #{params[:name]}" + puts "\t#{params[:name]}" # make sure categories don't go more than 2 levels deep if params[:parent_category_id] @@ -299,10 +307,11 @@ class ImportScripts::Base end def create_category(opts, import_id) - existing = category_from_imported_category_id(import_id) + existing = category_from_imported_category_id(import_id) || Category.where("LOWER(name) = ?", opts[:name].downcase).first return existing if existing post_create_action = opts.delete(:post_create_action) + new_category = Category.new( name: opts[:name], user_id: opts[:user_id] || opts[:user].try(:id) || -1, @@ -310,9 +319,12 @@ class ImportScripts::Base description: opts[:description], parent_category_id: opts[:parent_category_id] ) + new_category.custom_fields["import_id"] = import_id if import_id new_category.save! + post_create_action.try(:call, new_category) + new_category end @@ -343,7 +355,11 @@ class ImportScripts::Base new_post = create_post(params, import_id) if new_post.is_a?(Post) @existing_posts[import_id] = new_post.id - @topic_lookup[new_post.id] = {post_number: new_post.post_number, topic_id: new_post.topic_id} + @topic_lookup[new_post.id] = { + post_number: new_post.post_number, + topic_id: new_post.topic_id, + url: new_post.url, + } created += 1 else @@ -351,13 +367,13 @@ class ImportScripts::Base puts "Error creating post #{import_id}. Skipping." puts new_post.inspect 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 - rescue Discourse::InvalidAccess => e - skipped += 1 - puts "InvalidAccess creating post #{import_id}. Topic is closed? #{e.message}" end end @@ -455,7 +471,7 @@ class ImportScripts::Base end def print_status(current, max) - print "\r%9d / %d (%5.1f%%) " % [current, max, ((current.to_f / max.to_f) * 100).round(1)] + print "\r%9d / %d (%5.1f%%)" % [current, max, ((current.to_f / max.to_f) * 100).round(1)] end def batches(batch_size) diff --git a/script/import_scripts/vbulletin.rb b/script/import_scripts/vbulletin.rb index 3f2eda57850..791db618849 100644 --- a/script/import_scripts/vbulletin.rb +++ b/script/import_scripts/vbulletin.rb @@ -1 +1,591 @@ -# TODO: migrate it from https://github.com/discourse/discourse/blob/import-vbulletin/script/import_scripts/vbulletin.rb +require File.expand_path(File.dirname(__FILE__) + "/base.rb") +require "optparse" +require "csv" + +class ImportScripts::VBulletin < ImportScripts::Base + + attr_reader :options + + def self.run + options = Options.new + + begin + options.parse! + rescue OptionParser::MissingArgument, OptionParser::InvalidArgument => e + $stderr.puts e.to_s.capitalize + $stderr.puts options.usage + exit 1 + end + + new(options).perform + end + + def initialize(options) + super() + + @options = options + end + + def execute + load_groups_mapping + load_groups + load_users + + load_categories_mappings + load_categories + load_categories_permissions + + load_topics + load_posts + + import_groups + import_users + create_groups_membership + + import_categories + # import_category_groups + + preprocess_posts + + import_topics + import_posts + + postprocess_posts + + puts "", "Done" + end + + private + + ############################################################################ + # LOAD # + ############################################################################ + + def default_csv_options + @@default_csv_options ||= { headers: true, header_converters: :symbol } + end + + def load_groups_mapping + return if @options.group_mapping.blank? + + puts "", "Loading groups mappings from '#{@options.group_mapping}'..." + + data = File.read(@options.group_mapping) + @groups_mappings = CSV.parse(data, default_csv_options).map { |row| row.to_hash } + + @mapped_groups = {} + @new_group_ids = {} + + @groups_mappings.each do |gm| + @mapped_groups[gm[:old_id]] = gm + @new_group_ids[gm[:new_id]] ||= true + end + + puts "Loaded #{@groups_mappings.count} groups mappings for #{@new_group_ids.count} groups!" + end + + def load_groups + puts "", "Loading groups from '#{@options.user_group}'..." + + data = File.read(@options.user_group) + @groups = CSV.parse(data, default_csv_options).map { |row| row.to_hash } + original_count = @groups.count + + # reject unmapped groups + if @new_group_ids.try(:size) > 0 + @groups.reject! { |group| !@new_group_ids.has_key?(group[:usergroupid]) } + end + + puts "Loaded #{@groups.count} out of #{original_count} groups!" + end + + def load_users + puts "", "Loading users from '#{@options.user}'..." + + data = File.read(@options.user) + csv_options = default_csv_options.merge({ col_sep: "\t", quote_char: "\u200B" }) + @users = CSV.parse(data, csv_options).map { |row| row.to_hash } + original_count = @users.count + + if @mapped_groups.try(:size) > 0 + # reject users from unmapped groups + @users.reject! { |user| !@mapped_groups.has_key?(user[:usergroupid]) } + # change mapped groups + @users.each { |user| user[:usergroupid] = @mapped_groups[user[:usergroupid]][:new_id] } + end + + puts "Loaded #{@users.count} out of #{original_count} users!" + end + + def load_categories_mappings + return if @options.forum_mapping.blank? + + puts "", "Loading categories mappings from '#{@options.forum_mapping}'..." + + data = File.read(@options.forum_mapping) + @categories_mappings = CSV.parse(data, default_csv_options).map { |row| row.to_hash } + + @mapped_categories = {} + @new_category_ids = {} + + @categories_mappings.each do |cm| + @mapped_categories[cm[:old_id]] = cm + @new_category_ids[cm[:new_id]] ||= true + end + + puts "Loaded #{@categories_mappings.count} categories mappings for #{@new_category_ids.count} categories!" + end + + def load_categories + puts "", "Loading categories from '#{@options.forum}'..." + + data = File.read(@options.forum) + @categories = CSV.parse(data, default_csv_options).map { |row| row.to_hash } + original_count = @categories.count + + if @new_category_ids.try(:size) > 0 + # reject unmapped categories + @categories.reject! { |category| !@new_category_ids.has_key?(category[:forumid]) } + # update mapped categories' title + @categories.each { |category| category[:title] = @mapped_categories[category[:forumid]][:new_name] } + end + + puts "Loaded #{@categories.count} out of #{original_count} categories!" + end + + # extracted from the "bitfield_vbulletin.xml" file + VB_FORUM_PERMISSIONS_CAN_VIEW = 1 + VB_FORUM_PERMISSIONS_CAN_VIEW_THREADS = 524288 + VB_FORUM_PERMISSIONS_CAN_REPLY_OWN = 32 + VB_FORUM_PERMISSIONS_CAN_REPLY_OTHERS = 64 + VB_FORUM_PERMISSIONS_CAN_POST_NEW = 16 + + def translate_forum_permissions(permissions) + can_see = ((permissions & VB_FORUM_PERMISSIONS_CAN_VIEW) | (permissions & VB_FORUM_PERMISSIONS_CAN_VIEW_THREADS)) > 0 + can_reply = ((permissions & VB_FORUM_PERMISSIONS_CAN_REPLY_OWN) | (permissions & VB_FORUM_PERMISSIONS_CAN_REPLY_OTHERS)) > 0 + can_create = (permissions & VB_FORUM_PERMISSIONS_CAN_POST_NEW) > 0 + return CategoryGroup.permission_types[:full] if can_create + return CategoryGroup.permission_types[:create_post] if can_reply + return CategoryGroup.permission_types[:readonly] if can_see + nil + end + + def load_categories_permissions + puts "", "Loading categories permissions from '#{@options.forum_permission}'..." + + data = File.read(@options.forum_permission) + @categories_permissions = CSV.parse(data, default_csv_options).map { |row| row.to_hash } + original_count = @categories_permissions.count + + # reject unmapped groups + if @mapped_groups.try(:size) > 0 + @categories_permissions.reject! { |cp| !@mapped_groups.has_key?(cp[:usergroupid]) } + end + + # reject unmapped categories + if @mapped_categories.try(:size) > 0 + @categories_permissions.reject! { |cp| !@mapped_categories.has_key?(cp[:forumid]) } + end + + # translate permissions + @categories_permissions.each do |cp| + cp[:permission] = translate_forum_permissions(cp[:forumpermissions].to_i) + cp[:usergroupid] = @mapped_groups[cp[:usergroupid]][:new_id] + cp[:forumid] = @mapped_categories[cp[:forumid]][:new_id] + end + + # clean permissions up + @categories_permissions.reject! { |cp| cp[:permission].nil? } + + puts "Loaded #{@categories_permissions.count} out of #{original_count} categories permissions!" + end + + def load_topics + puts "", "Loading topics from '#{@options.thread}'..." + + data = File.read(@options.thread) + csv_options = default_csv_options.merge({ col_sep: "\t", quote_char: "\u200B" }) + @topics = CSV.parse(data, csv_options).map { |row| row.to_hash } + original_count = @topics.count + + if @mapped_categories.try(:size) > 0 + # reject topics from unmapped categories + @topics.reject! { |topic| !@mapped_categories.has_key?(topic[:forumid]) } + # change mapped categories + @topics.each do |topic| + topic[:old_forumid] = topic[:forumid] + topic[:forumid] = @mapped_categories[topic[:forumid]][:new_id] + end + end + + puts "Loaded #{@topics.count} out of #{original_count} topics!" + end + + def load_posts + puts "", "Loading posts from '#{@options.post}'..." + + data = File.read(@options.post) + csv_options = default_csv_options.merge({ col_sep: "\t", quote_char: "\u200B" }) + @posts = CSV.parse(data, csv_options).map { |row| row.to_hash } + original_count = @posts.count + + # reject posts without topics + topic_ids = Set.new(@topics.map { |t| t[:threadid] }) + @posts.reject! { |post| !topic_ids.include?(post[:threadid]) } + + puts "Loaded #{@posts.count} out of #{original_count} posts!" + end + + ############################################################################ + # IMPORT # + ############################################################################ + + def import_groups + puts "", "Importing groups..." + + create_groups(@groups) do |group| + { + id: group[:usergroupid], + name: group[:title], + } + end + + end + + def import_users + puts "", "Importing users..." + + @old_username_to_new_usernames = {} + + create_users(@users) do |user| + @old_username_to_new_usernames[user[:username]] = UserNameSuggester.suggest(user[:username]) + + { + id: user[:userid], + username: @old_username_to_new_usernames[user[:username]], + email: user[:email], + website: user[:homepage], + title: user[:usertitle], + primary_group_id: group_id_from_imported_group_id(user[:usergroupid]), + } + end + + end + + def create_groups_membership + puts "", "Creating groups membership..." + + Group.find_each do |group| + next if group.automatic + + puts "\t#{group.name}" + + next if GroupUser.where(group_id: group.id).count > 0 + + user_ids_in_group = User.where(primary_group_id: group.id).pluck(:id).to_a + next if user_ids_in_group.size == 0 + + values = user_ids_in_group.map { |user_id| "(#{group.id}, #{user_id})" }.join(",") + + User.exec_sql <<-SQL + BEGIN; + INSERT INTO group_users (group_id, user_id) VALUES #{values}; + COMMIT; + SQL + + Group.reset_counters(group.id, :group_users) + end + end + + def import_categories + puts "", "Importing categories..." + + create_categories(@categories) do |category| + { + id: category[:forumid], + name: category[:title].strip[0...50], + position: category[:displayorder].to_i, + description: category[:description], + } + end + + end + + def import_category_groups + puts "", "Importing category groups..." + + # TODO + end + + def preprocess_posts + puts "", "Preprocessing posts..." + + current = 0 + max = @posts.size + + @posts.each do |post| + post[:raw] = preprocess_post_raw(post[:pagetext]) + current += 1 + print_status(current, max) + end + end + + def preprocess_post_raw(raw) + return "" if raw.blank? + + raw = raw.gsub(/(\\r)?\\n/, "\n") + .gsub("\\t", "\t") + + # [URL]...[/URL] + raw = raw.gsub(/\[url\](.+?)\[\/url\]/i) { $1.to_s } + + # [MENTION][/MENTION] + raw = raw.gsub(/\[mention\](.+?)\[\/mention\]/i) do + old_username = $1 + if @old_username_to_new_usernames.has_key?(old_username) + username = @old_username_to_new_usernames[old_username] + "@#{username}" + else + $& + end + end + + # [MENTION=]...[/MENTION] + raw = raw.gsub(/\[mention=(\d+)\].+?\[\/mention\]/i) do + user_id = $1 + if user = @users.select { |u| u[:userid] == user_id }.first + username = @old_username_to_new_usernames[user[:username]] || user[:username] + "@#{username}" + else + $& + end + end + + # [QUOTE]...[/QUOTE] + raw = raw.gsub(/\[quote\](.+?)\[\/quote\]/im) { "\n> #{$1}\n" } + + # [QUOTE=]...[/QUOTE] + raw = raw.gsub(/\[quote=([^;\]]+)\](.+?)\[\/quote\]/im) do + old_username, quote = $1, $2 + if @old_username_to_new_usernames.has_key?(old_username) + username = @old_username_to_new_usernames[old_username] + "\n[quote=\"#{username}\"]\n#{quote}\n[/quote]\n" + else + $& + end + end + + # [HTML]...[/HTML] + # [PHP]...[/PHP] + ["html", "php"].each do |language| + raw = raw.gsub(/\[#{language}\](.+?)\[\/#{language}\]/im) { "\n```#{language}\n#{$1}\n```\n" } + end + + # [CODE]...[/CODE] + raw = raw.gsub(/\[code\](.+?)\[\/code\]/im) { "\n```\n#{$1}\n```\n" } + + # [HIGHLIGHT="..."]...[/HIGHLIGHT] + raw = raw.gsub(/\[highlight(?:[^\]]*)\](.+)\[\/highlight\]/im) { "\n```\n#{$1}\n```\n" } + + # [SAMP]...[SAMP] + raw = raw.gsub(/\[samp\](.+?)\[\/samp\]/i) { "`#{$1}`" } + + # [YOUTUBE][/YOUTUBE] + raw = raw.gsub(/\[youtube\](.+?)\[\/youtube\]/i) { "\n//youtu.be/#{$1}\n" } + + # [VIDEO=youtube;]...[/VIDEO] + raw = raw.gsub(/\[video=youtube;([^\]]+)\].*\[\/video\]/i) { "\n//youtu.be/#{$1}\n" } + + # [MP3][/MP3] + raw = raw.gsub(/\[MP3\](.+?)\[\/MP3\]/i) { "\n#{$1}\n" } + + raw + end + + def import_topics + puts "", "Importing topics..." + + create_posts(@topics) do |topic| + next unless post = @posts.select { |p| p[:postid] == topic[:firstpostid] }.first + + t = { + id: "thread#" + topic[:threadid], + user_id: user_id_from_imported_user_id(topic[:postuserid]) || Discourse::SYSTEM_USER_ID, + title: CGI.unescapeHTML(topic[:title]).strip[0...255], + category: category_from_imported_category_id(topic[:forumid]).try(:name), + raw: post[:raw], + created_at: Time.at(topic[:dateline].to_i), + visible: topic[:visible].to_i == 1, + closed: topic[:open].to_i == 0, + views: topic[:views].to_i, + } + + if topic[:sticky].to_i == 1 + t[:pinned_at] = t[:created_at] + end + + # tag + if (tag = @mapped_categories[topic[:old_forumid]][:tag] || "").present? + t[:custom_fields] ||= {} + t[:custom_fields]['tag'] = tag + end + + t + end + end + + def import_posts + puts "", "Importing posts..." + + # reject all first posts + first_post_ids = Set.new(@topics.map { |t| t[:firstpostid] }) + @posts.reject! { |post| first_post_ids.include?(post[:postid]) } + + create_posts(@posts) do |post| + t = topic_lookup_from_imported_post_id("thread#" + post[:threadid]) + + p = { + id: post[:postid], + user_id: user_id_from_imported_user_id(post[:postuserid]) || Discourse::SYSTEM_USER_ID, + topic_id: t[:topic_id], + raw: post[:raw], + created_at: Time.at(post[:dateline].to_i), + hidden: post[:visible].to_i == 0, + } + + if (edit_reason = (post[:editreason] || "").gsub("NULL", "")).present? + p[:edit_reason] = edit_reason + end + + if parent = topic_lookup_from_imported_post_id(post[:parentid]) + p[:reply_to_post_number] = parent[:post_number] + end + + p + end + end + + def postprocess_posts + puts "", "Postprocessing posts..." + + current = 0 + max = @posts.size + + @posts.each do |post| + new_raw = postprocess_post_raw(post[:raw]) + if new_raw != post[:raw] + p = Post.find(post_id_from_imported_post_id(post[:postid])) + p.raw = new_raw + p.save + end + current += 1 + print_status(current, max) + end + end + + def postprocess_post_raw(raw) + # [QUOTE=;]...[/QUOTE] + raw = raw.gsub(/\[quote=([^;]+);(\d+)\](.+?)\[\/quote\]/im) do + old_username, post_id, quote = $1, $2, $3 + if @old_username_to_new_usernames.has_key?(old_username) && topic_lookup = topic_lookup_from_imported_post_id(post_id) + post_number = topic_lookup[:post_number] + topic_id = topic_lookup[:topic_id] + username = @old_username_to_new_usernames[old_username] + "\n[quote=\"#{username},post:#{post_number},topic:#{topic_id}\"]\n#{quote}\n[/quote]\n" + else + $& + end + end + + # [THREAD][/THREAD] + # ==> http://my.discourse.org/t/slug/ + raw = raw.gsub(/\[thread\](\d+)\[\/thread\]/i) do + thread_id = $1 + if topic_lookup = topic_lookup_from_imported_post_id("thread#" + thread_id) + topic_lookup[:url] + else + $& + end + end + + # [THREAD=]...[/THREAD] + # ==> [...](http://my.discourse.org/t/slug/) + raw = raw.gsub(/\[thread=(\d+)\](.+?)\[\/thread\]/i) do + thread_id, link = $1, $2 + if topic_lookup = topic_lookup_from_imported_post_id("thread#" + thread_id) + url = topic_lookup[:url] + "[#{link}](#{url})" + else + $& + end + end + + # [POST][/POST] + # ==> http://my.discourse.org/t/slug// + raw = raw.gsub(/\[post\](\d+)\[\/post\]/i) do + post_id = $1 + if topic_lookup = topic_lookup_from_imported_post_id(post_id) + topic_lookup[:url] + else + $& + end + end + + # [POST=]...[/POST] + # ==> [...](http://my.discourse.org/t///) + raw = raw.gsub(/\[post=(\d+)\](.+?)\[\/post\]/i) do + post_id, link = $1, $2 + if topic_lookup = topic_lookup_from_imported_post_id(post_id) + url = topic_lookup[:url] + "[#{link}](#{url})" + else + $& + end + end + + raw + end + + ############################################################################ + # OPTIONS # + ############################################################################ + + class Options + + attr_accessor :user_group, :user, :forum, :forum_permission, :thread, :post + attr_accessor :group_mapping, :forum_mapping + + def parse!(args = ARGV) + parser.parse!(args) + + [:user_group, :user, :forum, :forum_permission, :thread, :post].each do |option_name| + option = self.send(option_name) + raise OptionParser::MissingArgument.new(option_name) if option.nil? + raise OptionParser::InvalidArgument.new("#{option} file does not exist") if !File.exists?(option) + end + end + + def usage + parser.to_s + end + + private + + def parser + @parser ||= OptionParser.new(nil, 50) do |opts| + opts.banner = "Usage:\truby #{File.basename($0)} [options]" + opts.on("--user-group USER-GROUP.csv", "list of usergroups") { |s| self.user_group = s } + opts.on("--user USER.csv", "list of users") { |s| self.user = s } + opts.on("--forum FORUM.csv", "list of forums") { |s| self.forum = s } + opts.on("--forum-permission FORUM-PERMISSION.csv", "list of forum permissions") { |s| self.forum_permission = s } + opts.on("--thread THREAD.csv", "list of threads") { |s| self.thread = s } + opts.on("--post POST.csv", "list of posts") { |s| self.post = s } + opts.on("--group-mapping GROUP-MAPPING.csv", "list of group mappings") { |s| self.group_mapping = s } + opts.on("--forum-mapping FORUM-MAPPING.csv", "list of forum mappings") { |s| self.forum_mapping = s } + end + end + end + +end + +ImportScripts::VBulletin.run