diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index bd64ea9..a09028a 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -48,9 +48,12 @@ class PostsController < ApplicationController viewed = current_user&.viewed?(post) || false - render json: (post - .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) - .merge(related: post.related(limit: 20), viewed:)) + json = post.as_json + json['tags'] = build_tag_tree_for(post.tags) + json['related'] = post.related(limit: 20) + json['viewed'] = viewed + + render json: end # POST /posts @@ -73,7 +76,9 @@ class PostsController < ApplicationController post.thumbnail.attach(thumbnail) if post.save post.resized_thumbnail! - sync_post_tags!(post, Tag.normalise_tags(tag_names)) + tags = Tag.normalise_tags(tag_names) + tags = Tag.expand_parent_tags(tags) + sync_post_tags!(post, tags) render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), status: :created else @@ -107,11 +112,13 @@ class PostsController < ApplicationController post = Post.find(params[:id].to_i) if post.update(title:, original_created_from:, original_created_before:) - sync_post_tags!(post, - (post.tags.where(category: 'nico').to_a + - Tag.normalise_tags(tag_names))) - render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), - status: :ok + tags = post.tags.where(category: 'nico').to_a + + Tag.normalise_tags(tag_names, with_tagme: false) + tags = Tag.expand_parent_tags(tags) + sync_post_tags!(post, tags) + json = post.as_json + json['tags'] = build_tag_tree_for(post.tags) + render json:, status: :ok else render json: post.errors, status: :unprocessable_entity end @@ -168,4 +175,46 @@ class PostsController < ApplicationController pt.discard_by!(current_user) end end + + def build_tag_tree_for tags + tags = tags.to_a + tag_ids = tags.map(&:id) + + implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) + + children_ids_by_parent = Hash.new { |h, k| h[k] = [] } + implications.each do |imp| + children_ids_by_parent[imp.parent_tag_id] << imp.tag_id + end + + child_ids = children_ids_by_parent.values.flatten.uniq + + root_ids = tag_ids - child_ids + + tags_by_id = tags.index_by(&:id) + + memo = { } + + build_node = -> tag_id, path do + tag = tags_by_id[tag_id] + return nil unless tag + + if path.include?(tag_id) + return tag.as_json(only: [:id, :name, :category, :post_count]).merge(children: []) + end + + if memo.key?(tag_id) + return memo[tag_id] + end + + new_path = path + [tag_id] + child_ids = children_ids_by_parent[tag_id] || [] + + children = child_ids.filter_map { |cid| build_node.(cid, new_path) } + + memo[tag_id] = tag.as_json(only: [:id, :name, :category, :post_count]).merge(children:) + end + + root_ids.filter_map { |id| build_node.call(id, []) } + end end diff --git a/backend/app/controllers/users_controller.rb b/backend/app/controllers/users_controller.rb index 8658a5f..4ee4836 100644 --- a/backend/app/controllers/users_controller.rb +++ b/backend/app/controllers/users_controller.rb @@ -6,12 +6,15 @@ class UsersController < ApplicationController end def verify + ip_bin = IPAddr.new(request.remote_ip).hton + ip_address = IpAddress.find_or_create_by!(ip_address: ip_bin) + user = User.find_by(inheritance_code: params[:code]) - render json: if user - { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } - else - { valid: false } - end + return render json: { valid: false } unless user + + UserIp.find_or_create_by!(user:, ip_address:) + + render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } end def renew diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index d5880e1..d496802 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -13,6 +13,14 @@ class Tag < ApplicationRecord dependent: :destroy has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag + has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy + has_many :children, through: :tag_implications, source: :tag + + has_many :reversed_tag_implications, class_name: 'TagImplication', + foreign_key: :tag_id, + dependent: :destroy + has_many :parents, through: :reversed_tag_implications, source: :parent_tag + enum :category, { deerjikist: 'deerjikist', meme: 'meme', character: 'character', @@ -59,10 +67,33 @@ class Tag < ApplicationRecord end end end - tags << Tag.tagme if with_tagme && tags.size < 20 && tags.none?(Tag.tagme) + tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) tags.uniq end + def self.expand_parent_tags tags + return [] if tags.blank? + + seen = Set.new + result = [] + stack = tags.compact.dup + + until stack.empty? + tag = stack.pop + next unless tag + + tag.parents.each do |parent| + next if seen.include?(parent.id) + + seen << parent.id + result << parent + stack << parent + end + end + + (result + tags).uniq { |t| t.id } + end + private def nico_tag_name_must_start_with_nico diff --git a/backend/app/models/tag_implication.rb b/backend/app/models/tag_implication.rb new file mode 100644 index 0000000..a629764 --- /dev/null +++ b/backend/app/models/tag_implication.rb @@ -0,0 +1,17 @@ +class TagImplication < ApplicationRecord + belongs_to :tag, class_name: 'Tag' + belongs_to :parent_tag, class_name: 'Tag' + + validates :tag_id, presence: true, uniqueness: { scope: :parent_tag_id } + validates :parent_tag_id, presence: true + + validate :parent_tag_mustnt_be_itself + + private + + def parent_tag_mustnt_be_itself + if parent_tag == tag + errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.' + end + end +end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 830d383..ede464a 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -8,7 +8,6 @@ class User < ApplicationRecord has_many :posts has_many :settings - has_many :ip_addresses has_many :user_ips, dependent: :destroy has_many :ip_addresses, through: :user_ips has_many :user_post_views, dependent: :destroy diff --git a/backend/db/migrate/20251009222200_create_tag_implications.rb b/backend/db/migrate/20251009222200_create_tag_implications.rb new file mode 100644 index 0000000..ea8df1a --- /dev/null +++ b/backend/db/migrate/20251009222200_create_tag_implications.rb @@ -0,0 +1,9 @@ +class CreateTagImplications < ActiveRecord::Migration[8.0] + def change + create_table :tag_implications do |t| + t.references :tag, null: false, foreign_key: { to_table: :tags } + t.references :parent_tag, null: false, foreign_key: { to_table: :tags } + t.timestamps + end + end +end diff --git a/backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb b/backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb new file mode 100644 index 0000000..60f78e6 --- /dev/null +++ b/backend/db/migrate/20251126231500_rename_ip_adress_column_to_ip_addresses.rb @@ -0,0 +1,5 @@ +class RenameIpAdressColumnToIpAddresses < ActiveRecord::Migration[8.0] + def change + rename_column :ip_addresses, :ip_adress, :ip_address + end +end diff --git a/backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb b/backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb new file mode 100644 index 0000000..681c1b5 --- /dev/null +++ b/backend/db/migrate/20251210123200_add_unique_index_to_tag_implications.rb @@ -0,0 +1,27 @@ +class AddUniqueIndexToTagImplications < ActiveRecord::Migration[8.0] + def up + execute <<~SQL + DELETE + ti1 + FROM + tag_implications ti1 + INNER JOIN + tag_implications ti2 + ON + ti1.tag_id = ti2.tag_id + AND ti1.parent_tag_id = ti2.parent_tag_id + AND ti1.id > ti2.id + ; + SQL + + add_index :tag_implications, [:tag_id, :parent_tag_id], + unique: true, + name: 'index_tag_implications_on_tag_id_and_parent_tag_id' + end + + def down + # NOTE: 重複削除は復元されなぃ. + remove_index :tag_implications, + name: 'index_tag_implications_on_tag_id_and_parent_tag_id' + end +end diff --git a/backend/lib/tasks/link_nico.rake b/backend/lib/tasks/link_nico.rake deleted file mode 100644 index 0c02f48..0000000 --- a/backend/lib/tasks/link_nico.rake +++ /dev/null @@ -1,12 +0,0 @@ -namespace :nico do - desc 'ニコタグ連携' - task link: :environment do - Post.find_each do |post| - tags = post.tags.where(category: 'nico') - tags.each do |tag| - post.tags.concat(tag.linked_tags) if tag.linked_tags.present? - end - post.tags = post.tags.to_a.uniq - end - end -end diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 60a4318..38b03b4 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -1,6 +1,6 @@ import axios from 'axios' import toCamel from 'camelcase-keys' -import { useState } from 'react' +import { useEffect, useState } from 'react' import PostFormTagsArea from '@/components/PostFormTagsArea' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' @@ -10,7 +10,23 @@ import { API_BASE_URL } from '@/config' import type { FC } from 'react' -import type { Post } from '@/types' +import type { Post, Tag } from '@/types' + + +const tagsToStr = (tags: Tag[]): string => { + const result: Tag[] = [] + + const walk = (tag: Tag) => { + const { children, ...rest } = tag + result.push (rest) + children?.forEach (walk) + } + + tags.filter (t => t.category !== 'nico').forEach (walk) + + return [...(new Set (result.map (t => t.name)))].join (' ') +} + type Props = { post: Post onSave: (newPost: Post) => void } @@ -22,10 +38,7 @@ export default (({ post, onSave }: Props) => { const [originalCreatedFrom, setOriginalCreatedFrom] = useState (post.originalCreatedFrom) const [title, setTitle] = useState (post.title) - const [tags, setTags] = useState (post.tags - .filter (t => t.category !== 'nico') - .map (t => t.name) - .join (' ')) + const [tags, setTags] = useState ('') const handleSubmit = async () => { const res = await axios.put ( @@ -43,6 +56,10 @@ export default (({ post, onSave }: Props) => { originalCreatedBefore: data.originalCreatedBefore } as Post) } + useEffect (() => { + setTags(tagsToStr (post.tags)) + }, [post]) + return (
{/* タイトル */} diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 6e2e2dd..da2c3b8 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -7,12 +7,32 @@ import SubsectionTitle from '@/components/common/SubsectionTitle' import SidebarComponent from '@/components/layout/SidebarComponent' import { CATEGORIES } from '@/consts' -import type { FC } from 'react' +import type { FC, ReactNode } from 'react' import type { Category, Post, Tag } from '@/types' type TagByCategory = { [key in Category]: Tag[] } + +const renderTagTree = ( + tag: Tag, + nestLevel: number, + path: string, + ): ReactNode[] => { + const key = `${ path }-${ tag.id }` + + const self = ( +
  • + +
  • ) + + return [self, + ...(tag.children + ?.sort ((a, b) => a.name < b.name ? -1 : 1) + .flatMap (child => renderTagTree (child, nestLevel + 1, key)) ?? [])] +} + + type Props = { post: Post | null } @@ -54,10 +74,7 @@ export default (({ post }: Props) => {
    {categoryNames[cat]}
      - {tags[cat].map (tag => ( -
    • - -
    • ))} + {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
    ))} {post && ( diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 0734d4d..a77ca5f 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -8,6 +8,7 @@ import type { ComponentProps, FC, HTMLAttributes } from 'react' import type { Tag } from '@/types' type CommonProps = { tag: Tag + nestLevel?: number withWiki?: boolean withCount?: boolean } @@ -21,6 +22,7 @@ type Props = PropsWithLink | PropsWithoutLink export default (({ tag, + nestLevel = 0, linkFlg = true, withWiki = true, withCount = true, @@ -42,6 +44,12 @@ export default (({ tag, ? )} + {nestLevel > 0 && ( + + ↳ + )} {linkFlg ? (