Merge remote-tracking branch 'origin/main' into '#106'
このコミットが含まれているのは:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,16 +0,0 @@
|
||||
class NicoTagRelationController < ApplicationController
|
||||
def index
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
|
||||
def destroy
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
class PostTagsController < ApplicationController
|
||||
def index
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
|
||||
def destroy
|
||||
end
|
||||
end
|
||||
@@ -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, []) }
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
class SettingsController < ApplicationController
|
||||
def index
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
|
||||
def destroy
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
class TagAliasesController < ApplicationController
|
||||
def index
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
|
||||
def destroy
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
class UserIpsController < ApplicationController
|
||||
def index
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
|
||||
def destroy
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
class UserPostViewsController < ApplicationController
|
||||
def index
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
|
||||
def destroy
|
||||
end
|
||||
end
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +1,6 @@
|
||||
class UserIp < ApplicationRecord
|
||||
self.primary_key = :user_id, :ip_address_id
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :ip_address
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class UserPostView < ApplicationRecord
|
||||
self.primary_key = :user_id, :post_id
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :post
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
新しい課題から参照
ユーザをブロックする