diff --git a/backend/Gemfile b/backend/Gemfile index bb5460b..303b937 100644 --- a/backend/Gemfile +++ b/backend/Gemfile @@ -63,3 +63,5 @@ gem 'diff-lcs' gem 'dotenv-rails' gem 'whenever', require: false + +gem 'discard' diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock index 8494a53..2c08f92 100644 --- a/backend/Gemfile.lock +++ b/backend/Gemfile.lock @@ -90,6 +90,8 @@ GEM crass (1.0.6) date (3.4.1) diff-lcs (1.6.2) + discard (1.4.0) + activerecord (>= 4.2, < 9.0) dotenv (3.1.8) dotenv-rails (3.1.8) dotenv (= 3.1.8) @@ -420,6 +422,7 @@ DEPENDENCIES bootsnap brakeman diff-lcs + discard dotenv-rails gollum image_processing (~> 1.14) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 73f3995..a3a4538 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -1,34 +1,50 @@ -require 'open-uri' -require 'nokogiri' - - class PostsController < ApplicationController + Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) + # GET /posts def index - limit = params[:limit].presence&.to_i + page = (params[:page].presence || 1).to_i + limit = (params[:limit].presence || 20).to_i cursor = params[:cursor].presence - q = filtered_posts.order(created_at: :desc) - q = q.where('posts.created_at < ?', Time.iso8601(cursor)) if cursor + page = 1 if page < 1 + limit = 1 if limit < 1 + + offset = (page - 1) * limit - posts = limit ? q.limit(limit + 1) : q + sort_sql = + 'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' + + 'posts.original_created_from,' + + 'posts.created_at)' + q = + filtered_posts + .preload(:tags) + .with_attached_thumbnail + .select("posts.*, #{ sort_sql } AS sort_ts") + .order(Arel.sql("#{ sort_sql } DESC")) + posts = ( + if cursor + q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1) + else + q.limit(limit).offset(offset) + end).to_a next_cursor = nil - if limit && posts.size > limit - next_cursor = posts.last.created_at.iso8601(6) + if cursor && posts.length > limit + next_cursor = posts.last.read_attribute('sort_ts').iso8601(6) posts = posts.first(limit) end render json: { posts: posts.map { |post| - post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap { |json| + post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap do |json| json['thumbnail'] = if post.thumbnail.attached? rails_storage_proxy_url(post.thumbnail, only_path: false) else nil end - } - }, next_cursor: } + end + }, count: filtered_posts.count(:id), next_cursor: } end def random @@ -39,7 +55,7 @@ class PostsController < ApplicationController render json: (post .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) - .merge(viewed: viewed)) + .merge(viewed:)) end # GET /posts/1 @@ -49,9 +65,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 @@ -60,18 +79,23 @@ class PostsController < ApplicationController return head :forbidden unless current_user.member? # TODO: URL が正規のものがチェック,不正ならエラー - # TODO: title、URL は必須にする. + # TODO: URL は必須にする(タイトルは省略可). # TODO: サイトに応じて thumbnail_base 設定 title = params[:title] url = params[:url] thumbnail = params[:thumbnail] tag_names = params[:tags].to_s.split(' ') + original_created_from = params[:original_created_from] + original_created_before = params[:original_created_before] - post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user) + post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, + original_created_from:, original_created_before:) post.thumbnail.attach(thumbnail) if post.save post.resized_thumbnail! - post.tags = Tag.normalise_tags(tags_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 @@ -100,12 +124,18 @@ class PostsController < ApplicationController title = params[:title] tag_names = params[:tags].to_s.split(' ') + original_created_from = params[:original_created_from] + original_created_before = params[:original_created_before] post = Post.find(params[:id].to_i) - tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names) - if post.update(title:, tags:) - render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), - status: :ok + if post.update(title:, original_created_from:, original_created_before:) + 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 @@ -115,12 +145,54 @@ class PostsController < ApplicationController def destroy end + def changes + id = params[:id] + page = (params[:page].presence || 1).to_i + limit = (params[:limit].presence || 20).to_i + + page = 1 if page < 1 + limit = 1 if limit < 1 + + offset = (page - 1) * limit + + pts = PostTag.with_discarded + pts = pts.where(post_id: id) if id.present? + pts = pts.includes(:post, :tag, :created_user, :deleted_user) + + events = [] + pts.each do |pt| + events << Event.new( + post: pt.post, + tag: pt.tag, + user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name }, + change_type: 'add', + timestamp: pt.created_at) + + if pt.discarded_at + events << Event.new( + post: pt.post, + tag: pt.tag, + user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name }, + change_type: 'remove', + timestamp: pt.discarded_at) + end + end + events.sort_by!(&:timestamp) + events.reverse! + + render json: { changes: events.slice(offset, limit).as_json, count: events.size } + end + private def filtered_posts tag_names = params[:tags]&.split(' ') match_type = params[:match] - tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all + if tag_names.present? + filter_posts_by_tags(tag_names, match_type) + else + Post.all + end end def filter_posts_by_tags tag_names, match_type @@ -134,4 +206,70 @@ class PostsController < ApplicationController end posts.distinct end + + def sync_post_tags! post, desired_tags + desired_tags.each do |t| + t.save! if t.new_record? + end + + desired_ids = desired_tags.map(&:id).to_set + current_ids = post.tags.pluck(:id).to_set + + to_add = desired_ids - current_ids + to_remove = current_ids - desired_ids + + Tag.where(id: to_add).find_each do |tag| + begin + PostTag.create!(post:, tag:, created_user: current_user) + rescue ActiveRecord::RecordNotUnique + ; + end + end + + PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| + 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/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index a778b48..c4dedf0 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -1,8 +1,8 @@ class WikiPagesController < ApplicationController - def index - wiki_pages = WikiPage.all + rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict - render json: wiki_pages + def index + render json: WikiPage.all end def show @@ -13,23 +13,43 @@ class WikiPagesController < ApplicationController render_wiki_page_or_404 WikiPage.find_by(title: params[:title]) end + def exists + if WikiPage.exists?(params[:id]) + head :no_content + else + head :not_found + end + end + + def exists_by_title + if WikiPage.exists?(title: params[:title]) + head :no_content + else + head :not_found + end + end + def diff id = params[:id] - from = params[:from] + return head :bad_request if id.blank? + + from = params[:from].presence to = params[:to].presence - return head :bad_request if id.blank? || from.blank? - wiki_page_from = WikiPage.find(id) - wiki_page_to = WikiPage.find(id) - wiki_page_from.sha = from - wiki_page_to.sha = to + page = WikiPage.find(id) + + from_rev = from && page.wiki_revisions.find(from) + to_rev = to ? page.wiki_revisions.find(to) : page.current_revision + if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?)) + return head :unprocessable_entity + end - diffs = Diff::LCS.sdiff(wiki_page_from.body, wiki_page_to.body) + diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) diff_json = diffs.map { |change| case change.action when ?= { type: 'context', content: change.old_element } - when ?| + when ?! [{ type: 'removed', content: change.old_element }, { type: 'added', content: change.new_element }] when ?+ @@ -39,23 +59,32 @@ class WikiPagesController < ApplicationController end }.flatten.compact - render json: { wiki_page_id: wiki_page_from.id, - title: wiki_page_from.title, - older_sha: wiki_page_from.sha, - newer_sha: wiki_page_to.sha, - diff: diff_json } + render json: { wiki_page_id: page.id, + title: page.title, + older_revision_id: from_rev&.id, + newer_revision_id: to_rev.id, + diff: diff_json } end def create return head :unauthorized unless current_user - return head :forbidden unless ['admin', 'member'].include?(current_user.role) + return head :forbidden unless current_user.member? + + title = params[:title]&.strip + body = params[:body].to_s + + return head :unprocessable_entity if title.blank? || body.blank? + + page = WikiPage.new(title:, created_user: current_user, updated_user: current_user) - wiki_page = WikiPage.new(title: params[:title], created_user: current_user, updated_user: current_user) - if wiki_page.save - wiki_page.set_body params[:body], user: current_user - render json: wiki_page, status: :created + if page.save + message = params[:message].presence + Wiki::Commit.content!(page:, body:, created_user: current_user, message:) + + render json: page, status: :created else - render json: { errors: wiki_page.errors.full_messages }, status: :unprocessable_entity + render json: { errors: page.errors.full_messages }, + status: :unprocessable_entity end end @@ -63,16 +92,24 @@ class WikiPagesController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.member? - title = params[:title] - body = params[:body] + title = params[:title]&.strip + body = params[:body].to_s return head :unprocessable_entity if title.blank? || body.blank? - wiki_page = WikiPage.find(params[:id]) - wiki_page.title = title - wiki_page.updated_user = current_user - wiki_page.set_body(body, user: current_user) - wiki_page.save! + page = WikiPage.find(params[:id]) + base_revision_id = page.current_revision.id + + if params[:title].present? && params[:title].strip != page.title + return head :unprocessable_entity + end + + message = params[:message].presence + Wiki::Commit.content!(page:, + body:, + created_user: current_user, + message:, + base_revision_id:) head :ok end @@ -81,55 +118,73 @@ class WikiPagesController < ApplicationController title = params[:title]&.strip q = WikiPage.all - q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") if title.present? + if title.present? + q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") + end render json: q.limit(20) end def changes - id = params[:id] - log = if id.present? - wiki.page("#{ id }.md")&.versions - else - wiki.repo.log('main', nil) - end - return render json: [] unless log - - render json: log.map { |commit| - wiki_page = WikiPage.find(commit.message.split(' ')[1].to_i) - wiki_page.sha = commit.id - - next nil if wiki_page.sha.blank? - - user = User.find(commit.author.name.to_i) - - { sha: wiki_page.sha, - pred: wiki_page.pred, - succ: wiki_page.succ, - wiki_page: wiki_page && { id: wiki_page.id, title: wiki_page.title }, - user: user && { id: user.id, name: user.name }, - change_type: commit.message.split(' ')[0].downcase[0...(-1)], - timestamp: commit.authored_date } + id = params[:id].presence + q = WikiRevision.includes(:wiki_page, :created_user).order(id: :desc) + q = q.where(wiki_page_id: id) if id + + render json: q.limit(200).map { |rev| + { revision_id: rev.id, + pred: rev.base_revision_id, + succ: nil, + wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title }, + user: { id: rev.created_user.id, name: rev.created_user.name }, + kind: rev.kind, + message: rev.message, + timestamp: rev.created_at } }.compact end private - WIKI_PATH = Rails.root.join('wiki').to_s + def render_wiki_page_or_404 page + return head :not_found unless page - def wiki - @wiki ||= Gollum::Wiki.new(WIKI_PATH) - end + if params[:version].present? + rev = page.wiki_revisions.find_by(id: params[:version]) + return head :not_found unless rev + + if rev.redirect? + return ( + redirect_to wiki_page_by_title_path(title: rev.redirect_page.title), + status: :moved_permanently) + end + + body = rev.body + revision_id = rev.id + pred = page.pred_revision_id(revision_id) + succ = page.succ_revision_id(revision_id) - def render_wiki_page_or_404 wiki_page - return head :not_found unless wiki_page + return render json: page.as_json.merge(body:, revision_id:, pred:, succ:) + end + + rev = page.current_revision + unless rev + return render json: page.as_json.merge(body: nil, revision_id: nil, pred: nil, succ: nil) + end - wiki_page.sha = params[:version].presence + if rev.redirect? + return ( + redirect_to wiki_page_by_title_path(title: rev.redirect_page.title), + status: :moved_permanently) + end + + body = rev.body + revision_id = rev.id + pred = page.pred_revision_id(revision_id) + succ = page.succ_revision_id(revision_id) + + render json: page.as_json.merge(body:, revision_id:, pred:, succ:) + end - body = wiki_page.body - sha = wiki_page.sha - pred = wiki_page.pred - succ = wiki_page.succ - render json: wiki_page.as_json.merge(body:, sha:, pred:, succ:) + def render_wiki_conflict err + render json: { error: 'conflict', message: err.message }, status: :conflict end end diff --git a/backend/app/models/nico_tag_relation.rb b/backend/app/models/nico_tag_relation.rb index ff4f3a6..d2c4a82 100644 --- a/backend/app/models/nico_tag_relation.rb +++ b/backend/app/models/nico_tag_relation.rb @@ -6,6 +6,7 @@ class NicoTagRelation < ApplicationRecord validates :tag_id, presence: true validate :nico_tag_must_be_nico + validate :tag_mustnt_be_nico private diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index bdc136a..6dd565b 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -1,11 +1,13 @@ -require 'mini_magick' - - class Post < ApplicationRecord + require 'mini_magick' + belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id' belongs_to :uploaded_user, class_name: 'User', optional: true - has_many :post_tags, dependent: :destroy - has_many :tags, through: :post_tags + + has_many :post_tags, dependent: :destroy, inverse_of: :post + has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post + has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' + has_many :tags, through: :active_post_tags has_many :user_post_views, dependent: :destroy has_many :post_similarities_as_post, class_name: 'PostSimilarity', @@ -15,6 +17,8 @@ class Post < ApplicationRecord foreign_key: :target_post_id has_one_attached :thumbnail + validate :validate_original_created_range + def as_json options = { } super(options).merge({ thumbnail: thumbnail.attached? ? Rails.application.routes.url_helpers.rails_blob_url( @@ -49,4 +53,20 @@ class Post < ApplicationRecord filename: 'resized_thumbnail.jpg', content_type: 'image/jpeg') end + + private + + def validate_original_created_range + f = original_created_from + b = original_created_before + return if f.blank? || b.blank? + + f = Time.zone.parse(f) if String === f + b = Time.zone.parse(b) if String === b + return if !(f) || !(b) + + if f >= b + errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.' + end + end end diff --git a/backend/app/models/post_tag.rb b/backend/app/models/post_tag.rb index 9dbd756..91a739d 100644 --- a/backend/app/models/post_tag.rb +++ b/backend/app/models/post_tag.rb @@ -1,7 +1,25 @@ class PostTag < ApplicationRecord + include Discard::Model + belongs_to :post belongs_to :tag, counter_cache: :post_count + belongs_to :created_user, class_name: 'User', optional: true + belongs_to :deleted_user, class_name: 'User', optional: true validates :post_id, presence: true validates :tag_id, presence: true + validates :post_id, uniqueness: { + scope: :tag_id, + conditions: -> { where(discarded_at: nil) } } + + def discard_by! deleted_user + return self if discarded? + + transaction do + update!(discarded_at: Time.current, deleted_user:) + Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)') + end + + self + end end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 137afff..d496802 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -1,6 +1,8 @@ class Tag < ApplicationRecord - has_many :post_tags, dependent: :destroy - has_many :posts, through: :post_tags + has_many :post_tags, dependent: :delete_all, inverse_of: :tag + has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag + has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' + has_many :posts, through: :active_post_tags has_many :tag_aliases, dependent: :destroy has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy @@ -11,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', @@ -35,13 +45,13 @@ class Tag < ApplicationRecord 'meta:' => 'meta' }.freeze def self.tagme - @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| + @tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag| tag.category = 'meta' end end def self.bot - @bot ||= Tag.find_or_initialize_by(name: 'bot操作') do |tag| + @bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag| tag.category = 'meta' end end @@ -57,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/tag_similarity.rb b/backend/app/models/tag_similarity.rb new file mode 100644 index 0000000..d983409 --- /dev/null +++ b/backend/app/models/tag_similarity.rb @@ -0,0 +1,4 @@ +class TagSimilarity < ApplicationRecord + belongs_to :tag, class_name: 'Tag', foreign_key: 'tag_id' + belongs_to :target_tag, class_name: 'Tag', foreign_key: 'target_tag_id' +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/app/models/wiki_line.rb b/backend/app/models/wiki_line.rb new file mode 100644 index 0000000..c169917 --- /dev/null +++ b/backend/app/models/wiki_line.rb @@ -0,0 +1,15 @@ +class WikiLine < ApplicationRecord + has_many :wiki_revision_lines, dependent: :restrict_with_exception + + validates :sha256, presence: true, uniqueness: true, length: { is: 64 } + validates :body, presence: true + + def self.upsert_by_body! body + sha = Digest::SHA256.hexdigest(body) + now = Time.current + + upsert({ sha256: sha, body:, created_at: now, updated_at: now }) + + find_by!(sha256: sha) + end +end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 2e57372..256d4df 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -1,80 +1,50 @@ -require 'gollum-lib' +require 'set' class WikiPage < ApplicationRecord - belongs_to :tag, optional: true - belongs_to :created_user, class_name: 'User', foreign_key: 'created_user_id' - belongs_to :updated_user, class_name: 'User', foreign_key: 'updated_user_id' + has_many :wiki_revisions, dependent: :destroy + belongs_to :created_user, class_name: 'User' + belongs_to :updated_user, class_name: 'User' + + has_many :redirected_from_revisions, + class_name: 'WikiRevision', + foreign_key: :redirect_page_id, + dependent: :nullify validates :title, presence: true, length: { maximum: 255 }, uniqueness: true - def as_json options = { } - self.sha = nil - super options + def current_revision + wiki_revisions.order(id: :desc).first end - def sha= val - if val.present? - @sha = val - @page = wiki.page("#{ id }.md", @sha) - else - @page = wiki.page("#{ id }.md") - @sha = @page.versions.first.id - end - vers = @page.versions - idx = vers.find_index { |ver| ver.id == @sha } - if idx - @pred = vers[idx + 1]&.id - @succ = idx.positive? ? vers[idx - 1].id : nil - @updated_at = vers[idx].authored_date - else - @sha = nil - @pred = nil - @succ = nil - @updated_at = nil - end - @sha - end - - def sha - @sha + def body + rev = current_revision + rev.body if rev&.content? end - def pred - @pred - end + def resolve_redirect limit: 10 + page = self + visited = Set.new - def succ - @succ - end + limit.times do + return page if visited.include?(page.id) - def updated_at - @updated_at - end + visited.add(page.id) - def body - sha = nil unless @page - @page&.raw_data&.force_encoding('UTF-8') - end + rev = page.current_revision + return page if !(rev&.redirect?) || !(rev.redirect_page) - def set_body content, user: - commit_info = { name: user.id.to_s, - email: 'dummy@example.com' } - page = wiki.page("#{ id }.md") - if page - commit_info[:message] = "Updated #{ id }" - wiki.update_page(page, id.to_s, :markdown, content, commit_info) - else - commit_info[:message] = "Created #{ id }" - wiki.write_page(id.to_s, :markdown, content, commit_info) + page = rev.redirect_page end - end - private + page + end - WIKI_PATH = Rails.root.join('wiki').to_s + def pred_revision_id revision_id + wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) + end - def wiki - @wiki ||= Gollum::Wiki.new(WIKI_PATH) + def succ_revision_id revision_id + wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) end end diff --git a/backend/app/models/wiki_revision.rb b/backend/app/models/wiki_revision.rb new file mode 100644 index 0000000..da6ca7d --- /dev/null +++ b/backend/app/models/wiki_revision.rb @@ -0,0 +1,55 @@ +class WikiRevision < ApplicationRecord + belongs_to :wiki_page + belongs_to :base_revision, class_name: 'WikiRevision', optional: true + belongs_to :created_user, class_name: 'User' + belongs_to :redirect_page, class_name: 'WikiPage', optional: true + + has_many :wiki_revision_lines, dependent: :delete_all + has_many :wiki_lines, through: :wiki_revision_lines + + enum :kind, { content: 0, redirect: 1 } + + validates :kind, presence: true + validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :tree_sha256, length: { is: 64 }, allow_nil: true + + validate :kind_consistency + + def body + return unless content? + + wiki_revision_lines + .includes(:wiki_line) + .order(:position) + .map { |rev| rev.wiki_line.body } + .join("\n") + end + + private + + def kind_consistency + if content? + if tree_sha256.blank? + errors.add(:tree_sha256, '種類がページの場合は必須です.') + end + + if redirect_page_id.present? + errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.') + end + end + + if redirect? + if redirect_page_id.blank? + errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.') + end + + if tree_sha256.present? + errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.') + end + + if lines_count.to_i > 0 + errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.') + end + end + end +end diff --git a/backend/app/models/wiki_revision_line.rb b/backend/app/models/wiki_revision_line.rb new file mode 100644 index 0000000..8d58642 --- /dev/null +++ b/backend/app/models/wiki_revision_line.rb @@ -0,0 +1,8 @@ +class WikiRevisionLine < ApplicationRecord + belongs_to :wiki_revision + belongs_to :wiki_line + + validates :position, presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :position, uniqueness: { scope: :wiki_revision_id } +end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb new file mode 100644 index 0000000..c0be98a --- /dev/null +++ b/backend/app/services/wiki/commit.rb @@ -0,0 +1,122 @@ +require 'digest' + + +module Wiki + class Commit + class Conflict < StandardError + ; + end + + def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil + new(page:, created_user:).content!(body:, message:, base_revision_id:) + end + + def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil + new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:) + end + + def initialize page:, created_user: + @page = page + @created_user = created_user + end + + def content! body:, message:, base_revision_id: + normalised = normalise_body(body) + lines = split_lines(normalised) + + line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } + tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) + + line_id_by_sha = upsert_lines!(lines, line_shas) + line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) } + + ActiveRecord::Base.transaction do + @page.lock! + + if base_revision_id.present? + current_id = @page.wiki_revisions.maximum(:id) + if current_id && current_id != base_revision_id.to_i + raise Conflict, + "競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." + end + end + + rev = WikiRevision.create!( + wiki_page: @page, + base_revision_id:, + created_user: @created_user, + kind: :content, + redirect_page_id: nil, + message:, + lines_count: lines.length, + tree_sha256: tree_sha) + + rows = line_ids.each_with_index.map do |line_id, pos| + { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } + end + WikiRevisionLine.insert_all!(rows) + + rev + end + end + + def redirect! redirect_page:, message:, base_revision_id: + ActiveRecord::Base.transaction do + @page.lock! + + if base_revision_id.present? + current_id = @page.wiki_revisions.maximum(:id) + if current_id && current_id != base_revision_id.to_i + raise Conflict, + "競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." + end + end + + WikiRevision.create!( + wiki_page: @page, + base_revision_id:, + created_user: @created_user, + kind: :redirect, + redirect_page:, + message:, + lines_count: 0, + tree_sha256: nil) + end + end + + private + + def normalise_body body + s = body.to_s + s.gsub!("\r\n", "\n") + s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') + end + + def split_lines body + body.split("\n") + end + + def upsert_lines! lines, line_shas + now = Time.current + + id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h + + missing_rows = [] + line_shas.each_with_index do |sha, i| + next if id_by_sha.key?(sha) + + missing_rows << { sha256: sha, + body: lines[i], + created_at: now, + updated_at: now } + end + + if missing_rows.any? + WikiLine.upsert_all(missing_rows) + id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h + end + + id_by_sha + end + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 709f1d0..206d1a6 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,45 +1,60 @@ Rails.application.routes.draw do - get 'tags/nico', to: 'nico_tags#index' - put 'tags/nico/:id', to: 'nico_tags#update' - get 'tags/autocomplete', to: 'tags#autocomplete' - get 'tags/name/:name', to: 'tags#show_by_name' - get 'posts/random', to: 'posts#random' - post 'posts/:id/viewed', to: 'posts#viewed' - delete 'posts/:id/viewed', to: 'posts#unviewed' - get 'preview/title', to: 'preview#title' - get 'preview/thumbnail', to: 'preview#thumbnail' - get 'wiki/title/:title', to: 'wiki_pages#show_by_title' - get 'wiki/search', to: 'wiki_pages#search' - get 'wiki/changes', to: 'wiki_pages#changes' - get 'wiki/:id/diff', to: 'wiki_pages#diff' - get 'wiki/:id', to: 'wiki_pages#show' - get 'wiki', to: 'wiki_pages#index' - post 'wiki', to: 'wiki_pages#create' - put 'wiki/:id', to: 'wiki_pages#update' - post 'users/code/renew', to: 'users#renew' - - resources :posts - resources :ip_addresses - resources :nico_tag_relations - resources :post_tags - resources :settings - resources :tag_aliases - resources :tags - resources :user_ips - resources :user_post_views + resources :nico_tags, path: 'tags/nico', only: [:index, :update] + + resources :tags do + collection do + get :autocomplete + get 'name/:name', action: :show_by_name + end + end + + scope :preview, controller: :preview do + get :title + get :thumbnail + end + + resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do + collection do + get :search + get :changes + + scope :title do + get ':title/exists', action: :exists_by_title + get ':title', action: :show_by_title + end + end + + member do + get :exists + get :diff + end + end + + resources :posts do + collection do + get :random + get :changes + end + + member do + post :viewed + delete :viewed, action: :unviewed + end + end + resources :users, only: [:create, :update] do collection do post :verify get :me + post 'code/renew', action: :renew end end - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - # get "up" => "rails/health#show", as: :rails_health_check - - # Defines the root path route ("/") - # root "posts#index" + resources :ip_addresses + resources :nico_tag_relations + resources :post_tags + resources :settings + resources :tag_aliases + resources :user_ips + resources :user_post_views end diff --git a/backend/config/schedule.rb b/backend/config/schedule.rb index 7ba2687..b4db72a 100644 --- a/backend/config/schedule.rb +++ b/backend/config/schedule.rb @@ -6,3 +6,7 @@ set :output, standard: '/var/log/btrc_hub_nico_sync.log', every 1.day, at: '3:00 pm' do rake 'nico:sync', environment: 'production' end + +every 1.day, at: '0:00 am' do + rake 'post_similarity:calc', environment: 'production' +end diff --git a/backend/db/migrate/20250909075500_add_original_created_at_to_posts.rb b/backend/db/migrate/20250909075500_add_original_created_at_to_posts.rb new file mode 100644 index 0000000..161892c --- /dev/null +++ b/backend/db/migrate/20250909075500_add_original_created_at_to_posts.rb @@ -0,0 +1,6 @@ +class AddOriginalCreatedAtToPosts < ActiveRecord::Migration[8.0] + def change + add_column :posts, :original_created_from, :datetime, after: :created_at + add_column :posts, :original_created_before, :datetime, after: :original_created_from + end +end 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/20251011200300_add_discard_to_post_tags.rb b/backend/db/migrate/20251011200300_add_discard_to_post_tags.rb new file mode 100644 index 0000000..f825a37 --- /dev/null +++ b/backend/db/migrate/20251011200300_add_discard_to_post_tags.rb @@ -0,0 +1,36 @@ +class AddDiscardToPostTags < ActiveRecord::Migration[8.0] + def up + execute <<~SQL + DELETE + pt1 + FROM + post_tags pt1 + INNER JOIN + post_tags pt2 + ON + pt1.post_id = pt2.post_id + AND pt1.tag_id = pt2.tag_id + AND pt1.id > pt2.id + ; + SQL + + add_column :post_tags, :discarded_at, :datetime + add_index :post_tags, :discarded_at + + add_column :post_tags, :is_active, :boolean, + as: 'discarded_at IS NULL', stored: true + + add_column :post_tags, :active_unique_key, :string, + as: "CASE WHEN discarded_at IS NULL THEN CONCAT(post_id, ':', tag_id) ELSE NULL END", + stored: true + + add_index :post_tags, :active_unique_key, unique: true, name: 'idx_post_tags_active_unique' + + add_index :post_tags, [:post_id, :discarded_at] + add_index :post_tags, [:tag_id, :discarded_at] + end + + def down + raise ActiveRecord::IrreversibleMigration, '戻せません.' + 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/db/migrate/20251229012100_remove_tag_from_wiki_pages.rb b/backend/db/migrate/20251229012100_remove_tag_from_wiki_pages.rb new file mode 100644 index 0000000..f4ef842 --- /dev/null +++ b/backend/db/migrate/20251229012100_remove_tag_from_wiki_pages.rb @@ -0,0 +1,5 @@ +class RemoveTagFromWikiPages < ActiveRecord::Migration[7.0] + def change + remove_reference :wiki_pages, :tag, if_exists: true + end +end diff --git a/backend/db/migrate/20251229020700_create_wiki_lines.rb b/backend/db/migrate/20251229020700_create_wiki_lines.rb new file mode 100644 index 0000000..cba6fb4 --- /dev/null +++ b/backend/db/migrate/20251229020700_create_wiki_lines.rb @@ -0,0 +1,11 @@ +class CreateWikiLines < ActiveRecord::Migration[7.0] + def change + create_table :wiki_lines do |t| + t.string :sha256, null: false, limit: 64 + t.text :body, null: false + t.timestamps + end + + add_index :wiki_lines, :sha256, unique: true + end +end diff --git a/backend/db/migrate/20251229021000_create_wiki_revisions.rb b/backend/db/migrate/20251229021000_create_wiki_revisions.rb new file mode 100644 index 0000000..5b80621 --- /dev/null +++ b/backend/db/migrate/20251229021000_create_wiki_revisions.rb @@ -0,0 +1,19 @@ +class CreateWikiRevisions < ActiveRecord::Migration[7.0] + def change + create_table :wiki_revisions do |t| + t.references :wiki_page, null: false, foreign_key: true + t.references :base_revision, foreign_key: { to_table: :wiki_revisions } + t.references :created_user, null: false, foreign_key: { to_table: :users } + t.integer :kind, null: false, default: 0 # 0: content, 1: redirect + t.references :redirect_page, foreign_key: { to_table: :wiki_pages } + t.string :message + t.integer :lines_count, null: false, default: 0 + t.string :tree_sha256, limit: 64 + t.timestamps + end + + add_index :wiki_revisions, :tree_sha256 + add_index :wiki_revisions, [:wiki_page_id, :id] + add_index :wiki_revisions, :kind + end +end diff --git a/backend/db/migrate/20251229022100_create_wiki_revision_lines.rb b/backend/db/migrate/20251229022100_create_wiki_revision_lines.rb new file mode 100644 index 0000000..e62a7c4 --- /dev/null +++ b/backend/db/migrate/20251229022100_create_wiki_revision_lines.rb @@ -0,0 +1,12 @@ +class CreateWikiRevisionLines < ActiveRecord::Migration[7.0] + def change + create_table :wiki_revision_lines do |t| + t.references :wiki_revision, null: false, foreign_key: true + t.integer :position, null: false + t.references :wiki_line, null: false, foreign_key: true + end + + add_index :wiki_revision_lines, [:wiki_revision_id, :position], unique: true + add_index :wiki_revision_lines, [:wiki_revision_id, :wiki_line_id] + end +end diff --git a/backend/db/migrate/20251230143400_make_thumbnail_base_nullable_in_posts.rb b/backend/db/migrate/20251230143400_make_thumbnail_base_nullable_in_posts.rb new file mode 100644 index 0000000..f9da7c0 --- /dev/null +++ b/backend/db/migrate/20251230143400_make_thumbnail_base_nullable_in_posts.rb @@ -0,0 +1,27 @@ +class MakeThumbnailBaseNullableInPosts < ActiveRecord::Migration[7.0] + def up + change_column_null :posts, :thumbnail_base, true + + execute <<~SQL + UPDATE + posts + SET + thumbnail_base = NULL + WHERE + thumbnail_base = '' + SQL + end + + def down + execute <<~SQL + UPDATE + posts + SET + thumbnail_base = '' + WHERE + thumbnail_base IS NULL + SQL + + change_column_null :posts, :thumbnail_base, false + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index f339414..697bd8f 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do +ActiveRecord::Schema[8.0].define(version: 2025_12_30_143400) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -40,7 +40,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do end create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.binary "ip_adress", limit: 16, null: false + t.binary "ip_address", limit: 16, null: false t.boolean "banned", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -70,19 +70,28 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do t.bigint "deleted_user_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "discarded_at" + t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true + t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true + t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true t.index ["created_user_id"], name: "index_post_tags_on_created_user_id" t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id" + t.index ["discarded_at"], name: "index_post_tags_on_discarded_at" + t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at" t.index ["post_id"], name: "index_post_tags_on_post_id" + t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at" t.index ["tag_id"], name: "index_post_tags_on_tag_id" end create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "title", null: false t.string "url", limit: 2000, null: false - t.string "thumbnail_base", limit: 2000, null: false + t.string "thumbnail_base", limit: 2000 t.bigint "parent_id" t.bigint "uploaded_user_id" t.datetime "created_at", null: false + t.datetime "original_created_from" + t.datetime "original_created_before" t.datetime "updated_at", null: false t.index ["parent_id"], name: "index_posts_on_parent_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" @@ -105,6 +114,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do t.index ["tag_id"], name: "index_tag_aliases_on_tag_id" end + create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.bigint "parent_tag_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_tag_id"], name: "index_tag_implications_on_parent_tag_id" + t.index ["tag_id", "parent_tag_id"], name: "index_tag_implications_on_tag_id_and_parent_tag_id", unique: true + t.index ["tag_id"], name: "index_tag_implications_on_tag_id" + end + create_table "tag_similarities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_id", null: false t.bigint "target_tag_id", null: false @@ -148,18 +167,54 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do t.datetime "updated_at", null: false end + create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "sha256", limit: 64, null: false + t.text "body", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["sha256"], name: "index_wiki_lines_on_sha256", unique: true + end + create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "title", null: false - t.bigint "tag_id" t.bigint "created_user_id", null: false t.bigint "updated_user_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" - t.index ["tag_id"], name: "index_wiki_pages_on_tag_id" t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" end + create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "wiki_revision_id", null: false + t.integer "position", null: false + t.bigint "wiki_line_id", null: false + t.index ["wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_line_id" + t.index ["wiki_revision_id", "position"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_position", unique: true + t.index ["wiki_revision_id", "wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_wiki_line_id" + t.index ["wiki_revision_id"], name: "index_wiki_revision_lines_on_wiki_revision_id" + end + + create_table "wiki_revisions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "wiki_page_id", null: false + t.bigint "base_revision_id" + t.bigint "created_user_id", null: false + t.integer "kind", default: 0, null: false + t.bigint "redirect_page_id" + t.string "message" + t.integer "lines_count", default: 0, null: false + t.string "tree_sha256", limit: 64 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["base_revision_id"], name: "index_wiki_revisions_on_base_revision_id" + t.index ["created_user_id"], name: "index_wiki_revisions_on_created_user_id" + t.index ["kind"], name: "index_wiki_revisions_on_kind" + t.index ["redirect_page_id"], name: "index_wiki_revisions_on_redirect_page_id" + t.index ["tree_sha256"], name: "index_wiki_revisions_on_tree_sha256" + t.index ["wiki_page_id", "id"], name: "index_wiki_revisions_on_wiki_page_id_and_id" + t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "nico_tag_relations", "tags" @@ -174,13 +229,20 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "settings", "users" add_foreign_key "tag_aliases", "tags" + add_foreign_key "tag_implications", "tags" + add_foreign_key "tag_implications", "tags", column: "parent_tag_id" add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "user_ips", "ip_addresses" add_foreign_key "user_ips", "users" add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" - add_foreign_key "wiki_pages", "tags" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" + add_foreign_key "wiki_revision_lines", "wiki_lines" + add_foreign_key "wiki_revision_lines", "wiki_revisions" + add_foreign_key "wiki_revisions", "users", column: "created_user_id" + add_foreign_key "wiki_revisions", "wiki_pages" + add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" + add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" end diff --git a/backend/lib/tasks/calc_tag_similarities.rake b/backend/lib/tasks/calc_tag_similarities.rake new file mode 100644 index 0000000..9e16977 --- /dev/null +++ b/backend/lib/tasks/calc_tag_similarities.rake @@ -0,0 +1,28 @@ +namespace :tag_similarity do + desc '関聯タグ・テーブル作成' + task calc: :environment do + dot = -> a, b { (a.keys & b.keys).sum { |k| a[k] * b[k] } } + norm = -> v { Math.sqrt(v.values.sum { |e| e * e }) } + cos = -> a, b do + na = norm.(a) + nb = norm.(b) + if na.zero? || nb.zero? + 0.0 + else + dot.(a, b) / na / nb + end + end + + tags = Tag.includes(:posts).to_a + tags.each_with_index do |tag, i| + existence_of_posts = tag.posts.index_with(1) + ((i + 1)...tags.size).each do |j| + target_tag = tags[j] + existence_of_target_posts = target_tag.posts.index_with(1) + TagSimilarity.find_or_initialize_by(tag:, target_tag:).tap { |ts| + ts.cos = cos.(existence_of_posts, existence_of_target_posts) + }.save! + end + end + 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/backend/lib/tasks/migrate_wiki.rake b/backend/lib/tasks/migrate_wiki.rake new file mode 100644 index 0000000..03a68f3 --- /dev/null +++ b/backend/lib/tasks/migrate_wiki.rake @@ -0,0 +1,74 @@ +namespace :wiki do + desc 'Wiki 移行' + task migrate: :environment do + require 'digest' + require 'gollum-lib' + + wiki = Gollum::Wiki.new(Rails.root.join('wiki').to_s) + + WikiPage.where.missing(:wiki_revisions).find_each do |wiki_page| + page = wiki.page("#{ wiki_page.id }.md") + next unless page + + versions = page.versions + next if versions.blank? + + base_revision_id = nil + versions.reverse_each do |version| + pg = wiki.page("#{ wiki_page.id }.md", version.id) + raw = pg&.raw_data + next unless raw + + lines = raw.force_encoding('UTF-8').split("\n") + + line_shas = lines.map { |l| Digest::SHA256.hexdigest(l) } + tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) + + at = version.authored_date + + line_id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h + + missing_rows = [] + line_shas.each_with_index do |sha, i| + next if line_id_by_sha.key?(sha) + + missing_rows << { sha256: sha, + body: lines[i], + created_at: at, + updated_at: at } + end + + if missing_rows.any? + WikiLine.upsert_all(missing_rows) + line_id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h + end + line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) } + + rev = nil + ActiveRecord::Base.transaction do + wiki_page.lock! + + rev = WikiRevision.create!( + wiki_page:, + base_revision_id:, + created_user_id: (Integer(version.author.name) rescue 2), + kind: :content, + redirect_page_id: nil, + message: nil, + lines_count: lines.length, + tree_sha256: tree_sha, + created_at: at, + updated_at: at) + + rows = line_ids.each_with_index.map do |line_id, pos| + { wiki_revision_id: rev.id, + wiki_line_id: line_id, + position: pos } + end + WikiRevisionLine.insert_all!(rows) + end + base_revision_id = rev.id + end + end + end +end diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index a8601c5..d09a424 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -5,12 +5,32 @@ namespace :nico do require 'open-uri' require 'nokogiri' - fetch_thumbnail = -> url { + fetch_thumbnail = -> url do html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read doc = Nokogiri::HTML(html) doc.at('meta[name="thumbnail"]')&.[]('content').presence - } + end + + def sync_post_tags! post, desired_tag_ids + desired_ids = desired_tag_ids.compact.to_set + current_ids = post.tags.pluck(:id).to_set + + to_add = desired_ids - current_ids + to_remove = current_ids - desired_ids + + Tag.where(id: to_add.to_a).find_each do |tag| + begin + PostTag.create!(post:, tag:) + rescue ActiveRecord::RecordNotUnique + ; + end + end + + PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| + pt.discard_by!(nil) + end + end mysql_user = ENV['MYSQL_USER'] mysql_pass = ENV['MYSQL_PASS'] @@ -19,43 +39,57 @@ namespace :nico do { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, 'python3', "#{ nizika_nico_path }/get_videos.py") - if status.success? - data = JSON.parse(stdout) - data.each do |datum| - post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post| - post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)} - } - unless post - title = datum['title'] - url = "https://www.nicovideo.jp/watch/#{ datum['code'] }" - thumbnail_base = fetch_thumbnail.(url) || '' rescue '' - post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) - if thumbnail_base.present? - post.thumbnail.attach( - io: URI.open(thumbnail_base), - filename: File.basename(URI.parse(thumbnail_base).path), - content_type: 'image/jpeg') - end - post.save! - post.resized_thumbnail! + abort unless status.success? + + data = JSON.parse(stdout) + data.each do |datum| + post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post| + post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)} + } + unless post + title = datum['title'] + url = "https://www.nicovideo.jp/watch/#{ datum['code'] }" + thumbnail_base = fetch_thumbnail.(url) rescue nil + post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) + if thumbnail_base.present? + post.thumbnail.attach( + io: URI.open(thumbnail_base), + filename: File.basename(URI.parse(thumbnail_base).path), + content_type: 'image/jpeg') end + post.save! + post.resized_thumbnail! + sync_post_tags!(post, [Tag.tagme.id]) + end + + kept_tags = post.tags.reload + kept_non_nico_ids = kept_tags.where.not(category: 'nico').pluck(:id).to_set - current_tags = post.tags.where(category: 'nico').pluck(:name).sort - new_tags = datum['tags'].map { |tag| "nico:#{ tag }" }.sort - if current_tags != new_tags - post.tags.destroy(post.tags.where(name: current_tags)) - tags_to_add = [] - new_tags.each do |name| - tag = Tag.find_or_initialize_by(name:) do |t| - t.category = 'nico' - end - tags_to_add.concat([tag] + tag.linked_tags) - end - tags_to_add << Tag.tagme if post.tags.size < 20 - tags_to_add << Tag.bot - post.tags = (post.tags + tags_to_add).uniq + desired_nico_ids = [] + desired_non_nico_ids = [] + datum['tags'].each do |raw| + name = "nico:#{ raw }" + tag = Tag.find_or_initialize_by(name:) do |t| + t.category = 'nico' end + tag.save! if tag.new_record? + desired_nico_ids << tag.id + unless tag.in?(kept_tags) + desired_non_nico_ids.concat(tag.linked_tags.pluck(:id)) + desired_nico_ids.concat(tag.linked_tags.pluck(:id)) + end + end + desired_nico_ids.uniq! + + desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids + desired_non_nico_ids.concat(kept_non_nico_ids.to_a) + desired_non_nico_ids.uniq! + if kept_non_nico_ids.to_set != desired_non_nico_ids.to_set + desired_all_ids << Tag.bot.id end + desired_all_ids.uniq! + + sync_post_tags!(post, desired_all_ids) end end end diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b9f4db2..764b2a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "ISC", "dependencies": { + "@fontsource-variable/noto-sans-jp": "^5.2.9", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-toast": "^1.2.14", @@ -16,6 +17,7 @@ "camelcase-keys": "^9.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.26", "humps": "^2.0.1", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", @@ -25,6 +27,8 @@ "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", "react-router-dom": "^6.30.0", + "react-youtube": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0", "unist-util-visit-parents": "^6.0.1" }, @@ -946,6 +950,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource-variable/noto-sans-jp": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.9.tgz", + "integrity": "sha512-osPL5f7dvGDjuMuFwDTGPLG37030D8X5zk+3BWea6txAVDFeE/ZIrKW0DY0uSDfRn9+NiKbiFn/2QvZveKXTog==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3376,7 +3389,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3563,6 +3575,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.26", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", + "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4169,6 +4208,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/load-script": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz", + "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4262,6 +4307,16 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4271,6 +4326,34 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -4295,6 +4378,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -4509,6 +4693,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -4940,6 +5245,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5015,7 +5335,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5390,6 +5709,17 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -5499,6 +5829,12 @@ "react": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -5652,6 +5988,23 @@ } } }, + "node_modules/react-youtube": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-10.1.0.tgz", + "integrity": "sha512-ZfGtcVpk0SSZtWCSTYOQKhfx5/1cfyEW1JN/mugGNfAxT3rmVJeMbGpA9+e78yG21ls5nc/5uZJETE3cm3knBg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "3.1.3", + "prop-types": "15.8.1", + "youtube-player": "5.5.2" + }, + "engines": { + "node": ">= 14.x" + }, + "peerDependencies": { + "react": ">=0.14.1" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5675,6 +6028,24 @@ "node": ">=8.10.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -5708,6 +6079,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5872,6 +6258,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sister": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sister/-/sister-3.0.2.tgz", + "integrity": "sha512-p19rtTs+NksBRKW9qn0UhZ8/TUI9BPw9lmtHny+Y3TinWlOa9jWh9xB0AtPSdmOy49NJJJSSe0Ey4C7h0TrcYA==", + "license": "BSD-3-Clause" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6812,6 +7204,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/youtube-player": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.5.2.tgz", + "integrity": "sha512-ZGtsemSpXnDky2AUYWgxjaopgB+shFHgXVpiJFeNB5nWEugpW1KWYDaHKuLqh2b67r24GtP6HoSW5swvf0fFIQ==", + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^2.6.6", + "load-script": "^1.0.0", + "sister": "^3.0.0" + } + }, + "node_modules/youtube-player/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/youtube-player/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 602e342..46cbd07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/noto-sans-jp": "^5.2.9", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-toast": "^1.2.14", @@ -18,6 +19,7 @@ "camelcase-keys": "^9.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.26", "humps": "^2.0.1", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", @@ -27,6 +29,8 @@ "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", "react-router-dom": "^6.30.0", + "react-youtube": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0", "unist-util-visit-parents": "^6.0.1" }, diff --git a/frontend/scripts/generate-sitemap.js b/frontend/scripts/generate-sitemap.js index f30786c..bf6c9fe 100644 --- a/frontend/scripts/generate-sitemap.js +++ b/frontend/scripts/generate-sitemap.js @@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`, { params: { ...(tagName && { tags: tagName, match: 'all', limit: '20' }) } })).data.posts -const fetchPostIds = async () => (await fetchPosts ()).map (post => post.id) const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) @@ -33,7 +32,7 @@ const createPostListOutlet = async tagName => `
広場
- ${ (await fetchPosts (tagName)).map (post => ` + ${ (await fetchPosts (tagName)).slice (0, 20).map (post => ` ${ post.title } ` fetchpriority="high" decoding="async" class="object-none w-full h-full" - src="${ post.url }" /> + src="${ post.thumbnail }" /> `).join ('') }
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d54981b..2195ca8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' import PostDetailPage from '@/pages/posts/PostDetailPage' +import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostListPage from '@/pages/posts/PostListPage' import PostNewPage from '@/pages/posts/PostNewPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' @@ -20,10 +21,12 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage' import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiSearchPage from '@/pages/wiki/WikiSearchPage' +import type { FC } from 'react' + import type { User } from '@/types' -export default () => { +export default (() => { const [user, setUser] = useState (null) const [status, setStatus] = useState (200) @@ -65,30 +68,31 @@ export default () => { switch (status) { case 503: - return + return } return (
- + - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/>
- +
) -} +}) satisfies FC diff --git a/frontend/src/components/ErrorScreen.tsx b/frontend/src/components/ErrorScreen.tsx index 651a81b..b77c7f9 100644 --- a/frontend/src/components/ErrorScreen.tsx +++ b/frontend/src/components/ErrorScreen.tsx @@ -5,10 +5,12 @@ import errorImg from '@/assets/images/not-found.gif' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' +import type { FC } from 'react' + type Props = { status: number } -export default ({ status }: Props) => { +export default (({ status }: Props) => { const [message, rightMsg, leftMsg]: [string, string, string] = (() => { switch (status) { @@ -39,7 +41,7 @@ export default ({ status }: Props) => { return ( - + {title} | {SITE_TITLE}

{leftMsg}

- 逃げたギター + 逃げたギター

{rightMsg}

{message}

) -} +}) satisfies FC diff --git a/frontend/src/components/MenuSeparator.tsx b/frontend/src/components/MenuSeparator.tsx index 43b94a7..8c7b5d3 100644 --- a/frontend/src/components/MenuSeparator.tsx +++ b/frontend/src/components/MenuSeparator.tsx @@ -1,6 +1,9 @@ -export default () => ( +import type { FC } from 'react' + + +export default (() => ( <> |
- ) + border-t border-black dark:border-white"/> + )) satisfies FC diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx index 71314f5..a71acb5 100644 --- a/frontend/src/components/NicoViewer.tsx +++ b/frontend/src/components/NicoViewer.tsx @@ -4,10 +4,10 @@ type Props = { id: string, height: number, style?: CSSProperties } -import type { CSSProperties } from 'react' +import type { CSSProperties, FC } from 'react' -export default (props: Props) => { +export default ((props: Props) => { const { id, width, height, style = { } } = props const iframeRef = useRef (null) @@ -107,5 +107,5 @@ export default (props: Props) => { height={height} style={margedStyle} allowFullScreen - allow="autoplay" />) -} + allow="autoplay"/>) +}) satisfies FC diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index b9a7adb..38b03b4 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -1,53 +1,85 @@ import axios from 'axios' import toCamel from 'camelcase-keys' -import { useState } from 'react' +import { useEffect, useState } from 'react' -import TextArea from '@/components/common/TextArea' +import PostFormTagsArea from '@/components/PostFormTagsArea' +import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' +import Label from '@/components/common/Label' import { Button } from '@/components/ui/button' import { API_BASE_URL } from '@/config' -import type { Post } from '@/types' +import type { FC } from 'react' -type Props = { post: Post - onSave: (newPost: Post) => void } +import type { Post, Tag } from '@/types' -export default ({ post, onSave }: Props) => { +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 } + + +export default (({ post, onSave }: Props) => { + const [originalCreatedBefore, setOriginalCreatedBefore] = + useState (post.originalCreatedBefore) + 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 (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags }, + const res = await axios.put ( + `${ API_BASE_URL }/posts/${ post.id }`, + { title, tags, + original_created_from: originalCreatedFrom, + original_created_before: originalCreatedBefore }, { headers: { 'Content-Type': 'multipart/form-data', - 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } } ) + 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) const data = toCamel (res.data as any, { deep: true }) as Post onSave ({ ...post, - title: data.title, - tags: data.tags } as Post) + title: data.title, + tags: data.tags, + originalCreatedFrom: data.originalCreatedFrom, + originalCreatedBefore: data.originalCreatedBefore } as Post) } + useEffect (() => { + setTags(tagsToStr (post.tags)) + }, [post]) + return (
{/* タイトル */}
-
- -
+ setTitle (e.target.value)} /> + onChange={ev => setTitle (ev.target.value)}/>
{/* タグ */} -
- -