ぼざクリタグ広場 https://hub.nizika.monster
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

650 lines
21 KiB

  1. class PostsController < ApplicationController
  2. Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
  3. def index
  4. url = params[:url].presence
  5. title = params[:title].presence
  6. original_created_from = params[:original_created_from].presence
  7. original_created_to = params[:original_created_to].presence
  8. created_between = params[:created_from].presence, params[:created_to].presence
  9. updated_between = params[:updated_from].presence, params[:updated_to].presence
  10. order = params[:order].to_s.split(':', 2).map(&:strip)
  11. unless order[0].in?(['title', 'url', 'original_created_at', 'created_at', 'updated_at'])
  12. order[0] = 'original_created_at'
  13. end
  14. unless order[1].in?(['asc', 'desc'])
  15. order[1] =
  16. if order[0].in?(['title', 'url'])
  17. 'asc'
  18. else
  19. 'desc'
  20. end
  21. end
  22. page = (params[:page].presence || 1).to_i
  23. limit = (params[:limit].presence || 20).to_i
  24. page = 1 if page < 1
  25. limit = 1 if limit < 1
  26. offset = (page - 1) * limit
  27. pt_max_sql =
  28. PostTag
  29. .select('post_id, MAX(updated_at) AS max_updated_at')
  30. .group('post_id')
  31. .to_sql
  32. updated_at_all_sql =
  33. 'GREATEST(posts.updated_at,' +
  34. 'COALESCE(pt_max.max_updated_at, posts.updated_at))'
  35. q =
  36. filtered_posts
  37. .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
  38. .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
  39. .preload(:parents, :children,
  40. tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
  41. .with_attached_thumbnail
  42. q = q.where('posts.url LIKE ?', "%#{ url }%") if url
  43. q = q.where('posts.title LIKE ?', "%#{ title }%") if title
  44. if original_created_from
  45. q = q.where('posts.original_created_before > ?', original_created_from)
  46. end
  47. if original_created_to
  48. q = q.where('posts.original_created_from <= ?', original_created_to)
  49. end
  50. q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
  51. q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
  52. if updated_between[0]
  53. q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
  54. end
  55. if updated_between[1]
  56. q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
  57. end
  58. sort_sql =
  59. case order[0]
  60. when 'original_created_at'
  61. 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' +
  62. 'posts.original_created_from,' +
  63. 'posts.created_at) '
  64. when 'updated_at'
  65. updated_at_all_sql
  66. else
  67. "posts.#{ order[0] }"
  68. end
  69. posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, posts.id #{ order[1] }"))
  70. .limit(limit)
  71. .offset(offset)
  72. .to_a
  73. q = q.except(:select, :order)
  74. render json: { posts: posts.map { |post|
  75. PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
  76. json['thumbnail'] =
  77. if post.thumbnail.attached?
  78. rails_storage_proxy_url(post.thumbnail, only_path: false)
  79. else
  80. nil
  81. end
  82. end
  83. }, count: q.group_values.present? ? q.count.size : q.count }
  84. end
  85. def random
  86. post = filtered_posts.preload(:parents, :childern,
  87. tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
  88. .order('RAND()')
  89. .first
  90. return head :not_found unless post
  91. render json: PostRepr.base(post, current_user)
  92. end
  93. def show
  94. post = Post
  95. .preload(:parents, :children)
  96. .includes(:parents, :children,
  97. tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
  98. .find_by(id: params[:id])
  99. return head :not_found unless post
  100. render json: PostRepr.base(post, current_user)
  101. .merge(tags: build_tag_tree_for(post.tags),
  102. related: PostRepr.many(post.related(limit: 20)))
  103. end
  104. def create
  105. return head :unauthorized unless current_user
  106. return head :forbidden unless current_user.gte_member?
  107. # TODO: サイトに応じて thumbnail_base 設定
  108. title = params[:title].presence
  109. url = params[:url]
  110. thumbnail = params[:thumbnail]
  111. tag_names = params[:tags].to_s.split
  112. original_created_from = params[:original_created_from]
  113. original_created_before = params[:original_created_before]
  114. parent_post_ids = parse_parent_post_ids
  115. post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
  116. original_created_from:, original_created_before:)
  117. post.thumbnail.attach(thumbnail) if thumbnail.present?
  118. ApplicationRecord.transaction do
  119. post.save!
  120. tags = Tag.normalise_tags!(tag_names)
  121. TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
  122. tags = Tag.expand_parent_tags(tags)
  123. sync_post_tags!(post, tags)
  124. sync_parent_posts!(post, parent_post_ids)
  125. post.resized_thumbnail!
  126. PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
  127. end
  128. post.reload
  129. render json: PostRepr.base(post), status: :created
  130. rescue Tag::NicoTagNormalisationError
  131. head :bad_request
  132. rescue ArgumentError => e
  133. render json: { errors: [e.message] }, status: :unprocessable_entity
  134. rescue ActiveRecord::RecordInvalid => e
  135. render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
  136. end
  137. def viewed
  138. return head :unauthorized unless current_user
  139. current_user.viewed_posts << Post.find(params[:id])
  140. head :no_content
  141. end
  142. def unviewed
  143. return head :unauthorized unless current_user
  144. current_user.viewed_posts.delete(Post.find(params[:id]))
  145. head :no_content
  146. end
  147. def update
  148. return head :unauthorized unless current_user
  149. return head :forbidden unless current_user.gte_member?
  150. force = bool?(:force)
  151. merge = bool?(:merge)
  152. return head :bad_request if force && merge
  153. base_version_no = parse_base_version_no
  154. return head :bad_request if !(force) && !(base_version_no)
  155. title = params[:title].presence
  156. tag_names = params[:tags].to_s.split
  157. original_created_from = params[:original_created_from]
  158. original_created_before = params[:original_created_before]
  159. parent_post_ids = parse_parent_post_ids
  160. post = nil
  161. conflict_json = nil
  162. ApplicationRecord.transaction do
  163. post = Post.lock.find(params[:id].to_i)
  164. base_version = nil
  165. base_snapshot = nil
  166. current_snapshot = nil
  167. unless force
  168. base_version = post.post_versions.find_by!(version_no: base_version_no)
  169. base_snapshot = post_snapshot_from_version(base_version)
  170. current_snapshot = post_snapshot_from_record(post)
  171. end
  172. incoming_snapshot = post_incoming_snapshot(title:,
  173. original_created_from:,
  174. original_created_before:,
  175. tag_names:,
  176. parent_post_ids:)
  177. snapshot_to_apply =
  178. if force || post.version_no == base_version_no || current_snapshot == base_snapshot
  179. incoming_snapshot
  180. else
  181. changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
  182. conflicts = changes.select { |change| change[:conflict] }
  183. if merge && conflicts.empty?
  184. merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
  185. else
  186. conflict_json = post_conflict_json(post:,
  187. base_version_no:,
  188. base_snapshot:,
  189. current_snapshot:,
  190. incoming_snapshot:,
  191. changes:,
  192. conflicts:)
  193. raise ActiveRecord::Rollback
  194. end
  195. end
  196. apply_post_snapshot!(post, snapshot_to_apply)
  197. end
  198. return render json: conflict_json, status: :conflict if conflict_json
  199. post.reload
  200. json = PostRepr.base(post, current_user)
  201. json['tags'] = build_tag_tree_for(post.tags)
  202. render json:, status: :ok
  203. rescue Tag::NicoTagNormalisationError
  204. head :bad_request
  205. rescue ArgumentError => e
  206. render json: { errors: [e.message] }, status: :unprocessable_entity
  207. rescue ActiveRecord::RecordInvalid => e
  208. render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
  209. end
  210. def changes
  211. id = params[:id].presence
  212. tag_id = params[:tag].presence
  213. page = (params[:page].presence || 1).to_i
  214. limit = (params[:limit].presence || 20).to_i
  215. page = 1 if page < 1
  216. limit = 1 if limit < 1
  217. offset = (page - 1) * limit
  218. pts = PostTag.with_discarded
  219. pts = pts.where(post_id: id) if id.present?
  220. pts = pts.where(tag_id:) if tag_id.present?
  221. pts = pts.includes(:post, :created_user, :deleted_user,
  222. tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
  223. events = []
  224. pts.each do |pt|
  225. tag = TagRepr.base(pt.tag)
  226. post = pt.post
  227. events << Event.new(
  228. post:,
  229. tag:,
  230. user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
  231. change_type: 'add',
  232. timestamp: pt.created_at)
  233. if pt.discarded_at
  234. events << Event.new(
  235. post:,
  236. tag:,
  237. user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
  238. change_type: 'remove',
  239. timestamp: pt.discarded_at)
  240. end
  241. end
  242. events.sort_by!(&:timestamp)
  243. events.reverse!
  244. render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
  245. end
  246. private
  247. def filtered_posts
  248. tag_names = params[:tags].to_s.split
  249. match_type = params[:match]
  250. if tag_names.present?
  251. filter_posts_by_tags(tag_names, match_type)
  252. else
  253. Post.all
  254. end
  255. end
  256. def filter_posts_by_tags tag_names, match_type
  257. literals = tag_names.map do |raw_name|
  258. { name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
  259. negative: raw_name.downcase.start_with?('not:') }
  260. end
  261. return Post.all if literals.empty?
  262. if match_type == 'any'
  263. literals.reduce(Post.none) do |posts, literal|
  264. posts.or(tag_literal_relation(literal[:name], negative: literal[:negative]))
  265. end
  266. else
  267. literals.reduce(Post.all) do |posts, literal|
  268. ids = tagged_post_ids_for(literal[:name])
  269. if literal[:negative]
  270. posts.where.not(id: ids)
  271. else
  272. posts.where(id: ids)
  273. end
  274. end
  275. end
  276. end
  277. def tag_literal_relation name, negative:
  278. ids = tagged_post_ids_for(name)
  279. if negative
  280. Post.where.not(id: ids)
  281. else
  282. Post.where(id: ids)
  283. end
  284. end
  285. def tagged_post_ids_for(name) =
  286. Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
  287. def sync_post_tags! post, desired_tags
  288. desired_tags.each do |t|
  289. t.save! if t.new_record?
  290. end
  291. desired_ids = desired_tags.map(&:id).to_set
  292. current_ids = post.tags.pluck(:id).to_set
  293. to_add = desired_ids - current_ids
  294. to_remove = current_ids - desired_ids
  295. Tag.where(id: to_add).find_each do |tag|
  296. begin
  297. PostTag.create!(post:, tag:, created_user: current_user)
  298. rescue ActiveRecord::RecordNotUnique
  299. ;
  300. end
  301. end
  302. PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
  303. pt.discard_by!(current_user)
  304. end
  305. end
  306. def build_tag_tree_for tags
  307. tags = tags.to_a
  308. tag_ids = tags.map(&:id)
  309. implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
  310. children_ids_by_parent = Hash.new { |h, k| h[k] = [] }
  311. implications.each do |imp|
  312. children_ids_by_parent[imp.parent_tag_id] << imp.tag_id
  313. end
  314. child_ids = children_ids_by_parent.values.flatten.uniq
  315. root_ids = tag_ids - child_ids
  316. tags_by_id = tags.index_by(&:id)
  317. memo = { }
  318. build_node = -> tag_id, path do
  319. tag = tags_by_id[tag_id]
  320. return nil unless tag
  321. if path.include?(tag_id)
  322. return TagRepr.base(tag).merge(children: [])
  323. end
  324. if memo.key?(tag_id)
  325. return memo[tag_id]
  326. end
  327. new_path = path + [tag_id]
  328. child_ids = children_ids_by_parent[tag_id] || []
  329. children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
  330. memo[tag_id] = TagRepr.base(tag).merge(children:)
  331. end
  332. root_ids.filter_map { |id| build_node.call(id, []) }
  333. end
  334. def parse_parent_post_ids
  335. raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
  336. params[:parent_post_ids].to_s.split.map { |token|
  337. id = Integer(token, exception: false)
  338. raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
  339. id
  340. }.uniq
  341. end
  342. def sync_parent_posts! post, parent_post_ids
  343. if parent_post_ids.include?(post.id)
  344. post.errors.add(:base, '自分自身を親投稿にはできません.')
  345. raise ActiveRecord::RecordInvalid, post
  346. end
  347. existing_ids = Post.where(id: parent_post_ids).pluck(:id)
  348. missing_ids = parent_post_ids - existing_ids
  349. if missing_ids.present?
  350. post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }")
  351. raise ActiveRecord::RecordInvalid, post
  352. end
  353. current_ids = post.parent_posts.pluck(:id)
  354. ids_to_add = parent_post_ids - current_ids
  355. ids_to_remove = current_ids - parent_post_ids
  356. PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all
  357. ids_to_add.each do |parent_post_id|
  358. PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
  359. end
  360. end
  361. def parse_base_version_no
  362. version_no = Integer(params[:base_version_no], exception: false)
  363. if version_no&.positive?
  364. version_no
  365. else
  366. nil
  367. end
  368. end
  369. def post_snapshot_from_version version
  370. { title: version.title,
  371. original_created_from: snapshot_time(version.original_created_from),
  372. original_created_before: snapshot_time(version.original_created_before),
  373. tag_names: editable_tag_names_from_version(version),
  374. parent_post_ids: snapshot_parent_post_ids_from_version(version) }
  375. end
  376. def editable_tag_names_from_version version
  377. version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
  378. end
  379. def post_snapshot_from_record post
  380. { title: post.title,
  381. original_created_from: snapshot_time(post.original_created_from),
  382. original_created_before: snapshot_time(post.original_created_before),
  383. tag_names: editable_tag_names_from_post(post),
  384. parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
  385. end
  386. def editable_tag_names_from_post post
  387. post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
  388. end
  389. def post_incoming_snapshot title:, original_created_from:, original_created_before:,
  390. tag_names:, parent_post_ids:
  391. { title:,
  392. original_created_from: snapshot_time(original_created_from),
  393. original_created_before: snapshot_time(original_created_before),
  394. tag_names: incoming_tag_names_for_snapshot(tag_names),
  395. parent_post_ids: parent_post_ids.sort }
  396. end
  397. def snapshot_parent_post_ids_from_version version
  398. if version.respond_to?(:parent_post_ids)
  399. version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort
  400. elsif version.respond_to?(:parent_id) && version.parent_id
  401. [version.parent_id]
  402. else
  403. []
  404. end
  405. end
  406. def snapshot_time value
  407. return nil if value.blank?
  408. value = Time.zone.parse(value.to_s) if value in String
  409. value&.in_time_zone&.iso8601(6)
  410. rescue ArgumentError, TypeError
  411. value.to_s
  412. end
  413. def incoming_tag_names_for_snapshot raw_tag_names
  414. tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
  415. Tag.expand_parent_tags(tags).map(&:name).uniq.sort
  416. end
  417. def post_conflict_json post:, base_version_no:, base_snapshot:,
  418. current_snapshot:, incoming_snapshot:, changes:, conflicts:
  419. { error: 'conflict',
  420. message: '競合が発生しました.',
  421. post_id: post.id,
  422. base_version_no:,
  423. current_version_no: post.version_no,
  424. base: base_snapshot,
  425. current: current_snapshot,
  426. mine: incoming_snapshot,
  427. changes:,
  428. conflicts:,
  429. mergeable: conflicts.empty? }
  430. end
  431. def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
  432. [scalar_snapshot_change(:title, 'タイトル',
  433. base_snapshot, current_snapshot, incoming_snapshot),
  434. scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
  435. base_snapshot, current_snapshot, incoming_snapshot),
  436. scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
  437. base_snapshot, current_snapshot, incoming_snapshot),
  438. set_snapshot_change(:tag_names, 'タグ',
  439. base_snapshot, current_snapshot, incoming_snapshot),
  440. set_snapshot_change(:parent_post_ids, '親投稿',
  441. base_snapshot, current_snapshot, incoming_snapshot)].compact
  442. end
  443. def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
  444. base = base_snapshot[field]
  445. current = current_snapshot[field]
  446. mine = incoming_snapshot[field]
  447. return nil if current == base && mine == base
  448. { field:, label:, base:, current:, mine:,
  449. changed_by_current: current != base,
  450. changed_by_me: mine != base,
  451. conflict: scalar_snapshot_conflict?(base, current, mine) }
  452. end
  453. def scalar_snapshot_conflict? base, current, mine
  454. current != base && mine != base && current != mine
  455. end
  456. def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
  457. base = base_snapshot[field].to_a
  458. current = current_snapshot[field].to_a
  459. mine = incoming_snapshot[field].to_a
  460. added_by_current = current - base
  461. removed_by_current = base - current
  462. added_by_me = mine - base
  463. removed_by_me = base - mine
  464. if (added_by_current.empty? &&
  465. removed_by_current.empty? &&
  466. added_by_me.empty? &&
  467. removed_by_me.empty?)
  468. return nil
  469. end
  470. { field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:,
  471. added_by_me:, removed_by_me:,
  472. changed_by_current: added_by_current.present? || removed_by_current.present?,
  473. changed_by_me: added_by_me.present? || removed_by_me.present?,
  474. conflict: set_snapshot_conflict?(added_by_current:,
  475. removed_by_current:,
  476. added_by_me:,
  477. removed_by_me:) }
  478. end
  479. def set_snapshot_conflict? added_by_current:, removed_by_current:,
  480. added_by_me:, removed_by_me:
  481. (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
  482. end
  483. def apply_post_snapshot! post, snapshot
  484. PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
  485. post.update!(title: snapshot[:title],
  486. original_created_from: snapshot[:original_created_from],
  487. original_created_before: snapshot[:original_created_before])
  488. editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
  489. TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
  490. readonly_tags = post.tags.nico.to_a
  491. tags = readonly_tags + editable_tags
  492. tags = Tag.expand_parent_tags(tags)
  493. sync_post_tags!(post, tags)
  494. sync_parent_posts!(post, snapshot[:parent_post_ids])
  495. PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
  496. end
  497. def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
  498. [:title, :original_created_from, :original_created_before].map {
  499. [_1, merge_scalar_snapshot_value(base_snapshot[_1],
  500. current_snapshot[_1],
  501. incoming_snapshot[_1])]
  502. }.to_h.merge([:tag_names, :parent_post_ids].map {
  503. [_1, merge_set_snapshot_value(base_snapshot[_1],
  504. current_snapshot[_1],
  505. incoming_snapshot[_1])]
  506. }.to_h)
  507. end
  508. def merge_scalar_snapshot_value base, current, mine
  509. return mine if current == base
  510. return current if mine == base || current == mine
  511. raise ArgumentError, '競合してゐる項目はマージできません.'
  512. end
  513. def merge_set_snapshot_value base, current, mine
  514. base = base.to_a
  515. current = current.to_a
  516. mine = mine.to_a
  517. added_by_current = current - base
  518. removed_by_current = base - current
  519. added_by_me = mine - base
  520. removed_by_me = base - mine
  521. merged = base + added_by_current + added_by_me
  522. merged -= removed_by_current
  523. merged -= removed_by_me
  524. merged.uniq.sort
  525. end
  526. end