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

408 lines
12 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: [: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.preload(tags: [:materials, { tag_name: :wiki_page }])
  86. .order('RAND()')
  87. .first
  88. return head :not_found unless post
  89. render json: PostRepr.base(post, current_user)
  90. end
  91. def show
  92. post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
  93. return head :not_found unless post
  94. render json: PostRepr.base(post, current_user)
  95. .merge(tags: build_tag_tree_for(post.tags),
  96. related: PostRepr.many(post.related(limit: 20)))
  97. end
  98. def create
  99. return head :unauthorized unless current_user
  100. return head :forbidden unless current_user.gte_member?
  101. # TODO: サイトに応じて thumbnail_base 設定
  102. title = params[:title].presence
  103. url = params[:url]
  104. thumbnail = params[:thumbnail]
  105. tag_names = params[:tags].to_s.split
  106. original_created_from = params[:original_created_from]
  107. original_created_before = params[:original_created_before]
  108. parent_post_ids = parse_parent_post_ids
  109. post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
  110. original_created_from:, original_created_before:)
  111. post.thumbnail.attach(thumbnail)
  112. ApplicationRecord.transaction do
  113. post.save!
  114. tags = Tag.normalise_tags!(tag_names)
  115. TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
  116. tags = Tag.expand_parent_tags(tags)
  117. sync_post_tags!(post, tags)
  118. sync_parent_posts!(post, parent_post_ids)
  119. post.resized_thumbnail!
  120. PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
  121. end
  122. post.reload
  123. render json: PostRepr.base(post), status: :created
  124. rescue ArgumentError => e
  125. render json: { errors: [e.message] }, status: :unprocessable_entity
  126. rescue ActiveRecord::RecordInvalid
  127. render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
  128. rescue Tag::NicoTagNormalisationError
  129. head :bad_request
  130. end
  131. def viewed
  132. return head :unauthorized unless current_user
  133. current_user.viewed_posts << Post.find(params[:id])
  134. head :no_content
  135. end
  136. def unviewed
  137. return head :unauthorized unless current_user
  138. current_user.viewed_posts.delete(Post.find(params[:id]))
  139. head :no_content
  140. end
  141. def update
  142. return head :unauthorized unless current_user
  143. return head :forbidden unless current_user.gte_member?
  144. title = params[:title].presence
  145. tag_names = params[:tags].to_s.split
  146. original_created_from = params[:original_created_from]
  147. original_created_before = params[:original_created_before]
  148. parent_post_ids = parse_parent_post_ids
  149. post = Post.find(params[:id].to_i)
  150. ApplicationRecord.transaction do
  151. PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
  152. post.update!(title:, original_created_from:, original_created_before:)
  153. normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false)
  154. TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
  155. tags = post.tags.nico.to_a + normalised_tags
  156. tags = Tag.expand_parent_tags(tags)
  157. sync_post_tags!(post, tags)
  158. sync_parent_posts!(post, parent_post_ids)
  159. PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
  160. end
  161. post.reload
  162. json = post.as_json
  163. json['tags'] = build_tag_tree_for(post.tags)
  164. render json:, status: :ok
  165. rescue ArgumentError => e
  166. render json: { errors: [e.message] }, status: :unprocessable_entity
  167. rescue ActiveRecord::RecordInvalid
  168. render json: post.errors, status: :unprocessable_entity
  169. rescue Tag::NicoTagNormalisationError
  170. head :bad_request
  171. end
  172. def changes
  173. id = params[:id].presence
  174. tag_id = params[:tag].presence
  175. page = (params[:page].presence || 1).to_i
  176. limit = (params[:limit].presence || 20).to_i
  177. page = 1 if page < 1
  178. limit = 1 if limit < 1
  179. offset = (page - 1) * limit
  180. pts = PostTag.with_discarded
  181. pts = pts.where(post_id: id) if id.present?
  182. pts = pts.where(tag_id:) if tag_id.present?
  183. pts = pts.includes(:post, :created_user, :deleted_user,
  184. tag: [:materials, { tag_name: :wiki_page }])
  185. events = []
  186. pts.each do |pt|
  187. tag = TagRepr.base(pt.tag)
  188. post = pt.post
  189. events << Event.new(
  190. post:,
  191. tag:,
  192. user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
  193. change_type: 'add',
  194. timestamp: pt.created_at)
  195. if pt.discarded_at
  196. events << Event.new(
  197. post:,
  198. tag:,
  199. user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
  200. change_type: 'remove',
  201. timestamp: pt.discarded_at)
  202. end
  203. end
  204. events.sort_by!(&:timestamp)
  205. events.reverse!
  206. render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
  207. end
  208. private
  209. def filtered_posts
  210. tag_names = params[:tags].to_s.split
  211. match_type = params[:match]
  212. if tag_names.present?
  213. filter_posts_by_tags(tag_names, match_type)
  214. else
  215. Post.all
  216. end
  217. end
  218. def filter_posts_by_tags tag_names, match_type
  219. literals = tag_names.map do |raw_name|
  220. { name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
  221. negative: raw_name.downcase.start_with?('not:') }
  222. end
  223. return Post.all if literals.empty?
  224. if match_type == 'any'
  225. literals.reduce(Post.none) do |posts, literal|
  226. posts.or(tag_literal_relation(literal[:name], negative: literal[:negative]))
  227. end
  228. else
  229. literals.reduce(Post.all) do |posts, literal|
  230. ids = tagged_post_ids_for(literal[:name])
  231. if literal[:negative]
  232. posts.where.not(id: ids)
  233. else
  234. posts.where(id: ids)
  235. end
  236. end
  237. end
  238. end
  239. def tag_literal_relation name, negative:
  240. ids = tagged_post_ids_for(name)
  241. if negative
  242. Post.where.not(id: ids)
  243. else
  244. Post.where(id: ids)
  245. end
  246. end
  247. def tagged_post_ids_for(name) =
  248. Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
  249. def sync_post_tags! post, desired_tags
  250. desired_tags.each do |t|
  251. t.save! if t.new_record?
  252. end
  253. desired_ids = desired_tags.map(&:id).to_set
  254. current_ids = post.tags.pluck(:id).to_set
  255. to_add = desired_ids - current_ids
  256. to_remove = current_ids - desired_ids
  257. Tag.where(id: to_add).find_each do |tag|
  258. begin
  259. PostTag.create!(post:, tag:, created_user: current_user)
  260. rescue ActiveRecord::RecordNotUnique
  261. ;
  262. end
  263. end
  264. PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
  265. pt.discard_by!(current_user)
  266. end
  267. end
  268. def build_tag_tree_for tags
  269. tags = tags.to_a
  270. tag_ids = tags.map(&:id)
  271. implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
  272. children_ids_by_parent = Hash.new { |h, k| h[k] = [] }
  273. implications.each do |imp|
  274. children_ids_by_parent[imp.parent_tag_id] << imp.tag_id
  275. end
  276. child_ids = children_ids_by_parent.values.flatten.uniq
  277. root_ids = tag_ids - child_ids
  278. tags_by_id = tags.index_by(&:id)
  279. memo = { }
  280. build_node = -> tag_id, path do
  281. tag = tags_by_id[tag_id]
  282. return nil unless tag
  283. if path.include?(tag_id)
  284. return TagRepr.base(tag).merge(children: [])
  285. end
  286. if memo.key?(tag_id)
  287. return memo[tag_id]
  288. end
  289. new_path = path + [tag_id]
  290. child_ids = children_ids_by_parent[tag_id] || []
  291. children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
  292. memo[tag_id] = TagRepr.base(tag).merge(children:)
  293. end
  294. root_ids.filter_map { |id| build_node.call(id, []) }
  295. end
  296. def parse_parent_post_ids
  297. raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
  298. params[:parent_post_ids].to_s.split.map { |token|
  299. id = Integer(token, exception: false)
  300. raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
  301. id
  302. }.uniq
  303. end
  304. def sync_parent_posts! post, parent_post_ids
  305. if parent_post_ids.include?(post.id)
  306. post.errors.add(:base, '自分自身を親投稿にはできません.')
  307. raise ActiveRecord::RecordInvalid, post
  308. end
  309. existing_ids = Post.where(id: parent_post_ids).pluck(:id)
  310. missing_ids = parent_post_ids - existing_ids
  311. if missing_ids.present?
  312. post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }")
  313. raise ActiveRecord::RecordInvalid, post
  314. end
  315. current_ids = post.parent_posts.pluck(:id)
  316. ids_to_add = parent_post_ids - current_ids
  317. ids_to_remove = current_ids - parent_post_ids
  318. PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all
  319. ids_to_add.each do |parent_post_id|
  320. PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
  321. end
  322. end
  323. end