# frozen_string_literal: true require "mysql2" require_relative "base" require "htmlentities" require "uri" require "json" # Before running this script, paste these lines into your shell, # then use arrow keys to edit the values =begin export DB_HOST="localhost" export DB_NAME="mylittleforum" export DB_PW="" export DB_USER="root" export TABLE_PREFIX="forum_" export IMPORT_AFTER="1970-01-01" export IMAGE_BASE="http://www.example.com/forum" export BASE="forum" # Optional: set a parent category in Discourse. When set, all imported MLF # categories will be created as subcategories under this one. # export PARENT_CATEGORY="Existing Discourse Category Name" # Optional: rewrite absolute legacy links (index.php?id=...) to Discourse permalinks # using IMAGE_BASE as the legacy base. # export REWRITE_LINKS="true" # # Optional: local path to the legacy uploads directory (flat namespace; files like 20250706095752686a48a0d33d0.pdf). # If the original forum is behind auth or lacks disk space, this folder can be mounted via sshfs. # Note: The path may only contain ASCII letters, digits, underscores (_), hyphens (-), dots (.) and slashes (/). # Characters such as spaces, German umlauts (ä, ö, ü, ß) or any other special characters are not allowed, # otherwise JPG and PNG files may cause an "InvalidAccess" error. # The files mlf_missing_uploads.txt and mlf_upload_map.json are created in the script directory to keep track of uploads. # If you update the Docker container and perform another import afterwards, make sure to back up these files beforehand. # export UPLOADS_DIR="/path/to/mlf/uploads" # # Optional: repair pass to update already-imported posts that still contain legacy upload links # (e.g., files became available only after the first run). When enabled, only posts imported by # this script are scanned, and only links under IMAGE_BASE/…/images/uploaded are rewritten. # export REPAIR_UPLOAD_LINKS="true" # # Optional: temporarily relax upload constraints during the uploads phase. When enabled, increases # max_attachment_size_kb / max_image_size_kb to a generous value and temporarily allows all file # extensions ('*') so the import won't fail on extension checks. All settings are restored after # the uploads step. # export LOOSEN_UPLOAD_CONSTRAINTS="true" # # Optional: import users as staged. When enabled, users are created as staged instead of full accounts. # Useful for historical migrations or when email delivery should be suppressed until first login. # Set to "true" (case-insensitive) to enable. # export STAGE_IMPORTED_USERS="true" # # Optional: control when per-post subjects are written into imported replies. # When enabled (default), a reply subject is only added when it differs from the topic title, # to avoid repeating the same title in many posts. # When set to "false", the reply subject is always written above the post body. # export ONLY_WRITE_POST_SUBJECTS_WHEN_DIFFERENT="true" =end class ImportScripts::MylittleforumSQL < ImportScripts::Base DB_HOST = ENV["DB_HOST"] || "localhost" DB_NAME = ENV["DB_NAME"] || "mylittleforum" DB_PW = ENV["DB_PW"] || "" DB_USER = ENV["DB_USER"] || "root" TABLE_PREFIX = ENV["TABLE_PREFIX"] || "forum_" IMPORT_AFTER = ENV["IMPORT_AFTER"] || "1970-01-01" IMAGE_BASE = (ENV["IMAGE_BASE"] || "").strip BASE = ENV["BASE"] || "forum/" PARENT_CATEGORY = (ENV["PARENT_CATEGORY"] || "").strip REWRITE_LINKS = (ENV["REWRITE_LINKS"] || "").strip.casecmp?("true") UPLOADS_DIR = (ENV["UPLOADS_DIR"] || "").strip REPAIR_UPLOAD_LINKS = (ENV["REPAIR_UPLOAD_LINKS"] || "").strip.casecmp?("true") LOOSEN_UPLOAD_CONSTRAINTS = (ENV["LOOSEN_UPLOAD_CONSTRAINTS"] || "").strip.casecmp?("true") BATCH_SIZE = 1000 CONVERT_HTML = true QUIET = nil || ENV["VERBOSE"] == "TRUE" FORCE_HOSTNAME = nil || ENV["FORCE_HOSTNAME"] STAGE_IMPORTED_USERS = (ENV["STAGE_IMPORTED_USERS"] || "").strip.upcase == "TRUE" ONLY_WRITE_POST_SUBJECTS_WHEN_DIFFERENT = (ENV["ONLY_WRITE_POST_SUBJECTS_WHEN_DIFFERENT"] || "true").strip.casecmp?("true") QUIET = true # Site settings SiteSetting.disable_emails = "non-staff" SiteSetting.force_hostname = FORCE_HOSTNAME if FORCE_HOSTNAME def get_site_settings_for_import settings = super return settings unless LOOSEN_UPLOAD_CONSTRAINTS max_file_size_kb = 102_400 settings[:max_image_size_kb] = [max_file_size_kb, SiteSetting.max_image_size_kb].max settings[:max_attachment_size_kb] = [max_file_size_kb, SiteSetting.max_attachment_size_kb].max settings[:authorized_extensions] = "*" settings[:authorized_extensions_for_staff] = "*" # allow the bumped limits to pass validations during the import begin SiteSetting.type_supervisor.load_setting( :max_image_size_kb, max: settings[:max_image_size_kb], ) SiteSetting.type_supervisor.load_setting( :max_attachment_size_kb, max: settings[:max_attachment_size_kb], ) rescue => e print_warning("Could not widen validation caps: #{e.message}") end settings end def resolve_parent_category_id # Find existing Discourse category by name or slug; return id or nil return nil if PARENT_CATEGORY.empty? name = PARENT_CATEGORY parent = Category.where("lower(name) = ?", name.downcase).first || Category.where("lower(slug) = ?", name.parameterize.downcase).first unless parent print_warning( "Note: PARENT_CATEGORY='#{PARENT_CATEGORY}' not found. " \ "Import will create top-level categories.", ) return nil end parent.id end def initialize print_warning("Importing data after #{IMPORT_AFTER}") if IMPORT_AFTER > "1970-01-01" super @htmlentities = HTMLEntities.new begin @client = Mysql2::Client.new(host: DB_HOST, username: DB_USER, password: DB_PW, database: DB_NAME) rescue Exception => e puts "=" * 50 puts e.message puts <<~TEXT Cannot log in to database. Hostname: #{DB_HOST} Username: #{DB_USER} Password: #{DB_PW} database: #{DB_NAME} You should set these variables: export DB_HOST="localhost" export DB_NAME="mylittleforum" export DB_PW="" export DB_USER="root" export TABLE_PREFIX="forum_" export IMPORT_AFTER="1970-01-01" export IMAGE_BASE="http://www.example.com/forum" export BASE="forum" Exiting. TEXT exit end @parent_category_id = resolve_parent_category_id build_legacy_link_rewriter # uploads @script_dir = File.expand_path(File.dirname(__FILE__)) @upload_map_path = File.join(@script_dir, "mlf_upload_map.json") @missing_uploads_path = File.join(@script_dir, "mlf_missing_uploads.txt") @upload_map = load_upload_map(@upload_map_path) build_legacy_upload_rewriter end def execute import_users import_categories # Uploads first, so posts can link to already-imported Discourse uploads. import_uploads import_topics import_posts repair_legacy_upload_links if REPAIR_UPLOAD_LINKS update_tl0 create_permalinks # persist the upload map at the very end save_upload_map(@upload_map_path, @upload_map) end def import_users puts "", "creating users" # MLF 1.x used 'user_place', MLF 2.x uses 'user_location' location_col = if mysql_query("SHOW COLUMNS FROM #{TABLE_PREFIX}userdata LIKE 'user_place'").any? "user_place" else "user_location" end total_count = mysql_query( "SELECT count(*) count FROM #{TABLE_PREFIX}userdata WHERE last_login > '#{IMPORT_AFTER}';", ).first[ "count" ] batches(BATCH_SIZE) do |offset| results = mysql_query( " SELECT user_id as UserID, user_name as username, user_real_name as Name, user_email as Email, user_hp as website, #{location_col} as Location, profile as bio_raw, last_login as DateLastActive, user_ip as InsertIPAddress, user_pw as password, logins as days_visited, # user_stats registered as DateInserted, user_pw as password, user_type FROM #{TABLE_PREFIX}userdata WHERE last_login > '#{IMPORT_AFTER}' order by UserID ASC LIMIT #{BATCH_SIZE} OFFSET #{offset};", ) break if results.size < 1 next if all_records_exist? :users, results.map { |u| u["UserID"].to_i } create_users(results, total: total_count, offset: offset) do |user| # respect already "prepared" users created earlier (e.g. via mbox import) next if user["Email"].blank? if existing = User.find_by_email(user["Email"]) @lookup.add_user(user["UserID"].to_s, existing) next end next if @lookup.user_id_from_imported_user_id(user["UserID"]) # username = fix_username(user['username']) { id: user["UserID"], email: user["Email"], username: user["username"], name: user["Name"], staged: STAGE_IMPORTED_USERS, created_at: user["DateInserted"] == nil ? 0 : Time.zone.at(user["DateInserted"]), bio_raw: user["bio_raw"], registration_ip_address: user["InsertIPAddress"], website: user["user_hp"], password: user["password"], last_seen_at: user["DateLastActive"] == nil ? 0 : Time.zone.at(user["DateLastActive"]), location: user["Location"], admin: user["user_type"] == "admin", moderator: user["user_type"] == "mod", } end end end def fix_username(username) olduser = username.dup username.gsub!(/Dr\. /, "Dr") # no & username.gsub!(%r{[ +!/,*()?]}, "_") # can't have these username.gsub!(/&/, "_and_") # no & username.gsub!(/@/, "_at_") # no @ username.gsub!(/#/, "_hash_") # no & username.gsub!(/\'/, "") # seriously? username.gsub!(/[._]+/, "_") # can't have 2 special in a row username.gsub!(/_+/, "_") # could result in dupes, but wtf? username.gsub!(/_$/, "") # could result in dupes, but wtf? print_warning("#{olduser} --> #{username}") if olduser != username username end def import_categories puts "", "importing categories..." categories = mysql_query( " SELECT id as CategoryID, category as Name, description as Description FROM #{TABLE_PREFIX}categories ORDER BY CategoryID ASC ", ).to_a parent_id = @parent_category_id create_categories(categories) do |category| { id: category["CategoryID"], name: CGI.unescapeHTML(category["Name"]), description: CGI.unescapeHTML(category["Description"]), # When parent_id is nil, Discourse creates a top-level category (default behavior). parent_category_id: parent_id, } end end def import_topics puts "", "importing topics..." total_count = mysql_query( "SELECT count(*) count FROM #{TABLE_PREFIX}entries WHERE time > '#{IMPORT_AFTER}' AND pid = 0;", ).first[ "count" ] # Optional youtube_link column (not present in standard MLF 2.x) youtube_available = mysql_query("SHOW COLUMNS FROM #{TABLE_PREFIX}entries LIKE 'youtube_link'").any? optional_youtube_column = youtube_available ? ", youtube_link as youtube" : "" batches(BATCH_SIZE) do |offset| discussions = mysql_query( "SELECT id as DiscussionID, category as CategoryID, subject as Name, text as Body, time as DateInserted, #{optional_youtube_column} user_id as InsertUserID FROM #{TABLE_PREFIX}entries WHERE pid = 0 AND time > '#{IMPORT_AFTER}' ORDER BY time ASC LIMIT #{BATCH_SIZE} OFFSET #{offset};", ) break if discussions.size < 1 if all_records_exist? :posts, discussions.map { |t| "discussion#" + t["DiscussionID"].to_s } next end create_posts(discussions, total: total_count, offset: offset) do |discussion| raw = clean_up(discussion["Body"]) youtube = nil if discussion["youtube"].present? youtube = clean_youtube(discussion["youtube"]) raw += "\n#{youtube}\n" print_warning(raw) end raw = rewrite_legacy_links(raw) raw = rewrite_legacy_uploads(raw) { id: "discussion#" + discussion["DiscussionID"].to_s, user_id: user_id_from_imported_user_id(discussion["InsertUserID"]) || Discourse::SYSTEM_USER_ID, title: discussion["Name"].gsub('\\"', '"'), category: category_id_from_imported_category_id(discussion["CategoryID"]), raw: raw, created_at: Time.zone.at(discussion["DateInserted"]), } end end end def import_posts puts "", "importing posts..." total_count = mysql_query( "SELECT count(*) count FROM #{TABLE_PREFIX}entries WHERE pid > 0 AND time > '#{IMPORT_AFTER}';", ).first[ "count" ] # Optional youtube_link column (not present in standard MLF 2.x) youtube_available = mysql_query("SHOW COLUMNS FROM #{TABLE_PREFIX}entries LIKE 'youtube_link'").any? optional_youtube_column = youtube_available ? ", youtube_link as youtube" : "" batches(BATCH_SIZE) do |offset| sql = if ONLY_WRITE_POST_SUBJECTS_WHEN_DIFFERENT " SELECT p.id as CommentID, p.tid as DiscussionID, CASE WHEN p.subject <> t.subject THEN p.subject END as Subject, p.text as Body, p.time as DateInserted #{optional_youtube_column}, p.user_id as InsertUserID FROM #{TABLE_PREFIX}entries p JOIN #{TABLE_PREFIX}entries t ON p.tid = t.id WHERE p.pid > 0 AND p.time > '#{IMPORT_AFTER}' ORDER BY p.time ASC LIMIT #{BATCH_SIZE} OFFSET #{offset}; " else " SELECT id as CommentID, tid as DiscussionID, subject as Subject, text as Body, time as DateInserted #{optional_youtube_column}, user_id as InsertUserID FROM #{TABLE_PREFIX}entries WHERE pid > 0 AND time > '#{IMPORT_AFTER}' ORDER BY time ASC LIMIT #{BATCH_SIZE} OFFSET #{offset}; " end comments = mysql_query(sql) break if comments.size < 1 if all_records_exist? :posts, comments.map { |comment| "comment#" + comment["CommentID"].to_s } next end create_posts(comments, total: total_count, offset: offset) do |comment| unless t = topic_lookup_from_imported_post_id("discussion#" + comment["DiscussionID"].to_s) next end next if comment["Body"].blank? raw = clean_up(comment["Body"]) youtube = nil if comment["youtube"].present? youtube = clean_youtube(comment["youtube"]) raw += "\n#{youtube}\n" end raw = rewrite_legacy_links(raw) raw = rewrite_legacy_uploads(raw) if comment["Subject"].present? subject = comment["Subject"].to_s first_line = raw.lines.find { |l| !l.strip.empty? }&.strip || "" raw = "**#{subject}**\n\n#{raw}" unless first_line.start_with?(subject) end { id: "comment#" + comment["CommentID"].to_s, user_id: user_id_from_imported_user_id(comment["InsertUserID"]) || Discourse::SYSTEM_USER_ID, topic_id: t[:topic_id], raw: clean_up(raw), created_at: Time.zone.at(comment["DateInserted"]), } end end end def clean_youtube(youtube_raw) youtube_cooked = clean_up(youtube_raw.dup.to_s) # get just src from }mix youtube_cooked.gsub!(re) { "\n#{$1}\n" } re = %r{}mix youtube_cooked.gsub!(re) { "\n#{$1}\n" } youtube_cooked.gsub!(%r{^//}, "https://") # make sure it has a protocol unless /http/.match(youtube_cooked) # handle case of only youtube object number if youtube_cooked.length < 8 || /[<>=]/.match(youtube_cooked) # probably not a youtube id youtube_cooked = "" else youtube_cooked = "https://www.youtube.com/watch?v=" + youtube_cooked end end print_warning("#{"-" * 40}\nBefore: #{youtube_raw}\nAfter: #{youtube_cooked}") unless QUIET youtube_cooked end def clean_up(raw) return "" if raw.blank? # decode HTML entities raw = @htmlentities.decode(raw) # don't \ quotes raw = raw.gsub('\\"', '"') raw = raw.gsub("\\'", "'") raw = raw.gsub(/\[b\]/i, "") raw = raw.gsub(%r{\[/b\]}i, "") raw = raw.gsub(/\[i\]/i, "") raw = raw.gsub(%r{\[/i\]}i, "") raw = raw.gsub(/\[u\]/i, "") raw = raw.gsub(%r{\[/u\]}i, "") raw = raw.gsub(%r{\[url\](\S+)\[/url\]}im) { "#{$1}" } raw = raw.gsub(%r{\[link\](\S+)\[/link\]}im) { "#{$1}" } # URL & LINK with text raw = raw.gsub(%r{\[url=(\S+?)\](.*?)\[/url\]}im) { "#{$2}" } raw = raw.gsub(%r{\[link=(\S+?)\](.*?)\[/link\]}im) { "#{$2}" } # remote images raw = raw.gsub(%r{\[img\](https?:.+?)\[/img\]}im) { "" } raw = raw.gsub(%r{\[img=(https?.+?)\](.+?)\[/img\]}im) { "\"#{$2}\"" } # local images raw = raw.gsub(%r{\[img\](.+?)\[/img\]}i) { "" } raw = raw.gsub(%r{\[img=(.+?)\](https?.+?)\[/img\]}im) do "\"#{$2}\"" end # Convert image bbcode raw.gsub!(%r{\[img=(\d+),(\d+)\]([^\]]*)\[/img\]}im, '') # [div]s are really [quote]s raw.gsub!(/\[div\]/mix, "[quote]") raw.gsub!(%r{\[/div\]}mix, "[/quote]") # BBCode list to Markdown (unordered) raw.gsub!(/\[list\]/i, "") raw.gsub!(%r{\[/list\]}i, "") raw.gsub!(/\[\*\]\s*/i, "* ") # [postedby] -> link to @user raw.gsub(%r{\[postedby\](.+?)\[b\](.+?)\[/b\]\[/postedby\]}i) { "#{$1}@#{$2}" } # CODE (not tested) raw = raw.gsub(%r{\[code\](\S+)\[/code\]}im) { "```\n#{$1}\n```" } raw = raw.gsub(%r{\[pre\](\S+)\[/pre\]}im) { "```\n#{$1}\n```" } raw = raw.gsub(%r{(https://youtu\S+)}i) { "\n#{$1}\n" } #youtube links on line by themselves # no center raw = raw.gsub(%r{\[/?center\]}i, "") # no size raw = raw.gsub(%r{\[/?size.*?\]}i, "") ### FROM VANILLA: # fix whitespaces raw = raw.gsub(/(\\r)?\\n/, "\n").gsub("\\t", "\t") # enforce block boundaries so BBCode [quote] is parsed reliably raw.gsub!(/[ \t]*\n?[ \t]*\[quote\][ \t]*/i) { "\n\n[quote]\n" } raw.gsub!(%r{[ \t]*\[/quote\][ \t]*\n?}i) { "\n[/quote]\n\n" } # end a Markdown blockquote unless the next line intentionally continues it # (next line starts with '>' or a list marker like '- ', '* ', '1. ') # prevent Markdown lazy-continuation from pulling the next paragraph into the quote raw = ensure_quote_breaks(raw) unless CONVERT_HTML # replace all chevrons with HTML entities # NOTE: must be done # - AFTER all the "code" processing # - BEFORE the "quote" processing raw = raw .gsub(/`([^`]+)`/im) { "`" + $1.gsub("<", "\u2603") + "`" } .gsub("<", "<") .gsub("\u2603", "<") raw = raw .gsub(/`([^`]+)`/im) { "`" + $1.gsub(">", "\u2603") + "`" } .gsub(">", ">") .gsub("\u2603", ">") end # Remove the color tag raw.gsub!(/\[color=[#a-z0-9]+\]/i, "") raw.gsub!(%r{\[/color\]}i, "") ### END VANILLA: raw end def staff_guardian @_staff_guardian ||= Guardian.new(Discourse.system_user) end def mysql_query(sql) @client.query(sql) # @client.query(sql, cache_rows: false) #segfault: cache_rows: false causes segmentation fault end def create_permalinks puts "", "Creating redirects...", "" puts "", "Users...", "" User.find_each do |u| ucf = u.custom_fields if ucf && ucf["import_id"] && ucf["import_username"] begin Permalink.create( url: "#{BASE}/user-id-#{ucf["import_id"]}.html", external_url: "/u/#{u.username}", ) rescue StandardError nil end print "." end end puts "", "Posts...", "" Post.find_each do |post| pcf = post.custom_fields if pcf && pcf["import_id"] topic = post.topic id = pcf["import_id"].split("#").last if post.post_number == 1 begin Permalink.create(url: "#{BASE}/forum_entry-id-#{id}.html", topic_id: topic.id) rescue StandardError nil end unless QUIET print_warning("forum_entry-id-#{id}.html --> http://localhost:3000/t/#{topic.id}") end else begin Permalink.create(url: "#{BASE}/forum_entry-id-#{id}.html", post_id: post.id) rescue StandardError nil end unless QUIET print_warning( "forum_entry-id-#{id}.html --> http://localhost:3000/t/#{topic.id}/#{post.id}", ) end end print "." end end puts "", "Categories...", "" Category.find_each do |cat| ccf = cat.custom_fields next unless id = ccf["import_id"] print_warning("forum-category-#{id}.html --> /t/#{cat.id}") unless QUIET begin Permalink.create(url: "#{BASE}/forum-category-#{id}.html", category_id: cat.id) rescue StandardError nil end print "." end end def print_warning(message) $stderr.puts "#{message}" end # ensure a blank line after a quoted block unless the next line intentionally continues it def ensure_quote_breaks(text) return text if text.blank? out = [] in_quote = false lines = text.split("\n", -1) # keep trailing empties lines.each do |line| if line =~ /\A\s*>/ in_quote = true out << line next end if in_quote # leaving a quote: if the current non-quote line is NOT a continuation marker, insert a blank line before it unless line =~ /\A\s*(?:>|[-*]\s|\d+\.\s)/ out << "" unless out.last == "" end end in_quote = false out << line end out.join("\n") end private def build_legacy_link_rewriter @rewrite_links_enabled = false return unless REWRITE_LINKS return if IMAGE_BASE.empty? begin u = URI.parse(IMAGE_BASE) host = u.host&.downcase path = u.path.to_s return unless host # normalize host (allow matching with/without www) host = host.delete_prefix("www.") # normalize path (no trailing slash) path = path.delete_suffix("/") host_esc = Regexp.escape(host) path_esc = Regexp.escape(path) # match absolute legacy links: http(s)://(www.)?host/path/index.php?id=123... @legacy_link_regex = %r{https?://(?:www\.)?#{host_esc}#{path_esc}/index\.php\?id=(\d+)(?:[&#][^"\s<]*)?}i @rewrite_links_enabled = true rescue => e print_warning( "Note: REWRITE_LINKS enabled but IMAGE_BASE invalid (#{e.message}). Skipping link rewrite.", ) @rewrite_links_enabled = false end end def rewrite_legacy_links(raw) return raw unless @rewrite_links_enabled return raw if raw.blank? prefix = "/#{BASE.to_s.strip.sub(%r{\A/}, "").sub(%r{/\z}, "")}" prefix = "" if prefix == "/" # 1) rewrite only href="LEGACY..." raw = raw.gsub(/(\bhref\s*=\s*["'])#{@legacy_link_regex}/i) do %(#{$1}#{prefix}/forum_entry-id-#{$2}.html) end # 2) rewrite naked legacy URLs to clickable anchors (avoid attr contexts) raw = raw.gsub(/(?#{path}) end raw end # # uploads support # def build_legacy_upload_rewriter @rewrite_uploads_enabled = false return if IMAGE_BASE.empty? begin u = URI.parse(IMAGE_BASE) host = u.host path = u.path || "" return unless host host = host.sub(/\Awww\./i, "") path = path.sub(%r{/\z}, "") host_esc = Regexp.escape(host) path_esc = Regexp.escape(path) # match absolute legacy uploads: http(s)://(www.)?host/path/images/uploaded/ @legacy_upload_regex = %r{https?://(?:www\.)?#{host_esc}#{path_esc}/images/uploaded/([^\s"'<>\?#]+)}i @rewrite_uploads_enabled = true rescue => e print_warning( "Note: IMAGE_BASE invalid for upload rewrite (#{e.message}). Skipping upload link rewrite.", ) @rewrite_uploads_enabled = false end end def relative_upload_url(url) return url if url.blank? url.sub(%r{\Ahttps?://[^/]+}, "") end def load_upload_map(path) if File.exist?(path) JSON.parse(File.read(path)) else {} end rescue => e print_warning("Could not read upload map at #{path}: #{e.message}") {} end def save_upload_map(path, map) File.open(path, "w") { |f| f.write(JSON.pretty_generate(map)) } rescue => e print_warning("Could not write upload map at #{path}: #{e.message}") end def append_missing_upload(filename) begin File.open(@missing_uploads_path, "a") { |f| f.puts(filename) } rescue => e print_warning( "Could not append to missing uploads list #{@missing_uploads_path}: #{e.message}", ) end end def find_uploads_table_name # try common variants candidates = ["#{TABLE_PREFIX}uploads", "mlf_uploads", "#{TABLE_PREFIX}mlf_uploads"] names = @client.query("SHOW TABLES").map { |r| r.values.first.to_s } candidates.find { |t| names.include?(t) } end def import_uploads return if UPLOADS_DIR.empty? table = find_uploads_table_name unless table print_warning( "No uploads table found (tried '#{TABLE_PREFIX}uploads', 'mlf_uploads'). Skipping uploads import.", ) return end puts "", "importing uploads from #{table}..." # only import uploads newer than IMPORT_AFTER (consistent with topics/posts) total = mysql_query( "SELECT count(*) AS c FROM #{table} WHERE tstamp > '#{IMPORT_AFTER}'", ).first[ "c" ] offset = 0 missing = 0 created = 0 skipped = 0 while offset < total rows = mysql_query( "SELECT id, uploader, filename, tstamp FROM #{table} WHERE tstamp > '#{IMPORT_AFTER}' ORDER BY id ASC LIMIT #{BATCH_SIZE} OFFSET #{offset}", ).to_a break if rows.empty? rows.each do |row| fn = row["filename"].to_s next if fn.blank? if @upload_map.key?(fn) skipped += 1 next end base_fn = fn path = File.join(UPLOADS_DIR, fn) actual_fn = fn # handle filenames without extension in DB: try to find ".*" on disk unless File.exist?(path) if File.extname(base_fn).to_s.empty? candidates = Dir.glob(File.join(UPLOADS_DIR, "#{base_fn}.*")) if candidates.any? path = candidates.first actual_fn = File.basename(path) # don't double-import if we already mapped the on-disk name somehow if @upload_map.key?(actual_fn) @upload_map[base_fn] = @upload_map[actual_fn] skipped += 1 next end print_warning("Resolved extensionless '#{base_fn}' to '#{actual_fn}'") end end end unless File.exist?(path) missing += 1 append_missing_upload(fn) next end uploader_id = user_id_from_imported_user_id(row["uploader"]) || Discourse::SYSTEM_USER_ID begin File.open(path, "rb") do |file| # create an Upload in Discourse; Discourse deduplicates identical files internally # note: the uploader context is determined by the argument to `create_for` upload = UploadCreator.new(file, File.basename(path), type: "attachment").create_for( uploader_id, ) if upload && upload.url url = relative_upload_url(upload.url) # map both keys: the DB name (possibly without extension) and the actual filename @upload_map[base_fn] = url @upload_map[actual_fn] = url unless actual_fn == base_fn created += 1 else missing += 1 append_missing_upload(fn) begin print_warning( "Upload returned no URL for #{actual_fn} (ext='#{File.extname(actual_fn).delete(".")}', size=#{File.size(path)} bytes)", ) rescue StandardError nil end end end rescue => e bt = (e.backtrace && e.backtrace.first) ? " @ #{e.backtrace.first}" : "" print_warning("Upload failed for #{actual_fn}: #{e.class}: #{e.message}#{bt}") missing += 1 append_missing_upload(base_fn) end end offset += rows.length save_upload_map(@upload_map_path, @upload_map) # persist incrementally end puts "uploads: created=#{created}, skipped=#{skipped}, missing=#{missing}" end def rewrite_legacy_uploads(raw) return raw if raw.blank? return raw unless @rewrite_uploads_enabled return raw if @upload_map.nil? || @upload_map.empty? # 0) [img]LEGACY[/img] -> [img]NEW[/img] raw = raw.gsub(%r{\[img\]\s*(#{@legacy_upload_regex})\s*\[/img\]}i) do legacy = $1 # full legacy URL filename = $2 # captured filename from @legacy_upload_regex new_url = @upload_map[filename] new_url ? "[img]#{new_url}[/img]" : $& end # 0b) -> src="NEW" raw = raw.gsub(/(]*\bsrc\s*=\s*["'])(#{@legacy_upload_regex})(["'][^>]*>)/i) do pre = $1 filename = $3 # 1=pre, 2=full legacy URL, 3=filename, 4=post post = $4 new_url = @upload_map[filename] new_url ? "#{pre}#{new_url}#{post}" : $& end # 1) rewrite only href="LEGACY_UPLOAD..." (1=href prefix, 2=full legacy URL, 3=filename) raw = raw.gsub(/(\bhref\s*=\s*["'])#{@legacy_upload_regex}/i) do # @legacy_upload_regex exposes the filename as its single capture group filename = $2 new_url = @upload_map[filename] new_url ? "#{$1}#{new_url}" : $& end # 2) rewrite naked legacy upload URLs in plain text (groups: 1=prefix, 2=filename) raw = raw.gsub(/(^|[\s\(\[\{>"'])#{@legacy_upload_regex}(?=$|[\s\)\]\}',\.\!\?:;])/i) do prefix = $1 filename = $2 new_url = @upload_map[filename] new_url ? %(#{prefix}#{new_url}) : $& end raw end def repair_legacy_upload_links puts "", "repairing legacy upload links in imported posts...", "" return unless @rewrite_uploads_enabled ids = PostCustomField.where(name: "import_id").pluck(:post_id) return if ids.empty? Post .where(id: ids) .where("raw LIKE '%/images/uploaded/%'") .find_in_batches(batch_size: BATCH_SIZE) do |posts| posts.each do |post| begin old_raw = post.raw.to_s next if old_raw.blank? #next unless old_raw =~ @legacy_upload_regex next if old_raw.exclude?("/images/uploaded/") begin total_legacy = old_raw.scan(@legacy_upload_regex).length mapped_legacy = old_raw .scan(@legacy_upload_regex) .count { |m| @upload_map[m.is_a?(Array) ? m.last : m] } if total_legacy > 0 print_warning( "Repair post #{post.id}: legacy=#{total_legacy}, mapped=#{mapped_legacy}", ) end rescue StandardError end new_raw = rewrite_legacy_uploads(old_raw) next if new_raw == old_raw post.raw = new_raw post.save! post.rebake! print "." rescue => e print_warning("Repair failed for post #{post.id}: #{e.message}") end end end puts "", "repair pass finished", "" end end ImportScripts::MylittleforumSQL.new.perform