コミットを比較

..
3 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 520ffa4c4c #442 2026-11-07 18:34:17 +09:00
みてるぞ 50185ca8b7 #442 2026-11-07 15:48:47 +09:00
みてるぞ dad3ac99b4 #442 2026-11-01 22:29:40 +09:00
34個のファイルの変更、191行の追加、1029行の削除
+1 -64
ファイルの表示
@@ -1,34 +1,4 @@
class TagDescriptionsController < ApplicationController 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 def show
locale = resolve_locale! locale = resolve_locale!
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code) description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
@@ -48,25 +18,6 @@ class TagDescriptionsController < ApplicationController
render json: representation(description) render json: representation(description)
end 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 def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
@@ -81,9 +32,7 @@ class TagDescriptionsController < ApplicationController
description = TagDescription.find_by(tag:, language_code: locale.language_code) description = TagDescription.find_by(tag:, language_code: locale.language_code)
if description if description
TagDescriptionOperation.update_with_title!( TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
locale, tag:, body:, title: params[:title], actor: current_user,
base_revision_id: params[:base_revision_id].presence)
description.reload description.reload
render json: representation(description) render json: representation(description)
else else
@@ -99,20 +48,8 @@ class TagDescriptionsController < ApplicationController
private private
def render_conflict error
render json: { error: 'conflict', message: error.message }, status: :conflict
end
def representation description def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion) 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: [ description.as_json(only: [
:tag_id, :tag_id,
:language_code, :language_code,
+7 -56
ファイルの表示
@@ -1,22 +1,7 @@
class TagGroupsController < ApplicationController class TagGroupsController < ApplicationController
rescue_from TagGroupMarkdownParser::InvalidDocument,
TagGroupMarkdownRenderer::UnsupportedStructure, with: :render_document_error
def index def index
locale = resolve_locale! locale = resolve_locale!
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion) groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
.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) render json: TagGroupRepr.many(locale, groups.to_a)
end end
@@ -25,15 +10,7 @@ class TagGroupsController < ApplicationController
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id]) group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
return head :not_found unless group return head :not_found unless group
render json: representation(locale, group) render json: TagGroupRepr.one(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 end
def create def create
@@ -41,8 +18,8 @@ class TagGroupsController < ApplicationController
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale! locale = resolve_locale!
group = TagGroupOperation.create!(locale, **write_attributes(locale), actor: current_user) group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
render json: representation(locale, group), status: :created render json: TagGroupRepr.one(locale, group), status: :created
end end
def update def update
@@ -53,39 +30,13 @@ class TagGroupsController < ApplicationController
group = TagGroup.find_by(id: params[:id]) group = TagGroup.find_by(id: params[:id])
return head :not_found unless group return head :not_found unless group
TagGroupOperation.update!(locale, group:, **write_attributes(locale, group:), actor: current_user) TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
render json: representation(locale, group.reload) render json: TagGroupRepr.one(locale, group.reload)
end end
private private
def render_document_error error def write_attributes
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], { name: params[:name], description: params[:description],
members: params[:members], placements: params[:placements] } members: params[:members], placements: params[:placements] }
end end
+34 -12
ファイルの表示
@@ -6,11 +6,6 @@ class WikiPagesController < ApplicationController
def index def index
locale = resolve_locale! 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 title = params[:title].to_s.strip
descriptions = descriptions =
HistoryAccess.current(TagDescription.all, TagDescriptionVersion) HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
@@ -19,8 +14,6 @@ class WikiPagesController < ApplicationController
descriptions = descriptions.order(updated_at: :desc, id: :asc) descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).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? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -253,8 +246,25 @@ class WikiPagesController < ApplicationController
base_revision_id = params[:base_revision_id].presence base_revision_id = params[:base_revision_id].presence
TagDescriptionOperation.update_with_title!( ApplicationRecord.transaction do
locale, tag:, body:, title:, actor: current_user, base_revision_id:) 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
head :ok head :ok
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -316,9 +326,21 @@ class WikiPagesController < ApplicationController
end end
def render_group_representation representation, version, body: nil, status: :ok def render_group_representation representation, version, body: nil, status: :ok
document = TagGroupRepr.document(representation, version) body ||= TagGroupMarkdownRenderer.render(representation)
document['body'] = body if body versions = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: version.tag_group_id)
render json: document, status: 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:
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
render_unprocessable_entity(error.message) render_unprocessable_entity(error.message)
end end
+1 -1
ファイルの表示
@@ -4,7 +4,7 @@ require 'digest'
class HistoryAccess < ActiveSupport::CurrentAttributes class HistoryAccess < ActiveSupport::CurrentAttributes
attribute :user attribute :user
class Forbidden < StandardError; end class Forbidden < StandardError; end
ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'], KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
'nico_tag_versions' => ['tag_id'], 'nico_tag_versions' => ['tag_id'],
-15
ファイルの表示
@@ -56,21 +56,6 @@ module TagGroupRepr
'updated_at' => wiki_page.updated_at } 'updated_at' => wiki_page.updated_at }
end 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 def current_version group
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no) HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
end end
+13 -56
ファイルの表示
@@ -1,41 +1,12 @@
class CountsAxes class CountsAxes
MAX_AXES = 8 MAX_AXES = 8
MASKED_TEXT = '********'.freeze
MASKED_VALUE = '__counts_private__'.freeze MASKED_VALUE = '__counts_private__'.freeze
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user', PUBLIC_FIELDS = ['created_at', 'event_type'].freeze
'created_at'].freeze
class TooManyAxes < ArgumentError; end class TooManyAxes < ArgumentError; end
class PrivateCondition < ArgumentError; end class PrivateCondition < ArgumentError; end
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
def self.private_field?(field)
!PUBLIC_FIELDS.include?(field) && field != 'user.id'
end
def self.visible_ids_sql(definition)
"SELECT counts_visible.id FROM #{ definition.table } counts_visible WHERE " +
HistoryAccess.sql(definition.model, 'counts_visible')
end
def self.visibility_sql(definition, field)
return 'TRUE' if HistoryAccess.admin?
source = visible_ids_sql(definition)
visible = "v.id IN (#{ source })"
return visible unless field == 'changed_field'
"(#{ visible }) AND (previous.id IS NULL OR previous.id IN (#{ source }))"
end
def self.masked_sql(expression, definition, field)
return expression if HistoryAccess.admin? || !private_field?(field)
visible = visibility_sql(definition, field)
quoted = ApplicationRecord.connection.quote(MASKED_VALUE)
"CASE WHEN #{ visible } THEN #{ expression } ELSE #{ quoted } END"
end
def self.time_expression(expression, grain, timezone) def self.time_expression(expression, grain, timezone)
raise ArgumentError unless GRAINS.include?(grain) raise ArgumentError unless GRAINS.include?(grain)
@@ -89,9 +60,13 @@ class CountsAxes
@definition.fields[field] == :datetime ? self.class.time_expression( @definition.fields[field] == :datetime ? self.class.time_expression(
expression, axis.fetch('grain', 'month'), @population.timezone) : expression expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end end
if !HistoryAccess.admin? && self.class.private_field?(field) if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
expression = self.class.masked_sql( visible = HistoryAccess.sql(@definition.model, 'v')
"CAST((#{ expression }) AS CHAR)", @definition, field) if field == 'changed_field'
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
end
expression = "CASE WHEN #{ visible } " +
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
end end
expression expression
end end
@@ -102,12 +77,10 @@ class CountsAxes
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" } selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
identities = ['v.id'] identities = ['v.id']
if rows.any? { _1['field'] == 'tags' } if rows.any? { _1['field'] == 'tags' }
identities << self.class.masked_sql( # Raw identities stay inside COUNT; masked tag occurrences remain additive.
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " + identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags') "CONCAT('external:', tag_entry.external_tag_id))"
if rows.any? { _1['field'] == 'changed_field' } identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
end
end end
members = identities.each_index.map { "counts_member_#{ _1 }" } members = identities.each_index.map { "counts_member_#{ _1 }" }
selected += identities.each_with_index.map { |expression, index| selected += identities.each_with_index.map { |expression, index|
@@ -125,17 +98,8 @@ class CountsAxes
def joins(fields = all.map { _1['field'] }) def joins(fields = all.map { _1['field'] })
result = '' result = ''
if fields.include?('tags') if fields.include?('tags')
document = 'CAST(v.tags_json AS JSON)'
unless HistoryAccess.admin?
visible = self.class.visible_ids_sql(@definition)
result += " LEFT JOIN (#{ visible }) counts_tags_visible " +
'ON counts_tags_visible.id = v.id '
document = "CASE WHEN counts_tags_visible.id IS NOT NULL " +
"THEN #{ document } ELSE JSON_ARRAY(" +
"JSON_OBJECT('tag_id', 0)) END"
end
result += <<~SQL.squish result += <<~SQL.squish
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS ( JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY, tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
@@ -148,13 +112,6 @@ class CountsAxes
condition = @definition.changes.map do |field| condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })" "(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ') end.join(' OR ')
unless HistoryAccess.admin?
visible = self.class.visibility_sql(@definition, 'changed_field')
marker = ApplicationRecord.connection.quote(MASKED_VALUE)
fields += " UNION ALL SELECT #{ marker } AS field_key"
condition = "((#{ visible }) AND (#{ condition })) OR " +
"(NOT (#{ visible }) AND changed.field_key = #{ marker })"
end
result += " JOIN (#{ fields }) changed ON (#{ condition })" result += " JOIN (#{ fields }) changed ON (#{ condition })"
end end
result result
+2 -41
ファイルの表示
@@ -82,32 +82,14 @@ class CountsCondition
def apply(scope, entry) def apply(scope, entry)
raise ArgumentError unless entry.is_a?(Hash) raise ArgumentError unless entry.is_a?(Hash)
if entry['operator'] == 'axis_eq' if entry['operator'] == 'axis_eq'
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty? raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
value = entry['value'] value = entry['value']
unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) || unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
[true, false].include?(value))
raise ArgumentError raise ArgumentError
end end
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192 raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
if (!HistoryAccess.admin? && CountsAxes.private_field?(entry['field']) &&
(value == CountsAxes::MASKED_VALUE ||
(value == CountsAxes::MASKED_TEXT &&
(@definition.fields[entry['field']] != :string ||
['tags', 'changed_field'].include?(entry['field'])))))
field = entry.fetch('field')
raise ArgumentError unless @definition.axes(@population.history).include?(field)
if entry.key?('grain')
unless (@definition.fields[field] == :datetime &&
CountsAxes::GRAINS.include?(entry['grain']))
raise ArgumentError
end
end
return scope.none
end
scope = @population.condition_scope(scope, entry.fetch('field'))
return @population.axis_condition(scope, entry) return @population.axis_condition(scope, entry)
end end
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'] allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
@@ -129,13 +111,6 @@ class CountsCondition
source, expression = source_for(field[:key]) source, expression = source_for(field[:key])
type = field[:type] type = field[:type]
# Preserve typed input compatibility; display markers are never real typed values.
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
entry['value'] == CountsAxes::MASKED_TEXT && type != :string && !entry['blank'])
return scope.none unless entry['operator'] == 'ne'
return @population.condition_scope(scope, field[:key])
end
blank = blank_sql(expression, type) blank = blank_sql(expression, type)
if entry['blank'] if entry['blank']
sql = if source && field[:collection] sql = if source && field[:collection]
@@ -144,7 +119,6 @@ class CountsCondition
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))" "NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
else blank else blank
end end
scope = @population.condition_scope(scope, field[:key])
return scope.where(Arel.sql(sql)) return scope.where(Arel.sql(sql))
end end
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator']) if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
@@ -157,7 +131,6 @@ class CountsCondition
validate_enum!(field[:key], parsed.first) if type == :enum validate_enum!(field[:key], parsed.first) if type == :enum
operator = entry['operator'] operator = entry['operator']
if type == :interval if type == :interval
scope = @population.condition_scope(scope, field[:key])
return interval(scope, source, expression, parsed) return interval(scope, source, expression, parsed)
end end
clauses = [] clauses = []
@@ -185,7 +158,6 @@ class CountsCondition
elsif operator == 'ne' elsif operator == 'ne'
predicate = "(NOT (#{ predicate }) OR #{ blank })" predicate = "(NOT (#{ predicate }) OR #{ blank })"
end end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *binds) scope.where(predicate, *binds)
end end
@@ -196,13 +168,6 @@ class CountsCondition
values = entry['values'] values = entry['values']
raise ArgumentError unless values.is_a?(Array) && values.length <= 100 raise ArgumentError unless values.is_a?(Array) && values.length <= 100
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
values.include?(CountsAxes::MASKED_TEXT) &&
(field[:type] != :string || field[:set_type] == 'tag_reference'))
return scope.none if entry['operator'] == 'all'
values = values - [CountsAxes::MASKED_TEXT]
end
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' } raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
parsed = values.map do |value| parsed = values.map do |value|
@@ -226,10 +191,7 @@ class CountsCondition
operator = entry['operator'] operator = entry['operator']
if parsed.empty? if parsed.empty?
return scope.none if ['in', 'any'].include?(operator) return ['in', 'any'].include?(operator) ? scope.none : scope
return scope if entry['values'].empty?
return @population.condition_scope(scope, field[:key])
end end
source, expression = source_for(field[:key]) source, expression = source_for(field[:key])
if field[:set_type] == 'tag_reference' if field[:set_type] == 'tag_reference'
@@ -250,7 +212,6 @@ class CountsCondition
predicate = source ? "NOT #{ predicate }" : predicate = source ? "NOT #{ predicate }" :
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })" "(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
end end
scope = @population.condition_scope(scope, field[:key])
scope.where(predicate, *parsed) scope.where(predicate, *parsed)
end end
-8
ファイルの表示
@@ -70,13 +70,6 @@ class CountsPopulation
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE' @authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
end end
# Each predicate keeps its own visibility restriction, including OR branches.
def condition_scope(scope, field)
return scope if HistoryAccess.admin? || !CountsAxes.private_field?(field)
scope.where(Arel.sql(CountsAxes.visibility_sql(definition, field)))
end
def axis_condition(scope, entry) def axis_condition(scope, entry)
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
@@ -216,7 +209,6 @@ class CountsPopulation
def apply_tags def apply_tags
raise ArgumentError unless definition.resource == 'posts' raise ArgumentError unless definition.resource == 'posts'
@scope = condition_scope(@scope, 'tags')
if params['tag'].present? if params['tag'].present?
@scope = @scope.where(snapshot_membership( @scope = @scope.where(snapshot_membership(
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }")) "#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
+1 -1
ファイルの表示
@@ -253,7 +253,7 @@ class CountsTableExport
end end
def summary_value(value, index, page) def summary_value(value, index, page)
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE return '********' if value == CountsAxes::MASKED_VALUE
field = @query.axes.all.fetch(index).fetch('field') field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil? return @labels.fetch('bot', '') if field == 'user' && value.nil?
+10 -2
ファイルの表示
@@ -220,11 +220,19 @@ class CountsTableQuery
private private
# Private predicates require visibility independently of their operator. # Public timeline predicates may include hidden records. Private predicates must
# Ordering/measure state must not select a server-only masked coordinate. # never become a count oracle, even when the selected axes themselves are public.
def validate_summary_conditions! def validate_summary_conditions!
return if mode != 'summary' || HistoryAccess.admin? return if mode != 'summary' || HistoryAccess.admin?
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
keys = ['tags', 'tag', 'kind', 'resource_id', 'user_id']
private_parameters = keys.any? do |key|
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
end
raise CountsAxes::PrivateCondition if private_fields || private_parameters
['column_order', 'measures'].each do |key| ['column_order', 'measures'].each do |key|
# Coordinates are compared only to already masked expressions, never raw # Coordinates are compared only to already masked expressions, never raw
# values. Public coordinates remain sortable; a guessed private ID cannot match. # values. Public coordinates remain sortable; a guessed private ID cannot match.
-31
ファイルの表示
@@ -37,37 +37,6 @@ class TagDescriptionOperation
end end
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: def self.record_version! description:, body:, user:
creating = description.new_record? creating = description.new_record?
version_no = creating ? 1 : description.version_no + 1 version_no = creating ? 1 : description.version_no + 1
+1 -8
ファイルの表示
@@ -17,11 +17,7 @@ Rails.application.routes.draw do
resources :locales, only: [:index] resources :locales, only: [:index]
resources :sitemap_entries, only: [:index, :show], param: :resource resources :sitemap_entries, only: [:index, :show], param: :resource
resources :tag_groups, only: [:index, :show, :create, :update] do resources :tag_groups, only: [:index, :show, :create, :update]
collection do
get 'name/:name', action: :show_by_name
end
end
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
@@ -32,9 +28,6 @@ Rails.application.routes.draw do
resources :tags, only: [:index, :show] do resources :tags, only: [:index, :show] do
collection 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 :autocomplete
get :'with-depth', action: :with_depth get :'with-depth', action: :with_depth
get :versions, to: 'tag_versions#index' get :versions, to: 'tag_versions#index'
+2 -15
ファイルの表示
@@ -9,21 +9,8 @@
# end # end
ApplicationRecord.transaction do ApplicationRecord.transaction do
Language.find_or_create_by!(code: 'ja') { _1.name = '日本語' } Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
Script.find_or_create_by!(code: 'Jpan') { _1.name = '漢字、仮名文字およびラテン文字' } Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
Region.find_or_create_by!(code: 392) do
_1.alpha_code = 'JP'
_1.name = '日本国'
end
Locale.find_or_create_by!(code: 'ja') do
_1.language_code = 'ja'
_1.script_code = 'Jpan'
_1.region_code = 392
_1.name = '日本語'
end
Language.find_or_create_by!(code: 'ain') { _1.name = 'ヤウンモシルン アイヌ イタㇰ' }
Script.find_or_create_by!(code: 'Kana') { _1.name = 'カナモジ オヨビ ラテンモジ' }
end end
post_url_sanitisation_rules = [ post_url_sanitisation_rules = [
-196
ファイルの表示
@@ -1,196 +0,0 @@
require 'rails_helper'
RSpec.describe 'Counts masked search', type: :request do
let!(:tag) { create(:tag, primary_name: '秘密のタグ') }
let(:actor) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:base) do
{ 'resource' => 'tags', 'definition' => 'history', 'mode' => 'summary',
'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z',
'timezone' => 'UTC', 'rows' => '', 'cols' => '', 'sort' => '' }
end
before do
[1, 2].each do |number|
TagVersion.create!(tag:, version_no: number,
event_type: number == 1 ? 'create' : 'discard', name: '秘密のタグ',
category: 'general', aliases: '秘密の別名', parent_tag_ids: '',
names: { ja: { name: '秘密のタグ', script_code: 'Jpan', auto_generated: false,
aliases: [{ name: '秘密の別名', script_code: 'Jpan' }] } },
created_by_user: actor, created_at: Time.utc(2023, number))
end
end
def query(extra = {})
get '/counts/query', params: base.merge(extra)
expect(response).to have_http_status(:ok), extra.inspect
response.parsed_body
end
def predicate(field, operator, value, index = 0)
prefix = "conditions.#{ index }"
result = { 'conditions' => '', "#{ prefix }.field" => field,
"#{ prefix }.operator" => operator }
if ['in', 'any', 'all', 'none', 'not_in'].include?(operator)
result.merge!("#{ prefix }.values" => '1', 'condition_values' => '',
"condition_values.#{ index }.condition" => index.to_s,
"condition_values.#{ index }.value_type" => 'string',
"condition_values.#{ index }.value" => value)
else
result["#{ prefix }.value"] = value
end
result
end
it 'rejects every name and alias predicate on private history' do
['name', 'name_ja', 'names.name', 'aliases.name', 'names.aliases.name'].each do |field|
secret = field.include?('aliases') ? '秘密の別名' : '秘密のタグ'
multiple = !['name', 'name_ja'].include?(field)
operators = multiple ? ['any', 'all', 'none'] : ['in', 'not_in']
['eq', 'ne', 'like', *operators].each do |operator|
[secret, '********', '%', ''].each do |value|
expected = (value == '' && ['eq', 'ne', 'like'].include?(operator)) ? 2 : 0
expect(query(predicate(field, operator, value))['count']).to eq(expected)
end
end
blank = predicate(field, 'eq', '').merge('conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(0)
end
end
it 'ignores missing and empty scalar values unless blank is checked' do
['name', 'name_ja', 'names.name', 'aliases.name'].each do |field|
['eq', 'ne', 'like'].each do |operator|
condition = predicate(field, operator, '')
[condition, condition.except('conditions.0.value')].each do |empty|
expect(query(empty)['count']).to eq(2)
expect(query(empty.merge('conditions.0.blank' => '0'))['count']).to eq(2)
expect(query(empty.merge('conditions.0.blank' => '1'))['count']).to eq(0)
end
end
end
end
it 'keeps details totals unchanged for inactive private predicates' do
settings = predicate('name', 'ne', '').merge('mode' => 'details')
result = query(settings)
expect(result['count']).to eq(0)
expect(result['versions']).to be_empty
expect(result['output_count']).to eq(2)
active = query(settings.merge('conditions.0.blank' => '1'))
expect(active['output_count']).to eq(0)
end
it 'ignores missing sets but preserves explicit empty set semantics' do
{ 'name' => ['in', 'not_in'], 'aliases.name' => ['any', 'all', 'none'] }.each do
|field, operators|
operators.each do |operator|
condition = { 'conditions' => '', 'conditions.0.field' => field,
'conditions.0.operator' => operator }
expect(query(condition)['count']).to eq(2)
empty = condition.merge('conditions.0.values' => '1', 'condition_values' => '')
expected = ['in', 'any'].include?(operator) ? 0 : 2
expect(query(empty)['count']).to eq(expected)
expect(query(condition.merge('conditions.0.blank' => '1'))['count']).to eq(0)
# An explicit empty string element is a real value, not an unspecified set.
expect(query(predicate(field, operator, ''))['count']).to eq(0)
end
end
end
it 'ignores empty private predicates regardless of their position' do
first = predicate('name', 'ne', '')
.merge(predicate('resource_id', 'eq', tag.id.to_s, 1))
last = predicate('resource_id', 'eq', tag.id.to_s)
.merge(predicate('aliases.name', 'like', '', 1))
expect(query(first)['count']).to eq(2)
expect(query(last)['count']).to eq(2)
active = predicate('name', 'ne', 'unrelated')
.merge(predicate('aliases.name', 'like', '', 1))
expect(query(active)['count']).to eq(0)
end
it 'searches public literal markers and treats axis predicates as explicit' do
public_tag = create(:tag, primary_name: '********')
TagVersion.create!(tag: public_tag, version_no: 1, event_type: 'create',
name: '********', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: '********', script_code: 'Jpan', auto_generated: false,
aliases: [] } }, created_at: Time.utc(2023))
expect(query['count']).to eq(3)
['eq', 'ne', 'like', 'in', 'not_in'].each do |operator|
expected = ['ne', 'not_in'].include?(operator) ? 0 : 1
expect(query(predicate('name', operator, '********'))['count']).to eq(expected)
end
blank = predicate('aliases.name', 'eq', '').merge('conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(1)
['********', '', nil].each do |value|
condition = { 'conditions' => '', 'conditions.0.field' => 'name',
'conditions.0.operator' => 'axis_eq',
'conditions.0.value_type' => value.nil? ? 'null' : 'string' }
condition['conditions.0.value'] = value unless value.nil?
expected = value == '********' ? 1 : 0
expect(query(condition)['count']).to eq(expected)
drill = { 'drill' => '', 'drill.0.field' => 'name',
'drill.0.value_type' => value.nil? ? 'null' : 'string' }
drill['drill.0.value'] = value unless value.nil?
expect(query(drill)['count']).to eq(expected)
end
end
it 'keeps public identity and timeline predicates searchable' do
expect(query['count']).to eq(2)
values = { 'resource_id' => tag.id.to_s, 'version_no' => '2',
'event_type' => 'discard', 'user' => actor.id.to_s,
'created_at' => '2023-02-01T00:00:00Z' }
values.each do |field, value|
expected = ['resource_id', 'user'].include?(field) ? 2 : 1
expect(query(predicate(field, 'eq', value))['count']).to eq(expected)
end
condition = predicate('name', 'ne', '秘密のタグ')
.merge(predicate('event_type', 'eq', 'discard', 1))
expect(query(condition)['count']).to eq(0)
expect(query(predicate('category', 'eq', '********'))['count']).to eq(0)
expect(query(predicate('category', 'eq', 'general'))['count']).to eq(0)
end
it 'keeps public predicates available after combining multiple conditions' do
condition = predicate('resource_id', 'eq', tag.id.to_s)
.merge(predicate('event_type', 'eq', 'discard', 1))
expect(query(condition)['count']).to eq(1)
condition.merge!(predicate('name', 'ne', 'unrelated', 2))
expect(query(condition)['count']).to eq(0)
end
it 'keeps real name searches for administrators and excludes private details' do
condition = predicate('name', 'eq', '秘密のタグ')
details = query(condition.merge('mode' => 'details'))
expect(details['versions']).to be_empty
expect(details['output_count']).to eq(0)
masked = query(predicate('name', 'eq', '********').merge('mode' => 'details'))
expect(masked['versions']).to be_empty
expect(masked['output_count']).to eq(0)
sign_in_as(admin)
expect(query(condition)['count']).to eq(2)
expect(query(condition.merge('mode' => 'details'))['versions'].length).to eq(2)
end
it 'rejects private names reached through a post tag snapshot' do
post = create(:post)
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'post',
url: post.url, tags: '', parent_post_ids: '',
tags_json: [{ tag_id: tag.id, version_no: 1, name: '秘密のタグ',
category: 'general', sections: [] }],
created_at: Time.utc(2024))
['tags.name', 'tags.names.name', 'tags.name_ja'].each do |field|
['eq', 'ne', 'like', 'any', 'all', 'none'].each do |operator|
['秘密のタグ', '********', '%'].each do |value|
settings = predicate(field, operator, value).merge('resource' => 'posts')
expect(query(settings)['count']).to eq(0)
end
end
blank = predicate(field, 'eq', '').merge('resource' => 'posts',
'conditions.0.blank' => '1')
expect(query(blank)['count']).to eq(0)
end
end
end
+5 -15
ファイルの表示
@@ -354,25 +354,15 @@ RSpec.describe 'Counts table API', type: :request do
expect(result['cells'].sum { _1['count'] }).to eq(4) expect(result['cells'].sum { _1['count'] }).to eq(4)
expect(response.body).not_to include('hidden title') expect(response.body).not_to include('hidden title')
masked = query(mode: 'summary', rows: 'resource_id', cols: '') masked = query(mode: 'summary', rows: 'resource_id', cols: '')
expect(masked['cells'].map { _1['values'] }).to eq([[record.id]]) expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
expect(masked['cells'].first['count']).to eq(4) expect(masked['cells'].first['count']).to eq(4)
expect(query['versions']).to be_empty expect(query['versions']).to be_empty
end end
it 'allows public identity predicates without exposing private snapshot fields' do it 'rejects private summary predicates instead of exposing a count oracle' do
PostVersion.create!(post: record, version_no: 4, event_type: 'discard', get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
url: record.url, title: 'hidden title', tags: '', tags_json: [], expect(response).to have_http_status(:forbidden)
parent_post_ids: '', created_at: Time.utc(2024, 3)) expect(json).to eq('error' => 'private_condition')
get '/counts/query', params: base.merge(mode: 'summary',
resource_id: record.id.to_s, rows: 'resource_id', cols: 'title')
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(4)
expect(json['cells'].sum { _1['count'] }).to eq(4)
expect(json['cells'].map { _1['values'].last })
.to include(CountsAxes::MASKED_VALUE)
expect(response.body).not_to include('hidden title')
end end
it 'reports the combined axis limit without replacing it with a generic 400' do it 'reports the combined axis limit without replacing it with a generic 400' do
+23 -193
ファイルの表示
@@ -69,29 +69,27 @@ RSpec.describe 'Counts masked pivot contracts' do
end end
end end
it 'counts one opaque collection per private version across pages' do it 'preserves five tag occurrences after merging private tags and across pages' do
[nil, guest, member, admin].each do |user| [nil, guest, member, admin].each do |user|
page = result(user) page = result(user)
expected = user == admin ? 5 : 6 expect(grand(page)).to eq(5)
expect(grand(page)).to eq(expected) expect(page[:cells].sum { _1[:count] }).to eq(5)
expect(page[:cells].sum { _1[:count] }).to eq(expected)
expect(page).not_to have_key(:excluded_count) expect(page).not_to have_key(:excluded_count)
paged = result(user, 'limit' => '1') paged = result(user, 'limit' => '1')
expect(grand(paged)).to eq(expected) expect(grand(paged)).to eq(5)
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count]) expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
.to eq(page[:group_count]) .to eq(page[:group_count])
end end
public_page = result(nil) public_page = result(nil)
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE } hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
expect(hidden.sum { _1[:count] }).to eq(4) expect(hidden.sum { _1[:count] }).to eq(3)
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }") expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
expect(public_page.to_json).not_to include('hidden-title', 'private-discard') expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
end end
it 'retains distinct membership when tags are not a row axis' do it 'retains distinct membership when tags are not a row axis' do
[nil, admin].each do |user| [nil, admin].each do |user|
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))) expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3)
.to eq(user == admin ? 3 : 5)
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5) expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '') details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
expect(details[:count]).to eq(user == admin ? 5 : 1) expect(details[:count]).to eq(user == admin ? 5 : 1)
@@ -123,13 +121,14 @@ RSpec.describe 'Counts masked pivot contracts' do
it 'counts distinct changed records when tags are not additive row tuples' do it 'counts distinct changed records when tags are not additive row tuples' do
[nil, admin].each do |user| [nil, admin].each do |user|
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type') page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
expect(grand(page)).to eq(user == admin ? 2 : 4) expect(grand(page)).to eq(2)
if user.nil? if user.nil?
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE]) expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
end end
# Private empty collections contribute the same opaque tuple as populated ones. # Changed discard snapshots have no tags: no full cell contributes here.
combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags') empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
expect(grand(combined)).to eq(user == admin ? 0 : 4) expect(empty[:cells]).to be_empty
expect(grand(empty)).to eq(0)
end end
end end
@@ -143,7 +142,7 @@ RSpec.describe 'Counts masked pivot contracts' do
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data')) expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
end end
it 'sorts public version numbers numerically including formerly hidden records' do it 'sorts public numeric values numerically alongside a masked group' do
post = PostVersion.order(:id).last.post post = PostVersion.order(:id).last.post
[2, 10].each do |number| [2, 10].each do |number|
PostVersion.create!(post:, version_no: number, event_type: 'update', PostVersion.create!(post:, version_no: number, event_type: 'update',
@@ -152,196 +151,27 @@ RSpec.describe 'Counts masked pivot contracts' do
end end
page = result(nil, 'rows' => 'version_no', 'cols' => '') page = result(nil, 'rows' => 'version_no', 'cols' => '')
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first } values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
expect(values.map(&:to_i)).to eq([1, 2, 10]) expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10])
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
end end
it 'keeps public identity predicates and excludes private attributes' do it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
hidden_id = PostVersion.first.post_id.to_s probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
base = { 'rows' => '', 'cols' => '' } { 'tags' => 'A' },
condition = lambda do |field, operator, value| { 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
{ 'conditions' => '', 'conditions.0.field' => field, 'conditions.0.value' => 'hidden-title-0' },
'conditions.0.operator' => operator, 'conditions.0.value' => value } { 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',
end 'drill.0.value' => "internal:#{ tags.first.id }" },
expect(result(nil, base.merge('resource_id' => hidden_id))[:cells].sole[:count]).to eq(2)
expect(result(nil, base.merge(condition.call('version_no', 'eq', '2')))[:cells]
.sole[:count]).to eq(2)
expect(result(nil, base.merge(condition.call('user', 'eq', member.id.to_s)))[:cells]
.sole[:count]).to eq(5)
expect(result(nil, base.merge(condition.call('title', 'eq', 'hidden-title-0')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('title', 'ne', 'hidden-title-0')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge(condition.call('title', 'eq', '********')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('video_ms', 'eq', '********')))[:cells]
.sole[:count]).to eq(0)
expect(result(nil, base.merge(condition.call('title', 'like', '%title%')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge(condition.call('title', 'like', '%')))[:cells]
.sole[:count]).to eq(1)
expect(result(nil, base.merge('tags' => 'C'))[:cells].sole[:count]).to eq(1)
expect(result(nil, base.merge('tags' => 'not:C'))[:cells].sole[:count]).to eq(0)
end
it 'retains public OR branches while rejecting private predicates on hidden rows' do
HistoryAccess.set(user: nil) do
population = CountsPopulation.new(params.merge('rows' => '', 'cols' => ''),
authorisation_flg: false)
condition = CountsCondition.new(population)
public_scope = condition.apply(population.scope,
{ 'field' => 'event_type', 'operator' => 'eq', 'value' => 'discard' })
private_scope = condition.apply(population.scope,
{ 'field' => 'title', 'operator' => 'ne', 'value' => 'unrelated' })
masked_scope = condition.apply(population.scope,
{ 'field' => 'title', 'operator' => 'eq', 'value' => '********' })
expect(public_scope.except(:select).count).to eq(2)
expect(public_scope.or(private_scope).except(:select).count).to eq(3)
expect(private_scope.or(public_scope).except(:select).count).to eq(3)
expect(public_scope.or(masked_scope).except(:select).count).to eq(2)
expect(public_scope.merge(private_scope).except(:select).count).to eq(0)
end
end
it 'excludes private collections from positive, negative, AND and OR tag searches' do
settings = { 'rows' => '', 'cols' => '' }
{ 'C' => 1, 'not:C' => 0, 'not:unknown' => 1, '********' => 0,
'not:********' => 1, 'not:C C' => 0, 'not:C type:or C' => 1 }.each do |value, count|
expect(result(nil, settings.merge('tags' => value))[:cells].sole[:count]).to eq(count)
end
end
it 'ignores empty numeric and interval bounds without restricting private history' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '' }
{ 'video_ms' => ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'],
'tags.sections' => ['range'] }.each do |field, operators|
operators.each do |operator|
condition = settings.merge('conditions.0.field' => field,
'conditions.0.operator' => operator)
empty = condition.merge('conditions.0.value' => '')
expect(result(nil, condition)[:cells].sole[:count]).to eq(5)
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
expect(result(nil, empty.merge('conditions.0.blank' => '0'))[:cells]
.sole[:count]).to eq(5)
if operator == 'range'
empty['conditions.0.end'] = ''
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
['conditions.0.value', 'conditions.0.end'].each do |key|
active = empty.merge(key => '100')
expect(result(nil, active)[:cells].sole[:count]).to eq(0)
end
end
end
end
end
it 'keeps typed marker sets active after incompatible marker values are removed' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'video_ms', 'conditions.0.values' => '1',
'condition_values' => '', 'condition_values.0.condition' => '0',
'condition_values.0.value_type' => 'string',
'condition_values.0.value' => '********' }
['in', 'not_in'].each do |operator|
expected = operator == 'in' ? 0 : 1
condition = settings.merge('conditions.0.operator' => operator)
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
end
it 'restricts active numeric comparisons and preserves empty selection semantics' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'video_ms' }
['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.value' => '0')
condition['conditions.0.end'] = '100' if operator == 'range'
# The one public row is NULL; private NULLs and real values never match.
expected = operator == 'ne' ? 1 : 0
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
['in', 'not_in'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.values' => '1', 'condition_values' => '')
expected = operator == 'not_in' ? 5 : 0
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
expect(result(nil, blank)[:cells].sole[:count]).to eq(1)
end
it 'preserves empty tag sets and restricts active blank collection predicates' do
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
'conditions.0.field' => 'tags' }
['any', 'all', 'none'].each do |operator|
condition = settings.merge('conditions.0.operator' => operator,
'conditions.0.values' => '1', 'condition_values' => '')
expected = operator == 'any' ? 0 : 5
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
end
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
expect(result(nil, blank)[:cells].sole[:count]).to eq(0)
end
it 'rejects server-only coordinates in column order' do
probes = [
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string', { 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
'column_order.0.value' => CountsAxes::MASKED_VALUE }] 'column_order.0.value' => CountsAxes::MASKED_VALUE }]
HistoryAccess.set(user: nil) do HistoryAccess.set(user: nil) do
probes.each do |probe| probes.each do |probe|
expect { CountsTableQuery.new(params.merge(probe)).call } expect { CountsTableQuery.new(params.merge(probe)) }
.to raise_error(CountsAxes::PrivateCondition) .to raise_error(CountsAxes::PrivateCondition)
end end
end end
end end
it 'keeps private collection and change counts independent of stored elements' do
settings = [
{ 'rows' => 'tags', 'cols' => 'changed_field' },
{ 'rows' => 'changed_field tags', 'cols' => '' }]
original = settings.map { result(nil, _1) }
snapshot = tags.map do |tag|
{ tag_id: tag.id, version_no: 1, sections: [] }
end
hidden_posts = PostVersion.where(event_type: 'discard').pluck(:post_id)
PostVersion.where(post_id: hidden_posts).update_all(
title: 'same hidden value', tags_json: snapshot)
expect(settings.map { result(nil, _1) }).to eq(original)
end
it 'excludes private tag and change coordinates from drill conditions' do
['tags', 'changed_field'].each do |field|
settings = { 'rows' => field, 'cols' => '' }
page = result(nil, settings)
cell = page[:cells].find { _1[:values] == [CountsAxes::MASKED_VALUE] }
expect(cell[:count]).to eq(4)
[CountsAxes::MASKED_VALUE, CountsAxes::MASKED_TEXT].each do |value|
drill = { 'drill' => '', 'drill.0.field' => field,
'drill.0.value_type' => 'string', 'drill.0.value' => value }
expect(result(nil, settings.merge(drill))[:cells]).to be_empty
details = result(nil, settings.merge(drill).merge('mode' => 'details'))
expect(details[:versions]).to be_empty
condition = { 'conditions' => '', 'conditions.0.field' => field,
'conditions.0.operator' => 'axis_eq',
'conditions.0.value_type' => 'string', 'conditions.0.value' => value }
expect(result(nil, settings.merge(condition))[:cells]).to be_empty
end
end
end
it 'rejects real private tag and change coordinates for non-admin users' do
[nil, guest, member].each do |user|
{ 'tags' => "internal:#{ tags.first.id }", 'changed_field' => 'title' }.each do
|field, value|
settings = { 'rows' => '', 'cols' => '', 'drill' => '',
'drill.0.field' => field, 'drill.0.value_type' => 'string',
'drill.0.value' => value }
expect(result(user, settings)[:cells].sole[:count]).to eq(0)
expect(result(admin, settings)[:cells].sole[:count]).to be > 0
end
end
end
it 'keeps public column sorting usable while guessed private coordinates match nothing' do it 'keeps public column sorting usable while guessed private coordinates match nothing' do
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags') first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags', probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
@@ -357,7 +187,7 @@ RSpec.describe 'Counts masked pivot contracts' do
HistoryAccess.set(user: nil) do HistoryAccess.set(user: nil) do
payload = params.merge('format' => 'json') payload = params.merge('format' => 'json')
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join) json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6) expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5)
expect(json.to_json).not_to include('hidden-title', 'private-discard') expect(json.to_json).not_to include('hidden-title', 'private-discard')
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(csv).to include('********') expect(csv).to include('********')
+1 -6
ファイルの表示
@@ -166,12 +166,7 @@ export const pageRoutes = (
robots: 'noindex', authentication: 'user' } }, robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/>, { path: ':id', element: <MaterialDetailPage/>,
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] }, handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
{ path: 'wiki', element: <LocaleRedirect to="/tags"/>, { path: 'wiki', element: <WikiSearchPage/>,
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'] } }, handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'wiki/:title', element: <WikiDetailPage/>, { path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale, handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
-120
ファイルの表示
@@ -1,120 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagSearch from '@/components/TagSearch'
import * as api from '@/lib/api'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const LocationOutput = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderSearch = () => renderWithProviders (
<>
<TagSearch/>
<LocationOutput/>
</>, { route: '/ja/posts?page=3' })
describe ('TagSearch', () => {
beforeEach (() => {
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
})
it ('searches through native submission without an Enter key event', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
expect (input.form).toBeInstanceOf (HTMLFormElement)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
expect (input).toHaveValue ('虹夏 not:test type:or')
})
it ('requests a search action from the software keyboard', () => {
renderSearch ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
})
it ('searches through the form when Enter is pressed', async () => {
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it.each ([
{ label: 'composition flag', composing: true, keyCode: 13 },
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: composing, keyCode })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
const uncancelled = fireEvent.keyDown (input, {
key: 'Enter', isComposing: false, keyCode: 13 })
expect (uncancelled).toBe (false)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
})
it ('blocks direct submission during composition and allows it after confirmation', () => {
renderSearch ()
const input = screen.getByRole<HTMLInputElement> ('textbox')
fireEvent.compositionStart (input)
fireEvent.change (input, { target: { value: '虹夏' } })
fireEvent.submit (input.form!)
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (input).toHaveValue ('虹夏')
fireEvent.compositionEnd (input)
fireEvent.submit (input.form!)
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
expect (screen.getByTestId ('location')).toHaveTextContent (
`/ja/posts?${ query }`)
})
it ('selects the active suggestion without submitting the form', async () => {
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
const user = userEvent.setup ()
renderSearch ()
await user.type (screen.getByRole ('textbox'), '虹')
await screen.findByText ('虹夏')
await user.keyboard ('{ArrowDown}{Enter}')
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
})
+22 -42
ファイルの表示
@@ -1,6 +1,6 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する. // TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox' import TagSearchBox from './TagSearchBox'
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react' import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -20,7 +20,6 @@ const TagSearch: FC = () => {
const locale = useLocale () const locale = useLocale ()
const location = useLocation () const location = useLocation ()
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1) const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('') const [search, setSearch] = useState ('')
@@ -75,34 +74,7 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
}, [locale]) }, [locale])
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
if (compositionFlgRef.current)
return
const parts = search.split (' ')
const match =
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match })).toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| ev.nativeEvent.isComposing
|| ev.nativeEvent.keyCode === 229)
{
if (ev.key === 'Enter')
ev.preventDefault ()
return
}
switch (ev.key) switch (ev.key)
{ {
case 'ArrowDown': case 'ArrowDown':
@@ -118,11 +90,12 @@ const TagSearch: FC = () => {
break break
case 'Enter': case 'Enter':
{ if (activeIndex < 0)
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
if (selected == null)
break break
ev.preventDefault () ev.preventDefault ()
{
const selected = suggestions[activeIndex]
if (selected)
handleTagSelect (selected) handleTagSelect (selected)
} }
break break
@@ -132,6 +105,20 @@ const TagSearch: FC = () => {
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
break break
} }
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
const parts = search.split (' ')
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
navigate (`/posts?${
(new URLSearchParams ({
tags: parts.map (t => t.toLowerCase ())
.filter (t => t !== 'type:or')
.join (' '),
match }))
.toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
} }
const handleTagSelect = (tag: Tag) => { const handleTagSelect = (tag: Tag) => {
@@ -157,9 +144,8 @@ const TagSearch: FC = () => {
}, [location.search]) }, [location.search])
return ( return (
<form onSubmit={handleSubmit} className="relative w-full"> <div className="relative w-full">
<input type="text" <input type="text"
enterKeyHint="search"
data-shortcut-focus-search="true" data-shortcut-focus-search="true"
placeholder={message ('labels.tagSearch').text} placeholder={message ('labels.tagSearch').text}
value={search} value={search}
@@ -167,18 +153,12 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)} onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)} onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onCompositionStart={() => {
compositionFlgRef.current = true
}}
onCompositionEnd={() => {
compositionFlgRef.current = false
}}
className={inputClass (false, className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/> 'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex} activeIndex={activeIndex}
onSelect={handleTagSelect}/> onSelect={handleTagSelect}/>
</form>) </div>)
} }
export default TagSearch export default TagSearch
+11 -25
ファイルの表示
@@ -29,7 +29,7 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
import type { AvailableLocale, LocaleCode } from '@/lib/locales' import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User, WikiPage } from '@/types' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { type Props = {
user: User | null user: User | null
@@ -39,11 +39,10 @@ type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = ( export const menuOutline = (
{ tag, material, wikiId, wikiResourceType, user, pathName: fullPathname }: { { tag, material, wikiId, user, pathName: fullPathname }: {
tag?: Tag | null tag?: Tag | null
material?: Material | null material?: Material | null
wikiId: number | null wikiId: number | null
wikiResourceType?: WikiPage['resourceType']
user: User | null user: User | null
pathName: string }, pathName: string },
): Menu => { ): Menu => {
@@ -100,29 +99,17 @@ export const menuOutline = (
visible: materialFlg && Boolean (material?.tag) }, visible: materialFlg && Boolean (material?.tag) },
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`, { ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] }, visible: materialFlg }] },
...([ { ...message ('wiki'), to: '/wiki', subMenu: [
{ key: 'tagGroups', path: '/tag-groups', type: 'tag_group' }] as const) { ...message ('search'), to: '/wiki' },
.map (({ key, path, type }): MenuVisibleItem => { { ...message ('create'), to: '/wiki/new', visible: editable },
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 ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' }, { ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: detailFlg }, { component: <Separator/>, visible: wikiPageFlg },
{ ...message ('postsWithCount', { count: postCount }), { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
to: `/posts?tags=${ wikiTitle }`, visible: detailFlg }, visible: wikiPageFlg },
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, { ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
visible: detailFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`, { ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: detailFlg && editable }] } visible: wikiPageFlg && editable }] },
}),
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [ { ...message ('help'), to: '/help/home', base: '/help', subMenu: [
{ ...message ('search'), to: '/help' }, { ...message ('search'), to: '/help' },
{ ...message ('allHistory'), to: '/help/changes' }, { ...message ('allHistory'), to: '/help/changes' },
@@ -242,8 +229,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
queryKey: materialsKeys.show (effectiveTitle, locale), queryKey: materialsKeys.show (effectiveTitle, locale),
queryFn: () => fetchMaterial (effectiveTitle, locale) }) queryFn: () => fetchMaterial (effectiveTitle, locale) })
const menu = menuOutline ({ tag, material, wikiId, const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
wikiResourceType: wikiPage?.resourceType, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000') const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
const moreMenu = menu.filter (item => const moreMenu = menu.filter (item =>
-10
ファイルの表示
@@ -70,16 +70,6 @@ describe ('SidebarComponent', () => {
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true) vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
}) })
it ('limits sidebar scrolling to desktop to avoid clipping mobile suggestions', () => {
renderSidebar ()
// jsdom has no layout; guard the responsive classes responsible for clipping.
const sidebar = screen.getByText ('Sidebar body')
expect (sidebar).not.toHaveClass ('overflow-y-auto')
expect (sidebar).toHaveClass ('md:overflow-y-auto')
})
it ('uses maxWidth without applying the container main-width reserve twice', async () => { it ('uses maxWidth without applying the container main-width reserve twice', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '700') localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
const onWidthChange = vi.fn () const onWidthChange = vi.fn ()
+1 -1
ファイルの表示
@@ -339,7 +339,7 @@ const SidebarComponent: FC<Props> = ({
</style> </style>
</Helmet> </Helmet>
<div className="h-full md:overflow-y-auto p-4 sidebar"> <div className="h-full overflow-y-auto p-4 sidebar">
{children} {children}
</div> </div>
+3 -5
ファイルの表示
@@ -100,11 +100,10 @@ const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => { const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
const title = url.searchParams.get ('title') ?? '' const title = url.searchParams.get ('title') ?? ''
const resourceType = 'tag_group'
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ title, resourceType }, locale), queryKey: wikiKeys.index ({ title }, locale),
queryFn: () => fetchWikiPages ({ title, resourceType }, locale) }) queryFn: () => fetchWikiPages ({ title }, locale) })
} }
@@ -311,8 +310,7 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
&& Boolean (mPost (u.pathname))), && Boolean (mPost (u.pathname))),
run: prefetchPostShow }, run: prefetchPostShow },
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges }, { test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
{ test: u => u.pathname === '/tag-groups', { test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex },
run: prefetchWikiPagesIndex },
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
&& Boolean (mWiki (u.pathname))), && Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow }, run: prefetchWikiPageShow },
+1 -2
ファイルの表示
@@ -5,7 +5,6 @@ import type {
FetchPostsParams, FetchPostsParams,
FetchTagsParams, FetchTagsParams,
MaterialFilter, MaterialFilter,
WikiPage,
WikiTagReference } from '@/types' WikiTagReference } from '@/types'
export const localesKeys = { export const localesKeys = {
@@ -131,7 +130,7 @@ export const wikiKeys = {
autolinkCandidates: (language: string, locale: LocaleCode) => autolinkCandidates: (language: string, locale: LocaleCode) =>
['wiki', 'autolink', locale, language] as const, ['wiki', 'autolink', locale, language] as const,
index: ( index: (
p: { title?: string, resourceType?: WikiPage['resourceType'] }, p: { title?: string },
locale: LocaleCode = 'ja', locale: LocaleCode = 'ja',
) => ['wiki', locale, 'index', p] as const, ) => ['wiki', locale, 'index', p] as const,
show: ( show: (
+10 -54
ファイルの表示
@@ -1,6 +1,6 @@
import { isAxiosError } from 'axios' import { isAxiosError } from 'axios'
import { apiGet, apiPost, apiPut } from '@/lib/api' import { apiGet } from '@/lib/api'
import { switchLocalePath } from '@/lib/locales' import { switchLocalePath } from '@/lib/locales'
import { fetchTag, fetchTagByName } from '@/lib/tags' import { fetchTag, fetchTagByName } from '@/lib/tags'
@@ -24,54 +24,19 @@ export const fetchWikiChanges = (
export const fetchWikiPages = async ( export const fetchWikiPages = async (
{ title, resourceType = 'tag_description' }: { { title }: { title?: string },
title?: string, resourceType?: WikiPage['resourceType'] },
locale: LocaleCode = 'ja', locale: LocaleCode = 'ja',
): Promise<WikiPage[]> => apiGet ( ): Promise<WikiPage[]> =>
resourceType === 'tag_group' ? '/tag_groups' : '/tags/descriptions', await apiGet ('/wiki', { locale, params: { title } })
{ locale, params: { title, document: '1' } })
export const fetchWikiPage = async ( export const fetchWikiPage = async (
id: string, id: string,
{ version }: { version?: string }, { version }: { version?: string },
locale: LocaleCode = 'ja', locale: LocaleCode = 'ja',
): Promise<WikiPage> => { ): Promise<WikiPage> =>
if (version != null) await apiGet (`/wiki/${ encodeURIComponent (id) }`, {
return apiGet (`/wiki/${encodeURIComponent (id)}`, { locale, params: { version } }) locale, params: version ? { 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 ( export const fetchWikiPageByTitle = async (
@@ -81,14 +46,7 @@ export const fetchWikiPageByTitle = async (
): Promise<WikiPage | null> => { ): Promise<WikiPage | null> => {
try try
{ {
if (version != null) return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } })
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) catch (error)
{ {
@@ -114,7 +72,7 @@ export const fetchWikiPageByReference = async (
{ {
if (!isAxiosError (error) || error.response?.status !== 404) if (!isAxiosError (error) || error.response?.status !== 404)
throw error throw error
const tag = await fetchTagByName (reference, locale) const tag = await fetchWikiTag ({ wikiId: reference }, locale)
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale) return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
} }
} }
@@ -128,9 +86,7 @@ export const fetchWikiTag = async (
return fetchTagByName (reference.name, locale) return fetchTagByName (reference.name, locale)
try try
{ {
const page = await fetchWikiPage (reference.wikiId, { }, locale) return await apiGet (`/wiki/${encodeURIComponent (reference.wikiId)}/tag`, { locale })
return (page.resourceType === 'tag_description' && page.tagId != null)
? fetchTag (String (page.tagId), locale) : null
} }
catch (error) catch (error)
{ {
-2
ファイルの表示
@@ -14,8 +14,6 @@
"materials": "チカㇻペ", "materials": "チカㇻペ",
"suppressions": "アエアイカㇷ゚テㇷ゚", "suppressions": "アエアイカㇷ゚テㇷ゚",
"wiki": "Wiki", "wiki": "Wiki",
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
"tagGroups": "$t(counts:resources.tag_groups)",
"create": "アシㇼ", "create": "アシㇼ",
"edit": "イヌイェㇷ゚", "edit": "イヌイェㇷ゚",
"entertainment": "シノッ", "entertainment": "シノッ",
-2
ファイルの表示
@@ -13,8 +13,6 @@
"materials": "素材", "materials": "素材",
"suppressions": "抑止", "suppressions": "抑止",
"wiki": "Wiki", "wiki": "Wiki",
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
"tagGroups": "$t(counts:resources.tag_groups)",
"create": "新規", "create": "新規",
"edit": "編輯", "edit": "編輯",
"entertainment": "おたのしみ", "entertainment": "おたのしみ",
+1 -1
ファイルの表示
@@ -135,7 +135,7 @@ const MaterialDetailPage: FC = () => {
<TabGroup> <TabGroup>
{material.tag && ( {material.tag && (
<Tab name={label ('navigation:tagDescriptions')}> <Tab name={label ('navigation:wiki')}>
<WikiBody <WikiBody
title={material.tag.name} title={material.tag.name}
language={localeLanguage (locale)} language={localeLanguage (locale)}
+1 -1
ファイルの表示
@@ -176,7 +176,7 @@ const PostListPage: FC = () => {
{loading && label ('common:loading')} {loading && label ('common:loading')}
</Tab> </Tab>
{tags.length === 1 && ( {tags.length === 1 && (
<Tab name={label ('navigation:tagDescriptions')}> <Tab name={label ('navigation:wiki')}>
<WikiBody title={tags[0]} body={wikiPage?.body} <WikiBody title={tags[0]} body={wikiPage?.body}
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description' language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
? localeLanguage (locale) : 'und')}/> ? localeLanguage (locale) : 'und')}/>
+2 -9
ファイルの表示
@@ -35,10 +35,6 @@ const renderPage = () =>
describe ('TagDetailPage', () => { describe ('TagDetailPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
tagsApi.fetchTag.mockReset ()
api.apiPut.mockReset ()
api.isApiError.mockReset ()
toastApi.toast.mockReset ()
api.isApiError.mockReturnValue (false) api.isApiError.mockReturnValue (false)
}) })
@@ -55,11 +51,8 @@ describe ('TagDetailPage', () => {
}) })
it ('submits edited tag fields', async () => { it ('submits edited tag fields', async () => {
const updatedTag = buildTag ({ id: 7, name: 'new', aliases: ['alias'] }) tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
tagsApi.fetchTag api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] }))
.mockResolvedValue (updatedTag)
.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.apiPut.mockResolvedValueOnce (updatedTag)
renderPage () renderPage ()
+1 -2
ファイルの表示
@@ -51,8 +51,7 @@ const WikiDetailPage: FC = () => {
queryKey: helpKeys.titles ('ja'), queryKey: helpKeys.titles ('ja'),
queryFn: () => getHelpTitleIndex ('ja'), queryFn: () => getHelpTitleIndex ('ja'),
staleTime: Infinity }) staleTime: Infinity })
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase () const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
.replace (/^(?:ヘルプ|手引):/, '')
const identifiers = useMemo (() => ( const identifiers = useMemo (() => (
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle)) (titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle]) ? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
+13 -10
ファイルの表示
@@ -11,8 +11,8 @@ import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
import { fetchWikiPage, saveTagDocument } from '@/lib/wiki' import { apiGet, apiPut } from '@/lib/api'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys' import { wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' 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 Props = { user: User | null }
type WikiFormField = 'title' | 'name' | 'body' type WikiFormField = 'title' | 'body'
const WikiEditPage: FC<Props> = ({ user }) => { const WikiEditPage: FC<Props> = ({ user }) => {
@@ -50,7 +50,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
const source = `${locale}:${id}` const source = `${locale}:${id}`
const { data: wiki, isPending: loading } = useQuery ({ const { data: wiki, isPending: loading } = useQuery ({
queryKey: wikiKeys.show (id ?? '', { }, locale), queryKey: wikiKeys.show (id ?? '', { }, locale),
queryFn: () => fetchWikiPage (id ?? '', { }, locale), queryFn: () => apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, { locale }),
enabled: editable }) enabled: editable })
const [body, setBody] = useState (wiki?.body ?? '') const [body, setBody] = useState (wiki?.body ?? '')
const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source) const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source)
@@ -60,19 +60,22 @@ const WikiEditPage: FC<Props> = ({ user }) => {
useValidationErrors<WikiFormField> () useValidationErrors<WikiFormField> ()
const handleSubmit = async () => { const handleSubmit = async () => {
if (pending || !editable || wiki == null) if (pending || !editable)
return return
clearValidationErrors () clearValidationErrors ()
const formData = new FormData ()
formData.append ('title', title)
formData.append ('body', body)
try try
{ {
await saveTagDocument ({ title, body, page: wiki }, locale) await apiPut (`/wiki/${ id }`, formData,
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
qc.setQueryData (wikiKeys.show (title, { }, locale), qc.setQueryData (wikiKeys.show (title, { }, locale),
(prev: WikiPage | undefined) => (prev: WikiPage | undefined) =>
prev == null ? prev : { ...prev, title, body }) prev == null ? prev : { ...prev, title, body })
await Promise.all ([ qc.invalidateQueries ({ queryKey: wikiKeys.root })
qc.invalidateQueries ({ queryKey: wikiKeys.root }),
qc.invalidateQueries ({ queryKey: tagsKeys.root })])
toast ({ title: label ('shared:messages.postSuccess') }) toast ({ title: label ('shared:messages.postSuccess') })
navigate (`/wiki/${ encodeURIComponent (title) }`) navigate (`/wiki/${ encodeURIComponent (title) }`)
} }
@@ -107,7 +110,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{/* タイトル */} {/* タイトル */}
{/* TODO: タグ補完 */} {/* TODO: タグ補完 */}
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}> <FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input type="text" <input type="text"
value={title} value={title}
+10 -5
ファイルの表示
@@ -12,7 +12,7 @@ import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
import { saveTagDocument } from '@/lib/wiki' import { apiPost } from '@/lib/api'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys' import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
@@ -21,7 +21,7 @@ import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
import type { User } from '@/types' import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt const mdParser = new MarkdownIt
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const 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 Props = { user: User | null }
type WikiFormField = 'title' | 'name' | 'body' type WikiFormField = 'title' | 'body'
const WikiNewPage: FC<Props> = ({ user }) => { const WikiNewPage: FC<Props> = ({ user }) => {
@@ -55,9 +55,14 @@ const WikiNewPage: FC<Props> = ({ user }) => {
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors () clearValidationErrors ()
const formData = new FormData
formData.append ('title', title)
formData.append ('body', body)
try try
{ {
const data = await saveTagDocument ({ title, body }, locale) const data = await apiPost<WikiPage> ('/wiki', formData,
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
await qc.cancelQueries ({ queryKey: wikiKeys.root }) await qc.cancelQueries ({ queryKey: wikiKeys.root })
qc.setQueryData (wikiKeys.show (data.title, { }, locale), data) qc.setQueryData (wikiKeys.show (data.title, { }, locale), data)
await Promise.all ([ await Promise.all ([
@@ -86,7 +91,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{/* タイトル */} {/* タイトル */}
{/* TODO: タグ補完 */} {/* TODO: タグ補完 */}
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}> <FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input type="text" <input type="text"
value={title} value={title}
+7 -11
ファイルの表示
@@ -8,7 +8,7 @@ import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
import { fetchWikiPages } from '@/lib/wiki' import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys' import { wikiKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
@@ -17,20 +17,16 @@ import type { FormEvent , FC } from 'react'
import type { WikiPage } from '@/types' import type { WikiPage } from '@/types'
const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = ( const WikiSearchPage: FC = () => {
{ resourceType = 'tag_description' },
) => {
const { message, label } = useUITranslation ('shared') const { message, label } = useUITranslation ('shared')
const locale = useLocale () const locale = useLocale ()
const heading =
resourceType === 'tag_group'
? 'navigation:tagGroups' : 'navigation:tagDescriptions'
const [title, setTitle] = useState ('') const [title, setTitle] = useState ('')
const [text, setText] = useState ('') const [text, setText] = useState ('')
const [submittedTitle, setSubmittedTitle] = useState ('') const [submittedTitle, setSubmittedTitle] = useState ('')
const { data: results = [], refetch } = useQuery ({ const { data: results = [], refetch } = useQuery ({
queryKey: wikiKeys.index ({ title: submittedTitle, resourceType }, locale), queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
queryFn: () => fetchWikiPages ({ title: submittedTitle, resourceType }, locale) }) queryFn: () => apiGet<WikiPage[]> ('/wiki', {
locale, params: { title: submittedTitle } }) })
const handleSearch = (ev: FormEvent) => { const handleSearch = (ev: FormEvent) => {
ev.preventDefault () ev.preventDefault ()
@@ -46,10 +42,10 @@ const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
return ( return (
<MainArea> <MainArea>
<PageHead title={message (heading)}/> <PageHead title={message ('navigation:wiki')}/>
<div className="max-w-xl"> <div className="max-w-xl">
<PageTitle>{label (heading)}</PageTitle> <PageTitle>{label ('navigation:wiki')}</PageTitle>
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */} {/* タイトル */}
<FormField label={label ('labels.title')}> <FormField label={label ('labels.title')}>