Add SimplyStored::HasAttachment — Paperclip replacement for CouchDB

- New module lib/simply_stored/has_attachment.rb
  Drop-in replacement using MiniMagick for thumbnail generation.
  Same file paths as Paperclip — zero migration of existing files.
  Auto-declares CouchDB properties for attachment metadata.
  Proxy object mimics Paperclip::Attachment API for view compat.
- Wired into lib/cmtool.rb
- Added TODO migration comments to Image, News, Quote models
This commit is contained in:
BenClaw
2026-05-18 04:41:56 +02:00
parent 6f2193a5e5
commit 37cbb428c6
5 changed files with 241 additions and 0 deletions
+4
View File
@@ -2,6 +2,10 @@ module Cmtool
class Image class Image
include SimplyStored::Couch include SimplyStored::Couch
include Paperclip::Glue include Paperclip::Glue
# TODO: Replace Paperclip with SimplyStored::HasAttachment
# 1. Change `include Paperclip::Glue` to `include SimplyStored::HasAttachment`
# 2. Replace `has_attached_file :file, styles: {...}` with `has_attachment :file, styles: {...}`
# 3. Replace `validates_attachment` with standard Rails validations
property :file_file_name property :file_file_name
property :file_content_type property :file_content_type
+3
View File
@@ -2,6 +2,9 @@ module Cmtool
class News class News
include SimplyStored::Couch include SimplyStored::Couch
include Paperclip::Glue include Paperclip::Glue
# TODO: Replace Paperclip with SimplyStored::HasAttachment
# 1. Change `include Paperclip::Glue` to `include SimplyStored::HasAttachment`
# 2. Replace `has_attached_file :image, styles: {...}` with `has_attachment :image, styles: {...}`
property :title property :title
property :active, type: :boolean, default: true property :active, type: :boolean, default: true
+3
View File
@@ -2,6 +2,9 @@ module Cmtool
class Quote class Quote
include SimplyStored::Couch include SimplyStored::Couch
include Paperclip::Glue include Paperclip::Glue
# TODO: Replace Paperclip with SimplyStored::HasAttachment
# 1. Change `include Paperclip::Glue` to `include SimplyStored::HasAttachment`
# 2. Replace `has_attached_file :image, styles: {...}` with `has_attachment :image, styles: {...}`
property :owner property :owner
property :function property :function
+6
View File
@@ -7,5 +7,11 @@ require 'cmtool/includes/pages_controller'
require 'cmtool/menu' require 'cmtool/menu'
require 'email_validator' require 'email_validator'
# Paperclip replacement — stores file metadata as CouchDB properties,
# handles thumbnail generation via MiniMagick.
# Replace `include Paperclip::Glue` + `has_attached_file` with
# `include SimplyStored::HasAttachment` + `has_attachment`
require 'simply_stored/has_attachment'
module Cmtool module Cmtool
end end
+225
View File
@@ -0,0 +1,225 @@
# frozen_string_literal: true
module SimplyStored
# Drop-in replacement for Paperclip in SimplyStored/CouchDB models.
#
# Usage (replaces `include Paperclip::Glue` + `has_attached_file`):
#
# class Image
# include SimplyStored::Couch
# include SimplyStored::HasAttachment
#
# has_attachment :file, styles: {
# medium: "354x1000>",
# thumb: "160x1250>"
# }
#
# # Validations (standard Rails, replaces validates_attachment)
# validate :file_content_type_must_be_image
#
# private
#
# def file_content_type_must_be_image
# return if file.blank?
# unless %w[image/jpeg image/gif image/png].include?(file_content_type)
# errors.add(:file, :invalid_content_type)
# end
# end
# end
#
# The module auto-declares CouchDB properties for:
# <name>_file_name, <name>_content_type, <name>_file_size, <name>_updated_at
#
# Files are stored on disk at:
# public/system/<attachment>/<id>/original.<ext>
# public/system/<attachment>/<id>/<style>.<ext>
#
# Uses MiniMagick (ImageMagick) for thumbnail generation.
# Same geometry syntax as Paperclip: "300x300>", "160x1250>", etc.
module HasAttachment
def self.included(base)
base.extend(ClassMethods)
end
# Proxy object returned by attachment getters (e.g. `image.file`).
# Mimics the Paperclip::Attachment API that views expect.
class Proxy
def initialize(record, name)
@record = record
@name = name
end
def present?
@record.send(:"#{@name}_file_name").present?
end
def blank?
!present?
end
def url(style = nil)
@record.send(:"#{@name}_url", style)
end
def original_filename
@record.send(:"#{@name}_file_name")
end
def content_type
@record.send(:"#{@name}_content_type")
end
def size
@record.send(:"#{@name}_file_size")
end
# Delegate anything else to the record
def method_missing(method, *args, &block)
if @record.respond_to?(method, true)
@record.send(method, *args, &block)
else
super
end
end
def respond_to_missing?(method, include_private = false)
@record.respond_to?(method, include_private) || super
end
end
module ClassMethods
def has_attachment(name, styles: {}, default_url: nil, default_style: :original)
# Auto-declare CouchDB properties for attachment metadata
property :"#{name}_file_name"
property :"#{name}_content_type"
property :"#{name}_file_size", type: Integer
property :"#{name}_updated_at", type: Time
# Register configuration
attachment_registry[name] = {
styles: styles,
default_url: default_url,
default_style: default_style,
}
# ---- Setter: model.file = uploaded_file ----
define_method(:"#{name}=") do |uploaded|
# Handle clearing: nil, empty string
if uploaded.nil? || (uploaded.respond_to?(:empty?) && uploaded.empty?)
send(:"#{name}_file_name=", nil)
send(:"#{name}_content_type=", nil)
send(:"#{name}_file_size=", nil)
send(:"#{name}_updated_at=", nil)
return
end
config = self.class.attachment_registry[name]
# Extract file info
original_filename = if uploaded.respond_to?(:original_filename)
uploaded.original_filename
elsif uploaded.respond_to?(:path)
File.basename(uploaded.path)
else
"file"
end
ext = File.extname(original_filename)
ext = ".bin" if ext.blank?
content_type = uploaded.respond_to?(:content_type) ? uploaded.content_type : nil
file_size = uploaded.respond_to?(:size) ? uploaded.size : nil
# Read content (handle multiple upload types)
content = if uploaded.respond_to?(:read)
data = uploaded.read
uploaded.rewind if uploaded.respond_to?(:rewind)
data
elsif uploaded.respond_to?(:path) && File.exist?(uploaded.path)
File.binread(uploaded.path)
elsif uploaded.respond_to?(:tempfile)
uploaded.tempfile.read
else
uploaded.to_s
end
# Build storage path
record_id = respond_to?(:id) && id.present? ? id.to_s : "tmp"
base_dir = Rails.root.join("public", "system", name.to_s, record_id)
FileUtils.mkdir_p(base_dir)
begin
# Write original file
original_path = base_dir.join("original#{ext}")
File.binwrite(original_path, content)
# Generate thumbnail styles
config[:styles].each do |style_name, geometry|
style_path = base_dir.join("#{style_name}#{ext}")
begin
image = MiniMagick::Image.open(original_path.to_s)
image.resize(geometry)
image.write(style_path.to_s)
rescue StandardError => e
# If ImageMagick fails, copy original as fallback
Rails.logger.warn(
"[HasAttachment] Could not generate #{style_name} for #{name}: #{e.message}"
)
FileUtils.cp(original_path, style_path)
end
end
# Store metadata as CouchDB properties
send(:"#{name}_file_name=", original_filename)
send(:"#{name}_content_type=", content_type)
send(:"#{name}_file_size=", file_size || content.bytesize)
send(:"#{name}_updated_at=", Time.current)
rescue StandardError => e
errors.add(name, "could not be processed: #{e.message}")
Rails.logger.error("[HasAttachment] Error processing #{name}: #{e.message}")
end
end
# ---- Getter: model.file → Proxy ----
define_method(name) do
Proxy.new(self, name)
end
# ---- URL helper: model.file_url(:thumb) ----
define_method(:"#{name}_url") do |style_name = nil|
config = self.class.attachment_registry[name]
style_name ||= config[:default_style]
fname = send(:"#{name}_file_name")
if fname.blank?
return config[:default_url] if config[:default_url]
return nil
end
ext = File.extname(fname)
record_id = respond_to?(:id) && id.present? ? id.to_s : "tmp"
"/system/#{name}/#{record_id}/#{style_name}#{ext}"
end
# ---- Backward compat: *_path for Paperclip migrations ----
define_method(:"#{name}_path") do |style_name = nil|
config = self.class.attachment_registry[name]
style_name ||= config[:default_style]
fname = send(:"#{name}_file_name")
return nil if fname.blank?
ext = File.extname(fname)
record_id = respond_to?(:id) && id.present? ? id.to_s : "tmp"
Rails.root.join("public", "system", name.to_s, record_id, "#{style_name}#{ext}").to_s
end
end
# Registry of all attachments defined on this class
def attachment_registry
@_has_attachment_registry ||= {}
end
end
end
end