コミットを比較
7 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 1d11c01247 | |||
| cb7b9ee808 | |||
| f9f0010e03 | |||
| 62d0830aec | |||
| cda90b76d2 | |||
| 673a5dbd23 | |||
| ffebce36b9 |
@@ -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
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class GekanatorGamesController < ApplicationController
|
|||||||
questions,
|
questions,
|
||||||
post_id: game.correct_post_id,
|
post_id: game.correct_post_id,
|
||||||
user: current_user,
|
user: current_user,
|
||||||
limit: 6)
|
limit: 2)
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
questions: selected.map { |question| extra_question_json(question) }
|
questions: selected.map { |question| extra_question_json(question) }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -14,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'])
|
||||||
@@ -50,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]
|
||||||
@@ -84,21 +79,9 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
tag_ids =
|
tag_ids =
|
||||||
if parent_tag_id
|
if parent_tag_id
|
||||||
TagImplication.joins(:tag)
|
TagImplication.where(parent_tag_id:).select(:tag_id)
|
||||||
.where(parent_tag_id:)
|
|
||||||
.where(tags: { deprecated_at: nil })
|
|
||||||
.select(:tag_id)
|
|
||||||
else
|
else
|
||||||
Tag.where(deprecated_at: nil)
|
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
|
||||||
.where.not(id: TagImplication
|
|
||||||
.joins(<<~SQL.squish)
|
|
||||||
INNER JOIN
|
|
||||||
tags parent_tags
|
|
||||||
ON parent_tags.id = tag_implications.parent_tag_id
|
|
||||||
SQL
|
|
||||||
.where('parent_tags.deprecated_at IS NULL')
|
|
||||||
.select(:tag_id))
|
|
||||||
.select(:id)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
tags =
|
tags =
|
||||||
@@ -106,7 +89,6 @@ class TagsController < ApplicationController
|
|||||||
.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(category: [:meme, :character, :material])
|
||||||
.where(deprecated_at: nil)
|
|
||||||
.where(id: tag_ids)
|
.where(id: tag_ids)
|
||||||
.order('tag_names.name')
|
.order('tag_names.name')
|
||||||
.distinct
|
.distinct
|
||||||
@@ -119,8 +101,7 @@ class TagsController < ApplicationController
|
|||||||
TagImplication
|
TagImplication
|
||||||
.joins(:tag)
|
.joins(:tag)
|
||||||
.where(parent_tag_id: tags.map(&:id),
|
.where(parent_tag_id: tags.map(&:id),
|
||||||
tags: { category: [:meme, :character, :material],
|
tags: { category: [:meme, :character, :material] })
|
||||||
deprecated_at: nil })
|
|
||||||
.distinct
|
.distinct
|
||||||
.pluck(:parent_tag_id)
|
.pluck(:parent_tag_id)
|
||||||
end
|
end
|
||||||
@@ -152,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 =
|
||||||
@@ -272,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)
|
||||||
@@ -298,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
|
||||||
@@ -330,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
|
||||||
@@ -352,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
|
||||||
|
|
||||||
|
|||||||
@@ -58,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 }
|
||||||
|
|
||||||
@@ -78,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
|
||||||
@@ -231,10 +228,4 @@ class Tag < ApplicationRecord
|
|||||||
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def nico_tags_cannot_be_deprecated
|
|
||||||
if nico? && deprecated_at.present?
|
|
||||||
errors.add :deprecated_at, 'ニコタグは廃止できません.'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
-5
@@ -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_21_000000) 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
|
||||||
@@ -319,7 +319,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) 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
|
||||||
@@ -337,13 +336,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe TagNameSanitisationRule, type: :model do
|
RSpec.describe TagNameSanitisationRule, type: :model do
|
||||||
before do
|
|
||||||
described_class.unscoped.delete_all
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.sanitise' do
|
describe '.sanitise' do
|
||||||
before do
|
before do
|
||||||
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
|
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
|
||||||
|
|||||||
@@ -206,40 +206,6 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
expect(example.gekanator_game_id).to eq(json['id'])
|
expect(example.gekanator_game_id).to eq(json['id'])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'learns accepted post_similarity answers from main game logs' do
|
|
||||||
sign_in_as admin
|
|
||||||
|
|
||||||
question = create_post_similarity_question!(text: '泣いてる?')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
post '/gekanator/games', params: {
|
|
||||||
guessed_post_id: guessed_post.id,
|
|
||||||
correct_post_id: correct_post.id,
|
|
||||||
answers: [
|
|
||||||
{
|
|
||||||
question_id: "post-similarity:#{question.id}",
|
|
||||||
question_text: '泣いてる?',
|
|
||||||
answer: 'partial',
|
|
||||||
original_answer: 'partial'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}.to change { GekanatorQuestionExample.count }.by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
expect(json['learned_example_count']).to eq(1)
|
|
||||||
|
|
||||||
example = GekanatorQuestionExample.last
|
|
||||||
expect(example).to have_attributes(
|
|
||||||
gekanator_question_id: question.id,
|
|
||||||
post_id: correct_post.id,
|
|
||||||
user_id: admin.id,
|
|
||||||
answer: 'partial',
|
|
||||||
source: 'post_game_answer'
|
|
||||||
)
|
|
||||||
expect(example.gekanator_game_id).to eq(json['id'])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not learn fact questions or nico tag questions from main game logs' do
|
it 'does not learn fact questions or nico tag questions from main game logs' do
|
||||||
sign_in_as admin
|
sign_in_as admin
|
||||||
|
|
||||||
@@ -509,59 +475,28 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /gekanator/games/:id/extra_questions' do
|
describe 'GET /gekanator/games/:id/extra_questions' do
|
||||||
it 'returns at most six accepted user_suggested post_similarity questions without duplicates' do
|
it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do
|
||||||
sign_in_as admin
|
sign_in_as admin
|
||||||
|
|
||||||
lowest = create_post_similarity_question!(
|
|
||||||
text: 'lowest?',
|
|
||||||
priority_weight: 0.5
|
|
||||||
)
|
|
||||||
low = create_post_similarity_question!(
|
low = create_post_similarity_question!(
|
||||||
text: 'low?',
|
text: 'low?',
|
||||||
priority_weight: 1.0
|
priority_weight: 1.0
|
||||||
)
|
)
|
||||||
middle = create_post_similarity_question!(
|
|
||||||
text: 'middle?',
|
|
||||||
priority_weight: 1.5
|
|
||||||
)
|
|
||||||
medium_high = create_post_similarity_question!(
|
|
||||||
text: 'medium high?',
|
|
||||||
priority_weight: 2.0
|
|
||||||
)
|
|
||||||
high = create_post_similarity_question!(
|
high = create_post_similarity_question!(
|
||||||
text: 'high?',
|
text: 'high?',
|
||||||
priority_weight: 2.5
|
|
||||||
)
|
|
||||||
higher = create_post_similarity_question!(
|
|
||||||
text: 'higher?',
|
|
||||||
priority_weight: 2.8
|
|
||||||
)
|
|
||||||
highest = create_post_similarity_question!(
|
|
||||||
text: 'highest?',
|
|
||||||
priority_weight: 3.0
|
priority_weight: 3.0
|
||||||
)
|
)
|
||||||
overflow = create_post_similarity_question!(
|
middle = create_post_similarity_question!(
|
||||||
text: 'overflow?',
|
text: 'middle?',
|
||||||
priority_weight: 2.2
|
priority_weight: 2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
get "/gekanator/games/#{game.id}/extra_questions"
|
get "/gekanator/games/#{game.id}/extra_questions"
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json['questions'].length).to eq(6)
|
expect(json['questions'].length).to eq(2)
|
||||||
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(6)
|
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2)
|
||||||
expect(json['questions'].map { _1['id'] }).to all(
|
expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id]))
|
||||||
be_in([
|
|
||||||
lowest.id,
|
|
||||||
low.id,
|
|
||||||
middle.id,
|
|
||||||
medium_high.id,
|
|
||||||
high.id,
|
|
||||||
higher.id,
|
|
||||||
highest.id,
|
|
||||||
overflow.id,
|
|
||||||
])
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'can return questions that already have an example for the correct post' do
|
it 'can return questions that already have an example for the correct post' do
|
||||||
@@ -584,37 +519,6 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
expect(json['questions'].map { _1['id'] }).to include(existing.id)
|
expect(json['questions'].map { _1['id'] }).to include(existing.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'prioritizes questions the current user has not answered' do
|
|
||||||
sign_in_as admin
|
|
||||||
|
|
||||||
answered = create_post_similarity_question!(
|
|
||||||
text: 'already answered?',
|
|
||||||
priority_weight: 3.0
|
|
||||||
)
|
|
||||||
GekanatorQuestionExample.create!(
|
|
||||||
gekanator_question: answered,
|
|
||||||
post: other_post,
|
|
||||||
user: admin,
|
|
||||||
answer: 'yes',
|
|
||||||
source: 'post_game_extra'
|
|
||||||
)
|
|
||||||
|
|
||||||
unanswered =
|
|
||||||
6.times.map { |index|
|
|
||||||
create_post_similarity_question!(
|
|
||||||
text: "unanswered #{index}?",
|
|
||||||
priority_weight: 0.5
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get "/gekanator/games/#{game.id}/extra_questions"
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json['questions'].map { _1['id'] }).to match_array(
|
|
||||||
unanswered.map(&:id)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'can return questions already asked in the game using snake_case question_id' do
|
it 'can return questions already asked in the game using snake_case question_id' do
|
||||||
sign_in_as admin
|
sign_in_as admin
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { apiPost } from '@/lib/api'
|
|||||||
import {
|
import {
|
||||||
buildGekanatorQuestions,
|
buildGekanatorQuestions,
|
||||||
expectedAnswerForQuestion,
|
expectedAnswerForQuestion,
|
||||||
learnedSemanticSideForPost,
|
|
||||||
questionIdForCondition,
|
questionIdForCondition,
|
||||||
restoreGekanatorQuestion,
|
restoreGekanatorQuestion,
|
||||||
saveGekanatorExtraQuestionAnswers,
|
saveGekanatorExtraQuestionAnswers,
|
||||||
@@ -189,33 +188,6 @@ describe('expectedAnswerForQuestion', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('learnedSemanticSideForPost', () => {
|
|
||||||
it('classifies post_similarity examples as positive, negative, or unknown', () => {
|
|
||||||
const question: StoredGekanatorQuestion = {
|
|
||||||
id: 'post-similarity:10',
|
|
||||||
text: '喜多ちゃんが泣いてる?',
|
|
||||||
kind: 'post_similarity',
|
|
||||||
source: 'user_suggested',
|
|
||||||
priorityWeight: 1.2,
|
|
||||||
condition: {
|
|
||||||
type: 'post-similarity',
|
|
||||||
postId: 123,
|
|
||||||
answer: 'partial',
|
|
||||||
threshold: 0.65,
|
|
||||||
},
|
|
||||||
exampleAnswers: {
|
|
||||||
1: 'yes',
|
|
||||||
2: 'probably_no',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(learnedSemanticSideForPost(question, post({ id: 1 }))).toBe('positive')
|
|
||||||
expect(learnedSemanticSideForPost(question, post({ id: 2 }))).toBe('negative')
|
|
||||||
expect(learnedSemanticSideForPost(question, post({ id: 3 }))).toBe('unknown')
|
|
||||||
expect(learnedSemanticSideForPost(question, post({ id: 123 }))).toBe('positive')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('restoreGekanatorQuestion', () => {
|
describe('restoreGekanatorQuestion', () => {
|
||||||
it('uses default source and priority weight when omitted', () => {
|
it('uses default source and priority weight when omitted', () => {
|
||||||
const question = restoreGekanatorQuestion({
|
const question = restoreGekanatorQuestion({
|
||||||
@@ -276,7 +248,7 @@ describe('restoreGekanatorQuestion', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(question.test(post({ id: 1 }))).toBe(true)
|
expect(question.test(post({ id: 1 }))).toBe(true)
|
||||||
expect(question.test(post({ id: 2 }))).toBe(true)
|
expect(question.test(post({ id: 2 }))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes legacy title-length-greater-than questions', () => {
|
it('normalizes legacy title-length-greater-than questions', () => {
|
||||||
@@ -400,10 +372,6 @@ describe('Gekanator API writers', () => {
|
|||||||
type: 'tag',
|
type: 'tag',
|
||||||
key: 'character:喜多郁代',
|
key: 'character:喜多郁代',
|
||||||
},
|
},
|
||||||
questionMode: 'normal',
|
|
||||||
questionPurpose: 'effective_user_suggested',
|
|
||||||
effectiveQuestion: true,
|
|
||||||
learningQuestion: false,
|
|
||||||
answer: 'yes',
|
answer: 'yes',
|
||||||
originalAnswer: 'partial',
|
originalAnswer: 'partial',
|
||||||
},
|
},
|
||||||
@@ -428,10 +396,6 @@ describe('Gekanator API writers', () => {
|
|||||||
type: 'tag',
|
type: 'tag',
|
||||||
key: 'character:喜多郁代',
|
key: 'character:喜多郁代',
|
||||||
},
|
},
|
||||||
question_mode: 'normal',
|
|
||||||
question_purpose: 'effective_user_suggested',
|
|
||||||
effective_question: true,
|
|
||||||
learning_question: false,
|
|
||||||
answer: 'yes',
|
answer: 'yes',
|
||||||
original_answer: 'partial',
|
original_answer: 'partial',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,24 +9,11 @@ export type GekanatorAnswerValue =
|
|||||||
| 'probably_no'
|
| 'probably_no'
|
||||||
| 'unknown'
|
| 'unknown'
|
||||||
|
|
||||||
export type LearnedSemanticSide =
|
|
||||||
| 'positive'
|
|
||||||
| 'negative'
|
|
||||||
| 'unknown'
|
|
||||||
|
|
||||||
export type GekanatorQuestionPurpose =
|
|
||||||
| 'effective_user_suggested'
|
|
||||||
| 'learning_user_suggested'
|
|
||||||
| 'normal'
|
|
||||||
|
|
||||||
export type GekanatorAnswerLog = {
|
export type GekanatorAnswerLog = {
|
||||||
questionId: string
|
questionId: string
|
||||||
questionText: string
|
questionText: string
|
||||||
questionCondition?: GekanatorQuestionCondition
|
questionCondition?: GekanatorQuestionCondition
|
||||||
questionMode?: 'normal' | 'winning_run'
|
questionMode?: 'normal' | 'winning_run'
|
||||||
questionPurpose?: GekanatorQuestionPurpose
|
|
||||||
effectiveQuestion?: boolean
|
|
||||||
learningQuestion?: boolean
|
|
||||||
answer: GekanatorAnswerValue
|
answer: GekanatorAnswerValue
|
||||||
originalAnswer: GekanatorAnswerValue }
|
originalAnswer: GekanatorAnswerValue }
|
||||||
|
|
||||||
@@ -176,26 +163,6 @@ const directExampleAnswerFor = (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const isLearnedSemanticQuestion = (
|
|
||||||
question: StoredGekanatorQuestion | GekanatorQuestion,
|
|
||||||
): boolean =>
|
|
||||||
question.kind === 'post_similarity'
|
|
||||||
&& question.source === 'user_suggested'
|
|
||||||
|
|
||||||
|
|
||||||
export const learnedSemanticSideForAnswer = (
|
|
||||||
answer: GekanatorAnswerValue | null,
|
|
||||||
): LearnedSemanticSide => {
|
|
||||||
if (answer === 'yes' || answer === 'partial')
|
|
||||||
return 'positive'
|
|
||||||
|
|
||||||
if (answer === 'no' || answer === 'probably_no')
|
|
||||||
return 'negative'
|
|
||||||
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||||
const counts = new Map<T, number> ()
|
const counts = new Map<T, number> ()
|
||||||
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
||||||
@@ -318,8 +285,8 @@ const questionMatches = (
|
|||||||
): boolean => {
|
): boolean => {
|
||||||
const directAnswer = directExampleAnswerFor (question, post)
|
const directAnswer = directExampleAnswerFor (question, post)
|
||||||
if (directAnswer)
|
if (directAnswer)
|
||||||
return question.kind === 'post_similarity'
|
return question.condition.type === 'post-similarity'
|
||||||
? learnedSemanticSideForAnswer (directAnswer) === 'positive'
|
? directAnswer === question.condition.answer
|
||||||
: directAnswer === 'yes'
|
: directAnswer === 'yes'
|
||||||
|
|
||||||
switch (question.condition.type)
|
switch (question.condition.type)
|
||||||
@@ -361,11 +328,6 @@ export const expectedAnswerForQuestion = (
|
|||||||
|
|
||||||
switch (question.condition.type)
|
switch (question.condition.type)
|
||||||
{
|
{
|
||||||
case 'post-similarity':
|
|
||||||
if (question.condition.postId === post.id)
|
|
||||||
return question.condition.answer
|
|
||||||
|
|
||||||
return null
|
|
||||||
case 'tag':
|
case 'tag':
|
||||||
case 'source':
|
case 'source':
|
||||||
case 'original-year':
|
case 'original-year':
|
||||||
@@ -376,17 +338,12 @@ export const expectedAnswerForQuestion = (
|
|||||||
case 'title-has-ascii':
|
case 'title-has-ascii':
|
||||||
case 'title-contains':
|
case 'title-contains':
|
||||||
return questionMatches (post, question) ? 'yes' : 'no'
|
return questionMatches (post, question) ? 'yes' : 'no'
|
||||||
|
case 'post-similarity':
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const learnedSemanticSideForPost = (
|
|
||||||
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
|
||||||
post: Post | null,
|
|
||||||
): LearnedSemanticSide =>
|
|
||||||
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
|
|
||||||
|
|
||||||
|
|
||||||
export const restoreGekanatorQuestion = (
|
export const restoreGekanatorQuestion = (
|
||||||
question: StoredGekanatorQuestion,
|
question: StoredGekanatorQuestion,
|
||||||
): GekanatorQuestion => {
|
): GekanatorQuestion => {
|
||||||
@@ -466,15 +423,15 @@ export const buildGekanatorQuestions = (
|
|||||||
const originalYears = countBy (
|
const originalYears = countBy (
|
||||||
posts
|
posts
|
||||||
.map (originalYearOf)
|
.map (originalYearOf)
|
||||||
.filter ((year): year is number => year != null))
|
.filter ((year): year is number => year !== null))
|
||||||
const originalMonths = countBy (
|
const originalMonths = countBy (
|
||||||
posts
|
posts
|
||||||
.map (originalMonthOf)
|
.map (originalMonthOf)
|
||||||
.filter ((month): month is number => month != null))
|
.filter ((month): month is number => month !== null))
|
||||||
const originalMonthDays = countBy (
|
const originalMonthDays = countBy (
|
||||||
posts
|
posts
|
||||||
.map (originalMonthDayOf)
|
.map (originalMonthDayOf)
|
||||||
.filter ((monthDay): monthDay is string => monthDay != null))
|
.filter ((monthDay): monthDay is string => monthDay !== null))
|
||||||
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
|
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
|
||||||
const titleWordCounts =
|
const titleWordCounts =
|
||||||
includeTitleContains
|
includeTitleContains
|
||||||
@@ -636,10 +593,6 @@ export const saveGekanatorGame = async ({
|
|||||||
question_id: answer.questionId,
|
question_id: answer.questionId,
|
||||||
question_text: answer.questionText,
|
question_text: answer.questionText,
|
||||||
question_condition: answer.questionCondition ?? null,
|
question_condition: answer.questionCondition ?? null,
|
||||||
question_mode: answer.questionMode,
|
|
||||||
question_purpose: answer.questionPurpose,
|
|
||||||
effective_question: answer.effectiveQuestion,
|
|
||||||
learning_question: answer.learningQuestion,
|
|
||||||
answer: answer.answer,
|
answer: answer.answer,
|
||||||
original_answer: answer.originalAnswer })) })
|
original_answer: answer.originalAnswer })) })
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
GekanatorAnswerValue,
|
GekanatorAnswerValue,
|
||||||
GekanatorQuestion,
|
GekanatorQuestion,
|
||||||
} from '@/lib/gekanator'
|
} from '@/lib/gekanator'
|
||||||
import type { RecoveredCandidateState } from '@/lib/gekanatorCandidateRecovery'
|
|
||||||
import type { Post } from '@/types'
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
@@ -79,15 +78,6 @@ const answer = (
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const recoveredState = (
|
|
||||||
answerCountAtRecovery: number,
|
|
||||||
scoreAtRecovery = 0,
|
|
||||||
): RecoveredCandidateState => ({
|
|
||||||
answerCountAtRecovery,
|
|
||||||
scoreAtRecovery,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
describe('candidatePostsFor', () => {
|
describe('candidatePostsFor', () => {
|
||||||
it('does not hard-filter semantic post_similarity answers', () => {
|
it('does not hard-filter semantic post_similarity answers', () => {
|
||||||
const posts = [post (1), post (2), post (3)]
|
const posts = [post (1), post (2), post (3)]
|
||||||
@@ -109,8 +99,8 @@ describe('candidatePostsFor', () => {
|
|||||||
softenedQuestionIds: new Set (),
|
softenedQuestionIds: new Set (),
|
||||||
rejectedPostIds: new Set (),
|
rejectedPostIds: new Set (),
|
||||||
recoveredCandidatePosts: new Map ([
|
recoveredCandidatePosts: new Map ([
|
||||||
[1, recoveredState (1)],
|
[1, 1],
|
||||||
[3, recoveredState (1)],
|
[3, 1],
|
||||||
]) })
|
]) })
|
||||||
|
|
||||||
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
|
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
|
||||||
@@ -132,8 +122,8 @@ describe('candidatePostsFor', () => {
|
|||||||
softenedQuestionIds: new Set (),
|
softenedQuestionIds: new Set (),
|
||||||
rejectedPostIds: new Set (),
|
rejectedPostIds: new Set (),
|
||||||
recoveredCandidatePosts: new Map ([
|
recoveredCandidatePosts: new Map ([
|
||||||
[1, recoveredState (1)],
|
[1, 1],
|
||||||
[3, recoveredState (1)],
|
[3, 1],
|
||||||
]) })
|
]) })
|
||||||
|
|
||||||
expect(candidates.map (candidate => candidate.id)).toEqual ([3])
|
expect(candidates.map (candidate => candidate.id)).toEqual ([3])
|
||||||
@@ -152,7 +142,7 @@ describe('candidatePostsFor', () => {
|
|||||||
answers: [answer (question, 'yes')],
|
answers: [answer (question, 'yes')],
|
||||||
softenedQuestionIds: new Set (),
|
softenedQuestionIds: new Set (),
|
||||||
rejectedPostIds: new Set ([1]),
|
rejectedPostIds: new Set ([1]),
|
||||||
recoveredCandidatePosts: new Map ([[1, recoveredState (1)]]) })
|
recoveredCandidatePosts: new Map ([[1, 1]]) })
|
||||||
|
|
||||||
expect(candidates.map (candidate => candidate.id)).toEqual ([2])
|
expect(candidates.map (candidate => candidate.id)).toEqual ([2])
|
||||||
})
|
})
|
||||||
@@ -219,7 +209,7 @@ describe('recoverCandidatePosts', () => {
|
|||||||
posts,
|
posts,
|
||||||
scores,
|
scores,
|
||||||
rejectedPostIds: new Set ([10]),
|
rejectedPostIds: new Set ([10]),
|
||||||
recoveredCandidatePosts: new Map ([[8, recoveredState (1, 8)]]),
|
recoveredCandidatePosts: new Map ([[8, 1]]),
|
||||||
eligiblePostIds: new Set ([9]),
|
eligiblePostIds: new Set ([9]),
|
||||||
answerCountAtRecovery: 2,
|
answerCountAtRecovery: 2,
|
||||||
recoveryStepCount: 0,
|
recoveryStepCount: 0,
|
||||||
@@ -228,10 +218,7 @@ describe('recoverCandidatePosts', () => {
|
|||||||
expect(recovered?.recoveryStepCount).toBe (1)
|
expect(recovered?.recoveryStepCount).toBe (1)
|
||||||
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
|
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
|
||||||
.toEqual ([8, 7, 6, 5, 4])
|
.toEqual ([8, 7, 6, 5, 4])
|
||||||
expect(recovered?.recoveredCandidatePosts.get (7)).toEqual ({
|
expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2)
|
||||||
answerCountAtRecovery: 2,
|
|
||||||
scoreAtRecovery: 7,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not add posts when recovered and eligible candidates already hit the target', () => {
|
it('does not add posts when recovered and eligible candidates already hit the target', () => {
|
||||||
@@ -243,9 +230,9 @@ describe('recoverCandidatePosts', () => {
|
|||||||
scores,
|
scores,
|
||||||
rejectedPostIds: new Set (),
|
rejectedPostIds: new Set (),
|
||||||
recoveredCandidatePosts: new Map ([
|
recoveredCandidatePosts: new Map ([
|
||||||
[1, recoveredState (1, 1)],
|
[1, 1],
|
||||||
[2, recoveredState (1, 2)],
|
[2, 1],
|
||||||
[3, recoveredState (1, 3)],
|
[3, 1],
|
||||||
]),
|
]),
|
||||||
eligiblePostIds: new Set ([4, 5, 6]),
|
eligiblePostIds: new Set ([4, 5, 6]),
|
||||||
answerCountAtRecovery: 2,
|
answerCountAtRecovery: 2,
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { isLearnedSemanticQuestion,
|
import { expectedAnswerForQuestion } from '@/lib/gekanator'
|
||||||
learnedSemanticSideForPost } from '@/lib/gekanator'
|
|
||||||
|
|
||||||
import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
|
import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
|
||||||
import type { Post } from '@/types'
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
export type RecoveredCandidatePost = {
|
export type RecoveredCandidatePost = {
|
||||||
postId: number
|
postId: number
|
||||||
answerCountAtRecovery: number
|
answerCountAtRecovery: number }
|
||||||
scoreAtRecovery: number }
|
|
||||||
|
|
||||||
export type RecoveredCandidateState = {
|
|
||||||
answerCountAtRecovery: number
|
|
||||||
scoreAtRecovery: number }
|
|
||||||
|
|
||||||
|
|
||||||
const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean =>
|
const questionIsFactLikeForHardFiltering = (question: GekanatorQuestion): boolean =>
|
||||||
!(isLearnedSemanticQuestion (question)
|
!(question.kind === 'post_similarity'
|
||||||
|| (question.kind === 'tag'
|
|| (question.kind === 'tag'
|
||||||
&& question.condition.type === 'tag'
|
&& question.condition.type === 'tag'
|
||||||
&& !(question.condition.key.startsWith ('nico:'))))
|
&& !(question.condition.key.startsWith ('nico:'))))
|
||||||
@@ -32,7 +26,7 @@ export const candidatePostsFor = (
|
|||||||
answers: GekanatorAnswerLog[]
|
answers: GekanatorAnswerLog[]
|
||||||
softenedQuestionIds: Set<string>
|
softenedQuestionIds: Set<string>
|
||||||
rejectedPostIds: Set<number>
|
rejectedPostIds: Set<number>
|
||||||
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
|
recoveredCandidatePosts: Map<number, number> },
|
||||||
): Post[] => {
|
): Post[] => {
|
||||||
const questionById = new Map (questions.map (question => [question.id, question]))
|
const questionById = new Map (questions.map (question => [question.id, question]))
|
||||||
|
|
||||||
@@ -40,10 +34,10 @@ export const candidatePostsFor = (
|
|||||||
if (rejectedPostIds.has (post.id))
|
if (rejectedPostIds.has (post.id))
|
||||||
return false
|
return false
|
||||||
|
|
||||||
const recoveredCandidate = recoveredCandidatePosts.get (post.id)
|
const answerCountAtRecovery = recoveredCandidatePosts.get (post.id)
|
||||||
|
|
||||||
return answers.every ((answer, index) => {
|
return answers.every ((answer, index) => {
|
||||||
if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
|
if (answerCountAtRecovery != null && index < answerCountAtRecovery)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
if (softenedQuestionIds.has (answer.questionId))
|
if (softenedQuestionIds.has (answer.questionId))
|
||||||
@@ -52,7 +46,7 @@ export const candidatePostsFor = (
|
|||||||
const question = questionById.get (answer.questionId)
|
const question = questionById.get (answer.questionId)
|
||||||
if (!(question))
|
if (!(question))
|
||||||
return true
|
return true
|
||||||
if (!(questionSupportsAnswerBasedHardFiltering (question)))
|
if (!(questionIsFactLikeForHardFiltering (question)))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
switch (answer.answer)
|
switch (answer.answer)
|
||||||
@@ -60,10 +54,8 @@ export const candidatePostsFor = (
|
|||||||
case 'yes':
|
case 'yes':
|
||||||
case 'no':
|
case 'no':
|
||||||
{
|
{
|
||||||
const expected = learnedSemanticSideForPost (question, post)
|
const expected = expectedAnswerForQuestion (question, post)
|
||||||
return expected === 'unknown'
|
return expected === null || expected === 'unknown' || expected === answer.answer
|
||||||
|| (answer.answer === 'yes' && expected === 'positive')
|
|
||||||
|| (answer.answer === 'no' && expected === 'negative')
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
@@ -78,17 +70,15 @@ export const hardFilteredPostsForAnswer = (
|
|||||||
question: GekanatorQuestion
|
question: GekanatorQuestion
|
||||||
answer: GekanatorAnswerValue },
|
answer: GekanatorAnswerValue },
|
||||||
): Post[] => {
|
): Post[] => {
|
||||||
if (!(questionSupportsAnswerBasedHardFiltering (question)))
|
if (!(questionIsFactLikeForHardFiltering (question)))
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
if (!(answer === 'yes' || answer === 'no'))
|
if (!(answer === 'yes' || answer === 'no'))
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
return posts.filter (post => {
|
return posts.filter (post => {
|
||||||
const side = learnedSemanticSideForPost (question, post)
|
const expected = expectedAnswerForQuestion (question, post)
|
||||||
return side === 'unknown'
|
return expected == null || expected === 'unknown' || expected === answer
|
||||||
|| (answer === 'yes' && side === 'positive')
|
|
||||||
|| (answer === 'no' && side === 'negative')
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,11 +112,11 @@ export const recoverCandidatePosts = (
|
|||||||
recoveryStepCount }: { posts: Post[]
|
recoveryStepCount }: { posts: Post[]
|
||||||
scores: Map<number, number>
|
scores: Map<number, number>
|
||||||
rejectedPostIds: Set<number>
|
rejectedPostIds: Set<number>
|
||||||
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
|
recoveredCandidatePosts: Map<number, number>
|
||||||
eligiblePostIds: Set<number>
|
eligiblePostIds: Set<number>
|
||||||
answerCountAtRecovery: number
|
answerCountAtRecovery: number
|
||||||
recoveryStepCount: number },
|
recoveryStepCount: number },
|
||||||
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
|
): { recoveredCandidatePosts: Map<number, number>
|
||||||
recoveryStepCount: number } | null => {
|
recoveryStepCount: number } | null => {
|
||||||
const recovered = new Map (recoveredCandidatePosts)
|
const recovered = new Map (recoveredCandidatePosts)
|
||||||
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
|
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
|
||||||
@@ -150,9 +140,7 @@ export const recoverCandidatePosts = (
|
|||||||
if (candidates.length === 0)
|
if (candidates.length === 0)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
candidates.forEach (post => recovered.set (post.id, {
|
candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery))
|
||||||
answerCountAtRecovery,
|
|
||||||
scoreAtRecovery: scores.get (post.id) ?? 0 }))
|
|
||||||
|
|
||||||
return { recoveredCandidatePosts: recovered,
|
return { recoveredCandidatePosts: recovered,
|
||||||
recoveryStepCount: recoveryStepCount + 1 }
|
recoveryStepCount: recoveryStepCount + 1 }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 }) } })
|
||||||
|
|||||||
@@ -60,14 +60,6 @@ const gekanatorBackdropSource = gekanatorPageSource.slice (
|
|||||||
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
|
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
|
||||||
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
|
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
|
||||||
|
|
||||||
const gekanatorChooseQuestionSource = gekanatorPageSource.slice (
|
|
||||||
gekanatorPageSource.indexOf ('const chooseQuestion'),
|
|
||||||
gekanatorPageSource.indexOf ('const winningRunPriorityFor'))
|
|
||||||
|
|
||||||
const gekanatorFallbackQuestionSource = gekanatorPageSource.slice (
|
|
||||||
gekanatorPageSource.indexOf ('const chooseFallbackQuestion'),
|
|
||||||
gekanatorPageSource.indexOf ('const shouldEnterGuessPhase'))
|
|
||||||
|
|
||||||
|
|
||||||
describe('GekanatorBackdrop regression structure', () => {
|
describe('GekanatorBackdrop regression structure', () => {
|
||||||
it('keeps displayedBackdropMode as the render-time source of truth', () => {
|
it('keeps displayedBackdropMode as the render-time source of truth', () => {
|
||||||
@@ -111,30 +103,6 @@ describe('GekanatorBackdrop regression structure', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
describe('Gekanator question selection regression structure', () => {
|
|
||||||
it('prefers normal questions after user_suggested quota has been met', () => {
|
|
||||||
const normalFallbackIndex = gekanatorChooseQuestionSource.indexOf (
|
|
||||||
'else if (normalPool.length > 0)')
|
|
||||||
const effectiveFallbackIndex = gekanatorChooseQuestionSource.indexOf (
|
|
||||||
'else if (effectiveUserSuggestedPool.length > 0)')
|
|
||||||
|
|
||||||
expect(normalFallbackIndex).toBeGreaterThan(0)
|
|
||||||
expect(effectiveFallbackIndex).toBeGreaterThan(0)
|
|
||||||
expect(normalFallbackIndex).toBeLessThan(effectiveFallbackIndex)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not let fallback questions bypass user_suggested purpose tracking', () => {
|
|
||||||
expect(gekanatorFallbackQuestionSource).toContain (
|
|
||||||
"question.source !== 'user_suggested'")
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not show a fixed extra-question count in the extra learning UI', () => {
|
|
||||||
expect(gekanatorPageSource).not.toContain ('追加で 2 問まで答えてください。')
|
|
||||||
expect(gekanatorPageSource).toContain ('追加で質問に答えてください。')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
describe('isQuestionHardFilteredAfterAnswers', () => {
|
describe('isQuestionHardFilteredAfterAnswers', () => {
|
||||||
it('blocks only contradictory or redundant month questions after a yes answer', () => {
|
it('blocks only contradictory or redundant month questions after a yes answer', () => {
|
||||||
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
||||||
|
|||||||
+181
-817
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -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
|
||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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,
|
||||||
|
|||||||
+12
-15
@@ -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
|
||||||
@@ -197,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
|
||||||
@@ -215,7 +213,6 @@ 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
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする