replace catalog service to api

This commit is contained in:
2026-05-05 19:27:29 +02:00
parent 189bce21e1
commit 93849795e3
91 changed files with 1503 additions and 1540 deletions
+32
View File
@@ -0,0 +1,32 @@
ActiveAdmin.register AdminUser do
permit_params :email, :password, :password_confirmation
index do
selectable_column
id_column
column :email
column :created_at
actions
end
filter :email
filter :created_at
form do |f|
f.inputs do
f.input :email
f.input :password
f.input :password_confirmation
end
f.actions
end
show do
attributes_table do
row :id
row :email
row :created_at
row :updated_at
end
end
end
+27
View File
@@ -0,0 +1,27 @@
ActiveAdmin.register_page "Dashboard" do
menu priority: 1, label: proc { I18n.t("active_admin.dashboard") }
content title: proc { I18n.t("active_admin.dashboard") } do
columns do
column do
panel "Softwares" do
ul do
li "Total: #{Software.count}"
li "Released: #{Software.where(status: 'released').count}"
li "In development: #{Software.where(status: 'development').count}"
end
end
end
column do
panel "Recent Releases" do
table_for Release.order(created_at: :desc).limit(10) do
column(:software) { |r| link_to r.software.title, admin_software_path(r.software) rescue r.software_id }
column :version
column :created_at
end
end
end
end
end
end
+34
View File
@@ -0,0 +1,34 @@
ActiveAdmin.register ExternalLink do
permit_params :software_id, :label, :url
menu false
belongs_to :software
index do
selectable_column
id_column
column :label
column(:url) { |el| link_to el.url, el.url, target: "_blank" }
column :created_at
actions
end
form do |f|
f.inputs do
f.input :label
f.input :url
end
f.actions
end
show do
attributes_table do
row :id
row :label
row(:url) { |el| link_to el.url, el.url, target: "_blank" }
row :created_at
row :updated_at
end
end
end
+33
View File
@@ -0,0 +1,33 @@
ActiveAdmin.register Release do
actions :index, :show
menu false
belongs_to :software, optional: true
index do
selectable_column
id_column
column(:software) { |r| link_to r.software.title, admin_software_path(r.software) }
column :version
column :html_folder_path
column :created_at
actions only: [:show]
end
filter :version
show do
attributes_table do
row :id
row(:software) { |r| link_to r.software.title, admin_software_path(r.software) }
row :version
row :cartridge_path
row :source_path
row :html_folder_path
row :docs_folder_path
row :created_at
row :updated_at
end
end
end
+97
View File
@@ -0,0 +1,97 @@
ActiveAdmin.register Software do
permit_params :name, :title, :author, :desc, :story,
:license, :platform, :status, :highlighted,
external_links_attributes: [:id, :label, :url, :_destroy],
releases_attributes: [:id, :_destroy]
menu priority: 2
index do
selectable_column
id_column
column :name
column :title
column :platform
column :status do |sw|
status_tag sw.status
end
column :highlighted
column :created_at
actions
end
filter :name
filter :title
filter :author
filter :platform, as: :select, collection: %w[tic80 ebitengine love]
filter :status, as: :select, collection: %w[development demo released archived]
filter :highlighted
show do
attributes_table do
row :id
row :name
row :title
row :author
row :platform
row :status
row :highlighted
row :license
row(:desc) { |sw| simple_format sw.desc }
row(:story) { |sw| simple_format sw.story }
row :created_at
row :updated_at
end
panel "External Links" do
table_for resource.external_links do
column :label
column(:url) { |el| link_to el.url, el.url, target: "_blank" }
column :created_at
column do |el|
link_to "Edit", edit_admin_software_external_link_path(resource, el)
concat " "
link_to "Delete", admin_software_external_link_path(resource, el),
method: :delete, data: { confirm: "Are you sure?" }
end
end
br
link_to "Add External Link",
new_admin_software_external_link_path(resource)
end
panel "Releases" do
table_for resource.releases.order(created_at: :desc) do
column :version
column :html_folder_path
column :cartridge_path
column :source_path
column :docs_folder_path
column :created_at
end
end
end
form do |f|
f.inputs "Details" do
f.input :name
f.input :title
f.input :author
f.input :platform, as: :select, collection: %w[tic80 ebitengine love]
f.input :status, as: :select, collection: %w[development demo released archived]
f.input :highlighted
f.input :license
f.input :desc, as: :text, input_html: { rows: 4 }
f.input :story, as: :text, input_html: { rows: 8 }
end
f.inputs "External Links" do
f.has_many :external_links, allow_destroy: true, new_record: true do |el|
el.input :label
el.input :url
end
end
f.actions
end
end
+2
View File
@@ -0,0 +1,2 @@
//= link active_admin.css
//= link active_admin.js
@@ -0,0 +1 @@
//= require active_admin/base
@@ -0,0 +1,2 @@
@import "active_admin/mixins";
@import "active_admin/base";
@@ -0,0 +1,8 @@
module Api
class SoftwareController < ApiController
def index
result = SoftwareListService.new.call
render json: result
end
end
end
@@ -0,0 +1,12 @@
module Api
class SoftwareHighlightedController < ApiController
def index
result = SoftwareHighlightedService.new.call
if result.nil?
render json: { error: "no highlighted software found" }, status: :not_found
else
render json: result
end
end
end
end
@@ -0,0 +1,2 @@
class ApiController < ActionController::API
end
@@ -0,0 +1,3 @@
class ApplicationController < ActionController::Base
protect_from_forgery with: :null_session
end
@@ -0,0 +1,13 @@
class FilesController < ApplicationController
def show
base = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath
full = base.join(params[:path])
return render plain: "Not Found", status: :not_found unless full.to_s.start_with?(base.to_s)
return render plain: "Not Found", status: :not_found unless full.exist? && full.file?
send_file full.to_s, disposition: "inline"
rescue Errno::ENOENT
render plain: "Not Found", status: :not_found
end
end
@@ -0,0 +1,32 @@
class UpdateController < ApiController
PLATFORM_SERVICES = {
"tic80" => "SoftwareUpdater::Tic80Service",
"ebitengine" => "SoftwareUpdater::EbitengineService",
"love" => "SoftwareUpdater::LoveService"
}.freeze
def update
return render plain: "Unauthorized", status: :unauthorized unless authorized?
platform = params[:platform]
name = params[:name]
version = params[:version]
return render plain: "Version not provided", status: :bad_request if version.blank?
service_class_name = PLATFORM_SERVICES[platform]
return render plain: "Unsupported platform: #{platform}", status: :bad_request unless service_class_name
service_class_name.constantize.new.update(name, version)
render plain: "Updated"
rescue => e
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
render plain: e.message, status: :internal_server_error
end
private
def authorized?
params[:secret] == ENV.fetch("UPDATE_SECRET", "")
end
end
+8
View File
@@ -0,0 +1,8 @@
class AdminUser < ApplicationRecord
devise :database_authenticatable,
:recoverable, :rememberable, :validatable
def self.ransackable_attributes(auth_object = nil)
%w[created_at email id updated_at]
end
end
@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
+15
View File
@@ -0,0 +1,15 @@
class ExternalLink < ApplicationRecord
self.table_name = "external_links"
belongs_to :software
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id label software_id updated_at url]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
end
end
+15
View File
@@ -0,0 +1,15 @@
class Release < ApplicationRecord
self.table_name = "releases"
belongs_to :software
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[cartridge_path created_at deleted_at docs_folder_path html_folder_path id software_id source_path updated_at version web_playable]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
end
end
+18
View File
@@ -0,0 +1,18 @@
class Software < ApplicationRecord
self.table_name = "softwares"
has_many :releases, foreign_key: :software_id
has_many :external_links, foreign_key: :software_id
accepts_nested_attributes_for :external_links, allow_destroy: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[author created_at desc highlighted id license name platform site status story title updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[releases external_links]
end
end
@@ -0,0 +1,12 @@
class SoftwareHighlightedService
def call
software = Software.includes(:external_links)
.where(highlighted: true)
.order(id: :desc)
.first
return nil unless software
releases = Release.where(software_id: software.id).to_a
SoftwareSerializer.show_data(software, releases)
end
end
@@ -0,0 +1,9 @@
class SoftwareListService
def call
softwares = Software.includes(:releases, :external_links).all
items = softwares.map do |sw|
SoftwareSerializer.show_data(sw, sw.releases.to_a)
end
{ softwares: items }
end
end
@@ -0,0 +1,82 @@
class SoftwareSerializer
GO_ZERO_TIME = "0001-01-01T00:00:00Z"
FILE_PATH_FROM = "/softwares/"
FILE_PATH_TO = "/file/"
def self.show_data(software, releases)
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
web_playable = sorted.find { |r| r.html_folder_path.present? }
{
software: serialize_software(software),
releases: sorted.map { |r| serialize_release(r) },
latestRelease: latest ? serialize_release(latest) : nil,
webPlayableRelease: web_playable ? serialize_release(web_playable) : nil
}
end
def self.serialize_software(sw)
{
"ID" => sw.id,
"CreatedAt" => fmt(sw.created_at),
"UpdatedAt" => fmt(sw.updated_at),
"DeletedAt" => fmt_nullable(sw.deleted_at),
"name" => sw.name,
"title" => sw.title,
"author" => sw.author,
"desc" => sw.desc.to_s,
"story" => sw.story.to_s,
"license" => sw.license.to_s,
"platform" => sw.platform,
"status" => sw.status,
"highlighted" => sw.highlighted ? true : false,
"externalLinks" => sw.external_links.map { |el| serialize_external_link(el) }
}
end
def self.serialize_release(r)
{
"ID" => r.id,
"CreatedAt" => fmt(r.created_at),
"UpdatedAt" => fmt(r.updated_at),
"DeletedAt" => fmt_nullable(r.deleted_at),
"softwareId" => r.software_id,
"version" => r.version,
"cartridgePath" => rewrite(r.cartridge_path),
"sourcePath" => rewrite(r.source_path),
"htmlFolderPath" => rewrite(r.html_folder_path),
"docsFolderPath" => rewrite(r.docs_folder_path)
}
end
def self.serialize_external_link(el)
{
"ID" => el.id,
"CreatedAt" => fmt(el.created_at),
"UpdatedAt" => fmt(el.updated_at),
"DeletedAt" => fmt_nullable(el.deleted_at),
"softwareId" => el.software_id,
"label" => el.label,
"url" => el.url
}
end
# null → Go zero-time string (matches GORM behavior)
def self.fmt(t)
return GO_ZERO_TIME if t.nil?
t.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
end
# null → JSON null (matches gorm.DeletedAt behavior)
def self.fmt_nullable(t)
return nil if t.nil?
t.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
end
def self.rewrite(path)
return "" if path.blank?
path.gsub(FILE_PATH_FROM, FILE_PATH_TO)
end
end
@@ -0,0 +1,11 @@
class SoftwareShowService
def initialize(name)
@name = name
end
def call
software = Software.includes(:external_links).find_by!(name: @name)
releases = Release.where(software_id: software.id).to_a
SoftwareSerializer.show_data(software, releases)
end
end
@@ -0,0 +1,61 @@
require "zip"
require "fileutils"
module SoftwareUpdater
class BaseService
def initialize
@base_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
end
private
def full_path(filename)
File.join(@base_path, filename)
end
def dir_exists?(dirname)
File.directory?(full_path(dirname))
end
def delete_dir(dirname)
FileUtils.rm_rf(full_path(dirname))
end
def create_dir(dirname)
FileUtils.mkdir_p(full_path(dirname))
end
def unzip_file(zip_filename, dest_dirname)
dest = full_path(dest_dirname)
Zip::File.open(full_path(zip_filename)) do |zip|
zip.each do |entry|
entry_dest = File.join(dest, entry.name)
FileUtils.mkdir_p(File.dirname(entry_dest))
entry.extract(entry_dest) { true }
end
end
end
def update_or_create_software(attrs)
software = Software.unscoped.find_or_initialize_by(name: attrs[:name])
software.assign_attributes(attrs.except(:name))
software.deleted_at = nil
software.save!
software
end
def upsert_external_link(software_id, label, url)
link = ExternalLink.unscoped.find_or_initialize_by(software_id: software_id, label: label)
link.url = url
link.deleted_at = nil
link.save!
end
def create_release_if_not_exists(attrs)
existing = Release.unscoped.find_by(software_id: attrs[:software_id], version: attrs[:version])
return existing if existing
Release.create!(attrs)
end
end
end
@@ -0,0 +1,34 @@
require "json"
module SoftwareUpdater
class EbitengineService < BaseService
def update(name, version)
versioned = "#{name}-#{version}"
zip_file = "#{versioned}.html.zip"
html_dir = versioned
delete_dir(html_dir) if dir_exists?(html_dir)
create_dir(html_dir)
unzip_file(zip_file, html_dir)
metadata = parse_metadata(full_path("#{versioned}.metadata.json"))
site_url = metadata.delete(:site)
software = update_or_create_software(metadata.merge(platform: "ebitengine"))
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
create_release_if_not_exists(
software_id: software.id,
version: version,
html_folder_path: full_path(html_dir)
)
end
private
def parse_metadata(path)
raw = JSON.parse(File.read(path), symbolize_names: true)
raw.slice(:name, :title, :author, :desc, :site, :license)
end
end
end
@@ -0,0 +1,34 @@
require "json"
module SoftwareUpdater
class LoveService < BaseService
def update(name, version)
versioned = "#{name}-#{version}"
zip_file = "#{versioned}.html.zip"
html_dir = versioned
delete_dir(html_dir) if dir_exists?(html_dir)
create_dir(html_dir)
unzip_file(zip_file, html_dir)
metadata = parse_metadata(full_path("#{versioned}.metadata.json"))
site_url = metadata.delete(:site)
software = update_or_create_software(metadata.merge(platform: "love"))
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
create_release_if_not_exists(
software_id: software.id,
version: version,
html_folder_path: full_path(html_dir)
)
end
private
def parse_metadata(path)
raw = JSON.parse(File.read(path), symbolize_names: true)
raw.slice(:name, :title, :author, :desc, :site, :license)
end
end
end
@@ -0,0 +1,50 @@
module SoftwareUpdater
class Tic80Service < BaseService
def update(name, version)
versioned = "#{name}-#{version}"
zip_file = "#{versioned}.html.zip"
docs_zip_file = "#{versioned}-docs.zip"
html_dir = versioned
docs_dir = "#{versioned}-docs"
delete_dir(html_dir) if dir_exists?(html_dir)
delete_dir(docs_dir) if dir_exists?(docs_dir)
create_dir(html_dir)
unzip_file(zip_file, html_dir)
create_dir(docs_dir)
unzip_file(docs_zip_file, docs_dir)
metadata = parse_lua_metadata(full_path("#{versioned}.lua"))
site_url = metadata.delete(:site)
software = update_or_create_software(metadata.merge(platform: "tic80"))
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
create_release_if_not_exists(
software_id: software.id,
version: version,
cartridge_path: full_path("#{versioned}.tic"),
source_path: full_path("#{versioned}.lua"),
html_folder_path: full_path(html_dir),
docs_folder_path: full_path(docs_dir)
)
end
private
def parse_lua_metadata(source_path)
metadata = {}
File.foreach(source_path) do |line|
break unless line.start_with?("--")
parts = line[2..].split(":", 2)
next if parts.length != 2
key = parts[0].strip.downcase.to_sym
value = parts[1].strip
metadata[key] = value
end
metadata
end
end
end