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

このコミットが含まれているのは:
2026-02-11 17:54:48 +09:00
コミット e5048dc9b3
123個のファイルの変更5109行の追加1257行の削除
+11
ファイルの表示
@@ -11,4 +11,15 @@ class ApplicationController < ActionController::API
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
@current_user = User.find_by(inheritance_code: code)
end
def bool? key, default: false
return default if params[key].nil?
s = params[key].to_s.strip.downcase
if default
!(s.in?(['0', 'false', 'off', 'no']))
else
s.in?(['', '1', 'true', 'on', 'yes'])
end
end
end
-20
ファイルの表示
@@ -1,20 +0,0 @@
class IpAddressesController < ApplicationController
def index
@ip_addresses = IpAddress.all
render json: @ip_addresses
end
def show
render json: @ip_address
end
def create
end
def update
end
def destroy
end
end
-16
ファイルの表示
@@ -1,16 +0,0 @@
class NicoTagRelationController < ApplicationController
def index
end
def show
end
def create
end
def update
end
def destroy
end
end
+11 -6
ファイルの表示
@@ -1,12 +1,16 @@
class NicoTagsController < ApplicationController
TAG_JSON = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze
def index
limit = (params[:limit] || 20).to_i
cursor = params[:cursor].presence
q = Tag.nico_tags.includes(:linked_tags).order(updated_at: :desc)
q = Tag.nico_tags
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
.order(updated_at: :desc)
q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor
tags = q.limit(limit + 1)
tags = q.limit(limit + 1).to_a
next_cursor = nil
if tags.size > limit
@@ -15,7 +19,9 @@ class NicoTagsController < ApplicationController
end
render json: { tags: tags.map { |tag|
tag.as_json(include: :linked_tags)
tag.as_json(TAG_JSON).merge(linked_tags: tag.linked_tags.map { |lt|
lt.as_json(TAG_JSON)
})
}, next_cursor: }
end
@@ -30,12 +36,11 @@ class NicoTagsController < ApplicationController
linked_tag_names = params[:tags].to_s.split(' ')
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false)
return head :bad_request if linked_tags.filter { |t| t.category == 'nico' }.present?
return head :bad_request if linked_tags.any? { |t| t.category == 'nico' }
tag.linked_tags = linked_tags
tag.updated_at = Time.now
tag.save!
render json: tag.linked_tags, status: :ok
render json: tag.linked_tags.map { |t| t.as_json(TAG_JSON) }, status: :ok
end
end
-16
ファイルの表示
@@ -1,16 +0,0 @@
class PostTagsController < ApplicationController
def index
end
def show
end
def create
end
def update
end
def destroy
end
end
+60 -44
ファイルの表示
@@ -1,7 +1,6 @@
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
# GET /posts
def index
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -18,16 +17,17 @@ class PostsController < ApplicationController
'posts.created_at)'
q =
filtered_posts
.preload(:tags)
.preload(tags: { tag_name: :wiki_page })
.with_attached_thumbnail
.select("posts.*, #{ sort_sql } AS sort_ts")
.order(Arel.sql("#{ sort_sql } DESC"))
posts = (
posts =
if cursor
q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1)
else
q.limit(limit).offset(offset)
end).to_a
end
.to_a
next_cursor = nil
if cursor && posts.length > limit
@@ -36,7 +36,8 @@ class PostsController < ApplicationController
end
render json: { posts: posts.map { |post|
post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap do |json|
post.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } }).tap do |json|
json['thumbnail'] =
if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -44,23 +45,29 @@ class PostsController < ApplicationController
nil
end
end
}, count: filtered_posts.count(:id), next_cursor: }
}, count: if filtered_posts.group_values.present?
filtered_posts.count.size
else
filtered_posts.count
end, next_cursor: }
end
def random
post = filtered_posts.order('RAND()').first
post = filtered_posts.preload(tags: { tag_name: :wiki_page })
.order('RAND()')
.first
return head :not_found unless post
viewed = current_user&.viewed?(post) || false
render json: (post
.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } })
.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } })
.merge(viewed:))
end
# GET /posts/1
def show
post = Post.includes(:tags).find(params[:id])
post = Post.includes(tags: { tag_name: :wiki_page }).find(params[:id])
return head :not_found unless post
viewed = current_user&.viewed?(post) || false
@@ -73,22 +80,19 @@ class PostsController < ApplicationController
render json:
end
# POST /posts
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.member?
# TODO: URL が正規のものがチェック,不正ならエラー
# TODO: URL は必須にする(タイトルは省略可).
# TODO: サイトに応じて thumbnail_base 設定
title = params[:title]
title = params[:title].presence
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: nil, uploaded_user: current_user,
original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail)
if post.save
@@ -96,11 +100,16 @@ class PostsController < ApplicationController
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] } }),
post.reload
render json: post.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } }),
status: :created
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
rescue Tag::NicoTagNormalisationError
head :bad_request
end
def viewed
@@ -117,12 +126,11 @@ class PostsController < ApplicationController
head :no_content
end
# PATCH/PUT /posts/1
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.member?
title = params[:title]
title = params[:title].presence
tag_names = params[:tags].to_s.split(' ')
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
@@ -133,20 +141,20 @@ class PostsController < ApplicationController
Tag.normalise_tags(tag_names, with_tagme: false)
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
post.reload
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
end
# DELETE /posts/1
def destroy
rescue Tag::NicoTagNormalisationError
head :bad_request
end
def changes
id = params[:id]
id = params[:id].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -157,36 +165,40 @@ class PostsController < ApplicationController
pts = PostTag.with_discarded
pts = pts.where(post_id: id) if id.present?
pts = pts.includes(:post, :tag, :created_user, :deleted_user)
pts = pts.includes(:post, :created_user, :deleted_user,
tag: { tag_name: :wiki_page })
events = []
pts.each do |pt|
tag = pt.tag.as_json(only: [:id, :category], methods: [:name, :has_wiki])
post = pt.post
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)
post:,
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)
post:,
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 }
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
end
private
def filtered_posts
tag_names = params[:tags]&.split(' ')
tag_names = params[:tags].to_s.split(' ')
match_type = params[:match]
if tag_names.present?
filter_posts_by_tags(tag_names, match_type)
@@ -196,15 +208,17 @@ class PostsController < ApplicationController
end
def filter_posts_by_tags tag_names, match_type
posts = Post.joins(:tags)
tag_names = TagName.canonicalise(tag_names)
posts = Post.joins(tags: :tag_name)
if match_type == 'any'
posts = posts.where(tags: { name: tag_names }).distinct
posts.where(tag_names: { name: tag_names }).distinct
else
tag_names.each do |tag|
posts = posts.where(id: Post.joins(:tags).where(tags: { name: tag }))
end
posts.where(tag_names: { name: tag_names })
.group('posts.id')
.having('COUNT(DISTINCT tag_names.id) = ?', tag_names.uniq.size)
end
posts.distinct
end
def sync_post_tags! post, desired_tags
@@ -255,7 +269,8 @@ class PostsController < ApplicationController
return nil unless tag
if path.include?(tag_id)
return tag.as_json(only: [:id, :name, :category, :post_count]).merge(children: [])
return tag.as_json(only: [:id, :category, :post_count],
methods: [:name, :has_wiki]).merge(children: [])
end
if memo.key?(tag_id)
@@ -267,7 +282,8 @@ class PostsController < ApplicationController
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:)
memo[tag_id] = tag.as_json(only: [:id, :category, :post_count],
methods: [:name, :has_wiki]).merge(children:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
-16
ファイルの表示
@@ -1,16 +0,0 @@
class SettingsController < ApplicationController
def index
end
def show
end
def create
end
def update
end
def destroy
end
end
-16
ファイルの表示
@@ -1,16 +0,0 @@
class TagAliasesController < ApplicationController
def index
end
def show
end
def create
end
def update
end
def destroy
end
end
+27
ファイルの表示
@@ -0,0 +1,27 @@
class TagChildrenController < ApplicationController
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.admin?
parent_id = params[:parent_id]
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?
Tag.find(parent_id).children << Tag.find(child_id) rescue nil
head :no_content
end
def destroy
return head :unauthorized unless current_user
return head :forbidden unless current_user.admin?
parent_id = params[:parent_id]
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?
Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil
head :no_content
end
end
+85 -22
ファイルの表示
@@ -1,46 +1,109 @@
class TagsController < ApplicationController
def index
post_id = params[:post]
tags = if post_id.present?
Tag.joins(:posts).where(posts: { id: post_id })
else
Tag.all
end
render json: tags
tags =
if post_id.present?
Tag.joins(:posts, :tag_name)
else
Tag.joins(:tag_name)
end
.includes(:tag_name, tag_name: :wiki_page)
if post_id.present?
tags = tags.where(posts: { id: post_id })
end
render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
end
def autocomplete
q = params[:q].to_s.strip
return render json: [] if q.blank?
tags = (Tag
.where('(category = ? AND name LIKE ?) OR name LIKE ?',
'nico', "nico:#{ q }%", "#{ q }%")
.order('post_count DESC, name ASC')
.limit(20))
render json: tags
with_nico = bool?(:nico, default: true)
present_only = bool?(:present, default: true)
alias_rows =
TagName
.where('name LIKE ?', "#{ q }%")
.where.not(canonical_id: nil)
.pluck(:canonical_id, :name)
matched_alias_by_tag_name_id = { }
canonical_ids = []
alias_rows.each do |canonical_id, alias_name|
canonical_ids << canonical_id
matched_alias_by_tag_name_id[canonical_id] ||= alias_name
end
base = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
base = base.where('tags.post_count > 0') if present_only
canonical_hit =
base
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
'tag_names.name LIKE ?'),
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
tags =
if canonical_ids.present?
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
else
canonical_hit
end
tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a
render json: tags.map { |tag|
tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
.merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
end
def show
tag = Tag.find(params[:id])
render json: tag
end
def show_by_name
tag = Tag.find_by(name: params[:name])
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
if tag
render json: tag
render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
else
head :not_found
end
end
def create
def show_by_name
name = params[:name].to_s.strip
return head :bad_request if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: })
if tag
render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
else
head :not_found
end
end
def update
end
return head :unauthorized unless current_user
return head :forbidden unless current_user.member?
def destroy
name = params[:name].presence
category = params[:category].presence
tag = Tag.find(params[:id])
if name.present?
tag.tag_name.update!(name:)
end
if category.present?
tag.update!(category:)
end
render json: tag.as_json(methods: [:name])
end
end
-16
ファイルの表示
@@ -1,16 +0,0 @@
class UserIpsController < ApplicationController
def index
end
def show
end
def create
end
def update
end
def destroy
end
end
-16
ファイルの表示
@@ -1,16 +0,0 @@
class UserPostViewsController < ApplicationController
def index
end
def show
end
def create
end
def update
end
def destroy
end
end
+2
ファイルの表示
@@ -18,6 +18,8 @@ class UsersController < ApplicationController
end
def renew
return head :unauthorized unless current_user
user = current_user
user.inheritance_code = SecureRandom.uuid
user.save!
+114 -73
ファイルの表示
@@ -1,20 +1,36 @@
class WikiPagesController < ApplicationController
def index
wiki_pages = WikiPage.all
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
render json: wiki_pages
def index
title = params[:title].to_s.strip
if title.blank?
return render json: WikiPage.joins(:tag_name)
.includes(:tag_name)
.as_json(methods: [:title])
end
q = WikiPage.joins(:tag_name).includes(:tag_name)
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
render json: q.limit(20).as_json(methods: [:title])
end
def show
render_wiki_page_or_404 WikiPage.find(params[:id])
page = WikiPage.joins(:tag_name)
.includes(:tag_name)
.find_by(id: params[:id])
render_wiki_page_or_404 page
end
def show_by_title
render_wiki_page_or_404 WikiPage.find_by(title: params[:title])
title = params[:title].to_s.strip
page = WikiPage.joins(:tag_name)
.includes(:tag_name)
.find_by(tag_name: { name: title })
render_wiki_page_or_404 page
end
def exists
if WikiPage.exists?(params[:id])
if WikiPage.exists?(id: params[:id])
head :no_content
else
head :not_found
@@ -22,7 +38,8 @@ class WikiPagesController < ApplicationController
end
def exists_by_title
if WikiPage.exists?(title: params[:title])
title = params[:title].to_s.strip
if WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
head :no_content
else
head :not_found
@@ -31,21 +48,25 @@ class WikiPagesController < ApplicationController
def diff
id = params[:id]
from = params[:from]
return head :bad_request if id.blank?
from = params[:from].presence
to = params[:to].presence
return head :bad_request if id.blank? || from.blank?
wiki_page_from = WikiPage.find(id)
wiki_page_to = WikiPage.find(id)
wiki_page_from.sha = from
wiki_page_to.sha = to
page = WikiPage.joins(:tag_name).includes(:tag_name).find(id)
diffs = Diff::LCS.sdiff(wiki_page_from.body, wiki_page_to.body)
from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
return head :unprocessable_entity
end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
diff_json = diffs.map { |change|
case change.action
when ?=
{ type: 'context', content: change.old_element }
when ?|
when ?!
[{ type: 'removed', content: change.old_element },
{ type: 'added', content: change.new_element }]
when ?+
@@ -55,23 +76,32 @@ class WikiPagesController < ApplicationController
end
}.flatten.compact
render json: { wiki_page_id: wiki_page_from.id,
title: wiki_page_from.title,
older_sha: wiki_page_from.sha,
newer_sha: wiki_page_to.sha,
diff: diff_json }
render json: { wiki_page_id: page.id,
title: page.title,
older_revision_id: from_rev&.id,
newer_revision_id: to_rev.id,
diff: diff_json }
end
def create
return head :unauthorized unless current_user
return head :forbidden unless ['admin', 'member'].include?(current_user.role)
return head :forbidden unless current_user.member?
wiki_page = WikiPage.new(title: params[:title], created_user: current_user, updated_user: current_user)
if wiki_page.save
wiki_page.set_body params[:body], user: current_user
render json: wiki_page, status: :created
name = params[:title]&.strip
body = params[:body].to_s
return head :unprocessable_entity if name.blank? || body.blank?
tag_name = TagName.find_or_create_by!(name:)
page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user)
if page.save
message = params[:message].presence
Wiki::Commit.content!(page:, body:, created_user: current_user, message:)
render json: page.as_json(methods: [:title]), status: :created
else
render json: { errors: wiki_page.errors.full_messages }, status: :unprocessable_entity
render json: { errors: page.errors.full_messages },
status: :unprocessable_entity
end
end
@@ -79,73 +109,84 @@ class WikiPagesController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.member?
title = params[:title]
body = params[:body]
title = params[:title]&.strip
body = params[:body].to_s
return head :unprocessable_entity if title.blank? || body.blank?
wiki_page = WikiPage.find(params[:id])
wiki_page.title = title
wiki_page.updated_user = current_user
wiki_page.set_body(body, user: current_user)
wiki_page.save!
page = WikiPage.find(params[:id])
base_revision_id = page.current_revision.id
if params[:title].present? && params[:title].strip != page.title
return head :unprocessable_entity
end
message = params[:message].presence
Wiki::Commit.content!(page:,
body:,
created_user: current_user,
message:,
base_revision_id:)
head :ok
end
def search
title = params[:title]&.strip
q = WikiPage.all
q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") if title.present?
render json: q.limit(20)
index
end
def changes
id = params[:id]
log = if id.present?
wiki.page("#{ id }.md")&.versions
else
wiki.repo.log('main', nil)
end
return render json: [] unless log
id = params[:id].presence
q = WikiRevision.joins(wiki_page: :tag_name)
.includes(:created_user, wiki_page: :tag_name)
.order(id: :desc)
q = q.where(wiki_page_id: id) if id
render json: log.map { |commit|
wiki_page = WikiPage.find(commit.message.split(' ')[1].to_i)
wiki_page.sha = commit.id
next nil if wiki_page.sha.blank?
user = User.find(commit.author.name.to_i)
{ sha: wiki_page.sha,
pred: wiki_page.pred,
succ: wiki_page.succ,
wiki_page: wiki_page && { id: wiki_page.id, title: wiki_page.title },
user: user && { id: user.id, name: user.name },
change_type: commit.message.split(' ')[0].downcase[0...(-1)],
timestamp: commit.authored_date }
render json: q.limit(200).map { |rev|
{ revision_id: rev.id,
pred: rev.base_revision_id,
succ: nil,
wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title },
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
kind: rev.kind,
message: rev.message,
timestamp: rev.created_at }
}.compact
end
private
WIKI_PATH = Rails.root.join('wiki').to_s
def render_wiki_page_or_404 page
return head :not_found unless page
def wiki
@wiki ||= Gollum::Wiki.new(WIKI_PATH)
rev = find_revision(page)
return head :not_found unless rev
if rev.redirect?
return (
redirect_to wiki_page_by_title_path(title: rev.redirect_page.title),
status: :moved_permanently)
end
body = rev.body
revision_id = rev.id
pred = page.pred_revision_id(revision_id)
succ = page.succ_revision_id(revision_id)
updated_at = rev.created_at
render json: page.as_json(methods: [:title])
.merge(body:, revision_id:, pred:, succ:, updated_at:)
end
def render_wiki_page_or_404 wiki_page
return head :not_found unless wiki_page
def find_revision page
if params[:version].present?
page.wiki_revisions.find_by(id: params[:version])
else
page.current_revision
end
end
wiki_page.sha = params[:version].presence
body = wiki_page.body
sha = wiki_page.sha
pred = wiki_page.pred
succ = wiki_page.succ
render json: wiki_page.as_json.merge(body:, sha:, pred:, succ:)
def render_wiki_conflict err
render json: { error: 'conflict', message: err.message }, status: :conflict
end
end
+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
+106
ファイルの表示
@@ -0,0 +1,106 @@
module Similarity
class Calc
def self.call model, tgt
similarity_model = "#{ model.name }Similarity".constantize
# 最大保存件数
n = 20
similarity_model.delete_all
posts = model.includes(tgt).select(:id).to_a
tag_ids = { }
tag_cnts = { }
posts.each do |p|
arr = p.public_send(tgt).map(&:id).sort
tag_ids[p.id] = arr
tag_cnts[p.id] = arr.size
end
intersection_size = -> a, b do
i = 0
j = 0
cnt = 0
while i < a.size && j < b.size
a_i = a[i]
b_j = b[j]
if a_i == b_j
cnt += 1
i += 1
j += 1
elsif a_i < b_j
i += 1
else
j += 1
end
end
cnt
end
push_topk = -> list, cos, target_id do
return if list.size >= n && cos <= list[-1][0]
idx = nil
list.each_with_index do |(c, tid), i|
if tid == target_id
idx = i
break
end
end
if idx
return if cos <= list[idx][0]
list.delete_at(idx)
end
insert_at = list.size
list.each_with_index do |(c, _), i|
if cos > c
insert_at = i
break
end
end
list.insert(insert_at, [cos, target_id])
list.pop if list.size > n
end
top = Hash.new { |h, key| h[key] = [] }
ids = posts.map(&:id)
ids.each_with_index do |post_id, i|
a = tag_ids[post_id]
a_cnt = tag_cnts[post_id]
((i + 1)...ids.size).each do |j|
target_id = ids[j]
b = tag_ids[target_id]
b_cnt = tag_cnts[target_id]
norm = Math.sqrt(a_cnt * b_cnt)
cos = norm.zero? ? 0.0 : intersection_size.(a, b).fdiv(norm)
push_topk.(top[post_id], cos, target_id)
push_topk.(top[target_id], cos, post_id)
end
end
buf = []
flush = -> do
return if buf.empty?
similarity_model.insert_all!(buf)
buf.clear
end
top.each do |post_id, list|
list.each do |cos, target_post_id|
buf << { "#{ model.name.underscore }_id".to_sym => post_id,
"target_#{ model.name.underscore }_id".to_sym => target_post_id,
cos: }
flush.call if buf.size >= 1_000
end
end
flush.call
end
end
end
+122
ファイルの表示
@@ -0,0 +1,122 @@
require 'digest'
module Wiki
class Commit
class Conflict < StandardError
;
end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:)
end
def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil
new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:)
end
def initialize page:, created_user:
@page = page
@created_user = created_user
end
def content! body:, message:, base_revision_id:
normalised = normalise_body(body)
lines = split_lines(normalised)
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
ActiveRecord::Base.transaction do
@page.lock!
if base_revision_id.present?
current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Conflict,
"競合が発生してゐます(現在の Id.#{ current_id },ベース Id.#{ base_revision_id })."
end
end
rev = WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
created_user: @created_user,
kind: :content,
redirect_page_id: nil,
message:,
lines_count: lines.length,
tree_sha256: tree_sha)
rows = line_ids.each_with_index.map do |line_id, pos|
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end
WikiRevisionLine.insert_all!(rows)
rev
end
end
def redirect! redirect_page:, message:, base_revision_id:
ActiveRecord::Base.transaction do
@page.lock!
if base_revision_id.present?
current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Conflict,
"競合が発生してゐます(現在の Id.#{ current_id },ベース Id.#{ base_revision_id })."
end
end
WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
created_user: @created_user,
kind: :redirect,
redirect_page:,
message:,
lines_count: 0,
tree_sha256: nil)
end
end
private
def normalise_body body
s = body.to_s
s.gsub!("\r\n", "\n")
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
end
def split_lines body
body.split("\n")
end
def upsert_lines! lines, line_shas
now = Time.current
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
missing_rows = []
line_shas.each_with_index do |sha, i|
next if id_by_sha.key?(sha)
missing_rows << { sha256: sha,
body: lines[i],
created_at: now,
updated_at: now }
end
if missing_rows.any?
WikiLine.upsert_all(missing_rows)
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
end
id_by_sha
end
end
end