| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -0,0 +1,5 @@ | |||
| class RenameIpAdressColumnToIpAddresses < ActiveRecord::Migration[8.0] | |||
| def change | |||
| rename_column :ip_addresses, :ip_adress, :ip_address | |||
| end | |||
| end | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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<string | null> (post.originalCreatedFrom) | |||
| const [title, setTitle] = useState (post.title) | |||
| const [tags, setTags] = useState<string> (post.tags | |||
| .filter (t => t.category !== 'nico') | |||
| .map (t => t.name) | |||
| .join (' ')) | |||
| const [tags, setTags] = useState<string> ('') | |||
| 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 ( | |||
| <div className="max-w-xl pt-2 space-y-4"> | |||
| {/* タイトル */} | |||
| @@ -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 = ( | |||
| <li key={key} className="mb-1"> | |||
| <TagLink tag={tag} nestLevel={nestLevel}/> | |||
| </li>) | |||
| 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) => { | |||
| <div className="my-3" key={cat}> | |||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||
| <ul> | |||
| {tags[cat].map (tag => ( | |||
| <li key={tag.id} className="mb-1"> | |||
| <TagLink tag={tag}/> | |||
| </li>))} | |||
| {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))} | |||
| </ul> | |||
| </div>))} | |||
| {post && ( | |||
| @@ -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, | |||
| ? | |||
| </Link> | |||
| </span>)} | |||
| {nestLevel > 0 && ( | |||
| <span | |||
| className="ml-1 mr-1" | |||
| style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}> | |||
| ↳ | |||
| </span>)} | |||
| {linkFlg | |||
| ? ( | |||
| <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | |||
| @@ -1,7 +1,7 @@ | |||
| import React from 'react' | |||
| import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | |||
| import type { ReactNode } from 'react' | |||
| export type Category = typeof CATEGORIES[number] | |||
| export type Menu = MenuItem[] | |||
| @@ -29,19 +29,19 @@ export type Post = { | |||
| originalCreatedFrom: string | null | |||
| originalCreatedBefore: string | null } | |||
| export type SubMenuItem = { | |||
| component: React.ReactNode | |||
| visible: boolean | |||
| } | { | |||
| name: string | |||
| to: string | |||
| visible?: boolean } | |||
| export type SubMenuItem = | |||
| | { component: ReactNode | |||
| visible: boolean } | |||
| | { name: string | |||
| to: string | |||
| visible?: boolean } | |||
| export type Tag = { | |||
| id: number | |||
| name: string | |||
| category: Category | |||
| postCount: number } | |||
| postCount: number | |||
| children?: Tag[] } | |||
| export type User = { | |||
| id: number | |||