e03cc01109
#171 #171 #171 #171 #171 #171 #171 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: #345
644 lines
21 KiB
Ruby
644 lines
21 KiB
Ruby
class PostsController < ApplicationController
|
|
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
|
|
|
|
def index
|
|
url = params[:url].presence
|
|
title = params[:title].presence
|
|
original_created_from = params[:original_created_from].presence
|
|
original_created_to = params[:original_created_to].presence
|
|
created_between = params[:created_from].presence, params[:created_to].presence
|
|
updated_between = params[:updated_from].presence, params[:updated_to].presence
|
|
|
|
order = params[:order].to_s.split(':', 2).map(&:strip)
|
|
unless order[0].in?(['title', 'url', 'original_created_at', 'created_at', 'updated_at'])
|
|
order[0] = 'original_created_at'
|
|
end
|
|
unless order[1].in?(['asc', 'desc'])
|
|
order[1] =
|
|
if order[0].in?(['title', 'url'])
|
|
'asc'
|
|
else
|
|
'desc'
|
|
end
|
|
end
|
|
|
|
page = (params[:page].presence || 1).to_i
|
|
limit = (params[:limit].presence || 20).to_i
|
|
|
|
page = 1 if page < 1
|
|
limit = 1 if limit < 1
|
|
|
|
offset = (page - 1) * limit
|
|
|
|
pt_max_sql =
|
|
PostTag
|
|
.select('post_id, MAX(updated_at) AS max_updated_at')
|
|
.group('post_id')
|
|
.to_sql
|
|
|
|
updated_at_all_sql =
|
|
'GREATEST(posts.updated_at,' +
|
|
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
|
|
|
|
q =
|
|
filtered_posts
|
|
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
|
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
|
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
|
.with_attached_thumbnail
|
|
|
|
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
|
q = q.where('posts.title LIKE ?', "%#{ title }%") if title
|
|
if original_created_from
|
|
q = q.where('posts.original_created_before > ?', original_created_from)
|
|
end
|
|
if original_created_to
|
|
q = q.where('posts.original_created_from <= ?', original_created_to)
|
|
end
|
|
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
|
|
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
|
|
if updated_between[0]
|
|
q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
|
|
end
|
|
if updated_between[1]
|
|
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
|
|
end
|
|
|
|
sort_sql =
|
|
case order[0]
|
|
when 'original_created_at'
|
|
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' +
|
|
'posts.original_created_from,' +
|
|
'posts.created_at) '
|
|
when 'updated_at'
|
|
updated_at_all_sql
|
|
else
|
|
"posts.#{ order[0] }"
|
|
end
|
|
posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, posts.id #{ order[1] }"))
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.to_a
|
|
|
|
q = q.except(:select, :order)
|
|
|
|
render json: { posts: posts.map { |post|
|
|
PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
|
|
json['thumbnail'] =
|
|
if post.thumbnail.attached?
|
|
rails_storage_proxy_url(post.thumbnail, only_path: false)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
}, count: q.group_values.present? ? q.count.size : q.count }
|
|
end
|
|
|
|
def random
|
|
post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
|
.order('RAND()')
|
|
.first
|
|
return head :not_found unless post
|
|
|
|
render json: PostRepr.base(post, current_user)
|
|
end
|
|
|
|
def show
|
|
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
|
|
return head :not_found unless post
|
|
|
|
render json: PostRepr.base(post, current_user)
|
|
.merge(tags: build_tag_tree_for(post.tags),
|
|
related: PostRepr.many(post.related(limit: 20)))
|
|
end
|
|
|
|
def create
|
|
return head :unauthorized unless current_user
|
|
return head :forbidden unless current_user.gte_member?
|
|
|
|
# TODO: サイトに応じて thumbnail_base 設定
|
|
title = params[:title].presence
|
|
url = params[:url]
|
|
thumbnail = params[:thumbnail]
|
|
tag_names = params[:tags].to_s.split
|
|
original_created_from = params[:original_created_from]
|
|
original_created_before = params[:original_created_before]
|
|
parent_post_ids = parse_parent_post_ids
|
|
|
|
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
|
|
original_created_from:, original_created_before:)
|
|
post.thumbnail.attach(thumbnail) if thumbnail.present?
|
|
|
|
ApplicationRecord.transaction do
|
|
post.save!
|
|
|
|
tags = Tag.normalise_tags!(tag_names)
|
|
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
|
|
|
tags = Tag.expand_parent_tags(tags)
|
|
sync_post_tags!(post, tags)
|
|
|
|
sync_parent_posts!(post, parent_post_ids)
|
|
|
|
post.resized_thumbnail!
|
|
|
|
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
|
end
|
|
|
|
post.reload
|
|
render json: PostRepr.base(post), status: :created
|
|
rescue Tag::NicoTagNormalisationError
|
|
head :bad_request
|
|
rescue ArgumentError => e
|
|
render json: { errors: [e.message] }, status: :unprocessable_entity
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
|
end
|
|
|
|
def viewed
|
|
return head :unauthorized unless current_user
|
|
|
|
current_user.viewed_posts << Post.find(params[:id])
|
|
head :no_content
|
|
end
|
|
|
|
def unviewed
|
|
return head :unauthorized unless current_user
|
|
|
|
current_user.viewed_posts.delete(Post.find(params[:id]))
|
|
head :no_content
|
|
end
|
|
|
|
def update
|
|
return head :unauthorized unless current_user
|
|
return head :forbidden unless current_user.gte_member?
|
|
|
|
force = bool?(:force)
|
|
merge = bool?(:merge)
|
|
return head :bad_request if force && merge
|
|
|
|
base_version_no = parse_base_version_no
|
|
return head :bad_request if !(force) && !(base_version_no)
|
|
|
|
title = params[:title].presence
|
|
tag_names = params[:tags].to_s.split
|
|
original_created_from = params[:original_created_from]
|
|
original_created_before = params[:original_created_before]
|
|
parent_post_ids = parse_parent_post_ids
|
|
|
|
post = nil
|
|
conflict_json = nil
|
|
|
|
ApplicationRecord.transaction do
|
|
post = Post.lock.find(params[:id].to_i)
|
|
|
|
base_version = nil
|
|
base_snapshot = nil
|
|
current_snapshot = nil
|
|
unless force
|
|
base_version = post.post_versions.find_by!(version_no: base_version_no)
|
|
|
|
base_snapshot = post_snapshot_from_version(base_version)
|
|
current_snapshot = post_snapshot_from_record(post)
|
|
end
|
|
incoming_snapshot = post_incoming_snapshot(title:,
|
|
original_created_from:,
|
|
original_created_before:,
|
|
tag_names:,
|
|
parent_post_ids:)
|
|
|
|
snapshot_to_apply =
|
|
if force || post.version_no == base_version_no || current_snapshot == base_snapshot
|
|
incoming_snapshot
|
|
else
|
|
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
|
|
conflicts = changes.select { |change| change[:conflict] }
|
|
|
|
if merge && conflicts.empty?
|
|
merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
|
|
else
|
|
conflict_json = post_conflict_json(post:,
|
|
base_version_no:,
|
|
base_snapshot:,
|
|
current_snapshot:,
|
|
incoming_snapshot:,
|
|
changes:,
|
|
conflicts:)
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
end
|
|
|
|
apply_post_snapshot!(post, snapshot_to_apply)
|
|
end
|
|
|
|
return render json: conflict_json, status: :conflict if conflict_json
|
|
|
|
post.reload
|
|
json = PostRepr.base(post, current_user)
|
|
json['tags'] = build_tag_tree_for(post.tags)
|
|
render json:, status: :ok
|
|
rescue Tag::NicoTagNormalisationError
|
|
head :bad_request
|
|
rescue ArgumentError => e
|
|
render json: { errors: [e.message] }, status: :unprocessable_entity
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
|
end
|
|
|
|
def changes
|
|
id = params[:id].presence
|
|
tag_id = params[:tag].presence
|
|
page = (params[:page].presence || 1).to_i
|
|
limit = (params[:limit].presence || 20).to_i
|
|
|
|
page = 1 if page < 1
|
|
limit = 1 if limit < 1
|
|
|
|
offset = (page - 1) * limit
|
|
|
|
pts = PostTag.with_discarded
|
|
pts = pts.where(post_id: id) if id.present?
|
|
pts = pts.where(tag_id:) if tag_id.present?
|
|
pts = pts.includes(:post, :created_user, :deleted_user,
|
|
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
|
|
|
events = []
|
|
pts.each do |pt|
|
|
tag = TagRepr.base(pt.tag)
|
|
post = pt.post
|
|
|
|
events << Event.new(
|
|
post:,
|
|
tag:,
|
|
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
|
|
change_type: 'add',
|
|
timestamp: pt.created_at)
|
|
|
|
if pt.discarded_at
|
|
events << Event.new(
|
|
post:,
|
|
tag:,
|
|
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
|
|
change_type: 'remove',
|
|
timestamp: pt.discarded_at)
|
|
end
|
|
end
|
|
events.sort_by!(&:timestamp)
|
|
events.reverse!
|
|
|
|
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
|
|
end
|
|
|
|
private
|
|
|
|
def filtered_posts
|
|
tag_names = params[:tags].to_s.split
|
|
match_type = params[:match]
|
|
if tag_names.present?
|
|
filter_posts_by_tags(tag_names, match_type)
|
|
else
|
|
Post.all
|
|
end
|
|
end
|
|
|
|
def filter_posts_by_tags tag_names, match_type
|
|
literals = tag_names.map do |raw_name|
|
|
{ name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
|
|
negative: raw_name.downcase.start_with?('not:') }
|
|
end
|
|
|
|
return Post.all if literals.empty?
|
|
|
|
if match_type == 'any'
|
|
literals.reduce(Post.none) do |posts, literal|
|
|
posts.or(tag_literal_relation(literal[:name], negative: literal[:negative]))
|
|
end
|
|
else
|
|
literals.reduce(Post.all) do |posts, literal|
|
|
ids = tagged_post_ids_for(literal[:name])
|
|
if literal[:negative]
|
|
posts.where.not(id: ids)
|
|
else
|
|
posts.where(id: ids)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def tag_literal_relation name, negative:
|
|
ids = tagged_post_ids_for(name)
|
|
if negative
|
|
Post.where.not(id: ids)
|
|
else
|
|
Post.where(id: ids)
|
|
end
|
|
end
|
|
|
|
def tagged_post_ids_for(name) =
|
|
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
|
|
|
|
def sync_post_tags! post, desired_tags
|
|
desired_tags.each do |t|
|
|
t.save! if t.new_record?
|
|
end
|
|
|
|
desired_ids = desired_tags.map(&:id).to_set
|
|
current_ids = post.tags.pluck(:id).to_set
|
|
|
|
to_add = desired_ids - current_ids
|
|
to_remove = current_ids - desired_ids
|
|
|
|
Tag.where(id: to_add).find_each do |tag|
|
|
begin
|
|
PostTag.create!(post:, tag:, created_user: current_user)
|
|
rescue ActiveRecord::RecordNotUnique
|
|
;
|
|
end
|
|
end
|
|
|
|
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
|
|
pt.discard_by!(current_user)
|
|
end
|
|
end
|
|
|
|
def build_tag_tree_for tags
|
|
tags = tags.to_a
|
|
tag_ids = tags.map(&:id)
|
|
|
|
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
|
|
|
|
children_ids_by_parent = Hash.new { |h, k| h[k] = [] }
|
|
implications.each do |imp|
|
|
children_ids_by_parent[imp.parent_tag_id] << imp.tag_id
|
|
end
|
|
|
|
child_ids = children_ids_by_parent.values.flatten.uniq
|
|
|
|
root_ids = tag_ids - child_ids
|
|
|
|
tags_by_id = tags.index_by(&:id)
|
|
|
|
memo = { }
|
|
|
|
build_node = -> tag_id, path do
|
|
tag = tags_by_id[tag_id]
|
|
return nil unless tag
|
|
|
|
if path.include?(tag_id)
|
|
return TagRepr.base(tag).merge(children: [])
|
|
end
|
|
|
|
if memo.key?(tag_id)
|
|
return memo[tag_id]
|
|
end
|
|
|
|
new_path = path + [tag_id]
|
|
child_ids = children_ids_by_parent[tag_id] || []
|
|
|
|
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
|
|
|
|
memo[tag_id] = TagRepr.base(tag).merge(children:)
|
|
end
|
|
|
|
root_ids.filter_map { |id| build_node.call(id, []) }
|
|
end
|
|
|
|
def parse_parent_post_ids
|
|
raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
|
|
|
|
params[:parent_post_ids].to_s.split.map { |token|
|
|
id = Integer(token, exception: false)
|
|
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
|
|
|
|
id
|
|
}.uniq
|
|
end
|
|
|
|
def sync_parent_posts! post, parent_post_ids
|
|
if parent_post_ids.include?(post.id)
|
|
post.errors.add(:base, '自分自身を親投稿にはできません.')
|
|
raise ActiveRecord::RecordInvalid, post
|
|
end
|
|
|
|
existing_ids = Post.where(id: parent_post_ids).pluck(:id)
|
|
missing_ids = parent_post_ids - existing_ids
|
|
|
|
if missing_ids.present?
|
|
post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }")
|
|
raise ActiveRecord::RecordInvalid, post
|
|
end
|
|
|
|
current_ids = post.parent_posts.pluck(:id)
|
|
|
|
ids_to_add = parent_post_ids - current_ids
|
|
ids_to_remove = current_ids - parent_post_ids
|
|
|
|
PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all
|
|
|
|
ids_to_add.each do |parent_post_id|
|
|
PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
|
|
end
|
|
end
|
|
|
|
def parse_base_version_no
|
|
version_no = Integer(params[:base_version_no], exception: false)
|
|
if version_no&.positive?
|
|
version_no
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def post_snapshot_from_version version
|
|
{ title: version.title,
|
|
original_created_from: snapshot_time(version.original_created_from),
|
|
original_created_before: snapshot_time(version.original_created_before),
|
|
tag_names: editable_tag_names_from_version(version),
|
|
parent_post_ids: snapshot_parent_post_ids_from_version(version) }
|
|
end
|
|
|
|
def editable_tag_names_from_version version
|
|
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
|
|
end
|
|
|
|
def post_snapshot_from_record post
|
|
{ title: post.title,
|
|
original_created_from: snapshot_time(post.original_created_from),
|
|
original_created_before: snapshot_time(post.original_created_before),
|
|
tag_names: editable_tag_names_from_post(post),
|
|
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
|
|
end
|
|
|
|
def editable_tag_names_from_post post
|
|
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
|
end
|
|
|
|
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
|
tag_names:, parent_post_ids:
|
|
{ title:,
|
|
original_created_from: snapshot_time(original_created_from),
|
|
original_created_before: snapshot_time(original_created_before),
|
|
tag_names: incoming_tag_names_for_snapshot(tag_names),
|
|
parent_post_ids: parent_post_ids.sort }
|
|
end
|
|
|
|
def snapshot_parent_post_ids_from_version version
|
|
if version.respond_to?(:parent_post_ids)
|
|
version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort
|
|
elsif version.respond_to?(:parent_id) && version.parent_id
|
|
[version.parent_id]
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
def snapshot_time value
|
|
return nil if value.blank?
|
|
|
|
value = Time.zone.parse(value.to_s) if value in String
|
|
value&.in_time_zone&.iso8601(6)
|
|
rescue ArgumentError, TypeError
|
|
value.to_s
|
|
end
|
|
|
|
def incoming_tag_names_for_snapshot raw_tag_names
|
|
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
|
|
|
|
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
|
|
end
|
|
|
|
def post_conflict_json post:, base_version_no:, base_snapshot:,
|
|
current_snapshot:, incoming_snapshot:, changes:, conflicts:
|
|
{ error: 'conflict',
|
|
message: '競合が発生しました.',
|
|
post_id: post.id,
|
|
base_version_no:,
|
|
current_version_no: post.version_no,
|
|
base: base_snapshot,
|
|
current: current_snapshot,
|
|
mine: incoming_snapshot,
|
|
changes:,
|
|
conflicts:,
|
|
mergeable: conflicts.empty? }
|
|
end
|
|
|
|
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
|
|
[scalar_snapshot_change(:title, 'タイトル',
|
|
base_snapshot, current_snapshot, incoming_snapshot),
|
|
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
|
|
base_snapshot, current_snapshot, incoming_snapshot),
|
|
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
|
|
base_snapshot, current_snapshot, incoming_snapshot),
|
|
set_snapshot_change(:tag_names, 'タグ',
|
|
base_snapshot, current_snapshot, incoming_snapshot),
|
|
set_snapshot_change(:parent_post_ids, '親投稿',
|
|
base_snapshot, current_snapshot, incoming_snapshot)].compact
|
|
end
|
|
|
|
def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
|
|
base = base_snapshot[field]
|
|
current = current_snapshot[field]
|
|
mine = incoming_snapshot[field]
|
|
|
|
return nil if current == base && mine == base
|
|
|
|
{ field:, label:, base:, current:, mine:,
|
|
changed_by_current: current != base,
|
|
changed_by_me: mine != base,
|
|
conflict: scalar_snapshot_conflict?(base, current, mine) }
|
|
end
|
|
|
|
def scalar_snapshot_conflict? base, current, mine
|
|
current != base && mine != base && current != mine
|
|
end
|
|
|
|
def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
|
|
base = base_snapshot[field].to_a
|
|
current = current_snapshot[field].to_a
|
|
mine = incoming_snapshot[field].to_a
|
|
|
|
added_by_current = current - base
|
|
removed_by_current = base - current
|
|
added_by_me = mine - base
|
|
removed_by_me = base - mine
|
|
|
|
if (added_by_current.empty? &&
|
|
removed_by_current.empty? &&
|
|
added_by_me.empty? &&
|
|
removed_by_me.empty?)
|
|
return nil
|
|
end
|
|
|
|
{ field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:,
|
|
added_by_me:, removed_by_me:,
|
|
changed_by_current: added_by_current.present? || removed_by_current.present?,
|
|
changed_by_me: added_by_me.present? || removed_by_me.present?,
|
|
conflict: set_snapshot_conflict?(added_by_current:,
|
|
removed_by_current:,
|
|
added_by_me:,
|
|
removed_by_me:) }
|
|
end
|
|
|
|
def set_snapshot_conflict? added_by_current:, removed_by_current:,
|
|
added_by_me:, removed_by_me:
|
|
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
|
|
end
|
|
|
|
def apply_post_snapshot! post, snapshot
|
|
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
|
|
|
|
post.update!(title: snapshot[:title],
|
|
original_created_from: snapshot[:original_created_from],
|
|
original_created_before: snapshot[:original_created_before])
|
|
|
|
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
|
|
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
|
|
|
readonly_tags = post.tags.nico.to_a
|
|
|
|
tags = readonly_tags + editable_tags
|
|
tags = Tag.expand_parent_tags(tags)
|
|
|
|
sync_post_tags!(post, tags)
|
|
sync_parent_posts!(post, snapshot[:parent_post_ids])
|
|
|
|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
|
|
end
|
|
|
|
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
|
|
[:title, :original_created_from, :original_created_before].map {
|
|
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
|
|
current_snapshot[_1],
|
|
incoming_snapshot[_1])]
|
|
}.to_h.merge([:tag_names, :parent_post_ids].map {
|
|
[_1, merge_set_snapshot_value(base_snapshot[_1],
|
|
current_snapshot[_1],
|
|
incoming_snapshot[_1])]
|
|
}.to_h)
|
|
end
|
|
|
|
def merge_scalar_snapshot_value base, current, mine
|
|
return mine if current == base
|
|
return current if mine == base || current == mine
|
|
|
|
raise ArgumentError, '競合してゐる項目はマージできません.'
|
|
end
|
|
|
|
def merge_set_snapshot_value base, current, mine
|
|
base = base.to_a
|
|
current = current.to_a
|
|
mine = mine.to_a
|
|
|
|
added_by_current = current - base
|
|
removed_by_current = base - current
|
|
added_by_me = mine - base
|
|
removed_by_me = base - mine
|
|
|
|
merged = base + added_by_current + added_by_me
|
|
merged -= removed_by_current
|
|
merged -= removed_by_me
|
|
|
|
merged.uniq.sort
|
|
end
|
|
end
|