コミットを比較

...

32 コミット

作成者 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
みてるぞ 7d554e3950 #206 2026-02-26 23:53:08 +09:00
みてるぞ 4b447e952b #206 2026-02-26 23:33:09 +09:00
みてるぞ c3aa8bc580 #206 2026-02-26 12:43:31 +09:00
みてるぞ 663206c14c #206 2026-02-26 06:48:28 +09:00
みてるぞ 3e5eb4687b #206 2026-02-25 00:56:37 +09:00
みてるぞ 2a412b589f Merge remote-tracking branch 'origin/main' into feature/206 2026-02-24 21:29:36 +09:00
みてるぞ 7d1a87f452 表のレーアウト統一(#194) (#271)
#194

#194

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #271
2026-02-24 21:29:00 +09:00
みてるぞ 498f215538 #206 2026-02-23 18:52:09 +09:00
みてるぞ a0d6aeb91e タグ補完ウィンドウ(#103) (#269)
#103

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

#103

#103 タグ補完からニコタグ除外

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #269
2026-02-22 23:32:01 +09:00
みてるぞ be983e4ad1 オリジナルの投稿日時 Safari でのバグ修正(#129) (#265)
Merge branch 'main' into feature/129

#129

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #265
2026-02-22 02:15:07 +09:00
みてるぞ 82302cd3d1 Representations 追加(#241) (#267)
#241

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #267
2026-02-22 01:50:56 +09:00
みてるぞ a01c63d972 PostEmbed の安全性強化(#130) (#263)
#130

#130

#130

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #263
2026-02-13 12:39:51 +09:00
みてるぞ 16bfc6eba2 プレフィクスのバグ修正(#124) (#261)
#124

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #261
2026-02-11 17:42:34 +09:00
みてるぞ 67c2590f2b 既存タグ名と同名の Wiki を新規作成できないバグ修正(#257) (#260)
#257

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

#257

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #260
2026-02-11 15:22:49 +09:00
47個のファイルの変更1698行の追加283行の削除
+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
+4 -6
ファイルの表示
@@ -1,6 +1,4 @@
class NicoTagsController < ApplicationController
TAG_JSON = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze
def index
limit = (params[:limit] || 20).to_i
cursor = params[:cursor].presence
@@ -19,15 +17,15 @@ class NicoTagsController < ApplicationController
end
render json: { tags: tags.map { |tag|
tag.as_json(TAG_JSON).merge(linked_tags: tag.linked_tags.map { |lt|
lt.as_json(TAG_JSON)
TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt|
TagRepr.base(lt)
})
}, next_cursor: }
end
def update
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
@@ -41,6 +39,6 @@ class NicoTagsController < ApplicationController
tag.linked_tags = linked_tags
tag.save!
render json: tag.linked_tags.map { |t| t.as_json(TAG_JSON) }, status: :ok
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
end
end
+78 -43
ファイルの表示
@@ -2,42 +2,88 @@ class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
def index
url = params[:url].presence
title = params[:title].presence
original_created_from = params[:original_created_from].presence
original_created_to = params[:original_created_to].presence
created_between = params[:created_from].presence, params[:created_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
limit = (params[:limit].presence || 20).to_i
cursor = params[:cursor].presence
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
sort_sql =
'COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' +
'posts.original_created_from,' +
'posts.created_at)'
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 =
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 })
.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 cursor && posts.length > limit
next_cursor = posts.last.read_attribute('sort_ts').iso8601(6)
posts = posts.first(limit)
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
q = q.where('posts.title LIKE ?', "%#{ title }%") if title
if original_created_from
q = q.where('posts.original_created_before > ?', original_created_from)
end
if original_created_to
q = q.where('posts.original_created_from <= ?', original_created_to)
end
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]
if updated_between[0]
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 =
case order[0]
when 'original_created_at'
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' +
'posts.original_created_from,' +
'posts.created_at) '
when 'updated_at'
updated_at_all_sql
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|
post.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } }).tap do |json|
PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
json['thumbnail'] =
if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -45,11 +91,7 @@ class PostsController < ApplicationController
nil
end
end
}, count: if filtered_posts.group_values.present?
filtered_posts.count.size
else
filtered_posts.count
end, next_cursor: }
}, count: q.group_values.present? ? q.count.size : q.count }
end
def random
@@ -60,14 +102,11 @@ class PostsController < ApplicationController
viewed = current_user&.viewed?(post) || false
render json: (post
.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } })
.merge(viewed:))
render json: PostRepr.base(post).merge(viewed:)
end
def show
post = Post.includes(tags: { tag_name: :wiki_page }).find(params[:id])
post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id])
return head :not_found unless post
viewed = current_user&.viewed?(post) || false
@@ -82,13 +121,13 @@ class PostsController < ApplicationController
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.member?
return head :forbidden unless current_user.gte_member?
# TODO: サイトに応じて thumbnail_base 設定
title = params[:title].presence
url = params[:url]
thumbnail = params[:thumbnail]
tag_names = params[:tags].to_s.split(' ')
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
@@ -102,9 +141,7 @@ class PostsController < ApplicationController
sync_post_tags!(post, tags)
post.reload
render json: post.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } }),
status: :created
render json: PostRepr.base(post), status: :created
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
@@ -128,10 +165,10 @@ class PostsController < ApplicationController
def update
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
tag_names = params[:tags].to_s.split(' ')
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
@@ -170,7 +207,7 @@ class PostsController < ApplicationController
events = []
pts.each do |pt|
tag = pt.tag.as_json(only: [:id, :category], methods: [:name, :has_wiki])
tag = TagRepr.base(pt.tag)
post = pt.post
events << Event.new(
@@ -198,7 +235,7 @@ class PostsController < ApplicationController
private
def filtered_posts
tag_names = params[:tags].to_s.split(' ')
tag_names = params[:tags].to_s.split
match_type = params[:match]
if tag_names.present?
filter_posts_by_tags(tag_names, match_type)
@@ -269,8 +306,7 @@ class PostsController < ApplicationController
return nil unless tag
if path.include?(tag_id)
return tag.as_json(only: [:id, :category, :post_count],
methods: [:name, :has_wiki]).merge(children: [])
return TagRepr.base(tag).merge(children: [])
end
if memo.key?(tag_id)
@@ -282,8 +318,7 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = tag.as_json(only: [:id, :category, :post_count],
methods: [:name, :has_wiki]).merge(children:)
memo[tag_id] = TagRepr.base(tag).merge(children:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
+27 -7
ファイルの表示
@@ -13,7 +13,7 @@ class TagsController < ApplicationController
tags = tags.where(posts: { id: post_id })
end
render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
render json: TagRepr.base(tags)
end
def autocomplete
@@ -57,8 +57,7 @@ class TagsController < ApplicationController
tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a
render json: tags.map { |tag|
tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
.merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
end
@@ -67,7 +66,7 @@ class TagsController < ApplicationController
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
if tag
render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
render json: TagRepr.base(tag)
else
head :not_found
end
@@ -81,15 +80,36 @@ class TagsController < ApplicationController
.includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: })
if tag
render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
render json: TagRepr.base(tag)
else
head :not_found
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
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
category = params[:category].presence
@@ -104,6 +124,6 @@ class TagsController < ApplicationController
tag.update!(category:)
end
render json: tag.as_json(methods: [:name])
render json: TagRepr.base(tag)
end
end
+10 -13
ファイルの表示
@@ -4,14 +4,12 @@ class WikiPagesController < ApplicationController
def index
title = params[:title].to_s.strip
if title.blank?
return render json: WikiPage.joins(:tag_name)
.includes(:tag_name)
.as_json(methods: [:title])
return render json: WikiPageRepr.base(WikiPage.joins(:tag_name).includes(:tag_name))
end
q = WikiPage.joins(:tag_name).includes(:tag_name)
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
render json: q.limit(20).as_json(methods: [:title])
render json: WikiPageRepr.base(q.limit(20))
end
def show
@@ -85,20 +83,20 @@ class WikiPagesController < ApplicationController
def create
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
name = 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)
return head :unprocessable_entity if name.blank? || body.blank?
tag_name = TagName.find_or_create_by!(name:)
page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user)
if page.save
message = params[:message].presence
Wiki::Commit.content!(page:, body:, created_user: current_user, message:)
render json: page.as_json(methods: [:title]), status: :created
render json: WikiPageRepr.base(page), status: :created
else
render json: { errors: page.errors.full_messages },
status: :unprocessable_entity
@@ -107,7 +105,7 @@ class WikiPagesController < ApplicationController
def update
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
body = params[:body].to_s
@@ -174,8 +172,7 @@ class WikiPagesController < ApplicationController
succ = page.succ_revision_id(revision_id)
updated_at = rev.created_at
render json: page.as_json(methods: [:title])
.merge(body:, revision_id:, pred:, succ:, updated_at:)
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
end
def find_revision page
+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
+66 -18
ファイルの表示
@@ -23,6 +23,10 @@ class Tag < ApplicationRecord
has_many :parents, through: :reversed_tag_implications, source: :parent_tag
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
delegate :wiki_page, to: :tag_name
@@ -42,24 +46,27 @@ class Tag < ApplicationRecord
validate :nico_tag_name_must_start_with_nico
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 = {
'gen:' => :general,
'djk:' => :deerjikist,
'meme:' => :meme,
'chr:' => :character,
'mtr:' => :material,
'meta:' => :meta }.freeze
'general:' => :general,
'gen:' => :general,
'deerjikist:' => :deerjikist,
'djk:' => :deerjikist,
'meme:' => :meme,
'character:' => :character,
'chr:' => :character,
'material:' => :material,
'mtr:' => :material,
'meta:' => :meta }.freeze
def name= val
(self.tag_name ||= build_tag_name).name = val
end
def has_wiki
wiki_page.present?
end
def has_wiki = wiki_page.present?
def self.tagme
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
@@ -69,6 +76,10 @@ class Tag < ApplicationRecord
@bot ||= find_or_create_by_tag_name!('bot操作', category: :meta)
end
def self.no_deerjikist
@no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
end
def self.video
@video ||= find_or_create_by_tag_name!('動画', category: :meta)
end
@@ -78,21 +89,20 @@ class Tag < ApplicationRecord
end
def self.normalise_tags tag_names, with_tagme: true, deny_nico: true
if deny_nico && tag_names.any? { |n| n.start_with?('nico:') }
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
tags = tag_names.map do |name|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.delete_prefix(pf)).first
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if cat && tag.category != cat
tag.update!(category: cat)
end
tag.update!(category: cat) if cat && tag.category != cat
end
end
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)
end
@@ -130,12 +140,44 @@ class Tag < ApplicationRecord
retry
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
def nico_tag_name_must_start_with_nico
n = name.to_s
if ((category == 'nico' && !(n.start_with?('nico:'))) ||
(category != 'nico' && n.start_with?('nico:')))
if ((nico? && !(n.downcase.start_with?('nico:'))) ||
(!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end
end
@@ -145,4 +187,10 @@ class Tag < ApplicationRecord
errors.add :tag_name, 'tag_names へは実体を示す必要があります.'
end
end
def category_must_be_deerjikist_with_deerjikists
if !(deerjikist?) && deerjikists.exists?
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
end
end
end
+9 -15
ファイルの表示
@@ -1,29 +1,23 @@
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 :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys }
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 :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy
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 :updated_wiki_pages, class_name: 'WikiPage', foreign_key: 'updated_user_id', dependent: :nullify
has_many :created_wiki_pages,
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
user_post_views.exists? post_id: post.id
end
def member?
['member', 'admin'].include?(role)
end
def admin?
role == 'admin'
end
def viewed?(post) = user_post_views.exists?(post_id: post.id)
def gte_member? = member? || admin?
end
+1 -1
ファイルの表示
@@ -8,7 +8,7 @@ class WikiLine < ApplicationRecord
sha = Digest::SHA256.hexdigest(body)
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)
end
+6 -13
ファイルの表示
@@ -14,17 +14,13 @@ class WikiPage < ApplicationRecord
belongs_to :tag_name
validates :tag_name, presence: true
def title
tag_name.name
end
def title = tag_name.name
def title= val
(self.tag_name ||= build_tag_name).name = val
end
def current_revision
wiki_revisions.order(id: :desc).first
end
def current_revision = wiki_revisions.order(id: :desc).first
def body
rev = current_revision
@@ -49,11 +45,8 @@ class WikiPage < ApplicationRecord
page
end
def pred_revision_id revision_id
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
end
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
def pred_revision_id(revision_id) =
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
def succ_revision_id(revision_id) =
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
+1 -1
ファイルの表示
@@ -7,7 +7,7 @@ class WikiRevision < ApplicationRecord
has_many :wiki_revision_lines, dependent: :delete_all
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 :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
+16
ファイルの表示
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module PostRepr
BASE = { include: { tags: TagRepr::BASE } }.freeze
module_function
def base post
post.as_json(BASE)
end
def many posts
posts.map { |p| base(p) }
end
end
+16
ファイルの表示
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module TagRepr
BASE = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze
module_function
def base tag
tag.as_json(BASE)
end
def many tags
tags.map { |t| base(t) }
end
end
+16
ファイルの表示
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module WikiPageRepr
BASE = { methods: [:title] }.freeze
module_function
def base wiki_page
wiki_page.as_json(BASE)
end
def many wiki_pages
wiki_pages.map { |p| base(p) }
end
end
+19 -1
ファイルの表示
@@ -9,7 +9,15 @@ Rails.application.routes.draw do
resources :tags, only: [:index, :show, :update] do
collection do
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
@@ -54,4 +62,14 @@ Rails.application.routes.draw do
post 'code/renew', action: :renew
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
+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.
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|
t.string "name", 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
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|
t.binary "ip_address", limit: 16, null: false
t.boolean "banned", default: false, null: false
+46 -26
ファイルの表示
@@ -2,9 +2,9 @@ namespace :nico do
desc 'ニコニコ DB 同期'
task sync: :environment do
require 'json'
require 'open3'
require 'open-uri'
require 'nokogiri'
require 'open-uri'
require 'open3'
require 'set'
require 'time'
@@ -15,12 +15,12 @@ namespace :nico do
doc.at('meta[name="thumbnail"]')&.[]('content').presence
end
def sync_post_tags! post, desired_tag_ids, current_ids: nil
current_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_ids = desired_tag_ids.compact.to_set
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_ids - current_ids
to_remove = current_ids - desired_ids
to_add = desired_tag_ids - current_tag_ids
to_remove = current_tag_ids - desired_tag_ids
Tag.where(id: to_add.to_a).find_each do |tag|
begin
@@ -42,12 +42,14 @@ namespace :nico do
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', "#{ nizika_nico_path }/get_videos.py")
abort unless status.success?
unless status.success?
warn stderr
abort
end
data = JSON.parse(stdout)
data.each do |datum|
code = datum['code']
post =
Post
.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])
end
kept_ids = PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
kept_non_nico_ids = post.tags.where.not(category: 'nico').pluck(:id).to_set
tags = post.tags
# 既存のタグ 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|
name = "nico:#{ raw }"
tag = Tag.find_or_create_by_tag_name!(name, category: 'nico')
desired_nico_ids << tag.id
unless tag.id.in?(kept_ids)
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
desired_nico_tag_based_ids << tag.id
# 新たに記載される外部タグと連携される内部タグを記載
unless tag.id.in?(kept_tag_ids)
linked_ids = tag.linked_tags.pluck(:id)
desired_non_nico_ids.concat(linked_ids)
desired_nico_ids.concat(linked_ids)
desired_non_nico_tag_ids.concat(linked_ids)
desired_nico_tag_based_ids.concat(linked_ids)
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
deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
if deerjikist
desired_non_nico_tag_ids << deerjikist.tag_id
desired_nico_tag_based_ids << deerjikist.tag_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
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
+2 -2
ファイルの表示
@@ -1,11 +1,11 @@
FactoryBot.define do
factory :tag do
category { 'general' }
category { :general }
post_count { 0 }
association :tag_name
trait :nico do
category { 'nico' }
category { :nico }
tag_name { association(:tag_name, name: "nico:#{ SecureRandom.hex(4) }") }
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
+200 -1
ファイルの表示
@@ -1,7 +1,8 @@
include ActiveSupport::Testing::TimeHelpers
require 'rails_helper'
require 'set'
RSpec.describe 'Posts API', type: :request do
# create / update で thumbnail.attach は走るが、
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
@@ -114,6 +115,204 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('count')).to eq(0)
end
end
context 'when url is provided' do
let!(:url_hit_post) do
Post.create!(uploaded_user: user, title: 'url hit',
url: 'https://example.com/needle-url-xyz').tap do |p|
PostTag.create!(post: p, tag:)
end
end
let!(:url_miss_post) do
Post.create!(uploaded_user: user, title: 'url miss',
url: 'https://example.com/other-url').tap do |p|
PostTag.create!(post: p, tag:)
end
end
it 'filters posts by url substring' do
get '/posts', params: { url: 'needle-url-xyz' }
expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }
expect(ids).to include(url_hit_post.id)
expect(ids).not_to include(url_miss_post.id)
expect(json.fetch('count')).to eq(1)
end
end
context 'when title is provided' do
let!(:title_hit_post) do
Post.create!(uploaded_user: user, title: 'needle-title-xyz',
url: 'https://example.com/title-hit').tap do |p|
PostTag.create!(post: p, tag:)
end
end
let!(:title_miss_post) do
Post.create!(uploaded_user: user, title: 'other title',
url: 'https://example.com/title-miss').tap do |p|
PostTag.create!(post: p, tag:)
end
end
it 'filters posts by title substring' do
get '/posts', params: { title: 'needle-title-xyz' }
expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }
expect(ids).to include(title_hit_post.id)
expect(ids).not_to include(title_miss_post.id)
expect(json.fetch('count')).to eq(1)
end
end
context 'when created_from/created_to are provided' do
let(:t_created_hit) { Time.zone.local(2010, 1, 5, 12, 0, 0) }
let(:t_created_miss) { Time.zone.local(2012, 1, 5, 12, 0, 0) }
let!(:created_hit_post) do
travel_to(t_created_hit) do
Post.create!(uploaded_user: user, title: 'created hit',
url: 'https://example.com/created-hit').tap do |p|
PostTag.create!(post: p, tag:)
end
end
end
let!(:created_miss_post) do
travel_to(t_created_miss) do
Post.create!(uploaded_user: user, title: 'created miss',
url: 'https://example.com/created-miss').tap do |p|
PostTag.create!(post: p, tag:)
end
end
end
it 'filters posts by created_at range' do
get '/posts', params: {
created_from: Time.zone.local(2010, 1, 1, 0, 0, 0).iso8601,
created_to: Time.zone.local(2010, 12, 31, 23, 59, 59).iso8601
}
expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }
expect(ids).to include(created_hit_post.id)
expect(ids).not_to include(created_miss_post.id)
expect(json.fetch('count')).to eq(1)
end
end
context 'when updated_from/updated_to are provided' do
let(:t0) { Time.zone.local(2011, 2, 1, 12, 0, 0) }
let(:t1) { Time.zone.local(2011, 2, 10, 12, 0, 0) }
let!(:updated_hit_post) do
p = nil
travel_to(t0) do
p = Post.create!(uploaded_user: user, title: 'updated hit',
url: 'https://example.com/updated-hit').tap do |pp|
PostTag.create!(post: pp, tag:)
end
end
travel_to(t1) do
p.update!(title: 'updated hit v2')
end
p
end
let!(:updated_miss_post) do
travel_to(Time.zone.local(2013, 1, 1, 12, 0, 0)) do
Post.create!(uploaded_user: user, title: 'updated miss',
url: 'https://example.com/updated-miss').tap do |p|
PostTag.create!(post: p, tag:)
end
end
end
it 'filters posts by updated_at range' do
get '/posts', params: {
updated_from: Time.zone.local(2011, 2, 5, 0, 0, 0).iso8601,
updated_to: Time.zone.local(2011, 2, 20, 23, 59, 59).iso8601
}
expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }
expect(ids).to include(updated_hit_post.id)
expect(ids).not_to include(updated_miss_post.id)
expect(json.fetch('count')).to eq(1)
end
end
context 'when original_created_from/original_created_to are provided' do
# 注意: controller の現状ロジックに合わせてる
# original_created_from は `original_created_before > ?`
# original_created_to は `original_created_from <= ?`
let!(:oc_hit_post) do
Post.create!(uploaded_user: user, title: 'oc hit',
url: 'https://example.com/oc-hit',
original_created_from: Time.zone.local(2015, 1, 1, 0, 0, 0),
original_created_before: Time.zone.local(2015, 1, 10, 0, 0, 0)).tap do |p|
PostTag.create!(post: p, tag:)
end
end
# original_created_from の条件は「original_created_before > param」なので、
# before が param 以下になるようにする(ただし before >= from は守る)
let!(:oc_miss_post_for_from) do
Post.create!(
uploaded_user: user,
title: 'oc miss for from',
url: 'https://example.com/oc-miss-from',
original_created_from: Time.zone.local(2014, 12, 1, 0, 0, 0),
original_created_before: Time.zone.local(2015, 1, 1, 0, 0, 0)
).tap { |p| PostTag.create!(post: p, tag:) }
end
# original_created_to の条件は「original_created_from <= param」なので、
# from が param より後になるようにする(before >= from は守る)
let!(:oc_miss_post_for_to) do
Post.create!(
uploaded_user: user,
title: 'oc miss for to',
url: 'https://example.com/oc-miss-to',
original_created_from: Time.zone.local(2015, 2, 1, 0, 0, 0),
original_created_before: Time.zone.local(2015, 2, 10, 0, 0, 0)
).tap { |p| PostTag.create!(post: p, tag:) }
end
it 'filters posts by original_created_from (current controller behavior)' do
get '/posts', params: {
original_created_from: Time.zone.local(2015, 1, 5, 0, 0, 0).iso8601
}
expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }
expect(ids).to include(oc_hit_post.id)
expect(ids).not_to include(oc_miss_post_for_from.id)
expect(json.fetch('count')).to eq(2)
end
it 'filters posts by original_created_to (current controller behavior)' do
get '/posts', params: {
original_created_to: Time.zone.local(2015, 1, 15, 0, 0, 0).iso8601
}
expect(response).to have_http_status(:ok)
ids = json.fetch('posts').map { |p| p['id'] }
expect(ids).to include(oc_hit_post.id)
expect(ids).not_to include(oc_miss_post_for_to.id)
expect(json.fetch('count')).to eq(2)
end
end
end
describe 'GET /posts/:id' do
-1
ファイルの表示
@@ -1,4 +1,3 @@
# spec/requests/tag_children_spec.rb
require "rails_helper"
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
before do
allow(member_user).to receive(:member?).and_return(true)
allow(non_member_user).to receive(:member?).and_return(false)
allow(member_user).to receive(:gte_member?).and_return(true)
allow(non_member_user).to receive(:gte_member?).and_return(false)
end
describe "PATCH /tags/:id" do
+2
ファイルの表示
@@ -16,6 +16,7 @@ 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 PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
@@ -42,6 +43,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
+23 -5
ファイルの表示
@@ -18,17 +18,35 @@ export default (({ post }: Props) => {
{
case 'nicovideo.jp':
{
const [videoId] = url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)!
const mVideoId = url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)
if (!(mVideoId))
break
const [videoId] = mVideoId
return <NicoViewer id={videoId} width={640} height={360}/>
}
case 'twitter.com':
case 'x.com':
const [userId] = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)!
const [statusId] = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)!
return <TwitterEmbed userId={userId} statusId={statusId}/>
{
const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)
const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)
if (!(mUserId) || !(mStatusId))
break
const [userId] = mUserId
const [statusId] = mStatusId
return <TwitterEmbed userId={userId} statusId={statusId}/>
}
case 'youtube.com':
{
const videoId = url.searchParams.get ('v')!
const videoId = url.searchParams.get ('v')
if (!(videoId))
break
return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1,
+28 -11
ファイルの表示
@@ -1,3 +1,5 @@
// TODO: TagSearch と共通化する.
import { useRef, useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox'
@@ -25,8 +27,8 @@ const getTokenAt = (value: string, pos: number) => {
}
const replaceToken = (value: string, start: number, end: number, text: string) => (
`${ value.slice (0, start) }${ text }${ value.slice (end) }`)
const replaceToken = (value: string, start: number, end: number, text: string) =>
`${ value.slice (0, start) }${ text }${ value.slice (end) }`
type Props = {
@@ -38,16 +40,17 @@ export default (({ tags, setTags }: Props) => {
const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
const [focused, setFocused] = useState (false)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const handleTagSelect = (tag: Tag) => {
setSuggestionsVsbl (false)
const textarea = ref.current!
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name)
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
setTags (newValue)
requestAnimationFrame (async () => {
const p = bounds.start + tag.name.length
const p = bounds.start + tag.name.length + 1
textarea.selectionStart = textarea.selectionEnd = p
textarea.focus ()
await recompute (p, newValue)
@@ -56,14 +59,21 @@ export default (({ tags, setTags }: Props) => {
const recompute = async (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos)
if (!(token.trim ()))
{
setSuggestionsVsbl (false)
return
}
setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token } })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token, nico: '0' } })
setSuggestions (data.filter (t => t.postCount > 0))
setSuggestionsVsbl (suggestions.length > 0)
}
return (
<div>
<div className="relative w-full">
<Label></Label>
<TextArea
ref={ref}
@@ -72,11 +82,18 @@ export default (({ tags, setTags }: Props) => {
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos)
}}
onFocus={() => setFocused (true)}
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
}}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</div>)
}) satisfies FC<Props>
+47 -28
ファイルの表示
@@ -1,5 +1,6 @@
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import { Button } from '@/components/ui/button'
import type { FC } from 'react'
@@ -16,34 +17,52 @@ export default (({ originalCreatedFrom,
setOriginalCreatedBefore }: Props) => (
<div>
<Label></Label>
<div className="my-1">
<DateTimeField
className="mr-2"
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getSeconds () === 0)
{
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
}
else
d.setSeconds (d.getSeconds () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
<div className="my-1">
<DateTimeField
className="mr-2"
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</div>)) satisfies FC<Props>
+7 -12
ファイルの表示
@@ -19,6 +19,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast'
import { CATEGORIES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
@@ -343,7 +344,7 @@ export default (({ post }: Props) => {
: 'bot操作'}
</li>
*/}
<li>: {(new Date (post.createdAt)).toLocaleString ()}</li>
<li>: {dateString (post.createdAt)}</li>
<li>
<>: </>
<a
@@ -355,20 +356,14 @@ export default (({ post }: Props) => {
</a>
</li>
<li>
{/* TODO: 表示形式きしょすぎるので何とかする */}
<>稿: </>
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
? '不明'
: (
<>
{post.originalCreatedFrom
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
{post.originalCreatedBefore
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
</>)}
{originalCreatedAtString (post.originalCreatedFrom,
post.originalCreatedBefore)}
</li>
<li>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}></PrefetchLink>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>
</PrefetchLink>
</li>
</ul>
</div>)}
+2
ファイルの表示
@@ -1,3 +1,5 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
+2 -1
ファイルの表示
@@ -69,8 +69,9 @@ export default (({ user }: Props) => {
const menu: Menu = [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '投稿追加', to: '/posts/new' },
{ name: '耕作履歴', to: '/posts/changes' },
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: false },
+2 -4
ファイルの表示
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'
import type { FC, FocusEvent } from 'react'
const pad = (n: number) => n.toString ().padStart (2, '0')
const pad = (n: number): string => n.toString ().padStart (2, '0')
const toDateTimeLocalValue = (d: Date) => {
@@ -14,8 +14,7 @@ const toDateTimeLocalValue = (d: Date) => {
const day = pad (d.getDate ())
const h = pad (d.getHours ())
const min = pad (d.getMinutes ())
const s = pad (d.getSeconds ())
return `${ y }-${ m }-${ day }T${ h }:${ min }:${ s }`
return `${ y }-${ m }-${ day }T${ h }:${ min }:00`
}
@@ -37,7 +36,6 @@ export default (({ value, onChange, className, onBlur }: Props) => {
<input
className={cn ('border rounded p-2', className)}
type="datetime-local"
step={1}
value={local}
onChange={ev => {
const v = ev.target.value
+24 -11
ファイルの表示
@@ -3,13 +3,23 @@ import type { Category } from 'types'
export const LIGHT_COLOUR_SHADE = 800
export const DARK_COLOUR_SHADE = 300
export const CATEGORIES = ['deerjikist',
'meme',
'character',
'general',
'material',
'meta',
'nico'] as const
export const CATEGORIES = [
'deerjikist',
'meme',
'character',
'general',
'material',
'meta',
'nico',
] as const
export const FETCH_POSTS_ORDER_FIELDS = [
'title',
'url',
'original_created_at',
'created_at',
'updated_at',
] as const
export const TAG_COLOUR = {
deerjikist: 'rose',
@@ -18,10 +28,13 @@ export const TAG_COLOUR = {
general: 'cyan',
material: 'orange',
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 ViewFlagBehavior = { OnShowedDetail: 1,
OnClickedLink: 2,
NotAuto: 3 } as const
export const ViewFlagBehavior = {
OnShowedDetail: 1,
OnClickedLink: 2,
NotAuto: 3,
} as const
+16 -13
ファイルの表示
@@ -1,25 +1,28 @@
import { apiDelete, apiGet, apiPost } from '@/lib/api'
import type { Post, PostTagChange } from '@/types'
import type { FetchPostsParams, Post, PostTagChange } from '@/types'
export const fetchPosts = async (
{ tags, match, page, limit, cursor }: {
tags: string
match: 'any' | 'all'
page?: number
limit?: number
cursor?: string }
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams
): Promise<{
posts: Post[]
count: number
nextCursor: string }> =>
posts: Post[]
count: number }> =>
await apiGet ('/posts', { params: {
tags,
match,
...(url && { url }),
...(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 }),
...(limit && { limit }),
...(cursor && { cursor }) } })
...(order && { order }) } })
export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`)
+37 -6
ファイルの表示
@@ -8,6 +8,8 @@ import { fetchWikiPage,
fetchWikiPageByTitle,
fetchWikiPages } from '@/lib/wiki'
import type { FetchPostsOrder } from '@/types'
type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id')
@@ -59,7 +61,20 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
if (version)
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 ({
queryKey: postsKeys.index (p),
queryFn: () => fetchPosts (p) })
@@ -68,13 +83,27 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
const prefetchPostsIndex: Prefetcher = async (qc, url) => {
const tags = url.searchParams.get ('tags') ?? ''
const m = url.searchParams.get ('match') === 'any' ? 'any' : 'all'
const qURL = url.searchParams.get ('url') ?? ''
const title = url.searchParams.get ('title') ?? ''
const originalCreatedFrom = url.searchParams.get ('original_created_from') ?? ''
const originalCreatedTo = url.searchParams.get ('original_created_to') ?? ''
const createdFrom = url.searchParams.get ('created_from') ?? ''
const createdTo = url.searchParams.get ('created_to') ?? ''
const updatedFrom = url.searchParams.get ('updated_from') ?? ''
const updatedTo = url.searchParams.get ('updated_to') ?? ''
const m: 'all' | 'any' = url.searchParams.get ('match') === 'any' ? 'any' : 'all'
const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20)
const order = (url.searchParams.get ('order') ?? 'original_created_at:desc') as FetchPostsOrder
const keys = {
tags, match: m, page, limit, url: qURL, title,
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }
await qc.prefetchQuery ({
queryKey: postsKeys.index ({ tags, match: m, page, limit }),
queryFn: () => fetchPosts ({ tags, match: m, page, limit }) })
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
}
@@ -103,8 +132,9 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => {
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
{ test: u => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex },
{ test: u => (!(['/posts/new', '/posts/changes'].includes (u.pathname))
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
run: prefetchPostsIndex },
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
&& Boolean (mPost (u.pathname))),
run: prefetchPostShow },
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
@@ -119,5 +149,6 @@ export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<
const r = routePrefetchers.find (x => x.test (u))
if (!(r))
return
await r.run (qc, u)
}
+3 -2
ファイルの表示
@@ -1,7 +1,8 @@
import type { FetchPostsParams } from '@/types'
export const postsKeys = {
root: ['posts'] as const,
index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) =>
['posts', 'index', p] as const,
index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', id] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
+24 -4
ファイルの表示
@@ -1,6 +1,26 @@
import { clsx, type ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn (...inputs: ClassValue[]) {
return twMerge(clsx(...inputs))
}
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 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 (' '))
|| '不明'
+31 -12
ファイルの表示
@@ -1,4 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
@@ -10,6 +12,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPostChanges } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { cn, dateString } from '@/lib/utils'
import type { FC } from 'react'
@@ -30,6 +33,10 @@ export default (() => {
const changes = data?.changes ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
return (
<MainArea>
<Helmet>
@@ -44,7 +51,7 @@ export default (() => {
{loading ? 'Loading...' : (
<>
<table className="table-auto w-full border-collapse">
<thead>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">稿</th>
<th className="p-2 text-left"></th>
@@ -64,27 +71,39 @@ export default (() => {
++rowsCnt
}
return (
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}
className={cn ('even:bg-gray-100 dark:even:bg-gray-700',
withPost && 'border-t')}>
{withPost && (
<td className="align-top" rowSpan={rowsCnt}>
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.post.id }`}>
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
alt={change.post.title || change.post.url}
title={change.post.title || change.post.url || undefined}
className="w-40"/>
<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}
alt={change.post.title || change.post.url}
title={change.post.title || change.post.url || undefined}
className="w-40"/>
</motion.div>
</PrefetchLink>
</td>)}
<td>
<td className="p-2">
<TagLink tag={change.tag} withWiki={false} withCount={false}/>
{`${ change.changeType === 'add' ? '記載' : '消除' }`}
</td>
<td>
{change.user ? (
<td className="p-2">
{change.user
? (
<PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name}
</PrefetchLink>) : 'bot 操作'}
</PrefetchLink>)
: 'bot 操作'}
<br/>
{change.timestamp}
{dateString (change.timestamp)}
</td>
</tr>)
})}
+8 -3
ファイルの表示
@@ -35,11 +35,16 @@ export default (() => {
const page = Number (query.get ('page') ?? 1)
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 ({
queryKey: postsKeys.index ({ tags: tagsKey, match, page, limit }),
queryFn: () => fetchPosts ({ tags: tagsKey, match, page, limit }) })
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
const posts = data?.posts ?? []
const cursor = data?.nextCursor ?? ''
const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0
useLayoutEffect (() => {
+410
ファイルの表示
@@ -0,0 +1,410 @@
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import TagSearchBox from '@/components/TagSearchBox'
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils'
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 t = v?.trim ()
if (t)
qs.set (k, t)
}
export default (() => {
const location = useLocation ()
const navigate = useNavigate ()
const query = useMemo (() => new URLSearchParams (location.search),
[location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const qURL = query.get ('url') ?? ''
const qTitle = query.get ('title') ?? ''
const qTags = query.get ('tags') ?? ''
const qMatch: 'all' | 'any' = query.get ('match') === 'any' ? 'any' : 'all'
const qOriginalCreatedFrom = query.get ('original_created_from') ?? ''
const qOriginalCreatedTo = query.get ('original_created_to') ?? ''
const qCreatedFrom = query.get ('created_from') ?? ''
const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
const [activeIndex, setActiveIndex] = useState (-1)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const [tagsStr, setTagsStr] = useState ('')
const [title, setTitle] = useState ('')
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
const [url, setURL] = useState ('')
const keys: FetchPostsParams = {
tags: qTags, match: qMatch, page, limit,
url: qURL,
title: qTitle,
originalCreatedFrom: qOriginalCreatedFrom,
originalCreatedTo: qOriginalCreatedTo,
createdFrom: qCreatedFrom,
createdTo: qCreatedTo,
updatedFrom: qUpdatedFrom,
updatedTo: qUpdatedTo,
order }
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
const results = data?.posts ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setURL (qURL ?? '')
setTitle (qTitle ?? '')
setTagsStr (qTags ?? '')
setMatchType (qMatch ?? 'all')
setOriginalCreatedFrom (qOriginalCreatedFrom)
setOriginalCreatedTo (qOriginalCreatedTo)
setCreatedFrom (qCreatedFrom)
setCreatedTo (qCreatedTo)
setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [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 qs = new URLSearchParams ()
setIf (qs, 'tags', tagsStr)
setIf (qs, 'url', url)
setIf (qs, 'title', title)
setIf (qs, 'original_created_from', originalCreatedFrom)
setIf (qs, 'original_created_to', originalCreatedTo)
setIf (qs, 'created_from', createdFrom)
setIf (qs, 'created_to', createdTo)
setIf (qs, 'updated_from', updatedFrom)
setIf (qs, 'updated_to', updatedTo)
qs.set ('match', matchType)
qs.set ('page', String ('1'))
qs.set ('order', order)
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) => {
e.preventDefault ()
search ()
}
return (
<MainArea>
<Helmet>
<title> | {SITE_TITLE}</title>
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<div>
<Label></Label>
<input
type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* URL */}
<div>
<Label>URL</Label>
<input
type="text"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* タグ */}
<div className="relative">
<Label></Label>
<input
type="text"
value={tagsStr}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
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">
<label></label>
<label className="mx-2">
<input
type="radio"
name="match-type"
checked={matchType === 'all'}
onChange={() => setMatchType ('all')}/>
AND
</label>
<label className="mx-2">
<input
type="radio"
name="match-type"
checked={matchType === 'any'}
onChange={() => setMatchType ('any')}/>
OR
</label>
</fieldset>
</div>
{/* オリジナルの投稿日時 */}
<div>
<Label>稿</Label>
<DateTimeField
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={originalCreatedTo ?? undefined}
onChange={setOriginalCreatedTo}/>
</div>
{/* 投稿日時 */}
<div>
<Label>稿</Label>
<DateTimeField
value={createdFrom ?? undefined}
onChange={setCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={createdTo ?? undefined}
onChange={setCreatedTo}/>
</div>
{/* 更新日時 */}
<div>
<Label></Label>
<DateTimeField
value={updatedFrom ?? undefined}
onChange={setUpdatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={updatedTo ?? undefined}
onChange={setUpdatedTo}/>
</div>
{/* 検索 */}
<div className="py-3">
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded">
</button>
</div>
</form>
</div>
{loading ? 'Loading...' : (results.length > 0 ? (
<div className="mt-4">
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse">
<colgroup>
<col className="w-14"/>
<col className="w-72"/>
<col className="w-80"/>
<col className="w-[24rem]"/>
<col className="w-60"/>
<col className="w-44"/>
<col className="w-44"/>
</colgroup>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left whitespace-nowrap">稿</th>
<th className="p-2 text-left whitespace-nowrap">
<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">
<SortHeader by="original_created_at" label="オリジナルの投稿日時"/>
</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>
</thead>
<tbody>
{results.map (row => (
<tr key={row.id} className={'even:bg-gray-100 dark:even:bg-gray-700'}>
<td className="p-2">
<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}
alt={row.title || row.url}
title={row.title || row.url || undefined}
className="w-8"/>
</motion.div>
</PrefetchLink>
</td>
<td className="p-2 truncate">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
{row.title}
</PrefetchLink>
</td>
<td className="p-2 truncate">
<a href={row.url}
title={row.url}
target="_blank"
rel="noopener noreferrer nofollow">
{row.url}
</a>
</td>
<td className="p-2">
{row.tags.map (t => (
<span key={t.id} className="mr-2">
<TagLink tag={t} withWiki={false} withCount={false}/>
</span>))}
</td>
<td className="p-2">
{originalCreatedAtString (row.originalCreatedFrom,
row.originalCreatedBefore)}
</td>
<td className="p-2">{dateString (row.createdAt)}</td>
<td className="p-2">{dateString (row.updatedAt)}</td>
</tr>))}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages}/>
</div>) : '結果ないよ(笑)')}
</MainArea>)
}) satisfies FC
+5 -5
ファイルの表示
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import TagLink from '@/components/TagLink'
import SectionTitle from '@/components/common/SectionTitle'
import PageTitle from '@/components/common/PageTitle'
import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
@@ -92,13 +92,13 @@ export default ({ user }: Props) => {
</Helmet>
<div className="max-w-xl">
<SectionTitle></SectionTitle>
<PageTitle></PageTitle>
</div>
<div className="mt-4">
{nicoTags.length > 0 && (
<table className="table-auto w-full border-collapse mb-4">
<thead>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
@@ -107,7 +107,7 @@ export default ({ user }: Props) => {
</thead>
<tbody>
{nicoTags.map ((tag, i) => (
<tr key={i}>
<tr key={i} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<TagLink tag={tag} withWiki={false} withCount={false}/>
</td>
@@ -125,7 +125,7 @@ export default ({ user }: Props) => {
</span>))}
</td>
{memberFlg && (
<td>
<td className="p-2">
<a href="#" onClick={ev => {
ev.preventDefault ()
handleEdit (tag.id)
+7 -2
ファイルの表示
@@ -44,10 +44,15 @@ export default () => {
queryKey: tagsKeys.show (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 ({
enabled: Boolean (effectiveTitle) && !(version),
queryKey: postsKeys.index ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }),
queryFn: () => fetchPosts ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }) })
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
const posts = data?.posts || []
useEffect (() => {
+9 -4
ファイルの表示
@@ -3,9 +3,11 @@ import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'
import type { WikiPageChange } from '@/types'
@@ -28,8 +30,11 @@ export default () => {
<Helmet>
<title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageTitle>Wiki </PageTitle>
<table className="table-auto w-full border-collapse">
<thead>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th></th>
<th className="p-2 text-left"></th>
@@ -39,8 +44,8 @@ export default () => {
</thead>
<tbody>
{changes.map (change => (
<tr key={change.revisionId}>
<td>
<tr key={change.revisionId} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
{change.pred != null && (
<PrefetchLink
to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}>
@@ -61,7 +66,7 @@ export default () => {
{change.user.name}
</PrefetchLink>
<br/>
{change.timestamp}
{dateString (change.timestamp)}
</td>
</tr>))}
</tbody>
+8 -6
ファイルの表示
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink'
import SectionTitle from '@/components/common/SectionTitle'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'
import type { FormEvent } from 'react'
@@ -35,8 +36,9 @@ export default () => {
<Helmet>
<title>Wiki | {SITE_TITLE}</title>
</Helmet>
<div className="max-w-xl">
<SectionTitle>Wiki</SectionTitle>
<PageTitle>Wiki</PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<div>
@@ -68,7 +70,7 @@ export default () => {
<div className="mt-4">
<table className="table-auto w-full border-collapse">
<thead>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
@@ -76,14 +78,14 @@ export default () => {
</thead>
<tbody>
{results.map (page => (
<tr key={page.id}>
<tr key={page.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title}
</PrefetchLink>
</td>
<td className="p-2 text-gray-100 text-sm">
{page.updatedAt}
<td className="p-2">
{dateString (page.updatedAt)}
</td>
</tr>))}
</tbody>
+26 -3
ファイルの表示
@@ -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'
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 MenuItem = {
@@ -25,9 +47,10 @@ export type Post = {
tags: Tag[]
viewed: boolean
related: Post[]
createdAt: string
originalCreatedFrom: string | null
originalCreatedBefore: string | null }
originalCreatedBefore: string | null
createdAt: string
updatedAt: string }
export type PostTagChange = {
post: Post