コミットを比較

...

18 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 409498729a Merge remote-tracking branch 'origin/main' into feature/206 2026-03-08 23:12:35 +09:00
みてるぞ 9e3cbd2469 投稿検索ページ(#206) (#274)
#206 エラー修正

#206 updated_at の並び順修正

Merge remote-tracking branch 'origin/main' into feature/206

Merge branch 'main' into feature/206

Merge branch 'main' into feature/206

Merge branch 'main' into feature/206

#206

#206

#206

#206

#206

#206 タグ補完追加

#206

#206

#206

#206

#206

Merge remote-tracking branch 'origin/main' into feature/206

#206

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #274
2026-03-08 23:12:16 +09:00
みてるぞ f7af5efaf9 #206 エラー修正 2026-03-08 23:06:10 +09:00
みてるぞ 4235903b49 #206 updated_at の並び順修正 2026-03-08 22:58:22 +09:00
みてるぞ 17ff0e8114 Merge remote-tracking branch 'origin/main' into feature/206 2026-03-08 16:30:56 +09:00
みてるぞ 16e9b8ca49 タグの合併処理追加(#282) (#284)
#282

#282

#282

#282

#282

#282

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #284
2026-03-08 15:46:05 +09:00
みてるぞ 6b1079fc1a Merge branch 'main' into feature/206 2026-03-07 13:58:59 +09:00
みてるぞ 7885f6dfb9 feat: “ニジラー情報不詳” タグの自動付与(#106) (#196)
#106

#106 エラー対応

#106

Merge branch 'main' into '#106'

Merge branch 'main' into '#106'

Merge remote-tracking branch 'origin/main' into '#106'

#106 誤字

Merge remote-tracking branch 'origin/main' into '#106'

#106 ニジラー情報なし

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #196
2026-03-07 13:58:43 +09:00
みてるぞ a66a73e004 Merge branch 'main' into feature/206 2026-03-05 23:10:45 +09:00
みてるぞ 98330b00bb ニコニコ同期にてニジラー情報自動記載(#276) (#277)
#276

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #277
2026-03-05 23:10:37 +09:00
みてるぞ 95ea097636 Merge branch 'main' into feature/206 2026-03-05 21:38:49 +09:00
みてるぞ e0e7a22c38 ニジラー管理(#247) (#275)
#247

#247

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #275
2026-03-05 20:35:11 +09:00
みてるぞ da45bc95eb #206 2026-03-03 00:35:31 +09:00
みてるぞ 55dcd4e1f3 #206 2026-03-02 23:29:22 +09:00
みてるぞ 1e54fb5be4 #206 2026-03-02 22:40:55 +09:00
みてるぞ 1350ae3d99 #206 2026-03-01 15:05:40 +09:00
みてるぞ a088503272 #206 2026-03-01 13:48:47 +09:00
みてるぞ f8d2d753fe #206 タグ補完追加 2026-03-01 12:20:49 +09:00
38個のファイルの変更1001行の追加221行の削除
+47
ファイルの表示
@@ -0,0 +1,47 @@
class DeerjikistsController < ApplicationController
def show
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
return head :bad_request if platform.blank? || code.blank?
deerjikist = Deerjikist
.joins(:tag)
.includes(tag: :tag_name)
.find_by(platform:, code:)
if deerjikist
render json: DeerjikistRepr.base(deerjikist)
else
head :not_found
end
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i
return head :bad_request if platform.blank? || code.blank? || tag_id <= 0
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d|
d.tag_id = tag_id
d.save!
end
render json: DeerjikistRepr.base(deerjikist)
end
def destroy
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
return head :bad_request if platform.blank? || code.blank?
Deerjikist.find([platform, code]).destroy!
head :no_content
end
end
+1 -1
ファイルの表示
@@ -25,7 +25,7 @@ class NicoTagsController < ApplicationController
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.member? return head :forbidden unless current_user.gte_member?
id = params[:id].to_i id = params[:id].to_i
+48 -9
ファイルの表示
@@ -9,6 +9,19 @@ class PostsController < ApplicationController
created_between = params[:created_from].presence, params[:created_to].presence created_between = params[:created_from].presence, params[:created_to].presence
updated_between = params[:updated_from].presence, params[:updated_to].presence updated_between = params[:updated_from].presence, params[:updated_to].presence
order = params[:order].to_s.split(':', 2).map(&:strip)
unless order[0].in?(['title', 'url', 'original_created_at', 'created_at', 'updated_at'])
order[0] = 'original_created_at'
end
unless order[1].in?(['asc', 'desc'])
order[1] =
if order[0].in?(['title', 'url'])
'asc'
else
'desc'
end
end
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -17,8 +30,20 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
pt_max_sql =
PostTag
.select('post_id, MAX(updated_at) AS max_updated_at')
.group('post_id')
.to_sql
updated_at_all_sql =
'GREATEST(posts.updated_at,' +
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
q = q =
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: { tag_name: :wiki_page }) .preload(tags: { tag_name: :wiki_page })
.with_attached_thumbnail .with_attached_thumbnail
@@ -32,19 +57,33 @@ class PostsController < ApplicationController
end end
q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0] q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1] q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
q = q.where('posts.updated_at >= ?', updated_between[0]) if updated_between[0] if updated_between[0]
q = q.where('posts.updated_at <= ?', updated_between[1]) if updated_between[1] q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
end
if updated_between[1]
q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
end
sort_sql = sort_sql =
case order[0]
when 'original_created_at'
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' + 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' +
'posts.original_created_from,' + 'posts.original_created_from,' +
'posts.created_at)' 'posts.created_at) '
posts = q.select("posts.*, #{ sort_sql } AS sort_ts") when 'updated_at'
.order(Arel.sql("#{ sort_sql } DESC")) updated_at_all_sql
.limit(limit).offset(offset).to_a else
"posts.#{ order[0] }"
end
posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a
q = q.except(:select, :order)
render json: { posts: posts.map { |post| render json: { posts: posts.map { |post|
PostRepr.base(post).tap do |json| PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
json['thumbnail'] = json['thumbnail'] =
if post.thumbnail.attached? if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false) rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -82,7 +121,7 @@ class PostsController < ApplicationController
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.member? return head :forbidden unless current_user.gte_member?
# TODO: サイトに応じて thumbnail_base 設定 # TODO: サイトに応じて thumbnail_base 設定
title = params[:title].presence title = params[:title].presence
@@ -126,7 +165,7 @@ class PostsController < ApplicationController
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.member? return head :forbidden unless current_user.gte_member?
title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
+22 -1
ファイルの表示
@@ -86,9 +86,30 @@ class TagsController < ApplicationController
end end
end end
def deerjikists
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
return head :not_found unless tag
render json: DeerjikistRepr.many(tag.deerjikists)
end
def deerjikists_by_name
name = params[:name].to_s.strip
return head :bad_request if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: })
return head :not_found unless tag
render json: DeerjikistRepr.many(tag.deerjikists)
end
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.member? return head :forbidden unless current_user.gte_member?
name = params[:name].presence name = params[:name].presence
category = params[:category].presence category = params[:category].presence
+2 -2
ファイルの表示
@@ -83,7 +83,7 @@ class WikiPagesController < ApplicationController
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.member? return head :forbidden unless current_user.gte_member?
name = params[:title]&.strip name = params[:title]&.strip
body = params[:body].to_s body = params[:body].to_s
@@ -105,7 +105,7 @@ class WikiPagesController < ApplicationController
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.member? return head :forbidden unless current_user.gte_member?
title = params[:title]&.strip title = params[:title]&.strip
body = params[:body].to_s body = params[:body].to_s
+21
ファイルの表示
@@ -0,0 +1,21 @@
class Deerjikist < ApplicationRecord
self.primary_key = :platform, :code
belongs_to :tag
validates :platform, presence: true
validates :code, presence: true
validates :tag_id, presence: true
validate :tag_must_be_deerjikist
enum :platform, nico: 'nico', youtube: 'youtube'
private
def tag_must_be_deerjikist
if tag && !(tag.deerjikist?)
errors.add :tag, 'タグはニジラー・カテゴリである必要があります.'
end
end
end
+53 -9
ファイルの表示
@@ -23,6 +23,10 @@ class Tag < ApplicationRecord
has_many :parents, through: :reversed_tag_implications, source: :parent_tag has_many :parents, through: :reversed_tag_implications, source: :parent_tag
has_many :tag_similarities, dependent: :delete_all has_many :tag_similarities, dependent: :delete_all
has_many :tag_similarities_as_target,
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
has_many :deerjikists, dependent: :delete_all
belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name
@@ -42,8 +46,9 @@ class Tag < ApplicationRecord
validate :nico_tag_name_must_start_with_nico validate :nico_tag_name_must_start_with_nico
validate :tag_name_must_be_canonical validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists
scope :nico_tags, -> { where(category: :nico) } scope :nico_tags, -> { nico }
CATEGORY_PREFIXES = { CATEGORY_PREFIXES = {
'general:' => :general, 'general:' => :general,
@@ -61,9 +66,7 @@ class Tag < ApplicationRecord
(self.tag_name ||= build_tag_name).name = val (self.tag_name ||= build_tag_name).name = val
end end
def has_wiki def has_wiki = wiki_page.present?
wiki_page.present?
end
def self.tagme def self.tagme
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
@@ -73,6 +76,10 @@ class Tag < ApplicationRecord
@bot ||= find_or_create_by_tag_name!('bot操作', category: :meta) @bot ||= find_or_create_by_tag_name!('bot操作', category: :meta)
end end
def self.no_deerjikist
@no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
end
def self.video def self.video
@video ||= find_or_create_by_tag_name!('動画', category: :meta) @video ||= find_or_create_by_tag_name!('動画', category: :meta)
end end
@@ -90,13 +97,12 @@ class Tag < ApplicationRecord
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if cat && tag.category != cat tag.update!(category: cat) if cat && tag.category != cat
tag.update!(category: cat)
end
end end
end end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if tags.all? { |t| !(t.deerjikist?) }
tags.uniq(&:id) tags.uniq(&:id)
end end
@@ -134,12 +140,44 @@ class Tag < ApplicationRecord
retry retry
end end
def self.merge_tags! target_tag, source_tags
target_tag => Tag
Tag.transaction do
Array(source_tags).compact.uniq.each do |st|
st => Tag
next if st == target_tag
st.post_tags.find_each do |pt|
if PostTag.kept.exists?(post_id: pt.post_id, tag_id: target_tag.id)
pt.discard_by!(nil)
# discard 後の update! は禁止なので DB を直に更新
pt.update_columns(tag_id: target_tag.id, updated_at: Time.current)
else
pt.update!(tag: target_tag)
end
end
tag_name = st.tag_name
st.destroy!
tag_name.reload
tag_name.update!(canonical: target_tag.tag_name)
end
# 投稿件数を再集計
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
end
target_tag.reload
end
private private
def nico_tag_name_must_start_with_nico def nico_tag_name_must_start_with_nico
n = name.to_s n = name.to_s
if ((category == 'nico' && !(n.downcase.start_with?('nico:'))) || if ((nico? && !(n.downcase.start_with?('nico:'))) ||
(category != 'nico' && n.downcase.start_with?('nico:'))) (!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end end
end end
@@ -149,4 +187,10 @@ class Tag < ApplicationRecord
errors.add :tag_name, 'tag_names へは実体を示す必要があります.' errors.add :tag_name, 'tag_names へは実体を示す必要があります.'
end end
end end
def category_must_be_deerjikist_with_deerjikists
if !(deerjikist?) && deerjikists.exists?
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
end
end
end end
+9 -15
ファイルの表示
@@ -1,29 +1,23 @@
class User < ApplicationRecord class User < ApplicationRecord
enum :role, { guest: 'guest', member: 'member', admin: 'admin' } enum :role, guest: 'guest', member: 'member', admin: 'admin'
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
validates :inheritance_code, presence: true, length: { maximum: 64 } validates :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys } validates :role, presence: true, inclusion: { in: roles.keys }
validates :banned, inclusion: { in: [true, false] } validates :banned, inclusion: { in: [true, false] }
has_many :posts has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
has_many :settings has_many :settings
has_many :user_ips, dependent: :destroy has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy has_many :user_post_views, dependent: :destroy
has_many :viewed_posts, through: :user_post_views, source: :post has_many :viewed_posts, through: :user_post_views, source: :post
has_many :created_wiki_pages, class_name: 'WikiPage', foreign_key: 'created_user_id', dependent: :nullify has_many :created_wiki_pages,
has_many :updated_wiki_pages, class_name: 'WikiPage', foreign_key: 'updated_user_id', dependent: :nullify class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify
has_many :updated_wiki_pages,
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed? post def viewed?(post) = user_post_views.exists?(post_id: post.id)
user_post_views.exists? post_id: post.id def gte_member? = member? || admin?
end
def member?
['member', 'admin'].include?(role)
end
def admin?
role == 'admin'
end
end end
+1 -1
ファイルの表示
@@ -8,7 +8,7 @@ class WikiLine < ApplicationRecord
sha = Digest::SHA256.hexdigest(body) sha = Digest::SHA256.hexdigest(body)
now = Time.current now = Time.current
upsert({ sha256: sha, body:, created_at: now, updated_at: now }) upsert(sha256: sha, body:, created_at: now, updated_at: now)
find_by!(sha256: sha) find_by!(sha256: sha)
end end
+4 -11
ファイルの表示
@@ -14,17 +14,13 @@ class WikiPage < ApplicationRecord
belongs_to :tag_name belongs_to :tag_name
validates :tag_name, presence: true validates :tag_name, presence: true
def title def title = tag_name.name
tag_name.name
end
def title= val def title= val
(self.tag_name ||= build_tag_name).name = val (self.tag_name ||= build_tag_name).name = val
end end
def current_revision def current_revision = wiki_revisions.order(id: :desc).first
wiki_revisions.order(id: :desc).first
end
def body def body
rev = current_revision rev = current_revision
@@ -49,11 +45,8 @@ class WikiPage < ApplicationRecord
page page
end end
def pred_revision_id revision_id def pred_revision_id(revision_id) =
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
end def succ_revision_id(revision_id) =
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
end end
+1 -1
ファイルの表示
@@ -7,7 +7,7 @@ class WikiRevision < ApplicationRecord
has_many :wiki_revision_lines, dependent: :delete_all has_many :wiki_revision_lines, dependent: :delete_all
has_many :wiki_lines, through: :wiki_revision_lines has_many :wiki_lines, through: :wiki_revision_lines
enum :kind, { content: 0, redirect: 1 } enum :kind, content: 0, redirect: 1
validates :kind, presence: true validates :kind, presence: true
validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+16
ファイルの表示
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module DeerjikistRepr
BASE = { only: [:platform, :code], include: { tag: TagRepr::BASE } }.freeze
module_function
def base deerjikist
deerjikist.as_json(BASE)
end
def many deerjikists
deerjikists.map { |d| base(d) }
end
end
+19 -1
ファイルの表示
@@ -9,7 +9,15 @@ Rails.application.routes.draw do
resources :tags, only: [:index, :show, :update] do resources :tags, only: [:index, :show, :update] do
collection do collection do
get :autocomplete get :autocomplete
get 'name/:name', action: :show_by_name
scope :name do
get ':name/deerjikists', action: :deerjikists_by_name
get ':name', action: :show_by_name
end
end
member do
get :deerjikists
end end
end end
@@ -54,4 +62,14 @@ Rails.application.routes.draw do
post 'code/renew', action: :renew post 'code/renew', action: :renew
end end
end end
resources :deerjikists, only: [] do
collection do
scope ':platform/:code' do
get '', action: :show
put '', action: :update
delete '', action: :destroy
end
end
end
end end
+10
ファイルの表示
@@ -0,0 +1,10 @@
class CreateDeerjikists < ActiveRecord::Migration[8.0]
def change
create_table :deerjikists, primary_key: [:platform, :code] do |t|
t.string :platform, null: false, limit: 16
t.string :code, null: false
t.references :tag, null: false
t.timestamps
end
end
end
生成ファイル
+10 -1
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_01_27_005300) do ActiveRecord::Schema[8.0].define(version: 2026_03_03_122700) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -39,6 +39,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_27_005300) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end end
create_table "deerjikists", primary_key: ["platform", "code"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "platform", limit: 16, null: false
t.string "code", null: false
t.bigint "tag_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false t.binary "ip_address", limit: 16, null: false
t.boolean "banned", default: false, null: false t.boolean "banned", default: false, null: false
+46 -26
ファイルの表示
@@ -2,9 +2,9 @@ namespace :nico do
desc 'ニコニコ DB 同期' desc 'ニコニコ DB 同期'
task sync: :environment do task sync: :environment do
require 'json' require 'json'
require 'open3'
require 'open-uri'
require 'nokogiri' require 'nokogiri'
require 'open-uri'
require 'open3'
require 'set' require 'set'
require 'time' require 'time'
@@ -15,12 +15,12 @@ namespace :nico do
doc.at('meta[name="thumbnail"]')&.[]('content').presence doc.at('meta[name="thumbnail"]')&.[]('content').presence
end end
def sync_post_tags! post, desired_tag_ids, current_ids: nil def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_ids = desired_tag_ids.compact.to_set desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_ids - current_ids to_add = desired_tag_ids - current_tag_ids
to_remove = current_ids - desired_ids to_remove = current_tag_ids - desired_tag_ids
Tag.where(id: to_add.to_a).find_each do |tag| Tag.where(id: to_add.to_a).find_each do |tag|
begin begin
@@ -42,12 +42,14 @@ namespace :nico do
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', "#{ nizika_nico_path }/get_videos.py") 'python3', "#{ nizika_nico_path }/get_videos.py")
abort unless status.success? unless status.success?
warn stderr
abort
end
data = JSON.parse(stdout) data = JSON.parse(stdout)
data.each do |datum| data.each do |datum|
code = datum['code'] code = datum['code']
post = post =
Post Post
.where('url REGEXP ?', "nicovideo\\.jp/watch/#{ Regexp.escape(code) }([^0-9]|$)") .where('url REGEXP ?', "nicovideo\\.jp/watch/#{ Regexp.escape(code) }([^0-9]|$)")
@@ -94,32 +96,50 @@ namespace :nico do
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end end
kept_ids = PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set tags = post.tags
kept_non_nico_ids = post.tags.where.not(category: 'nico').pluck(:id).to_set # 既存のタグ Id. 集合
kept_tag_ids = tags.pluck(:id).to_set
# うち内部タグ Id. 集合
kept_non_nico_tag_ids = tags.not_nico.pluck(:id).to_set
# 記載すべき外部タグ Id. および連携される内部タグ Id. のリスト
desired_nico_tag_based_ids = []
# 記載すべき内部タグ Id. のリスト
desired_non_nico_tag_ids = []
desired_nico_ids = []
desired_non_nico_ids = []
datum['tags'].each do |raw| datum['tags'].each do |raw|
name = "nico:#{ raw }" name = "nico:#{ raw }"
tag = Tag.find_or_create_by_tag_name!(name, category: 'nico') tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
desired_nico_ids << tag.id desired_nico_tag_based_ids << tag.id
unless tag.id.in?(kept_ids)
# 新たに記載される外部タグと連携される内部タグを記載
unless tag.id.in?(kept_tag_ids)
linked_ids = tag.linked_tags.pluck(:id) linked_ids = tag.linked_tags.pluck(:id)
desired_non_nico_ids.concat(linked_ids) desired_non_nico_tag_ids.concat(linked_ids)
desired_nico_ids.concat(linked_ids) desired_nico_tag_based_ids.concat(linked_ids)
end end
end end
desired_nico_ids.uniq!
desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
desired_non_nico_ids.concat(kept_non_nico_ids.to_a) if deerjikist
desired_non_nico_ids.uniq! desired_non_nico_tag_ids << deerjikist.tag_id
if kept_non_nico_ids.to_set != desired_non_nico_ids.to_set desired_nico_tag_based_ids << deerjikist.tag_id
desired_all_ids << Tag.bot.id elsif !(Tag.where(id: kept_non_nico_tag_ids).where(category: :deerjikist).exists?)
desired_non_nico_tag_ids << Tag.no_deerjikist.id
desired_nico_tag_based_ids << Tag.no_deerjikist.id
end end
desired_all_ids.uniq!
sync_post_tags!(post, desired_all_ids, current_ids: kept_ids) desired_nico_tag_based_ids.uniq!
desired_all_tag_ids = kept_non_nico_tag_ids.to_a + desired_nico_tag_based_ids
desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a)
desired_non_nico_tag_ids.uniq!
if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set
desired_all_tag_ids << Tag.bot.id
end
desired_all_tag_ids.uniq!
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
end end
end end
end end
+2 -2
ファイルの表示
@@ -1,11 +1,11 @@
FactoryBot.define do FactoryBot.define do
factory :tag do factory :tag do
category { 'general' } category { :general }
post_count { 0 } post_count { 0 }
association :tag_name association :tag_name
trait :nico do trait :nico do
category { 'nico' } category { :nico }
tag_name { association(:tag_name, name: "nico:#{ SecureRandom.hex(4) }") } tag_name { association(:tag_name, name: "nico:#{ SecureRandom.hex(4) }") }
end end
end end
+71
ファイルの表示
@@ -0,0 +1,71 @@
require 'rails_helper'
RSpec.describe Tag, type: :model do
describe '.merge_tags!' do
let!(:target_tag) { create(:tag) }
let!(:source_tag) { create(:tag) }
let!(:post_record) { Post.create!(url: 'https://example.com/posts/1', title: 'test post') }
context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'moves the post_tag, deletes the source tag, and aliases the source tag_name' do
described_class.merge_tags!(target_tag, [source_tag])
expect(source_post_tag.reload.tag_id).to eq(target_tag.id)
expect(Tag.exists?(source_tag.id)).to be(false)
expect(source_tag.tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
end
end
context 'when the target already has the same post_tag' do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the duplicate source post_tag and keeps one active target post_tag' do
described_class.merge_tags!(target_tag, [source_tag])
active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)
discarded_source = PostTag.with_discarded.find(source_post_tag.id)
expect(active.count).to eq(1)
expect(discarded_source.discarded_at).to be_present
expect(Tag.exists?(source_tag.id)).to be(false)
expect(source_tag.tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
end
end
context 'when source_tags includes the target itself' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'ignores the target tag in source_tags' do
described_class.merge_tags!(target_tag, [source_tag, target_tag])
expect(Tag.exists?(target_tag.id)).to be(true)
expect(Tag.exists?(source_tag.id)).to be(false)
expect(source_post_tag.reload.tag_id).to eq(target_tag.id)
end
end
context 'when aliasing the source tag_name is invalid' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
let!(:wiki_page) do
WikiPage.create!(
tag_name: source_tag.tag_name,
created_user: create_admin_user!,
updated_user: create_admin_user!)
end
it 'rolls back the transaction' do
expect {
described_class.merge_tags!(target_tag, [source_tag])
}.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.exists?(source_tag.id)).to be(true)
expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_tag.tag_name.reload.canonical_id).to be_nil
end
end
end
end
+181
ファイルの表示
@@ -0,0 +1,181 @@
require 'rails_helper'
RSpec.describe 'Deerjikists API', type: :request do
let(:platform) { 'nico' }
let(:code) { 'deerjika-bot' }
let!(:tag1) { create(:tag, category: :deerjikist) }
let!(:tag2) { create(:tag, category: :deerjikist) }
let(:member) { create(:user, :member) }
let(:guest) { create(:user, role: :guest) }
describe 'GET /deerjikists/:platform/:code' do
subject(:do_request) do
get "/deerjikists/#{ platform }/#{ code }"
end
context 'when deerjikist exists' do
before do
Deerjikist.create!(platform:, code:, tag: tag1)
end
it 'returns 200 and deerjikist json' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Hash)
expect(json['platform']).to eq(platform)
expect(json['code']).to eq(code)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag1.id)
expect(json['tag']['name']).to eq(tag1.name)
end
end
context 'when deerjikist does not exist' do
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
context 'when platform or code become blank after strip' do
it 'returns 400' do
get '/deerjikists/%20/%20'
expect(response).to have_http_status(:bad_request)
end
end
end
describe 'PUT /deerjikists/:platform/:code' do
subject(:do_request) do
put "/deerjikists/#{ platform }/#{ code }", params: payload
end
let(:payload) { { tag_id: tag1.id } }
context 'when not legged in' do
it 'returns 401' do
sign_out
do_request
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in but not member' do
it 'returns 403' do
sign_in_as guest
do_request
expect(response).to have_http_status(:forbidden)
end
end
context 'when member' do
before do
sign_in_as member
end
context 'when params invalid' do
it 'returns 400 when tag_id is missing or invalid' do
put "/deerjikists/#{ platform }/#{ code }", params: { tag_id: 0 }
expect(response).to have_http_status(:bad_request)
end
it 'returns 400 when platform or code blank after strip' do
put '/deerjikists/%20/%20', params: { tag_id: tag1.id }
expect(response).to have_http_status(:bad_request)
end
end
context 'when creating new deerjikist' do
it 'creates and returns 200 with json' do
expect { do_request }.to change { Deerjikist.count }.by(1)
expect(response).to have_http_status(:ok)
d = Deerjikist.find_by(platform:, code:)
expect(d).to be_present
expect(d.tag_id).to eq(tag1.id)
expect(json['platform']).to eq(platform)
expect(json['code']).to eq(code)
end
end
context 'when updating existing deerjikist' do
before do
Deerjikist.create!(platform:, code:, tag: tag1)
end
let(:payload) { { tag_id: tag2.id } }
it 'updates tag_id and returns 200' do
expect { do_request }.not_to change { Deerjikist.count }
expect(response).to have_http_status(:ok)
d = Deerjikist.find_by(platform:, code:)
expect(d.tag_id).to eq(tag2.id)
expect(json['platform']).to eq(platform)
expect(json['code']).to eq(code)
end
end
end
end
describe 'DELETE /deerjikists/:platform/:code' do
subject(:do_request) do
delete "/deerjikists/#{ platform }/#{ code }"
end
context 'when not logged in' do
it 'returns 401' do
sign_out
do_request
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in but not member' do
it 'returns 403' do
sign_in_as guest
do_request
expect(response).to have_http_status(:forbidden)
end
end
context 'when member' do
before do
sign_in_as member
end
it 'returns 400 when platform/code blank after strip' do
delete '/deerjikists/%20/%20'
expect(response).to have_http_status(:bad_request)
end
context 'when deerjikist exists' do
before do
Deerjikist.create!(platform: platform, code: code, tag: tag1)
end
it 'destroys and returns 204' do
expect {
do_request
}.to change { Deerjikist.exists?(platform: platform, code: code) }
.from(true).to(false)
expect(response).to have_http_status(:no_content)
end
end
context 'when deerjikist does not exist' do
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
end
end
end
-1
ファイルの表示
@@ -1,4 +1,3 @@
# spec/requests/tag_children_spec.rb
require "rails_helper" require "rails_helper"
RSpec.describe "TagChildren", type: :request do RSpec.describe "TagChildren", type: :request do
+102
ファイルの表示
@@ -0,0 +1,102 @@
require 'rails_helper'
RSpec.describe 'Tags deerjikists API', type: :request do
let(:platform1) { 'nico' }
let(:code1) { 'deerjika-bot' }
let(:platform2) { 'youtube' }
let(:code2) { 'deerjika-bot.bsky.social' }
let!(:tag) { create(:tag, category: :deerjikist) }
before do
# show_by_name / deerjikists_by_name 用に名前を固定
tag.tag_name.update!(name: 'deerjika')
end
describe 'GET /tags/:id/deerjikists' do
subject(:do_request) do
get "/tags/#{ tag_id }/deerjikists"
end
let(:tag_id) { tag.id }
context 'when tag exists and has no deerjikists' do
it 'returns 200 and empty array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end
context 'when tag exists and has deerjikists' do
before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end
it 'returns 200 and deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Array)
expect(json.size).to eq(2)
expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end
context 'when tag does not exist' do
let(:tag_id) { 9_999_999 }
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
end
describe 'GET /tags/name/:name/deerjikists' do
subject(:do_request) do
get "/tags/name/#{ name }/deerjikists"
end
let(:name) { 'deerjika' }
context 'when name is blank after strip' do
let(:name) { '%20' }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when tag does not exist for name' do
let(:name) { 'no-such-tag' }
it 'returns 404' do
do_request
expect(response).to have_http_status(:not_found)
end
end
context 'when tag exists and has deerjikists' do
before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
it 'returns 200 and deerjikists array' do
do_request
expect(response).to have_http_status(:ok)
expect(json).to be_a(Array)
expect(json.size).to eq(1)
expect(json[0]['platform']).to eq(platform1)
expect(json[0]['code']).to eq(code1)
end
end
end
end
+2 -2
ファイルの表示
@@ -106,8 +106,8 @@ RSpec.describe 'Tags API', type: :request do
end end
before do before do
allow(member_user).to receive(:member?).and_return(true) allow(member_user).to receive(:gte_member?).and_return(true)
allow(non_member_user).to receive(:member?).and_return(false) allow(non_member_user).to receive(:gte_member?).and_return(false)
end end
describe "PATCH /tags/:id" do describe "PATCH /tags/:id" do
+3 -3
ファイルの表示
@@ -1,3 +1,5 @@
// TODO: TagSearch と共通化する.
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
@@ -81,9 +83,7 @@ export default (({ tags, setTags }: Props) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos) await recompute (pos)
}} }}
onFocus={() => { onFocus={() => setFocused (true)}
setFocused (true)
}}
onBlur={() => { onBlur={() => {
setFocused (false) setFocused (false)
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
+7 -12
ファイルの表示
@@ -19,6 +19,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core' import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react' import type { FC, MutableRefObject, ReactNode } from 'react'
@@ -343,7 +344,7 @@ export default (({ post }: Props) => {
: 'bot操作'} : 'bot操作'}
</li> </li>
*/} */}
<li>: {(new Date (post.createdAt)).toLocaleString ()}</li> <li>: {dateString (post.createdAt)}</li>
<li> <li>
<>: </> <>: </>
<a <a
@@ -355,20 +356,14 @@ export default (({ post }: Props) => {
</a> </a>
</li> </li>
<li> <li>
{/* TODO: 表示形式きしょすぎるので何とかする */}
<>稿: </> <>稿: </>
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore) {originalCreatedAtString (post.originalCreatedFrom,
? '不明' post.originalCreatedBefore)}
: (
<>
{post.originalCreatedFrom
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
{post.originalCreatedBefore
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
</>)}
</li> </li>
<li> <li>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}></PrefetchLink> <PrefetchLink to={`/posts/changes?id=${ post.id }`}>
</PrefetchLink>
</li> </li>
</ul> </ul>
</div>)} </div>)}
+2
ファイルの表示
@@ -1,3 +1,5 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
+18 -5
ファイルの表示
@@ -3,13 +3,23 @@ import type { Category } from 'types'
export const LIGHT_COLOUR_SHADE = 800 export const LIGHT_COLOUR_SHADE = 800
export const DARK_COLOUR_SHADE = 300 export const DARK_COLOUR_SHADE = 300
export const CATEGORIES = ['deerjikist', export const CATEGORIES = [
'deerjikist',
'meme', 'meme',
'character', 'character',
'general', 'general',
'material', 'material',
'meta', 'meta',
'nico'] as const 'nico',
] as const
export const FETCH_POSTS_ORDER_FIELDS = [
'title',
'url',
'original_created_at',
'created_at',
'updated_at',
] as const
export const TAG_COLOUR = { export const TAG_COLOUR = {
deerjikist: 'rose', deerjikist: 'rose',
@@ -18,10 +28,13 @@ export const TAG_COLOUR = {
general: 'cyan', general: 'cyan',
material: 'orange', material: 'orange',
meta: 'yellow', meta: 'yellow',
nico: 'gray' } as const satisfies Record<Category, string> nico: 'gray',
} as const satisfies Record<Category, string>
export const USER_ROLES = ['admin', 'member', 'guest'] as const export const USER_ROLES = ['admin', 'member', 'guest'] as const
export const ViewFlagBehavior = { OnShowedDetail: 1, export const ViewFlagBehavior = {
OnShowedDetail: 1,
OnClickedLink: 2, OnClickedLink: 2,
NotAuto: 3 } as const NotAuto: 3,
} as const
+15 -18
ファイルの表示
@@ -1,31 +1,28 @@
import { apiDelete, apiGet, apiPost } from '@/lib/api' import { apiDelete, apiGet, apiPost } from '@/lib/api'
import type { Post, PostTagChange } from '@/types' import type { FetchPostsParams, Post, PostTagChange } from '@/types'
export const fetchPosts = async ( export const fetchPosts = async (
{ url, title, tags, match, created_from, created_to, updated_from, { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
updated_to, original_created_from, original_created_to, page, limit }: { originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams
url?: string
title?: string
tags?: string
match?: 'all' | 'any'
created_from?: string
created_to?: string
updated_from?: string
updated_to?: string
original_created_from?: string
original_created_to?: string
page?: number
limit?: number },
): Promise<{ ): Promise<{
posts: Post[] posts: Post[]
count: number }> => count: number }> =>
await apiGet ('/posts', { params: { await apiGet ('/posts', { params: {
url, title, tags, match, created_from, created_to, updated_from, updated_to, ...(url && { url }),
original_created_from, original_created_to, ...(title && { title }),
...(tags && { tags }),
...(match && { match }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }),
...(updatedTo && { updated_to: updatedTo }),
...(originalCreatedFrom && { original_created_from: originalCreatedFrom }),
...(originalCreatedTo && { original_created_to: originalCreatedTo }),
...(page && { page }), ...(page && { page }),
...(limit && { limit }) } }) ...(limit && { limit }),
...(order && { order }) } })
export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`) export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`)
+28 -18
ファイルの表示
@@ -8,6 +8,8 @@ import { fetchWikiPage,
fetchWikiPageByTitle, fetchWikiPageByTitle,
fetchWikiPages } from '@/lib/wiki' fetchWikiPages } from '@/lib/wiki'
import type { FetchPostsOrder } from '@/types'
type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id') const mPost = match<{ id: string }> ('/posts/:id')
@@ -59,7 +61,20 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
if (version) if (version)
return return
const p = { tags: effectiveTitle, match: 'all', page: 1, limit: 8 } as const const p = {
tags: effectiveTitle,
match: 'all',
page: 1,
limit: 8,
url: '',
title: '',
originalCreatedFrom: '',
originalCreatedTo: '',
createdFrom: '',
createdTo: '',
updatedFrom: '',
updatedTo: '',
order: 'original_created_at:desc' } as const
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: postsKeys.index (p), queryKey: postsKeys.index (p),
queryFn: () => fetchPosts (p) }) queryFn: () => fetchPosts (p) })
@@ -68,28 +83,23 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
const prefetchPostsIndex: Prefetcher = async (qc, url) => { const prefetchPostsIndex: Prefetcher = async (qc, url) => {
const tags = url.searchParams.get ('tags') ?? '' const tags = url.searchParams.get ('tags') ?? ''
const qURL = url.searchParams.get ('url') const qURL = url.searchParams.get ('url') ?? ''
const title = url.searchParams.get ('title') const title = url.searchParams.get ('title') ?? ''
const originalCreatedFrom = url.searchParams.get ('original_created_from') const originalCreatedFrom = url.searchParams.get ('original_created_from') ?? ''
const originalCreatedTo = url.searchParams.get ('original_created_to') const originalCreatedTo = url.searchParams.get ('original_created_to') ?? ''
const createdFrom = url.searchParams.get ('created_from') const createdFrom = url.searchParams.get ('created_from') ?? ''
const createdTo = url.searchParams.get ('created_to') const createdTo = url.searchParams.get ('created_to') ?? ''
const updatedFrom = url.searchParams.get ('updated_from') const updatedFrom = url.searchParams.get ('updated_from') ?? ''
const updatedTo = url.searchParams.get ('updated_to') const updatedTo = url.searchParams.get ('updated_to') ?? ''
const m: 'all' | 'any' = url.searchParams.get ('match') === 'any' ? 'any' : 'all' const m: 'all' | 'any' = url.searchParams.get ('match') === 'any' ? 'any' : 'all'
const page = Number (url.searchParams.get ('page') || 1) const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20) const limit = Number (url.searchParams.get ('limit') || 20)
const order = (url.searchParams.get ('order') ?? 'original_created_at:desc') as FetchPostsOrder
const keys = { const keys = {
tags, match: m, page, limit, tags, match: m, page, limit, url: qURL, title,
...(qURL && { url: qURL }), originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
...(title && { title }), updatedFrom, updatedTo, order }
...(originalCreatedFrom && { original_created_from: originalCreatedFrom }),
...(originalCreatedTo && { original_created_to: originalCreatedTo }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }),
...(updatedTo && { updated_to: updatedTo }) }
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: postsKeys.index (keys), queryKey: postsKeys.index (keys),
+3 -12
ファイルの表示
@@ -1,17 +1,8 @@
import type { FetchPostsParams } from '@/types'
export const postsKeys = { export const postsKeys = {
root: ['posts'] as const, root: ['posts'] as const,
index: (p: { url?: string index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
title?: string
tags?: string
match?: 'all' | 'any'
created_from?: string
created_to?: string
updated_from?: string
updated_to?: string
original_created_from?: string
original_created_to?: string
page?: number
limit?: number }) => ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const, show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', id] as const, related: (id: string) => ['related', id] as const,
changes: (p: { id?: string; page: number; limit: number }) => changes: (p: { id?: string; page: number; limit: number }) =>
+19
ファイルの表示
@@ -4,4 +4,23 @@ import { twMerge } from 'tailwind-merge'
import type { ClassValue } from 'clsx' import type { ClassValue } from 'clsx'
export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Date (d) : d
export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs)) export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
export const dateString = (d: string | Date): string =>
toDate (d).toLocaleString ('ja-JP-u-ca-japanese')
// TODO: 表示形式きしょすぎるので何とかする
export const originalCreatedAtString = (
f: string | Date | null,
b: string | Date | null,
): string =>
([f ? `${ dateString (f) } 以降` : '',
b ? `${ dateString (b) } より前` : '']
.filter (Boolean)
.join (' '))
|| '不明'
+14 -4
ファイルの表示
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
@@ -11,7 +12,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchPostChanges } from '@/lib/posts' import { fetchPostChanges } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils' import { cn, dateString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -77,10 +78,17 @@ export default (() => {
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r" <td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
rowSpan={rowsCnt}> rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.post.id }`}> <PrefetchLink to={`/posts/${ change.post.id }`}>
<motion.div
layoutId={`page-${ change.post.id }`}
transition={{ type: 'spring',
stiffness: 500,
damping: 40,
mass: .5 }}>
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined} <img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
alt={change.post.title || change.post.url} alt={change.post.title || change.post.url}
title={change.post.title || change.post.url || undefined} title={change.post.title || change.post.url || undefined}
className="w-40"/> className="w-40"/>
</motion.div>
</PrefetchLink> </PrefetchLink>
</td>)} </td>)}
<td className="p-2"> <td className="p-2">
@@ -88,12 +96,14 @@ export default (() => {
{`${ change.changeType === 'add' ? '記載' : '消除' }`} {`${ change.changeType === 'add' ? '記載' : '消除' }`}
</td> </td>
<td className="p-2"> <td className="p-2">
{change.user ? ( {change.user
? (
<PrefetchLink to={`/users/${ change.user.id }`}> <PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name} {change.user.name}
</PrefetchLink>) : 'bot 操作'} </PrefetchLink>)
: 'bot 操作'}
<br/> <br/>
{change.timestamp} {dateString (change.timestamp)}
</td> </td>
</tr>) </tr>)
})} })}
+7 -2
ファイルの表示
@@ -35,9 +35,14 @@ export default (() => {
const page = Number (query.get ('page') ?? 1) const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20) const limit = Number (query.get ('limit') ?? 20)
const keys = {
tags: tagsKey, match, page, limit,
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
const { data, isLoading: loading } = useQuery ({ const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }), queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) }) queryFn: () => fetchPosts (keys) })
const posts = data?.posts ?? [] const posts = data?.posts ?? []
const cursor = '' const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
+163 -39
ファイルの表示
@@ -1,20 +1,29 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import TagSearchBox from '@/components/TagSearchBox'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
import type { FetchPostsOrder,
FetchPostsOrderField,
FetchPostsParams,
Tag } from '@/types'
const setIf = (qs: URLSearchParams, k: string, v: string | null) => { const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
@@ -35,38 +44,43 @@ export default (() => {
const page = Number (query.get ('page') ?? 1) const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20) const limit = Number (query.get ('limit') ?? 20)
const qURL = query.get ('url') const qURL = query.get ('url') ?? ''
const qTitle = query.get ('title') const qTitle = query.get ('title') ?? ''
const qTags = query.get ('tags') ?? '' const qTags = query.get ('tags') ?? ''
const qMatch: 'all' | 'any' = query.get ('match') === 'any' ? 'any' : 'all' const qMatch: 'all' | 'any' = query.get ('match') === 'any' ? 'any' : 'all'
const qOriginalCreatedFrom = query.get ('original_created_from') const qOriginalCreatedFrom = query.get ('original_created_from') ?? ''
const qOriginalCreatedTo = query.get ('original_created_to') const qOriginalCreatedTo = query.get ('original_created_to') ?? ''
const qCreatedFrom = query.get ('created_from') const qCreatedFrom = query.get ('created_from') ?? ''
const qCreatedTo = query.get ('created_to') const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
const [createdFrom, setCreatedFrom] = useState (qCreatedFrom) const [activeIndex, setActiveIndex] = useState (-1)
const [createdTo, setCreatedTo] = useState (qCreatedTo) const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [matchType, setMatchType] = useState (qMatch ?? 'all') const [createdTo, setCreatedTo] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] = useState (qOriginalCreatedFrom) const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
const [originalCreatedTo, setOriginalCreatedTo] = useState (qOriginalCreatedTo) const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [tagsStr, setTagsStr] = useState (qTags) const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
const [title, setTitle] = useState (qTitle ?? '') const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [updatedFrom, setUpdatedFrom] = useState (qUpdatedFrom) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const [updatedTo, setUpdatedTo] = useState (qUpdatedTo) const [tagsStr, setTagsStr] = useState ('')
const [url, setURL] = useState (qURL ?? '') const [title, setTitle] = useState ('')
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
const [url, setURL] = useState ('')
const keys = { const keys: FetchPostsParams = {
tags: qTags, match: qMatch, page, limit, tags: qTags, match: qMatch, page, limit,
...(qURL && { url: qURL }), url: qURL,
...(qTitle && { title: qTitle }), title: qTitle,
...(qOriginalCreatedFrom && { original_created_from: qOriginalCreatedFrom }), originalCreatedFrom: qOriginalCreatedFrom,
...(qOriginalCreatedTo && { original_created_to: qOriginalCreatedTo }), originalCreatedTo: qOriginalCreatedTo,
...(qCreatedFrom && { created_from: qCreatedFrom }), createdFrom: qCreatedFrom,
...(qCreatedTo && { created_to: qCreatedTo }), createdTo: qCreatedTo,
...(qUpdatedFrom && { updated_from: qUpdatedFrom }), updatedFrom: qUpdatedFrom,
...(qUpdatedTo && { updated_to: qUpdatedTo }) } updatedTo: qUpdatedTo,
order }
const { data, isLoading: loading } = useQuery ({ const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys), queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) }) queryFn: () => fetchPosts (keys) })
@@ -88,6 +102,80 @@ export default (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search]) }, [location.search])
const SortHeader = ({ by, label }: { by: FetchPostsOrderField; label: string }) => {
const [fld, dir] = order.split (':')
const qs = new URLSearchParams (location.search)
const nextDir =
(by === fld)
? (dir === 'asc' ? 'desc' : 'asc')
: (['title', 'url'].includes (by) ? 'asc' : 'desc')
qs.set ('order', `${ by }:${ nextDir }`)
qs.set ('page', '1')
return (
<PrefetchLink
className="text-inherit visited:text-inherit hover:text-inherit"
to={`${ location.pathname }?${ qs.toString () }`}>
<span className="font-bold">
{label}
{by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
</span>
</PrefetchLink>)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setTagsStr (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break
case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}
const search = async () => { const search = async () => {
const qs = new URLSearchParams () const qs = new URLSearchParams ()
setIf (qs, 'tags', tagsStr) setIf (qs, 'tags', tagsStr)
@@ -101,9 +189,19 @@ export default (() => {
setIf (qs, 'updated_to', updatedTo) setIf (qs, 'updated_to', updatedTo)
qs.set ('match', matchType) qs.set ('match', matchType)
qs.set ('page', String ('1')) qs.set ('page', String ('1'))
qs.set ('order', order)
navigate (`${ location.pathname }?${ qs.toString () }`) navigate (`${ location.pathname }?${ qs.toString () }`)
} }
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = tagsStr.split (' ')
parts[parts.length - 1] = tag.name
setTagsStr (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}
const handleSearch = (e: FormEvent) => { const handleSearch = (e: FormEvent) => {
e.preventDefault () e.preventDefault ()
search () search ()
@@ -140,13 +238,21 @@ export default (() => {
</div> </div>
{/* タグ */} {/* タグ */}
<div> <div className="relative">
<Label></Label> <Label></Label>
<input <input
type="text" type="text"
value={tagsStr} value={tagsStr}
onChange={e => setTagsStr (e.target.value)} onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/> className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
<fieldset className="w-full my-2"> <fieldset className="w-full my-2">
<label></label> <label></label>
<label className="mx-2"> <label className="mx-2">
@@ -224,7 +330,7 @@ export default (() => {
<col className="w-72"/> <col className="w-72"/>
<col className="w-80"/> <col className="w-80"/>
<col className="w-[24rem]"/> <col className="w-[24rem]"/>
<col className="w-44"/> <col className="w-60"/>
<col className="w-44"/> <col className="w-44"/>
<col className="w-44"/> <col className="w-44"/>
</colgroup> </colgroup>
@@ -232,12 +338,22 @@ export default (() => {
<thead className="border-b-2 border-black dark:border-white"> <thead className="border-b-2 border-black dark:border-white">
<tr> <tr>
<th className="p-2 text-left whitespace-nowrap">稿</th> <th className="p-2 text-left whitespace-nowrap">稿</th>
<th className="p-2 text-left whitespace-nowrap"></th> <th className="p-2 text-left whitespace-nowrap">
<th className="p-2 text-left whitespace-nowrap">URL</th> <SortHeader by="title" label="タイトル"/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="url" label="URL"/>
</th>
<th className="p-2 text-left whitespace-nowrap"></th> <th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap">稿</th> <th className="p-2 text-left whitespace-nowrap">
<th className="p-2 text-left whitespace-nowrap">稿</th> <SortHeader by="original_created_at" label="オリジナルの投稿日時"/>
<th className="p-2 text-left whitespace-nowrap"></th> </th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="created_at" label="投稿日時"/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader by="updated_at" label="更新日時"/>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -245,10 +361,17 @@ export default (() => {
<tr key={row.id} className={'even:bg-gray-100 dark:even:bg-gray-700'}> <tr key={row.id} className={'even:bg-gray-100 dark:even:bg-gray-700'}>
<td className="p-2"> <td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}> <PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
<motion.div
layoutId={`page-${ row.id }`}
transition={{ type: 'spring',
stiffness: 500,
damping: 40,
mass: .5 }}>
<img src={row.thumbnail || row.thumbnailBase || undefined} <img src={row.thumbnail || row.thumbnailBase || undefined}
alt={row.title || row.url} alt={row.title || row.url}
title={row.title || row.url || undefined} title={row.title || row.url || undefined}
className="w-8"/> className="w-8"/>
</motion.div>
</PrefetchLink> </PrefetchLink>
</td> </td>
<td className="p-2 truncate"> <td className="p-2 truncate">
@@ -271,10 +394,11 @@ export default (() => {
</span>))} </span>))}
</td> </td>
<td className="p-2"> <td className="p-2">
{row.originalCreatedFrom} {row.originalCreatedBefore} {originalCreatedAtString (row.originalCreatedFrom,
row.originalCreatedBefore)}
</td> </td>
<td className="p-2">{row.createdAt}</td> <td className="p-2">{dateString (row.createdAt)}</td>
<td className="p-2">{row.updatedAt}</td> <td className="p-2">{dateString (row.updatedAt)}</td>
</tr>))} </tr>))}
</tbody> </tbody>
</table> </table>
+7 -2
ファイルの表示
@@ -44,10 +44,15 @@ export default () => {
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) }) queryFn: () => fetchTagByName (effectiveTitle) })
const keys = {
tags: effectiveTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
const { data } = useQuery ({ const { data } = useQuery ({
enabled: Boolean (effectiveTitle) && !(version), enabled: Boolean (effectiveTitle) && !(version),
queryKey: postsKeys.index ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }), queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }) }) queryFn: () => fetchPosts (keys) })
const posts = data?.posts || [] const posts = data?.posts || []
useEffect (() => { useEffect (() => {
+2 -1
ファイルの表示
@@ -7,6 +7,7 @@ import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'
import type { WikiPageChange } from '@/types' import type { WikiPageChange } from '@/types'
@@ -65,7 +66,7 @@ export default () => {
{change.user.name} {change.user.name}
</PrefetchLink> </PrefetchLink>
<br/> <br/>
{change.timestamp} {dateString (change.timestamp)}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>
+2 -1
ファイルの表示
@@ -6,6 +6,7 @@ import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
@@ -84,7 +85,7 @@ export default () => {
</PrefetchLink> </PrefetchLink>
</td> </td>
<td className="p-2"> <td className="p-2">
{page.updatedAt} {dateString (page.updatedAt)}
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>
+23 -1
ファイルの表示
@@ -1,9 +1,31 @@
import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' import { CATEGORIES,
FETCH_POSTS_ORDER_FIELDS,
USER_ROLES,
ViewFlagBehavior } from '@/consts'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export type Category = typeof CATEGORIES[number] export type Category = typeof CATEGORIES[number]
export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }`
export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number]
export type FetchPostsParams = {
url: string
title: string
tags: string
match: 'all' | 'any'
originalCreatedFrom: string
originalCreatedTo: string
createdFrom: string
createdTo: string
updatedFrom: string
updatedTo: string
page: number
limit: number
order: FetchPostsOrder }
export type Menu = MenuItem[] export type Menu = MenuItem[]
export type MenuItem = { export type MenuItem = {