このコミットが含まれているのは:
@@ -12,33 +12,47 @@ class MaterialsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
tag_id = params[:tag_id].presence
|
||||
parent_id = params[:parent_id].presence
|
||||
unclassified = bool?(:unclassified)
|
||||
filters = material_index_filters
|
||||
q = Material.includes(:material_export_items,
|
||||
thumbnail_attachment: :blob,
|
||||
file_attachment: :blob,
|
||||
tag: :tag_name)
|
||||
q = q.where(file_suppressed_at: nil) if filters[:suppression] == 'active'
|
||||
q = q.where.not(file_suppressed_at: nil) if filters[:suppression] == 'suppressed'
|
||||
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
|
||||
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
|
||||
q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from]
|
||||
q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to]
|
||||
q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from]
|
||||
q = q.where('materials.updated_at <= ?', filters[:updated_to]) if filters[:updated_to]
|
||||
|
||||
q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file
|
||||
if unclassified
|
||||
q = q.where(tag_id: nil)
|
||||
else
|
||||
q = q.where(tag_id:) if tag_id
|
||||
q = q.where(parent_id:) if parent_id
|
||||
end
|
||||
q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters)
|
||||
q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters)
|
||||
q = apply_material_query(q, filters[:q]) if filters[:q].present?
|
||||
q = apply_material_media_kind(q, filters[:media_kind])
|
||||
|
||||
count = q.count
|
||||
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
|
||||
count = q.distinct.count(:id)
|
||||
materials =
|
||||
q
|
||||
.order(Arel.sql(material_index_order_sql(filters)))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.to_a
|
||||
|
||||
render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count }
|
||||
render json: { materials: MaterialRepr.list_many(materials, host: request.base_url),
|
||||
count: }
|
||||
end
|
||||
|
||||
def show
|
||||
material =
|
||||
Material
|
||||
.includes(:tag, :material_export_items)
|
||||
.with_attached_thumbnail
|
||||
.with_attached_file
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless material
|
||||
|
||||
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body
|
||||
wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
|
||||
|
||||
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
|
||||
end
|
||||
@@ -83,6 +97,8 @@ class MaterialsController < ApplicationController
|
||||
end
|
||||
|
||||
if material
|
||||
MaterialThumbnailGenerator.generate!(material)
|
||||
material.reload
|
||||
render json: MaterialRepr.base(material, host: request.base_url), status: :created
|
||||
else
|
||||
render_validation_error material
|
||||
@@ -134,6 +150,9 @@ class MaterialsController < ApplicationController
|
||||
raise
|
||||
end
|
||||
|
||||
MaterialThumbnailGenerator.generate!(material)
|
||||
material.reload
|
||||
|
||||
render json: MaterialRepr.base(material, host: request.base_url)
|
||||
end
|
||||
|
||||
@@ -196,6 +215,134 @@ class MaterialsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def material_index_filters
|
||||
tag_state = params[:tag_state].to_s.presence
|
||||
tag_state = 'untagged' if bool?(:unclassified)
|
||||
tag_state = 'all' unless ['all', 'tagged', 'untagged'].include?(tag_state)
|
||||
|
||||
media_kind = params[:media_kind].to_s.presence
|
||||
unless ['all', 'image', 'video', 'audio', 'file_other', 'url_only'].include?(media_kind)
|
||||
media_kind = 'all'
|
||||
end
|
||||
|
||||
suppression = params[:suppression].to_s.presence
|
||||
suppression = 'active' unless ['active', 'suppressed', 'all'].include?(suppression)
|
||||
|
||||
sort = params[:sort].to_s.presence
|
||||
unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
|
||||
'version_no', 'id'].include?(sort)
|
||||
sort = 'created_at'
|
||||
end
|
||||
|
||||
direction = params[:direction].to_s.downcase
|
||||
direction = 'desc' unless ['asc', 'desc'].include?(direction)
|
||||
|
||||
{ q: params[:q].to_s.strip.presence,
|
||||
tag_state:,
|
||||
media_kind:,
|
||||
suppression:,
|
||||
created_from: parse_time_param(:created_from),
|
||||
created_to: parse_time_param(:created_to),
|
||||
updated_from: parse_time_param(:updated_from),
|
||||
updated_to: parse_time_param(:updated_to),
|
||||
sort:,
|
||||
direction: }
|
||||
end
|
||||
|
||||
def parse_time_param name
|
||||
value = params[name].to_s.strip
|
||||
return nil if value.blank?
|
||||
|
||||
Time.zone.parse(value)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def material_index_needs_tag_name? filters
|
||||
filters[:q].present? || filters[:sort] == 'tag_name'
|
||||
end
|
||||
|
||||
def material_index_needs_file_blob? filters
|
||||
filters[:q].present? ||
|
||||
filters[:media_kind] != 'all' ||
|
||||
['media_kind', 'file_byte_size'].include?(filters[:sort])
|
||||
end
|
||||
|
||||
def material_index_join_tag_name q
|
||||
q.left_joins(tag: :tag_name)
|
||||
end
|
||||
|
||||
def material_index_join_file_blob q
|
||||
q.joins(<<~SQL.squish)
|
||||
LEFT JOIN active_storage_attachments material_file_attachments
|
||||
ON material_file_attachments.record_type = 'Material'
|
||||
AND material_file_attachments.record_id = materials.id
|
||||
AND material_file_attachments.name = 'file'
|
||||
LEFT JOIN active_storage_blobs material_file_blobs
|
||||
ON material_file_blobs.id = material_file_attachments.blob_id
|
||||
SQL
|
||||
end
|
||||
|
||||
def apply_material_query q, term
|
||||
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
|
||||
q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
|
||||
'material_file_blobs.filename LIKE :q',
|
||||
q: like)
|
||||
end
|
||||
|
||||
def apply_material_media_kind q, media_kind
|
||||
case media_kind
|
||||
when 'image'
|
||||
q.where('material_file_blobs.content_type LIKE ?', 'image/%')
|
||||
when 'video'
|
||||
q.where('material_file_blobs.content_type LIKE ?', 'video/%')
|
||||
when 'audio'
|
||||
q.where('material_file_blobs.content_type LIKE ?', 'audio/%')
|
||||
when 'file_other'
|
||||
q.where('material_file_attachments.id IS NOT NULL')
|
||||
.where.not('material_file_blobs.content_type LIKE ?', 'image/%')
|
||||
.where.not('material_file_blobs.content_type LIKE ?', 'video/%')
|
||||
.where.not('material_file_blobs.content_type LIKE ?', 'audio/%')
|
||||
when 'url_only'
|
||||
q.where('material_file_attachments.id IS NULL').where.not(url: [nil, ''])
|
||||
else
|
||||
q
|
||||
end
|
||||
end
|
||||
|
||||
def material_index_order_sql filters
|
||||
direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC'
|
||||
sort_sql =
|
||||
case filters[:sort]
|
||||
when 'tag_name'
|
||||
'tag_names.name'
|
||||
when 'media_kind'
|
||||
material_media_kind_sql
|
||||
when 'file_byte_size'
|
||||
'material_file_blobs.byte_size'
|
||||
when 'updated_at'
|
||||
'materials.updated_at'
|
||||
when 'version_no'
|
||||
'materials.version_no'
|
||||
when 'id'
|
||||
'materials.id'
|
||||
else
|
||||
'materials.created_at'
|
||||
end
|
||||
|
||||
"#{ sort_sql } #{ direction }, materials.id #{ direction }"
|
||||
end
|
||||
|
||||
def material_media_kind_sql
|
||||
"CASE " \
|
||||
"WHEN material_file_attachments.id IS NULL AND materials.url IS NOT NULL THEN 5 " \
|
||||
"WHEN material_file_blobs.content_type LIKE 'image/%' THEN 1 " \
|
||||
"WHEN material_file_blobs.content_type LIKE 'video/%' THEN 2 " \
|
||||
"WHEN material_file_blobs.content_type LIKE 'audio/%' THEN 3 " \
|
||||
"WHEN material_file_attachments.id IS NOT NULL THEN 4 " \
|
||||
"ELSE 6 END"
|
||||
end
|
||||
|
||||
def upsert_export_paths! material
|
||||
raw = params[:export_paths]
|
||||
return if raw.blank?
|
||||
|
||||
@@ -15,6 +15,7 @@ class Material < ApplicationRecord
|
||||
has_many :material_export_items, dependent: :destroy
|
||||
|
||||
has_one_attached :file, dependent: :purge
|
||||
has_one_attached :thumbnail, dependent: :purge
|
||||
|
||||
validates :tag_id, presence: true, uniqueness: true
|
||||
|
||||
@@ -28,6 +29,18 @@ class Material < ApplicationRecord
|
||||
file.blob.content_type
|
||||
end
|
||||
|
||||
def file_byte_size
|
||||
return nil unless file&.attached?
|
||||
|
||||
file.blob.byte_size
|
||||
end
|
||||
|
||||
def file_filename
|
||||
return nil unless file&.attached?
|
||||
|
||||
file.blob.filename.to_s
|
||||
end
|
||||
|
||||
def file_suppressed? = file_suppressed_at.present?
|
||||
|
||||
def snapshot_export_paths
|
||||
|
||||
@@ -17,6 +17,11 @@ module MaterialRepr
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
material.file, host:)
|
||||
end,
|
||||
thumbnail: thumbnail_url(material, host:),
|
||||
thumbnail_fallback_text: thumbnail_fallback_text(material),
|
||||
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
|
||||
media_kind: media_kind(material),
|
||||
file_byte_size: material.file_byte_size,
|
||||
export_paths: export_paths(material),
|
||||
export_items: export_items(material))
|
||||
end
|
||||
@@ -25,6 +30,28 @@ module MaterialRepr
|
||||
materials.map { |m| base(m, host:) }
|
||||
end
|
||||
|
||||
def list material, host:
|
||||
{ id: material.id,
|
||||
version_no: material.version_no,
|
||||
url: material.url,
|
||||
tag: compact_tag(material.tag),
|
||||
thumbnail: thumbnail_url(material, host:),
|
||||
thumbnail_fallback_text: thumbnail_fallback_text(material),
|
||||
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
|
||||
media_kind: media_kind(material),
|
||||
content_type: material.content_type,
|
||||
file_byte_size: material.file_byte_size,
|
||||
file_suppressed_at: material.file_suppressed_at,
|
||||
created_at: material.created_at,
|
||||
updated_at: material.updated_at,
|
||||
export_paths: export_paths(material),
|
||||
export_items: export_items(material) }
|
||||
end
|
||||
|
||||
def list_many materials, host:
|
||||
materials.map { |m| list(m, host:) }
|
||||
end
|
||||
|
||||
def export_paths material
|
||||
material.material_export_items.each_with_object({ }) do |item, hash|
|
||||
hash[item.profile] = item.enabled ? item.export_path : ''
|
||||
@@ -39,4 +66,41 @@ module MaterialRepr
|
||||
enabled: item.enabled }
|
||||
end
|
||||
end
|
||||
|
||||
def thumbnail_url material, host:
|
||||
return nil if material.file_suppressed?
|
||||
return nil unless material.thumbnail.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
||||
material.thumbnail, host:)
|
||||
end
|
||||
|
||||
def thumbnail_fallback_text material
|
||||
material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
|
||||
end
|
||||
|
||||
def thumbnail_fallback_kind material
|
||||
material.tag.present? ? 'tag_name' : 'created_at'
|
||||
end
|
||||
|
||||
def media_kind material
|
||||
return 'suppressed' if material.file_suppressed?
|
||||
return 'url_only' unless material.file.attached?
|
||||
|
||||
content_type = material.file.blob.content_type.to_s
|
||||
return 'image' if content_type.start_with?('image/')
|
||||
return 'video' if content_type.start_with?('video/')
|
||||
return 'audio' if content_type.start_with?('audio/')
|
||||
|
||||
'file_other'
|
||||
end
|
||||
|
||||
def compact_tag tag
|
||||
return nil unless tag
|
||||
|
||||
{ id: tag.id,
|
||||
name: tag.name,
|
||||
category: tag.category,
|
||||
deprecated_at: tag.deprecated_at }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'mini_magick'
|
||||
require 'open3'
|
||||
require 'tempfile'
|
||||
|
||||
|
||||
class MaterialThumbnailGenerator
|
||||
SIZE = '180x180'
|
||||
|
||||
class << self
|
||||
def generate! material
|
||||
new(material).generate!
|
||||
rescue ActiveStorage::FileNotFoundError, ArgumentError, MiniMagick::Error => e
|
||||
Rails.logger.warn("Material thumbnail generation skipped: #{ e.class }: #{ e.message }")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def initialize material
|
||||
@material = material
|
||||
end
|
||||
|
||||
def generate!
|
||||
@material.thumbnail.purge if @material.thumbnail.attached?
|
||||
return unless @material.file.attached?
|
||||
return unless image? || video?
|
||||
|
||||
@material.file.blob.open do |file|
|
||||
thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path)
|
||||
attach_thumbnail(thumbnail) if thumbnail
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def image? = content_type.start_with?('image/')
|
||||
|
||||
def video? = content_type.start_with?('video/')
|
||||
|
||||
def content_type = @material.file.blob.content_type.to_s
|
||||
|
||||
def image_thumbnail path
|
||||
image = MiniMagick::Image.open(path)
|
||||
image.resize(SIZE)
|
||||
image.format('jpg')
|
||||
image
|
||||
end
|
||||
|
||||
def video_thumbnail path
|
||||
[1, 0].each do |seconds|
|
||||
tempfile = Tempfile.new(['material-thumbnail', '.jpg'])
|
||||
tempfile.close
|
||||
ok = extract_video_frame(path, tempfile.path, seconds)
|
||||
next unless ok && File.size?(tempfile.path)
|
||||
|
||||
return image_thumbnail(tempfile.path)
|
||||
ensure
|
||||
tempfile&.unlink
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_video_frame input_path, output_path, seconds
|
||||
_stdout, stderr, status =
|
||||
Open3.capture3('ffmpeg',
|
||||
'-y',
|
||||
'-ss', seconds.to_s,
|
||||
'-i', input_path,
|
||||
'-frames:v', '1',
|
||||
'-f', 'image2',
|
||||
output_path)
|
||||
Rails.logger.warn("ffmpeg thumbnail failed: #{ stderr }") unless status.success?
|
||||
status.success?
|
||||
rescue Errno::ENOENT => e
|
||||
Rails.logger.warn("ffmpeg unavailable for material thumbnail: #{ e.message }")
|
||||
false
|
||||
end
|
||||
|
||||
def attach_thumbnail image
|
||||
@material.thumbnail.attach(io: File.open(image.path),
|
||||
filename: 'material-thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
end
|
||||
end
|
||||
新しい課題から参照
ユーザをブロックする