このコミットが含まれているのは:
2026-07-18 22:32:34 +09:00
コミット 5ff3fc9441
9個のファイルの変更145行の追加82行の削除
+54
ファイルの表示
@@ -140,10 +140,22 @@ npm run preview
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。 99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
- Ruby の method chain や call argument を折り返す際、call-site の `)` - Ruby の method chain や call argument を折り返す際、call-site の `)`
block close のやうに独立させない。 block close のやうに独立させない。
- Ruby では、行末の `\` を用途を問はず一切使用しない。
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
- Ruby の block body は、その基準位置から 2 空白深くする。 - Ruby の block body は、その基準位置から 2 空白深くする。
- Ruby の wrapped expression、method argument、array element、hash pair などの - Ruby の wrapped expression、method argument、array element、hash pair などの
continuation indentation は、その statement の基準位置から 4 空白深くする。 continuation indentation は、その statement の基準位置から 4 空白深くする。
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。 - Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
括弧内ではバックスラッシュなしで改行する。
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
method へ抽出する、のいづれかへ書き換へる。
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白 - 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
深くするため、結果として行頭から 6 空白になる。 深くするため、結果として行頭から 6 空白になる。
@@ -197,6 +209,48 @@ end
Bad: Bad:
```rb
Rails.logger.info(
"post_import_metadata_fetch_failure "\
"#{ payload.to_json }")
```
Good:
```rb
payload = {
error: e.class.name,
message: e.message }
Rails.logger.info(
"post_import_metadata_fetch_failure #{ payload.to_json }")
```
Bad:
```rb
message = "first "\
"second"
```
Good:
```rb
message = format(
'%<first>s %<second>s',
first: 'first',
second: 'second')
```
Bad:
```rb
result = first_value + \
second_value
```
Bad:
```rb ```rb
records.each { records.each {
do_work(_1) } do_work(_1) }
+4 -2
ファイルの表示
@@ -138,8 +138,10 @@ class Post < ApplicationRecord
end end
def self.section_literal section def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-"\ end_ms =
"#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]" section.end_ms ? Post.ms_to_time(section.end_ms) : ''
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
end end
def self.ms_to_time ms def self.ms_to_time ms
+8 -5
ファイルの表示
@@ -106,11 +106,14 @@ module PostRepr
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options) Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
Rails.logger.warn( payload = {
"PostRepr.thumbnail_url failed post_id=#{post.id} " \ post_id: post.id,
"attachment_id=#{post.thumbnail.attachment&.id} " \ attachment_id: post.thumbnail.attachment&.id,
"blob_id=#{post.thumbnail.blob&.id} " \ blob_id: post.thumbnail.blob&.id,
"error_class=#{e.class} message=#{e.message}") error_class: e.class,
message: e.message }
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
nil nil
end end
end end
+8 -8
ファイルの表示
@@ -225,15 +225,15 @@ class PostImportPreviewer
end end
{ data:, warnings:, validation_errors: { } } { data:, warnings:, validation_errors: { } }
rescue Preview::UrlSafety::UnsafeUrl => e rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\ "post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } } { data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue Preview::HttpFetcher::FetchFailed, rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::ResponseTooLarge => e Preview::HttpFetcher::ResponseTooLarge => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_failure "\ "post_import_metadata_fetch_failure #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
@@ -335,14 +335,14 @@ class PostImportPreviewer
def safe_fetch_metadata url def safe_fetch_metadata url
fetch_metadata(url) fetch_metadata(url)
rescue Preview::UrlSafety::UnsafeUrl => e rescue Preview::UrlSafety::UnsafeUrl => e
payload = { error: e.class.name, message: e.message }
Rails.logger.info( Rails.logger.info(
"post_import_metadata_fetch_unsafe_url "\ "post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } } { data: { }, warnings: { }, validation_errors: { url: [e.message] } }
rescue StandardError => e rescue StandardError => e
payload = { error: e.class.name, message: e.message }
Rails.logger.error( Rails.logger.error(
"post_import_metadata_fetch_unexpected_failure "\ "post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
"#{ { error: e.class.name, message: e.message }.to_json }")
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
+3 -4
ファイルの表示
@@ -3,10 +3,9 @@ require 'date'
class PostMetadataFetcher class PostMetadataFetcher
TIMESTAMP_PATTERN = TIMESTAMP_PATTERN =
Regexp.new( /\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \ (?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \ (Z|[+-]\d{2}:?\d{2})?\z/x
'(Z|[+-]\d{2}:?\d{2})?\z')
def self.fetch raw_url def self.fetch raw_url
uri, = Preview::UrlSafety.validate(raw_url) uri, = Preview::UrlSafety.validate(raw_url)
+3 -3
ファイルの表示
@@ -1,4 +1,4 @@
require "active_support/core_ext/integer/time" require 'active_support/core_ext/integer/time'
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
@@ -17,8 +17,8 @@ Rails.application.configure do
# Enable/disable Action Controller caching. By default Action Controller caching is disabled. # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching. # Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join("tmp/caching-dev.txt").exist? if Rails.root.join('tmp/caching-dev.txt').exist?
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
end end
+14 -12
ファイルの表示
@@ -101,34 +101,34 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
describe "GET /posts" do describe 'GET /posts' do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { TagName.create!(name: "spec_tag") } let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name:, category: :general) } let!(:tag) { Tag.create!(tag_name:, category: :general) }
let!(:tag_name2) { TagName.create!(name: 'unko') } let!(:tag_name2) { TagName.create!(name: 'unko') }
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) } let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
let!(:hit_post) do let!(:hit_post) do
Post.create!(uploaded_user: user, title: "hello spec world", Post.create!(uploaded_user: user, title: 'hello spec world',
url: 'https://example.com/spec2').tap do |p| url: 'https://example.com/spec2').tap do |p|
PostTag.create!(post: p, tag:) PostTag.create!(post: p, tag:)
end end
end end
let!(:miss_post) do let!(:miss_post) do
Post.create!(uploaded_user: user, title: "unrelated title", Post.create!(uploaded_user: user, title: 'unrelated title',
url: 'https://example.com/spec3').tap do |p| url: 'https://example.com/spec3').tap do |p|
PostTag.create!(post: p, tag: tag2) PostTag.create!(post: p, tag: tag2)
end end
end end
it 'returns posts with tag name in JSON' do it 'returns posts with tag name in JSON' do
get "/posts" get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
posts = json.fetch("posts") posts = json.fetch('posts')
# 全postの全tagが name を含むこと # 全postの全tagが name を含むこと
expect(posts).not_to be_empty expect(posts).not_to be_empty
@@ -141,8 +141,8 @@ RSpec.describe 'Posts API', type: :request do
expect(json['count']).to be_an(Integer) expect(json['count']).to be_an(Integer)
# spec_tag を含む投稿が存在すること # spec_tag を含む投稿が存在すること
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } } all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
expect(all_tag_names).to include("spec_tag") expect(all_tag_names).to include('spec_tag')
end end
it 'keeps children and sections keys in non-detail tag responses' do it 'keeps children and sections keys in non-detail tag responses' do
@@ -201,10 +201,10 @@ RSpec.describe 'Posts API', type: :request do
end end
it 'returns empty posts when nothing matches' do it 'returns empty posts when nothing matches' do
get "/posts", params: { tags: "no_such_keyword_12345" } get '/posts', params: { tags: 'no_such_keyword_12345' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch("posts")).to eq([]) expect(json.fetch('posts')).to eq([])
expect(json.fetch('count')).to eq(0) expect(json.fetch('count')).to eq(0)
end end
end end
@@ -1712,8 +1712,10 @@ RSpec.describe 'Posts API', type: :request do
expect(post_record.reload.title).to eq('updated by other user') expect(post_record.reload.title).to eq('updated by other user')
end end
it 'returns 409 with mergeable true when stale tag changes'\ it(
' do not conflict but merge is not requested' do 'returns 409 with mergeable true when stale tag changes '
'do not conflict but merge is not requested'
) do
sign_in_as(member) sign_in_as(member)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
+21 -21
ファイルの表示
@@ -1,10 +1,10 @@
require "rails_helper" require 'rails_helper'
RSpec.describe "nico:sync" do RSpec.describe 'nico:sync' do
def stub_python(json_array) def stub_python(json_array)
status = instance_double(Process::Status, success?: true) status = instance_double(Process::Status, success?: true)
allow(Open3).to receive(:capture3).and_return([json_array.to_json, "", status]) allow(Open3).to receive(:capture3).and_return([json_array.to_json, '', status])
end end
def create_tag!(name, category:) def create_tag!(name, category:)
@@ -25,12 +25,12 @@ RSpec.describe "nico:sync" do
) )
# 既存の非nicoタグ(kept_non_nico_ids) # 既存の非nicoタグ(kept_non_nico_ids)
kept_general = create_tag!("spec_kept", category: "general") kept_general = create_tag!('spec_kept', category: 'general')
PostTag.create!(post: post, tag: kept_general) PostTag.create!(post: post, tag: kept_general)
# 追加される linked tag を準備(nico tag に紐付く一般タグ) # 追加される linked tag を準備(nico tag に紐付く一般タグ)
linked = create_tag!("spec_linked", category: "general") linked = create_tag!('spec_linked', category: 'general')
nico = create_tag!("nico:AAA", category: "nico") nico = create_tag!('nico:AAA', category: 'nico')
link_nico_to_tag!(nico, linked) link_nico_to_tag!(nico, linked)
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提) # bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
@@ -46,22 +46,22 @@ RSpec.describe "nico:sync" do
'deleted_at' => '2026-01-31 00:00:00' }]) 'deleted_at' => '2026-01-31 00:00:00' }])
# 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ # 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>")) allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task("nico:sync") run_rake_task('nico:sync')
post.reload post.reload
active_tag_names = post.tags.joins(:tag_name).pluck("tag_names.name") active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_tag_names).to include("spec_kept") expect(active_tag_names).to include('spec_kept')
expect(active_tag_names).to include("nico:AAA") expect(active_tag_names).to include('nico:AAA')
expect(active_tag_names).to include("spec_linked") expect(active_tag_names).to include('spec_linked')
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z')) expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z')) expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids) # 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
expect(active_tag_names).to include("bot操作") expect(active_tag_names).to include('bot操作')
end end
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
@@ -116,21 +116,21 @@ RSpec.describe "nico:sync" do
) )
# 旧nicoタグ(今回の同期結果に含まれない) # 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!("nico:OLD", category: "nico") old_nico = create_tag!('nico:OLD', category: 'nico')
old_pt = PostTag.create!(post: post, tag: old_nico) old_pt = PostTag.create!(post: post, tag: old_nico)
expect(old_pt.discarded_at).to be_nil expect(old_pt.discarded_at).to be_nil
# 今回は NEW のみ欲しい # 今回は NEW のみ欲しい
new_nico = create_tag!("nico:NEW", category: "nico") new_nico = create_tag!('nico:NEW', category: 'nico')
# bot/tagme 念のため # bot/tagme 念のため
Tag.bot Tag.bot
Tag.tagme Tag.tagme
stub_python([{ "code" => "sm9", "title" => "t", "tags" => ["NEW"] }]) stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => ['NEW'] }])
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>")) allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task("nico:sync") run_rake_task('nico:sync')
# OLD は active から外れる(discarded_at が入る) # OLD は active から外れる(discarded_at が入る)
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a
@@ -138,9 +138,9 @@ RSpec.describe "nico:sync" do
# NEW は active にいる # NEW は active にいる
post.reload post.reload
active_names = post.tags.joins(:tag_name).pluck("tag_names.name") active_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_names).to include("nico:NEW") expect(active_names).to include('nico:NEW')
expect(active_names).not_to include("nico:OLD") expect(active_names).not_to include('nico:OLD')
end end
def snapshot_tags(post) def snapshot_tags(post)
+30 -27
ファイルの表示
@@ -13,7 +13,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials' import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils' import { cn, dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -113,10 +113,11 @@ const clearedTagSelectionPath = (
const MaterialThumb: FC<{ material: Material }> = ({ material }) => ( const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div <div
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center className={cn (
overflow-hidden rounded-lg border border-stone-200 bg-white text-center 'flex aspect-square h-[180px] w-[180px] items-center justify-center',
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900 'overflow-hidden rounded-lg border border-stone-200 bg-white text-center',
dark:text-stone-100`}> 'text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900',
'dark:text-stone-100')}>
{material.thumbnail {material.thumbnail
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/> ? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
: ( : (
@@ -531,30 +532,32 @@ const MaterialListPage: FC = () => {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
onClick={() => updateQuery ({ view: 'card' })} onClick={() => updateQuery ({ view: 'card' })}
className={`rounded-full border px-4 py-2 text-sm ${ className={cn (
view === 'card' 'rounded-full border px-4 py-2 text-sm',
? [ view === 'card'
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400', ? [
'dark:bg-sky-950 dark:text-sky-100'].join (' ') 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
: [ 'dark:bg-sky-950 dark:text-sky-100']
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', : [
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}> 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
</button> </button>
<button <button
type="button" type="button"
onClick={() => updateQuery ({ view: 'list' })} onClick={() => updateQuery ({ view: 'list' })}
className={`rounded-full border px-4 py-2 text-sm ${ className={cn (
view === 'list' 'rounded-full border px-4 py-2 text-sm',
? [ view === 'list'
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400', ? [
'dark:bg-sky-950 dark:text-sky-100'].join (' ') 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
: [ 'dark:bg-sky-950 dark:text-sky-100']
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', : [
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}> 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}>
</button> </button>
</div> </div>