コミットを比較
3 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| d184659d30 | |||
| 789e00b2e7 | |||
| 3f1c6c135b |
@@ -107,16 +107,11 @@ npm run preview
|
|||||||
- Prefer single quotes for strings unless interpolation or escaping makes
|
- Prefer single quotes for strings unless interpolation or escaping makes
|
||||||
double quotes better.
|
double quotes better.
|
||||||
- Ruby: never put a space before method-call parentheses.
|
- Ruby: never put a space before method-call parentheses.
|
||||||
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
|
|
||||||
- Ruby: never put a line break immediately before `)`.
|
- Ruby: never put a line break immediately before `)`.
|
||||||
- Ruby: do not use `%w` or `%i`.
|
- Ruby: do not use `%w` or `%i`.
|
||||||
- In Ruby, when an `if` condition is split across multiple lines and combines
|
|
||||||
clauses with `&&` or `||`, wrap the whole condition in parentheses.
|
|
||||||
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
|
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
|
||||||
- Ruby hashes keep the first pair on the same line as `{` unless line length
|
- Ruby hashes keep the first pair on the same line as `{` unless line length
|
||||||
requires a break.
|
requires a break.
|
||||||
- Short Ruby hashes may stay visually compact across two lines with the first
|
|
||||||
pair kept on the opening line and aligned continuation pairs below it.
|
|
||||||
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
|
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
|
||||||
indentation.
|
indentation.
|
||||||
- For arrays, never put whitespace or a line break immediately before `]`.
|
- For arrays, never put whitespace or a line break immediately before `]`.
|
||||||
|
|||||||
@@ -72,23 +72,17 @@ service, representation, and spec.
|
|||||||
- Prefer precise, minimal changes.
|
- Prefer precise, minimal changes.
|
||||||
- Use single quotes unless interpolation or escaping makes double quotes better.
|
- Use single quotes unless interpolation or escaping makes double quotes better.
|
||||||
- Do not put a space before Ruby method-call parentheses.
|
- Do not put a space before Ruby method-call parentheses.
|
||||||
- For `render`-family method calls, omit parentheses even when passing
|
|
||||||
keyword arguments.
|
|
||||||
- Never put a line break immediately before `)` in Ruby.
|
- Never put a line break immediately before `)` in Ruby.
|
||||||
- Do not use `%w` or `%i` in new Ruby code.
|
- Do not use `%w` or `%i` in new Ruby code.
|
||||||
- Never write a Ruby line longer than 99 characters.
|
- Never write a Ruby line longer than 99 characters.
|
||||||
- Aim to keep Ruby lines within 79 characters where practical.
|
- Aim to keep Ruby lines within 79 characters where practical.
|
||||||
- For small Ruby method definitions that take keyword arguments, match the
|
- For small Ruby method definitions that take keyword arguments, match the
|
||||||
local no-parentheses style when nearby code uses it.
|
local no-parentheses style when nearby code uses it.
|
||||||
- When an `if` condition is split across multiple lines and combines clauses
|
|
||||||
with `&&` or `||`, wrap the whole condition in parentheses.
|
|
||||||
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
|
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
|
||||||
rules.
|
rules.
|
||||||
- Do not format Ruby hashes like Ruby blocks.
|
- Do not format Ruby hashes like Ruby blocks.
|
||||||
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
|
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
|
||||||
- Keep the first pair on the same line as `{` by default.
|
- Keep the first pair on the same line as `{` by default.
|
||||||
- Short Ruby hashes may stay visually compact across two lines with the first
|
|
||||||
pair kept on the opening line and aligned continuation pairs below it.
|
|
||||||
- If the hash would exceed the line limit, break after `{` and indent pairs
|
- If the hash would exceed the line limit, break after `{` and indent pairs
|
||||||
by 4 spaces.
|
by 4 spaces.
|
||||||
- Put one logical pair per line when the expression would otherwise become
|
- Put one logical pair per line when the expression would otherwise become
|
||||||
|
|||||||
@@ -7,24 +7,13 @@ class GekanatorPostsController < ApplicationController
|
|||||||
.order(Arel.sql(
|
.order(Arel.sql(
|
||||||
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
||||||
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
|
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
|
||||||
.to_a
|
|
||||||
|
|
||||||
active_tags_by_post_id =
|
render json: { posts: posts.map { |post| post_json(post) } }
|
||||||
posts.each_with_object({ }) do |post, h|
|
|
||||||
h[post.id] = post.tags.reject(&:deprecated?)
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: {
|
|
||||||
posts: posts.map { |post|
|
|
||||||
post_json(post,
|
|
||||||
active_tags_by_post_id:)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def post_json post, active_tags_by_post_id:
|
def post_json post
|
||||||
{
|
{
|
||||||
id: post.id,
|
id: post.id,
|
||||||
url: post.url,
|
url: post.url,
|
||||||
@@ -33,26 +22,16 @@ class GekanatorPostsController < ApplicationController
|
|||||||
thumbnail_base: post.thumbnail_base,
|
thumbnail_base: post.thumbnail_base,
|
||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
post_similarity_edges: post_similarity_edges_json(
|
post_similarity_edges: post.post_similarities.map { |similarity|
|
||||||
post,
|
{
|
||||||
active_tags_by_post_id:),
|
target_post_id: similarity.target_post_id,
|
||||||
tags: active_tags_by_post_id.fetch(post.id, []).map { |tag| tag_json(tag) }
|
cos: similarity.cos.to_f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: post.tags.map { |tag| tag_json(tag) }
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_similarity_edges_json post, active_tags_by_post_id:
|
|
||||||
post
|
|
||||||
.post_similarities
|
|
||||||
.filter_map do |similarity|
|
|
||||||
next unless active_tags_by_post_id.key?(similarity.target_post_id)
|
|
||||||
|
|
||||||
{
|
|
||||||
target_post_id: similarity.target_post_id,
|
|
||||||
cos: similarity.cos.to_f
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def tag_json tag
|
def tag_json tag
|
||||||
{
|
{
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
|
|||||||
@@ -5,16 +5,9 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
.accepted
|
.accepted
|
||||||
.includes(:gekanator_question_examples)
|
.includes(:gekanator_question_examples)
|
||||||
.order(priority_weight: :desc, id: :asc)
|
.order(priority_weight: :desc, id: :asc)
|
||||||
.to_a
|
|
||||||
deprecated_tag_keys = deprecated_tag_keys_for(questions)
|
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
questions: questions.filter_map { |question|
|
questions: questions.map { |question| question_json(question) }
|
||||||
json = question_json(question)
|
|
||||||
next if hidden_question?(json[:condition], deprecated_tag_keys)
|
|
||||||
|
|
||||||
json
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -107,41 +100,4 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
.first
|
.first
|
||||||
&.first
|
&.first
|
||||||
end
|
end
|
||||||
|
|
||||||
def deprecated_tag_keys_for questions
|
|
||||||
tag_keys = questions.filter_map { |question|
|
|
||||||
condition = condition_json(question.condition)
|
|
||||||
next unless condition['type'] == 'tag'
|
|
||||||
|
|
||||||
condition['key'].to_s.presence
|
|
||||||
}.uniq
|
|
||||||
return {} if tag_keys.empty?
|
|
||||||
|
|
||||||
categories = []
|
|
||||||
names = []
|
|
||||||
tag_keys.each do |key|
|
|
||||||
category, name = parse_tag_key(key)
|
|
||||||
categories << category
|
|
||||||
names << name
|
|
||||||
end
|
|
||||||
|
|
||||||
Tag
|
|
||||||
.joins(:tag_name)
|
|
||||||
.where(category: categories.uniq)
|
|
||||||
.where(tag_names: { name: names.uniq })
|
|
||||||
.where.not(deprecated_at: nil)
|
|
||||||
.pluck('tags.category', 'tag_names.name')
|
|
||||||
.each_with_object({ }) do |(category, name), h|
|
|
||||||
h["#{ category }:#{ name }"] = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def hidden_question? condition, deprecated_tag_keys
|
|
||||||
condition[:type] == 'tag' && deprecated_tag_keys[condition[:key].to_s]
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_tag_key key
|
|
||||||
parts = key.to_s.split(':')
|
|
||||||
[parts.first.to_s, parts.drop(1).join(':')]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class PostVersionsController < ApplicationController
|
|||||||
SQL
|
SQL
|
||||||
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
|
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
|
||||||
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
|
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
|
||||||
'prev.video_ms AS prev_video_ms',
|
|
||||||
'prev.original_created_from AS prev_original_created_from',
|
'prev.original_created_from AS prev_original_created_from',
|
||||||
'prev.original_created_before AS prev_original_created_before')
|
'prev.original_created_before AS prev_original_created_before')
|
||||||
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||||
@@ -75,10 +74,6 @@ class PostVersionsController < ApplicationController
|
|||||||
current: row.thumbnail_base,
|
current: row.thumbnail_base,
|
||||||
prev: row.attributes['prev_thumbnail_base']
|
prev: row.attributes['prev_thumbnail_base']
|
||||||
},
|
},
|
||||||
video_ms: {
|
|
||||||
current: row.video_ms,
|
|
||||||
prev: row.attributes['prev_video_ms']
|
|
||||||
},
|
|
||||||
tags: build_version_tags(cur_tags, prev_tags),
|
tags: build_version_tags(cur_tags, prev_tags),
|
||||||
original_created_from: {
|
original_created_from: {
|
||||||
current: row.original_created_from&.iso8601,
|
current: row.original_created_from&.iso8601,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
class PostsController < ApplicationController
|
class PostsController < ApplicationController
|
||||||
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
|
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
|
||||||
|
|
||||||
class VideoMsParseError < ArgumentError
|
|
||||||
;
|
|
||||||
end
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
url = params[:url].presence
|
url = params[:url].presence
|
||||||
title = params[:title].presence
|
title = params[:title].presence
|
||||||
@@ -49,9 +45,7 @@ class PostsController < ApplicationController
|
|||||||
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
||||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||||
.preload(:uploaded_user, :parents, :children,
|
.preload(:uploaded_user, :parents, :children,
|
||||||
active_post_tags: [:sections,
|
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
||||||
{ tag: [:deerjikists, :materials,
|
|
||||||
{ tag_name: :wiki_page }] }])
|
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
|
|
||||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||||
@@ -103,9 +97,7 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
def random
|
def random
|
||||||
post = filtered_posts.preload(:uploaded_user, :parents, :children,
|
post = filtered_posts.preload(:uploaded_user, :parents, :children,
|
||||||
active_post_tags: [:sections,
|
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
||||||
{ tag: [:deerjikists, :materials,
|
|
||||||
{ tag_name: :wiki_page }] }])
|
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
.order('RAND()')
|
.order('RAND()')
|
||||||
.first
|
.first
|
||||||
@@ -118,9 +110,7 @@ class PostsController < ApplicationController
|
|||||||
post =
|
post =
|
||||||
Post
|
Post
|
||||||
.includes(:uploaded_user, :parents, :children,
|
.includes(:uploaded_user, :parents, :children,
|
||||||
active_post_tags: [:sections,
|
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
|
||||||
{ tag: [:deerjikists, :materials,
|
|
||||||
{ tag_name: :wiki_page }] }])
|
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless post
|
return head :not_found unless post
|
||||||
@@ -135,7 +125,7 @@ class PostsController < ApplicationController
|
|||||||
child_posts:,
|
child_posts:,
|
||||||
sibling_posts:,
|
sibling_posts:,
|
||||||
related:)
|
related:)
|
||||||
.merge(tags: build_tag_tree_for(post))
|
.merge(tags: build_tag_tree_for(post.tags))
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -158,15 +148,11 @@ class PostsController < ApplicationController
|
|||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
post.save!
|
post.save!
|
||||||
|
|
||||||
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
|
tags = Tag.normalise_tags!(tag_names)
|
||||||
{ tags:, sections: }
|
|
||||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
||||||
|
|
||||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
tags = Tag.expand_parent_tags(tags)
|
||||||
post.video_ms = normalise_video_ms(tags)
|
sync_post_tags!(post, tags)
|
||||||
validate_video_sections!(post.video_ms, sections)
|
|
||||||
post.save!
|
|
||||||
sync_post_tags!(post, tags, sections)
|
|
||||||
|
|
||||||
sync_parent_posts!(post, parent_post_ids)
|
sync_parent_posts!(post, parent_post_ids)
|
||||||
|
|
||||||
@@ -179,12 +165,6 @@ class PostsController < ApplicationController
|
|||||||
render json: PostRepr.base(post), status: :created
|
render json: PostRepr.base(post), status: :created
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
||||||
rescue Tag::DeprecatedTagNormalisationError
|
|
||||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
|
||||||
rescue Tag::SectionLiteralParseError
|
|
||||||
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
|
|
||||||
rescue VideoMsParseError
|
|
||||||
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
|
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
@@ -241,8 +221,6 @@ class PostsController < ApplicationController
|
|||||||
original_created_from:,
|
original_created_from:,
|
||||||
original_created_before:,
|
original_created_before:,
|
||||||
tag_names:,
|
tag_names:,
|
||||||
video_ms_param: params[:video_ms],
|
|
||||||
duration_param: params[:duration],
|
|
||||||
parent_post_ids:)
|
parent_post_ids:)
|
||||||
|
|
||||||
snapshot_to_apply =
|
snapshot_to_apply =
|
||||||
@@ -273,16 +251,10 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
post.reload
|
post.reload
|
||||||
json = PostRepr.base(post, current_user)
|
json = PostRepr.base(post, current_user)
|
||||||
json['tags'] = build_tag_tree_for(post)
|
json['tags'] = build_tag_tree_for(post.tags)
|
||||||
render json:, status: :ok
|
render json:, status: :ok
|
||||||
rescue Tag::NicoTagNormalisationError
|
rescue Tag::NicoTagNormalisationError
|
||||||
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
|
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
|
||||||
rescue Tag::DeprecatedTagNormalisationError
|
|
||||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
|
||||||
rescue Tag::SectionLiteralParseError
|
|
||||||
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
|
|
||||||
rescue VideoMsParseError
|
|
||||||
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
|
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
@@ -381,7 +353,7 @@ class PostsController < ApplicationController
|
|||||||
def tagged_post_ids_for(name) =
|
def tagged_post_ids_for(name) =
|
||||||
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
|
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
|
||||||
|
|
||||||
def sync_post_tags! post, desired_tags, sections
|
def sync_post_tags! post, desired_tags
|
||||||
desired_tags.each do |t|
|
desired_tags.each do |t|
|
||||||
t.save! if t.new_record?
|
t.save! if t.new_record?
|
||||||
end
|
end
|
||||||
@@ -400,21 +372,13 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
PostTagSection.where(post_id: post.id).destroy_all
|
|
||||||
sections.each do |tag_id, ranges|
|
|
||||||
ranges.each do |begin_ms, end_ms|
|
|
||||||
PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
|
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
|
||||||
pt.discard_by!(current_user)
|
pt.discard_by!(current_user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_tag_tree_for post
|
def build_tag_tree_for tags
|
||||||
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
|
tags = tags.to_a
|
||||||
tags = post_tags.map(&:tag)
|
|
||||||
tag_ids = tags.map(&:id)
|
tag_ids = tags.map(&:id)
|
||||||
|
|
||||||
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
|
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
|
||||||
@@ -429,9 +393,6 @@ class PostsController < ApplicationController
|
|||||||
root_ids = tag_ids - child_ids
|
root_ids = tag_ids - child_ids
|
||||||
|
|
||||||
tags_by_id = tags.index_by(&:id)
|
tags_by_id = tags.index_by(&:id)
|
||||||
sections_by_tag_id = post_tags.to_h { |post_tag|
|
|
||||||
[post_tag.tag_id, post_tag.sections.as_json(only: [:begin_ms, :end_ms])]
|
|
||||||
}
|
|
||||||
|
|
||||||
memo = { }
|
memo = { }
|
||||||
|
|
||||||
@@ -439,10 +400,8 @@ class PostsController < ApplicationController
|
|||||||
tag = tags_by_id[tag_id]
|
tag = tags_by_id[tag_id]
|
||||||
return nil unless tag
|
return nil unless tag
|
||||||
|
|
||||||
sections = sections_by_tag_id.fetch(tag_id, [])
|
|
||||||
|
|
||||||
if path.include?(tag_id)
|
if path.include?(tag_id)
|
||||||
return TagRepr.inline(tag).merge(children: [], sections:)
|
return TagRepr.inline(tag).merge(children: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
if memo.key?(tag_id)
|
if memo.key?(tag_id)
|
||||||
@@ -454,7 +413,7 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
|
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
|
||||||
|
|
||||||
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
|
memo[tag_id] = TagRepr.inline(tag).merge(children:)
|
||||||
end
|
end
|
||||||
|
|
||||||
root_ids.filter_map { |id| build_node.call(id, []) }
|
root_ids.filter_map { |id| build_node.call(id, []) }
|
||||||
@@ -479,7 +438,7 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
params[:parent_post_ids].to_s.split.map { |token|
|
params[:parent_post_ids].to_s.split.map { |token|
|
||||||
id = Integer(token, exception: false)
|
id = Integer(token, exception: false)
|
||||||
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if !(id) || id <= 0
|
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
|
||||||
|
|
||||||
id
|
id
|
||||||
}.uniq
|
}.uniq
|
||||||
@@ -523,7 +482,6 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
def post_snapshot_from_version version
|
def post_snapshot_from_version version
|
||||||
{ title: version.title,
|
{ title: version.title,
|
||||||
video_ms: version.respond_to?(:video_ms) ? version.video_ms : nil,
|
|
||||||
original_created_from: snapshot_time(version.original_created_from),
|
original_created_from: snapshot_time(version.original_created_from),
|
||||||
original_created_before: snapshot_time(version.original_created_before),
|
original_created_before: snapshot_time(version.original_created_before),
|
||||||
tag_names: editable_tag_names_from_version(version),
|
tag_names: editable_tag_names_from_version(version),
|
||||||
@@ -536,7 +494,6 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
def post_snapshot_from_record post
|
def post_snapshot_from_record post
|
||||||
{ title: post.title,
|
{ title: post.title,
|
||||||
video_ms: post.video_ms,
|
|
||||||
original_created_from: snapshot_time(post.original_created_from),
|
original_created_from: snapshot_time(post.original_created_from),
|
||||||
original_created_before: snapshot_time(post.original_created_before),
|
original_created_before: snapshot_time(post.original_created_before),
|
||||||
tag_names: editable_tag_names_from_post(post),
|
tag_names: editable_tag_names_from_post(post),
|
||||||
@@ -544,41 +501,15 @@ class PostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def editable_tag_names_from_post post
|
def editable_tag_names_from_post post
|
||||||
post
|
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||||
.post_tags
|
|
||||||
.kept
|
|
||||||
.joins(tag: :tag_name)
|
|
||||||
.merge(Tag.not_nico)
|
|
||||||
.merge(Tag.where(deprecated_at: nil))
|
|
||||||
.includes(:sections, tag: :tag_name)
|
|
||||||
.order('tag_names.name')
|
|
||||||
.map do |post_tag|
|
|
||||||
name = post_tag.tag.tag_name.name
|
|
||||||
sections = post_tag.sections.sort_by(&:begin_ms)
|
|
||||||
|
|
||||||
next name if sections.empty?
|
|
||||||
|
|
||||||
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
||||||
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
|
tag_names:, parent_post_ids:
|
||||||
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
|
|
||||||
with_sections: true) =>
|
|
||||||
{ tags:, sections: }
|
|
||||||
|
|
||||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
|
||||||
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
|
|
||||||
validate_video_sections!(video_ms, sections)
|
|
||||||
|
|
||||||
{ title:,
|
{ title:,
|
||||||
video_ms:,
|
|
||||||
original_created_from: snapshot_time(original_created_from),
|
original_created_from: snapshot_time(original_created_from),
|
||||||
original_created_before: snapshot_time(original_created_before),
|
original_created_before: snapshot_time(original_created_before),
|
||||||
tag_names: tags.uniq(&:id).map { |tag|
|
tag_names: incoming_tag_names_for_snapshot(tag_names),
|
||||||
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
|
|
||||||
}.sort,
|
|
||||||
parent_post_ids: parent_post_ids.sort }
|
parent_post_ids: parent_post_ids.sort }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -601,8 +532,10 @@ class PostsController < ApplicationController
|
|||||||
value.to_s
|
value.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def section_literal section
|
def incoming_tag_names_for_snapshot raw_tag_names
|
||||||
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
|
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
|
||||||
|
|
||||||
|
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_conflict_json post:, base_version_no:, base_snapshot:,
|
def post_conflict_json post:, base_version_no:, base_snapshot:,
|
||||||
@@ -623,8 +556,6 @@ class PostsController < ApplicationController
|
|||||||
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
|
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
|
||||||
[scalar_snapshot_change(:title, 'タイトル',
|
[scalar_snapshot_change(:title, 'タイトル',
|
||||||
base_snapshot, current_snapshot, incoming_snapshot),
|
base_snapshot, current_snapshot, incoming_snapshot),
|
||||||
scalar_snapshot_change(:video_ms, '動画時間',
|
|
||||||
base_snapshot, current_snapshot, incoming_snapshot),
|
|
||||||
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
|
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
|
||||||
base_snapshot, current_snapshot, incoming_snapshot),
|
base_snapshot, current_snapshot, incoming_snapshot),
|
||||||
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
|
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
|
||||||
@@ -688,32 +619,25 @@ class PostsController < ApplicationController
|
|||||||
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
|
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
|
||||||
|
|
||||||
post.update!(title: snapshot[:title],
|
post.update!(title: snapshot[:title],
|
||||||
video_ms: snapshot[:video_ms],
|
|
||||||
original_created_from: snapshot[:original_created_from],
|
original_created_from: snapshot[:original_created_from],
|
||||||
original_created_before: snapshot[:original_created_before])
|
original_created_before: snapshot[:original_created_before])
|
||||||
|
|
||||||
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
|
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
|
||||||
deny_deprecated: true,
|
|
||||||
with_sections: true) =>
|
|
||||||
{ tags: editable_tags, sections: }
|
|
||||||
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
||||||
|
|
||||||
readonly_tags = post.tags.nico.to_a
|
readonly_tags = post.tags.nico.to_a
|
||||||
|
|
||||||
tags = readonly_tags + editable_tags
|
tags = readonly_tags + editable_tags
|
||||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
tags = Tag.expand_parent_tags(tags)
|
||||||
|
|
||||||
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
|
sync_post_tags!(post, tags)
|
||||||
validate_video_sections!(post.video_ms, sections)
|
|
||||||
post.save!
|
|
||||||
sync_post_tags!(post, tags, sections)
|
|
||||||
sync_parent_posts!(post, snapshot[:parent_post_ids])
|
sync_parent_posts!(post, snapshot[:parent_post_ids])
|
||||||
|
|
||||||
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
|
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
|
||||||
[:title, :video_ms, :original_created_from, :original_created_before].map {
|
[:title, :original_created_from, :original_created_before].map {
|
||||||
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
|
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
|
||||||
current_snapshot[_1],
|
current_snapshot[_1],
|
||||||
incoming_snapshot[_1])]
|
incoming_snapshot[_1])]
|
||||||
@@ -757,44 +681,4 @@ class PostsController < ApplicationController
|
|||||||
render_validation_error record
|
render_validation_error record
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalise_video_ms tags, video_ms_param: params[:video_ms], duration_param: params[:duration]
|
|
||||||
return nil unless tags.any? { _1.id == Tag.video.id }
|
|
||||||
|
|
||||||
if video_ms_param.present?
|
|
||||||
video_ms = Integer(video_ms_param, exception: false)
|
|
||||||
raise VideoMsParseError unless video_ms&.positive?
|
|
||||||
|
|
||||||
return video_ms
|
|
||||||
end
|
|
||||||
|
|
||||||
return nil if duration_param.blank?
|
|
||||||
|
|
||||||
video_ms = Tag.time_to_ms!(duration_param.to_s, tag_name: '動画時間')
|
|
||||||
raise VideoMsParseError unless video_ms.positive?
|
|
||||||
|
|
||||||
video_ms
|
|
||||||
rescue Tag::SectionLiteralParseError
|
|
||||||
raise VideoMsParseError
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_video_sections! video_ms, sections
|
|
||||||
return unless video_ms
|
|
||||||
|
|
||||||
sections.each_value do |ranges|
|
|
||||||
ranges.each do |begin_ms, end_ms|
|
|
||||||
if begin_ms >= video_ms
|
|
||||||
post = Post.new
|
|
||||||
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
|
|
||||||
raise ActiveRecord::RecordInvalid, post
|
|
||||||
end
|
|
||||||
|
|
||||||
if end_ms && end_ms > video_ms
|
|
||||||
post = Post.new
|
|
||||||
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
|
|
||||||
raise ActiveRecord::RecordInvalid, post
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class TagVersionsController < ApplicationController
|
|||||||
AND prev.version_no = tag_versions.version_no - 1
|
AND prev.version_no = tag_versions.version_no - 1
|
||||||
SQL
|
SQL
|
||||||
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
||||||
'prev.deprecated_at AS prev_deprecated_at',
|
|
||||||
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
|
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
|
||||||
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||||
|
|
||||||
@@ -63,8 +62,6 @@ class TagVersionsController < ApplicationController
|
|||||||
event_type: row.event_type,
|
event_type: row.event_type,
|
||||||
name: { current: row.name, prev: row.attributes['prev_name'] },
|
name: { current: row.name, prev: row.attributes['prev_name'] },
|
||||||
category: { current: row.category, prev: row.attributes['prev_category'] },
|
category: { current: row.category, prev: row.attributes['prev_category'] },
|
||||||
deprecated_at: { current: row.deprecated_at&.iso8601,
|
|
||||||
prev: row.attributes['prev_deprecated_at']&.iso8601 },
|
|
||||||
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
|
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
|
||||||
parent_tags:,
|
parent_tags:,
|
||||||
created_at: row.created_at.iso8601,
|
created_at: row.created_at.iso8601,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
require 'net/http'
|
require 'net/http'
|
||||||
require 'uri'
|
require 'uri'
|
||||||
require 'set'
|
|
||||||
|
|
||||||
|
|
||||||
class TagsController < ApplicationController
|
class TagsController < ApplicationController
|
||||||
@@ -15,8 +14,6 @@ class TagsController < ApplicationController
|
|||||||
post_count_between[1] = nil if post_count_between[1] < 0
|
post_count_between[1] = nil if post_count_between[1] < 0
|
||||||
created_between = params[:created_from].presence, params[:created_to].presence
|
created_between = params[:created_from].presence, params[:created_to].presence
|
||||||
updated_between = params[:updated_from].presence, params[:updated_to].presence
|
updated_between = params[:updated_from].presence, params[:updated_to].presence
|
||||||
deprecated_given = params.key?(:deprecated)
|
|
||||||
deprecated = bool?(:deprecated)
|
|
||||||
|
|
||||||
order = params[:order].to_s.split(':', 2).map(&:strip)
|
order = params[:order].to_s.split(':', 2).map(&:strip)
|
||||||
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
|
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
|
||||||
@@ -51,9 +48,6 @@ class TagsController < ApplicationController
|
|||||||
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
|
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
|
||||||
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
|
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
|
||||||
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
|
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
|
||||||
if deprecated_given
|
|
||||||
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
sort_sql =
|
sort_sql =
|
||||||
case order[0]
|
case order[0]
|
||||||
@@ -83,27 +77,37 @@ class TagsController < ApplicationController
|
|||||||
parent_tag_id = params[:parent].to_i
|
parent_tag_id = params[:parent].to_i
|
||||||
parent_tag_id = nil if parent_tag_id <= 0
|
parent_tag_id = nil if parent_tag_id <= 0
|
||||||
|
|
||||||
graph = build_with_depth_graph
|
|
||||||
|
|
||||||
tag_ids =
|
tag_ids =
|
||||||
if parent_tag_id
|
if parent_tag_id
|
||||||
visible_child_tag_ids(parent_tag_id, graph)
|
TagImplication.where(parent_tag_id:).select(:tag_id)
|
||||||
else
|
else
|
||||||
visible_root_tag_ids(graph)
|
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
|
||||||
end
|
end
|
||||||
|
|
||||||
tags =
|
tags =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_name)
|
.joins(:tag_name)
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
|
.where(category: [:meme, :character, :material])
|
||||||
.where(id: tag_ids)
|
.where(id: tag_ids)
|
||||||
.order('tag_names.name')
|
.order('tag_names.name')
|
||||||
.distinct
|
.distinct
|
||||||
.to_a
|
.to_a
|
||||||
|
|
||||||
|
has_children_tag_ids =
|
||||||
|
if tags.empty?
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
TagImplication
|
||||||
|
.joins(:tag)
|
||||||
|
.where(parent_tag_id: tags.map(&:id),
|
||||||
|
tags: { category: [:meme, :character, :material] })
|
||||||
|
.distinct
|
||||||
|
.pluck(:parent_tag_id)
|
||||||
|
end
|
||||||
|
|
||||||
render json: tags.map { |tag|
|
render json: tags.map { |tag|
|
||||||
TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?,
|
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
|
||||||
children: [])
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -129,7 +133,6 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
base = Tag.joins(:tag_name)
|
base = Tag.joins(:tag_name)
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
.where(deprecated_at: nil)
|
|
||||||
base = base.where('tags.post_count > 0') if present_only
|
base = base.where('tags.post_count > 0') if present_only
|
||||||
|
|
||||||
canonical_hit =
|
canonical_hit =
|
||||||
@@ -249,24 +252,18 @@ class TagsController < ApplicationController
|
|||||||
category = params[:category].to_s.strip
|
category = params[:category].to_s.strip
|
||||||
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
|
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
|
||||||
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
||||||
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
|
|
||||||
|
|
||||||
if (name != tag.name &&
|
if name != tag.name &&
|
||||||
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]))
|
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
|
||||||
return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
|
return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name)
|
||||||
|
end
|
||||||
|
|
||||||
|
if tag.nico? || category == 'nico'
|
||||||
|
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
|
||||||
end
|
end
|
||||||
|
|
||||||
alias_names = params[:aliases].to_s.split.uniq
|
alias_names = params[:aliases].to_s.split.uniq
|
||||||
parent_names = params[:parent_tags].to_s.split.uniq
|
parent_names = params[:parent_tags].to_s.split.uniq
|
||||||
deprecated = bool?(:deprecated)
|
|
||||||
|
|
||||||
if tag.nico? && deprecated
|
|
||||||
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
|
||||||
end
|
|
||||||
|
|
||||||
if tag.nico? || category == 'nico'
|
|
||||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
|
||||||
end
|
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||||
@@ -275,11 +272,7 @@ class TagsController < ApplicationController
|
|||||||
name_changed = name != old_name
|
name_changed = name != old_name
|
||||||
wiki_page = tag.tag_name.wiki_page if name_changed
|
wiki_page = tag.tag_name.wiki_page if name_changed
|
||||||
|
|
||||||
if tag.deprecated? == deprecated
|
tag.update!(category:)
|
||||||
tag.update!(category:)
|
|
||||||
else
|
|
||||||
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
|
|
||||||
end
|
|
||||||
tag.tag_name.update!(name:)
|
tag.tag_name.update!(name:)
|
||||||
|
|
||||||
alias_names << old_name if name_changed
|
alias_names << old_name if name_changed
|
||||||
@@ -307,17 +300,11 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
name = params[:name].presence
|
name = params[:name].presence
|
||||||
category = params[:category].presence
|
category = params[:category].presence
|
||||||
deprecated_given = params.key?(:deprecated)
|
|
||||||
deprecated = bool?(:deprecated)
|
|
||||||
|
|
||||||
tag = Tag.find(params[:id])
|
tag = Tag.find(params[:id])
|
||||||
|
|
||||||
if tag.nico? && deprecated_given && deprecated
|
|
||||||
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
|
||||||
end
|
|
||||||
|
|
||||||
if tag.nico? || (category.present? && category == 'nico')
|
if tag.nico? || (category.present? && category == 'nico')
|
||||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
|
||||||
end
|
end
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
@@ -329,9 +316,6 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
tag.tag_name.update!(name:) if name.present?
|
tag.tag_name.update!(name:) if name.present?
|
||||||
tag.update!(category:) if category.present?
|
tag.update!(category:) if category.present?
|
||||||
if deprecated_given && tag.deprecated? != deprecated
|
|
||||||
tag.update!(deprecated_at: deprecated ? Time.current : nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
tag.reload
|
tag.reload
|
||||||
|
|
||||||
@@ -348,93 +332,6 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def build_with_depth_graph
|
|
||||||
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
|
|
||||||
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
|
|
||||||
|
|
||||||
TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id|
|
|
||||||
children_by_parent_id[parent_id] << child_id
|
|
||||||
parent_ids_by_child_id[child_id] << parent_id
|
|
||||||
end
|
|
||||||
|
|
||||||
tag_ids = (children_by_parent_id.keys +
|
|
||||||
parent_ids_by_child_id.keys +
|
|
||||||
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
|
|
||||||
|
|
||||||
tags_by_id = Tag.where(id: tag_ids)
|
|
||||||
.pluck(:id, :category, :deprecated_at)
|
|
||||||
.each_with_object({ }) do |(id, category, deprecated_at), h|
|
|
||||||
h[id] = { category:, deprecated: deprecated_at.present? }
|
|
||||||
end
|
|
||||||
|
|
||||||
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
|
|
||||||
visible_child_tag_ids_by_parent_id: { } }
|
|
||||||
end
|
|
||||||
|
|
||||||
def visible_root_tag_ids graph
|
|
||||||
graph[:tags_by_id].filter_map do |tag_id, attrs|
|
|
||||||
next unless with_depth_visible_tag?(attrs)
|
|
||||||
next unless visible_root_tag?(tag_id, graph)
|
|
||||||
|
|
||||||
tag_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def visible_root_tag? tag_id, graph
|
|
||||||
seen = Set.new([tag_id])
|
|
||||||
stack = graph[:parent_ids_by_child_id][tag_id].dup
|
|
||||||
|
|
||||||
until stack.empty?
|
|
||||||
parent_id = stack.pop
|
|
||||||
next if seen.include?(parent_id)
|
|
||||||
|
|
||||||
seen << parent_id
|
|
||||||
|
|
||||||
parent = graph[:tags_by_id][parent_id]
|
|
||||||
next unless parent
|
|
||||||
|
|
||||||
return false unless parent[:deprecated]
|
|
||||||
|
|
||||||
stack.concat(graph[:parent_ids_by_child_id][parent_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def visible_child_tag_ids parent_tag_id, graph
|
|
||||||
cache = graph[:visible_child_tag_ids_by_parent_id]
|
|
||||||
return cache[parent_tag_id] if cache.key?(parent_tag_id)
|
|
||||||
|
|
||||||
visible_ids = Set.new
|
|
||||||
|
|
||||||
graph[:children_by_parent_id][parent_tag_id].each do |child_tag_id|
|
|
||||||
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, Set.new([parent_tag_id]))
|
|
||||||
end
|
|
||||||
|
|
||||||
cache[parent_tag_id] = visible_ids.to_a
|
|
||||||
end
|
|
||||||
|
|
||||||
def collect_visible_child_tag_ids tag_id, graph, visible_ids, seen
|
|
||||||
return if seen.include?(tag_id)
|
|
||||||
|
|
||||||
seen = seen.dup << tag_id
|
|
||||||
tag = graph[:tags_by_id][tag_id]
|
|
||||||
return unless tag
|
|
||||||
|
|
||||||
if tag[:deprecated]
|
|
||||||
graph[:children_by_parent_id][tag_id].each do |child_tag_id|
|
|
||||||
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, seen)
|
|
||||||
end
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
visible_ids << tag_id if with_depth_visible_tag?(tag)
|
|
||||||
end
|
|
||||||
|
|
||||||
def with_depth_visible_tag? tag
|
|
||||||
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_tag_children tag
|
def build_tag_children tag
|
||||||
material = tag.materials.first
|
material = tag.materials.first
|
||||||
file = nil
|
file = nil
|
||||||
|
|||||||
@@ -4,18 +4,17 @@ class WikiPagesController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
if title.blank?
|
if title.blank?
|
||||||
return render json: WikiPageRepr.base(
|
return render json: WikiPageRepr.base(WikiPage.joins(:tag_name).includes(:tag_name))
|
||||||
WikiPage.joins(:tag_name).includes(tag_name: :tag))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
q = WikiPage.joins(:tag_name).includes(tag_name: :tag)
|
q = WikiPage.joins(:tag_name).includes(:tag_name)
|
||||||
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
|
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
|
||||||
render json: WikiPageRepr.base(q.limit(20))
|
render json: WikiPageRepr.base(q.limit(20))
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
page = WikiPage.joins(:tag_name)
|
page = WikiPage.joins(:tag_name)
|
||||||
.includes(tag_name: :tag)
|
.includes(:tag_name)
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
render_wiki_page_or_404 page
|
render_wiki_page_or_404 page
|
||||||
end
|
end
|
||||||
@@ -23,7 +22,7 @@ class WikiPagesController < ApplicationController
|
|||||||
def show_by_title
|
def show_by_title
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
page = WikiPage.joins(:tag_name)
|
page = WikiPage.joins(:tag_name)
|
||||||
.includes(tag_name: :tag)
|
.includes(:tag_name)
|
||||||
.find_by(tag_name: { name: title })
|
.find_by(tag_name: { name: title })
|
||||||
render_wiki_page_or_404 page
|
render_wiki_page_or_404 page
|
||||||
end
|
end
|
||||||
@@ -52,7 +51,7 @@ class WikiPagesController < ApplicationController
|
|||||||
from = params[:from].presence
|
from = params[:from].presence
|
||||||
to = params[:to].presence
|
to = params[:to].presence
|
||||||
|
|
||||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
page = WikiPage.joins(:tag_name).includes(:tag_name).find(id)
|
||||||
|
|
||||||
from_rev = from && page.wiki_revisions.find(from)
|
from_rev = from && page.wiki_revisions.find(from)
|
||||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||||
@@ -77,7 +76,6 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
render json: { wiki_page_id: page.id,
|
render json: { wiki_page_id: page.id,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
deprecated_at: page.deprecated_at,
|
|
||||||
older_revision_id: from_rev&.id,
|
older_revision_id: from_rev&.id,
|
||||||
newer_revision_id: to_rev.id,
|
newer_revision_id: to_rev.id,
|
||||||
diff: diff_json }
|
diff: diff_json }
|
||||||
@@ -159,7 +157,7 @@ class WikiPagesController < ApplicationController
|
|||||||
def changes
|
def changes
|
||||||
id = params[:id].presence
|
id = params[:id].presence
|
||||||
q = WikiRevision.joins(wiki_page: :tag_name)
|
q = WikiRevision.joins(wiki_page: :tag_name)
|
||||||
.includes(:created_user, wiki_page: { tag_name: :tag })
|
.includes(:created_user, wiki_page: :tag_name)
|
||||||
.order(id: :desc)
|
.order(id: :desc)
|
||||||
q = q.where(wiki_page_id: id) if id
|
q = q.where(wiki_page_id: id) if id
|
||||||
|
|
||||||
@@ -167,9 +165,7 @@ class WikiPagesController < ApplicationController
|
|||||||
{ revision_id: rev.id,
|
{ revision_id: rev.id,
|
||||||
pred: rev.base_revision_id,
|
pred: rev.base_revision_id,
|
||||||
succ: nil,
|
succ: nil,
|
||||||
wiki_page: { id: rev.wiki_page_id,
|
wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title },
|
||||||
title: rev.wiki_page.title,
|
|
||||||
deprecated_at: rev.wiki_page.deprecated_at },
|
|
||||||
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
||||||
kind: rev.kind,
|
kind: rev.kind,
|
||||||
message: rev.message,
|
message: rev.message,
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ class Post < ApplicationRecord
|
|||||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
||||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||||
has_many :tags, through: :active_post_tags
|
has_many :tags, through: :active_post_tags
|
||||||
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
|
||||||
through: :active_post_tags, source: :tag
|
|
||||||
|
|
||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
@@ -46,7 +44,6 @@ class Post < ApplicationRecord
|
|||||||
before_validation :normalise_url
|
before_validation :normalise_url
|
||||||
|
|
||||||
validates :url, presence: true, uniqueness: true
|
validates :url, presence: true, uniqueness: true
|
||||||
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
|
||||||
|
|
||||||
validate :validate_original_created_range
|
validate :validate_original_created_range
|
||||||
validate :url_must_be_http_url
|
validate :url_must_be_http_url
|
||||||
@@ -70,42 +67,7 @@ class Post < ApplicationRecord
|
|||||||
super(options).merge(thumbnail: nil)
|
super(options).merge(thumbnail: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_tag_names
|
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||||
post_tags
|
|
||||||
.kept
|
|
||||||
.joins(tag: :tag_name)
|
|
||||||
.includes(:sections, tag: :tag_name)
|
|
||||||
.order('tag_names.name')
|
|
||||||
.map do |post_tag|
|
|
||||||
name = post_tag.tag.tag_name.name
|
|
||||||
sections = post_tag.sections.sort_by(&:begin_ms)
|
|
||||||
|
|
||||||
next name if sections.empty?
|
|
||||||
|
|
||||||
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.section_literal section
|
|
||||||
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.ms_to_time ms
|
|
||||||
total_s = ms / 1_000
|
|
||||||
s = total_s % 60
|
|
||||||
min = (total_s / 60) % 60
|
|
||||||
h = total_s / 3_600
|
|
||||||
remainder_ms = ms % 1_000
|
|
||||||
|
|
||||||
base =
|
|
||||||
if h.positive?
|
|
||||||
'%d:%02d:%02d' % [h, min, s]
|
|
||||||
else
|
|
||||||
'%d:%02d' % [min, s]
|
|
||||||
end
|
|
||||||
|
|
||||||
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
|
|
||||||
end
|
|
||||||
|
|
||||||
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
|
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ class PostTag < ApplicationRecord
|
|||||||
belongs_to :created_user, class_name: 'User', optional: true
|
belongs_to :created_user, class_name: 'User', optional: true
|
||||||
belongs_to :deleted_user, class_name: 'User', optional: true
|
belongs_to :deleted_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
|
|
||||||
foreign_key: [:post_id, :tag_id],
|
|
||||||
primary_key: [:post_id, :tag_id],
|
|
||||||
dependent: :delete_all,
|
|
||||||
inverse_of: :post_tag
|
|
||||||
|
|
||||||
validates :post_id, presence: true
|
validates :post_id, presence: true
|
||||||
validates :tag_id, presence: true
|
validates :tag_id, presence: true
|
||||||
validates :post_id, uniqueness: {
|
validates :post_id, uniqueness: {
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
class PostTagSection < ApplicationRecord
|
|
||||||
self.primary_key = :post_id, :tag_id, :begin_ms
|
|
||||||
|
|
||||||
belongs_to :post
|
|
||||||
belongs_to :tag
|
|
||||||
|
|
||||||
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
|
|
||||||
primary_key: [:post_id, :tag_id],
|
|
||||||
inverse_of: :sections,
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
validates :post_id, presence: true
|
|
||||||
validates :tag_id, presence: true
|
|
||||||
|
|
||||||
validates :begin_ms, presence: true,
|
|
||||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
||||||
|
|
||||||
validates :end_ms, numericality: { only_integer: true, greater_than: :begin_ms },
|
|
||||||
allow_nil: true
|
|
||||||
end
|
|
||||||
@@ -5,7 +5,6 @@ class PostVersion < ApplicationRecord
|
|||||||
belongs_to :parent, class_name: 'Post', optional: true
|
belongs_to :parent, class_name: 'Post', optional: true
|
||||||
|
|
||||||
validates :url, presence: true
|
validates :url, presence: true
|
||||||
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
|
||||||
|
|
||||||
validate :validate_original_created_range
|
validate :validate_original_created_range
|
||||||
|
|
||||||
|
|||||||
+3
-135
@@ -8,25 +8,6 @@ class Tag < ApplicationRecord
|
|||||||
;
|
;
|
||||||
end
|
end
|
||||||
|
|
||||||
class DeprecatedTagNormalisationError < ArgumentError
|
|
||||||
attr_reader :tag_names
|
|
||||||
|
|
||||||
def initialize tag_names
|
|
||||||
@tag_names = Array(tag_names)
|
|
||||||
super('deprecated tags are not allowed')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class SectionLiteralParseError < ArgumentError
|
|
||||||
attr_reader :tag_name, :literal
|
|
||||||
|
|
||||||
def initialize tag_name, literal
|
|
||||||
@tag_name = tag_name
|
|
||||||
@literal = literal
|
|
||||||
super("invalid section literal for tag #{ tag_name }: #{ literal }")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
has_many :post_tags, inverse_of: :tag
|
has_many :post_tags, inverse_of: :tag
|
||||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', 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 :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||||
@@ -77,7 +58,6 @@ class Tag < ApplicationRecord
|
|||||||
validate :nico_tag_name_must_start_with_nico
|
validate :nico_tag_name_must_start_with_nico
|
||||||
validate :tag_name_must_be_canonical
|
validate :tag_name_must_be_canonical
|
||||||
validate :category_must_be_deerjikist_with_deerjikists
|
validate :category_must_be_deerjikist_with_deerjikists
|
||||||
validate :nico_tags_cannot_be_deprecated
|
|
||||||
|
|
||||||
scope :nico_tags, -> { nico }
|
scope :nico_tags, -> { nico }
|
||||||
|
|
||||||
@@ -97,8 +77,6 @@ class Tag < ApplicationRecord
|
|||||||
(self.tag_name ||= build_tag_name).name = val
|
(self.tag_name ||= build_tag_name).name = val
|
||||||
end
|
end
|
||||||
|
|
||||||
def deprecated? = deprecated_at?
|
|
||||||
|
|
||||||
def has_wiki = wiki_page.present?
|
def has_wiki = wiki_page.present?
|
||||||
|
|
||||||
def material_id = materials.first&.id
|
def material_id = materials.first&.id
|
||||||
@@ -114,61 +92,22 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
def self.normalise_tags! tag_names, with_tagme: true,
|
def self.normalise_tags! tag_names, with_tagme: true,
|
||||||
with_no_deerjikist: true,
|
with_no_deerjikist: true,
|
||||||
deny_nico: true,
|
deny_nico: true
|
||||||
deny_deprecated: false,
|
|
||||||
with_sections: false
|
|
||||||
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||||
raise NicoTagNormalisationError
|
raise NicoTagNormalisationError
|
||||||
end
|
end
|
||||||
|
|
||||||
sections = { }
|
|
||||||
tags = tag_names.map do |name|
|
tags = tag_names.map do |name|
|
||||||
raw_name = name
|
|
||||||
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
|
||||||
|
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
|
||||||
name = name.sub(/\A#{ pf }/i, '')
|
|
||||||
|
|
||||||
sections_by_tag = []
|
|
||||||
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
|
|
||||||
name = "#{ match[1] }#{ match[4] }"
|
|
||||||
next if match[2].empty? && match[3].empty?
|
|
||||||
|
|
||||||
sections_by_tag << normalise_section_range!(
|
|
||||||
begin_raw: match[2],
|
|
||||||
end_raw: match[3],
|
|
||||||
tag_name: name)
|
|
||||||
end
|
|
||||||
|
|
||||||
if name.include?('[') || name.include?(']')
|
|
||||||
raise SectionLiteralParseError.new(raw_name, raw_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
name = TagName.canonicalise(name).first
|
|
||||||
|
|
||||||
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
|
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
|
||||||
if deny_deprecated && tag.deprecated?
|
|
||||||
raise DeprecatedTagNormalisationError, [tag.name]
|
|
||||||
end
|
|
||||||
|
|
||||||
tag.update!(category: cat) if cat && tag.category != cat
|
tag.update!(category: cat) if cat && tag.category != cat
|
||||||
next if sections_by_tag.empty?
|
|
||||||
|
|
||||||
sections[tag.id] ||= []
|
|
||||||
sections[tag.id].concat(sections_by_tag)
|
|
||||||
sections[tag.id] = merge_section_ranges(sections[tag.id])
|
|
||||||
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
|
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
|
||||||
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
|
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
|
||||||
tags.uniq!(&:id)
|
tags.uniq(&:id)
|
||||||
|
|
||||||
if with_sections
|
|
||||||
{ tags:, sections: }
|
|
||||||
else
|
|
||||||
tags
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.expand_parent_tags tags
|
def self.expand_parent_tags tags
|
||||||
@@ -194,45 +133,6 @@ class Tag < ApplicationRecord
|
|||||||
(result + tags).uniq { |t| t.id }
|
(result + tags).uniq { |t| t.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.normalise_section_range! begin_raw:, end_raw:, tag_name:
|
|
||||||
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
|
|
||||||
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
|
|
||||||
|
|
||||||
if end_ms
|
|
||||||
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
|
|
||||||
end_ms = begin_ms + 1 if begin_ms == end_ms
|
|
||||||
end
|
|
||||||
|
|
||||||
[begin_ms, end_ms]
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.merge_section_ranges ranges
|
|
||||||
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
|
|
||||||
merged = []
|
|
||||||
|
|
||||||
sorted_ranges.each do |begin_ms, end_ms|
|
|
||||||
if merged.empty?
|
|
||||||
merged << [begin_ms, end_ms]
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
last_begin_ms, last_end_ms = merged[-1]
|
|
||||||
if last_end_ms.nil? || begin_ms <= last_end_ms
|
|
||||||
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
|
|
||||||
else
|
|
||||||
merged << [begin_ms, end_ms]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
merged
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.merge_section_end left_end_ms, right_end_ms
|
|
||||||
return nil if left_end_ms.nil? || right_end_ms.nil?
|
|
||||||
|
|
||||||
[left_end_ms, right_end_ms].max
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.find_or_create_by_tag_name! name, category:
|
def self.find_or_create_by_tag_name! name, category:
|
||||||
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
|
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
|
||||||
tn = tn.canonical if tn.canonical_id?
|
tn = tn.canonical if tn.canonical_id?
|
||||||
@@ -328,36 +228,4 @@ class Tag < ApplicationRecord
|
|||||||
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.time_to_ms! str, tag_name:
|
|
||||||
match =
|
|
||||||
case str
|
|
||||||
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
|
|
||||||
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
|
|
||||||
ms: Regexp.last_match[:ms] }
|
|
||||||
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
|
|
||||||
{ hours: nil, minutes: Regexp.last_match[:minutes],
|
|
||||||
seconds: Regexp.last_match[:seconds],
|
|
||||||
ms: Regexp.last_match[:ms] }
|
|
||||||
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
|
|
||||||
{ hours: Regexp.last_match[:hours],
|
|
||||||
minutes: Regexp.last_match[:minutes],
|
|
||||||
seconds: Regexp.last_match[:seconds],
|
|
||||||
ms: Regexp.last_match[:ms] }
|
|
||||||
end
|
|
||||||
|
|
||||||
raise SectionLiteralParseError.new(tag_name, str) unless match
|
|
||||||
|
|
||||||
total_s = match[:seconds].to_i
|
|
||||||
total_s += match[:minutes].to_i * 60 if match[:minutes]
|
|
||||||
total_s += match[:hours].to_i * 3_600 if match[:hours]
|
|
||||||
|
|
||||||
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def nico_tags_cannot_be_deprecated
|
|
||||||
if nico? && deprecated_at.present?
|
|
||||||
errors.add :deprecated_at, 'ニコタグは廃止できません.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class WikiPage < ApplicationRecord
|
|||||||
validates :body, presence: true
|
validates :body, presence: true
|
||||||
|
|
||||||
def title = tag_name.name
|
def title = tag_name.name
|
||||||
def deprecated_at = tag_name.tag&.deprecated_at
|
|
||||||
|
|
||||||
def title= val
|
def title= val
|
||||||
(self.tag_name ||= build_tag_name).name = val
|
(self.tag_name ||= build_tag_name).name = val
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ module PostRepr
|
|||||||
:url,
|
:url,
|
||||||
:title,
|
:title,
|
||||||
:thumbnail_base,
|
:thumbnail_base,
|
||||||
:video_ms,
|
|
||||||
:original_created_from,
|
:original_created_from,
|
||||||
:original_created_before,
|
:original_created_before,
|
||||||
:created_at,
|
:created_at,
|
||||||
@@ -19,7 +18,7 @@ module PostRepr
|
|||||||
|
|
||||||
def base post, current_user = nil
|
def base post, current_user = nil
|
||||||
json = common(post)
|
json = common(post)
|
||||||
json['tags'] = tag_json(post)
|
json['tags'] = tag_json(post.tags)
|
||||||
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
|
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
|
||||||
json['viewed'] = current_user ? current_user.viewed?(post) : false
|
json['viewed'] = current_user ? current_user.viewed?(post) : false
|
||||||
json
|
json
|
||||||
@@ -53,16 +52,8 @@ module PostRepr
|
|||||||
.merge('thumbnail' => thumbnail_url(post))
|
.merge('thumbnail' => thumbnail_url(post))
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_json post
|
def tag_json tags
|
||||||
post
|
tags.map { |tag| TagRepr.inline(tag) }
|
||||||
.active_post_tags
|
|
||||||
.reject { _1.tag.deprecated? }
|
|
||||||
.sort_by { _1.tag.name }
|
|
||||||
.map { |post_tag|
|
|
||||||
TagRepr.inline(post_tag.tag).merge(
|
|
||||||
'children' => [],
|
|
||||||
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_url post
|
def thumbnail_url post
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
module TagRepr
|
module TagRepr
|
||||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
|
||||||
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
|
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
module WikiPageRepr
|
module WikiPageRepr
|
||||||
BASE = { methods: [:title, :deprecated_at] }.freeze
|
BASE = { methods: [:title] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class PostVersionRecorder < VersionRecorder
|
|||||||
{ title: @record.title,
|
{ title: @record.title,
|
||||||
url: @record.url,
|
url: @record.url,
|
||||||
thumbnail_base: @record.thumbnail_base,
|
thumbnail_base: @record.thumbnail_base,
|
||||||
video_ms: @record.video_ms,
|
|
||||||
tags: @record.snapshot_tag_names.join(' '),
|
tags: @record.snapshot_tag_names.join(' '),
|
||||||
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: @record.original_created_from,
|
original_created_from: @record.original_created_from,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module Similarity
|
module Similarity
|
||||||
class Calc
|
class Calc
|
||||||
def self.call model, tgt, scope: nil
|
def self.call model, tgt
|
||||||
similarity_model = "#{ model.name }Similarity".constantize
|
similarity_model = "#{ model.name }Similarity".constantize
|
||||||
|
|
||||||
# 最大保存件数
|
# 最大保存件数
|
||||||
@@ -8,8 +8,7 @@ module Similarity
|
|||||||
|
|
||||||
similarity_model.delete_all
|
similarity_model.delete_all
|
||||||
|
|
||||||
scope ||= model.all
|
posts = model.includes(tgt).select(:id).to_a
|
||||||
posts = scope.includes(tgt).select(:id).to_a
|
|
||||||
|
|
||||||
tag_ids = { }
|
tag_ids = { }
|
||||||
tag_cnts = { }
|
tag_cnts = { }
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ class TagVersionRecorder < VersionRecorder
|
|||||||
def snapshot_attributes
|
def snapshot_attributes
|
||||||
{ name: @record.name,
|
{ name: @record.name,
|
||||||
category: @record.category,
|
category: @record.category,
|
||||||
deprecated_at: @record.deprecated_at,
|
|
||||||
aliases: @record.snapshot_aliases.join(' '),
|
aliases: @record.snapshot_aliases.join(' '),
|
||||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
class AddVideoMsToPosts < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
add_column :posts, :video_ms, :integer
|
|
||||||
add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id'
|
|
||||||
|
|
||||||
add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0',
|
|
||||||
name: 'chk_posts_video_ms_positive'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
class AddDeprecatedAtToTags < ActiveRecord::Migration[8.0]
|
|
||||||
def up
|
|
||||||
add_column :tags, :deprecated_at, :datetime, after: :category
|
|
||||||
add_column :tag_versions, :deprecated_at, :datetime, after: :parent_tag_ids
|
|
||||||
|
|
||||||
add_index :tags, :deprecated_at
|
|
||||||
|
|
||||||
add_check_constraint :tags, "deprecated_at IS NULL OR category <> 'nico'",
|
|
||||||
name: 'chk_tags_deprecated_at_not_nico'
|
|
||||||
end
|
|
||||||
|
|
||||||
def down
|
|
||||||
remove_check_constraint :tags, name: 'chk_tags_deprecated_at_not_nico'
|
|
||||||
|
|
||||||
remove_index :tags, :deprecated_at
|
|
||||||
|
|
||||||
remove_column :tag_versions, :deprecated_at, :datetime
|
|
||||||
remove_column :tags, :deprecated_at
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
class CreatePostTagSections < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t|
|
|
||||||
t.references :post, null: false, foreign_key: true, index: false
|
|
||||||
t.references :tag, null: false, foreign_key: true, index: false
|
|
||||||
t.integer :begin_ms, null: false
|
|
||||||
t.integer :end_ms, null: true
|
|
||||||
t.timestamps
|
|
||||||
|
|
||||||
t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms'
|
|
||||||
|
|
||||||
t.check_constraint 'begin_ms >= 0',
|
|
||||||
name: 'chk_post_tag_sections_begin_ms_natural'
|
|
||||||
|
|
||||||
t.check_constraint 'end_ms IS NULL OR begin_ms < end_ms',
|
|
||||||
name: 'chk_post_tag_sections_end_ms_after_begin_ms'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
class AddVideoMsToPostVersions < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
add_column :post_versions, :video_ms, :integer
|
|
||||||
add_index :post_versions, [:video_ms, :post_id], name: 'idx_post_versions_video_ms_post_id'
|
|
||||||
|
|
||||||
add_check_constraint :post_versions, 'video_ms IS NULL OR video_ms > 0',
|
|
||||||
name: 'chk_post_versions_video_ms_positive'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
生成ファイル
+1
-26
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_06_22_020000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do
|
||||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -210,19 +210,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_22_020000) do
|
|||||||
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
|
||||||
t.bigint "post_id", null: false
|
|
||||||
t.bigint "tag_id", null: false
|
|
||||||
t.integer "begin_ms", null: false
|
|
||||||
t.integer "end_ms"
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
|
|
||||||
t.index ["tag_id"], name: "fk_rails_8be3847903"
|
|
||||||
t.check_constraint "(`end_ms` is null) or (`begin_ms` < `end_ms`)", name: "chk_post_tag_sections_end_ms_after_begin_ms"
|
|
||||||
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "post_id", null: false
|
t.bigint "post_id", null: false
|
||||||
t.bigint "tag_id", null: false
|
t.bigint "tag_id", null: false
|
||||||
@@ -256,12 +243,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_22_020000) do
|
|||||||
t.datetime "original_created_before"
|
t.datetime "original_created_before"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.bigint "created_by_user_id"
|
t.bigint "created_by_user_id"
|
||||||
t.integer "video_ms"
|
|
||||||
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
|
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
|
||||||
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
|
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
|
||||||
t.index ["post_id"], name: "index_post_versions_on_post_id"
|
t.index ["post_id"], name: "index_post_versions_on_post_id"
|
||||||
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
|
|
||||||
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
|
|
||||||
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
|
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
|
||||||
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
|
||||||
end
|
end
|
||||||
@@ -276,11 +260,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_22_020000) do
|
|||||||
t.datetime "original_created_before"
|
t.datetime "original_created_before"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
t.integer "video_ms"
|
|
||||||
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
||||||
t.index ["url"], name: "index_posts_on_url", unique: true
|
t.index ["url"], name: "index_posts_on_url", unique: true
|
||||||
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
|
|
||||||
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
|
|
||||||
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -338,7 +319,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_22_020000) do
|
|||||||
t.string "event_type", null: false
|
t.string "event_type", null: false
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "category", null: false
|
t.string "category", null: false
|
||||||
t.datetime "deprecated_at"
|
|
||||||
t.text "aliases", null: false
|
t.text "aliases", null: false
|
||||||
t.text "parent_tag_ids", null: false
|
t.text "parent_tag_ids", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -356,13 +336,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_22_020000) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "post_count", default: 0, null: false
|
t.integer "post_count", default: 0, null: false
|
||||||
t.datetime "deprecated_at"
|
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
|
||||||
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
|
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
|
||||||
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
||||||
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
|
|
||||||
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -603,8 +580,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_22_020000) do
|
|||||||
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
||||||
add_foreign_key "post_similarities", "posts"
|
add_foreign_key "post_similarities", "posts"
|
||||||
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
||||||
add_foreign_key "post_tag_sections", "posts"
|
|
||||||
add_foreign_key "post_tag_sections", "tags"
|
|
||||||
add_foreign_key "post_tags", "posts"
|
add_foreign_key "post_tags", "posts"
|
||||||
add_foreign_key "post_tags", "tags"
|
add_foreign_key "post_tags", "tags"
|
||||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace :post_similarity do
|
namespace :post_similarity do
|
||||||
desc '関聯投稿テーブル作成'
|
desc '関聯投稿テーブル作成'
|
||||||
task calc: :environment do
|
task calc: :environment do
|
||||||
Similarity::Calc.call(Post, :active_tags)
|
Similarity::Calc.call(Post, :tags)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace :tag_similarity do
|
namespace :tag_similarity do
|
||||||
desc '関聯タグ・テーブル作成'
|
desc '関聯タグ・テーブル作成'
|
||||||
task calc: :environment do
|
task calc: :environment do
|
||||||
Similarity::Calc.call(Tag, :posts, scope: Tag.where(deprecated_at: nil))
|
Similarity::Calc.call(Tag, :posts)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
FactoryBot.define do
|
|
||||||
factory :post_tag_section do
|
|
||||||
association :post
|
|
||||||
association :tag
|
|
||||||
begin_ms { 1_000 }
|
|
||||||
end_ms { 2_000 }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
FactoryBot.define do
|
|
||||||
factory :post_tag do
|
|
||||||
association :post
|
|
||||||
association :tag
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
FactoryBot.define do
|
|
||||||
factory :post do
|
|
||||||
sequence(:url) { |n| "https://example.com/factory-post-#{ n }" }
|
|
||||||
title { 'factory post' }
|
|
||||||
thumbnail_base { nil }
|
|
||||||
uploaded_user { nil }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
RSpec.describe PostTag, type: :model do
|
|
||||||
describe '#sections' do
|
|
||||||
it 'loads sections by post_id and tag_id' do
|
|
||||||
post_tag = create(:post_tag)
|
|
||||||
section = create(:post_tag_section,
|
|
||||||
post: post_tag.post,
|
|
||||||
tag: post_tag.tag,
|
|
||||||
begin_ms: 1000,
|
|
||||||
end_ms: 2000)
|
|
||||||
|
|
||||||
expect(post_tag.sections).to contain_exactly(section)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not load sections for another tag on the same post' do
|
|
||||||
post = create(:post)
|
|
||||||
tag = create(:tag)
|
|
||||||
other_tag = create(:tag)
|
|
||||||
|
|
||||||
post_tag = create(:post_tag, post:, tag:)
|
|
||||||
create(:post_tag_section,
|
|
||||||
post:,
|
|
||||||
tag: other_tag,
|
|
||||||
begin_ms: 1000,
|
|
||||||
end_ms: 2000)
|
|
||||||
|
|
||||||
expect(post_tag.sections).to be_empty
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows open-ended sections' do
|
|
||||||
post_tag = create(:post_tag)
|
|
||||||
section = create(:post_tag_section,
|
|
||||||
post: post_tag.post,
|
|
||||||
tag: post_tag.tag,
|
|
||||||
begin_ms: 1000,
|
|
||||||
end_ms: nil)
|
|
||||||
|
|
||||||
expect(section).to be_valid
|
|
||||||
expect(post_tag.sections).to contain_exactly(section)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,168 +1,6 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Tag, type: :model do
|
RSpec.describe Tag, type: :model do
|
||||||
describe '.normalise_tags!' do
|
|
||||||
it 'rejects deprecated tags when deny_deprecated is enabled' do
|
|
||||||
tag_name = TagName.create!(name: 'normalise deprecated tag')
|
|
||||||
deprecated_tag = Tag.create!(
|
|
||||||
tag_name:,
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: 1.day.from_now
|
|
||||||
)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.normalise_tags!(
|
|
||||||
[deprecated_tag.name],
|
|
||||||
deny_deprecated: true
|
|
||||||
)
|
|
||||||
}.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
|
|
||||||
expect(error.tag_names).to eq([deprecated_tag.name])
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rejects invalid section literals instead of treating them as zero' do
|
|
||||||
expect {
|
|
||||||
described_class.normalise_tags!(
|
|
||||||
['normalise_invalid_section[1:aa-2:00]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
}.to raise_error(Tag::SectionLiteralParseError)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'parses open-ended section literals' do
|
|
||||||
result = described_class.normalise_tags!(
|
|
||||||
['伊地知ニジカ[1:00-]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
|
|
||||||
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'parses omitted begin as zero' do
|
|
||||||
result = described_class.normalise_tags!(
|
|
||||||
['伊地知ニジカ[-1:00]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
|
|
||||||
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'treats fully open section literals as plain tags' do
|
|
||||||
result = described_class.normalise_tags!(
|
|
||||||
['伊地知ニジカ[-]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
|
|
||||||
expect(result.fetch(:sections)[tag.id]).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'treats [0:00-] as a plain tag' do
|
|
||||||
result = described_class.normalise_tags!(
|
|
||||||
['伊地知ニジカ[0:00-]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
|
|
||||||
expect(result.fetch(:sections)[tag.id]).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'expands zero-width sections to one millisecond' do
|
|
||||||
result = described_class.normalise_tags!(
|
|
||||||
['伊地知ニジカ[1:00-1:00]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
|
|
||||||
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'swaps reversed section boundaries' do
|
|
||||||
result = described_class.normalise_tags!(
|
|
||||||
['伊地知ニジカ[2:00-1:00]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
|
|
||||||
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'merges open-ended sections over later bounded sections' do
|
|
||||||
result = described_class.normalise_tags!(
|
|
||||||
['伊地知ニジカ[1:00-][2:00-3:00]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
|
|
||||||
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'merges adjacent bounded and open-ended sections' do
|
|
||||||
result = described_class.normalise_tags!(
|
|
||||||
['伊地知ニジカ[1:00-3:00][3:00-]'],
|
|
||||||
with_sections: true
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
|
|
||||||
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.expand_parent_tags' do
|
|
||||||
it 'expands through multiple deprecated parents to an active ancestor' do
|
|
||||||
child = create(:tag, name: 'expand_child')
|
|
||||||
deprecated_parent = create(
|
|
||||||
:tag,
|
|
||||||
name: 'expand_deprecated_parent',
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
deprecated_grandparent = create(
|
|
||||||
:tag,
|
|
||||||
name: 'expand_deprecated_grandparent',
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
active_ancestor = create(:tag, name: 'expand_active_ancestor')
|
|
||||||
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
|
|
||||||
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
|
||||||
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
|
|
||||||
|
|
||||||
expanded = described_class.expand_parent_tags([child])
|
|
||||||
|
|
||||||
expect(expanded).to include(
|
|
||||||
child,
|
|
||||||
deprecated_parent,
|
|
||||||
deprecated_grandparent,
|
|
||||||
active_ancestor
|
|
||||||
)
|
|
||||||
expect(expanded.reject(&:deprecated?)).to contain_exactly(child, active_ancestor)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'terminates when implications contain a cycle' do
|
|
||||||
first = create(:tag, name: 'expand_cycle_first')
|
|
||||||
second = create(:tag, name: 'expand_cycle_second')
|
|
||||||
TagImplication.create!(tag: first, parent_tag: second)
|
|
||||||
TagImplication.create!(tag: second, parent_tag: first)
|
|
||||||
|
|
||||||
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'deprecated validation' do
|
|
||||||
it 'rejects deprecated nico tags' do
|
|
||||||
tag = build(
|
|
||||||
:tag,
|
|
||||||
name: 'nico:deprecated_validation',
|
|
||||||
category: :nico,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(tag).not_to be_valid
|
|
||||||
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.merge_tags!' do
|
describe '.merge_tags!' do
|
||||||
let!(:target_tag) { create(:tag, category: :general) }
|
let!(:target_tag) { create(:tag, category: :general) }
|
||||||
let!(:source_tag) { create(:tag, category: :general) }
|
let!(:source_tag) { create(:tag, category: :general) }
|
||||||
|
|||||||
@@ -897,37 +897,6 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /gekanator/questions' do
|
describe 'GET /gekanator/questions' do
|
||||||
it 'omits questions for deprecated tags' do
|
|
||||||
active_tag = Tag.create!(name: 'active_question_tag', category: :general)
|
|
||||||
deprecated_tag = Tag.create!(
|
|
||||||
name: 'deprecated_question_tag',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
|
|
||||||
[active_tag, deprecated_tag].each do |question_tag|
|
|
||||||
GekanatorQuestion.create!(
|
|
||||||
text: "#{ question_tag.name }?",
|
|
||||||
kind: 'tag',
|
|
||||||
source: 'admin_curated',
|
|
||||||
status: 'accepted',
|
|
||||||
priority_weight: 1.0,
|
|
||||||
condition: {
|
|
||||||
type: 'tag',
|
|
||||||
key: "#{ question_tag.category }:#{ question_tag.name }"
|
|
||||||
},
|
|
||||||
created_by: admin
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
get '/gekanator/questions'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
question_ids = json.fetch('questions').map { |question| question.fetch('id') }
|
|
||||||
expect(question_ids).to include('tag:general:active_question_tag')
|
|
||||||
expect(question_ids).not_to include('tag:general:deprecated_question_tag')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns accepted questions only and includes example_answers for post_similarity questions' do
|
it 'returns accepted questions only and includes example_answers for post_similarity questions' do
|
||||||
sign_in_as admin
|
sign_in_as admin
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
|
|
||||||
RSpec.describe 'Gekanator posts API', type: :request do
|
|
||||||
describe 'GET /gekanator/posts' do
|
|
||||||
it 'omits deprecated tags and returns the stored similarity cosine' do
|
|
||||||
active_tag = Tag.create!(name: 'active tag', category: :general)
|
|
||||||
deprecated_tag = Tag.create!(
|
|
||||||
name: 'deprecated tag',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
post_record = Post.create!(title: 'source', url: 'https://example.com/source')
|
|
||||||
target_post = Post.create!(title: 'target', url: 'https://example.com/target')
|
|
||||||
|
|
||||||
PostTag.create!(post: post_record, tag: active_tag)
|
|
||||||
PostTag.create!(post: post_record, tag: deprecated_tag)
|
|
||||||
PostTag.create!(post: target_post, tag: deprecated_tag)
|
|
||||||
PostSimilarity.create!(post: post_record, target_post:, cos: 0.375)
|
|
||||||
|
|
||||||
get '/gekanator/posts'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
post_json = json.fetch('posts').find { |post| post.fetch('id') == post_record.id }
|
|
||||||
expect(post_json.fetch('tags').map { |tag| tag.fetch('name') }).to eq(['active tag'])
|
|
||||||
expect(post_json.fetch('post_similarity_edges')).to contain_exactly(
|
|
||||||
'target_post_id' => target_post.id,
|
|
||||||
'cos' => 0.375
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -36,7 +36,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
title: post.title,
|
title: post.title,
|
||||||
url: post.url,
|
url: post.url,
|
||||||
thumbnail_base: post.thumbnail_base,
|
thumbnail_base: post.thumbnail_base,
|
||||||
video_ms: post.video_ms,
|
|
||||||
tags: post.snapshot_tag_names.join(' '),
|
tags: post.snapshot_tag_names.join(' '),
|
||||||
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
parent_post_ids: post.snapshot_parent_post_ids.join(' '),
|
||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
@@ -128,22 +127,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(all_tag_names).to include("spec_tag")
|
expect(all_tag_names).to include("spec_tag")
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps children and sections keys in non-detail tag responses' do
|
|
||||||
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
|
|
||||||
|
|
||||||
get '/posts'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
|
|
||||||
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
|
|
||||||
|
|
||||||
expect(tag_json.fetch('children')).to eq([])
|
|
||||||
expect(tag_json.fetch('sections')).to eq([
|
|
||||||
{ 'begin_ms' => 1_000, 'end_ms' => nil }
|
|
||||||
])
|
|
||||||
end
|
|
||||||
|
|
||||||
context "when q is provided" do
|
context "when q is provided" do
|
||||||
it "filters posts by q (hit case)" do
|
it "filters posts by q (hit case)" do
|
||||||
get "/posts", params: { tags: "spec_tag" }
|
get "/posts", params: { tags: "spec_tag" }
|
||||||
@@ -534,24 +517,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect([true, false]).to include(json['viewed'])
|
expect([true, false]).to include(json['viewed'])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'omits deprecated tags' do
|
|
||||||
deprecated_tag = Tag.create!(
|
|
||||||
name: 'deprecated_post_tag',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
PostTag.create!(post: post_record, tag: deprecated_tag)
|
|
||||||
|
|
||||||
request
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
tag_names = json.fetch('tags').flat_map { |node|
|
|
||||||
[node.fetch('name')] + node.fetch('children').map { |child| child.fetch('name') }
|
|
||||||
}
|
|
||||||
expect(tag_names).to include('spec_tag')
|
|
||||||
expect(tag_names).not_to include('deprecated_post_tag')
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when post has parent, child, and sibling posts' do
|
context 'when post has parent, child, and sibling posts' do
|
||||||
let!(:parent_post) do
|
let!(:parent_post) do
|
||||||
create_parent_post!(
|
create_parent_post!(
|
||||||
@@ -732,234 +697,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(names).not_to include('manko')
|
expect(names).not_to include('manko')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects a deprecated tag specified directly' do
|
|
||||||
Tag.create!(
|
|
||||||
name: 'deprecated_direct_tag',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'new post',
|
|
||||||
url: 'https://example.com/deprecated-direct-tag',
|
|
||||||
tags: 'deprecated_direct_tag',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'tags' => ['廃止済みタグは付与できません.']
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'expands through multiple deprecated parent tags and saves active ancestors' do
|
|
||||||
child = Tag.create!(name: 'active_child', category: :general)
|
|
||||||
deprecated_parent = Tag.create!(
|
|
||||||
name: 'deprecated_parent',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
deprecated_grandparent = Tag.create!(
|
|
||||||
name: 'deprecated_grandparent',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
active_grandparent = Tag.create!(name: 'active_grandparent', category: :general)
|
|
||||||
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
|
|
||||||
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
|
||||||
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent)
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'expanded post',
|
|
||||||
url: 'https://example.com/expanded-deprecated-parent',
|
|
||||||
tags: 'active_child',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
saved_names = Post.find(json.fetch('id')).tags.map(&:name)
|
|
||||||
expect(saved_names).to include('active_child', 'active_grandparent')
|
|
||||||
expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns validation error for an invalid section literal' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'invalid section literal',
|
|
||||||
url: 'https://example.com/invalid-section-literal',
|
|
||||||
tags: 'spec_tag[1:aa-2:00]',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json).to include(
|
|
||||||
'type' => 'validation_error',
|
|
||||||
'message' => '入力内容を確認してください.'
|
|
||||||
)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'tags' => ['タグ区間の記法が不正です.']
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates a video post with duration' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'video post',
|
|
||||||
url: 'https://example.com/video-post',
|
|
||||||
tags: '動画 spec_tag',
|
|
||||||
duration: '3:00.500',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
|
|
||||||
expect(json.fetch('video_ms')).to eq(180_500)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'clears video_ms when the saved tags do not include 動画' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'non video post',
|
|
||||||
url: 'https://example.com/non-video-post',
|
|
||||||
tags: 'spec_tag',
|
|
||||||
duration: '3:00',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
expect(Post.find(json.fetch('id')).video_ms).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns validation error when a bounded section exceeds duration' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'too long section',
|
|
||||||
url: 'https://example.com/too-long-section',
|
|
||||||
tags: '動画 伊地知ニジカ[2:50-3:10]',
|
|
||||||
duration: '3:00',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'video_ms' => ['タグ区間の終端が動画時間を超えてゐます.']
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'saves open-ended sections with end_ms NULL' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'open ended section literal',
|
|
||||||
url: 'https://example.com/open-ended-section-literal',
|
|
||||||
tags: '動画 伊地知ニジカ[1:00-]',
|
|
||||||
duration: '3:00',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
created_post = Post.find(json.fetch('id'))
|
|
||||||
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
|
|
||||||
section = PostTagSection.find_by!(post: created_post, tag:)
|
|
||||||
|
|
||||||
expect(section.begin_ms).to eq(60_000)
|
|
||||||
expect(section.end_ms).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not save sections for [-]' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'fully open section literal',
|
|
||||||
url: 'https://example.com/fully-open-section-literal',
|
|
||||||
tags: '伊地知ニジカ[-]',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
created_post = Post.find(json.fetch('id'))
|
|
||||||
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
|
|
||||||
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not save sections for [0:00-]' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'zero open ended section literal',
|
|
||||||
url: 'https://example.com/zero-open-ended-section-literal',
|
|
||||||
tags: '伊地知ニジカ[0:00-]',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
created_post = Post.find(json.fetch('id'))
|
|
||||||
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
|
|
||||||
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns end_ms null for open-ended sections in show response' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'show open ended section literal',
|
|
||||||
url: 'https://example.com/show-open-ended-section-literal',
|
|
||||||
tags: '動画 伊地知ニジカ[1:00-]',
|
|
||||||
duration: '3:00',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
get "/posts/#{ json.fetch('id') }"
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
tag_json = json.fetch('tags').find { |item| item['name'] == '伊地知ニジカ' }
|
|
||||||
expect(tag_json.fetch('sections')).to eq([
|
|
||||||
{ 'begin_ms' => 60_000, 'end_ms' => nil }
|
|
||||||
])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'allows open-ended sections when begin is within duration' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'valid open ended section literal',
|
|
||||||
url: 'https://example.com/valid-open-ended-section-literal',
|
|
||||||
tags: '動画 伊地知ニジカ[1:00-]',
|
|
||||||
duration: '3:00',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rejects open-ended sections when begin equals duration' do
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
post '/posts', params: post_write_params(
|
|
||||||
title: 'invalid open ended section literal',
|
|
||||||
url: 'https://example.com/invalid-open-ended-section-literal',
|
|
||||||
tags: '動画 伊地知ニジカ[3:00-]',
|
|
||||||
duration: '3:00',
|
|
||||||
thumbnail: dummy_upload
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'video_ms' => ['タグ区間の開始が動画時間以上です.']
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
context "when nico tag already exists in tags" do
|
context "when nico tag already exists in tags" do
|
||||||
before do
|
before do
|
||||||
Tag.find_undiscard_or_create_by!(
|
Tag.find_undiscard_or_create_by!(
|
||||||
@@ -1193,26 +930,6 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(names).to include('spec_tag_2')
|
expect(names).to include('spec_tag_2')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects a deprecated tag specified directly' do
|
|
||||||
Tag.create!(
|
|
||||||
name: 'deprecated_update_tag',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
sign_in_as(member)
|
|
||||||
|
|
||||||
put "/posts/#{ post_record.id }", params: post_update_params(
|
|
||||||
post_record,
|
|
||||||
title: 'updated title',
|
|
||||||
tags: 'deprecated_update_tag'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'tags' => ['廃止済みタグは付与できません.']
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
context "when nico tag already exists in tags" do
|
context "when nico tag already exists in tags" do
|
||||||
before do
|
before do
|
||||||
Tag.find_undiscard_or_create_by!(
|
Tag.find_undiscard_or_create_by!(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type:,
|
event_type:,
|
||||||
name:,
|
name:,
|
||||||
category:,
|
category:,
|
||||||
deprecated_at: nil,
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
parent_tags: [],
|
parent_tags: [],
|
||||||
created_by_user:,
|
created_by_user:,
|
||||||
@@ -34,7 +33,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type: event_type,
|
event_type: event_type,
|
||||||
name: name,
|
name: name,
|
||||||
category: category,
|
category: category,
|
||||||
deprecated_at: deprecated_at,
|
|
||||||
aliases: Array(aliases).join(' '),
|
aliases: Array(aliases).join(' '),
|
||||||
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||||
created_by_user: created_by_user,
|
created_by_user: created_by_user,
|
||||||
@@ -67,7 +65,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type: 'update',
|
event_type: 'update',
|
||||||
name: 'new_tag_name',
|
name: 'new_tag_name',
|
||||||
category: 'meme',
|
category: 'meme',
|
||||||
deprecated_at: t_v2,
|
|
||||||
aliases: ['alias_shared', 'alias_new'],
|
aliases: ['alias_shared', 'alias_new'],
|
||||||
parent_tags: [parent_shared, parent_new],
|
parent_tags: [parent_shared, parent_new],
|
||||||
created_by_user: member,
|
created_by_user: member,
|
||||||
@@ -136,10 +133,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
'current' => 'meme',
|
'current' => 'meme',
|
||||||
'prev' => 'general'
|
'prev' => 'general'
|
||||||
)
|
)
|
||||||
expect(latest.fetch('deprecated_at')).to eq(
|
|
||||||
'current' => t_v2.iso8601,
|
|
||||||
'prev' => nil
|
|
||||||
)
|
|
||||||
expect(latest.fetch('aliases')).to include(
|
expect(latest.fetch('aliases')).to include(
|
||||||
{ 'name' => 'alias_shared', 'type' => 'context' },
|
{ 'name' => 'alias_shared', 'type' => 'context' },
|
||||||
{ 'name' => 'alias_new', 'type' => 'added' },
|
{ 'name' => 'alias_new', 'type' => 'added' },
|
||||||
@@ -185,10 +178,6 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
'current' => 'general',
|
'current' => 'general',
|
||||||
'prev' => nil
|
'prev' => nil
|
||||||
)
|
)
|
||||||
expect(first.fetch('deprecated_at')).to eq(
|
|
||||||
'current' => nil,
|
|
||||||
'prev' => nil
|
|
||||||
)
|
|
||||||
expect(first.fetch('aliases')).to include(
|
expect(first.fetch('aliases')).to include(
|
||||||
{ 'name' => 'alias_shared', 'type' => 'added' },
|
{ 'name' => 'alias_shared', 'type' => 'added' },
|
||||||
{ 'name' => 'alias_old', 'type' => 'added' }
|
{ 'name' => 'alias_old', 'type' => 'added' }
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -124,7 +123,6 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -151,7 +149,6 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'put_tag_alias_only_alias',
|
aliases: 'put_tag_alias_only_alias',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|||||||
@@ -76,27 +76,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(response_tags.first['id']).to eq(meme.id)
|
expect(response_tags.first['id']).to eq(meme.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'filters tags by deprecated state' do
|
|
||||||
deprecated_tag = Tag.create!(
|
|
||||||
name: 'deprecated_filter',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: 1.day.from_now
|
|
||||||
)
|
|
||||||
active_tag = Tag.create!(name: 'active_filter', category: :general)
|
|
||||||
|
|
||||||
get '/tags', params: { name: '_filter', deprecated: '1' }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(response_names).to include(deprecated_tag.name)
|
|
||||||
expect(response_names).not_to include(active_tag.name)
|
|
||||||
|
|
||||||
get '/tags', params: { name: '_filter', deprecated: '0' }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(response_names).to include(active_tag.name)
|
|
||||||
expect(response_names).not_to include(deprecated_tag.name)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'filters tags by post_count range' do
|
it 'filters tags by post_count range' do
|
||||||
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
|
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
|
||||||
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
|
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
|
||||||
@@ -322,21 +301,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(t['matched_alias']).to eq('unko')
|
expect(t['matched_alias']).to eq('unko')
|
||||||
expect(json.map { |x| x['name'] }).not_to include('unknown')
|
expect(json.map { |x| x['name'] }).not_to include('unknown')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'omits deprecated tags' do
|
|
||||||
deprecated_tag = Tag.create!(
|
|
||||||
name: 'spec_deprecated',
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
deprecated_tag.update_columns(post_count: 1)
|
|
||||||
|
|
||||||
get '/tags/autocomplete', params: { q: 'spec_', present: '0' }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.map { |item| item.fetch('name') }).to include('spec_tag')
|
|
||||||
expect(json.map { |item| item.fetch('name') }).not_to include('spec_deprecated')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags/name/:name' do
|
describe 'GET /tags/name/:name' do
|
||||||
@@ -473,32 +437,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates deprecated state and records it in tag versions' do
|
|
||||||
expect {
|
|
||||||
patch "/tags/#{ tag.id }", params: { deprecated: '1' }
|
|
||||||
}.to change(TagVersion, :count).by(2)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(tag.reload.deprecated_at).to be_present
|
|
||||||
|
|
||||||
versions = tag.tag_versions.order(:version_no)
|
|
||||||
expect(versions.first.deprecated_at).to be_nil
|
|
||||||
expect(versions.second.deprecated_at).to eq(tag.deprecated_at)
|
|
||||||
expect(json.fetch('deprecated_at')).to be_present
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rejects deprecating a nico tag' do
|
|
||||||
nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)
|
|
||||||
|
|
||||||
patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(nico_tag.reload.deprecated_at).to be_nil
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'deprecated' => ['ニコタグは廃止できません.']
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 422 when changing normal tag category to nico' do
|
it 'returns 422 when changing normal tag category to nico' do
|
||||||
expect {
|
expect {
|
||||||
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
||||||
@@ -647,111 +585,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(row['has_children']).to eq(true)
|
expect(row['has_children']).to eq(true)
|
||||||
expect(row['children']).to eq([])
|
expect(row['children']).to eq([])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'passes through deprecated tags when finding children' do
|
|
||||||
deprecated_middle = Tag.create!(
|
|
||||||
name: 'depth_deprecated_middle',
|
|
||||||
category: :character,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
visible_descendant = Tag.create!(
|
|
||||||
name: 'depth_visible_descendant',
|
|
||||||
category: :material
|
|
||||||
)
|
|
||||||
TagImplication.create!(parent_tag: root_material, tag: deprecated_middle)
|
|
||||||
TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant)
|
|
||||||
|
|
||||||
get '/tags/with-depth', params: { parent: root_material.id }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.map { |item| item.fetch('name') }).to eq(['depth_visible_descendant'])
|
|
||||||
expect(json.map { |item| item.fetch('name') }).not_to include('depth_deprecated_middle')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'passes through multiple deprecated tags for roots and has_children' do
|
|
||||||
active_child = Tag.create!(
|
|
||||||
name: 'depth_active_child_below_deprecated',
|
|
||||||
category: :character
|
|
||||||
)
|
|
||||||
deprecated_parent = Tag.create!(
|
|
||||||
name: 'depth_deprecated_parent',
|
|
||||||
category: :character,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
deprecated_grandparent = Tag.create!(
|
|
||||||
name: 'depth_deprecated_grandparent',
|
|
||||||
category: :material,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
active_ancestor = Tag.create!(
|
|
||||||
name: 'depth_active_ancestor',
|
|
||||||
category: :meme
|
|
||||||
)
|
|
||||||
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
|
|
||||||
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
|
||||||
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
|
|
||||||
|
|
||||||
get '/tags/with-depth'
|
|
||||||
|
|
||||||
root_names = json.map { |item| item.fetch('name') }
|
|
||||||
expect(root_names).to include('depth_active_ancestor')
|
|
||||||
expect(root_names).not_to include('depth_active_child_below_deprecated')
|
|
||||||
ancestor_json = json.find { |item| item.fetch('id') == active_ancestor.id }
|
|
||||||
expect(ancestor_json.fetch('has_children')).to eq(true)
|
|
||||||
|
|
||||||
get '/tags/with-depth', params: { parent: active_ancestor.id }
|
|
||||||
|
|
||||||
expect(json.map { |item| item.fetch('name') }).to include(
|
|
||||||
'depth_active_child_below_deprecated'
|
|
||||||
)
|
|
||||||
expect(json.map { |item| item.fetch('name') }).not_to include(
|
|
||||||
'depth_deprecated_parent',
|
|
||||||
'depth_deprecated_grandparent'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'treats an active tag with only deprecated ancestors as a root' do
|
|
||||||
active_child = Tag.create!(
|
|
||||||
name: 'depth_root_below_deprecated',
|
|
||||||
category: :character
|
|
||||||
)
|
|
||||||
deprecated_parent = Tag.create!(
|
|
||||||
name: 'depth_root_deprecated_parent',
|
|
||||||
category: :material,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
|
|
||||||
|
|
||||||
get '/tags/with-depth'
|
|
||||||
|
|
||||||
expect(json.map { |item| item.fetch('name') }).to include(
|
|
||||||
'depth_root_below_deprecated'
|
|
||||||
)
|
|
||||||
expect(json.map { |item| item.fetch('name') }).not_to include(
|
|
||||||
'depth_root_deprecated_parent'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'terminates when deprecated implications contain a cycle' do
|
|
||||||
first = Tag.create!(
|
|
||||||
name: 'depth_cycle_first',
|
|
||||||
category: :character,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
second = Tag.create!(
|
|
||||||
name: 'depth_cycle_second',
|
|
||||||
category: :material,
|
|
||||||
deprecated_at: Time.current
|
|
||||||
)
|
|
||||||
TagImplication.create!(tag: first, parent_tag: root_material)
|
|
||||||
TagImplication.create!(tag: second, parent_tag: first)
|
|
||||||
TagImplication.create!(tag: first, parent_tag: second)
|
|
||||||
|
|
||||||
get '/tags/with-depth', params: { parent: root_material.id }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to eq([])
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags/name/:name/materials' do
|
describe 'GET /tags/name/:name/materials' do
|
||||||
@@ -899,20 +732,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(tag.category).to eq('general')
|
expect(tag.category).to eq('general')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'deprecated がなければ 422 を返す' do
|
|
||||||
put "/tags/#{ tag.id }", params: {
|
|
||||||
name: 'new',
|
|
||||||
category: 'general',
|
|
||||||
aliases: '',
|
|
||||||
parent_tags: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'deprecated' => ['廃止状態は必須です.']
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'name, category, aliases, parent tags をまとめて更新できる' do
|
it 'name, category, aliases, parent tags をまとめて更新できる' do
|
||||||
old_parent = Tag.create!(
|
old_parent = Tag.create!(
|
||||||
tag_name: TagName.create!(name: 'put_old_parent'),
|
tag_name: TagName.create!(name: 'put_old_parent'),
|
||||||
@@ -930,7 +749,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: 'put_alias_a put_alias_b put_alias_a',
|
aliases: 'put_alias_a put_alias_b put_alias_a',
|
||||||
parent_tags: 'put_kept_parent put_new_parent',
|
parent_tags: 'put_kept_parent put_new_parent',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -975,7 +793,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'spec_tag put_alias_self_test',
|
aliases: 'spec_tag put_alias_self_test',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -993,7 +810,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: 'spec_tag',
|
parent_tags: 'spec_tag',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -1009,7 +825,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meta',
|
category: 'meta',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}.to change(TagVersion, :count).by(2)
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
@@ -1045,7 +860,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: new_parent.name,
|
parent_tags: new_parent.name,
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -1061,7 +875,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'nico',
|
category: 'nico',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}.not_to change(TagVersion, :count)
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
@@ -1083,7 +896,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'nico',
|
category: 'nico',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}.not_to change(NicoTagVersion, :count)
|
}.not_to change(NicoTagVersion, :count)
|
||||||
|
|
||||||
@@ -1104,7 +916,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: old_category,
|
category: old_category,
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}.not_to change(TagVersion, :count)
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
@@ -1135,7 +946,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -1171,7 +981,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko put_stolen_alias',
|
aliases: 'unko put_stolen_alias',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change { tag.reload.tag_versions.count }.by(2)
|
.to change { tag.reload.tag_versions.count }.by(2)
|
||||||
@@ -1206,7 +1015,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: child.name,
|
parent_tags: child.name,
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
@@ -1228,7 +1036,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
deprecated: '0',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|||||||
@@ -18,13 +18,6 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
end
|
end
|
||||||
let!(:tag) do
|
|
||||||
Tag.create!(
|
|
||||||
tag_name: tn,
|
|
||||||
category: :general,
|
|
||||||
deprecated_at: Time.zone.local(2026, 6, 1)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'GET /wiki' do
|
describe 'GET /wiki' do
|
||||||
it 'returns wiki pages with title' do
|
it 'returns wiki pages with title' do
|
||||||
@@ -37,8 +30,6 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
expect(json[0]).to have_key('title')
|
expect(json[0]).to have_key('title')
|
||||||
expect(json.map { |p| p['title'] }).to include('spec_wiki_title')
|
expect(json.map { |p| p['title'] }).to include('spec_wiki_title')
|
||||||
wiki_json = json.find { |item| item.fetch('id') == page.id }
|
|
||||||
expect(wiki_json.fetch('deprecated_at')).to eq(tag.deprecated_at.iso8601(3))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -57,8 +48,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
expect(json).to include(
|
expect(json).to include(
|
||||||
'id' => page.id,
|
'id' => page.id,
|
||||||
'title' => 'spec_wiki_title',
|
'title' => 'spec_wiki_title')
|
||||||
'deprecated_at' => tag.deprecated_at.iso8601(3))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -419,11 +409,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
'kind' => 'content',
|
'kind' => 'content',
|
||||||
'message' => 'r2'
|
'message' => 'r2'
|
||||||
)
|
)
|
||||||
expect(top['wiki_page']).to include(
|
expect(top['wiki_page']).to include('id' => page.id, 'title' => 'spec_wiki_title')
|
||||||
'id' => page.id,
|
|
||||||
'title' => 'spec_wiki_title',
|
|
||||||
'deprecated_at' => tag.deprecated_at.iso8601(3)
|
|
||||||
)
|
|
||||||
expect(top['user']).to include('id' => user.id, 'name' => user.name)
|
expect(top['user']).to include('id' => user.id, 'name' => user.name)
|
||||||
expect(top).to have_key('timestamp')
|
expect(top).to have_key('timestamp')
|
||||||
|
|
||||||
@@ -493,7 +479,6 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
expect(json).to include(
|
expect(json).to include(
|
||||||
'wiki_page_id' => page.id,
|
'wiki_page_id' => page.id,
|
||||||
'title' => 'spec_wiki_title',
|
'title' => 'spec_wiki_title',
|
||||||
'deprecated_at' => tag.deprecated_at.iso8601(3),
|
|
||||||
'older_revision_id' => rev_a.id,
|
'older_revision_id' => rev_a.id,
|
||||||
'newer_revision_id' => rev_b.id
|
'newer_revision_id' => rev_b.id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ require 'rails_helper'
|
|||||||
RSpec.describe 'post_similarity:calc' do
|
RSpec.describe 'post_similarity:calc' do
|
||||||
include RakeTaskHelper
|
include RakeTaskHelper
|
||||||
|
|
||||||
it 'calculates similarities from active tags only' do
|
it 'calls Similarity::Calc with Post and :tags' do
|
||||||
# 必要最低限のデータ
|
# 必要最低限のデータ
|
||||||
t1 = Tag.create!(name: "t1")
|
t1 = Tag.create!(name: "t1")
|
||||||
t2 = Tag.create!(name: "t2")
|
t2 = Tag.create!(name: "t2")
|
||||||
t3 = Tag.create!(name: "t3")
|
t3 = Tag.create!(name: "t3")
|
||||||
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
|
|
||||||
|
|
||||||
p1 = Post.create!(url: "https://example.com/1")
|
p1 = Post.create!(url: "https://example.com/1")
|
||||||
p2 = Post.create!(url: "https://example.com/2")
|
p2 = Post.create!(url: "https://example.com/2")
|
||||||
@@ -23,8 +22,6 @@ RSpec.describe 'post_similarity:calc' do
|
|||||||
PostTag.create!(post: p2, tag: t3)
|
PostTag.create!(post: p2, tag: t3)
|
||||||
|
|
||||||
PostTag.create!(post: p3, tag: t3)
|
PostTag.create!(post: p3, tag: t3)
|
||||||
PostTag.create!(post: p1, tag: deprecated_tag)
|
|
||||||
PostTag.create!(post: p2, tag: deprecated_tag)
|
|
||||||
|
|
||||||
expect { run_rake_task("post_similarity:calc") }
|
expect { run_rake_task("post_similarity:calc") }
|
||||||
.to change { PostSimilarity.count }.from(0)
|
.to change { PostSimilarity.count }.from(0)
|
||||||
@@ -32,6 +29,6 @@ RSpec.describe 'post_similarity:calc' do
|
|||||||
ps = PostSimilarity.find_by!(post_id: p1.id, target_post_id: p2.id)
|
ps = PostSimilarity.find_by!(post_id: p1.id, target_post_id: p2.id)
|
||||||
ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id)
|
ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id)
|
||||||
expect(ps_rev.cos).to eq(ps.cos)
|
expect(ps_rev.cos).to eq(ps.cos)
|
||||||
expect(ps.cos).to be_within(0.0001).of(0.5)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ require 'rails_helper'
|
|||||||
RSpec.describe 'tag_similarity:calc' do
|
RSpec.describe 'tag_similarity:calc' do
|
||||||
include RakeTaskHelper
|
include RakeTaskHelper
|
||||||
|
|
||||||
it 'calculates similarities for active tags only' do
|
it 'calls Similarity::Calc with Tag and :posts' do
|
||||||
# 必要最低限のデータ
|
# 必要最低限のデータ
|
||||||
t1 = Tag.create!(name: "t1")
|
t1 = Tag.create!(name: "t1")
|
||||||
t2 = Tag.create!(name: "t2")
|
t2 = Tag.create!(name: "t2")
|
||||||
t3 = Tag.create!(name: "t3")
|
t3 = Tag.create!(name: "t3")
|
||||||
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
|
|
||||||
|
|
||||||
p1 = Post.create!(url: "https://example.com/1")
|
p1 = Post.create!(url: "https://example.com/1")
|
||||||
p2 = Post.create!(url: "https://example.com/2")
|
p2 = Post.create!(url: "https://example.com/2")
|
||||||
@@ -23,7 +22,6 @@ RSpec.describe 'tag_similarity:calc' do
|
|||||||
PostTag.create!(post: p2, tag: t3)
|
PostTag.create!(post: p2, tag: t3)
|
||||||
|
|
||||||
PostTag.create!(post: p3, tag: t3)
|
PostTag.create!(post: p3, tag: t3)
|
||||||
PostTag.create!(post: p1, tag: deprecated_tag)
|
|
||||||
|
|
||||||
expect { run_rake_task("tag_similarity:calc") }
|
expect { run_rake_task("tag_similarity:calc") }
|
||||||
.to change { TagSimilarity.count }.from(0)
|
.to change { TagSimilarity.count }.from(0)
|
||||||
@@ -31,7 +29,6 @@ RSpec.describe 'tag_similarity:calc' do
|
|||||||
ps = TagSimilarity.find_by!(tag_id: t1.id, target_tag_id: t2.id)
|
ps = TagSimilarity.find_by!(tag_id: t1.id, target_tag_id: t2.id)
|
||||||
ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id)
|
ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id)
|
||||||
expect(ps_rev.cos).to eq(ps.cos)
|
expect(ps_rev.cos).to eq(ps.cos)
|
||||||
expect(TagSimilarity.where(tag_id: deprecated_tag.id)).to be_empty
|
|
||||||
expect(TagSimilarity.where(target_tag_id: deprecated_tag.id)).to be_empty
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
@@ -9,35 +9,29 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { isApiError } from '@/lib/api'
|
import { isApiError } from '@/lib/api'
|
||||||
import { updatePost } from '@/lib/posts'
|
import { updatePost } from '@/lib/posts'
|
||||||
import { inputClass, msToTime } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
|
|
||||||
import type { Post, TagWithSections } from '@/types'
|
import type { Post, Tag } from '@/types'
|
||||||
|
|
||||||
type PostFormField =
|
type PostFormField =
|
||||||
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt'
|
'parentPostIds' | 'tags' | 'originalCreatedAt'
|
||||||
|
|
||||||
const videoMsToDurationValue = (videoMs: number | null): string =>
|
|
||||||
videoMs == null ? '' : String (videoMs / 1_000)
|
|
||||||
|
|
||||||
|
|
||||||
const tagsToStr = (tags: TagWithSections[]): string => {
|
const tagsToStr = (tags: Tag[]): string => {
|
||||||
const result: Omit<TagWithSections, 'children'>[] = []
|
const result: Tag[] = []
|
||||||
|
|
||||||
const walk = (tag: TagWithSections) => {
|
const walk = (tag: Tag) => {
|
||||||
const { children, ...rest } = tag
|
const { children, ...rest } = tag
|
||||||
result.push (rest)
|
result.push (rest)
|
||||||
children.forEach (walk)
|
children?.forEach (walk)
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.filter (t => t.category !== 'nico').forEach (walk)
|
tags.filter (t => t.category !== 'nico').forEach (walk)
|
||||||
|
|
||||||
return [...(new Set (result.map (t =>
|
return [...(new Set (result.map (t => t.name)))].join (' ')
|
||||||
`${ t.name }${ t.sections
|
|
||||||
.map (s => `[${ msToTime (s.beginMs) }-${ s.endMs == null ? '' : msToTime (s.endMs) }]`)
|
|
||||||
.join ('') }`)))].join (' ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +41,6 @@ type Props = { post: Post
|
|||||||
|
|
||||||
const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||||
const [disabled, setDisabled] = useState (false)
|
const [disabled, setDisabled] = useState (false)
|
||||||
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
|
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<PostFormField> ()
|
useValidationErrors<PostFormField> ()
|
||||||
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
const [originalCreatedBefore, setOriginalCreatedBefore] =
|
||||||
@@ -59,10 +52,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
const [tags, setTags] = useState<string> ('')
|
const [tags, setTags] = useState<string> ('')
|
||||||
const [title, setTitle] = useState (post.title)
|
const [title, setTitle] = useState (post.title)
|
||||||
|
|
||||||
const videoFlg =
|
|
||||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
|
||||||
[tags])
|
|
||||||
|
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
|
||||||
const update = async (...args: Parameters<typeof updatePost>) => {
|
const update = async (...args: Parameters<typeof updatePost>) => {
|
||||||
@@ -74,7 +63,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
onSave ({ ...post,
|
onSave ({ ...post,
|
||||||
versionNo: data.versionNo,
|
versionNo: data.versionNo,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
videoMs: data.videoMs,
|
|
||||||
tags: data.tags,
|
tags: data.tags,
|
||||||
parentPosts: data.parentPosts,
|
parentPosts: data.parentPosts,
|
||||||
childPosts: data.childPosts,
|
childPosts: data.childPosts,
|
||||||
@@ -114,7 +102,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
{
|
{
|
||||||
// TODO: 差分 UI
|
// TODO: 差分 UI
|
||||||
await update ({ id: post.id, title, tags, parentPostIds,
|
await update ({ id: post.id, title, tags, parentPostIds,
|
||||||
duration: videoFlg ? duration : null,
|
|
||||||
originalCreatedFrom, originalCreatedBefore },
|
originalCreatedFrom, originalCreatedBefore },
|
||||||
{ baseVersionNo: post.versionNo, merge: true })
|
{ baseVersionNo: post.versionNo, merge: true })
|
||||||
return
|
return
|
||||||
@@ -123,7 +110,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
if (action === 'overwrite')
|
if (action === 'overwrite')
|
||||||
{
|
{
|
||||||
await update ({ id: post.id, title, tags, parentPostIds,
|
await update ({ id: post.id, title, tags, parentPostIds,
|
||||||
duration: videoFlg ? duration : null,
|
|
||||||
originalCreatedFrom, originalCreatedBefore },
|
originalCreatedFrom, originalCreatedBefore },
|
||||||
{ baseVersionNo: post.versionNo, force: true })
|
{ baseVersionNo: post.versionNo, force: true })
|
||||||
return
|
return
|
||||||
@@ -138,7 +124,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await update ({ id: post.id, title, tags, parentPostIds,
|
await update ({ id: post.id, title, tags, parentPostIds,
|
||||||
duration: videoFlg ? duration : null,
|
|
||||||
originalCreatedFrom, originalCreatedBefore },
|
originalCreatedFrom, originalCreatedBefore },
|
||||||
{ baseVersionNo: post.versionNo })
|
{ baseVersionNo: post.versionNo })
|
||||||
}
|
}
|
||||||
@@ -149,15 +134,9 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setTags (tagsToStr (post.tags))
|
setTags(tagsToStr (post.tags))
|
||||||
setDuration (videoMsToDurationValue (post.videoMs))
|
|
||||||
}, [post])
|
}, [post])
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (!(videoFlg))
|
|
||||||
setDuration ('')
|
|
||||||
}, [videoFlg])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
|
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
|
||||||
<FieldError messages={baseErrors}/>
|
<FieldError messages={baseErrors}/>
|
||||||
@@ -170,7 +149,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={inputClass (invalid)}
|
className={inputClass (invalid)}
|
||||||
value={title ?? ''}
|
value={title ?? ''}
|
||||||
onChange={e => setTitle (e.target.value)}/>)}
|
onChange={ev => setTitle (ev.target.value)}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* 親投稿 */}
|
{/* 親投稿 */}
|
||||||
@@ -202,20 +181,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||||
errors={fieldErrors.originalCreatedAt}/>
|
errors={fieldErrors.originalCreatedAt}/>
|
||||||
|
|
||||||
{/* 動画時間 */}
|
|
||||||
{videoFlg && (
|
|
||||||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
|
||||||
{({ invalid }) => (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0.001"
|
|
||||||
step="0.001"
|
|
||||||
disabled={disabled}
|
|
||||||
className={inputClass (invalid)}
|
|
||||||
value={duration}
|
|
||||||
onChange={e => setDuration (e.target.value)}/>)}
|
|
||||||
</FormField>)}
|
|
||||||
|
|
||||||
{/* 送信 */}
|
{/* 送信 */}
|
||||||
<Button type="submit" disabled={disabled}>
|
<Button type="submit" disabled={disabled}>
|
||||||
更新
|
更新
|
||||||
@@ -223,5 +188,4 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
</form>)
|
</form>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default PostEditForm
|
export default PostEditForm
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ import { dateString, originalCreatedAtString } from '@/lib/utils'
|
|||||||
import type { DragEndEvent } from '@dnd-kit/core'
|
import type { DragEndEvent } from '@dnd-kit/core'
|
||||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||||
|
|
||||||
import type { Category, Post, TagWithSections } from '@/types'
|
import type { Category, Post, Tag } from '@/types'
|
||||||
|
|
||||||
type TagByCategory = { [key in Category]: TagWithSections[] }
|
type TagByCategory = { [key in Category]: Tag[] }
|
||||||
|
|
||||||
|
|
||||||
const renderTagTree = (
|
const renderTagTree = (
|
||||||
tag: TagWithSections,
|
tag: Tag,
|
||||||
nestLevel: number,
|
nestLevel: number,
|
||||||
path: string,
|
path: string,
|
||||||
suppressClickRef: MutableRefObject<boolean>,
|
suppressClickRef: MutableRefObject<boolean>,
|
||||||
@@ -63,7 +63,7 @@ const renderTagTree = (
|
|||||||
|
|
||||||
|
|
||||||
const isDescendant = (
|
const isDescendant = (
|
||||||
root: TagWithSections,
|
root: Tag,
|
||||||
targetId: number,
|
targetId: number,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!(root.children))
|
if (!(root.children))
|
||||||
@@ -84,8 +84,8 @@ const isDescendant = (
|
|||||||
const findTag = (
|
const findTag = (
|
||||||
byCat: TagByCategory,
|
byCat: TagByCategory,
|
||||||
id: number,
|
id: number,
|
||||||
): TagWithSections | undefined => {
|
): Tag | undefined => {
|
||||||
const walk = (nodes: TagWithSections[]): TagWithSections | undefined => {
|
const walk = (nodes: Tag[]): Tag | undefined => {
|
||||||
for (const t of nodes)
|
for (const t of nodes)
|
||||||
{
|
{
|
||||||
if (t.id === id)
|
if (t.id === id)
|
||||||
@@ -167,7 +167,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
|
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
|
||||||
tagsTmp[cat].sort ((tagA: TagWithSections, tagB: TagWithSections) => tagA.name < tagB.name ? -1 : 1)
|
tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1)
|
||||||
|
|
||||||
return tagsTmp
|
return tagsTmp
|
||||||
}, [post])
|
}, [post])
|
||||||
|
|||||||
@@ -18,21 +18,6 @@ describe ('TagLink', () => {
|
|||||||
expect (screen.getByText ('4')).toBeInTheDocument ()
|
expect (screen.getByText ('4')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('does not append deprecated state to the rendered tag name', () => {
|
|
||||||
renderWithProviders (
|
|
||||||
<TagLink
|
|
||||||
tag={buildTag ({
|
|
||||||
name: '旧タグ',
|
|
||||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
|
||||||
})}
|
|
||||||
withWiki={false}
|
|
||||||
withCount={false}/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect (screen.getByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
|
|
||||||
expect (screen.queryByText ('(廃止)')).not.toBeInTheDocument ()
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('links wiki markers to the correct detail route', () => {
|
it ('links wiki markers to the correct detail route', () => {
|
||||||
renderWithProviders (
|
renderWithProviders (
|
||||||
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
|
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { apiGet, apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
buildGekanatorQuestions,
|
buildGekanatorQuestions,
|
||||||
expectedAnswerForQuestion,
|
expectedAnswerForQuestion,
|
||||||
fetchGekanatorPosts,
|
|
||||||
fetchGekanatorQuestions,
|
|
||||||
learnedSemanticSideForPost,
|
learnedSemanticSideForPost,
|
||||||
questionIdForCondition,
|
questionIdForCondition,
|
||||||
restoreGekanatorQuestion,
|
restoreGekanatorQuestion,
|
||||||
@@ -26,7 +24,6 @@ vi.mock('@/lib/api', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const mockedApiPost = vi.mocked(apiPost)
|
const mockedApiPost = vi.mocked(apiPost)
|
||||||
const mockedApiGet = vi.mocked(apiGet)
|
|
||||||
|
|
||||||
const post = (overrides: Partial<Post> = {}): Post => ({
|
const post = (overrides: Partial<Post> = {}): Post => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -46,24 +43,6 @@ const post = (overrides: Partial<Post> = {}): Post => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Gekanator API functions', () => {
|
|
||||||
it('returns posts from the Gekanator posts endpoint', async () => {
|
|
||||||
const posts = [post()]
|
|
||||||
mockedApiGet.mockResolvedValueOnce({ posts })
|
|
||||||
|
|
||||||
await expect(fetchGekanatorPosts()).resolves.toEqual(posts)
|
|
||||||
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns questions from the Gekanator questions endpoint', async () => {
|
|
||||||
const questions: StoredGekanatorQuestion[] = []
|
|
||||||
mockedApiGet.mockResolvedValueOnce({ questions })
|
|
||||||
|
|
||||||
await expect(fetchGekanatorQuestions()).resolves.toEqual(questions)
|
|
||||||
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('expectedAnswerForQuestion', () => {
|
describe('expectedAnswerForQuestion', () => {
|
||||||
it('returns a direct example answer when present', () => {
|
it('returns a direct example answer when present', () => {
|
||||||
const question: StoredGekanatorQuestion = {
|
const question: StoredGekanatorQuestion = {
|
||||||
@@ -147,7 +126,6 @@ describe('expectedAnswerForQuestion', () => {
|
|||||||
postCount: 1,
|
postCount: 1,
|
||||||
createdAt: '2026-06-10T00:00:00.000Z',
|
createdAt: '2026-06-10T00:00:00.000Z',
|
||||||
updatedAt: '2026-06-10T00:00:00.000Z',
|
updatedAt: '2026-06-10T00:00:00.000Z',
|
||||||
deprecatedAt: null,
|
|
||||||
hasWiki: false,
|
hasWiki: false,
|
||||||
hasDeerjikists: false,
|
hasDeerjikists: false,
|
||||||
materialId: null,
|
materialId: null,
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const updatePost = async (
|
|||||||
post: { id: number
|
post: { id: number
|
||||||
title: string | null
|
title: string | null
|
||||||
tags: string
|
tags: string
|
||||||
duration: string | null
|
|
||||||
parentPostIds: string
|
parentPostIds: string
|
||||||
originalCreatedFrom: string | null
|
originalCreatedFrom: string | null
|
||||||
originalCreatedBefore: string | null },
|
originalCreatedBefore: string | null },
|
||||||
@@ -59,7 +58,6 @@ export const updatePost = async (
|
|||||||
`/posts/${ post.id }`,
|
`/posts/${ post.id }`,
|
||||||
{ title: post.title,
|
{ title: post.title,
|
||||||
tags: post.tags,
|
tags: post.tags,
|
||||||
duration: post.duration,
|
|
||||||
parent_post_ids: post.parentPostIds,
|
parent_post_ids: post.parentPostIds,
|
||||||
original_created_from: post.originalCreatedFrom,
|
original_created_from: post.originalCreatedFrom,
|
||||||
original_created_before: post.originalCreatedBefore },
|
original_created_before: post.originalCreatedBefore },
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ const mWiki = match<{ title: string }> ('/wiki/:title')
|
|||||||
const mTag = match<{ id: string }> ('/tags/:id')
|
const mTag = match<{ id: string }> ('/tags/:id')
|
||||||
|
|
||||||
|
|
||||||
const boolFromQuery = (value: string | null): boolean =>
|
|
||||||
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
|
||||||
|
|
||||||
|
|
||||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
||||||
const title = url.searchParams.get ('title') ?? ''
|
const title = url.searchParams.get ('title') ?? ''
|
||||||
|
|
||||||
@@ -160,16 +156,13 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
|
|||||||
const createdTo = url.searchParams.get ('created_to') ?? ''
|
const createdTo = url.searchParams.get ('created_to') ?? ''
|
||||||
const updatedFrom = url.searchParams.get ('updated_from') ?? ''
|
const updatedFrom = url.searchParams.get ('updated_from') ?? ''
|
||||||
const updatedTo = url.searchParams.get ('updated_to') ?? ''
|
const updatedTo = url.searchParams.get ('updated_to') ?? ''
|
||||||
const deprecated = url.searchParams.has ('deprecated')
|
|
||||||
? boolFromQuery (url.searchParams.get ('deprecated'))
|
|
||||||
: null
|
|
||||||
const page = Number (url.searchParams.get ('page') || 1)
|
const page = Number (url.searchParams.get ('page') || 1)
|
||||||
const limit = Number (url.searchParams.get ('limit') || 20)
|
const limit = Number (url.searchParams.get ('limit') || 20)
|
||||||
const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder
|
const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder
|
||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||||
updatedFrom, updatedTo, deprecated, page, limit, order }
|
updatedFrom, updatedTo, page, limit, order }
|
||||||
|
|
||||||
await qc.prefetchQuery ({
|
await qc.prefetchQuery ({
|
||||||
queryKey: tagsKeys.index (keys),
|
queryKey: tagsKeys.index (keys),
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const baseParams: FetchTagsParams = {
|
|||||||
createdTo: '',
|
createdTo: '',
|
||||||
updatedFrom: '',
|
updatedFrom: '',
|
||||||
updatedTo: '',
|
updatedTo: '',
|
||||||
deprecated: null,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
order: 'updated_at:desc',
|
order: 'updated_at:desc',
|
||||||
@@ -58,20 +57,6 @@ describe ('tags API functions', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each ([
|
|
||||||
[true, '1'],
|
|
||||||
[false, '0'],
|
|
||||||
] as const) ('maps deprecated=%s to %s', async (deprecated, expected) => {
|
|
||||||
api.apiGet.mockResolvedValueOnce ({ tags: [], count: 0 })
|
|
||||||
|
|
||||||
await fetchTags ({ ...baseParams, deprecated })
|
|
||||||
|
|
||||||
expect (api.apiGet).toHaveBeenCalledWith (
|
|
||||||
'/tags',
|
|
||||||
{ params: expect.objectContaining ({ deprecated: expected }) },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('returns null when tag fetches fail', async () => {
|
it ('returns null when tag fetches fail', async () => {
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import type { Deerjikist,
|
|||||||
|
|
||||||
export const fetchTags = async (
|
export const fetchTags = async (
|
||||||
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||||
updatedFrom, updatedTo, deprecated,
|
updatedFrom, updatedTo, page, limit, order }: FetchTagsParams,
|
||||||
page, limit, order }: FetchTagsParams,
|
|
||||||
): Promise<{ tags: Tag[]
|
): Promise<{ tags: Tag[]
|
||||||
count: number }> =>
|
count: number }> =>
|
||||||
await apiGet ('/tags', { params: {
|
await apiGet ('/tags', { params: {
|
||||||
@@ -24,7 +23,6 @@ export const fetchTags = async (
|
|||||||
...(createdTo && { created_to: createdTo }),
|
...(createdTo && { created_to: createdTo }),
|
||||||
...(updatedFrom && { updated_from: updatedFrom }),
|
...(updatedFrom && { updated_from: updatedFrom }),
|
||||||
...(updatedTo && { updated_to: updatedTo }),
|
...(updatedTo && { updated_to: updatedTo }),
|
||||||
...(deprecated != null && { deprecated: deprecated ? '1' : '0' }),
|
|
||||||
...(page && { page }),
|
...(page && { page }),
|
||||||
...(limit && { limit }),
|
...(limit && { limit }),
|
||||||
...(order && { order }) } })
|
...(order && { order }) } })
|
||||||
@@ -66,6 +64,7 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchTagChanges = async (
|
export const fetchTagChanges = async (
|
||||||
{ id, page, limit }: {
|
{ id, page, limit }: {
|
||||||
id?: string
|
id?: string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { cn, msToTime, originalCreatedAtString, toDate } from '@/lib/utils'
|
import { cn, originalCreatedAtString, toDate } from '@/lib/utils'
|
||||||
|
|
||||||
describe ('utils', () => {
|
describe ('utils', () => {
|
||||||
it ('converts strings to dates and leaves date instances intact', () => {
|
it ('converts strings to dates and leaves date instances intact', () => {
|
||||||
@@ -26,14 +26,3 @@ describe ('utils', () => {
|
|||||||
).toContain ('時刻不詳')
|
).toContain ('時刻不詳')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe ('msToTime', () => {
|
|
||||||
it ('keeps milliseconds when present', () => {
|
|
||||||
expect (msToTime (60_500)).toBe ('1:00.500')
|
|
||||||
expect (msToTime (60_001)).toBe ('1:00.001')
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('omits milliseconds when they are zero', () => {
|
|
||||||
expect (msToTime (60_000)).toBe ('1:00')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -73,22 +73,6 @@ export const originalCreatedAtString = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const msToTime = (ms: number): string => {
|
|
||||||
const totalS = Math.trunc (ms / 1_000)
|
|
||||||
const s = String (totalS % 60)
|
|
||||||
const min = String (Math.trunc (totalS / 60) % 60)
|
|
||||||
const h = Math.trunc (totalS / 3_600)
|
|
||||||
const remainderMs = ms % 1_000
|
|
||||||
|
|
||||||
const base =
|
|
||||||
(h > 0
|
|
||||||
? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }`
|
|
||||||
: `${ min }:${ s.padStart (2, '0') }`)
|
|
||||||
|
|
||||||
return remainderMs > 0 ? `${ base }.${ remainderMs.toString ().padStart (3, '0') }` : base
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const inputClass = (invalid?: boolean, className?: string): string =>
|
export const inputClass = (invalid?: boolean, className?: string): string =>
|
||||||
cn ('w-full rounded border p-2',
|
cn ('w-full rounded border p-2',
|
||||||
(invalid
|
(invalid
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
@@ -79,11 +78,6 @@ const DeerjikistDetailPage: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
|
||||||
<meta name="robots" content="noindex"/>
|
|
||||||
{tag && <title>{tag.name} | ニジラー情報</title>}
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
{(loading || !(tag)) ? 'Loading...' : (
|
{(loading || !(tag)) ? 'Loading...' : (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<PageTitle>
|
<PageTitle>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { SITE_TITLE } from '@/config'
|
|||||||
import { fetchPostChanges, updatePost } from '@/lib/posts'
|
import { fetchPostChanges, updatePost } from '@/lib/posts'
|
||||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||||
import { fetchTag } from '@/lib/tags'
|
import { fetchTag } from '@/lib/tags'
|
||||||
import { cn, dateString, msToTime, originalCreatedAtString } from '@/lib/utils'
|
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, MouseEvent } from 'react'
|
import type { FC, MouseEvent } from 'react'
|
||||||
|
|
||||||
@@ -91,13 +91,9 @@ const PostHistoryPage: FC = () => {
|
|||||||
.filter (p => p.type !== 'removed')
|
.filter (p => p.type !== 'removed')
|
||||||
.map (p => p.id)
|
.map (p => p.id)
|
||||||
.join (' ')
|
.join (' ')
|
||||||
const duration =
|
|
||||||
change.videoMs.current == null
|
|
||||||
? null
|
|
||||||
: msToTime (change.videoMs.current)
|
|
||||||
const originalCreatedFrom = change.originalCreatedFrom.current
|
const originalCreatedFrom = change.originalCreatedFrom.current
|
||||||
const originalCreatedBefore = change.originalCreatedBefore.current
|
const originalCreatedBefore = change.originalCreatedBefore.current
|
||||||
await updatePost ({ id, title, tags, duration, parentPostIds,
|
await updatePost ({ id, title, tags, parentPostIds,
|
||||||
originalCreatedFrom, originalCreatedBefore },
|
originalCreatedFrom, originalCreatedBefore },
|
||||||
{ force: true })
|
{ force: true })
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ import type { User } from '@/types'
|
|||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
type PostFormField =
|
type PostFormField =
|
||||||
'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail'
|
'url' | 'title' | 'tags' | 'parentPostIds' | 'originalCreatedAt' | 'thumbnail'
|
||||||
|
|
||||||
|
|
||||||
const PostNewPage: FC<Props> = ({ user }) => {
|
const PostNewPage: FC<Props> = ({ user }) => {
|
||||||
@@ -40,7 +40,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
||||||
const [parentPostIds, setParentPostIds] = useState ('')
|
const [parentPostIds, setParentPostIds] = useState ('')
|
||||||
const [tags, setTags] = useState ('')
|
const [tags, setTags] = useState ('')
|
||||||
const [duration, setDuration] = useState ('')
|
|
||||||
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
|
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
|
||||||
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
|
||||||
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
const [thumbnailLoading, setThumbnailLoading] = useState (false)
|
||||||
@@ -52,9 +51,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const previousURLRef = useRef ('')
|
const previousURLRef = useRef ('')
|
||||||
const thumbnailPreviewRef = useRef ('')
|
const thumbnailPreviewRef = useRef ('')
|
||||||
const videoFlg =
|
|
||||||
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
|
|
||||||
[tags])
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
clearValidationErrors ()
|
clearValidationErrors ()
|
||||||
@@ -64,8 +60,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
formData.append ('url', url)
|
formData.append ('url', url)
|
||||||
formData.append ('tags', tags)
|
formData.append ('tags', tags)
|
||||||
formData.append ('parent_post_ids', parentPostIds)
|
formData.append ('parent_post_ids', parentPostIds)
|
||||||
if (videoFlg && duration !== '')
|
|
||||||
formData.append ('duration', duration)
|
|
||||||
if (thumbnailFile)
|
if (thumbnailFile)
|
||||||
formData.append ('thumbnail', thumbnailFile)
|
formData.append ('thumbnail', thumbnailFile)
|
||||||
if (originalCreatedFrom)
|
if (originalCreatedFrom)
|
||||||
@@ -135,11 +129,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
fetchThumbnail ()
|
fetchThumbnail ()
|
||||||
}, [fetchThumbnail, thumbnailAutoFlg, url])
|
}, [fetchThumbnail, thumbnailAutoFlg, url])
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (!(videoFlg))
|
|
||||||
setDuration ('')
|
|
||||||
}, [videoFlg])
|
|
||||||
|
|
||||||
if (!(editable))
|
if (!(editable))
|
||||||
return <Forbidden/>
|
return <Forbidden/>
|
||||||
|
|
||||||
@@ -244,20 +233,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
|
|||||||
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
setOriginalCreatedBefore={setOriginalCreatedBefore}
|
||||||
errors={fieldErrors.originalCreatedAt}/>
|
errors={fieldErrors.originalCreatedAt}/>
|
||||||
|
|
||||||
{/* 動画時間 */}
|
|
||||||
{(videoFlg &&
|
|
||||||
<FormField label="動画時間" messages={fieldErrors.videoMs}>
|
|
||||||
{({ invalid }) => (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0.001"
|
|
||||||
step="0.001"
|
|
||||||
value={duration}
|
|
||||||
onChange={e => setDuration (e.target.value)}
|
|
||||||
aria-invalid={invalid}
|
|
||||||
className={inputClass (invalid)}/>)}
|
|
||||||
</FormField>)}
|
|
||||||
|
|
||||||
{/* 送信 */}
|
{/* 送信 */}
|
||||||
<Button onClick={handleSubmit}
|
<Button onClick={handleSubmit}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
||||||
|
|||||||
@@ -19,12 +19,7 @@ import type { FC, FormEvent } from 'react'
|
|||||||
|
|
||||||
import type { Category, Tag } from '@/types'
|
import type { Category, Tag } from '@/types'
|
||||||
|
|
||||||
type TagFormField =
|
type TagFormField = 'name' | 'category' | 'aliases' | 'parentTags'
|
||||||
| 'name'
|
|
||||||
| 'category'
|
|
||||||
| 'aliases'
|
|
||||||
| 'parentTags'
|
|
||||||
| 'deprecated'
|
|
||||||
|
|
||||||
|
|
||||||
const TagDetailPage: FC = () => {
|
const TagDetailPage: FC = () => {
|
||||||
@@ -40,7 +35,6 @@ const TagDetailPage: FC = () => {
|
|||||||
const [category, setCategory] = useState<Category> ('general')
|
const [category, setCategory] = useState<Category> ('general')
|
||||||
const [aliases, setAliases] = useState ('')
|
const [aliases, setAliases] = useState ('')
|
||||||
const [parentTags, setParentTags] = useState ('')
|
const [parentTags, setParentTags] = useState ('')
|
||||||
const [deprecated, setDeprecated] = useState (false)
|
|
||||||
const [disabled, setDisabled] = useState (true)
|
const [disabled, setDisabled] = useState (true)
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<TagFormField> ()
|
useValidationErrors<TagFormField> ()
|
||||||
@@ -56,7 +50,6 @@ const TagDetailPage: FC = () => {
|
|||||||
formData.append ('category', category)
|
formData.append ('category', category)
|
||||||
formData.append ('aliases', aliases)
|
formData.append ('aliases', aliases)
|
||||||
formData.append ('parent_tags', parentTags)
|
formData.append ('parent_tags', parentTags)
|
||||||
formData.append ('deprecated', deprecated ? '1' : '0')
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -66,7 +59,6 @@ const TagDetailPage: FC = () => {
|
|||||||
setCategory (data.category as Category)
|
setCategory (data.category as Category)
|
||||||
setAliases (data.aliases.join (' '))
|
setAliases (data.aliases.join (' '))
|
||||||
setParentTags (data.parents.map (t => t.name).join (' '))
|
setParentTags (data.parents.map (t => t.name).join (' '))
|
||||||
setDeprecated (Boolean (data.deprecatedAt))
|
|
||||||
|
|
||||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||||
@@ -90,7 +82,6 @@ const TagDetailPage: FC = () => {
|
|||||||
setCategory (tag.category as Category)
|
setCategory (tag.category as Category)
|
||||||
setAliases (tag.aliases.join (' '))
|
setAliases (tag.aliases.join (' '))
|
||||||
setParentTags (tag.parents.map (t => t.name).join (' '))
|
setParentTags (tag.parents.map (t => t.name).join (' '))
|
||||||
setDeprecated (Boolean (tag.deprecatedAt))
|
|
||||||
setDisabled (tag.category === 'nico')
|
setDisabled (tag.category === 'nico')
|
||||||
}, [tag])
|
}, [tag])
|
||||||
|
|
||||||
@@ -174,17 +165,6 @@ const TagDetailPage: FC = () => {
|
|||||||
</>)}
|
</>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="廃止済" messages={fieldErrors.deprecated}>
|
|
||||||
{({ describedBy, invalid }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
disabled={disabled}
|
|
||||||
checked={deprecated}
|
|
||||||
onChange={e => setDeprecated (e.target.checked)}
|
|
||||||
aria-describedby={describedBy}
|
|
||||||
aria-invalid={invalid}/>)}
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -20,28 +20,17 @@ import type { FC } from 'react'
|
|||||||
|
|
||||||
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
|
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
|
||||||
<>
|
<>
|
||||||
{diff.prev !== diff.current
|
{(diff.prev && diff.prev !== diff.current) && (
|
||||||
? (
|
|
||||||
<>
|
<>
|
||||||
<del className="text-red-600 dark:text-red-400">
|
<del className="text-red-600 dark:text-red-400">
|
||||||
{diff.prev && <>{diff.prev}<br/></>}
|
{diff.prev}
|
||||||
</del>
|
</del>
|
||||||
<ins className="text-green-600 dark:text-green-400">
|
{diff.current && <br/>}
|
||||||
{diff.current}
|
</>)}
|
||||||
</ins>
|
{diff.current}
|
||||||
</>)
|
|
||||||
: diff.current}
|
|
||||||
</>)
|
</>)
|
||||||
|
|
||||||
|
|
||||||
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
|
|
||||||
|
|
||||||
|
|
||||||
const renderStateDiff = (diff: { current: string | null; prev: string | null }) =>
|
|
||||||
renderDiff ({ current: tagStateLabel (diff.current),
|
|
||||||
prev: tagStateLabel (diff.prev) })
|
|
||||||
|
|
||||||
|
|
||||||
const TagHistoryPage: FC = () => {
|
const TagHistoryPage: FC = () => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
@@ -83,8 +72,6 @@ const TagHistoryPage: FC = () => {
|
|||||||
<col className="w-96"/>
|
<col className="w-96"/>
|
||||||
{/* カテゴリ */}
|
{/* カテゴリ */}
|
||||||
<col className="w-96"/>
|
<col className="w-96"/>
|
||||||
{/* 状態 */}
|
|
||||||
<col className="w-32"/>
|
|
||||||
{/* 別名 */}
|
{/* 別名 */}
|
||||||
<col className="w-[48rem]"/>
|
<col className="w-[48rem]"/>
|
||||||
{/* 上位タグ */}
|
{/* 上位タグ */}
|
||||||
@@ -100,7 +87,6 @@ const TagHistoryPage: FC = () => {
|
|||||||
<th className="p-2 text-left">版</th>
|
<th className="p-2 text-left">版</th>
|
||||||
<th className="p-2 text-left">名称</th>
|
<th className="p-2 text-left">名称</th>
|
||||||
<th className="p-2 text-left">カテゴリ</th>
|
<th className="p-2 text-left">カテゴリ</th>
|
||||||
<th className="p-2 text-left">状態</th>
|
|
||||||
<th className="p-2 text-left">別名</th>
|
<th className="p-2 text-left">別名</th>
|
||||||
<th className="p-2 text-left">上位タグ</th>
|
<th className="p-2 text-left">上位タグ</th>
|
||||||
<th className="p-2 text-left">更新日時</th>
|
<th className="p-2 text-left">更新日時</th>
|
||||||
@@ -120,9 +106,6 @@ const TagHistoryPage: FC = () => {
|
|||||||
prev: (change.category.prev
|
prev: (change.category.prev
|
||||||
&& CATEGORY_NAMES[change.category.prev]) })}
|
&& CATEGORY_NAMES[change.category.prev]) })}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 break-all">
|
|
||||||
{renderStateDiff (change.deprecatedAt)}
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{change.aliases.map ((tag, i) => (
|
{change.aliases.map ((tag, i) => (
|
||||||
tag.type === 'added'
|
tag.type === 'added'
|
||||||
@@ -195,7 +178,6 @@ const TagHistoryPage: FC = () => {
|
|||||||
`/tags/${ change.tagId }`,
|
`/tags/${ change.tagId }`,
|
||||||
{ name: change.name.current,
|
{ name: change.name.current,
|
||||||
category: change.category.current,
|
category: change.category.current,
|
||||||
deprecated: change.deprecatedAt.current ? '1' : '0',
|
|
||||||
aliases:
|
aliases:
|
||||||
change.aliases
|
change.aliases
|
||||||
.filter (t => t.type !== 'removed')
|
.filter (t => t.type !== 'removed')
|
||||||
@@ -229,5 +211,4 @@ const TagHistoryPage: FC = () => {
|
|||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default TagHistoryPage
|
export default TagHistoryPage
|
||||||
@@ -14,22 +14,13 @@ vi.mock ('@/lib/tags', () => tagsApi)
|
|||||||
describe ('TagListPage', () => {
|
describe ('TagListPage', () => {
|
||||||
it ('loads tags from URL filters and renders the results table', async () => {
|
it ('loads tags from URL filters and renders the results table', async () => {
|
||||||
tagsApi.fetchTags.mockResolvedValueOnce ({
|
tagsApi.fetchTags.mockResolvedValueOnce ({
|
||||||
tags: [buildTag ({
|
tags: [buildTag ({ id: 7, name: '虹夏', category: 'character', postCount: 99 })],
|
||||||
id: 7,
|
|
||||||
name: '虹夏',
|
|
||||||
category: 'character',
|
|
||||||
postCount: 99,
|
|
||||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
|
||||||
})],
|
|
||||||
count: 1,
|
count: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
renderWithProviders (
|
renderWithProviders (
|
||||||
<TagListPage/>,
|
<TagListPage/>,
|
||||||
{
|
{ route: '/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5' },
|
||||||
route:
|
|
||||||
'/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5&deprecated=1',
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
@@ -39,7 +30,6 @@ describe ('TagListPage', () => {
|
|||||||
category: 'character',
|
category: 'character',
|
||||||
page: 3,
|
page: 3,
|
||||||
postCountGTE: 5,
|
postCountGTE: 5,
|
||||||
deprecated: true,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -48,8 +38,6 @@ describe ('TagListPage', () => {
|
|||||||
'/tags/7',
|
'/tags/7',
|
||||||
)
|
)
|
||||||
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
|
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
|
||||||
expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
|
|
||||||
expect (screen.getAllByText ('廃止')).toHaveLength (2)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('navigates to a normalized search URL on submit', async () => {
|
it ('navigates to a normalized search URL on submit', async () => {
|
||||||
@@ -58,9 +46,7 @@ describe ('TagListPage', () => {
|
|||||||
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
||||||
|
|
||||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
|
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
|
||||||
fireEvent.change (screen.getAllByRole ('combobox')[0], {
|
fireEvent.change (screen.getByRole ('combobox'), { target: { value: 'character' } })
|
||||||
target: { value: 'character' },
|
|
||||||
})
|
|
||||||
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
|
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const boolFromQuery = (value: string | null): boolean =>
|
|
||||||
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
|
||||||
|
|
||||||
|
|
||||||
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
|
|
||||||
|
|
||||||
|
|
||||||
const TagListPage: FC = () => {
|
const TagListPage: FC = () => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
|
||||||
@@ -55,9 +48,6 @@ const TagListPage: FC = () => {
|
|||||||
const qCreatedTo = query.get ('created_to') ?? ''
|
const qCreatedTo = query.get ('created_to') ?? ''
|
||||||
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
||||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
const qUpdatedTo = query.get ('updated_to') ?? ''
|
||||||
const qDeprecated = query.has ('deprecated')
|
|
||||||
? boolFromQuery (query.get ('deprecated'))
|
|
||||||
: null
|
|
||||||
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
|
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
|
||||||
|
|
||||||
const [name, setName] = useState ('')
|
const [name, setName] = useState ('')
|
||||||
@@ -68,7 +58,6 @@ const TagListPage: FC = () => {
|
|||||||
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
||||||
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
||||||
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
|
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
|
||||||
const [deprecated, setDeprecated] = useState<boolean | null> (null)
|
|
||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
page, limit, order,
|
page, limit, order,
|
||||||
@@ -80,8 +69,7 @@ const TagListPage: FC = () => {
|
|||||||
createdFrom: qCreatedFrom,
|
createdFrom: qCreatedFrom,
|
||||||
createdTo: qCreatedTo,
|
createdTo: qCreatedTo,
|
||||||
updatedFrom: qUpdatedFrom,
|
updatedFrom: qUpdatedFrom,
|
||||||
updatedTo: qUpdatedTo,
|
updatedTo: qUpdatedTo }
|
||||||
deprecated: qDeprecated }
|
|
||||||
const { data, isLoading: loading } = useQuery ({
|
const { data, isLoading: loading } = useQuery ({
|
||||||
queryKey: tagsKeys.index (keys),
|
queryKey: tagsKeys.index (keys),
|
||||||
queryFn: () => fetchTags (keys) })
|
queryFn: () => fetchTags (keys) })
|
||||||
@@ -97,11 +85,10 @@ const TagListPage: FC = () => {
|
|||||||
setCreatedTo (qCreatedTo)
|
setCreatedTo (qCreatedTo)
|
||||||
setUpdatedFrom (qUpdatedFrom)
|
setUpdatedFrom (qUpdatedFrom)
|
||||||
setUpdatedTo (qUpdatedTo)
|
setUpdatedTo (qUpdatedTo)
|
||||||
setDeprecated (qDeprecated)
|
|
||||||
|
|
||||||
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
||||||
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
|
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
|
||||||
qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated])
|
qPostCountLTE, qUpdatedFrom, qUpdatedTo])
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
@@ -117,8 +104,6 @@ const TagListPage: FC = () => {
|
|||||||
setIf (qs, 'created_to', createdTo)
|
setIf (qs, 'created_to', createdTo)
|
||||||
setIf (qs, 'updated_from', updatedFrom)
|
setIf (qs, 'updated_from', updatedFrom)
|
||||||
setIf (qs, 'updated_to', updatedTo)
|
setIf (qs, 'updated_to', updatedTo)
|
||||||
if (deprecated != null)
|
|
||||||
qs.set ('deprecated', deprecated ? '1' : '0')
|
|
||||||
qs.set ('page', '1')
|
qs.set ('page', '1')
|
||||||
qs.set ('order', order)
|
qs.set ('order', order)
|
||||||
|
|
||||||
@@ -216,21 +201,6 @@ const TagListPage: FC = () => {
|
|||||||
</>)}
|
</>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="状態">
|
|
||||||
{({ invalid }) => (
|
|
||||||
<select
|
|
||||||
value={deprecated == null ? '' : (deprecated ? '1' : '0')}
|
|
||||||
onChange={e => setDeprecated (
|
|
||||||
e.target.value === ''
|
|
||||||
? null
|
|
||||||
: e.target.value === '1')}
|
|
||||||
className={inputClass (invalid)}>
|
|
||||||
<option value=""> </option>
|
|
||||||
<option value="0">有効</option>
|
|
||||||
<option value="1">廃止</option>
|
|
||||||
</select>)}
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -249,7 +219,6 @@ const TagListPage: FC = () => {
|
|||||||
<col className="w-72"/>
|
<col className="w-72"/>
|
||||||
<col className="w-16"/>
|
<col className="w-16"/>
|
||||||
<col className="w-48"/>
|
<col className="w-48"/>
|
||||||
<col className="w-32"/>
|
|
||||||
<col className="w-72"/>
|
<col className="w-72"/>
|
||||||
<col className="w-48"/>
|
<col className="w-48"/>
|
||||||
<col className="w-56"/>
|
<col className="w-56"/>
|
||||||
@@ -280,7 +249,6 @@ const TagListPage: FC = () => {
|
|||||||
currentOrder={order}
|
currentOrder={order}
|
||||||
defaultDirection={defaultDirection}/>
|
defaultDirection={defaultDirection}/>
|
||||||
</th>
|
</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">状態</th>
|
|
||||||
<th className="p-2 text-left whitespace-nowrap">別名</th>
|
<th className="p-2 text-left whitespace-nowrap">別名</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">上位タグ</th>
|
<th className="p-2 text-left whitespace-nowrap">上位タグ</th>
|
||||||
<th className="p-2 text-left whitespace-nowrap">
|
<th className="p-2 text-left whitespace-nowrap">
|
||||||
@@ -312,7 +280,6 @@ const TagListPage: FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-2 text-right">{row.postCount}</td>
|
<td className="p-2 text-right">{row.postCount}</td>
|
||||||
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
|
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
|
||||||
<td className="p-2">{tagStateLabel (row.deprecatedAt)}</td>
|
|
||||||
<td className="p-2">{row.aliases.join (' ')}</td>
|
<td className="p-2">{row.aliases.join (' ')}</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{row.parents.map (t => (
|
{row.parents.map (t => (
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { screen } from '@testing-library/react'
|
|
||||||
import { Route, Routes } from 'react-router-dom'
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
|
||||||
import { buildTag, buildWikiPage } from '@/test/factories'
|
|
||||||
import { renderWithProviders } from '@/test/render'
|
|
||||||
|
|
||||||
const wikiApi = vi.hoisted (() => ({
|
|
||||||
fetchWikiPage: vi.fn (),
|
|
||||||
fetchWikiPageByTitle: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const tagsApi = vi.hoisted (() => ({
|
|
||||||
fetchTagByName: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const postsApi = vi.hoisted (() => ({
|
|
||||||
fetchPosts: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock ('@/lib/wiki', () => wikiApi)
|
|
||||||
vi.mock ('@/lib/tags', () => tagsApi)
|
|
||||||
vi.mock ('@/lib/posts', () => postsApi)
|
|
||||||
|
|
||||||
describe ('WikiDetailPage', () => {
|
|
||||||
it ('renders deprecated state outside the wiki title link', async () => {
|
|
||||||
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
|
|
||||||
title: '旧タグ',
|
|
||||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
|
||||||
}))
|
|
||||||
tagsApi.fetchTagByName.mockResolvedValueOnce (buildTag ({
|
|
||||||
name: '旧タグ',
|
|
||||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
|
||||||
}))
|
|
||||||
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
|
|
||||||
|
|
||||||
renderWithProviders (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
|
||||||
</Routes>,
|
|
||||||
{ route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
|
|
||||||
)
|
|
||||||
|
|
||||||
const marker = await screen.findByText ('(廃止)')
|
|
||||||
const heading = marker.closest ('h1')
|
|
||||||
const link = screen.getByRole ('link', { name: '旧タグ' })
|
|
||||||
|
|
||||||
expect (heading).not.toBeNull ()
|
|
||||||
expect (heading!).toHaveTextContent ('旧タグ(廃止)')
|
|
||||||
expect (link).toBeInTheDocument ()
|
|
||||||
expect (marker.closest ('a')).toBeNull ()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -39,7 +39,6 @@ const WikiDetailPage: FC = () => {
|
|||||||
queryFn: () => fetchWikiPageByTitle (title, { version }) })
|
queryFn: () => fetchWikiPageByTitle (title, { version }) })
|
||||||
|
|
||||||
const effectiveTitle = wikiPage?.title ?? title
|
const effectiveTitle = wikiPage?.title ?? title
|
||||||
const deprecated = wikiPage?.deprecatedAt != null
|
|
||||||
|
|
||||||
const { data: tag } = useQuery ({
|
const { data: tag } = useQuery ({
|
||||||
enabled: Boolean (effectiveTitle),
|
enabled: Boolean (effectiveTitle),
|
||||||
@@ -89,7 +88,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`${ effectiveTitle }${ deprecated ? '(廃止)' : '' } Wiki | ${ SITE_TITLE }`}</title>
|
<title>{`${ title } Wiki | ${ SITE_TITLE }`}</title>
|
||||||
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
|
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
@@ -111,13 +110,10 @@ const WikiDetailPage: FC = () => {
|
|||||||
|
|
||||||
<article className="prose dark:prose-invert mx-auto p-4">
|
<article className="prose dark:prose-invert mx-auto p-4">
|
||||||
<h1 className="prose-a:no-underline">
|
<h1 className="prose-a:no-underline">
|
||||||
<TagLink tag={tag ?? { ...defaultTag,
|
<TagLink tag={tag ?? defaultTag}
|
||||||
name: effectiveTitle,
|
|
||||||
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
|
|
||||||
withWiki={false}
|
withWiki={false}
|
||||||
withCount={false}
|
withCount={false}
|
||||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||||
{deprecated && <span>(廃止)</span>}
|
|
||||||
</h1>
|
</h1>
|
||||||
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ describe ('WikiDiffPage', () => {
|
|||||||
api.apiGet.mockResolvedValueOnce ({
|
api.apiGet.mockResolvedValueOnce ({
|
||||||
wikiPageId: 3,
|
wikiPageId: 3,
|
||||||
title: '差分対象',
|
title: '差分対象',
|
||||||
deprecatedAt: null,
|
|
||||||
olderRevisionId: 1,
|
olderRevisionId: 1,
|
||||||
newerRevisionId: 2,
|
newerRevisionId: 2,
|
||||||
diff: [
|
diff: [
|
||||||
@@ -44,26 +43,4 @@ describe ('WikiDiffPage', () => {
|
|||||||
expect (screen.getByText ('added line')).toBeInTheDocument ()
|
expect (screen.getByText ('added line')).toBeInTheDocument ()
|
||||||
expect (screen.getByText ('removed line')).toBeInTheDocument ()
|
expect (screen.getByText ('removed line')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('appends deprecated state to the wiki title', async () => {
|
|
||||||
api.apiGet.mockResolvedValueOnce ({
|
|
||||||
wikiPageId: 3,
|
|
||||||
title: '廃止 Wiki',
|
|
||||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
|
||||||
olderRevisionId: 1,
|
|
||||||
newerRevisionId: 2,
|
|
||||||
diff: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
renderWithProviders (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
|
||||||
</Routes>,
|
|
||||||
{ route: '/wiki/3/diff?from=1&to=2' },
|
|
||||||
)
|
|
||||||
|
|
||||||
expect (await screen.findByRole ('heading', {
|
|
||||||
name: '廃止 Wiki(廃止)',
|
|
||||||
})).toBeInTheDocument ()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ const WikiDiffPage: FC = () => {
|
|||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const from = query.get ('from')
|
const from = query.get ('from')
|
||||||
const to = query.get ('to')
|
const to = query.get ('to')
|
||||||
const displayTitle = diff
|
|
||||||
? `${ diff.title }${ diff.deprecatedAt != null ? '(廃止)' : '' }`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -36,9 +33,9 @@ const WikiDiffPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`Wiki 差分: ${ displayTitle } | ${ SITE_TITLE }`}</title>
|
<title>{`Wiki 差分: ${ diff?.title } | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<PageTitle>{displayTitle}</PageTitle>
|
<PageTitle>{diff?.title}</PageTitle>
|
||||||
<div className="prose mx-auto p-4">
|
<div className="prose mx-auto p-4">
|
||||||
{diff
|
{diff
|
||||||
? (
|
? (
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
|
|
||||||
import { renderWithProviders } from '@/test/render'
|
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
|
||||||
apiGet: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock ('@/lib/api', () => api)
|
|
||||||
|
|
||||||
describe ('WikiHistoryPage', () => {
|
|
||||||
it ('renders deprecated state outside the wiki title link', async () => {
|
|
||||||
api.apiGet.mockResolvedValueOnce ([{
|
|
||||||
revisionId: 2,
|
|
||||||
pred: 1,
|
|
||||||
succ: null,
|
|
||||||
wikiPage: {
|
|
||||||
id: 3,
|
|
||||||
title: '旧タグ',
|
|
||||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
user: { id: 4, name: 'tester' },
|
|
||||||
kind: 'content',
|
|
||||||
message: 'updated',
|
|
||||||
timestamp: '2026-06-02T00:00:00.000Z',
|
|
||||||
}])
|
|
||||||
|
|
||||||
renderWithProviders (<WikiHistoryPage/>)
|
|
||||||
|
|
||||||
const link = await screen.findByRole ('link', { name: '旧タグ' })
|
|
||||||
const marker = screen.getByText ('(廃止)')
|
|
||||||
|
|
||||||
expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2')
|
|
||||||
expect (marker.closest ('a')).toBeNull ()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -59,7 +59,6 @@ const WikiHistoryPage: FC = () => {
|
|||||||
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
|
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
|
||||||
{change.wikiPage.title}
|
{change.wikiPage.title}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
{change.wikiPage.deprecatedAt != null && <span>(廃止)</span>}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{change.pred == null ? '新規' : '更新'}
|
{change.pred == null ? '新規' : '更新'}
|
||||||
|
|||||||
@@ -42,21 +42,4 @@ describe ('WikiSearchPage', () => {
|
|||||||
})
|
})
|
||||||
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('marks deprecated wiki tags in the result title', async () => {
|
|
||||||
api.apiGet.mockResolvedValueOnce ([
|
|
||||||
buildWikiPage ({
|
|
||||||
title: '旧タグ',
|
|
||||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
renderWithProviders (<WikiSearchPage/>)
|
|
||||||
|
|
||||||
const link = await screen.findByRole ('link', { name: '旧タグ' })
|
|
||||||
const marker = screen.getByText ('(廃止)')
|
|
||||||
|
|
||||||
expect (link).toBeInTheDocument ()
|
|
||||||
expect (marker.closest ('a')).toBeNull ()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ const WikiSearchPage: FC = () => {
|
|||||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
||||||
{page.title}
|
{page.title}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
{page.deprecatedAt != null && <span>(廃止)</span>}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{dateString (page.updatedAt)}
|
{dateString (page.updatedAt)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Material,
|
import type { Material,
|
||||||
Post,
|
Post,
|
||||||
TagWithSections,
|
Tag,
|
||||||
Theatre,
|
Theatre,
|
||||||
TheatreComment,
|
TheatreComment,
|
||||||
TheatreInfo,
|
TheatreInfo,
|
||||||
@@ -9,11 +9,10 @@ import type { Material,
|
|||||||
User,
|
User,
|
||||||
WikiPage } from '@/types'
|
WikiPage } from '@/types'
|
||||||
|
|
||||||
export const buildTag = (overrides: Partial<TagWithSections> = {}): TagWithSections => ({
|
export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'テストタグ',
|
name: 'テストタグ',
|
||||||
category: 'general',
|
category: 'general',
|
||||||
deprecatedAt: null,
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
parents: [],
|
parents: [],
|
||||||
postCount: 12,
|
postCount: 12,
|
||||||
@@ -23,8 +22,6 @@ export const buildTag = (overrides: Partial<TagWithSections> = {}): TagWithSecti
|
|||||||
materialId: null,
|
materialId: null,
|
||||||
hasDeerjikists: false,
|
hasDeerjikists: false,
|
||||||
matchedAlias: null,
|
matchedAlias: null,
|
||||||
sections: [],
|
|
||||||
children: [],
|
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -35,7 +32,6 @@ export const buildPost = (overrides: Partial<Post> = {}): Post => ({
|
|||||||
title: 'テスト投稿',
|
title: 'テスト投稿',
|
||||||
thumbnail: 'https://example.com/thumb.jpg',
|
thumbnail: 'https://example.com/thumb.jpg',
|
||||||
thumbnailBase: null,
|
thumbnailBase: null,
|
||||||
videoMs: null,
|
|
||||||
tags: [buildTag ()],
|
tags: [buildTag ()],
|
||||||
parentPosts: [],
|
parentPosts: [],
|
||||||
childPosts: [],
|
childPosts: [],
|
||||||
@@ -61,7 +57,6 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
|
|||||||
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
|
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'テストWiki',
|
title: 'テストWiki',
|
||||||
deprecatedAt: null,
|
|
||||||
createdUserId: 1,
|
createdUserId: 1,
|
||||||
updatedUserId: 1,
|
updatedUserId: 1,
|
||||||
createdAt: '2026-01-02T03:04:05.000Z',
|
createdAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
|||||||
+15
-25
@@ -39,19 +39,18 @@ export type FetchTagsOrderField =
|
|||||||
| 'updated_at'
|
| 'updated_at'
|
||||||
|
|
||||||
export type FetchTagsParams = {
|
export type FetchTagsParams = {
|
||||||
post: number | null
|
post: number | null
|
||||||
name: string
|
name: string
|
||||||
category: Category | null
|
category: Category | null
|
||||||
postCountGTE: number
|
postCountGTE: number
|
||||||
postCountLTE: number | null
|
postCountLTE: number | null
|
||||||
createdFrom: string
|
createdFrom: string
|
||||||
createdTo: string
|
createdTo: string
|
||||||
updatedFrom: string
|
updatedFrom: string
|
||||||
updatedTo: string
|
updatedTo: string
|
||||||
deprecated: boolean | null
|
page: number
|
||||||
page: number
|
limit: number
|
||||||
limit: number
|
order: FetchTagsOrder }
|
||||||
order: FetchTagsOrder }
|
|
||||||
|
|
||||||
export type FetchNicoTagsParams = {
|
export type FetchNicoTagsParams = {
|
||||||
name: string
|
name: string
|
||||||
@@ -140,12 +139,11 @@ export type Post = {
|
|||||||
title: string | null
|
title: string | null
|
||||||
thumbnail: string | null
|
thumbnail: string | null
|
||||||
thumbnailBase: string | null
|
thumbnailBase: string | null
|
||||||
videoMs: number | null
|
|
||||||
postSimilarityEdges?: {
|
postSimilarityEdges?: {
|
||||||
targetPostId: number
|
targetPostId: number
|
||||||
cos: number
|
cos: number
|
||||||
}[]
|
}[]
|
||||||
tags: TagWithSections[]
|
tags: Tag[]
|
||||||
parentPosts?: Post[]
|
parentPosts?: Post[]
|
||||||
childPosts?: Post[]
|
childPosts?: Post[]
|
||||||
siblingPosts?: Record<`${ number }`, Post[]>
|
siblingPosts?: Record<`${ number }`, Post[]>
|
||||||
@@ -173,7 +171,6 @@ export type PostVersion = {
|
|||||||
url: { current: string; prev: string | null }
|
url: { current: string; prev: string | null }
|
||||||
thumbnail: { current: string | null; prev: string | null }
|
thumbnail: { current: string | null; prev: string | null }
|
||||||
thumbnailBase: { current: string | null; prev: string | null }
|
thumbnailBase: { current: string | null; prev: string | null }
|
||||||
videoMs: { current: number | null; prev: number | null }
|
|
||||||
tags: { name: string
|
tags: { name: string
|
||||||
type: 'context' | 'added' | 'removed' }[]
|
type: 'context' | 'added' | 'removed' }[]
|
||||||
parentPosts: { id: number
|
parentPosts: { id: number
|
||||||
@@ -199,7 +196,6 @@ export type Tag = {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
category: Category
|
category: Category
|
||||||
deprecatedAt: string | null
|
|
||||||
aliases: string[]
|
aliases: string[]
|
||||||
parents: Tag[]
|
parents: Tag[]
|
||||||
postCount: number
|
postCount: number
|
||||||
@@ -208,6 +204,7 @@ export type Tag = {
|
|||||||
hasWiki: boolean
|
hasWiki: boolean
|
||||||
materialId: number | null
|
materialId: number | null
|
||||||
hasDeerjikists: boolean
|
hasDeerjikists: boolean
|
||||||
|
children?: Tag[]
|
||||||
matchedAlias?: string | null }
|
matchedAlias?: string | null }
|
||||||
|
|
||||||
export type TagVersion = {
|
export type TagVersion = {
|
||||||
@@ -216,16 +213,11 @@ export type TagVersion = {
|
|||||||
eventType: 'create' | 'update' | 'discard' | 'restore'
|
eventType: 'create' | 'update' | 'discard' | 'restore'
|
||||||
name: { current: string; prev: string | null }
|
name: { current: string; prev: string | null }
|
||||||
category: { current: Category; prev: Category | null }
|
category: { current: Category; prev: Category | null }
|
||||||
deprecatedAt: { current: string | null; prev: string | null }
|
|
||||||
aliases: { name: string; type: 'context' | 'added' | 'removed' }[]
|
aliases: { name: string; type: 'context' | 'added' | 'removed' }[]
|
||||||
parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[]
|
parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
createdByUser: { id: number; name: string | null } | null }
|
createdByUser: { id: number; name: string | null } | null }
|
||||||
|
|
||||||
export type TagWithSections = Tag & { sections: { beginMs: number
|
|
||||||
endMs: number | null }[]
|
|
||||||
children: TagWithSections[] }
|
|
||||||
|
|
||||||
export type Theatre = {
|
export type Theatre = {
|
||||||
id: number
|
id: number
|
||||||
name: string | null
|
name: string | null
|
||||||
@@ -304,7 +296,6 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
|
|||||||
export type WikiPage = {
|
export type WikiPage = {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
deprecatedAt: string | null
|
|
||||||
createdUserId: number
|
createdUserId: number
|
||||||
updatedUserId: number
|
updatedUserId: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -318,7 +309,7 @@ export type WikiPageChange = {
|
|||||||
revisionId: number
|
revisionId: number
|
||||||
pred: number | null
|
pred: number | null
|
||||||
succ: null
|
succ: null
|
||||||
wikiPage: Pick<WikiPage, 'id' | 'title' | 'deprecatedAt'>
|
wikiPage: Pick<WikiPage, 'id' | 'title'>
|
||||||
user: Pick<User, 'id' | 'name'>
|
user: Pick<User, 'id' | 'name'>
|
||||||
kind: 'content' | 'redirect'
|
kind: 'content' | 'redirect'
|
||||||
message: string | null
|
message: string | null
|
||||||
@@ -327,7 +318,6 @@ export type WikiPageChange = {
|
|||||||
export type WikiPageDiff = {
|
export type WikiPageDiff = {
|
||||||
wikiPageId: number
|
wikiPageId: number
|
||||||
title: string
|
title: string
|
||||||
deprecatedAt: string | null
|
|
||||||
olderRevisionId: number | null
|
olderRevisionId: number | null
|
||||||
newerRevisionId: number
|
newerRevisionId: number
|
||||||
diff: WikiPageDiffDiff[] }
|
diff: WikiPageDiffDiff[] }
|
||||||
|
|||||||
@@ -27,6 +27,5 @@
|
|||||||
"@/*": ["*"]
|
"@/*": ["*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"]
|
||||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする