ぼざクリタグ広場 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.
 
 
 
 

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