feat: add modlist export to Arma 3 Launcher HTML format
- RenderModlistHTML: generates compatible preset HTML from a modlist - GET /api/modlists/:id/export: returns downloadable .html file - Export button on ModlistEditor page - Skips local mods (no workshop ID), includes only Steam workshop mods
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"arma3-web-server/internal/models"
|
"arma3-web-server/internal/models"
|
||||||
"arma3-web-server/internal/services"
|
"arma3-web-server/internal/services"
|
||||||
@@ -121,6 +123,25 @@ func (h *Handler) ImportModlist(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, ml)
|
c.JSON(http.StatusCreated, ml)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ExportModlist(c *gin.Context) {
|
||||||
|
ml, err := h.modlists.Get(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := services.RenderModlistHTML(ml.Name, ml.Mods)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s.html", strings.ReplaceAll(ml.Name, `"`, `_`))
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
c.String(http.StatusOK, html)
|
||||||
|
}
|
||||||
|
|
||||||
type modStatus struct {
|
type modStatus struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ func (h *Handler) SetupRoutes(r *gin.Engine) {
|
|||||||
api.GET("/modlists/:id/check", h.CheckModlistMods)
|
api.GET("/modlists/:id/check", h.CheckModlistMods)
|
||||||
api.POST("/modlists/:id/download-missing", h.DownloadMissingMods)
|
api.POST("/modlists/:id/download-missing", h.DownloadMissingMods)
|
||||||
api.POST("/modlists/:id/update-all", h.UpdateAllMods)
|
api.POST("/modlists/:id/update-all", h.UpdateAllMods)
|
||||||
|
api.GET("/modlists/:id/export", h.ExportModlist)
|
||||||
|
|
||||||
api.GET("/mods", h.ListMods)
|
api.GET("/mods", h.ListMods)
|
||||||
api.DELETE("/mods", h.DeleteMod)
|
api.DELETE("/mods", h.DeleteMod)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
@@ -11,6 +13,42 @@ import (
|
|||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const modlistHTMLTmpl = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="arma:Type" content="list"/>
|
||||||
|
<meta name="generator" content="Arma 3 Web Server"/>
|
||||||
|
<title>Arma 3</title>
|
||||||
|
<style>
|
||||||
|
body{margin:0;padding:0;color:#fff;background:#000;font:95%/1.3 Roboto,Segoe UI,Tahoma,Arial,Helvetica,sans-serif}
|
||||||
|
td{padding:3px 30px 3px 0}
|
||||||
|
h1{padding:20px 20px 0;color:#fff;font-weight:200;font-family:segoe ui;font-size:3em;margin:0}
|
||||||
|
.before-list{padding:5px 20px 10px}
|
||||||
|
.mod-list{background:#222;padding:20px}
|
||||||
|
.footer{padding:20px;color:gray}
|
||||||
|
.from-steam{color:#449EBD}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Arma 3 Mods</h1>
|
||||||
|
<p class="before-list">
|
||||||
|
<em>To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file.</em>
|
||||||
|
</p>
|
||||||
|
<div class="mod-list">
|
||||||
|
<table>{{range .Mods}}
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">{{.Name}}</td>
|
||||||
|
<td><span class="from-steam">Steam</span></td>
|
||||||
|
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id={{.ID}}" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id={{.ID}}</a></td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<span>Created by Arma 3 Web Server.</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
func ParseModlistHTML(r io.Reader) (string, []models.ModEntry, error) {
|
func ParseModlistHTML(r io.Reader) (string, []models.ModEntry, error) {
|
||||||
doc, err := html.Parse(r)
|
doc, err := html.Parse(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,3 +152,32 @@ func FileNameToModlistName(filename string) string {
|
|||||||
name = strings.ReplaceAll(name, "-", " ")
|
name = strings.ReplaceAll(name, "-", " ")
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type renderMod struct {
|
||||||
|
Name string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderModlistHTML(name string, mods []models.ModEntry) (string, error) {
|
||||||
|
tmpl, err := template.New("modlist").Parse(modlistHTMLTmpl)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderMods []renderMod
|
||||||
|
for _, m := range mods {
|
||||||
|
if m.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
renderMods = append(renderMods, renderMod{
|
||||||
|
Name: m.Name,
|
||||||
|
ID: m.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, struct{ Mods []renderMod }{renderMods}); err != nil {
|
||||||
|
return "", fmt.Errorf("execute template: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const modlistsApi = {
|
|||||||
return r.json() as Promise<Modlist>
|
return r.json() as Promise<Modlist>
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
exportUrl: (id: string) => `${BASE}/modlists/${id}/export`,
|
||||||
checkMods: (id: string) => request<ModStatus[]>(`/modlists/${id}/check`),
|
checkMods: (id: string) => request<ModStatus[]>(`/modlists/${id}/check`),
|
||||||
downloadMissing: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/download-missing`, { method: 'POST' }),
|
downloadMissing: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/download-missing`, { method: 'POST' }),
|
||||||
updateAll: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/update-all`, { method: 'POST' }),
|
updateAll: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/update-all`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ export default function ModlistEditor() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<a
|
||||||
|
href={modlistsApi.exportUrl(id!)}
|
||||||
|
download
|
||||||
|
className="px-3 py-1.5 bg-neutral-700 hover:bg-neutral-600 rounded text-sm"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
<button onClick={() => saveMut.mutate()} disabled={!dirty || saveMut.isPending} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-sm">
|
<button onClick={() => saveMut.mutate()} disabled={!dirty || saveMut.isPending} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-sm">
|
||||||
{saveMut.isPending ? 'Saving...' : 'Save'}
|
{saveMut.isPending ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user