コミットを比較
6
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
9d1dc1390b | ||
|
|
d55f9cf3a4 | ||
|
|
2cd4b36769 | ||
|
|
c897ea8f8f | ||
|
|
4a7296eea0 | ||
|
|
2b18d72f7a |
@@ -0,0 +1,35 @@
|
||||
## 背景
|
||||
|
||||
なぜ必要か。
|
||||
|
||||
## 対象範囲
|
||||
|
||||
- backend:
|
||||
- frontend:
|
||||
- docs:
|
||||
- migration:
|
||||
|
||||
## やること
|
||||
|
||||
- [ ]
|
||||
|
||||
## 受け入れ条件
|
||||
|
||||
- [ ]
|
||||
|
||||
## 実行すべき確認
|
||||
|
||||
- [ ] `cd backend && bundle exec rspec`
|
||||
- [ ] `cd frontend && npm run build`
|
||||
- [ ] `cd frontend && npm run lint`
|
||||
|
||||
## 禁止事項
|
||||
|
||||
- unrelated refactor はしない
|
||||
- 既存 API response shape を壊さない
|
||||
- 認証・認可・BAN を弱めない
|
||||
|
||||
## Codex への指示
|
||||
|
||||
この issue を読んで実装してください。
|
||||
不明点があれば、実装前に調査結果と選択肢を提示してください。
|
||||
@@ -100,9 +100,18 @@ class PostVersionsController < ApplicationController
|
||||
|
||||
def external_tag_names_for snapshots
|
||||
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
|
||||
ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
|
||||
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
|
||||
[id, "#{ platform }:#{ name }"]
|
||||
}
|
||||
missing_ids = ids - names.keys
|
||||
|
||||
HistoryAccess.visible(NicoTagVersion.all)
|
||||
.where(tag_id: missing_ids)
|
||||
.order(:tag_id, version_no: :desc)
|
||||
.pluck(:tag_id, :name)
|
||||
.each { |id, name| names[id] ||= name }
|
||||
|
||||
names
|
||||
end
|
||||
|
||||
def snapshot_tag_literals snapshots, tag_names, external_tag_names
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
class TagDescriptionsController < ApplicationController
|
||||
rescue_from TagDescriptionOperation::Conflict, with: :render_conflict
|
||||
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
descriptions = HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
|
||||
.where(language_code: locale.language_code).includes(tag: :tag_names)
|
||||
.order(updated_at: :desc, id: :asc)
|
||||
title = params[:title].to_s.strip
|
||||
if title.present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||
descriptions = descriptions.joins(tag: :tag_names)
|
||||
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
end
|
||||
render json: descriptions.map { WikiPageRepr.tag_description(locale, _1) }
|
||||
end
|
||||
|
||||
def show_by_id
|
||||
locale = resolve_locale!
|
||||
reference = TagDescription.find_by(id: params[:description_id])
|
||||
return head :not_found unless reference
|
||||
|
||||
HistoryAccess.require_current!(reference, TagDescriptionVersion)
|
||||
description = TagDescription.find_by(tag_id: reference.tag_id,
|
||||
language_code: locale.language_code)
|
||||
return head :not_found unless description
|
||||
|
||||
render json: representation(description)
|
||||
end
|
||||
|
||||
def show
|
||||
locale = resolve_locale!
|
||||
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
|
||||
@@ -18,6 +48,25 @@ class TagDescriptionsController < ApplicationController
|
||||
render json: representation(description)
|
||||
end
|
||||
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
locale = resolve_locale!
|
||||
title = params[:title].to_s.strip
|
||||
body = params[:body].to_s
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
return render_unprocessable_entity field: :title if title.include?(':')
|
||||
|
||||
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
|
||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||
|
||||
description = TagDescriptionOperation.create!(
|
||||
locale, tag: tag_name.tag, body:, created_by_user: current_user)
|
||||
render json: representation(description), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
@@ -32,7 +81,9 @@ class TagDescriptionsController < ApplicationController
|
||||
description = TagDescription.find_by(tag:, language_code: locale.language_code)
|
||||
|
||||
if description
|
||||
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
|
||||
TagDescriptionOperation.update_with_title!(
|
||||
locale, tag:, body:, title: params[:title], actor: current_user,
|
||||
base_revision_id: params[:base_revision_id].presence)
|
||||
description.reload
|
||||
render json: representation(description)
|
||||
else
|
||||
@@ -48,8 +99,20 @@ class TagDescriptionsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def render_conflict error
|
||||
render json: { error: 'conflict', message: error.message }, status: :conflict
|
||||
end
|
||||
|
||||
def representation description
|
||||
HistoryAccess.require_current!(description, TagDescriptionVersion)
|
||||
if params[:document] == '1'
|
||||
locale = resolve_locale!
|
||||
version = HistoryAccess.visible(TagDescriptionVersion.all).find_by!(
|
||||
tag_id: description.tag_id, language_code: description.language_code,
|
||||
version_no: description.version_no)
|
||||
return WikiPageRepr.tag_description(locale, description).merge(revision_id: version.id)
|
||||
end
|
||||
|
||||
description.as_json(only: [
|
||||
:tag_id,
|
||||
:language_code,
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
class TagGroupsController < ApplicationController
|
||||
rescue_from TagGroupMarkdownParser::InvalidDocument,
|
||||
TagGroupMarkdownRenderer::UnsupportedStructure, with: :render_document_error
|
||||
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
|
||||
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion)
|
||||
.includes(:created_by_user, :updated_by_user)
|
||||
if params[:document] == '1'
|
||||
groups = groups.order(updated_at: :desc, id: :asc)
|
||||
title = params[:title].to_s.strip
|
||||
if title.present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||
groups = groups.where('CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
|
||||
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
|
||||
end
|
||||
return render json: groups.map { WikiPageRepr.tag_group(_1) }
|
||||
end
|
||||
groups = groups.order(:id)
|
||||
render json: TagGroupRepr.many(locale, groups.to_a)
|
||||
end
|
||||
|
||||
@@ -10,7 +25,15 @@ class TagGroupsController < ApplicationController
|
||||
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
render json: TagGroupRepr.one(locale, group)
|
||||
render json: representation(locale, group)
|
||||
end
|
||||
|
||||
def show_by_name
|
||||
locale = resolve_locale!
|
||||
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(name: params[:name])
|
||||
return head :not_found unless group
|
||||
|
||||
render json: representation(locale, group)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -18,8 +41,8 @@ class TagGroupsController < ApplicationController
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
locale = resolve_locale!
|
||||
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(locale, group), status: :created
|
||||
group = TagGroupOperation.create!(locale, **write_attributes(locale), actor: current_user)
|
||||
render json: representation(locale, group), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -30,13 +53,39 @@ class TagGroupsController < ApplicationController
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(locale, group.reload)
|
||||
TagGroupOperation.update!(locale, group:, **write_attributes(locale, group:), actor: current_user)
|
||||
render json: representation(locale, group.reload)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_attributes
|
||||
def render_document_error error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def representation locale, group
|
||||
representation = TagGroupRepr.one(locale, group)
|
||||
return representation unless params[:document] == '1'
|
||||
|
||||
version = TagGroupRepr.current_version(group)
|
||||
raise ActiveRecord::RecordNotFound unless version
|
||||
|
||||
TagGroupRepr.document(representation, version)
|
||||
end
|
||||
|
||||
def write_attributes locale, group: nil
|
||||
if params.key?(:body)
|
||||
body = params[:body].to_s
|
||||
if body.blank?
|
||||
group ||= TagGroup.new
|
||||
group.errors.add(:body, :blank)
|
||||
raise ActiveRecord::RecordInvalid, group
|
||||
end
|
||||
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
|
||||
return parsed.except(:compatibility_body).merge(
|
||||
name: params[:name], require_compatibility: true)
|
||||
end
|
||||
|
||||
{ name: params[:name], description: params[:description],
|
||||
members: params[:members], placements: params[:placements] }
|
||||
end
|
||||
|
||||
@@ -6,6 +6,11 @@ class WikiPagesController < ApplicationController
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
|
||||
resource_type = params[:resource_type]
|
||||
unless resource_type.nil? || ['tag_description', 'tag_group'].include?(resource_type)
|
||||
return head :bad_request
|
||||
end
|
||||
|
||||
title = params[:title].to_s.strip
|
||||
descriptions =
|
||||
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
|
||||
@@ -14,6 +19,8 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
||||
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
|
||||
descriptions = descriptions.none if resource_type == 'tag_group'
|
||||
tag_groups = tag_groups.none if resource_type == 'tag_description'
|
||||
if title.present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||
descriptions =
|
||||
@@ -246,25 +253,8 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
base_revision_id = params[:base_revision_id].presence
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
old_title = tag_name.name
|
||||
if title != old_title
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||
end
|
||||
|
||||
tag_name.update!(name: title) if title != old_title
|
||||
|
||||
message = params[:message].presence
|
||||
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
|
||||
if title != old_title
|
||||
tag.reload
|
||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
end
|
||||
TagDescriptionOperation.update_with_title!(
|
||||
locale, tag:, body:, title:, actor: current_user, base_revision_id:)
|
||||
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@@ -326,21 +316,9 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
|
||||
def render_group_representation representation, version, body: nil, status: :ok
|
||||
body ||= TagGroupMarkdownRenderer.render(representation)
|
||||
versions = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: version.tag_group_id)
|
||||
pred = versions.find_by(version_no: version.version_no - 1)&.id
|
||||
succ = versions.find_by(version_no: version.version_no + 1)&.id
|
||||
updated_at = version.created_at
|
||||
render json: {
|
||||
'id' => version.tag_group_id,
|
||||
'resource_type' => 'tag_group',
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
||||
'deprecated_at' => nil,
|
||||
'body' => body,
|
||||
'revision_id' => version.id,
|
||||
'pred' => pred,
|
||||
'succ' => succ,
|
||||
'updated_at' => updated_at }, status:
|
||||
document = TagGroupRepr.document(representation, version)
|
||||
document['body'] = body if body
|
||||
render json: document, status:
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
class ExternalTag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
# Must run before dependent association callbacks.
|
||||
before_destroy :prevent_destroy
|
||||
|
||||
enum :platform, nico: 'nico'
|
||||
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||
validate :immutable_identity, on: :update
|
||||
|
||||
has_many :post_external_tags, dependent: :delete_all
|
||||
has_many :posts, through: :post_external_tags
|
||||
@@ -19,21 +16,11 @@ class ExternalTag < ApplicationRecord
|
||||
|
||||
has_many :linked_tags, through: :nico_tag_relations, source: :tag
|
||||
|
||||
def snapshot_linked_tag_ids = linked_tags.distinct.order(:id).pluck(:id)
|
||||
|
||||
def delete
|
||||
raise ActiveRecord::ReadOnlyRecord, '外部タグは削除できません.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def immutable_identity
|
||||
errors.add(:platform, 'は変更できません.') if will_save_change_to_platform?
|
||||
errors.add(:name, 'は変更できません.') if will_save_change_to_name?
|
||||
end
|
||||
|
||||
def prevent_destroy
|
||||
errors.add(:base, '外部タグは削除できません.')
|
||||
throw(:abort)
|
||||
def snapshot_linked_tag_names language_code = 'ja'
|
||||
linked_tags
|
||||
.joins(:tag_names)
|
||||
.where(tag_names: { language_code:, primary_flg: true })
|
||||
.order('tag_names.name')
|
||||
.pluck('tag_names.name')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,7 +106,21 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||
snapshot_reference(TagVersion, row),
|
||||
snapshot_reference(NicoTagVersion, row, external: true)]
|
||||
when 'nico_tag_versions'
|
||||
[list_reference('tag_versions', 'tag_id', "#{ row }.linked_tag_ids")]
|
||||
# Legacy linked_tags snapshots contain names, not IDs. Match recorded names
|
||||
# conservatively; never expose a private linked name through an external tag.
|
||||
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
|
||||
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
|
||||
# DISTINCT prevents merging this independent name set into the correlated scan.
|
||||
# Keep byte-distinct names even when the column collation considers them equal.
|
||||
private_names = 'SELECT DISTINCT private_name.name, ' +
|
||||
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
|
||||
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
|
||||
'ON blocked.tag_id = private_tag.tag_id ' +
|
||||
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
|
||||
"WHERE private_name.name <> ''"
|
||||
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
|
||||
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
|
||||
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
|
||||
when 'tag_versions'
|
||||
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
|
||||
when 'tag_description_versions'
|
||||
|
||||
@@ -3,10 +3,5 @@ class NicoTagVersion < ApplicationRecord
|
||||
|
||||
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
|
||||
|
||||
validates :event_type, inclusion: { in: ['create', 'update'] }
|
||||
|
||||
def name
|
||||
tag = external_tag
|
||||
"#{ tag.platform }:#{ tag.name }" if tag
|
||||
end
|
||||
validates :name, presence: true
|
||||
end
|
||||
|
||||
@@ -139,8 +139,6 @@ class CountsVersionRepr
|
||||
result = {}
|
||||
table = model.table_name
|
||||
comparison = @inclusive ? '<=' : '<'
|
||||
name = model == NicoTagVersion ?
|
||||
CountsResourceDefinition.new('nico_tags').expression('name', table) : 'name'
|
||||
requests.each_slice(100) do |batch|
|
||||
queries = batch.map do |id, point|
|
||||
scope = HistoryAccess.visible(model.where(tag_id: id))
|
||||
@@ -150,8 +148,7 @@ class CountsVersionRepr
|
||||
"AND newer.version_no > #{ table }.version_no)", point)
|
||||
quoted = model.connection.quote(point.iso8601(6))
|
||||
names = model == TagVersion ? 'names' : 'JSON_OBJECT() AS names'
|
||||
scope.select("tag_id, #{ name } AS name, #{ names }, " +
|
||||
"#{ quoted } AS reference_point").to_sql
|
||||
scope.select("tag_id, name, #{ names }, #{ quoted } AS reference_point").to_sql
|
||||
end
|
||||
next if queries.empty?
|
||||
|
||||
|
||||
@@ -56,6 +56,21 @@ module TagGroupRepr
|
||||
'updated_at' => wiki_page.updated_at }
|
||||
end
|
||||
|
||||
def document representation, version
|
||||
HistoryAccess.require!(version)
|
||||
versions = HistoryAccess.visible(TagGroupVersion.all)
|
||||
.where(tag_group_id: version.tag_group_id)
|
||||
{ 'id' => version.tag_group_id,
|
||||
'resource_type' => 'tag_group',
|
||||
'title' => "#{ PREFIX }#{ representation.fetch('name') }",
|
||||
'deprecated_at' => nil,
|
||||
'body' => TagGroupMarkdownRenderer.render(representation),
|
||||
'revision_id' => version.id,
|
||||
'pred' => versions.find_by(version_no: version.version_no - 1)&.id,
|
||||
'succ' => versions.find_by(version_no: version.version_no + 1)&.id,
|
||||
'updated_at' => version.created_at }
|
||||
end
|
||||
|
||||
def current_version group
|
||||
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
end
|
||||
|
||||
@@ -28,18 +28,16 @@ class CountsCandidates
|
||||
[external, 'external', 'NULL', 'JSON_OBJECT()']]
|
||||
sources = sources.map do |population, kind, category, names|
|
||||
scope = population.scope.except(:select)
|
||||
name = population.definition.expression('name')
|
||||
if @params['query'].present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
|
||||
scope = scope.where("(#{ name } LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
|
||||
scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
|
||||
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
|
||||
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
|
||||
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
|
||||
end
|
||||
scope.select(Arel.sql(
|
||||
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
|
||||
"#{ name } AS name, #{ names } AS names, " +
|
||||
"#{ category } AS category, '#{ kind }' AS kind"))
|
||||
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
|
||||
.to_sql
|
||||
end
|
||||
clauses = []
|
||||
|
||||
@@ -113,7 +113,7 @@ class CountsPopulation
|
||||
else
|
||||
raise ArgumentError unless definition.axes(history).include?(key)
|
||||
|
||||
scope = scope.where("#{ definition.expression(key) } <=> ?", entry['value'])
|
||||
scope = scope.where(definition.expression(key) => entry['value'])
|
||||
end
|
||||
scope
|
||||
end
|
||||
|
||||
@@ -105,25 +105,12 @@ class CountsResourceDefinition
|
||||
def time_column = 'created_at'
|
||||
def column(key)
|
||||
raise ArgumentError unless fields.key?(key)
|
||||
# Keep the V1 Counts field name at the API boundary only.
|
||||
return 'linked_tag_ids' if resource == 'nico_tags' && key == 'linked_tags'
|
||||
|
||||
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
|
||||
end
|
||||
|
||||
def expression(key, row = 'v')
|
||||
if resource == 'nico_tags' && key == 'name'
|
||||
return "(SELECT CONCAT(external_tags.platform, ':', external_tags.name) " +
|
||||
"FROM external_tags WHERE external_tags.id = #{ row }.tag_id)"
|
||||
end
|
||||
|
||||
"#{ row }.#{ column(key) }"
|
||||
end
|
||||
|
||||
def changes
|
||||
columns = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
|
||||
resource == 'nico_tags' ? columns - ['name'] : columns
|
||||
end
|
||||
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
|
||||
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
|
||||
def axes(history)
|
||||
fields.reject { |_key, type| type == :json }.keys +
|
||||
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
|
||||
@@ -131,9 +118,7 @@ class CountsResourceDefinition
|
||||
|
||||
def metadata
|
||||
fields.map do |key, type|
|
||||
nullable = (resource == 'nico_tags' && key == 'name') ||
|
||||
model.columns_hash.fetch(column(key)).null
|
||||
{ key:, type:, nullable:,
|
||||
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
|
||||
operators: OPERATORS.fetch(type, []) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -201,8 +201,6 @@ class CountsStateQuery
|
||||
end
|
||||
|
||||
def listing
|
||||
external_name = CountsResourceDefinition.new('nico_tags')
|
||||
.expression('name', 'external_version')
|
||||
catalog = <<~SQL
|
||||
, tag_counts AS (
|
||||
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
|
||||
@@ -211,7 +209,7 @@ class CountsStateQuery
|
||||
SELECT 'internal' AS kind, tag_id, name, names,
|
||||
deprecated_at FROM tag_at WHERE event_type <> 'discard'
|
||||
UNION ALL
|
||||
SELECT tc.kind, tc.tag_id, #{ external_name } AS name, JSON_OBJECT() AS names,
|
||||
SELECT tc.kind, tc.tag_id, external_version.name, JSON_OBJECT() AS names,
|
||||
NULL AS deprecated_at
|
||||
FROM tag_counts tc
|
||||
LEFT JOIN tag_at internal_version
|
||||
|
||||
@@ -182,7 +182,6 @@ class CountsTableQuery
|
||||
order = sort_sql { detail_expression(_1) }
|
||||
query = query.order(Arel.sql((order + ['v.id ASC']).join(', ')))
|
||||
.limit(limit).offset((page - 1) * limit)
|
||||
query = query.preload(:external_tag) if @definition.resource == 'nico_tags'
|
||||
rows = query.to_a
|
||||
if ['posts', 'tags'].include?(@definition.resource)
|
||||
point = population.history ? population.at :
|
||||
@@ -487,7 +486,6 @@ class CountsTableQuery
|
||||
|
||||
table = model.table_name
|
||||
scope = HistoryAccess.visible(model.where(tag_id: ids)).where('created_at <= ?', point)
|
||||
scope = scope.includes(:external_tag) if model == NicoTagVersion
|
||||
bound = @connection.quote(point)
|
||||
if population.history && @definition.resource == 'posts'
|
||||
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class NicoTagVersionRecorder < VersionRecorder
|
||||
EVENT_TYPES = ['create', 'update'].freeze
|
||||
def self.record! external_tag:, event_type:, created_by_user:
|
||||
new(external_tag:, event_type:, created_by_user:).record!
|
||||
end
|
||||
@@ -15,7 +14,8 @@ class NicoTagVersionRecorder < VersionRecorder
|
||||
def record_key = :external_tag
|
||||
|
||||
def snapshot_attributes
|
||||
{ linked_tag_ids: @record.snapshot_linked_tag_ids.join(' ') }
|
||||
{ name: "#{ @record.platform }:#{ @record.name }",
|
||||
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
|
||||
end
|
||||
|
||||
def tracks_version_no_on_record? = false
|
||||
|
||||
@@ -37,6 +37,37 @@ class TagDescriptionOperation
|
||||
end
|
||||
end
|
||||
|
||||
def self.update_with_title! locale, tag:, body:, title:, actor:, base_revision_id: nil
|
||||
return update!(locale, tag:, body:, updated_by_user: actor,
|
||||
base_revision_id:) if title.nil?
|
||||
|
||||
title = title.to_s.strip
|
||||
tag_name = tag.tag_name(locale.language_code)
|
||||
if title.blank? || title.include?(':') || !tag_name || tag_name.name.include?(':')
|
||||
tag.errors.add(:title, :invalid)
|
||||
raise ActiveRecord::RecordInvalid, tag
|
||||
end
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
old_title = tag_name.name
|
||||
if title != old_title
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: actor)
|
||||
end
|
||||
|
||||
tag_name.update!(name: title) if title != old_title
|
||||
|
||||
update!(locale, tag:, body:, updated_by_user: actor,
|
||||
base_revision_id:)
|
||||
|
||||
if title != old_title
|
||||
tag.reload
|
||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: actor)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.record_version! description:, body:, user:
|
||||
creating = description.new_record?
|
||||
version_no = creating ? 1 : description.version_no + 1
|
||||
|
||||
@@ -17,7 +17,11 @@ Rails.application.routes.draw do
|
||||
resources :locales, only: [:index]
|
||||
resources :sitemap_entries, only: [:index, :show], param: :resource
|
||||
|
||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||
resources :tag_groups, only: [:index, :show, :create, :update] do
|
||||
collection do
|
||||
get 'name/:name', action: :show_by_name
|
||||
end
|
||||
end
|
||||
|
||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||
|
||||
@@ -28,6 +32,9 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :tags, only: [:index, :show] do
|
||||
collection do
|
||||
get :descriptions, to: 'tag_descriptions#index'
|
||||
post :descriptions, to: 'tag_descriptions#create'
|
||||
get 'descriptions/:description_id', to: 'tag_descriptions#show_by_id'
|
||||
get :autocomplete
|
||||
get :'with-depth', action: :with_depth
|
||||
get :versions, to: 'tag_versions#index'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddLinkedTagIdsToNicoTagVersions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :nico_tag_versions, :linked_tag_ids, :text
|
||||
end
|
||||
end
|
||||
@@ -1,42 +0,0 @@
|
||||
class MigrateLinkedTagsInNicoTagVersions < ActiveRecord::Migration[8.0]
|
||||
class MigrationTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'tag_versions'
|
||||
end
|
||||
|
||||
class MigrationNicoTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'nico_tag_versions'
|
||||
end
|
||||
|
||||
def up
|
||||
# 除票外部タグの恢復はないので除票履歴があれば全消しでよいはず.
|
||||
execute <<~SQL
|
||||
DELETE
|
||||
t1
|
||||
FROM
|
||||
nico_tag_versions t1
|
||||
INNER JOIN
|
||||
nico_tag_versions t2
|
||||
ON
|
||||
t1.tag_id = t2.tag_id
|
||||
AND t2.event_type = 'discard'
|
||||
SQL
|
||||
|
||||
MigrationNicoTagVersion.find_each do |ntv|
|
||||
linked_tag_ids = ntv.linked_tags.split.map { |name|
|
||||
# 厳密には一意ぢゃないけどめんだぅだからこれでいいや.
|
||||
MigrationTagVersion
|
||||
.order(created_at: :desc, id: :desc)
|
||||
.find_by("names ->> '$.ja.name' = ?", name)
|
||||
&.tag_id
|
||||
}.compact.uniq.sort.join(' ')
|
||||
|
||||
ntv.update_columns(linked_tag_ids:)
|
||||
end
|
||||
|
||||
change_column_null :nico_tag_versions, :linked_tag_ids, false
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
class RemoveLinkedTagsFromNicoTagVersions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_column :nico_tag_versions, :linked_tags
|
||||
remove_column :nico_tag_versions, :name
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddIndexToTagsPostCount < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_index :tags, :post_count
|
||||
end
|
||||
end
|
||||
生成ファイル
+3
-3
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_11_16_000000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -301,9 +301,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_16_000000) do
|
||||
t.bigint "tag_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.string "event_type", null: false
|
||||
t.string "name", null: false
|
||||
t.text "linked_tags", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.text "linked_tag_ids", null: false
|
||||
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
|
||||
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
||||
@@ -629,7 +630,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_11_16_000000) do
|
||||
t.integer "post_count", default: 0, null: false
|
||||
t.integer "version_no", null: false
|
||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
||||
t.index ["post_count"], name: "index_tags_on_post_count"
|
||||
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"
|
||||
end
|
||||
|
||||
@@ -11,63 +11,28 @@ RSpec.describe ExternalTag, type: :model do
|
||||
expect(external.reload.name).to eq('raw tag[]')
|
||||
end
|
||||
|
||||
it 'does not allow a persisted external tag to change its platform or name' do
|
||||
external = create(:external_tag, name: 'immutable')
|
||||
|
||||
expect { external.update!(name: 'renamed') }
|
||||
.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect(external.errors[:name]).to include('は変更できません.')
|
||||
|
||||
external.reload
|
||||
external.platform = nil
|
||||
expect { external.save! }.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect(external.errors[:platform]).to include('は変更できません.')
|
||||
|
||||
expect(external.reload).to have_attributes(platform: 'nico', name: 'immutable')
|
||||
end
|
||||
|
||||
it 'rejects delete, which bypasses destroy callbacks' do
|
||||
external = create(:external_tag)
|
||||
|
||||
expect { external.delete }.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
expect(external.reload).to be_persisted
|
||||
end
|
||||
|
||||
it 'increments the counter through post associations without changing identity' do
|
||||
external = create(:external_tag, name: 'counter')
|
||||
post = create(:post)
|
||||
|
||||
expect {
|
||||
PostExternalTag.create!(post:, external_tag: external)
|
||||
}.to change { external.reload.post_count }.by(1)
|
||||
|
||||
expect(external.reload).to have_attributes(platform: 'nico', name: 'counter')
|
||||
end
|
||||
|
||||
it 'does not destroy external tags or their post associations and history' do
|
||||
it 'deletes post associations without deleting posts or version history' do
|
||||
external = create(:external_tag)
|
||||
post = create(:post)
|
||||
PostExternalTag.create!(post:, external_tag: external)
|
||||
version = NicoTagVersionRecorder.record!(
|
||||
external_tag: external, event_type: :create, created_by_user: nil)
|
||||
|
||||
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
|
||||
external.destroy!
|
||||
|
||||
expect(external.reload).to be_persisted
|
||||
expect(PostExternalTag.exists?(post:, external_tag: external)).to be(true)
|
||||
expect(post.reload.external_tags).to contain_exactly(external)
|
||||
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
|
||||
expect(post.reload.external_tags).to be_empty
|
||||
expect(version.reload.tag_id).to eq(external.id)
|
||||
end
|
||||
|
||||
it 'does not destroy an external tag or its links to internal tags' do
|
||||
it 'deletes external links without deleting linked internal tags' do
|
||||
external = create(:external_tag)
|
||||
tag = create(:tag)
|
||||
relation = NicoTagRelation.create!(nico_tag: external, tag:)
|
||||
NicoTagRelation.create!(nico_tag: external, tag:)
|
||||
|
||||
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
|
||||
external.destroy!
|
||||
|
||||
expect(external.reload).to be_persisted
|
||||
expect(relation.reload).to be_persisted
|
||||
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
|
||||
expect(Tag.exists?(tag.id)).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,7 +76,7 @@ RSpec.describe 'Counts artifact policy and external references' do
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not expose a private linked tag ID through an external history snapshot' do
|
||||
it 'does not expose a private linked tag name through an external history snapshot' do
|
||||
tag = create(:tag, primary_name: 'private-linked-name')
|
||||
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
|
||||
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
|
||||
@@ -84,7 +84,7 @@ RSpec.describe 'Counts artifact policy and external references' do
|
||||
auto_generated: false, aliases: [] } })
|
||||
external = create(:external_tag, name: 'public-external')
|
||||
row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
|
||||
linked_tag_ids: tag.id.to_s,
|
||||
name: 'nico:public-external', linked_tags: 'private-linked-name',
|
||||
created_at: Time.utc(2024))
|
||||
post = create(:post)
|
||||
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
|
||||
@@ -99,7 +99,7 @@ RSpec.describe 'Counts artifact policy and external references' do
|
||||
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist
|
||||
end
|
||||
public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update',
|
||||
linked_tag_ids: '',
|
||||
name: 'nico:public-external', linked_tags: 'private-linked-name-extra',
|
||||
created_at: Time.utc(2024, 7))
|
||||
HistoryAccess.set(user: nil) do
|
||||
expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist
|
||||
|
||||
@@ -10,22 +10,23 @@ RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
|
||||
expect(next_tag.id).to be > external.id
|
||||
end
|
||||
|
||||
it 'does not reuse IDs after internal deletion while external tags remain' do
|
||||
it 'does not reuse physically deleted IDs in either table' do
|
||||
tag = create(:tag)
|
||||
external = create(:external_tag)
|
||||
TagName.where(tag_id: tag.id).delete_all
|
||||
tag.destroy!
|
||||
expect(ExternalTag.exists?(external.id)).to be(true)
|
||||
external.destroy!
|
||||
|
||||
expect(create(:tag).id).to be > external.id
|
||||
expect(create(:external_tag).id).to be > external.id
|
||||
end
|
||||
|
||||
it 'retains overlapping IDs after a tag update and a new allocation' do
|
||||
it 'retains existing overlapping IDs without renumbering on update or insert' do
|
||||
tag = create(:tag)
|
||||
external = create(:external_tag)
|
||||
external.update_columns(id: tag.id)
|
||||
tag.update!(category: :meta)
|
||||
external.update!(name: 'updated_external')
|
||||
before_tag = tag.reload.attributes
|
||||
before_external = external.reload.attributes
|
||||
|
||||
|
||||
@@ -2,17 +2,6 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Tag, type: :model do
|
||||
include WikiRecords
|
||||
|
||||
describe 'database indexes' do
|
||||
it 'has a non-unique index on post_count' do
|
||||
indexes = described_class.connection.indexes(described_class.table_name)
|
||||
index = indexes.find { |entry| entry.columns == ['post_count'] }
|
||||
|
||||
expect(index).to be_present
|
||||
expect(index.unique).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#has_description?' do
|
||||
let(:tag) { create(:tag) }
|
||||
|
||||
|
||||
@@ -47,34 +47,24 @@ RSpec.describe VersionRecord, type: :model do
|
||||
external_tag: nico_tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
linked_tag_ids: '',
|
||||
name: "nico:#{ nico_tag.name }",
|
||||
linked_tags: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
expect {
|
||||
version.update!(linked_tag_ids: '123')
|
||||
version.update!(name: 'nico:changed')
|
||||
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
end
|
||||
|
||||
|
||||
it 'rejects discard and restore versions for external tags' do
|
||||
[:discard, :restore].each do |event_type|
|
||||
version = NicoTagVersion.new(
|
||||
external_tag: nico_tag, version_no: 1, event_type:,
|
||||
linked_tag_ids: '', created_at: Time.current)
|
||||
|
||||
expect(version).not_to be_valid
|
||||
expect(version.errors[:event_type]).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
it 'prevents NicoTagVersion destroy' do
|
||||
version = NicoTagVersion.create!(
|
||||
external_tag: nico_tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
linked_tag_ids: '',
|
||||
name: "nico:#{ nico_tag.name }",
|
||||
linked_tags: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
||||
created_at: Time.utc(2023))
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 1,
|
||||
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
|
||||
event_type: 'create', name: 'beta', linked_tags: '', created_at: Time.utc(2023))
|
||||
snapshots = [
|
||||
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
|
||||
{ external_tag_id: external.id }]
|
||||
@@ -50,115 +50,6 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
|
||||
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
|
||||
|
||||
context 'nico link ID snapshots through the V1 field name' do
|
||||
let(:base) do
|
||||
super().merge(resource: 'nico_tags')
|
||||
end
|
||||
|
||||
before do
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 2,
|
||||
event_type: 'update', linked_tag_ids: tag.id.to_s,
|
||||
created_at: Time.utc(2024, 1, 1))
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 3,
|
||||
event_type: 'update', linked_tag_ids: '',
|
||||
created_at: Time.utc(2024, 2, 1))
|
||||
end
|
||||
|
||||
it 'returns ID values under linked_tags without exposing the physical column key' do
|
||||
result = query
|
||||
expect(versions(result)).to eq([1, 2, 3])
|
||||
expect(result['versions'].map { _1.fetch('linked_tags') }).to eq(['', tag.id.to_s, ''])
|
||||
result['versions'].each do |row|
|
||||
expect(row).not_to have_key('linked_tag_ids')
|
||||
expect(row.fetch('name')).to eq('nico:beta')
|
||||
end
|
||||
end
|
||||
|
||||
it 'preloads external names separately for aliased link-change details' do
|
||||
statements = []
|
||||
listener = lambda do |_name, _start, _finish, _id, payload|
|
||||
statements << payload[:sql]
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(listener, 'sql.active_record') do
|
||||
result = query(axis('changed_field', 'linked_tags'))
|
||||
expect(versions(result)).to eq([2, 3])
|
||||
expect(result.fetch('versions').map { _1.fetch('name') })
|
||||
.to eq(['nico:beta', 'nico:beta'])
|
||||
end
|
||||
|
||||
details = statements.find { _1.include?('AS counts_latest') }
|
||||
expect(details).not_to be_nil
|
||||
expect(details).not_to include('JOIN `external_tags`')
|
||||
names = statements.select { _1.match?(/SELECT .* FROM `external_tags`/) }
|
||||
expect(names.length).to eq(1)
|
||||
end
|
||||
|
||||
it 'advertises the legacy field, label and default columns only' do
|
||||
get '/counts/definitions/nico_tags', params: { locale: 'ja' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
['fields', 'filter_fields', 'output_fields'].each do |collection|
|
||||
keys = body.fetch(collection).map { _1.fetch('key') }
|
||||
expect(keys).to include('linked_tags')
|
||||
expect(keys).not_to include('linked_tag_ids')
|
||||
end
|
||||
field = body.fetch('output_fields').find { _1['key'] == 'linked_tags' }
|
||||
expect(field.fetch('label')).to eq('counts:attributes.linked_tags')
|
||||
body.fetch('axes').each_value do |keys|
|
||||
expect(keys).to include('linked_tags')
|
||||
expect(keys).not_to include('linked_tag_ids')
|
||||
end
|
||||
body.fetch('defaults').select { _1['mode'] == 'details' }.each do |entry|
|
||||
settings = entry.fetch('params').to_h { [_1.fetch('key'), _1.fetch('value')] }
|
||||
expect(settings.fetch('columns').split).to include('name', 'linked_tags')
|
||||
expect(settings.fetch('columns').split).not_to include('linked_tag_ids')
|
||||
end
|
||||
end
|
||||
|
||||
it 'filters IDs and legacy drills and compares link changes through linked_tags' do
|
||||
predicate = { conditions: '', 'conditions.0.field' => 'linked_tags',
|
||||
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
|
||||
expect(versions(query(predicate))).to eq([2])
|
||||
expect(versions(query(axis('linked_tags', tag.id.to_s, root: 'drill')))).to eq([2])
|
||||
expect(versions(query(axis('changed_field', 'linked_tags')))).to eq([2, 3])
|
||||
expect(versions(query(predicate.merge('conditions.0.value' => 'alpha')))).to be_empty
|
||||
end
|
||||
|
||||
it 'sorts ID snapshots through the public field in both directions' do
|
||||
expect(versions(query(sort: 'linked_tags:asc version_no:asc'))).to eq([1, 3, 2])
|
||||
expect(versions(query(sort: 'linked_tags:desc version_no:asc'))).to eq([2, 1, 3])
|
||||
end
|
||||
|
||||
it 'restores historical link IDs before applying a latest snapshot filter' do
|
||||
predicate = { definition: 'latest', at: '2024-01-15T00:00:00Z',
|
||||
conditions: '', 'conditions.0.field' => 'linked_tags',
|
||||
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
|
||||
expect(versions(query(predicate))).to eq([2])
|
||||
expect(versions(query(predicate.except(:at)))).to be_empty
|
||||
end
|
||||
|
||||
it 'groups by linked_tags ID values and accepts the resulting drill condition' do
|
||||
sign_in_as(create(:user, :admin))
|
||||
result = query(mode: 'summary', rows: 'linked_tags', cols: '', sort: '')
|
||||
expect(result.fetch('cells')).to match_array([
|
||||
{ 'values' => [''], 'count' => 2 },
|
||||
{ 'values' => [tag.id.to_s], 'count' => 1 }])
|
||||
expect(versions(query(axis('linked_tags', tag.id.to_s)))).to eq([2])
|
||||
end
|
||||
|
||||
it 'rejects the physical column as a public filter, axis, sort or change field' do
|
||||
sign_in_as(create(:user, :admin))
|
||||
[
|
||||
axis('linked_tag_ids', tag.id.to_s),
|
||||
axis('changed_field', 'linked_tag_ids'),
|
||||
{ mode: 'summary', rows: 'linked_tag_ids', cols: '', sort: '' },
|
||||
{ sort: 'linked_tag_ids:asc' }].each do |invalid|
|
||||
get '/counts/query', params: base.merge(invalid)
|
||||
expect(response).to have_http_status(:bad_request), invalid.inspect
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'identifies a totals timeout without exposing SQL in the error response' do
|
||||
allow_any_instance_of(CountsPivotTotals).to receive(:call)
|
||||
.and_raise(CountsPivotTotals::TimedOut, 'private SQL')
|
||||
@@ -332,10 +223,11 @@ RSpec.describe 'Counts table API', type: :request do
|
||||
expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4])
|
||||
end
|
||||
|
||||
it 'resolves immutable external names across historical snapshots' do
|
||||
expect(versions(query(tags: 'nico:beta'))).to eq([1])
|
||||
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([])
|
||||
expect(versions(query(tags: 'not:nico:beta'))).to eq([2, 3, 4])
|
||||
it 'resolves external names from current registrations too' do
|
||||
external.update!(name: 'renamed-beta')
|
||||
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([1])
|
||||
expect(versions(query(tags: 'nico:beta'))).to eq([])
|
||||
expect(versions(query(tags: 'not:nico:renamed-beta'))).to eq([2, 3, 4])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -38,9 +38,12 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
end
|
||||
|
||||
it 'filters by nico tag name, linked tag name, and link status' do
|
||||
linked = create(:external_tag, name: 'search_linked')
|
||||
unlinked = create(:external_tag, name: 'search_unlinked')
|
||||
other = create(:external_tag, name: 'other')
|
||||
linked = create(:external_tag)
|
||||
linked.update!(name: 'search_linked')
|
||||
unlinked = create(:external_tag)
|
||||
unlinked.update!(name: 'search_unlinked')
|
||||
other = create(:external_tag)
|
||||
other.update!(name: 'other')
|
||||
destination = create(:tag, category: :general)
|
||||
primary_tag_name_for(destination, 'ja').update!(name: 'destination_search')
|
||||
NicoTagRelation.create!(nico_tag: linked, tag: destination)
|
||||
@@ -77,9 +80,11 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
end
|
||||
|
||||
it 'sorts by name and timestamps' do
|
||||
older = create(:external_tag, name: 'ordered_a')
|
||||
older = create(:external_tag)
|
||||
older.update!(name: 'ordered_a')
|
||||
older.update_columns(created_at: 2.days.ago)
|
||||
newer = create(:external_tag, name: 'ordered_b')
|
||||
newer = create(:external_tag)
|
||||
newer.update!(name: 'ordered_b')
|
||||
newer.update_columns(created_at: 1.day.ago)
|
||||
older_post_tag =
|
||||
PostExternalTag.create!(post: create(:post), external_tag: older)
|
||||
@@ -184,8 +189,9 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
|
||||
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
|
||||
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||
expect(versions.last.linked_tag_ids.split.map(&:to_i))
|
||||
.to match_array([linked_a.id, linked_b.id])
|
||||
expect(versions.last.linked_tags.split).to match_array([
|
||||
'nico_linked_a',
|
||||
'nico_linked_b'])
|
||||
expect(versions.last.created_by_user_id).to eq(admin.id)
|
||||
end
|
||||
|
||||
@@ -204,7 +210,7 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
expect(json).to eq([])
|
||||
expect(nico_tag.reload.linked_tags).to be_empty
|
||||
expect(nico_tag.nico_tag_versions.order(:version_no).last)
|
||||
.to have_attributes(linked_tag_ids: '', created_by_user: member)
|
||||
.to have_attributes(linked_tags: '', created_by_user: member)
|
||||
end
|
||||
|
||||
it 'returns the tags field error when a nico tag is specified directly' do
|
||||
|
||||
@@ -20,7 +20,7 @@ RSpec.describe 'Post history privacy', type: :request do
|
||||
created_at: origin + index.hours)
|
||||
end
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
|
||||
linked_tag_ids: tag.id.to_s,
|
||||
name: 'nico:external-history-tag', linked_tags: 'private-history-tag',
|
||||
created_at: origin)
|
||||
['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
|
||||
snapshots =
|
||||
|
||||
@@ -22,55 +22,6 @@ RSpec.describe 'Counts export contracts' do
|
||||
.and_return(@counts_export_dir)
|
||||
end
|
||||
|
||||
context 'nico link IDs with the V1 export field name' do
|
||||
let(:tag) { create(:tag, primary_name: 'linked export tag') }
|
||||
let(:external) { create(:external_tag, name: 'external export tag') }
|
||||
let(:payload) do
|
||||
super().merge('resource' => 'nico_tags', 'columns' => 'linked_tags',
|
||||
'headers' => '["linked_tags"]')
|
||||
end
|
||||
|
||||
before do
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 1,
|
||||
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
|
||||
NicoTagVersion.create!(external_tag: external, version_no: 2,
|
||||
event_type: 'update', linked_tag_ids: tag.id.to_s, created_at: Time.utc(2024))
|
||||
end
|
||||
|
||||
it 'exports recorded IDs with the legacy JSON key and metadata' do
|
||||
result = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
||||
expect(result.fetch('data')).to eq([
|
||||
{ 'linked_tags' => '' }, { 'linked_tags' => tag.id.to_s }])
|
||||
expect(result.fetch('metadata').fetch('columns')).to eq(['linked_tags'])
|
||||
keys = result.fetch('metadata').fetch('fields').map { _1.fetch('key') }
|
||||
expect(keys).to include('linked_tags')
|
||||
expect(keys).not_to include('linked_tag_ids')
|
||||
end
|
||||
|
||||
it 'exports the selected legacy CSV header and ID values' do
|
||||
body = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
||||
expect(CSV.parse(body.delete_prefix("\uFEFF")))
|
||||
.to eq([['linked_tags'], [''], [tag.id.to_s]])
|
||||
end
|
||||
|
||||
it 'keeps the legacy link column in default JSON exports' do
|
||||
options = payload.except('columns', 'headers')
|
||||
result = JSON.parse(CountsTableExport.new(options).body.to_a.join)
|
||||
expect(result.fetch('metadata').fetch('columns')).to include('linked_tags')
|
||||
result.fetch('data').each do |row|
|
||||
expect(row).to have_key('linked_tags')
|
||||
expect(row).not_to have_key('linked_tag_ids')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects the physical column as a selected CSV or JSON field' do
|
||||
['csv', 'json'].each do |format|
|
||||
options = payload.merge('format' => format, 'columns' => 'linked_tag_ids')
|
||||
expect { CountsTableExport.new(options) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'serialization after the authorised query' do
|
||||
let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] }
|
||||
let(:page) do
|
||||
|
||||
@@ -8,23 +8,21 @@ RSpec.describe NicoTagVersionRecorder do
|
||||
described_class.record!(external_tag:, event_type:, created_by_user: member)
|
||||
end
|
||||
|
||||
it 'records the external association and derives the platform-qualified name' do
|
||||
it 'records the external association and platform-qualified name' do
|
||||
version = record(:create)
|
||||
|
||||
expect(version).to have_attributes(
|
||||
external_tag:, version_no: 1, event_type: 'create',
|
||||
name: 'nico:raw tag[]', linked_tag_ids: '', created_by_user: member)
|
||||
name: 'nico:raw tag[]', linked_tags: '', created_by_user: member)
|
||||
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
|
||||
end
|
||||
|
||||
it 'uses the latest history number when the record has no version_no column' do
|
||||
first = record(:create)
|
||||
tag = create(:tag)
|
||||
NicoTagRelation.create!(nico_tag: external_tag, tag:)
|
||||
external_tag.update!(name: 'changed')
|
||||
second = record(:update)
|
||||
|
||||
expect(second).to have_attributes(version_no: 2, name: 'nico:raw tag[]',
|
||||
linked_tag_ids: tag.id.to_s)
|
||||
expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
|
||||
expect(first.reload.name).to eq('nico:raw tag[]')
|
||||
expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
|
||||
end
|
||||
@@ -42,7 +40,7 @@ RSpec.describe NicoTagVersionRecorder do
|
||||
|
||||
expect(version).to have_attributes(
|
||||
external_tag: external, name: 'registered_external:foo', version_no: 1,
|
||||
event_type: 'create', linked_tag_ids: '', created_by_user: member)
|
||||
event_type: 'create', linked_tags: '', created_by_user: member)
|
||||
end
|
||||
|
||||
it 'returns the latest version without appending an unchanged snapshot' do
|
||||
@@ -66,21 +64,13 @@ RSpec.describe NicoTagVersionRecorder do
|
||||
expect(external_tag.nico_tag_versions).to contain_exactly(first)
|
||||
end
|
||||
|
||||
|
||||
it 'rejects discard and restore events for external tags' do
|
||||
[:discard, :restore].each do |event_type|
|
||||
expect { record(event_type) }
|
||||
.to raise_error(ArgumentError, "Invalid event_type: #{ event_type }")
|
||||
end
|
||||
end
|
||||
|
||||
it 'records sorted linked internal IDs and later link removal' do
|
||||
it 'records sorted linked internal names and later link removal' do
|
||||
tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) }
|
||||
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
|
||||
|
||||
expect(record(:create).linked_tag_ids).to eq(tags.map(&:id).sort.join(' '))
|
||||
expect(record(:create).linked_tags).to eq('a_link z_link')
|
||||
external_tag.linked_tags = [tags.first]
|
||||
|
||||
expect(record(:update)).to have_attributes(version_no: 2, linked_tag_ids: tags.first.id.to_s)
|
||||
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
|
||||
end
|
||||
end
|
||||
|
||||
-3366
ファイル差分が大きすぎるため省略します
差分を読み込み
+4
-12
@@ -158,15 +158,7 @@ private ID の正誤に応じて許可可否を変えないため、条件の一
|
||||
非公開列を含む列順変更は API の column_values_editable でも不可と通知する。
|
||||
公開列の値を使うソートは引き続き利用できる。
|
||||
明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。
|
||||
外部タグ履歴の linked_tag_ids は、連携する内部タグの ID を半角スペース区切りで
|
||||
記録する。除票履歴のある内部タグの ID を含むスナップショットを非管理者へ公開しない。
|
||||
名前や UI ロカールを参照 ID の判定に使わない。
|
||||
外部タグ名は履歴に保存せず、external_tags の platform と name を参照する。
|
||||
|
||||
V1 Counts の公開フィールド名は linked_tags を維持し、DB の linked_tag_ids へ対応付ける。
|
||||
明細・フィルタ・集計軸・変更項目・ソート・CSV / JSON 出力で同じ対応付けを使う。
|
||||
既定列と既存の表示ラベルも linked_tags のままとし、値は ID のスペース区切りとする。
|
||||
linked_tag_ids は物理カラム名であり、Counts の公開フィールドとしては受け付けない。
|
||||
旧 URL や保存済み設定のフィールド指定は維持できるが、値にタグ名を指定した検索条件や
|
||||
ドリル条件は ID 値への見直しが必要となる。旧履歴の名称は移行後の ID 列から復元できず、
|
||||
現在の名称からの推定変換も行わない。
|
||||
外部タグ履歴の linked_tags は名前しか記録していないため、非公開タグの記録名を含む
|
||||
スナップショットを保守的に非公開とする。名前全体を空白区切りの境界で照合する。
|
||||
区切りが曖昧な旧データでは過剰な秘匿の可能性はあるが、参照 ID がない旧形式から
|
||||
名前の公開可否を楽観的に推定しない。
|
||||
|
||||
+6
-1
@@ -166,7 +166,12 @@ export const pageRoutes = (
|
||||
robots: 'noindex', authentication: 'user' } },
|
||||
{ path: ':id', element: <MaterialDetailPage/>,
|
||||
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
|
||||
{ path: 'wiki', element: <WikiSearchPage/>,
|
||||
{ path: 'wiki', element: <LocaleRedirect to="/tags"/>,
|
||||
handle: { redirectTo: '/tags' } },
|
||||
{ path: 'tag-descriptions', element: <LocaleRedirect to="/tags"/>,
|
||||
handle: { redirectTo: '/tags' } },
|
||||
{ path: 'tag-groups',
|
||||
element: <WikiSearchPage key="tag_group" resourceType="tag_group"/>,
|
||||
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
|
||||
{ path: 'wiki/:title', element: <WikiDetailPage/>,
|
||||
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { fetchWikiPage } from '@/lib/wiki'
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
|
||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||
import type { Material, Menu, MenuVisibleItem, Tag, User, WikiPage } from '@/types'
|
||||
|
||||
type Props = {
|
||||
user: User | null
|
||||
@@ -39,10 +39,11 @@ type NavDensity = 'auto' | 'comfortable' | 'compact'
|
||||
|
||||
|
||||
export const menuOutline = (
|
||||
{ tag, material, wikiId, user, pathName: fullPathname }: {
|
||||
{ tag, material, wikiId, wikiResourceType, user, pathName: fullPathname }: {
|
||||
tag?: Tag | null
|
||||
material?: Material | null
|
||||
wikiId: number | null
|
||||
wikiResourceType?: WikiPage['resourceType']
|
||||
user: User | null
|
||||
pathName: string },
|
||||
): Menu => {
|
||||
@@ -99,17 +100,29 @@ export const menuOutline = (
|
||||
visible: materialFlg && Boolean (material?.tag) },
|
||||
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
|
||||
visible: materialFlg }] },
|
||||
{ ...message ('wiki'), to: '/wiki', subMenu: [
|
||||
{ ...message ('search'), to: '/wiki' },
|
||||
{ ...message ('create'), to: '/wiki/new', visible: editable },
|
||||
{ ...message ('allHistory'), to: '/wiki/changes' },
|
||||
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
|
||||
{ component: <Separator/>, visible: wikiPageFlg },
|
||||
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
|
||||
visible: wikiPageFlg },
|
||||
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||
visible: wikiPageFlg && editable }] },
|
||||
...([
|
||||
{ key: 'tagGroups', path: '/tag-groups', type: 'tag_group' }] as const)
|
||||
.map (({ key, path, type }): MenuVisibleItem => {
|
||||
const current = (wikiResourceType ?? 'tag_description') === type
|
||||
const detailFlg = wikiPageFlg && current
|
||||
const creationPath =
|
||||
type === 'tag_group'
|
||||
? `/wiki/new?title=${ encodeURIComponent ('タググループ:') }` : '/wiki/new'
|
||||
return { ...message (key), to: path,
|
||||
base: (pathName.startsWith ('/wiki') && current) ? '/wiki' : path,
|
||||
subMenu: [
|
||||
{ ...message ('search'), to: path },
|
||||
{ ...message ('create'), to: creationPath, visible: editable },
|
||||
{ ...message ('allHistory'), to: '/wiki/changes' },
|
||||
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
|
||||
{ component: <Separator/>, visible: detailFlg },
|
||||
{ ...message ('postsWithCount', { count: postCount }),
|
||||
to: `/posts?tags=${ wikiTitle }`, visible: detailFlg },
|
||||
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`,
|
||||
visible: detailFlg },
|
||||
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||
visible: detailFlg && editable }] }
|
||||
}),
|
||||
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
|
||||
{ ...message ('search'), to: '/help' },
|
||||
{ ...message ('allHistory'), to: '/help/changes' },
|
||||
@@ -229,7 +242,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||
queryKey: materialsKeys.show (effectiveTitle, locale),
|
||||
queryFn: () => fetchMaterial (effectiveTitle, locale) })
|
||||
|
||||
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
|
||||
const menu = menuOutline ({ tag, material, wikiId,
|
||||
wikiResourceType: wikiPage?.resourceType, user, pathName: location.pathname })
|
||||
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
||||
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
|
||||
const moreMenu = menu.filter (item =>
|
||||
|
||||
@@ -100,10 +100,11 @@ const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
|
||||
|
||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
|
||||
const title = url.searchParams.get ('title') ?? ''
|
||||
const resourceType = 'tag_group'
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: wikiKeys.index ({ title }, locale),
|
||||
queryFn: () => fetchWikiPages ({ title }, locale) })
|
||||
queryKey: wikiKeys.index ({ title, resourceType }, locale),
|
||||
queryFn: () => fetchWikiPages ({ title, resourceType }, locale) })
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +311,8 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
|
||||
&& Boolean (mPost (u.pathname))),
|
||||
run: prefetchPostShow },
|
||||
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
|
||||
{ test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex },
|
||||
{ test: u => u.pathname === '/tag-groups',
|
||||
run: prefetchWikiPagesIndex },
|
||||
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
|
||||
&& Boolean (mWiki (u.pathname))),
|
||||
run: prefetchWikiPageShow },
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
FetchPostsParams,
|
||||
FetchTagsParams,
|
||||
MaterialFilter,
|
||||
WikiPage,
|
||||
WikiTagReference } from '@/types'
|
||||
|
||||
export const localesKeys = {
|
||||
@@ -130,7 +131,7 @@ export const wikiKeys = {
|
||||
autolinkCandidates: (language: string, locale: LocaleCode) =>
|
||||
['wiki', 'autolink', locale, language] as const,
|
||||
index: (
|
||||
p: { title?: string },
|
||||
p: { title?: string, resourceType?: WikiPage['resourceType'] },
|
||||
locale: LocaleCode = 'ja',
|
||||
) => ['wiki', locale, 'index', p] as const,
|
||||
show: (
|
||||
|
||||
+56
-12
@@ -1,6 +1,6 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { apiGet, apiPost, apiPut } from '@/lib/api'
|
||||
import { switchLocalePath } from '@/lib/locales'
|
||||
import { fetchTag, fetchTagByName } from '@/lib/tags'
|
||||
|
||||
@@ -24,19 +24,54 @@ export const fetchWikiChanges = (
|
||||
|
||||
|
||||
export const fetchWikiPages = async (
|
||||
{ title }: { title?: string },
|
||||
{ title, resourceType = 'tag_description' }: {
|
||||
title?: string, resourceType?: WikiPage['resourceType'] },
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<WikiPage[]> =>
|
||||
await apiGet ('/wiki', { locale, params: { title } })
|
||||
): Promise<WikiPage[]> => apiGet (
|
||||
resourceType === 'tag_group' ? '/tag_groups' : '/tags/descriptions',
|
||||
{ locale, params: { title, document: '1' } })
|
||||
|
||||
|
||||
export const fetchWikiPage = async (
|
||||
id: string,
|
||||
{ version }: { version?: string },
|
||||
id: string,
|
||||
{ version }: { version?: string },
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<WikiPage> =>
|
||||
await apiGet (`/wiki/${ encodeURIComponent (id) }`, {
|
||||
locale, params: version ? { version } : { } })
|
||||
): Promise<WikiPage> => {
|
||||
if (version != null)
|
||||
return apiGet (`/wiki/${encodeURIComponent (id)}`, { locale, params: { version } })
|
||||
try
|
||||
{
|
||||
return await apiGet (`/tags/descriptions/${encodeURIComponent (id)}`,
|
||||
{ locale, params: { document: '1' } })
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
if (!isAxiosError (error) || error.response?.status !== 404)
|
||||
throw error
|
||||
return apiGet (`/tag_groups/${encodeURIComponent (id)}`,
|
||||
{ locale, params: { document: '1' } })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const saveTagDocument = async (
|
||||
{ title, body, page }: { title: string, body: string, page?: WikiPage },
|
||||
locale: LocaleCode,
|
||||
): Promise<WikiPage> => {
|
||||
const groupName = /^(?:タググループ:|tag_group:)(.*)$/i.exec (title.trim ())?.[1]
|
||||
const options = { locale, params: { document: '1' } }
|
||||
if (page?.resourceType === 'tag_group' || (page == null && groupName != null))
|
||||
{
|
||||
const attributes = { name: groupName ?? title.trim (), body }
|
||||
return page == null
|
||||
? apiPost ('/tag_groups', attributes, options)
|
||||
: apiPut (`/tag_groups/${encodeURIComponent (String (page.id))}`, attributes, options)
|
||||
}
|
||||
if (page == null)
|
||||
return apiPost ('/tags/descriptions', { title, body }, options)
|
||||
return apiPut (`/tags/${encodeURIComponent (String (page.tagId))}/description`,
|
||||
{ title, body }, options)
|
||||
}
|
||||
|
||||
|
||||
export const fetchWikiPageByTitle = async (
|
||||
@@ -46,7 +81,14 @@ export const fetchWikiPageByTitle = async (
|
||||
): Promise<WikiPage | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } })
|
||||
if (version != null)
|
||||
return await apiGet (`/wiki/title/${encodeURIComponent (title)}`,
|
||||
{ locale, params: { version } })
|
||||
const groupName = /^(?:タググループ:|tag_group:)(.*)$/i.exec (title.trim ())?.[1]
|
||||
const path = groupName == null
|
||||
? `/tags/name/${encodeURIComponent (title)}/description`
|
||||
: `/tag_groups/name/${encodeURIComponent (groupName)}`
|
||||
return await apiGet (path, { locale, params: { document: '1' } })
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
@@ -72,7 +114,7 @@ export const fetchWikiPageByReference = async (
|
||||
{
|
||||
if (!isAxiosError (error) || error.response?.status !== 404)
|
||||
throw error
|
||||
const tag = await fetchWikiTag ({ wikiId: reference }, locale)
|
||||
const tag = await fetchTagByName (reference, locale)
|
||||
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +128,9 @@ export const fetchWikiTag = async (
|
||||
return fetchTagByName (reference.name, locale)
|
||||
try
|
||||
{
|
||||
return await apiGet (`/wiki/${encodeURIComponent (reference.wikiId)}/tag`, { locale })
|
||||
const page = await fetchWikiPage (reference.wikiId, { }, locale)
|
||||
return (page.resourceType === 'tag_description' && page.tagId != null)
|
||||
? fetchTag (String (page.tagId), locale) : null
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"materials": "チカㇻペ",
|
||||
"suppressions": "アエアイカㇷ゚テㇷ゚",
|
||||
"wiki": "Wiki",
|
||||
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
|
||||
"tagGroups": "$t(counts:resources.tag_groups)",
|
||||
"create": "アシㇼ",
|
||||
"edit": "イヌイェㇷ゚",
|
||||
"entertainment": "シノッ",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"materials": "素材",
|
||||
"suppressions": "抑止",
|
||||
"wiki": "Wiki",
|
||||
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
|
||||
"tagGroups": "$t(counts:resources.tag_groups)",
|
||||
"create": "新規",
|
||||
"edit": "編輯",
|
||||
"entertainment": "おたのしみ",
|
||||
|
||||
@@ -135,7 +135,7 @@ const MaterialDetailPage: FC = () => {
|
||||
|
||||
<TabGroup>
|
||||
{material.tag && (
|
||||
<Tab name={label ('navigation:wiki')}>
|
||||
<Tab name={label ('navigation:tagDescriptions')}>
|
||||
<WikiBody
|
||||
title={material.tag.name}
|
||||
language={localeLanguage (locale)}
|
||||
|
||||
@@ -176,7 +176,7 @@ const PostListPage: FC = () => {
|
||||
{loading && label ('common:loading')}
|
||||
</Tab>
|
||||
{tags.length === 1 && (
|
||||
<Tab name={label ('navigation:wiki')}>
|
||||
<Tab name={label ('navigation:tagDescriptions')}>
|
||||
<WikiBody title={tags[0]} body={wikiPage?.body}
|
||||
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
|
||||
? localeLanguage (locale) : 'und')}/>
|
||||
|
||||
@@ -51,7 +51,8 @@ const WikiDetailPage: FC = () => {
|
||||
queryKey: helpKeys.titles ('ja'),
|
||||
queryFn: () => getHelpTitleIndex ('ja'),
|
||||
staleTime: Infinity })
|
||||
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
|
||||
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ()
|
||||
.replace (/^(?:ヘルプ|手引):/, '')
|
||||
const identifiers = useMemo (() => (
|
||||
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
|
||||
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
|
||||
|
||||
@@ -11,8 +11,8 @@ import FormField from '@/components/common/FormField'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { apiGet, apiPut } from '@/lib/api'
|
||||
import { wikiKeys } from '@/lib/queryKeys'
|
||||
import { fetchWikiPage, saveTagDocument } from '@/lib/wiki'
|
||||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
@@ -33,7 +33,7 @@ const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
type WikiFormField = 'title' | 'body'
|
||||
type WikiFormField = 'title' | 'name' | 'body'
|
||||
|
||||
|
||||
const WikiEditPage: FC<Props> = ({ user }) => {
|
||||
@@ -50,7 +50,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
||||
const source = `${locale}:${id}`
|
||||
const { data: wiki, isPending: loading } = useQuery ({
|
||||
queryKey: wikiKeys.show (id ?? '', { }, locale),
|
||||
queryFn: () => apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, { locale }),
|
||||
queryFn: () => fetchWikiPage (id ?? '', { }, locale),
|
||||
enabled: editable })
|
||||
const [body, setBody] = useState (wiki?.body ?? '')
|
||||
const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source)
|
||||
@@ -60,22 +60,19 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
||||
useValidationErrors<WikiFormField> ()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (pending || !editable)
|
||||
if (pending || !editable || wiki == null)
|
||||
return
|
||||
clearValidationErrors ()
|
||||
|
||||
const formData = new FormData ()
|
||||
formData.append ('title', title)
|
||||
formData.append ('body', body)
|
||||
|
||||
try
|
||||
{
|
||||
await apiPut (`/wiki/${ id }`, formData,
|
||||
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
await saveTagDocument ({ title, body, page: wiki }, locale)
|
||||
qc.setQueryData (wikiKeys.show (title, { }, locale),
|
||||
(prev: WikiPage | undefined) =>
|
||||
prev == null ? prev : { ...prev, title, body })
|
||||
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
||||
await Promise.all ([
|
||||
qc.invalidateQueries ({ queryKey: wikiKeys.root }),
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })])
|
||||
toast ({ title: label ('shared:messages.postSuccess') })
|
||||
navigate (`/wiki/${ encodeURIComponent (title) }`)
|
||||
}
|
||||
@@ -110,7 +107,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
||||
|
||||
{/* タイトル */}
|
||||
{/* TODO: タグ補完 */}
|
||||
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
|
||||
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input type="text"
|
||||
value={title}
|
||||
|
||||
@@ -12,7 +12,7 @@ import FormField from '@/components/common/FormField'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { apiPost } from '@/lib/api'
|
||||
import { saveTagDocument } from '@/lib/wiki'
|
||||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
@@ -21,7 +21,7 @@ import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User, WikiPage } from '@/types'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const mdParser = new MarkdownIt
|
||||
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
|
||||
@@ -32,7 +32,7 @@ const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
type WikiFormField = 'title' | 'body'
|
||||
type WikiFormField = 'title' | 'name' | 'body'
|
||||
|
||||
|
||||
const WikiNewPage: FC<Props> = ({ user }) => {
|
||||
@@ -55,14 +55,9 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
||||
const handleSubmit = async () => {
|
||||
clearValidationErrors ()
|
||||
|
||||
const formData = new FormData
|
||||
formData.append ('title', title)
|
||||
formData.append ('body', body)
|
||||
|
||||
try
|
||||
{
|
||||
const data = await apiPost<WikiPage> ('/wiki', formData,
|
||||
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
const data = await saveTagDocument ({ title, body }, locale)
|
||||
await qc.cancelQueries ({ queryKey: wikiKeys.root })
|
||||
qc.setQueryData (wikiKeys.show (data.title, { }, locale), data)
|
||||
await Promise.all ([
|
||||
@@ -91,7 +86,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
{/* タイトル */}
|
||||
{/* TODO: タグ補完 */}
|
||||
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
|
||||
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<input type="text"
|
||||
value={title}
|
||||
|
||||
@@ -8,7 +8,7 @@ import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import PageHead from '@/components/common/PageHead'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { fetchWikiPages } from '@/lib/wiki'
|
||||
import { wikiKeys } from '@/lib/queryKeys'
|
||||
import { dateString, inputClass } from '@/lib/utils'
|
||||
|
||||
@@ -17,16 +17,20 @@ import type { FormEvent , FC } from 'react'
|
||||
import type { WikiPage } from '@/types'
|
||||
|
||||
|
||||
const WikiSearchPage: FC = () => {
|
||||
const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
|
||||
{ resourceType = 'tag_description' },
|
||||
) => {
|
||||
const { message, label } = useUITranslation ('shared')
|
||||
const locale = useLocale ()
|
||||
const heading =
|
||||
resourceType === 'tag_group'
|
||||
? 'navigation:tagGroups' : 'navigation:tagDescriptions'
|
||||
const [title, setTitle] = useState ('')
|
||||
const [text, setText] = useState ('')
|
||||
const [submittedTitle, setSubmittedTitle] = useState ('')
|
||||
const { data: results = [], refetch } = useQuery ({
|
||||
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
|
||||
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
|
||||
locale, params: { title: submittedTitle } }) })
|
||||
queryKey: wikiKeys.index ({ title: submittedTitle, resourceType }, locale),
|
||||
queryFn: () => fetchWikiPages ({ title: submittedTitle, resourceType }, locale) })
|
||||
|
||||
const handleSearch = (ev: FormEvent) => {
|
||||
ev.preventDefault ()
|
||||
@@ -42,10 +46,10 @@ const WikiSearchPage: FC = () => {
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<PageHead title={message ('navigation:wiki')}/>
|
||||
<PageHead title={message (heading)}/>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<PageTitle>{label ('navigation:wiki')}</PageTitle>
|
||||
<PageTitle>{label (heading)}</PageTitle>
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
{/* タイトル */}
|
||||
<FormField label={label ('labels.title')}>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする