119 行
3.2 KiB
Ruby
119 行
3.2 KiB
Ruby
require 'stringio'
|
|
require 'zlib'
|
|
|
|
# Initial implementation keeps every file payload and the final ZIP in memory.
|
|
# Keep this service boundary stable so job/cached export paths can replace it later.
|
|
class MaterialZipExporter
|
|
Entry = Struct.new(:path, :data, :mtime, keyword_init: true)
|
|
|
|
class EmptyExportError < StandardError; end
|
|
class DuplicatePathError < StandardError; end
|
|
|
|
def initialize profile: 'legacy_drive', tag_id: nil
|
|
@profile = profile.presence || 'legacy_drive'
|
|
@tag_id = tag_id.presence
|
|
end
|
|
|
|
def export
|
|
entries = build_entries
|
|
raise EmptyExportError if entries.empty?
|
|
|
|
ZipWriter.write(entries)
|
|
end
|
|
|
|
private
|
|
|
|
def build_entries
|
|
rows = MaterialExportItem
|
|
.enabled
|
|
.includes(material: { file_attachment: :blob })
|
|
.joins(:material)
|
|
.merge(Material.kept)
|
|
.where(profile: @profile)
|
|
.where(materials: { file_suppressed_at: nil })
|
|
.order(:export_path)
|
|
|
|
rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id
|
|
|
|
entries = rows.filter_map do |item|
|
|
material = item.material
|
|
next unless material.file.attached?
|
|
|
|
Entry.new(path: item.export_path,
|
|
data: material.file.blob.download,
|
|
mtime: material.updated_at || Time.current)
|
|
end
|
|
|
|
paths = entries.map(&:path)
|
|
duplicated = paths.find { |path| paths.count(path) > 1 }
|
|
raise DuplicatePathError, duplicated if duplicated
|
|
|
|
entries
|
|
end
|
|
|
|
class ZipWriter
|
|
VERSION_NEEDED = 20
|
|
GP_FLAG = 0x0800
|
|
COMPRESSION_STORE = 0
|
|
|
|
def self.write entries
|
|
new(entries).write
|
|
end
|
|
|
|
def initialize entries
|
|
@entries = entries
|
|
@central_directory = []
|
|
end
|
|
|
|
def write
|
|
io = StringIO.new(''.b)
|
|
|
|
@entries.each do |entry|
|
|
write_entry(io, entry)
|
|
end
|
|
|
|
central_start = io.pos
|
|
@central_directory.each { |header| io.write(header) }
|
|
central_size = io.pos - central_start
|
|
|
|
io.write([0x06054b50, 0, 0, @entries.size, @entries.size,
|
|
central_size, central_start, 0].pack('VvvvvVVv'))
|
|
io.string
|
|
end
|
|
|
|
private
|
|
|
|
def write_entry io, entry
|
|
path = entry.path.b
|
|
data = entry.data.b
|
|
crc32 = Zlib.crc32(data)
|
|
dos_time, dos_date = dos_timestamp(entry.mtime)
|
|
offset = io.pos
|
|
|
|
local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
|
|
dos_time, dos_date, crc32, data.bytesize, data.bytesize,
|
|
path.bytesize, 0].pack('VvvvvvVVVvv')
|
|
|
|
io.write(local_header)
|
|
io.write(path)
|
|
io.write(data)
|
|
|
|
@central_directory << central_header(path:, crc32:, size: data.bytesize,
|
|
dos_time:, dos_date:, offset:)
|
|
end
|
|
|
|
def central_header path:, crc32:, size:, dos_time:, dos_date:, offset:
|
|
[0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
|
|
dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0,
|
|
offset].pack('VvvvvvvVVVvvvvvVV') + path
|
|
end
|
|
|
|
def dos_timestamp time
|
|
local = time.to_time
|
|
dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2)
|
|
dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day
|
|
[dos_time, dos_date]
|
|
end
|
|
end
|
|
end
|