diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index 9c05085..cc8c1d6 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -35,8 +35,18 @@ class TagVersionsController < ApplicationController private def serialise_versions rows, language_code + rows = rows.to_a user_ids = rows.map(&:created_by_user_id).compact.uniq users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h + parent_ids = rows.flat_map do |row| + split_parent_tag_ids(row.parent_tag_ids) + + split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) + end.uniq + parents_by_id = Tag.includes( + :tag_names, :materials, :deerjikists, :tag_descriptions, + parents: [:tag_names, :materials, :deerjikists, :tag_descriptions]) + .where(id: parent_ids).index_by(&:id) + parent_json = parents_by_id.transform_values { TagRepr.base(_1, language_code:) } rows.map do |row| current_names = names_for_locale(row.names, language_code) @@ -47,18 +57,10 @@ class TagVersionsController < ApplicationController cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) - all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids) - - tags_by_id = - Tag - .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) - .where(id: all_parent_tag_ids) - .index_by(&:id) - parent_tags = build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id) .map do |h| - { tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:), + { tag: parent_json.fetch(h[:tag_id]), type: h[:type] } end @@ -66,6 +68,7 @@ class TagVersionsController < ApplicationController version_no: row.version_no, event_type: row.event_type, names: row.names, + name_changes: name_changes(row.names, row.attributes['prev_names']), name: { current: current_names['name'], prev: previous_names['name'] }, category: { current: row.category, prev: row.attributes['prev_category'] }, deprecated_at: { current: row.deprecated_at&.iso8601, @@ -97,11 +100,44 @@ class TagVersionsController < ApplicationController def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?) def names_for_locale names, language_code + values = snapshot_names(names)[language_code] + values.is_a?(Hash) ? values : {} + end + + def snapshot_names names values = names.is_a?(String) ? JSON.parse(names) : names - values&.fetch(language_code, {}) || {} + values.is_a?(Hash) ? values : {} rescue JSON::ParserError {} end + def name_changes current_names, previous_names + current = snapshot_names(current_names) + previous = snapshot_names(previous_names) + (current.keys | previous.keys).sort.filter_map do |language| + current_name = language_snapshot(current[language]) + previous_name = language_snapshot(previous[language]) + next if current_name == previous_name + + { language_code: language, current: current_name, prev: previous_name } + end + end + + def language_snapshot entry + return nil unless entry.is_a?(Hash) + + aliases = Array(entry['aliases']).filter_map do |value| + value = { 'name' => value } if value.is_a?(String) + next unless value.is_a?(Hash) && value['name'].is_a?(String) + + { name: value['name'], script_code: value['script_code'] } + end + aliases = aliases.uniq.sort_by { [_1[:name], _1[:script_code].to_s] } + { name: entry['name'], + script_code: entry['script_code'], + auto_generated: entry['auto_generated'], + aliases: } + end + def split_parent_tag_ids(values) = split_values(values).map(&:to_i) end diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 34b0ae6..1991294 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -37,27 +37,18 @@ class WikiPagesController < ApplicationController def autolink_candidates locale = resolve_locale! language = params.fetch(:language, locale.language_code) + language = 'ja' if language == 'und' return head :not_found unless language.is_a?(String) - target_locale = - if language == 'und' - locale - else - Locale.where(language_code: language, deprecated_at: nil, - code: Locale.codes.values).order(:code).first - end - return head :not_found unless target_locale + return head :not_found unless Language.exists?(code: language) - candidates = [] - unless language == 'und' - names = TagDescription.joins(tag: :tag_names) - .where(language_code: language, tag_names: { language_code: language }) - .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') - candidates = names.map do |tag_id, description_id, name| - WikiPageRepr.autolink_tag(target_locale, tag_id:, description_id:, name:) - end + names = TagDescription.joins(tag: :tag_names) + .where(language_code: locale.language_code, tag_names: { language_code: language }) + .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') + candidates = names.map do |tag_id, description_id, name| + WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:) end candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name| - WikiPageRepr.autolink_group(target_locale, id:, name:, language:) + WikiPageRepr.autolink_group(locale, id:, name:, language:) end) render json: candidates end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index f9d9951..6993bd0 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -1,8 +1,7 @@ class Locale < ApplicationRecord attr_accessor :certified_tag_names_path - before_save :lock_tag_creation!, if: :will_generate_tag_names? - after_save :generate_tag_names!, if: :needs_tag_names? + around_save :save_with_tag_names!, if: :will_generate_tag_names? belongs_to :language, foreign_key: :language_code, primary_key: :code belongs_to :script, foreign_key: :script_code, primary_key: :code @@ -17,11 +16,17 @@ class Locale < ApplicationRecord deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?) end - def lock_tag_creation! = SharedResourceIdAllocator.lock_space!(Tag, 'tags') + def save_with_tag_names! + completed = self.class.transaction(requires_new: true) do + SharedResourceIdAllocator.lock_space!(Tag, 'tags') + saved = yield + raise ActiveRecord::RecordNotSaved, 'Locale save was halted' unless saved - def needs_tag_names? - deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?) + TagNameCompletion.for_locale!(self) + true + end + unless completed + raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back' + end end - - def generate_tag_names! = TagNameCompletion.for_locale!(self) end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index b870002..5336c01 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -111,6 +111,11 @@ class Tag < ApplicationRecord def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? def snapshot_aliases language_code + if tag_names.loaded? + return tag_names.select { _1.language_code == language_code && !_1.primary? } + .sort_by(&:name).map(&:name) + end + tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) end diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index b6e3e41..3a4aa21 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -27,13 +27,13 @@ module WikiPageRepr TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group') end - def autolink_tag locale, tag_id:, description_id:, name: + def autolink_tag locale, tag_id:, description_id:, name:, language: { resource_type: 'tag_description', resource_id: description_id, tag_id:, tag_description_id: description_id, name:, - language_code: locale.language_code, + language_code: language, locale_code: locale.code_before_type_cast } end diff --git a/backend/app/services/shared_resource_id_allocator.rb b/backend/app/services/shared_resource_id_allocator.rb index bcf5377..cbc6d0e 100644 --- a/backend/app/services/shared_resource_id_allocator.rb +++ b/backend/app/services/shared_resource_id_allocator.rb @@ -13,7 +13,19 @@ class SharedResourceIdAllocator ['tag_description_versions', 'tag_id']], 'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze LOCK_TIMEOUT = 10 - TRANSACTION_LOCKS = ObjectSpace::WeakMap.new + CONNECTION_LOCKS = ObjectSpace::WeakMap.new + + class LockCleanup + def initialize &finish + @finish = finish + end + + def trigger_transactional_callbacks? = false + def before_committed! = nil + def committed!(**) = @finish.call(self) + def rolledback!(**) = @finish.call(self) + end + private_constant :LockCleanup def self.assign! record space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) } @@ -42,14 +54,14 @@ class SharedResourceIdAllocator raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space) connection = record_class.connection - transaction = record_class.current_transaction + transaction = connection.current_transaction unless transaction.open? raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' end database = connection.pool.db_config.database name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }" - held_locks = TRANSACTION_LOCKS[transaction] ||= {} + held_locks = CONNECTION_LOCKS[connection] ||= {} return if held_locks.key?(name) quoted_name = connection.quote(name) @@ -61,20 +73,41 @@ class SharedResourceIdAllocator end held_locks[name] = true - release = lambda do - held_locks.delete(name) + finish = lambda do |cleanup| + next unless held_locks.key?(name) + + # Savepoint callbacks may run before the outer transaction ends. + # Keep one session lock and transfer its cleanup to the remaining parent. + parent = connection.current_transaction + if parent.open? + connection.add_transaction_record(cleanup) + next + end result = record_class.uncached do connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })") end unless result.to_i == 1 raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }" end + held_locks.delete(name) + rescue StandardError + # Never return a session with an untracked advisory lock to the pool. + begin + connection.disconnect! + ensure + held_locks.clear + end + raise end begin - transaction.after_commit(&release) - transaction.after_rollback(&release) + # Rails finalises remaining records in ensure if another callback raises. + connection.add_transaction_record(LockCleanup.new(&finish)) rescue StandardError - release.call + begin + connection.disconnect! + ensure + held_locks.clear + end raise end end diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 0256c95..6e72a11 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -1,6 +1,6 @@ // TODO: TagSearch と共通化する. -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' @@ -46,11 +46,19 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [focused, setFocused] = useState (false) - const [suggestions, setSuggestions] = useState ([]) + const [request, setRequest] = useState ({ query: '' }) + const [result, setResult] = useState<{ + locale: string + request: { query: string } + tags: Tag[] }> ({ locale, request, tags: [] }) + const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token + const suggestions = (result.locale === locale && result.request === request + && request.query === currentToken) ? result.tags : [] const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const handleTagSelect = (tag: Tag) => { setSuggestionsVsbl (false) + setRequest ({ query: '' }) const textarea = ref.current! const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ') setTags (newValue) @@ -62,8 +70,10 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { }) } - const recompute = async (pos: number, v: string = tags) => { + const recompute = (pos: number, v: string = tags) => { const { start, end, token } = getTokenAt (v, pos) + setRequest ({ query: token }) + setSuggestionsVsbl (token.trim () !== '') if (!(token.trim ())) { setSuggestionsVsbl (false) @@ -71,15 +81,41 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { } setBounds ({ start, end }) - - const data = await apiGet ('/tags/autocomplete', { locale, params: { - q: token, - nico: '0' } }) - const nextSuggestions = data.filter (t => t.postCount > 0) - setSuggestions (nextSuggestions) - setSuggestionsVsbl (nextSuggestions.length > 0) } + useEffect (() => { + if (request.query.trim () === '') + return + const controller = new AbortController () + let active = true + void (async () => { + try + { + const tags = await apiGet ('/tags/autocomplete', { + locale, signal: controller.signal, params: { q: request.query, nico: '0' } }) + if (active) + { + setResult ({ locale, request, + tags: tags.filter (tag => tag.postCount > 0) }) + } + } + catch + { + if (active) + setResult ({ locale, request, tags: [] }) + } + }) () + return () => { + active = false + controller.abort () + } + }, [request, locale]) + + useEffect (() => { + setSuggestionsVsbl (false) + setRequest ({ query: '' }) + }, [locale]) + return ( {({ describedBy, invalid }) => ( @@ -93,7 +129,7 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { onChange={ev => setTags (ev.target.value)} onSelect={async (ev: SyntheticEvent) => { const pos = (ev.target as HTMLTextAreaElement).selectionStart - await recompute (pos) + await recompute (pos, (ev.target as HTMLTextAreaElement).value) }} onFocus={() => setFocused (true)} onBlur={() => { diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index 5633f7c..aa9675f 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -23,25 +23,57 @@ const TagSearch: FC = () => { const [activeIndex, setActiveIndex] = useState (-1) const [search, setSearch] = useState ('') - const [suggestions, setSuggestions] = useState ([]) + const [request, setRequest] = useState ({ query: '' }) + const [result, setResult] = useState<{ + locale: string + request: { query: string } + tags: Tag[] }> ({ locale, request, tags: [] }) + const suggestions = result.locale === locale && result.request === request + ? result.tags : [] const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) - const whenChanged = async (ev: ChangeEvent) => { - setSearch (ev.target.value) - - const q = ev.target.value.trim ().split (' ').at (-1) - if (!(q)) - { - setSuggestions ([]) - return - } - - const data = await apiGet ('/tags/autocomplete', { locale, params: { q } }) - setSuggestions (data.filter (t => t.postCount > 0)) - if (suggestions.length > 0) - setSuggestionsVsbl (true) + const whenChanged = (ev: ChangeEvent) => { + const value = ev.target.value + const query = value.trim ().split (' ').at (-1) ?? '' + setSearch (value) + setRequest ({ query }) + setActiveIndex (-1) + setSuggestionsVsbl (query !== '') } + useEffect (() => { + if (request.query === '') + return + const controller = new AbortController () + let active = true + void (async () => { + try + { + const tags = await apiGet ('/tags/autocomplete', { + locale, signal: controller.signal, params: { q: request.query } }) + if (active) + { + setResult ({ locale, request, + tags: tags.filter (tag => tag.postCount > 0) }) + } + } + catch + { + if (active) + setResult ({ locale, request, tags: [] }) + } + }) () + return () => { + active = false + controller.abort () + } + }, [request, locale]) + + useEffect (() => { + setActiveIndex (-1) + setSuggestionsVsbl (false) + }, [locale]) + const handleKeyDown = (ev: KeyboardEvent) => { switch (ev.key) { @@ -85,6 +117,7 @@ const TagSearch: FC = () => { match })) .toString () }`) setSuggestionsVsbl (false) + setRequest ({ query: '' }) } } @@ -95,7 +128,8 @@ const TagSearch: FC = () => { ? ('not:' + tag.name) : tag.name) setSearch (parts.join (' ') + ' ') - setSuggestions ([]) + setRequest ({ query: '' }) + setSuggestionsVsbl (false) setActiveIndex (-1) } @@ -104,6 +138,9 @@ const TagSearch: FC = () => { const anyFlg = query.get ('match') === 'any' const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }` setSearch (tagsQuery) + setRequest ({ query: '' }) + setActiveIndex (-1) + setSuggestionsVsbl (false) }, [location.search]) return ( diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index 1a41e9f..9f1acf9 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -35,15 +35,16 @@ const WikiBody: FC = ({ title, body, language = 'und' }) => { const missingPage = message ('missingPage', { url: `/wiki/new?title=${ encodeURIComponent (title) }` }) const contentLanguage = body ? language : missingPage.language - const matchLanguage = contentLanguage === 'ain-Kana' ? 'ain' : contentLanguage + const matchLanguage = contentLanguage === 'und' + ? 'ja' : (contentLanguage === 'ain-Kana' ? 'ain' : contentLanguage) const { data } = useQuery ({ enabled: Boolean (body), queryKey: wikiKeys.autolinkCandidates (matchLanguage, locale), queryFn: () => fetchWikiAutolinkCandidates (matchLanguage, locale) }) const remarkPlugins = useMemo ( - () => [() => remarkWikiAutoLink (data ?? [], matchLanguage), remarkGFM], - [data, matchLanguage]) + () => [() => remarkWikiAutoLink (data ?? [], matchLanguage, locale), remarkGFM], + [data, matchLanguage, locale]) const components = { ...((contentLanguage === 'ain' || contentLanguage === 'ain-Kana') ? ainuMDXComponents : { }), diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index f642c51..be12274 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useLocale } from '@/lib/useLocale' import TagSearchBox from '@/components/TagSearchBox' @@ -27,34 +27,67 @@ const TagInput: FC = ({ }) => { const locale = useLocale () const [activeIndex, setActiveIndex] = useState (-1) - const [suggestions, setSuggestions] = useState ([]) + const [request, setRequest] = useState ({ query: '' }) + const [result, setResult] = useState<{ + locale: string + includeNico: boolean + request: { query: string } + tags: Tag[] }> ({ locale, includeNico, request, tags: [] }) + const suggestions = (result.locale === locale && result.includeNico === includeNico + && result.request === request + && request.query === value.trim ().split (' ').at (-1)) ? result.tags : [] const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) // TODO: TagSearch からのコピペのため,共通化を考へる. - const whenChanged = async (ev: ChangeEvent) => { + const whenChanged = (ev: ChangeEvent) => { setValue (ev.target.value) - - const q = ev.target.value.trim ().split (' ').at (-1) - if (!(q)) - { - setSuggestions ([]) - return - } - - const data = await apiGet ('/tags/autocomplete', { locale, params: { - q, - nico: includeNico ? '1' : '0' } }) - const nextSuggestions = data.filter (t => t.postCount > 0) - setSuggestions (nextSuggestions) - setSuggestionsVsbl (nextSuggestions.length > 0) + const query = ev.target.value.trim ().split (' ').at (-1) ?? '' + setRequest ({ query }) + setActiveIndex (-1) + setSuggestionsVsbl (query !== '') } + useEffect (() => { + if (request.query === '') + return + const controller = new AbortController () + let active = true + void (async () => { + try + { + const tags = await apiGet ('/tags/autocomplete', { + locale, signal: controller.signal, + params: { q: request.query, nico: includeNico ? '1' : '0' } }) + if (active) + { + setResult ({ locale, includeNico, request, + tags: tags.filter (tag => tag.postCount > 0) }) + } + } + catch + { + if (active) + setResult ({ locale, includeNico, request, tags: [] }) + } + }) () + return () => { + active = false + controller.abort () + } + }, [request, locale, includeNico]) + + useEffect (() => { + setActiveIndex (-1) + setSuggestionsVsbl (false) + setRequest ({ query: '' }) + }, [locale, includeNico]) + // TODO: TagSearch からのコピペのため,共通化を考へる. const handleTagSelect = (tag: Tag) => { const parts = value?.split (' ') parts[parts.length - 1] = tag.name setValue (parts.join (' ') + ' ') - setSuggestions ([]) + setRequest ({ query: '' }) setActiveIndex (-1) } diff --git a/frontend/src/lib/locales.ts b/frontend/src/lib/locales.ts index 99bd2f4..7d89054 100644 --- a/frontend/src/lib/locales.ts +++ b/frontend/src/lib/locales.ts @@ -35,6 +35,17 @@ export const localeFromPath = (pathname: string): LocaleCode | null => { } +export const resolveViewingLocale = ( + pathname: string, + routeLocale?: unknown, +): LocaleCode => + isLocaleCode (routeLocale) ? routeLocale : localeFromPath (pathname) ?? 'ja' + + +export const currentViewingLocale = (): LocaleCode => + resolveViewingLocale (typeof window === 'undefined' ? '' : window.location.pathname) + + export const unlocalisedPath = (pathname: string): string => { const locale = localeFromPath (pathname) return locale == null ? pathname : pathname.slice (locale.length + 1) || '/' diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 64d8240..4bb563d 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -118,8 +118,9 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => { if (wikiPage?.body) { - const language = wikiPage.languageCode + const contentLanguage = wikiPage.languageCode ?? (wikiPage.resourceType === 'tag_description' ? localeLanguage (locale) : 'und') + const language = contentLanguage === 'und' ? 'ja' : contentLanguage await qc.prefetchQuery ({ queryKey: wikiKeys.autolinkCandidates (language, locale), queryFn: () => fetchWikiAutolinkCandidates (language, locale) }) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index bc8a713..42a5a2c 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -60,11 +60,11 @@ export const tagsKeys = { nicoIndex: ( p: FetchNicoTagsParams, locale: LocaleCode = 'ja', -) => ['tags', locale, 'nico', 'index', p] as const, +) => ['tags', 'nico', locale, 'index', p] as const, externalShow: ( id: string, locale: LocaleCode = 'ja', -) => ['tags', locale, 'nico', id] as const, +) => ['tags', 'nico', locale, id] as const, show: ( name: string, locale: LocaleCode = 'ja', diff --git a/frontend/src/lib/remark-wiki-autolink.ts b/frontend/src/lib/remark-wiki-autolink.ts index 7e9e606..be7d47a 100644 --- a/frontend/src/lib/remark-wiki-autolink.ts +++ b/frontend/src/lib/remark-wiki-autolink.ts @@ -1,6 +1,7 @@ import type { Content, Parent, Root, RootContent } from 'mdast' import type { WikiAutolinkCandidate } from '@/types' +import type { LocaleCode } from '@/lib/locales' const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -34,12 +35,14 @@ const edgeCharacter = (node: Content, first: boolean): string => { const remarkWikiAutoLink = ( candidates: WikiAutolinkCandidate[], language: string, + locale: LocaleCode, ): ((tree: Root) => void) => { const targets = new Map () const ambiguous = new Set () for (const candidate of candidates) { - if (candidate.languageCode !== language || candidate.name.length === 0) + if (candidate.languageCode !== language || candidate.localeCode !== locale + || candidate.name.length === 0) continue const previous = targets.get (candidate.name) if (previous != null @@ -89,7 +92,7 @@ const remarkWikiAutoLink = ( parts.push ({ type: 'text', value: value.slice (last, start) }) parts.push ({ type: 'link', - url: `/${candidate.localeCode}/wiki/${encodeURIComponent (candidate.resourceId)}`, + url: `/${locale}/wiki/${encodeURIComponent (candidate.resourceId)}`, title: null, children: [{ type: 'text', value: match[0] }], data: { hProperties: { 'data-wiki': '1' } } }) diff --git a/frontend/src/lib/useLocale.ts b/frontend/src/lib/useLocale.ts index fb6ee21..31bc807 100644 --- a/frontend/src/lib/useLocale.ts +++ b/frontend/src/lib/useLocale.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' -import { isLocaleCode, localeFromPath, localiseTo } from '@/lib/locales' +import { localiseTo, resolveViewingLocale } from '@/lib/locales' import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom' @@ -11,7 +11,7 @@ import type { LocaleCode } from '@/lib/locales' export const useLocale = (): LocaleCode => { const { locale } = useParams () const location = useLocation () - return isLocaleCode (locale) ? locale : localeFromPath (location.pathname) ?? 'ja' + return resolveViewingLocale (location.pathname, locale) } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 1082eab..da3267e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -2,6 +2,7 @@ import { clsx } from 'clsx' import { twMerge } from 'tailwind-merge' import { i18n } from '@/lib/i18n' +import { currentViewingLocale } from '@/lib/locales' import type { ClassValue } from 'clsx' @@ -14,37 +15,39 @@ export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Da export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs)) -export const dateString = ( - d: string | Date, - unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string => - toDate (d).toLocaleString ( - 'ja-JP-u-ca-japanese', - { era: 'long', - year: 'numeric', - month: (unknown === 'month' ? undefined : 'long'), +type DatePrecision = 'month' | 'day' | 'hour' | 'minute' | 'second' | null + +const dateFormatters = new Map () +let ainuDateFormatter: Intl.DateTimeFormat | null = null + + +const formatDateString = ( + date: Date, + unknown: DatePrecision, + locale: LocaleCode, +): string => { + if (Number.isNaN (date.getTime ())) + return date.toString () + if (locale === 'ja') + { + if (!dateFormatters.has (unknown)) + { + dateFormatters.set (unknown, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', { + era: 'long', year: 'numeric', + month: unknown === 'month' ? undefined : 'long', day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric', weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short', hour: unknown == null || ['second', 'minute'].includes (unknown) ? 'numeric' : undefined, minute: unknown == null || unknown === 'second' ? 'numeric' : undefined, - second: unknown == null ? 'numeric' : undefined }) - - -export const localisedDateString = ( - value: string | Date, - locale: LocaleCode, - unknown: Parameters[1] = null, -): string => { - if (locale === 'ja') - return dateString (value, unknown) - - const date = toDate (value) - if (Number.isNaN (date.getTime ())) - return dateString (value, unknown) - - const parts = new Intl.DateTimeFormat ('en-CA', { + second: unknown == null ? 'numeric' : undefined })) + } + return dateFormatters.get (unknown)!.format (date) + } + ainuDateFormatter ??= new Intl.DateTimeFormat ('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', - hourCycle: 'h23' }).formatToParts (date) + hourCycle: 'h23' }) + const parts = ainuDateFormatter.formatToParts (date) const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? '' const omitDay = unknown != null && ['month', 'day'].includes (unknown) const dateParts = [part ('year'), @@ -60,6 +63,20 @@ export const localisedDateString = ( } +export const dateString = ( + value: string | Date, + unknown: DatePrecision = null, + locale?: LocaleCode, +): string => formatDateString (toDate (value), unknown, locale ?? currentViewingLocale ()) + + +export const localisedDateString = ( + value: string | Date, + locale: LocaleCode, + unknown: DatePrecision = null, +): string => dateString (value, unknown, locale) + + export const originalCreatedAtString = ( f: string | Date | null, b: string | Date | null, diff --git a/frontend/src/locales/ain-Kana/history.json b/frontend/src/locales/ain-Kana/history.json index c427931..24b4ac9 100644 --- a/frontend/src/locales/ain-Kana/history.json +++ b/frontend/src/locales/ain-Kana/history.json @@ -1,6 +1,11 @@ { "_untranslated": [ - "exportPath" + "exportPath", + "restoreScope", + "nameChanges.title", + "nameChanges.script", + "nameChanges.automatic", + "nameChanges.certified" ], "exportPath": "export_path", "postTitle": "$t(tags:history)", @@ -22,6 +27,13 @@ "confirmTitle": "アホシピレ エヌカㇻ", "confirmPost": "「{{name}}」 ウェㇽソン {{version}} ウン アホシピレ ヤ?\nピㇼカ ヤ?", "confirmTag": "シロシ「{{name}}」 ウェㇽソン {{version}} ウン アホシピレ ヤ?\nピㇼカ ヤ?", + "restoreScope": "名称・別名は現在表示中の言語だけを差し戻します。全言語の名称は復元しません。", + "nameChanges": { + "title": "言語別の変更", + "script": "文字体系", + "automatic": "自動生成", + "certified": "公証済み" + }, "revert": "アホシピレ", "restored": "アホシピレ オケレ!", "restoreFailed": "アホシピレ カ エアイカㇷ゚……", diff --git a/frontend/src/locales/ja/history.json b/frontend/src/locales/ja/history.json index 9e1421d..5fac891 100644 --- a/frontend/src/locales/ja/history.json +++ b/frontend/src/locales/ja/history.json @@ -19,6 +19,13 @@ "confirmTitle": "差戻の確認", "confirmPost": "『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?", "confirmTag": "タグ『{{name}}』を版 {{version}} に差戻します.\nよろしいですか?", + "restoreScope": "名称・別名は現在表示中の言語だけを差し戻します。全言語の名称は復元しません。", + "nameChanges": { + "title": "言語別の変更", + "script": "文字体系", + "automatic": "自動生成", + "certified": "公証済み" + }, "revert": "差戻", "restored": "差戻しました.", "restoreFailed": "差戻に失敗……", diff --git a/frontend/src/pages/TOSPage/index.tsx b/frontend/src/pages/TOSPage/index.tsx index 8962aee..be0436c 100644 --- a/frontend/src/pages/TOSPage/index.tsx +++ b/frontend/src/pages/TOSPage/index.tsx @@ -14,7 +14,7 @@ const TOSPage = () => { const Terms = terms[`./${ locale }.mdx`] ?? terms['./ja.mdx'] const language = terms[`./${ locale }.mdx`] != null ? locale : 'ja' - if (!terms) + if (!Terms) throw new Error ('TOSPage: ja.mdx not found') return ( diff --git a/frontend/src/pages/TOSPage/ja.mdx b/frontend/src/pages/TOSPage/ja.mdx index a0f1fe3..d10c384 100644 --- a/frontend/src/pages/TOSPage/ja.mdx +++ b/frontend/src/pages/TOSPage/ja.mdx @@ -1,7 +1,7 @@ import MainArea from '@/components/layout/MainArea' import { dateString } from '@/lib/utils' -export const lastUpdatedAt = dateString ('2026-04-12', 'hour') +export const lastUpdatedAt = dateString ('2026-04-12', 'hour', 'ja') diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx index c710181..296d84b 100644 --- a/frontend/src/pages/tags/TagHistoryPage.tsx +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -12,7 +12,7 @@ import PageHead from '@/components/common/PageHead' import { apiPut } from '@/lib/api' import { useDialogue } from '@/lib/dialogues/useDialogue' import { clientScrollBehaviour } from '@/lib/clientAnimation' -import { postsKeys, tagsKeys } from '@/lib/queryKeys' +import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { fetchTagChanges } from '@/lib/tags' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useUITranslation } from '@/lib/useUITranslation' @@ -20,6 +20,36 @@ import { cn, localisedDateString } from '@/lib/utils' import type { FC, ReactNode } from 'react' +import type { TagNameSnapshot } from '@/types' + + +const renderNameSnapshot = ( + snapshot: TagNameSnapshot, + language: string, + label: (key: string) => ReactNode, +) => ( +
+
+ {label ('name')}: + {snapshot.name} +
+ {snapshot.scriptCode != null && ( +
{label ('nameChanges.script')}:{snapshot.scriptCode}
)} + {snapshot.aliases.length > 0 && ( +
+ {label ('tags:aliases')}: + {snapshot.aliases.map (alias => ( + + {alias.name} + {alias.scriptCode != null && ({alias.scriptCode})} + ))} +
)} + {snapshot.autoGenerated != null && ( +
+ {label (snapshot.autoGenerated ? 'nameChanges.automatic' : 'nameChanges.certified')} +
)} +
) + const renderDiff = ( diff: { current: string | null; prev: string | null }, @@ -119,7 +149,25 @@ const TagHistoryPage: FC = () => { {change.tagId}.{change.versionNo} - {renderDiff (change.name)} + + {renderDiff (change.name)} + {(change.nameChanges?.length ?? 0) > 0 && ( +
+
{label ('nameChanges.title')}
+ {change.nameChanges?.map (entry => ( +
+ {entry.languageCode} + {entry.prev != null && ( +
+ {renderNameSnapshot (entry.prev, entry.languageCode, label)} +
)} + {entry.current != null && ( +
+ {renderNameSnapshot (entry.current, entry.languageCode, label)} +
)} +
))} +
)} + {renderDiff (change.category, category => label (`common:categories.${category}`))} @@ -193,9 +241,13 @@ const TagHistoryPage: FC = () => { if (!(await dialogue.confirm ({ title: label ('confirmTitle'), - description: label ('confirmTag', { - name: change.name.current ?? '', - version: change.versionNo }), + description: ( + <> + {label ('confirmTag', { + name: change.name.current ?? '', version: change.versionNo })} +
+ {label ('restoreScope')} + ), confirmText: label ('revert'), cancelText: label ('settings:actions.cancel'), closeLabel: label ('settings:actions.close') }))) @@ -225,6 +277,7 @@ const TagHistoryPage: FC = () => { qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) + qc.invalidateQueries ({ queryKey: wikiKeys.root }) toast ({ description: label ('restored') }) } catch diff --git a/frontend/src/pages/wiki/WikiDiffPage.tsx b/frontend/src/pages/wiki/WikiDiffPage.tsx index 9c1205b..29cb993 100644 --- a/frontend/src/pages/wiki/WikiDiffPage.tsx +++ b/frontend/src/pages/wiki/WikiDiffPage.tsx @@ -21,21 +21,42 @@ const WikiDiffPage: FC = () => { const location = useLocation () - const [diff, setDiff] = useState (null) + const [result, setResult] = useState<{ + key: string + diff: WikiPageDiff } | null> (null) const query = new URLSearchParams (location.search) const from = query.get ('from') const to = query.get ('to') + const requestKey = JSON.stringify ([locale, id, from, to]) + const diff = result?.key === requestKey ? result.diff : null const displayTitle = diff ? `${ diff.title }${ diff.deprecatedAt != null ? message ('shared:labels.deprecatedSuffix').text : '' }` : '' useEffect (() => { + const controller = new AbortController () + let active = true void (async () => { - setDiff (await apiGet (`/wiki/${ id }/diff`, { locale, params: { from, to } })) + try + { + const diff = await apiGet (`/wiki/${ encodeURIComponent (id ?? '') }/diff`, { + locale, signal: controller.signal, params: { from, to } }) + if (active) + setResult ({ key: requestKey, diff }) + } + catch (error) + { + if (active) + console.error (error) + } }) () - }, [from, id, to, locale]) + return () => { + active = false + controller.abort () + } + }, [from, id, to, locale, requestKey]) return ( diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index 1238a75..5368ef4 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -49,11 +49,16 @@ const WikiEditPage: FC = ({ user }) => { const [body, setBody] = useState ('') const [loading, setLoading] = useState (true) + const [loadedSource, setLoadedSource] = useState ('') + const source = `${locale}:${id}` + const pending = loading || loadedSource !== source const [title, setTitle] = useState ('') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () const handleSubmit = async () => { + if (pending || !editable) + return clearValidationErrors () const formData = new FormData () @@ -65,10 +70,11 @@ const WikiEditPage: FC = ({ user }) => { await apiPut (`/wiki/${ id }`, formData, { locale, headers: { 'Content-Type': 'multipart/form-data' } }) qc.setQueryData (wikiKeys.show (title, { }, locale), - (prev: WikiPage) => ({ ...prev, title, body })) + (prev: WikiPage | undefined) => + prev == null ? prev : { ...prev, title, body }) qc.invalidateQueries ({ queryKey: wikiKeys.root }) toast ({ title: label ('shared:messages.postSuccess') }) - navigate (`/wiki/${ title }`) + navigate (`/wiki/${ encodeURIComponent (title) }`) } catch (e) { @@ -82,14 +88,33 @@ const WikiEditPage: FC = ({ user }) => { if (!(editable)) return + const controller = new AbortController () + let active = true void (async () => { setLoading (true) - const data = await apiGet (`/wiki/${ id }`, { locale }) - setTitle (data.title) - setBody (data.body) - setLoading (false) + try + { + const data = await apiGet (`/wiki/${ encodeURIComponent (id ?? '') }`, { + locale, signal: controller.signal }) + if (active) + { + setTitle (data.title) + setBody (data.body) + setLoadedSource (source) + setLoading (false) + } + } + catch (error) + { + if (active) + console.error (error) + } }) () - }, [editable, id, locale]) + return () => { + active = false + controller.abort () + } + }, [editable, id, locale, source]) if (!(editable)) return @@ -100,7 +125,7 @@ const WikiEditPage: FC = ({ user }) => {

{label ('editPage')}

- {loading ? label ('common:loading') : ( + {pending ? label ('common:loading') : ( <> diff --git a/frontend/src/pages/wiki/WikiHistoryPage.tsx b/frontend/src/pages/wiki/WikiHistoryPage.tsx index ad7e5a4..a0ff567 100644 --- a/frontend/src/pages/wiki/WikiHistoryPage.tsx +++ b/frontend/src/pages/wiki/WikiHistoryPage.tsx @@ -56,7 +56,7 @@ const WikiHistoryPage: FC = () => { + to={`/wiki/${ change.wikiPage.id }?version=${ change.revisionId }`}> {change.wikiPage.title} {change.wikiPage.deprecatedAt != null && label ('shared:labels.deprecatedSuffix')} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 619f568..794b565 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -367,10 +367,22 @@ export type Tag = { children?: Tag[] matchedAlias?: string | null } +export type TagNameSnapshot = { + name: string | null + scriptCode: string | null + autoGenerated: boolean | null + aliases: { name: string; scriptCode: string | null }[] } + +export type TagNameChange = { + languageCode: string + current: TagNameSnapshot | null + prev: TagNameSnapshot | null } + export type TagVersion = { tagId: number versionNo: number eventType: 'create' | 'update' | 'discard' | 'restore' + nameChanges?: TagNameChange[] name: { current: string | null; prev: string | null } category: { current: Category; prev: Category | null } deprecatedAt: { current: string | null; prev: string | null }