# XenForo를 통해 vBulletin 3 포럼을 Discourse로 이전하기

**URL:** https://meta.discourse.org/t/migrate-a-vbulletin-3-forum-to-discourse-via-xenforo/177573
**Category:** Sysadmins
**Tags:** how-to
**Created:** [1월 28, 2021, 5:24오후 UTC](https://meta.discourse.org/t/migrate-a-vbulletin-3-forum-to-discourse-via-xenforo/177573 "2021-01-28T17:24:50Z")
**Posts on this page:** 1
**Page:** 1

<div class="post-metadata">

### Author: ![AstonJ](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/astonj/32/215041_2.png) [@AstonJ](https://meta.discourse.org/u/AstonJ)
#### Post date: [1월 28, 2021, 5:24오후 UTC](https://meta.discourse.org/t/migrate-a-vbulletin-3-forum-to-discourse-via-xenforo/177573/1 "2021-01-28T17:24:50Z")

</div>

아직 머릿속에 선명할 때 바로 정리해 봅니다. 현재 진행 중인 작업물이므로, 직접 테스트해보고 본인에게 필요한지 확인해 주세요.

제가 아는 한 vB3에서 Discourse로 가져오는 이모터(importer)는 없으며, Discourse 이모터가 대상인 vB4/5 라이선스도 가지고 있지 않습니다. 하지만 저는 Xenforo 1.4 라이선스는 가지고 있고, 이걸 위한 Discourse 이모터는 존재합니다! XF 라이선스가 없는 분들을 위해, 중고 시장에서 구매할 수 있거나, 누군가에게 비용을 지불하고 이모트를 대행받아 XF 데이터베이스를 받아오는 것도 방법입니다.

이전에 vB3.6에서 XF로의 이모트를 해본 경험이 있어 정상적으로 작동한다는 것을 알고 있습니다(유일하게 이모트되지 않는 것은 프로필 사진인데, XF에는 아바타만 있기 때문입니다. 하지만 이에 대한 해결책이 있습니다).

## 시작합니다…

먼저 평소처럼 vB 포럼을 XF로 이모트하세요.

vB 포럼을 인터넷에서 라이브 상태로 유지하고 접근 가능하게 두는 것을 권장합니다(이 경우 XF로의 이모트를 하위 디렉토리로 수행하세요). 이는 나중에 vB 포럼을 유지하기로 결정한 경우를 대비한 안전장치이며, 또한 라이브 사이트에서 프로필 사진을 복사해 올 것이기 때문입니다(실제로 필요하다면 아래에 있는 프로필 복사 스크립트를 미리 실행할 수도 있습니다).

포럼이 XF로 성공적으로 변환되었는지 확인한 후, 이 새로운 데이터베이스를 백업하고 개발 머신(dev machine)으로 복사하세요.

제 개발 머신은 Mac이므로 이 지침은 macOS를 대상으로 합니다.

```plaintext
brew install mysql 
// 또한 실행 중인지 확인하세요

mysql -u root

create database xenforo_db;
exit;

mysql -u root -p xenforo_db < /path/to/your/backup/and/downloaded/xenforo_db.sql

```

Discourse 개발 환경을 정상적으로 설정한 후( macOS용은 [여기](https://meta.discourse.org/t/beginners-guide-to-install-discourse-on-macos-for-development/15772) 참고) 다음을 수행하세요:

`database.yml`을 열고 데이터베이스 이름을 `discourse_development_sitename_01`과 같은 것으로 변경하세요 - 숫자를 사용하면 번호만 변경하여 이모트를 몇 번이고 다시 실행할 수 있습니다.

```plaintext
bundle
bundle exec rake db:create
bundle exec rake db:migrate
RAILS_ENV=development bundle exec rake admin:create 
RAILS_ENV=development bundle exec rake admin:create

```

첫 번째 관리자 계정은 기존 vB/XF 설치 환경의 관리자 계정과 동일한 이메일 주소를 사용하도록 시도해 보세요. 관리자 권한을 부여할지 묻는 질문에 'Y’를 선택하세요.

계정 생성의 두 번째 단계에서는 이메일을 `guest@something.com`과 같은 것으로 설정하고, 관리자 계정으로 만들지 묻는 질문에 'n’을 선택하세요. 이 계정은 게스트/삭제된 사용자와 관련된 게시물에 필요합니다. `rails c`에 접속한 후 `User.last`를 실행하여 ID를 확인할 수 있지만, 아마도 `2`일 것입니다. 이 값을 이모터 스크립트에 추가할 것입니다.

이모터 스크립트에 몇 가지 변경 사항을 가했는데, 제 버전의 스크립트는 다음과 같습니다(`script/import_scripts/xenforo.rb`의 내용을 이것으로 교체하세요):

```ruby
# frozen_string_literal: true

require "mysql2"
require_relative "base"

require "set" # 필요하지 않을 수 있음 - 왜 추가했는지 이제 기억이 안 남
require "htmlentities" # 필요하지 않을 수 있음 - 왜 추가했는지 이제 기억이 안 남

require File.expand_path(File.dirname( __FILE__ ) + "/base.rb")

# 이렇게 호출하세요:
# RAILS_ENV=production bundle exec ruby script/import_scripts/xenforo.rb
class ImportScripts::XenForo < ImportScripts::Base

  XENFORO_DB = "xenforo_db_3"
  TABLE_PREFIX = "xf_"
  BATCH_SIZE = 1000
  ATTACHMENT_DIR = '/full/path/to/attachments/eg/name/projects/discourse/sitename/discourse/tmp/attachments'
  AVATAR_DIR = '/full/path/to/avatars/eg/name/projects/discourse/sitename/discourse/tmp/avatars'
  PROFILE_PIC_DIR = '/full/path/to/profilepics/eg/name/projects/discourse/sitename/discourse/tmp/profilepics'

  def initialize
    super
    @client = Mysql2::Client.new(
      host: "localhost",
      username: "root",
      password: "",
      database: XENFORO_DB
    )

    @category_mappings = {}
    @prefix_as_category = false
  end

  def execute
    import_users
    import_avatars
    import_categories
    import_posts
  end

  def import_users
    puts '', "creating users"

    total_count = mysql_query("SELECT count(*) count FROM #{TABLE_PREFIX}user;").first['count']

    batches(BATCH_SIZE) do |offset|
      results = mysql_query(
        "SELECT user_id id, username, email, custom_title title, register_date created_at,
                last_activity last_visit_time, user_group_id, is_moderator, is_admin, is_staff
         FROM #{TABLE_PREFIX}user
         LIMIT #{BATCH_SIZE}
         OFFSET #{offset};")

      break if results.size < 1

      next if all_records_exist? :users, results.map { |u| u["id"].to_i }

      create_users(results, total: total_count, offset: offset) do |user|
        next if user['username'].blank?
        { id: user['id'],
          email: user['email'],
          username: user['username'],
          title: user['title'],
          created_at: Time.zone.at(user['created_at']),
          last_seen_at: Time.zone.at(user['last_visit_time']),
          moderator: user['is_moderator'] == 1 || user['is_staff'] == 1,
          admin: user['is_admin'] == 1 }
      end
    end
  end

  def import_user_profiles
    puts "Importing user profiles..."

    user_profiles = mysql_query("
        SELECT user_id, location, about
        FROM #{TABLE_PREFIX}user_profile
        ORDER BY user_id;
    ")
    
    puts "Importing profiles: fetching info"
    user_profiles.each do |row|
      usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
      if user = User.find(usf.user_id)
        puts "Updating profile for #{user.username}"
        profile = user.user_profile
        profile.location = row["location"]
        profile.bio_raw = row["about"]
        profile.save
      end
    end
  end

  def import_categories
    puts "", "importing categories..."

    categories = mysql_query("
        SELECT node_id id,
               title,
               description,
               parent_node_id,
               display_order
          FROM #{TABLE_PREFIX}node
      ORDER BY parent_node_id, display_order
      ").to_a

    top_level_categories = categories.select { |c| c["parent_node_id"] == 0 }

    create_categories(top_level_categories) do |c|
      {
        id: c['id'],
        name: c['title'],
        description: c['description'],
        position: c['display_order']
      }
    end

    top_level_category_ids = Set.new(top_level_categories.map { |c| c["id"] })

    subcategories = categories.select { |c| top_level_category_ids.include?(c["parent_node_id"]) }

    create_categories(subcategories) do |c|
      {
        id: c['id'],
        name: c['title'],
        description: c['description'],
        position: c['display_order'],
        parent_category_id: category_id_from_imported_category_id(c['parent_node_id'])
      }
    end

    subcategory_ids = Set.new(subcategories.map { |c| c["id"] })

    # 더 깊은 수준의 카테고리는 태그여야 합니다
    categories.each do |c|
      next if c['parent_node_id'] == 0
      next if top_level_category_ids.include?(c['id'])
      next if subcategory_ids.include?(c['id'])

      # 이 카테고리의 주제를 위한 하위 카테고리를 찾습니다
      parent = c
      while !parent.nil? && !subcategory_ids.include?(parent['id'])
        parent = categories.find { |subcat| subcat['id'] == parent['parent_node_id'] }
      end

      if parent
        tag_name = DiscourseTagging.clean_tag(c['title'])
        @category_mappings[c['id']] = {
          category_id: category_id_from_imported_category_id(parent['id']),
          tag: Tag.find_by_name(tag_name) || Tag.create(name: tag_name)
        }
      else
        puts '', "Couldn't find a category for #{c['id']} '#{c['title']}'!"
      end
    end
  end

  # 이 메서드는 import_categories의 대안입니다.
  # 노드 대신 접두사(prefix)를 사용합니다.
  def import_categories_from_thread_prefixes
    puts "", "importing categories..."

    categories = mysql_query("
                              SELECT prefix_id id
                              FROM #{TABLE_PREFIX}thread_prefix
                              ORDER BY prefix_id ASC
                            ").to_a

    create_categories(categories) do |category|
      {
        id: category["id"],
        name: "Category-#{category["id"]}"
      }
    end

    @prefix_as_category = true
  end

  def import_posts
    puts "", "creating topics and posts"

    total_count = mysql_query("SELECT count(*) count from #{TABLE_PREFIX}post").first["count"]

    posts_sql = "
        SELECT p.post_id id,
               t.thread_id topic_id,
               #{@prefix_as_category ? 't.prefix_id' : 't.node_id'} category_id,
               t.title title,
               t.first_post_id first_post_id,
               p.user_id user_id,
               p.message raw,
               p.post_date created_at
        FROM #{TABLE_PREFIX}post p,
             #{TABLE_PREFIX}thread t
        WHERE p.thread_id = t.thread_id
        AND p.message_state = 'visible'
        AND t.discussion_state = 'visible'
        ORDER BY p.post_date
        LIMIT #{BATCH_SIZE}" # OFFSET 필요

    batches(BATCH_SIZE) do |offset|
      results = mysql_query("#{posts_sql} OFFSET #{offset};").to_a

      break if results.size < 1
      next if all_records_exist? :posts, results.map { |p| p['id'] }

      create_posts(results, total: total_count, offset: offset) do |m|
        skip = false
        mapped = {}

        mapped[:id] = m['id']
        mapped[:user_id] = user_id_from_imported_user_id(m['user_id']) || 2
        mapped[:raw] = process_xenforo_post(m['raw'], m['id'])
        mapped[:created_at] = Time.zone.at(m['created_at'])

        if m['id'] == m['first_post_id']
          if m['category_id'].to_i == 0 || m['category_id'].nil?
            mapped[:category] = SiteSetting.uncategorized_category_id
          else
            mapped[:category] = category_id_from_imported_category_id(m['category_id'].to_i) ||
              @category_mappings[m['category_id']].try(:[], :category_id)
          end
          mapped[:title] = CGI.unescapeHTML(m['title'])
        else
          parent = topic_lookup_from_imported_post_id(m['first_post_id'])
          if parent
            mapped[:topic_id] = parent[:topic_id]
          else
            puts "Parent post #{m['first_post_id']} doesn't exist. Skipping #{m["id"]}: #{m["title"][0..40]}"
            skip = true
          end
        end

        skip ? nil : mapped
      end
    end

    # 태그 적용
    batches(BATCH_SIZE) do |offset|
      results = mysql_query("#{posts_sql} OFFSET #{offset};").to_a
      break if results.size < 1

      results.each do |m|
        next unless m['id'] == m['first_post_id'] && m['category_id'].to_i > 0
        next unless tag = @category_mappings[m['category_id']].try(:[], :tag)
        next unless topic_mapping = topic_lookup_from_imported_post_id(m['id'])

        topic = Topic.find_by_id(topic_mapping[:topic_id])

        topic.tags = [tag] if topic
      end
    end

  end
  
  def process_xenforo_post(raw, import_id)
    s = raw.dup

    # :) 은 <!-- s:) --><img src="{SMILIES_PATH}/icon_e_smile.gif" alt=":)" title="Smile" /><!-- s:) --> 로 인코딩됩니다
    s.gsub!(/<!-- s(\S+) --><img (?:[^>]+) \/><!-- s(?:\S+) -->/, '\1')

    # 일부 링크는 다음과 같은 형태를 가집니다: <!-- m --><a class="postlink" href="http://www.onegameamonth.com">http://www.onegameamonth.com</a><!-- m -->
    s.gsub!(/<!-- \w --><a(?:.+)href="(\S+)"(?:.*)>(.+)<\/a><!-- \w -->/, '[\2](\1)')

    # 많은 phpbb bbcode 태그에는 해시가 붙어 있습니다. 예:
    # [url=https&#58;//google&#46;com:1qh1i7ky]click here[/url:1qh1i7ky]
    # [quote=&quot;cybereality&quot;:b0wtlzex]Some text.[/quote:b0wtlzex]
    s.gsub!(/:(?:\w{8})\]/, ']')

    # mybb 비디오 태그 제거.
    s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')

    s = CGI.unescapeHTML(s)

    # phpBB는 링크 텍스트를 다음과 같이 줄여서 마크다운 처리를 깨뜨립니다:
    # [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
    #
    # 오류 수정: xenforo.rb: 160: in `gsub!': invalid byte sequence in UTF-8 (ArgumentError)
    if ! s.valid_encoding?
      s = s.encode("UTF-16be", invalid: :replace, replace: "?").encode('UTF-8')
    end

    # 당분간 이를 우회하기 위해:
    s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')

    # [QUOTE]...[/QUOTE]
    s.gsub!(/\[quote\](.+?)\[\/quote\]/im) { "\n> #{$1}\n" }

    # 중첩 인용문
    s.gsub!(/(\[\/?QUOTE.*?\])/mi) { |q| "\n#{q}\n" }

    # [QUOTE="username, post: 28662, member: 1283"]
    s.gsub!(/\[quote="(\w+), post: (\d*), member: (\d*)"\]/i) do
      username, imported_post_id, _imported_user_id = $1, $2, $3

      topic_mapping = topic_lookup_from_imported_post_id(imported_post_id)

      if topic_mapping
        "\n[quote=\"#{username}, post:#{topic_mapping[:post_number]}, topic:#{topic_mapping[:topic_id]}\"]\n"
      else
        "\n[quote=\"#{username}\"]\n"
      end
    end

    # [URL=...]...[/URL]
    s.gsub!(/\[url="?(.+?)"?\](.+)\[\/url\]/i) { "[#{$2}](#{$1})" }

    # [IMG]...[/IMG]
    s.gsub!(/\[\/?img\]/i, "")

    # list 태그를 ul로, list=1 태그를 ol로 변환
    # (기본적으로 여기서는 list=a만 빠져 있습니다...)
    s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
    s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
    # phpBB의 목록에 대해 bbcode-to-md가 마법을 부릴 수 있도록 *-태그를 li-태그로 변환:
    s.gsub!(/\[\*\](.*?)\[\/\*:m\]/, '[li]\1[/li]')

    # [YOUTUBE]<id>[/YOUTUBE]
    s.gsub!(/\[youtube\](.+?)\[\/youtube\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [youtube=425,350]id[/youtube]
    s.gsub!(/\[youtube="?(.+?)"?\](.+)\[\/youtube\]/i) { "\nhttps://www.youtube.com/watch?v=#{$2}\n" }

    # [MEDIA=youtube]id[/MEDIA]
    s.gsub!(/\[MEDIA=youtube\](.+?)\[\/MEDIA\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [ame="youtube_link"]title[/ame]
    s.gsub!(/\[ame="?(.+?)"?\](.+)\[\/ame\]/i) { "\n#{$1}\n" }

    # [VIDEO=youtube;<id>]...[/VIDEO]
    s.gsub!(/\[video=youtube;([^\]]+)\].*?\[\/video\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [USER=706]@username[/USER]
    s.gsub!(/\[user="?(.+?)"?\](.+)\[\/user\]/i) { $2 }

    # color 태그 제거
    s.gsub!(/\[color=[#a-z0-9]+\]/i, "")
    s.gsub!(/\[\/color\]/i, "")

    if Dir.exist? ATTACHMENT_DIR
      s = process_xf_attachments(:gallery, s)
      s = process_xf_attachments(:attachment, s)
    end

    s
  end

  def process_xf_attachments(xf_type, s)
    ids = Set.new
    ids.merge(s.scan(get_xf_regexp(xf_type)).map { |x| x[0].to_i })
    ids.each do |id|
      next unless id
      sql = get_xf_sql(xf_type, id).squish!
      results = mysql_query(sql)
      if results.size < 1
        # 첨부파일 제거
        s.gsub!(get_xf_regexp(xf_type, id), '')
        STDERR.puts "#{xf_type.capitalize} id #{id} not found in source database. Stripping."
        next
      end
      original_filename = results.first['filename']
      result = results.first
      upload = import_xf_attachment(result['data_id'], result['file_hash'], result['user_id'], original_filename)
      next unless upload
      if upload.present? && upload.persisted?
        s.gsub!(get_xf_regexp(xf_type, id), @uploader.html_for_upload(upload, original_filename))
      else
        STDERR.puts "Could not find upload: #{upload.id}. Skipping attachment id #{id}"
      end
    end
    s
  end

  def import_xf_attachment(data_id, file_hash, owner_id, original_filename)
    current_filename = "#{data_id}-#{file_hash}.data"
    path = Pathname.new(ATTACHMENT_DIR + "/#{data_id / 1000}/#{current_filename}")
    new_path = path.dirname + original_filename
    upload = nil
    if File.exist? path
      FileUtils.cp path, new_path
      upload = @uploader.create_upload owner_id, new_path, original_filename
      FileUtils.rm new_path
    else
      STDERR.puts "Could not find file #{path}. Skipping attachment id #{data_id}"
    end
    upload
  end

  def get_xf_regexp(type, id = nil)
    case type
    when :gallery
      Regexp.new(/\[GALLERY=media,\s#{id ? id : '(\d+)'}\].+?\]/i)
    when :attachment
      Regexp.new(/\[ATTACH(?>=\w+)?\]#{id ? id : '(\d+)'}\[\/ATTACH\]/i)
    end
  end

  def get_xf_sql(type, id)
    case type
    when :gallery
      <<-SQL
		SELECT m.media_id, m.media_title, a.attachment_id, a.data_id, d.filename, d.file_hash,d.user_id
		FROM xengallery_media as m
		INNER JOIN #{TABLE_PREFIX}attachment a on m.attachment_id = a.attachment_id
		INNER JOIN #{TABLE_PREFIX}attachment_data d on a.data_id = d.data_id
		WHERE media_id = #{id}
      SQL
    when :attachment
      <<-SQL
		SELECT a.attachment_id, a.data_id, d.filename, d.file_hash, d.user_id
		FROM #{TABLE_PREFIX}attachment AS a
		INNER JOIN #{TABLE_PREFIX}attachment_data d ON a.data_id = d.data_id
		WHERE attachment_id = #{id}
      SQL
    end
  end

  def mysql_query(sql)
    @client.query(sql, cache_rows: false)
  end
  
  def import_avatars
    if AVATAR_DIR
      users = User.all
      users.each do |u|
        unless u.custom_fields["import_id"].nil?
          import_id = u.custom_fields["import_id"]
          if import_id.to_i < 1000
            dir_num = "0"
          elsif import_id.to_i > 1000
            dir_num = import_id.first
          end
        
          filename = "#{import_id}.jpg"
          avatar_file_path = "#{AVATAR_DIR}/l/#{dir_num}"
          avatar_file_path_and_name = "#{avatar_file_path}/#{filename}"
          profile_pic_file_path_and_name = "#{PROFILE_PIC_DIR}/#{filename}"
          
          if File.exists?(profile_pic_file_path_and_name)
            upload_pic_or_avatar(u, profile_pic_file_path_and_name, filename)
          elsif File.exists?(avatar_file_path_and_name)
            upload_pic_or_avatar(u, avatar_file_path_and_name, filename)
          end
        end
      end
    end
  end
  
  def upload_pic_or_avatar(u, file_path_and_name, filename)
    upload = create_upload(u.id, file_path_and_name, filename)
    if upload.persisted?
      puts "upload persisted"
      u.import_mode = false
      u.create_user_avatar
      u.import_mode = true
      u.user_avatar.update(custom_upload_id: upload.id)
      u.update(uploaded_avatar_id: upload.id)
    else
      puts "Error: Upload did not persist for #{u.username} #{filename}!"
    end
  end
  
  
end

ImportScripts::XenForo.new.perform

```

## 참고 사항:

- import\_avatars 단계/메서드를 추가합니다 (이것들은 jpg여야 합니다)
- 아바타와 프로필 사진의 경로를 추가합니다
- 사용자가 존재하지 않지만 게시물은 존재할 경우(게스트 사용자)를 위한 폴백으로 새로 생성된 게스트 사용자의 ID를 추가합니다

이제 존재하는 경우 아바타로 사용될 프로필 사진을 복사해 보겠습니다 - 없다면 사용자가 업로드한 아바타가 사용됩니다. 아바타에서 아바타로 직접 이모트만 원한다면 이 단계를 건너뛸 수 있습니다.

## 프로필 사진 복사기:

먼저 `gem install down`으로 Down을 설치하세요.

그 다음 다음으로 새 파일을 생성하세요:

```ruby
require 'down'

(1..NUMBER_OF_USERS).each do |u|
  puts "Fetching user #{u}"
  puts ""
  profile_pic_url = "https://www.forum-name.com/image.php?u=#{u}&type=profile"
  destination = "/full/path/where/you/want/to/save/profile/pics/#{u}.jpg"
  begin
    Down.download(profile_pic_url, destination: destination)
    puts "Completed #{u}"
  rescue
    puts "Failed #{u}"
  end
  puts ""
end

```

## 참고 사항:

- 모든 프로필 사진(및 아바타)이 jpg라고 가정합니다. 다행히도 우리는 아바타와 프로필 사진으로 jpg만 허용했기 때문에 우리에게 이것은 잘 작동합니다.
- 경로와 URL이 정확한지, 그리고 프로필 및 프로필 사진이 게스트에게도 보이는지 확인하세요.
- NUMBER\_OF\_USERS를 가지고 있는 사용자 수(예: 3872)로 교체하세요.

그런 다음 터미널에서 `ruby /path/to/name-of-script.rb`를 실행하여 스크립트를 실행합니다. 그러면 모든 프로필 사진이 해당 디렉토리로 복사되며, 단순히 그곳으로 가서 파일 크기순으로 정렬한 후 빈 파일들을 삭제하면 됩니다(모든 사람이 프로필 사진을 업로드하지 않기 때문에 빈 파일이 많이 있을 것입니다).

## 이모트 실행:

위 모든 작업이 완료되면 시작할 준비가 된 것입니다 😃

```plaintext
RAILS_ENV=development bundle exec ruby script/import_scripts/xenforo.rb

```

게시물 100K개와 수천 명의 멤버가 있는 포럼을 이모트하는 데 약 90분이 걸리며, 제 초기 테스트 결과는 정상적으로 작동하는 것으로 보입니다. 그러나..

## 참고 사항:

- 프로필에서 `location`과 `about` 텍스트만 이모트됩니다.
- 테스트에 사용하는 포럼에서 첨부파일 업로드를 허용한 적이 없어 첨부파일 업로드를 확인하지 않았습니다. 이모트하려는 포럼 중 하나(훨씬 더 크므로 이 작은 포럼을 테스트용으로 사용)에서 첨부파일 업로드를 허용하고 있으므로, 그 진행 상황에 대해 나중에 보고할 것입니다.
- 개발 머신에서 수행된 이모트는 이제 라이브 프로덕션 설치로 성공적으로 이동/'복원’되었으며 모든 것이 잘 진행되었습니다 👍
- (첨부파일이 있는 더 큰 포럼에서 이것을 테스트해야 합니다 - 완료되면 이 게시물을 업데이트할 것입니다)

_몇몇 사람들이 현재 vB3 포럼을 Discourse로 이모트하려고 하고 있다고 생각하므로 지금 바로 게시합니다._
