Merge remote-tracking branch 'origin/main' into #92

This commit is contained in:
2025-12-30 13:08:04 +09:00
67 changed files with 2222 additions and 542 deletions
+163 -25
View File
@@ -1,34 +1,50 @@
require 'open-uri'
require 'nokogiri'
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
# GET /posts
def index
limit = params[:limit].presence&.to_i
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
cursor = params[:cursor].presence
q = filtered_posts.order(created_at: :desc)
q = q.where('posts.created_at < ?', Time.iso8601(cursor)) if cursor
page = 1 if page < 1
limit = 1 if limit < 1
posts = limit ? q.limit(limit + 1) : q
offset = (page - 1) * limit
sort_sql =
'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' +
'posts.original_created_from,' +
'posts.created_at)'
q =
filtered_posts
.preload(:tags)
.with_attached_thumbnail
.select("posts.*, #{ sort_sql } AS sort_ts")
.order(Arel.sql("#{ sort_sql } DESC"))
posts = (
if cursor
q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1)
else
q.limit(limit).offset(offset)
end).to_a
next_cursor = nil
if limit && posts.size > limit
next_cursor = posts.last.created_at.iso8601(6)
if cursor && posts.length > limit
next_cursor = posts.last.read_attribute('sort_ts').iso8601(6)
posts = posts.first(limit)
end
render json: { posts: posts.map { |post|
post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap { |json|
post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap do |json|
json['thumbnail'] =
if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
else
nil
end
}
}, next_cursor: }
end
}, count: filtered_posts.count(:id), next_cursor: }
end
def random
@@ -39,7 +55,7 @@ class PostsController < ApplicationController
render json: (post
.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } })
.merge(viewed: viewed))
.merge(viewed:))
end
# GET /posts/1
@@ -49,9 +65,12 @@ class PostsController < ApplicationController
viewed = current_user&.viewed?(post) || false
render json: (post
.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } })
.merge(related: post.related(limit: 20), viewed:))
json = post.as_json
json['tags'] = build_tag_tree_for(post.tags)
json['related'] = post.related(limit: 20)
json['viewed'] = viewed
render json:
end
# POST /posts
@@ -60,18 +79,23 @@ class PostsController < ApplicationController
return head :forbidden unless current_user.member?
# TODO: URL が正規のものがチェック,不正ならエラー
# TODO: title、URL は必須にする.
# TODO: URL は必須にする(タイトルは省略可)
# TODO: サイトに応じて thumbnail_base 設定
title = params[:title]
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]
post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user)
post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user,
original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail)
if post.save
post.resized_thumbnail!
post.tags = Tag.normalise_tags(tags_names)
tags = Tag.normalise_tags(tag_names)
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }),
status: :created
else
@@ -100,12 +124,18 @@ class PostsController < ApplicationController
title = params[:title]
tag_names = params[:tags].to_s.split(' ')
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
post = Post.find(params[:id].to_i)
tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names)
if post.update(title:, tags:)
render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }),
status: :ok
if post.update(title:, original_created_from:, original_created_before:)
tags = post.tags.where(category: 'nico').to_a +
Tag.normalise_tags(tag_names, with_tagme: false)
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
json = post.as_json
json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok
else
render json: post.errors, status: :unprocessable_entity
end
@@ -115,12 +145,54 @@ class PostsController < ApplicationController
def destroy
end
def changes
id = params[:id]
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.includes(:post, :tag, :created_user, :deleted_user)
events = []
pts.each do |pt|
events << Event.new(
post: pt.post,
tag: pt.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: pt.post,
tag: pt.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]&.split(' ')
match_type = params[:match]
tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all
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
@@ -134,4 +206,70 @@ class PostsController < ApplicationController
end
posts.distinct
end
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 tag.as_json(only: [:id, :name, :category, :post_count]).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] = tag.as_json(only: [:id, :name, :category, :post_count]).merge(children:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
end
end
+8 -5
View File
@@ -6,12 +6,15 @@ class UsersController < ApplicationController
end
def verify
ip_bin = IPAddr.new(request.remote_ip).hton
ip_address = IpAddress.find_or_create_by!(ip_address: ip_bin)
user = User.find_by(inheritance_code: params[:code])
render json: if user
{ valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
else
{ valid: false }
end
return render json: { valid: false } unless user
UserIp.find_or_create_by!(user:, ip_address:)
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
end
def renew
@@ -13,6 +13,22 @@ class WikiPagesController < ApplicationController
render_wiki_page_or_404 WikiPage.find_by(title: params[:title])
end
def exists
if WikiPage.exists?(params[:id])
head :no_content
else
head :not_found
end
end
def exists_by_title
if WikiPage.exists?(title: params[:title])
head :no_content
else
head :not_found
end
end
def diff
id = params[:id]
from = params[:from]
+1
View File
@@ -6,6 +6,7 @@ class NicoTagRelation < ApplicationRecord
validates :tag_id, presence: true
validate :nico_tag_must_be_nico
validate :tag_mustnt_be_nico
private
+25 -5
View File
@@ -1,11 +1,13 @@
require 'mini_magick'
class Post < ApplicationRecord
require 'mini_magick'
belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy
has_many :tags, through: :post_tags
has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :user_post_views, dependent: :destroy
has_many :post_similarities_as_post,
class_name: 'PostSimilarity',
@@ -15,6 +17,8 @@ class Post < ApplicationRecord
foreign_key: :target_post_id
has_one_attached :thumbnail
validate :validate_original_created_range
def as_json options = { }
super(options).merge({ thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url(
@@ -49,4 +53,20 @@ class Post < ApplicationRecord
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
end
private
def validate_original_created_range
f = original_created_from
b = original_created_before
return if f.blank? || b.blank?
f = Time.zone.parse(f) if String === f
b = Time.zone.parse(b) if String === b
return if !(f) || !(b)
if f >= b
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
end
end
end
+18
View File
@@ -1,7 +1,25 @@
class PostTag < ApplicationRecord
include Discard::Model
belongs_to :post
belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
scope: :tag_id,
conditions: -> { where(discarded_at: nil) } }
def discard_by! deleted_user
return self if discarded?
transaction do
update!(discarded_at: Time.current, deleted_user:)
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
end
self
end
end
+38 -5
View File
@@ -1,6 +1,8 @@
class Tag < ApplicationRecord
has_many :post_tags, dependent: :destroy
has_many :posts, through: :post_tags
has_many :post_tags, dependent: :delete_all, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :posts, through: :active_post_tags
has_many :tag_aliases, dependent: :destroy
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
@@ -11,6 +13,14 @@ class Tag < ApplicationRecord
dependent: :destroy
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
has_many :children, through: :tag_implications, source: :tag
has_many :reversed_tag_implications, class_name: 'TagImplication',
foreign_key: :tag_id,
dependent: :destroy
has_many :parents, through: :reversed_tag_implications, source: :parent_tag
enum :category, { deerjikist: 'deerjikist',
meme: 'meme',
character: 'character',
@@ -35,13 +45,13 @@ class Tag < ApplicationRecord
'meta:' => 'meta' }.freeze
def self.tagme
@tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag|
@tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag|
tag.category = 'meta'
end
end
def self.bot
@bot ||= Tag.find_or_initialize_by(name: 'bot操作') do |tag|
@bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag|
tag.category = 'meta'
end
end
@@ -57,10 +67,33 @@ class Tag < ApplicationRecord
end
end
end
tags << Tag.tagme if with_tagme && tags.size < 20 && tags.none?(Tag.tagme)
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags.uniq
end
def self.expand_parent_tags tags
return [] if tags.blank?
seen = Set.new
result = []
stack = tags.compact.dup
until stack.empty?
tag = stack.pop
next unless tag
tag.parents.each do |parent|
next if seen.include?(parent.id)
seen << parent.id
result << parent
stack << parent
end
end
(result + tags).uniq { |t| t.id }
end
private
def nico_tag_name_must_start_with_nico
+17
View File
@@ -0,0 +1,17 @@
class TagImplication < ApplicationRecord
belongs_to :tag, class_name: 'Tag'
belongs_to :parent_tag, class_name: 'Tag'
validates :tag_id, presence: true, uniqueness: { scope: :parent_tag_id }
validates :parent_tag_id, presence: true
validate :parent_tag_mustnt_be_itself
private
def parent_tag_mustnt_be_itself
if parent_tag == tag
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
end
end
end
-1
View File
@@ -8,7 +8,6 @@ class User < ApplicationRecord
has_many :posts
has_many :settings
has_many :ip_addresses
has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy