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

このコミットが含まれているのは:
2026-02-11 17:54:48 +09:00
コミット e5048dc9b3
123個のファイルの変更5109行の追加1257行の削除
+46 -19
ファイルの表示
@@ -8,16 +8,18 @@ class Post < ApplicationRecord
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',
foreign_key: :post_id
has_many :post_similarities_as_target_post,
class_name: 'PostSimilarity',
foreign_key: :target_post_id
has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all
has_one_attached :thumbnail
before_validation :normalise_url
validates :url, presence: true, uniqueness: true
validate :validate_original_created_range
validate :url_must_be_http_url
def as_json options = { }
super(options).merge({ thumbnail: thumbnail.attached? ?
@@ -28,19 +30,15 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil)
end
def related(limit: nil)
ids_with_cos =
post_similarities_as_post.select(:target_post_id, :cos)
.map { |ps| [ps.target_post_id, ps.cos] } +
post_similarities_as_target_post.select(:post_id, :cos)
.map { |ps| [ps.post_id, ps.cos] }
def related limit: nil
ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit
ids = ids.pluck(:target_post_id)
return Post.none if ids.empty?
sorted = ids_with_cos.sort_by { |_, cos| -cos }
ids = sorted.map(&:first)
ids = ids.first(limit) if limit
Post.where(id: ids).index_by(&:id).values_at(*ids)
Post.where(id: ids)
.with_attached_thumbnail
.order(Arel.sql("FIELD(posts.id, #{ ids.join(',') })"))
end
def resized_thumbnail!
@@ -69,4 +67,33 @@ class Post < ApplicationRecord
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
end
end
def url_must_be_http_url
begin
u = URI.parse(url)
rescue URI::InvalidURIError
errors.add(:url, 'URL が不正です.')
return
end
if !(u in URI::HTTP) || u.host.blank?
errors.add(:url, 'URL が不正です.')
return
end
end
def normalise_url
return if url.blank?
self.url = url.strip
u = URI.parse(url)
return unless u in URI::HTTP
u.host = u.host.downcase if u.host
u.path = u.path.sub(/\/\Z/, '') if u.path.present?
self.url = u.to_s
rescue URI::InvalidURIError
;
end
end
+4 -2
ファイルの表示
@@ -1,4 +1,6 @@
class PostSimilarity < ApplicationRecord
belongs_to :post, class_name: 'Post', foreign_key: 'post_id'
belongs_to :target_post, class_name: 'Post', foreign_key: 'target_post_id'
self.primary_key = :post_id, :target_post_id
belongs_to :post
belongs_to :target_post, class_name: 'Post'
end
+4
ファイルの表示
@@ -1,6 +1,10 @@
class PostTag < ApplicationRecord
include Discard::Model
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
end
belongs_to :post
belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true
+85 -40
ファイルの表示
@@ -1,81 +1,108 @@
class Tag < ApplicationRecord
has_many :post_tags, dependent: :delete_all, inverse_of: :tag
class NicoTagNormalisationError < ArgumentError
;
end
has_many :post_tags, 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
has_many :linked_tags, through: :nico_tag_relations, source: :tag
has_many :reversed_nico_tag_relations, class_name: 'NicoTagRelation',
foreign_key: :tag_id,
dependent: :destroy
has_many :reversed_nico_tag_relations,
class_name: 'NicoTagRelation', foreign_key: :tag_id, 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 :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',
general: 'general',
material: 'material',
nico: 'nico',
meta: 'meta' }
has_many :tag_similarities, dependent: :delete_all
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true
enum :category, deerjikist: 'deerjikist',
meme: 'meme',
character: 'character',
general: 'general',
material: 'material',
nico: 'nico',
meta: 'meta'
validates :name, presence: true, length: { maximum: 255 }
validates :category, presence: true, inclusion: { in: Tag.categories.keys }
validate :nico_tag_name_must_start_with_nico
validate :tag_name_must_be_canonical
scope :nico_tags, -> { where(category: :nico) }
CATEGORY_PREFIXES = {
'gen:' => 'general',
'djk:' => 'deerjikist',
'meme:' => 'meme',
'chr:' => 'character',
'mtr:' => 'material',
'meta:' => 'meta' }.freeze
'general:' => :general,
'gen:' => :general,
'deerjikist:' => :deerjikist,
'djk:' => :deerjikist,
'meme:' => :meme,
'character:' => :character,
'chr:' => :character,
'material:' => :material,
'mtr:' => :material,
'meta:' => :meta }.freeze
def name= val
(self.tag_name ||= build_tag_name).name = val
end
def has_wiki
wiki_page.present?
end
def self.tagme
@tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag|
tag.category = 'meta'
end
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
end
def self.bot
@bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag|
tag.category = 'meta'
end
@bot ||= find_or_create_by_tag_name!('bot操作', category: :meta)
end
def self.no_deerjikist
@no_deerjikist ||= Tag.find_or_initialize_by(name: 'ニジラー情報不詳') do |tag|
tag.category = 'meta'
end
@no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
end
def self.normalise_tags tag_names, with_tagme: true
def self.video
@video ||= find_or_create_by_tag_name!('動画', category: :meta)
end
def self.niconico
@niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta)
end
def self.normalise_tags tag_names, with_tagme: true, deny_nico: true
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
tags = tag_names.map do |name|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil]
name.delete_prefix!(pf)
Tag.find_or_initialize_by(name:).tap do |tag|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if cat && tag.category != cat
tag.category = cat
tag.save!
tag.update!(category: cat)
end
end
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if tags.all? { |t| t.category != 'deerjikist' }
tags.uniq
tags.uniq(&:id)
end
def self.expand_parent_tags tags
@@ -101,12 +128,30 @@ class Tag < ApplicationRecord
(result + tags).uniq { |t| t.id }
end
def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id?
Tag.find_or_create_by!(tag_name_id: tn.id) do |t|
t.category = category
end
rescue ActiveRecord::RecordNotUnique
retry
end
private
def nico_tag_name_must_start_with_nico
if ((category == 'nico' && !(name.start_with?('nico:'))) ||
(category != 'nico' && name.start_with?('nico:')))
n = name.to_s
if ((category == 'nico' && !(n.downcase.start_with?('nico:'))) ||
(category != 'nico' && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end
end
def tag_name_must_be_canonical
if tag_name&.canonical_id?
errors.add :tag_name, 'tag_names へは実体を示す必要があります.'
end
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TagAlias < ApplicationRecord
belongs_to :tag
validates :tag_id, presence: true
validates :name, presence: true, length: { maximum: 255 }, uniqueness: true
end
+42
ファイルの表示
@@ -0,0 +1,42 @@
class TagName < ApplicationRecord
has_one :tag
has_one :wiki_page
belongs_to :canonical, class_name: 'TagName', optional: true
has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id
validates :name, presence: true, length: { maximum: 255 }, uniqueness: true
validate :canonical_must_be_canonical
validate :alias_name_must_not_have_prefix
validate :canonical_must_not_be_present_with_tag_or_wiki_page
def self.canonicalise names
names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?)
return [] if names.blank?
tns = TagName.includes(:canonical).where(name: names).index_by(&:name)
names.map { |name| tns[name]&.canonical&.name || name }.uniq
end
private
def canonical_must_be_canonical
if canonical&.canonical_id?
errors.add :canonical, 'canonical は実体を示す必要があります.'
end
end
def alias_name_must_not_have_prefix
if canonical_id? && name.to_s.include?(':')
errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.'
end
end
def canonical_must_not_be_present_with_tag_or_wiki_page
if canonical_id? && (tag || wiki_page)
errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.'
end
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TagSimilarity < ApplicationRecord
self.primary_key = :tag_id, :target_tag_id
belongs_to :tag
belongs_to :target_tag, class_name: 'Tag'
end
+2
ファイルの表示
@@ -1,4 +1,6 @@
class UserIp < ApplicationRecord
self.primary_key = :user_id, :ip_address_id
belongs_to :user
belongs_to :ip_address
+2
ファイルの表示
@@ -1,4 +1,6 @@
class UserPostView < ApplicationRecord
self.primary_key = :user_id, :post_id
belongs_to :user
belongs_to :post
+15
ファイルの表示
@@ -0,0 +1,15 @@
class WikiLine < ApplicationRecord
has_many :wiki_revision_lines, dependent: :restrict_with_exception
validates :sha256, presence: true, uniqueness: true, length: { is: 64 }
validates :body, presence: true
def self.upsert_by_body! body
sha = Digest::SHA256.hexdigest(body)
now = Time.current
upsert({ sha256: sha, body:, created_at: now, updated_at: now })
find_by!(sha256: sha)
end
end
+39 -60
ファイルの表示
@@ -1,80 +1,59 @@
require 'gollum-lib'
require 'set'
class WikiPage < ApplicationRecord
belongs_to :tag, optional: true
belongs_to :created_user, class_name: 'User', foreign_key: 'created_user_id'
belongs_to :updated_user, class_name: 'User', foreign_key: 'updated_user_id'
has_many :wiki_revisions, dependent: :destroy
belongs_to :created_user, class_name: 'User'
belongs_to :updated_user, class_name: 'User'
validates :title, presence: true, length: { maximum: 255 }, uniqueness: true
has_many :redirected_from_revisions,
class_name: 'WikiRevision',
foreign_key: :redirect_page_id,
dependent: :nullify
def as_json options = { }
self.sha = nil
super options
belongs_to :tag_name
validates :tag_name, presence: true
def title
tag_name.name
end
def sha= val
if val.present?
@sha = val
@page = wiki.page("#{ id }.md", @sha)
else
@page = wiki.page("#{ id }.md")
@sha = @page.versions.first.id
end
vers = @page.versions
idx = vers.find_index { |ver| ver.id == @sha }
if idx
@pred = vers[idx + 1]&.id
@succ = idx.positive? ? vers[idx - 1].id : nil
@updated_at = vers[idx].authored_date
else
@sha = nil
@pred = nil
@succ = nil
@updated_at = nil
end
@sha
def title= val
(self.tag_name ||= build_tag_name).name = val
end
def sha
@sha
end
def pred
@pred
end
def succ
@succ
end
def updated_at
@updated_at
def current_revision
wiki_revisions.order(id: :desc).first
end
def body
sha = nil unless @page
@page&.raw_data&.force_encoding('UTF-8')
rev = current_revision
rev.body if rev&.content?
end
def set_body content, user:
commit_info = { name: user.id.to_s,
email: 'dummy@example.com' }
page = wiki.page("#{ id }.md")
if page
commit_info[:message] = "Updated #{ id }"
wiki.update_page(page, id.to_s, :markdown, content, commit_info)
else
commit_info[:message] = "Created #{ id }"
wiki.write_page(id.to_s, :markdown, content, commit_info)
def resolve_redirect limit: 10
page = self
visited = Set.new
limit.times do
return page if visited.include?(page.id)
visited.add(page.id)
rev = page.current_revision
return page if !(rev&.redirect?) || !(rev.redirect_page)
page = rev.redirect_page
end
page
end
private
def pred_revision_id revision_id
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
end
WIKI_PATH = Rails.root.join('wiki').to_s
def wiki
@wiki ||= Gollum::Wiki.new(WIKI_PATH)
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
end
+55
ファイルの表示
@@ -0,0 +1,55 @@
class WikiRevision < ApplicationRecord
belongs_to :wiki_page
belongs_to :base_revision, class_name: 'WikiRevision', optional: true
belongs_to :created_user, class_name: 'User'
belongs_to :redirect_page, class_name: 'WikiPage', optional: true
has_many :wiki_revision_lines, dependent: :delete_all
has_many :wiki_lines, through: :wiki_revision_lines
enum :kind, { content: 0, redirect: 1 }
validates :kind, presence: true
validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :tree_sha256, length: { is: 64 }, allow_nil: true
validate :kind_consistency
def body
return unless content?
wiki_revision_lines
.includes(:wiki_line)
.order(:position)
.map { |rev| rev.wiki_line.body }
.join("\n")
end
private
def kind_consistency
if content?
if tree_sha256.blank?
errors.add(:tree_sha256, '種類がページの場合は必須です.')
end
if redirect_page_id.present?
errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.')
end
end
if redirect?
if redirect_page_id.blank?
errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.')
end
if tree_sha256.present?
errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.')
end
if lines_count.to_i > 0
errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.')
end
end
end
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
class WikiRevisionLine < ApplicationRecord
belongs_to :wiki_revision
belongs_to :wiki_line
validates :position, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :position, uniqueness: { scope: :wiki_revision_id }
end