node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,10 @@
# Codegen
Run after adding/removing/updating:
- A struct returned by a route handler.
- A route handler.
- A route endpoint.
Code is generated in the `./codegen` directory and in `../seanime-web/src/api/generated`.
Make sure the web codebase is up-to-date after running this script.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
package codegen
import (
"seanime/internal/api/anilist"
hibiketorrent "seanime/internal/extension/hibike/torrent"
)
//type Struct1 struct {
// Struct2
//}
//
//type Struct2 struct {
// Text string `json:"text"`
//}
//type Struct3 []string
type Struct4 struct {
Torrents []hibiketorrent.AnimeTorrent `json:"torrents"`
Destination string `json:"destination"`
SmartSelect struct {
Enabled bool `json:"enabled"`
MissingEpisodeNumbers []int `json:"missingEpisodeNumbers"`
} `json:"smartSelect"`
Media *anilist.BaseAnime `json:"media"`
}

View File

@@ -0,0 +1,308 @@
package codegen
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
)
type (
RouteHandler struct {
Name string `json:"name"`
TrimmedName string `json:"trimmedName"`
Comments []string `json:"comments"`
Filepath string `json:"filepath"`
Filename string `json:"filename"`
Api *RouteHandlerApi `json:"api"`
}
RouteHandlerApi struct {
Summary string `json:"summary"`
Descriptions []string `json:"descriptions"`
Endpoint string `json:"endpoint"`
Methods []string `json:"methods"`
Params []*RouteHandlerParam `json:"params"`
BodyFields []*RouteHandlerParam `json:"bodyFields"`
Returns string `json:"returns"`
ReturnGoType string `json:"returnGoType"`
ReturnTypescriptType string `json:"returnTypescriptType"`
}
RouteHandlerParam struct {
Name string `json:"name"`
JsonName string `json:"jsonName"`
GoType string `json:"goType"` // e.g., []models.User
InlineStructType string `json:"inlineStructType,omitempty"` // e.g., struct{Test string `json:"test"`}
UsedStructType string `json:"usedStructType"` // e.g., models.User
TypescriptType string `json:"typescriptType"` // e.g., Array<User>
Required bool `json:"required"`
Descriptions []string `json:"descriptions"`
}
)
func GenerateHandlers(dir string, outDir string) {
handlers := make([]*RouteHandler, 0)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(info.Name(), ".go") || strings.HasPrefix(info.Name(), "_") {
return nil
}
// Parse the file
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments)
if err != nil {
return err
}
for _, decl := range file.Decls {
// Check if the declaration is a function
fn, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
// Check if the function has comments
if fn.Doc == nil {
continue
}
// Get the comments
comments := strings.Split(fn.Doc.Text(), "\n")
if len(comments) == 0 {
continue
}
// Get the function name
name := fn.Name.Name
trimmedName := strings.TrimPrefix(name, "Handle")
// Get the filename
filep := strings.ReplaceAll(strings.ReplaceAll(path, "\\", "/"), "../", "")
filename := filepath.Base(path)
// Get the endpoint
endpoint := ""
var methods []string
params := make([]*RouteHandlerParam, 0)
summary := ""
descriptions := make([]string, 0)
returns := "bool"
for _, comment := range comments {
cmt := strings.TrimSpace(strings.TrimPrefix(comment, "//"))
if strings.HasPrefix(cmt, "@summary") {
summary = strings.TrimSpace(strings.TrimPrefix(cmt, "@summary"))
}
if strings.HasPrefix(cmt, "@desc") {
descriptions = append(descriptions, strings.TrimSpace(strings.TrimPrefix(cmt, "@desc")))
}
if strings.HasPrefix(cmt, "@route") {
endpointParts := strings.Split(strings.TrimSpace(strings.TrimPrefix(cmt, "@route")), " ")
if len(endpointParts) == 2 {
endpoint = endpointParts[0]
methods = strings.Split(endpointParts[1][1:len(endpointParts[1])-1], ",")
}
}
if strings.HasPrefix(cmt, "@param") {
paramParts := strings.Split(strings.TrimSpace(strings.TrimPrefix(cmt, "@param")), " - ")
if len(paramParts) == 4 {
required := paramParts[2] == "true"
params = append(params, &RouteHandlerParam{
Name: paramParts[0],
JsonName: paramParts[0],
GoType: paramParts[1],
TypescriptType: goTypeToTypescriptType(paramParts[1]),
Required: required,
Descriptions: []string{strings.ReplaceAll(paramParts[3], "\"", "")},
})
}
}
if strings.HasPrefix(cmt, "@returns") {
returns = strings.TrimSpace(strings.TrimPrefix(cmt, "@returns"))
}
}
bodyFields := make([]*RouteHandlerParam, 0)
// To get the request body fields, we need to look at the function body for a struct called "body"
// Get the function body
body := fn.Body
if body != nil {
for _, stmt := range body.List {
// Check if the statement is a declaration
declStmt, ok := stmt.(*ast.DeclStmt)
if !ok {
continue
}
// Check if the declaration is a gen decl
genDecl, ok := declStmt.Decl.(*ast.GenDecl)
if !ok {
continue
}
// Check if the declaration is a type
if genDecl.Tok != token.TYPE {
continue
}
// Check if the type is a struct
if len(genDecl.Specs) != 1 {
continue
}
typeSpec, ok := genDecl.Specs[0].(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
// Check if the struct is called "body"
if typeSpec.Name.Name != "body" {
continue
}
// Get the fields
for _, field := range structType.Fields.List {
// Get the field name
fieldName := field.Names[0].Name
// Get the field type
fieldType := field.Type
jsonName := fieldName
// Get the field tag
required := !jsonFieldOmitEmpty(field)
jsonField := jsonFieldName(field)
if jsonField != "" {
jsonName = jsonField
}
// Get field comments
fieldComments := make([]string, 0)
cmtsTxt := field.Doc.Text()
if cmtsTxt != "" {
fieldComments = strings.Split(cmtsTxt, "\n")
}
for _, cmt := range fieldComments {
cmt = strings.TrimSpace(strings.TrimPrefix(cmt, "//"))
if cmt != "" {
fieldComments = append(fieldComments, cmt)
}
}
switch fieldType.(type) {
case *ast.StarExpr:
required = false
}
goType := fieldTypeString(fieldType)
goTypeUnformatted := fieldTypeUnformattedString(fieldType)
packageName := "handlers"
if strings.Contains(goTypeUnformatted, ".") {
parts := strings.Split(goTypeUnformatted, ".")
packageName = parts[0]
}
tsType := fieldTypeToTypescriptType(fieldType, packageName)
usedStructType := goTypeUnformatted
switch goTypeUnformatted {
case "string", "int", "int64", "float64", "float32", "bool", "nil", "uint", "uint64", "uint32", "uint16", "uint8", "byte", "rune", "[]byte", "interface{}", "error":
usedStructType = ""
}
// Add the request body field
bodyFields = append(bodyFields, &RouteHandlerParam{
Name: fieldName,
JsonName: jsonName,
GoType: goType,
UsedStructType: usedStructType,
TypescriptType: tsType,
Required: required,
Descriptions: fieldComments,
})
// Check if it's an inline struct and capture its definition
if structType, ok := fieldType.(*ast.StructType); ok {
bodyFields[len(bodyFields)-1].InlineStructType = formatInlineStruct(structType)
} else {
// Check if it's a slice of inline structs
if arrayType, ok := fieldType.(*ast.ArrayType); ok {
if structType, ok := arrayType.Elt.(*ast.StructType); ok {
bodyFields[len(bodyFields)-1].InlineStructType = "[]" + formatInlineStruct(structType)
}
}
// Check if it's a map with inline struct values
if mapType, ok := fieldType.(*ast.MapType); ok {
if structType, ok := mapType.Value.(*ast.StructType); ok {
bodyFields[len(bodyFields)-1].InlineStructType = "map[" + fieldTypeString(mapType.Key) + "]" + formatInlineStruct(structType)
}
}
}
}
}
}
// Add the route handler
routeHandler := &RouteHandler{
Name: name,
TrimmedName: trimmedName,
Comments: comments,
Filepath: filep,
Filename: filename,
Api: &RouteHandlerApi{
Summary: summary,
Descriptions: descriptions,
Endpoint: endpoint,
Methods: methods,
Params: params,
BodyFields: bodyFields,
Returns: returns,
ReturnGoType: getUnformattedGoType(returns),
ReturnTypescriptType: stringGoTypeToTypescriptType(returns),
},
}
handlers = append(handlers, routeHandler)
}
return nil
})
if err != nil {
panic(err)
}
// Write structs to file
_ = os.MkdirAll(outDir, os.ModePerm)
file, err := os.Create(outDir + "/handlers.json")
if err != nil {
fmt.Println("Error:", err)
return
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(handlers); err != nil {
fmt.Println("Error:", err)
return
}
return
}

View File

@@ -0,0 +1,146 @@
package codegen
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GenerateHandlerHookEvents generates hook_events.go file for handlers
func GenerateHandlerHookEvents(handlersJsonPath string, outputDir string) {
// Create output directory if it doesn't exist
err := os.MkdirAll(outputDir, os.ModePerm)
if err != nil {
panic(err)
}
// Read handlers.json
handlersJson, err := os.ReadFile(handlersJsonPath)
if err != nil {
panic(err)
}
// Parse handlers.json
var handlers []RouteHandler
err = json.Unmarshal(handlersJson, &handlers)
if err != nil {
panic(err)
}
// Create hook_events.go file
outFilePath := filepath.Join(outputDir, "hook_events.go")
f, err := os.Create(outFilePath)
if err != nil {
panic(err)
}
defer f.Close()
// Write package declaration and imports
f.WriteString("package handlers\n\n")
f.WriteString("import (\n")
//f.WriteString("\t\"seanime/internal/hook_resolver\"\n")
imports := []string{
"\"seanime/internal/api/anilist\"",
"\"seanime/internal/api/tvdb\"",
"\"seanime/internal/continuity\"",
"\"seanime/internal/database/models\"",
"\"seanime/internal/debrid/client\"",
"\"seanime/internal/debrid/debrid\"",
"\"seanime/internal/extension\"",
"hibikemanga \"seanime/internal/extension/hibike/manga\"",
"hibikeonlinestream \"seanime/internal/extension/hibike/onlinestream\"",
"hibiketorrent \"seanime/internal/extension/hibike/torrent\"",
"\"seanime/internal/extension_playground\"",
"\"seanime/internal/extension_repo\"",
"\"seanime/internal/hook_resolver\"",
"\"seanime/internal/library/anime\"",
"\"seanime/internal/library/summary\"",
"\"seanime/internal/manga\"",
"\"seanime/internal/manga/downloader\"",
"\"seanime/internal/mediastream\"",
"\"seanime/internal/onlinestream\"",
"\"seanime/internal/report\"",
"\"seanime/internal/sync\"",
"\"seanime/internal/torrent_clients/torrent_client\"",
"\"seanime/internal/torrents/torrent\"",
"\"seanime/internal/torrentstream\"",
"\"seanime/internal/updater\"",
}
for _, imp := range imports {
f.WriteString("\t" + imp + "\n")
}
f.WriteString(")\n\n")
// Generate events for each handler
for _, handler := range handlers {
// Skip if handler name is empty or doesn't start with 'Handle'
if handler.Name == "" || !strings.HasPrefix(handler.Name, "Handle") {
continue
}
// Generate the "Requested" event
f.WriteString(fmt.Sprintf("// %sRequestedEvent is triggered when %s is requested.\n", handler.Name, handler.TrimmedName))
f.WriteString("// Prevent default to skip the default behavior and return your own data.\n")
f.WriteString(fmt.Sprintf("type %sRequestedEvent struct {\n", handler.Name))
f.WriteString("\thook_resolver.Event\n")
// Add path parameters
for _, param := range handler.Api.Params {
f.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", pascalCase(param.Name), param.GoType, param.JsonName))
}
// Add body fields
for _, field := range handler.Api.BodyFields {
goType := field.GoType
if goType == "__STRUCT__" || goType == "[]__STRUCT__" || (strings.HasPrefix(goType, "map[") && strings.Contains(goType, "__STRUCT__")) {
goType = field.InlineStructType
}
goType = strings.Replace(goType, "handlers.", "", 1)
addPointer := isCustomStruct(goType)
if addPointer {
goType = "*" + goType
}
f.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", pascalCase(field.Name), goType, field.JsonName))
}
// If handler returns something other than bool or true, add a Data field to store the result
if handler.Api.ReturnGoType != "" && handler.Api.ReturnGoType != "true" && handler.Api.ReturnGoType != "bool" {
returnGoType := strings.Replace(handler.Api.ReturnGoType, "handlers.", "", 1)
addPointer := isCustomStruct(returnGoType)
if addPointer {
returnGoType = "*" + returnGoType
}
f.WriteString(fmt.Sprintf("\t// Empty data object, will be used if the hook prevents the default behavior\n"))
f.WriteString(fmt.Sprintf("\tData %s `json:\"data\"`\n", returnGoType))
}
f.WriteString("}\n\n")
// Generate the response event if handler returns something other than bool or true
if handler.Api.ReturnGoType != "" && handler.Api.ReturnGoType != "true" && handler.Api.ReturnGoType != "bool" {
returnGoType := strings.Replace(handler.Api.ReturnGoType, "handlers.", "", 1)
addPointer := isCustomStruct(returnGoType)
if addPointer {
returnGoType = "*" + returnGoType
}
f.WriteString(fmt.Sprintf("// %sEvent is triggered after processing %s.\n", handler.Name, handler.TrimmedName))
f.WriteString(fmt.Sprintf("type %sEvent struct {\n", handler.Name))
f.WriteString("\thook_resolver.Event\n")
f.WriteString(fmt.Sprintf("\tData %s `json:\"data\"`\n", returnGoType))
f.WriteString("}\n\n")
}
}
cmd := exec.Command("gofmt", "-w", outFilePath)
cmd.Run()
}
func pascalCase(s string) string {
return strings.ReplaceAll(strings.Title(strings.ReplaceAll(s, "_", " ")), " ", "")
}

View File

@@ -0,0 +1,797 @@
package codegen
import (
"cmp"
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"slices"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var (
additionalStructNamesForHooks = []string{
"discordrpc_presence.MangaActivity",
"discordrpc_presence.AnimeActivity",
"discordrpc_presence.LegacyAnimeActivity",
"anilist.ListAnime",
"anilist.ListManga",
"anilist.MediaSort",
"anilist.ListRecentAnime",
"anilist.AnimeCollectionWithRelations",
"onlinestream.Episode",
"continuity.WatchHistoryItem",
"continuity.WatchHistoryItemResponse",
"continuity.UpdateWatchHistoryItemOptions",
"continuity.WatchHistory",
"torrent_client.Torrent",
}
)
func GeneratePluginEventFile(inFilePath string, outDir string) {
// Parse the input file
file, err := parser.ParseFile(token.NewFileSet(), inFilePath, nil, parser.ParseComments)
if err != nil {
panic(err)
}
// Create output directory if it doesn't exist
_ = os.MkdirAll(outDir, os.ModePerm)
const OutFileName = "plugin-events.ts"
// Create output file
f, err := os.Create(filepath.Join(outDir, OutFileName))
if err != nil {
panic(err)
}
defer f.Close()
// Write imports
f.WriteString(`// This file is auto-generated. Do not edit.
import { useWebsocketPluginMessageListener, useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets"
import { useCallback } from "react"
`)
// Extract client and server event types
clientEvents := make([]string, 0)
serverEvents := make([]string, 0)
clientPayloads := make(map[string]string)
serverPayloads := make(map[string]string)
clientEventValues := make(map[string]string)
serverEventValues := make(map[string]string)
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
// Find const declarations
if genDecl.Tok == token.CONST {
for _, spec := range genDecl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
if len(valueSpec.Names) == 1 && len(valueSpec.Values) == 1 {
name := valueSpec.Names[0].Name
if strings.HasPrefix(name, "Client") && strings.HasSuffix(name, "Event") {
eventName := name[len("Client") : len(name)-len("Event")]
// Get the string literal value for the enum
if basicLit, ok := valueSpec.Values[0].(*ast.BasicLit); ok {
eventValue := strings.Trim(basicLit.Value, "\"")
clientEvents = append(clientEvents, eventName)
// Get payload type name
payloadType := name + "Payload"
clientPayloads[eventName] = payloadType
// Store the original string value
clientEventValues[eventName] = eventValue
}
} else if strings.HasPrefix(name, "Server") && strings.HasSuffix(name, "Event") {
eventName := name[len("Server") : len(name)-len("Event")]
// Get the string literal value for the enum
if basicLit, ok := valueSpec.Values[0].(*ast.BasicLit); ok {
eventValue := strings.Trim(basicLit.Value, "\"")
serverEvents = append(serverEvents, eventName)
// Get payload type name
payloadType := name + "Payload"
serverPayloads[eventName] = payloadType
// Store the original string value
serverEventValues[eventName] = eventValue
}
}
}
}
}
}
// Write enums
f.WriteString("export enum PluginClientEvents {\n")
for _, event := range clientEvents {
enumName := toPascalCase(event)
f.WriteString(fmt.Sprintf(" %s = \"%s\",\n", enumName, clientEventValues[event]))
}
f.WriteString("}\n\n")
f.WriteString("export enum PluginServerEvents {\n")
for _, event := range serverEvents {
enumName := toPascalCase(event)
f.WriteString(fmt.Sprintf(" %s = \"%s\",\n", enumName, serverEventValues[event]))
}
f.WriteString("}\n\n")
// Write client to server section
f.WriteString("/////////////////////////////////////////////////////////////////////////////////////\n")
f.WriteString("// Client to server\n")
f.WriteString("/////////////////////////////////////////////////////////////////////////////////////\n\n")
// Write client event types and hooks
for _, event := range clientEvents {
// Get the payload type
payloadType := clientPayloads[event]
payloadFound := false
// Find the payload type in the AST
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
if genDecl.Tok == token.TYPE {
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if typeSpec.Name.Name == payloadType {
payloadFound = true
// Write the payload type
f.WriteString(fmt.Sprintf("export type Plugin_Client_%sEventPayload = {\n", toPascalCase(event)))
if structType, ok := typeSpec.Type.(*ast.StructType); ok {
for _, field := range structType.Fields.List {
if len(field.Names) > 0 {
fieldName := jsonFieldName(field)
fieldType := fieldTypeToTypescriptType(field.Type, "")
f.WriteString(fmt.Sprintf(" %s: %s\n", fieldName, fieldType))
}
}
}
f.WriteString("}\n\n")
// Write the hook
hookName := fmt.Sprintf("usePluginSend%sEvent", toPascalCase(event))
f.WriteString(fmt.Sprintf("export function %s() {\n", hookName))
f.WriteString(" const { sendPluginMessage } = useWebsocketSender()\n")
f.WriteString("\n")
f.WriteString(fmt.Sprintf(" const send%sEvent = useCallback((payload: Plugin_Client_%sEventPayload, extensionID?: string) => {\n",
toPascalCase(event), toPascalCase(event)))
f.WriteString(fmt.Sprintf(" sendPluginMessage(PluginClientEvents.%s, payload, extensionID)\n",
toPascalCase(event)))
f.WriteString(" }, [])\n")
f.WriteString("\n")
f.WriteString(" return {\n")
f.WriteString(fmt.Sprintf(" send%sEvent,\n", toPascalCase(event)))
f.WriteString(" }\n")
f.WriteString("}\n\n")
}
}
}
}
// If payload type not found, write empty object type
if !payloadFound {
f.WriteString(fmt.Sprintf("export type Plugin_Client_%sEventPayload = {}\n\n", toPascalCase(event)))
// Write the hook
hookName := fmt.Sprintf("usePluginSend%sEvent", toPascalCase(event))
f.WriteString(fmt.Sprintf("export function %s() {\n", hookName))
f.WriteString(" const { sendPluginMessage } = useWebsocketSender()\n")
f.WriteString("\n")
f.WriteString(fmt.Sprintf(" const sendPlugin%sEvent = useCallback((payload: Plugin_Client_%sEventPayload, extensionID?: string) => {\n",
toPascalCase(event), toPascalCase(event)))
f.WriteString(fmt.Sprintf(" sendPluginMessage(PluginClientEvents.%s, payload, extensionID)\n",
toPascalCase(event)))
f.WriteString(" }, [])\n")
f.WriteString("\n")
f.WriteString(" return {\n")
f.WriteString(fmt.Sprintf(" send%sEvent,\n", toPascalCase(event)))
f.WriteString(" }\n")
f.WriteString("}\n\n")
}
}
// Write server to client section
f.WriteString("/////////////////////////////////////////////////////////////////////////////////////\n")
f.WriteString("// Server to client\n")
f.WriteString("/////////////////////////////////////////////////////////////////////////////////////\n\n")
// Write server event types and hooks
for _, event := range serverEvents {
// Get the payload type
payloadType := serverPayloads[event]
payloadFound := false
// Find the payload type in the AST
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
if genDecl.Tok == token.TYPE {
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if typeSpec.Name.Name == payloadType {
payloadFound = true
// Write the payload type
f.WriteString(fmt.Sprintf("export type Plugin_Server_%sEventPayload = {\n", toPascalCase(event)))
if structType, ok := typeSpec.Type.(*ast.StructType); ok {
for _, field := range structType.Fields.List {
if len(field.Names) > 0 {
fieldName := jsonFieldName(field)
fieldType := fieldTypeToTypescriptType(field.Type, "")
f.WriteString(fmt.Sprintf(" %s: %s\n", fieldName, fieldType))
}
}
}
f.WriteString("}\n\n")
// Write the hook
hookName := fmt.Sprintf("usePluginListen%sEvent", toPascalCase(event))
f.WriteString(fmt.Sprintf("export function %s(cb: (payload: Plugin_Server_%sEventPayload, extensionId: string) => void, extensionID: string) {\n",
hookName, toPascalCase(event)))
f.WriteString(" return useWebsocketPluginMessageListener<Plugin_Server_" + toPascalCase(event) + "EventPayload>({\n")
f.WriteString(" extensionId: extensionID,\n")
f.WriteString(fmt.Sprintf(" type: PluginServerEvents.%s,\n", toPascalCase(event)))
f.WriteString(" onMessage: cb,\n")
f.WriteString(" })\n")
f.WriteString("}\n\n")
}
}
}
}
// If payload type not found, write empty object type
if !payloadFound {
f.WriteString(fmt.Sprintf("export type Plugin_Server_%sEventPayload = {}\n\n", toPascalCase(event)))
// Write the hook
hookName := fmt.Sprintf("usePluginListen%sEvent", toPascalCase(event))
f.WriteString(fmt.Sprintf("export function %s(cb: (payload: Plugin_Server_%sEventPayload, extensionId: string) => void, extensionID: string) {\n",
hookName, toPascalCase(event)))
f.WriteString(" return useWebsocketPluginMessageListener<Plugin_Server_" + toPascalCase(event) + "EventPayload>({\n")
f.WriteString(" extensionId: extensionID,\n")
f.WriteString(fmt.Sprintf(" type: PluginServerEvents.%s,\n", toPascalCase(event)))
f.WriteString(" onMessage: cb,\n")
f.WriteString(" })\n")
f.WriteString("}\n\n")
}
}
}
var execptions = map[string]string{
"playbackmanager": "PlaybackManager ",
}
func toPascalCase(s string) string {
if exception, ok := execptions[s]; ok {
return exception
}
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "_", " ")
s = cases.Title(language.English, cases.NoLower).String(s)
return strings.ReplaceAll(s, " ", "")
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type HookEventDefinition struct {
Package string `json:"package"`
GoStruct *GoStruct `json:"goStruct"`
}
func GeneratePluginHooksDefinitionFile(outDir string, publicStructsFilePath string, genOutDir string) {
// Create output file
f, err := os.Create(filepath.Join(outDir, "app.d.ts"))
if err != nil {
panic(err)
}
defer f.Close()
mdFile, err := os.Create(filepath.Join(genOutDir, "hooks.mdx"))
if err != nil {
panic(err)
}
defer mdFile.Close()
goStructs := LoadPublicStructs(publicStructsFilePath)
// e.g. map["models.User"]*GoStruct
goStructsMap := make(map[string]*GoStruct)
for _, goStruct := range goStructs {
goStructsMap[goStruct.Package+"."+goStruct.Name] = goStruct
}
// Expand the structs with embedded structs
for _, goStruct := range goStructs {
for _, embeddedStructType := range goStruct.EmbeddedStructTypes {
if embeddedStructType != "" {
if usedStruct, ok := goStructsMap[embeddedStructType]; ok {
for _, usedField := range usedStruct.Fields {
goStruct.Fields = append(goStruct.Fields, usedField)
}
}
}
}
}
// Key = package
eventGoStructsMap := make(map[string][]*GoStruct)
for _, goStruct := range goStructs {
if goStruct.Filename == "hook_events.go" {
if _, ok := eventGoStructsMap[goStruct.Package]; !ok {
eventGoStructsMap[goStruct.Package] = make([]*GoStruct, 0)
}
eventGoStructsMap[goStruct.Package] = append(eventGoStructsMap[goStruct.Package], goStruct)
}
}
// Create `hooks.json`
hookEventDefinitions := make([]*HookEventDefinition, 0)
for _, goStruct := range goStructs {
if goStruct.Filename == "hook_events.go" {
hookEventDefinitions = append(hookEventDefinitions, &HookEventDefinition{
Package: goStruct.Package,
GoStruct: goStruct,
})
}
}
jsonFile, err := os.Create(filepath.Join(genOutDir, "hooks.json"))
if err != nil {
panic(err)
}
defer jsonFile.Close()
encoder := json.NewEncoder(jsonFile)
encoder.SetIndent("", " ")
if err := encoder.Encode(hookEventDefinitions); err != nil {
fmt.Println("Error:", err)
return
}
////////////////////////////////////////////////////
// Write `app.d.ts`
// Write namespace declaration
////////////////////////////////////////////////////
f.WriteString("declare namespace $app {\n")
packageNames := make([]string, 0)
for packageName := range eventGoStructsMap {
packageNames = append(packageNames, packageName)
}
slices.Sort(packageNames)
//////////////////////////////////////////////////////////
// Get referenced structs so we can write them at the end
//////////////////////////////////////////////////////////
sharedStructs := make([]*GoStruct, 0)
otherStructs := make([]*GoStruct, 0)
// Go through all the event structs' fields, and get the types that are structs
sharedStructsMap := make(map[string]*GoStruct)
for _, goStructs := range eventGoStructsMap {
for _, goStruct := range goStructs {
for _, field := range goStruct.Fields {
if isCustomStruct(field.GoType) {
if _, ok := sharedStructsMap[field.GoType]; !ok && goStructsMap[field.UsedStructType] != nil {
sharedStructsMap[field.UsedStructType] = goStructsMap[field.UsedStructType]
}
}
}
}
}
// Add additional structs to otherStructs
for _, structName := range additionalStructNamesForHooks {
if _, ok := sharedStructsMap[structName]; !ok {
sharedStructsMap[structName] = goStructsMap[structName]
}
}
for _, goStruct := range sharedStructsMap {
//fmt.Println(goStruct.FormattedName)
if goStruct.Package != "" {
sharedStructs = append(sharedStructs, goStruct)
}
}
referencedStructsMap, ok := getReferencedStructsRecursively(sharedStructs, otherStructs, goStructsMap)
if !ok {
panic("Failed to get referenced structs")
}
for _, packageName := range packageNames {
writePackageEventGoStructs(f, packageName, eventGoStructsMap[packageName], goStructsMap)
}
f.WriteString(" ///////////////////////////////////////////////////////////////////////////////////////////////////////////////\n")
f.WriteString(" ///////////////////////////////////////////////////////////////////////////////////////////////////////////////\n")
f.WriteString(" ///////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n")
referencedStructs := make([]*GoStruct, 0)
for _, goStruct := range referencedStructsMap {
//fmt.Println(goStruct.FormattedName)
referencedStructs = append(referencedStructs, goStruct)
}
slices.SortFunc(referencedStructs, func(a, b *GoStruct) int {
return strings.Compare(a.FormattedName, b.FormattedName)
})
// Write the shared structs at the end
for _, goStruct := range referencedStructs {
if goStruct.Package != "" {
writeEventTypescriptType(f, goStruct, make(map[string]*GoStruct))
}
}
f.WriteString("}\n")
// Generate markdown documentation
writeMarkdownFile(mdFile, hookEventDefinitions, referencedStructsMap, referencedStructs)
}
func writePackageEventGoStructs(f *os.File, packageName string, goStructs []*GoStruct, allGoStructs map[string]*GoStruct) {
// Header comment block
f.WriteString(fmt.Sprintf("\n /**\n * @package %s\n */\n\n", packageName))
// Declare the hook functions
for _, goStruct := range goStructs {
// Write comments
comments := ""
comments += fmt.Sprintf("\n * @event %s\n", goStruct.Name)
comments += fmt.Sprintf(" * @file %s\n", strings.TrimPrefix(goStruct.Filepath, "../"))
shouldAddPreventDefault := false
if len(goStruct.Comments) > 0 {
comments += fmt.Sprintf(" * @description\n")
}
for _, comment := range goStruct.Comments {
if strings.Contains(strings.ToLower(comment), "prevent default") {
shouldAddPreventDefault = true
}
comments += fmt.Sprintf(" * %s\n", strings.TrimSpace(comment))
}
f.WriteString(fmt.Sprintf(" /**%s */\n", comments))
//////// Write hook function
f.WriteString(fmt.Sprintf(" function on%s(cb: (event: %s) => void): void;\n\n", strings.TrimSuffix(goStruct.Name, "Event"), goStruct.Name))
/////// Write event interface
f.WriteString(fmt.Sprintf(" interface %s {\n", goStruct.Name))
f.WriteString(fmt.Sprintf(" next(): void;\n\n"))
if shouldAddPreventDefault {
f.WriteString(fmt.Sprintf(" preventDefault(): void;\n\n"))
}
// Write the fields
for _, field := range goStruct.Fields {
if field.Name == "next" || field.Name == "preventDefault" || field.Name == "DefaultPrevented" {
continue
}
if field.JsonName == "" {
continue
}
// Field type
fieldNameSuffix := ""
if !field.Required {
fieldNameSuffix = "?"
}
if len(field.Comments) > 0 {
f.WriteString(fmt.Sprintf(" /**\n"))
for _, cmt := range field.Comments {
f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt)))
}
f.WriteString(fmt.Sprintf(" */\n"))
}
typeText := field.TypescriptType
f.WriteString(fmt.Sprintf(" %s%s: %s;\n", field.JsonName, fieldNameSuffix, typeText))
}
f.WriteString(fmt.Sprintf(" }\n\n"))
}
}
func writeEventTypescriptType(f *os.File, goStruct *GoStruct, writtenTypes map[string]*GoStruct) {
f.WriteString(" /**\n")
f.WriteString(fmt.Sprintf(" * - Filepath: %s\n", strings.TrimPrefix(goStruct.Filepath, "../")))
if len(goStruct.Comments) > 0 {
f.WriteString(fmt.Sprintf(" * @description\n"))
for _, cmt := range goStruct.Comments {
f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt)))
}
}
f.WriteString(" */\n")
if len(goStruct.Fields) > 0 {
f.WriteString(fmt.Sprintf(" interface %s {\n", goStruct.FormattedName))
for _, field := range goStruct.Fields {
fieldNameSuffix := ""
if !field.Required {
fieldNameSuffix = "?"
}
if field.JsonName == "" {
continue
}
if len(field.Comments) > 0 {
f.WriteString(fmt.Sprintf(" /**\n"))
for _, cmt := range field.Comments {
f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt)))
}
f.WriteString(fmt.Sprintf(" */\n"))
}
typeText := field.TypescriptType
if typeText == "Habari_Metadata" {
typeText = "$habari.Metadata"
}
f.WriteString(fmt.Sprintf(" %s%s: %s;\n", field.JsonName, fieldNameSuffix, typeText))
}
f.WriteString(" }\n\n")
}
if goStruct.AliasOf != nil {
if goStruct.AliasOf.DeclaredValues != nil && len(goStruct.AliasOf.DeclaredValues) > 0 {
union := ""
if len(goStruct.AliasOf.DeclaredValues) > 5 {
union = strings.Join(goStruct.AliasOf.DeclaredValues, " |\n ")
} else {
union = strings.Join(goStruct.AliasOf.DeclaredValues, " | ")
}
f.WriteString(fmt.Sprintf(" export type %s = %s;\n\n", goStruct.FormattedName, union))
} else {
f.WriteString(fmt.Sprintf(" export type %s = %s;\n\n", goStruct.FormattedName, goStruct.AliasOf.TypescriptType))
}
}
// Add the struct to the written types
writtenTypes[goStruct.Package+"."+goStruct.Name] = goStruct
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// writeMarkdownFile generates a well-formatted Markdown documentation for hooks
func writeMarkdownFile(mdFile *os.File, hookEventDefinitions []*HookEventDefinition, referencedStructsMap map[string]*GoStruct, referencedStructs []*GoStruct) {
mdFile.WriteString("---\n")
mdFile.WriteString("title: Hooks\n")
mdFile.WriteString("description: How to use hooks\n")
mdFile.WriteString("---")
mdFile.WriteString("\n\n")
// Group hooks by package
packageHooks := make(map[string][]*HookEventDefinition)
for _, hook := range hookEventDefinitions {
packageHooks[hook.Package] = append(packageHooks[hook.Package], hook)
}
// Sort packages alphabetically
packageNames := make([]string, 0, len(packageHooks))
for pkg := range packageHooks {
packageNames = append(packageNames, pkg)
}
slices.Sort(packageNames)
// Write each package section
for _, pkg := range packageNames {
hooks := packageHooks[pkg]
mdFile.WriteString(fmt.Sprintf("<a id=\"%s\"></a>\n", pkg))
mdFile.WriteString(fmt.Sprintf("# %s\n\n", toPascalCase(pkg)))
// Write each hook in the package
for _, hook := range hooks {
goStruct := hook.GoStruct
eventName := goStruct.Name
hookName := fmt.Sprintf("on%s", strings.TrimSuffix(eventName, "Event"))
mdFile.WriteString(fmt.Sprintf("<a id=\"on%s\"></a>\n", strings.ToLower(strings.TrimSuffix(eventName, "Event"))))
mdFile.WriteString(fmt.Sprintf("## %s\n\n", hookName))
// Write description
if len(goStruct.Comments) > 0 {
for _, comment := range goStruct.Comments {
mdFile.WriteString(fmt.Sprintf("%s\n", strings.TrimSpace(comment)))
}
mdFile.WriteString("\n")
}
// Check if it has preventDefault
hasPreventDefault := false
for _, comment := range goStruct.Comments {
if strings.Contains(strings.ToLower(comment), "prevent default") {
hasPreventDefault = true
break
}
}
if hasPreventDefault {
mdFile.WriteString("**Can prevent default:** Yes\n\n")
} else {
mdFile.WriteString("**Can prevent default:** No\n\n")
}
// Write event interface
mdFile.WriteString("**Event Interface:**\n\n")
mdFile.WriteString("```typescript\n")
mdFile.WriteString(fmt.Sprintf("interface %s {\n", eventName))
mdFile.WriteString(" next();\n")
if hasPreventDefault {
mdFile.WriteString(" preventDefault();\n")
}
// Write fields
for _, field := range goStruct.Fields {
if field.Name == "next" || field.Name == "preventDefault" || field.Name == "DefaultPrevented" {
continue
}
if field.JsonName == "" {
continue
}
fieldNameSuffix := ""
if !field.Required {
fieldNameSuffix = "?"
}
// Add comments if available
if len(field.Comments) > 0 {
mdFile.WriteString("\n /**\n")
for _, comment := range field.Comments {
mdFile.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(comment)))
}
mdFile.WriteString(" */\n")
}
mdFile.WriteString(fmt.Sprintf(" %s%s: %s;\n", field.JsonName, fieldNameSuffix, field.TypescriptType))
}
mdFile.WriteString("}\n")
mdFile.WriteString("```\n\n")
referenced := make([]*GoStruct, 0)
for _, field := range goStruct.Fields {
if !isCustomStruct(field.GoType) {
continue
}
goStruct, ok := referencedStructsMap[field.UsedStructType]
if !ok {
continue
}
referenced = append(referenced, goStruct)
}
// Add a list of referenced structs links
if len(referenced) > 0 {
mdFile.WriteString("**Event types:**\n\n")
}
for _, goStruct := range referenced {
mdFile.WriteString(fmt.Sprintf("- [%s](#%s)\n", goStruct.FormattedName, goStruct.FormattedName))
}
mdFile.WriteString("\n")
// Add example usage
mdFile.WriteString("**Example:**\n\n")
mdFile.WriteString("```typescript\n")
mdFile.WriteString(fmt.Sprintf("$app.%s((e) => {\n", hookName))
// Generate example code based on fields
for _, field := range goStruct.Fields {
if field.Name == "next" || field.Name == "preventDefault" || field.Name == "DefaultPrevented" {
continue
}
mdFile.WriteString(fmt.Sprintf(" // console.log(e.%s);\n", field.JsonName))
}
if hasPreventDefault {
mdFile.WriteString("\n // Prevent default behavior if needed\n")
mdFile.WriteString(" // e.preventDefault();\n")
}
mdFile.WriteString(" \n e.next();\n")
mdFile.WriteString("});\n")
mdFile.WriteString("```\n\n")
// Add separator between hooks
mdFile.WriteString("---\n\n")
}
}
// Write the referenced structs
if len(referencedStructs) > 0 {
mdFile.WriteString("\n# Referenced Types\n\n")
}
for _, goStruct := range referencedStructs {
mdFile.WriteString(fmt.Sprintf("#### %s\n\n", goStruct.FormattedName))
mdFile.WriteString(fmt.Sprintf("<div id=\"%s\"></div>\n\n", goStruct.FormattedName))
mdFile.WriteString(fmt.Sprintf("**Filepath:** `%s`\n\n", strings.TrimPrefix(goStruct.Filepath, "../")))
if len(goStruct.Fields) > 0 {
mdFile.WriteString("**Fields:**\n\n")
mdFile.WriteString("<Table>\n")
mdFile.WriteString("<TableCaption>Fields</TableCaption>\n")
mdFile.WriteString("<TableHeader>\n")
mdFile.WriteString("<TableRow>\n")
mdFile.WriteString("<TableHead>Property</TableHead>\n")
mdFile.WriteString("<TableHead>Type</TableHead>\n")
mdFile.WriteString("<TableHead>Description</TableHead>\n")
mdFile.WriteString("</TableRow>\n")
mdFile.WriteString("</TableHeader>\n")
mdFile.WriteString("<TableBody>\n")
for _, field := range goStruct.Fields {
mdFile.WriteString(fmt.Sprintf("<TableRow>\n"))
mdFile.WriteString(fmt.Sprintf("<TableCell className=\"py-1 px-2 max-w-[200px] break-all\">%s</TableCell>\n", field.JsonName))
typeContainsReference := false
if field.UsedStructType != "" && isCustomStruct(field.UsedStructType) {
typeContainsReference = true
}
if typeContainsReference {
link := fmt.Sprintf("<a href=\"#%s\">`%s`</a>", field.UsedTypescriptType, field.TypescriptType)
mdFile.WriteString(fmt.Sprintf("<TableCell className=\"py-1 px-2 break-all\">%s</TableCell>\n", link))
} else {
mdFile.WriteString(fmt.Sprintf("<TableCell className=\"py-1 px-2 break-all\">`%s`</TableCell>\n", field.TypescriptType))
}
mdFile.WriteString(fmt.Sprintf("<TableCell className=\"py-1 px-2 max-w-[200px] break-all\">%s</TableCell>\n", cmp.Or(strings.Join(field.Comments, "\n"), "-")))
mdFile.WriteString("</TableRow>\n")
}
mdFile.WriteString("</TableBody>\n")
mdFile.WriteString("</Table>\n")
}
if goStruct.AliasOf != nil {
if goStruct.AliasOf.DeclaredValues != nil && len(goStruct.AliasOf.DeclaredValues) > 0 {
union := ""
if len(goStruct.AliasOf.DeclaredValues) > 5 {
union = strings.Join(goStruct.AliasOf.DeclaredValues, " |\n ")
} else {
union = strings.Join(goStruct.AliasOf.DeclaredValues, " | ")
}
mdFile.WriteString(fmt.Sprintf("`%s`\n\n", union))
} else {
mdFile.WriteString(fmt.Sprintf("`%s`\n\n", goStruct.AliasOf.TypescriptType))
}
}
mdFile.WriteString("\n")
}
}

View File

@@ -0,0 +1,810 @@
package codegen
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"strings"
"unicode"
)
type GoStruct struct {
Filepath string `json:"filepath"`
Filename string `json:"filename"`
Name string `json:"name"`
FormattedName string `json:"formattedName"` // name with package prefix e.g. models.User => Models_User
Package string `json:"package"`
Fields []*GoStructField `json:"fields"`
AliasOf *GoAlias `json:"aliasOf,omitempty"`
Comments []string `json:"comments"`
EmbeddedStructTypes []string `json:"embeddedStructNames,omitempty"`
}
type GoAlias struct {
GoType string `json:"goType"`
TypescriptType string `json:"typescriptType"`
UsedTypescriptType string `json:"usedTypescriptType,omitempty"`
DeclaredValues []string `json:"declaredValues"`
UsedStructType string `json:"usedStructName,omitempty"`
}
type GoStructField struct {
Name string `json:"name"`
JsonName string `json:"jsonName"`
// e.g. map[string]models.User
GoType string `json:"goType"`
// e.g. []struct{Test string `json:"test"`, Test2 string `json:"test2"`}
InlineStructType string `json:"inlineStructType,omitempty"`
// e.g. User
TypescriptType string `json:"typescriptType"`
// e.g. TypescriptType = Array<Models_User> => UsedTypescriptType = Models_User
UsedTypescriptType string `json:"usedTypescriptType,omitempty"`
// e.g. GoType = map[string]models.User => TypescriptType = User => UsedStructType = models.User
UsedStructType string `json:"usedStructName,omitempty"`
// If no 'omitempty' and not a pointer
Required bool `json:"required"`
Public bool `json:"public"`
Comments []string `json:"comments"`
}
var typePrefixesByPackage = map[string]string{
"anilist": "AL_",
"auto_downloader": "AutoDownloader_",
"autodownloader": "AutoDownloader_",
"entities": "",
"db": "DB_",
"db_bridge": "DB_",
"models": "Models_",
"playbackmanager": "PlaybackManager_",
"torrent_client": "TorrentClient_",
"events": "Events_",
"torrent": "Torrent_",
"manga": "Manga_",
"autoscanner": "AutoScanner_",
"listsync": "ListSync_",
"util": "Util_",
"scanner": "Scanner_",
"offline": "Offline_",
"discordrpc": "DiscordRPC_",
"discordrpc_presence": "DiscordRPC_",
"anizip": "Anizip_",
"animap": "Animap_",
"onlinestream": "Onlinestream_",
"onlinestream_providers": "Onlinestream_",
"onlinestream_sources": "Onlinestream_",
"manga_providers": "Manga_",
"chapter_downloader": "ChapterDownloader_",
"manga_downloader": "MangaDownloader_",
"docs": "INTERNAL_",
"tvdb": "TVDB_",
"metadata": "Metadata_",
"mappings": "Mappings_",
"mal": "MAL_",
"handlers": "",
"animetosho": "AnimeTosho_",
"updater": "Updater_",
"anime": "Anime_",
"anime_types": "Anime_",
"summary": "Summary_",
"filesystem": "Filesystem_",
"filecache": "Filecache_",
"core": "INTERNAL_",
"comparison": "Comparison_",
"mediastream": "Mediastream_",
"torrentstream": "Torrentstream_",
"extension": "Extension_",
"extension_repo": "ExtensionRepo_",
//"vendor_hibike_manga": "HibikeManga_",
//"vendor_hibike_onlinestream": "HibikeOnlinestream_",
//"vendor_hibike_torrent": "HibikeTorrent_",
//"vendor_hibike_mediaplayer": "HibikeMediaPlayer_",
//"vendor_hibike_extension": "HibikeExtension_",
"hibikemanga": "HibikeManga_",
"hibikeonlinestream": "HibikeOnlinestream_",
"hibiketorrent": "HibikeTorrent_",
"hibikemediaplayer": "HibikeMediaPlayer_",
"hibikeextension": "HibikeExtension_",
"continuity": "Continuity_",
"local": "Local_",
"debrid": "Debrid_",
"debrid_client": "DebridClient_",
"report": "Report_",
"habari": "Habari_",
"vendor_habari": "Habari_",
"discordrpc_client": "DiscordRPC_",
"directstream": "Directstream_",
"nativeplayer": "NativePlayer_",
"mkvparser": "MKVParser_",
"nakama": "Nakama_",
}
func getTypePrefix(packageName string) string {
if prefix, ok := typePrefixesByPackage[packageName]; ok {
return prefix
}
return ""
}
func ExtractStructs(dir string, outDir string) {
structs := make([]*GoStruct, 0)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".go") {
res, err := getGoStructsFromFile(path, info)
if err != nil {
return err
}
structs = append(structs, res...)
}
return nil
})
if err != nil {
fmt.Println("Error:", err)
return
}
// Write structs to file
_ = os.MkdirAll(outDir, os.ModePerm)
file, err := os.Create(outDir + "/public_structs.json")
if err != nil {
fmt.Println("Error:", err)
return
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(structs); err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Public structs extracted and saved to public_structs.json")
}
func getGoStructsFromFile(path string, info os.FileInfo) (structs []*GoStruct, err error) {
// Parse the Go file
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
packageName := file.Name.Name
// Extract public structs
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
//
// Go through each type declaration
//
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if !typeSpec.Name.IsExported() {
continue
}
//
// The type declaration is an alias
// e.g. alias.Name: string, typeSpec.Name.Name: MediaListStatus
//
alias, ok := typeSpec.Type.(*ast.Ident)
if ok {
if alias.Name == typeSpec.Name.Name {
continue
}
goStruct := goStructFromAlias(path, info, genDecl, typeSpec, packageName, alias, file)
structs = append(structs, goStruct)
continue
}
//
// The type declaration is a struct
//
structType, ok := typeSpec.Type.(*ast.StructType)
if ok {
subStructs := make([]*GoStruct, 0)
for _, field := range structType.Fields.List {
if field.Names != nil && len(field.Names) > 0 {
subStructType, ok := field.Type.(*ast.StructType)
if ok {
name := fmt.Sprintf("%s_%s", typeSpec.Name.Name, field.Names[0].Name)
subStruct := goStructFromStruct(path, info, genDecl, name, packageName, subStructType)
subStructs = append(subStructs, subStruct)
continue
}
}
}
goStruct := goStructFromStruct(path, info, genDecl, typeSpec.Name.Name, packageName, structType)
// Replace struct fields with sub structs
for _, field := range goStruct.Fields {
if field.GoType == "__STRUCT__" {
for _, subStruct := range subStructs {
if subStruct.Name == fmt.Sprintf("%s_%s", typeSpec.Name.Name, field.Name) {
field.GoType = subStruct.FormattedName
field.TypescriptType = subStruct.FormattedName
field.UsedStructType = fmt.Sprintf("%s.%s", subStruct.Package, subStruct.Name)
break
}
}
}
}
structs = append(structs, goStruct)
structs = append(structs, subStructs...)
continue
}
mapType, ok := typeSpec.Type.(*ast.MapType)
if ok {
goStruct := &GoStruct{
Filepath: path,
Filename: info.Name(),
Name: typeSpec.Name.Name,
FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name,
Package: packageName,
Fields: make([]*GoStructField, 0),
}
usedStructType, usedStructPkgName := getUsedStructType(mapType, packageName)
goStruct.AliasOf = &GoAlias{
GoType: fieldTypeString(mapType),
TypescriptType: fieldTypeToTypescriptType(mapType, usedStructPkgName),
UsedStructType: usedStructType,
}
structs = append(structs, goStruct)
continue
}
sliceType, ok := typeSpec.Type.(*ast.ArrayType)
if ok {
goStruct := &GoStruct{
Filepath: path,
Filename: info.Name(),
Name: typeSpec.Name.Name,
FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name,
Package: packageName,
Fields: make([]*GoStructField, 0),
}
usedStructType, usedStructPkgName := getUsedStructType(sliceType, packageName)
goStruct.AliasOf = &GoAlias{
GoType: fieldTypeString(sliceType),
TypescriptType: fieldTypeToTypescriptType(sliceType, usedStructPkgName),
UsedStructType: usedStructType,
}
structs = append(structs, goStruct)
continue
}
}
}
return structs, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Example:
//
// type User struct {
// ID int `json:"id"`
// Name string `json:"name"`
// }
func goStructFromStruct(path string, info os.FileInfo, genDecl *ast.GenDecl, name string, packageName string, structType *ast.StructType) *GoStruct {
// Get comments
comments := make([]string, 0)
if genDecl.Doc != nil && genDecl.Doc.List != nil && len(genDecl.Doc.List) > 0 {
for _, comment := range genDecl.Doc.List {
comments = append(comments, strings.TrimPrefix(comment.Text, "//"))
}
}
goStruct := &GoStruct{
Filepath: filepath.ToSlash(path),
Filename: info.Name(),
Name: name,
FormattedName: getTypePrefix(packageName) + name,
Package: packageName,
Fields: make([]*GoStructField, 0),
EmbeddedStructTypes: make([]string, 0),
Comments: comments,
}
// Get fields
for _, field := range structType.Fields.List {
if field.Names == nil || len(field.Names) == 0 {
if len(field.Names) == 0 {
switch field.Type.(type) {
case *ast.Ident, *ast.StarExpr, *ast.SelectorExpr:
usedStructType, _ := getUsedStructType(field.Type, packageName)
goStruct.EmbeddedStructTypes = append(goStruct.EmbeddedStructTypes, usedStructType)
}
}
continue
}
// Get fields comments
comments := make([]string, 0)
if field.Comment != nil && field.Comment.List != nil && len(field.Comment.List) > 0 {
for _, comment := range field.Comment.List {
comments = append(comments, strings.TrimPrefix(comment.Text, "//"))
}
}
required := true
if field.Tag != nil {
tag := reflect.StructTag(field.Tag.Value[1 : len(field.Tag.Value)-1])
jsonTag := tag.Get("json")
if jsonTag != "" {
jsonParts := strings.Split(jsonTag, ",")
if len(jsonParts) > 1 && jsonParts[1] == "omitempty" {
required = false
}
}
}
switch field.Type.(type) {
case *ast.StarExpr, *ast.ArrayType, *ast.MapType, *ast.SelectorExpr:
required = false
}
fieldName := field.Names[0].Name
usedStructType, usedStructPkgName := getUsedStructType(field.Type, packageName)
tsType := fieldTypeToTypescriptType(field.Type, usedStructPkgName)
goStructField := &GoStructField{
Name: fieldName,
JsonName: jsonFieldName(field),
GoType: fieldTypeString(field.Type),
TypescriptType: tsType,
UsedTypescriptType: fieldTypeToUsedTypescriptType(tsType),
Required: required,
Public: field.Names[0].IsExported(),
UsedStructType: usedStructType,
Comments: comments,
}
// If it's an inline struct, capture the full definition as a string
if goStructField.GoType == "__STRUCT__" {
if structType, ok := field.Type.(*ast.StructType); ok {
goStructField.InlineStructType = formatInlineStruct(structType)
}
} else {
// Check if it's a slice of inline structs
if arrayType, ok := field.Type.(*ast.ArrayType); ok {
if structType, ok := arrayType.Elt.(*ast.StructType); ok {
goStructField.InlineStructType = "[]" + formatInlineStruct(structType)
}
}
// Check if it's a map with inline struct values
if mapType, ok := field.Type.(*ast.MapType); ok {
if structType, ok := mapType.Value.(*ast.StructType); ok {
goStructField.InlineStructType = "map[" + fieldTypeString(mapType.Key) + "]" + formatInlineStruct(structType)
}
}
}
goStruct.Fields = append(goStruct.Fields, goStructField)
}
return goStruct
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func goStructFromAlias(path string, info os.FileInfo, genDecl *ast.GenDecl, typeSpec *ast.TypeSpec, packageName string, alias *ast.Ident, file *ast.File) *GoStruct {
// Get comments
comments := make([]string, 0)
if genDecl.Doc != nil && genDecl.Doc.List != nil && len(genDecl.Doc.List) > 0 {
for _, comment := range genDecl.Doc.List {
comments = append(comments, strings.TrimPrefix(comment.Text, "//"))
}
}
usedStructType, usedStructPkgName := getUsedStructType(typeSpec.Type, packageName)
tsType := fieldTypeToTypescriptType(typeSpec.Type, usedStructPkgName)
goStruct := &GoStruct{
Filepath: filepath.ToSlash(path),
Filename: info.Name(),
Name: typeSpec.Name.Name,
Package: packageName,
FormattedName: getTypePrefix(packageName) + typeSpec.Name.Name,
Fields: make([]*GoStructField, 0),
Comments: comments,
AliasOf: &GoAlias{
GoType: alias.Name,
TypescriptType: tsType,
UsedTypescriptType: fieldTypeToUsedTypescriptType(tsType),
UsedStructType: usedStructType,
},
}
// Get declared values - useful for building enums or union types
// e.g. const Something AliasType = "something"
goStruct.AliasOf.DeclaredValues = make([]string, 0)
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.CONST {
continue
}
for _, spec := range genDecl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
valueSpecType := fieldTypeString(valueSpec.Type)
if len(valueSpec.Names) == 1 && valueSpec.Names[0].IsExported() && valueSpecType == typeSpec.Name.Name {
for _, value := range valueSpec.Values {
name, ok := value.(*ast.BasicLit)
if !ok {
continue
}
goStruct.AliasOf.DeclaredValues = append(goStruct.AliasOf.DeclaredValues, name.Value)
}
}
}
}
return goStruct
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// getUsedStructType returns the used struct type for a given type declaration.
// For example, if the type declaration is `map[string]models.User`, the used struct type is `models.User`.
// If the type declaration is `[]User`, the used struct type is `{packageName}.User`.
func getUsedStructType(expr ast.Expr, packageName string) (string, string) {
usedStructType := fieldTypeToUsedStructType(expr)
switch usedStructType {
case "string", "bool", "byte", "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64":
return "", ""
case "__STRUCT__":
return "", ""
}
if usedStructType != "__STRUCT__" && usedStructType != "" && !strings.Contains(usedStructType, ".") {
usedStructType = packageName + "." + usedStructType
}
pkgName := strings.Split(usedStructType, ".")[0]
return usedStructType, pkgName
}
// fieldTypeString returns the field type as a string.
// For example, if the field type is `[]*models.User`, the return value is `[]models.User`.
// If the field type is `[]InternalStruct`, the return value is `[]InternalStruct`.
func fieldTypeString(fieldType ast.Expr) string {
switch t := fieldType.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
//return "*" + fieldTypeString(t.X)
return fieldTypeString(t.X)
case *ast.ArrayType:
if fieldTypeString(t.Elt) == "byte" {
return "string"
}
return "[]" + fieldTypeString(t.Elt)
case *ast.MapType:
return "map[" + fieldTypeString(t.Key) + "]" + fieldTypeString(t.Value)
case *ast.SelectorExpr:
return fieldTypeString(t.X) + "." + t.Sel.Name
case *ast.StructType:
return "__STRUCT__"
default:
return ""
}
}
// fieldTypeToTypescriptType returns the field type as a string in TypeScript format.
// For example, if the field type is `[]*models.User`, the return value is `Array<Models_User>`.
func fieldTypeToTypescriptType(fieldType ast.Expr, usedStructPkgName string) string {
switch t := fieldType.(type) {
case *ast.Ident:
switch t.Name {
case "string":
return "string"
case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64":
return "number"
case "bool":
return "boolean"
case "byte":
return "string"
case "time.Time":
return "string"
case "nil":
return "null"
default:
return getTypePrefix(usedStructPkgName) + t.Name
}
case *ast.StarExpr:
return fieldTypeToTypescriptType(t.X, usedStructPkgName)
case *ast.ArrayType:
if fieldTypeToTypescriptType(t.Elt, usedStructPkgName) == "byte" {
return "string"
}
return "Array<" + fieldTypeToTypescriptType(t.Elt, usedStructPkgName) + ">"
case *ast.MapType:
return "Record<" + fieldTypeToTypescriptType(t.Key, usedStructPkgName) + ", " + fieldTypeToTypescriptType(t.Value, usedStructPkgName) + ">"
case *ast.SelectorExpr:
if t.Sel.Name == "Time" {
return "string"
}
return getTypePrefix(usedStructPkgName) + t.Sel.Name
case *ast.StructType:
s := "{ "
for _, field := range t.Fields.List {
s += jsonFieldName(field) + ": " + fieldTypeToTypescriptType(field.Type, usedStructPkgName) + "; "
}
s += "}"
return s
default:
return "any"
}
}
func stringGoTypeToTypescriptType(goType string) string {
switch goType {
case "string":
return "string"
case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64":
return "number"
case "nil":
return "null"
case "bool":
return "boolean"
case "time.Time":
return "string"
}
if strings.HasPrefix(goType, "[]") {
return "Array<" + stringGoTypeToTypescriptType(goType[2:]) + ">"
}
if strings.HasPrefix(goType, "*") {
return stringGoTypeToTypescriptType(goType[1:])
}
if strings.HasPrefix(goType, "map[") {
s := strings.TrimPrefix(goType, "map[")
key := ""
value := ""
for i, c := range s {
if c == ']' {
key = s[:i]
value = s[i+1:]
break
}
}
return "Record<" + stringGoTypeToTypescriptType(key) + ", " + stringGoTypeToTypescriptType(value) + ">"
}
if strings.Contains(goType, ".") {
parts := strings.Split(goType, ".")
return getTypePrefix(parts[0]) + parts[1]
}
return goType
}
func goTypeToTypescriptType(goType string) string {
switch goType {
case "string":
return "string"
case "uint", "uint8", "uint16", "uint32", "uint64", "int", "int8", "int16", "int32", "int64", "float", "float32", "float64":
return "number"
case "bool":
return "boolean"
case "nil":
return "null"
case "time.Time":
return "string"
default:
return "unknown"
}
}
// fieldTypeUnformattedString returns the field type as a string without formatting.
// For example, if the field type is `[]*models.User`, the return value is `models.User`.
// /!\ Caveat: this assumes that the map key is always a string.
func fieldTypeUnformattedString(fieldType ast.Expr) string {
switch t := fieldType.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
//return "*" + fieldTypeString(t.X)
return fieldTypeUnformattedString(t.X)
case *ast.ArrayType:
return fieldTypeUnformattedString(t.Elt)
case *ast.MapType:
return fieldTypeUnformattedString(t.Value)
case *ast.SelectorExpr:
return fieldTypeString(t.X) + "." + t.Sel.Name
default:
return ""
}
}
// fieldTypeToUsedStructType returns the used struct type for a given field type.
// For example, if the field type is `[]*models.User`, the return value is `models.User`.
func fieldTypeToUsedStructType(fieldType ast.Expr) string {
switch t := fieldType.(type) {
case *ast.StarExpr:
return fieldTypeString(t.X)
case *ast.ArrayType:
return fieldTypeString(t.Elt)
case *ast.MapType:
return fieldTypeUnformattedString(t.Value)
case *ast.SelectorExpr:
return fieldTypeString(t)
case *ast.Ident:
return t.Name
case *ast.StructType:
return "__STRUCT__"
default:
return ""
}
}
func jsonFieldName(field *ast.Field) string {
if field.Tag != nil {
tag := reflect.StructTag(strings.ReplaceAll(field.Tag.Value[1:len(field.Tag.Value)-1], "\\\"", "\""))
jsonTag := tag.Get("json")
if jsonTag != "" {
jsonParts := strings.Split(jsonTag, ",")
if jsonParts[0] == "-" {
return ""
}
if jsonParts[0] != "" {
return jsonParts[0]
}
return jsonParts[0]
}
}
return field.Names[0].Name
}
func jsonFieldOmitEmpty(field *ast.Field) bool {
if field.Tag != nil {
tag := reflect.StructTag(strings.ReplaceAll(field.Tag.Value[1:len(field.Tag.Value)-1], "\\\"", "\""))
jsonTag := tag.Get("json")
if jsonTag != "" {
jsonParts := strings.Split(jsonTag, ",")
return len(jsonParts) > 1 && jsonParts[1] == "omitempty"
}
}
return false
}
func isCustomStruct(goType string) bool {
return goTypeToTypescriptType(goType) == "unknown"
}
var nameExceptions = map[string]string{"OAuth2": "oauth2"}
func convertGoToJSName(name string) string {
if v, ok := nameExceptions[name]; ok {
return v
}
startUppercase := make([]rune, 0, len(name))
for _, c := range name {
if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) {
break
}
startUppercase = append(startUppercase, c)
}
totalStartUppercase := len(startUppercase)
// all uppercase eg. "JSON" -> "json"
if len(name) == totalStartUppercase {
return strings.ToLower(name)
}
// eg. "JSONField" -> "jsonField"
if totalStartUppercase > 1 {
return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:]
}
// eg. "GetField" -> "getField"
if totalStartUppercase == 1 {
return strings.ToLower(name[0:1]) + name[1:]
}
return name
}
// fieldTypeToUsedTypescriptType extracts the core TypeScript type from complex type expressions
// For example, if the type is Array<Models_User>, it returns Models_User
// If the type is Record<string, Models_User>, it returns Models_User
func fieldTypeToUsedTypescriptType(tsType string) string {
// Handle arrays: Array<Type> -> Type
if strings.HasPrefix(tsType, "Array<") && strings.HasSuffix(tsType, ">") {
innerType := strings.TrimPrefix(strings.TrimSuffix(tsType, ">"), "Array<")
return fieldTypeToUsedTypescriptType(innerType)
}
// Handle records: Record<Key, Value> -> Value
if strings.HasPrefix(tsType, "Record<") && strings.HasSuffix(tsType, ">") {
innerType := strings.TrimPrefix(strings.TrimSuffix(tsType, ">"), "Record<")
// Find the comma that separates key and value
commaIndex := -1
bracketCount := 0
for i, char := range innerType {
if char == '<' {
bracketCount++
} else if char == '>' {
bracketCount--
} else if char == ',' && bracketCount == 0 {
commaIndex = i
break
}
}
if commaIndex != -1 {
valueType := strings.TrimSpace(innerType[commaIndex+1:])
return fieldTypeToUsedTypescriptType(valueType)
}
}
// Handle primitive types
switch tsType {
case "string", "number", "boolean", "any", "null", "undefined":
return ""
}
return tsType
}
// formatInlineStruct formats an inline struct definition as a string
// e.g. struct{Test string `json:"test"`, Test2 string `json:"test2"`}
func formatInlineStruct(structType *ast.StructType) string {
result := "struct{\n"
for i, field := range structType.Fields.List {
if i > 0 {
result += "\n"
}
if field.Names != nil && len(field.Names) > 0 {
result += field.Names[0].Name + " " + fieldTypeString(field.Type)
if field.Tag != nil {
result += " " + field.Tag.Value
}
} else {
result += fieldTypeString(field.Type)
}
}
result += "}"
return result
}

View File

@@ -0,0 +1,23 @@
package codegen
import (
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
)
func TestGetGoStructsFromFile(t *testing.T) {
testPath := filepath.Join(".", "examples", "structs1.go")
info, err := os.Stat(testPath)
require.NoError(t, err)
goStructs, err := getGoStructsFromFile(testPath, info)
require.NoError(t, err)
spew.Dump(goStructs)
}

View File

@@ -0,0 +1,465 @@
package codegen
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/samber/lo"
)
const (
typescriptEndpointsFileName = "endpoints.ts"
typescriptEndpointTypesFileName = "endpoint.types.ts"
typescriptHooksFileName = "hooks_template.ts"
goEndpointsFileName = "endpoints.go"
space = " "
)
var additionalStructNamesForEndpoints = []string{}
func GenerateTypescriptEndpointsFile(handlersJsonPath string, structsJsonPath string, outDir string, eventDir string) []string {
handlers := LoadHandlers(handlersJsonPath)
structs := LoadPublicStructs(structsJsonPath)
_ = os.MkdirAll(outDir, os.ModePerm)
f, err := os.Create(filepath.Join(outDir, typescriptEndpointsFileName))
if err != nil {
panic(err)
}
defer f.Close()
typeF, err := os.Create(filepath.Join(outDir, typescriptEndpointTypesFileName))
if err != nil {
panic(err)
}
defer typeF.Close()
hooksF, err := os.Create(filepath.Join(outDir, typescriptHooksFileName))
if err != nil {
panic(err)
}
defer hooksF.Close()
f.WriteString("// This code was generated by codegen/main.go. DO NOT EDIT.\n\n")
f.WriteString(`export type ApiEndpoints = Record<string, Record<string, {
key: string,
methods: ("POST" | "GET" | "PATCH" | "PUT" | "DELETE")[],
endpoint: string
}>>
`)
f.WriteString("export const API_ENDPOINTS = {\n")
groupedByFile := make(map[string][]*RouteHandler)
for _, handler := range handlers {
if _, ok := groupedByFile[handler.Filename]; !ok {
groupedByFile[handler.Filename] = make([]*RouteHandler, 0)
}
groupedByFile[handler.Filename] = append(groupedByFile[handler.Filename], handler)
}
filenames := make([]string, 0)
for k := range groupedByFile {
filenames = append(filenames, k)
}
slices.SortStableFunc(filenames, func(i, j string) int {
return strings.Compare(i, j)
})
// Store the endpoints
endpointsMap := make(map[string]string)
for _, filename := range filenames {
routes := groupedByFile[filename]
if len(routes) == 0 {
continue
}
if lo.EveryBy(routes, func(route *RouteHandler) bool {
return route.Api == nil || len(route.Api.Methods) == 0
}) {
continue
}
groupName := strings.ToUpper(strings.TrimSuffix(filename, ".go"))
writeLine(f, fmt.Sprintf("\t%s: {", groupName)) // USERS: {
for _, route := range groupedByFile[filename] {
if route.Api == nil || len(route.Api.Methods) == 0 {
continue
}
if len(route.Api.Descriptions) > 0 {
writeLine(f, " /**")
f.WriteString(fmt.Sprintf(" * @description\n"))
f.WriteString(fmt.Sprintf(" * Route %s\n", route.Api.Summary))
for _, cmt := range route.Api.Descriptions {
writeLine(f, fmt.Sprintf(" * %s", strings.TrimSpace(cmt)))
}
writeLine(f, " */")
}
writeLine(f, fmt.Sprintf("\t\t%s: {", strings.TrimPrefix(route.Name, "Handle"))) // GetAnimeCollection: {
methodStr := ""
if len(route.Api.Methods) > 1 {
methodStr = fmt.Sprintf("\"%s\"", strings.Join(route.Api.Methods, "\", \""))
} else {
methodStr = fmt.Sprintf("\"%s\"", route.Api.Methods[0])
}
endpointsMap[strings.TrimPrefix(route.Name, "Handle")] = getEndpointKey(route.Name, groupName)
writeLine(f, fmt.Sprintf("\t\t\tkey: \"%s\",", getEndpointKey(route.Name, groupName)))
writeLine(f, fmt.Sprintf("\t\t\tmethods: [%s],", methodStr)) // methods: ['GET'],
writeLine(f, fmt.Sprintf("\t\t\tendpoint: \"%s\",", route.Api.Endpoint)) // path: '/api/v1/anilist/collection',
writeLine(f, "\t\t},") // },
}
writeLine(f, "\t},") // },
}
f.WriteString("} satisfies ApiEndpoints\n\n")
referenceGoStructs := make([]string, 0)
for _, filename := range filenames {
routes := groupedByFile[filename]
if len(routes) == 0 {
continue
}
for _, route := range groupedByFile[filename] {
if route.Api == nil || len(route.Api.Methods) == 0 {
continue
}
if len(route.Api.Params) == 0 && len(route.Api.BodyFields) == 0 {
continue
}
for _, param := range route.Api.BodyFields {
if param.UsedStructType != "" {
referenceGoStructs = append(referenceGoStructs, param.UsedStructType)
}
}
for _, param := range route.Api.Params {
if param.UsedStructType != "" {
referenceGoStructs = append(referenceGoStructs, param.UsedStructType)
}
}
}
}
referenceGoStructs = lo.Uniq(referenceGoStructs)
typeF.WriteString("// This code was generated by codegen/main.go. DO NOT EDIT.\n\n")
//
// Imports
//
importedTypes := make([]string, 0)
//
for _, structName := range referenceGoStructs {
parts := strings.Split(structName, ".")
if len(parts) != 2 {
continue
}
var goStruct *GoStruct
for _, s := range structs {
if s.Name == parts[1] && s.Package == parts[0] {
goStruct = s
break
}
}
if goStruct == nil {
continue
}
importedTypes = append(importedTypes, goStruct.FormattedName)
}
for _, otherStrctName := range additionalStructNamesForEndpoints {
importedTypes = append(importedTypes, stringGoTypeToTypescriptType(otherStrctName))
}
//
slices.SortStableFunc(importedTypes, func(i, j string) int {
return strings.Compare(i, j)
})
typeF.WriteString("import type {\n")
for _, typeName := range importedTypes {
typeF.WriteString(fmt.Sprintf(" %s,\n", typeName))
}
typeF.WriteString("} from \"@/api/generated/types.ts\"\n\n")
//
// Types
//
for _, filename := range filenames {
routes := groupedByFile[filename]
if len(routes) == 0 {
continue
}
typeF.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n")
typeF.WriteString(fmt.Sprintf("// %s\n", strings.TrimSuffix(filename, ".go")))
typeF.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n")
for _, route := range groupedByFile[filename] {
if route.Api == nil || len(route.Api.Methods) == 0 {
continue
}
if len(route.Api.Params) == 0 && len(route.Api.BodyFields) == 0 {
continue
}
typeF.WriteString("/**\n")
typeF.WriteString(fmt.Sprintf(" * - Filepath: %s\n", filepath.ToSlash(strings.TrimPrefix(route.Filepath, "..\\"))))
typeF.WriteString(fmt.Sprintf(" * - Filename: %s\n", route.Filename))
typeF.WriteString(fmt.Sprintf(" * - Endpoint: %s\n", route.Api.Endpoint))
if len(route.Api.Summary) > 0 {
typeF.WriteString(fmt.Sprintf(" * @description\n"))
typeF.WriteString(fmt.Sprintf(" * Route %s\n", strings.TrimSpace(route.Api.Summary)))
}
typeF.WriteString(" */\n")
typeF.WriteString(fmt.Sprintf("export type %s_Variables = {\n", strings.TrimPrefix(route.Name, "Handle"))) // export type EditAnimeEntry_Variables = {
addedBodyFields := false
for _, param := range route.Api.BodyFields {
writeParamField(typeF, route, param) // mediaId: number;
if param.UsedStructType != "" {
referenceGoStructs = append(referenceGoStructs, param.UsedStructType)
}
addedBodyFields = true
}
if !addedBodyFields {
for _, param := range route.Api.Params {
writeParamField(typeF, route, param) // mediaId: number;
if param.UsedStructType != "" {
referenceGoStructs = append(referenceGoStructs, param.UsedStructType)
}
}
}
writeLine(typeF, "}\n")
}
}
generateHooksFile(hooksF, groupedByFile, filenames)
generateEventFile(eventDir, endpointsMap)
return referenceGoStructs
}
func generateHooksFile(f *os.File, groupedHandlers map[string][]*RouteHandler, filenames []string) {
queryTemplate := `// export function use{handlerName}({props}) {
// return useServerQuery{<}{TData}{TVar}{>}({
// endpoint: API_ENDPOINTS.{groupName}.{handlerName}.endpoint{endpointSuffix},
// method: API_ENDPOINTS.{groupName}.{handlerName}.methods[%d],
// queryKey: [API_ENDPOINTS.{groupName}.{handlerName}.key],
// enabled: true,
// })
// }
`
mutationTemplate := `// export function use{handlerName}({props}) {
// return useServerMutation{<}{TData}{TVar}{>}({
// endpoint: API_ENDPOINTS.{groupName}.{handlerName}.endpoint{endpointSuffix},
// method: API_ENDPOINTS.{groupName}.{handlerName}.methods[%d],
// mutationKey: [API_ENDPOINTS.{groupName}.{handlerName}.key],
// onSuccess: async () => {
//
// },
// })
// }
`
tmpGroupTmpls := make(map[string][]string)
for _, filename := range filenames {
routes := groupedHandlers[filename]
if len(routes) == 0 {
continue
}
if lo.EveryBy(routes, func(route *RouteHandler) bool {
return route.Api == nil || len(route.Api.Methods) == 0
}) {
continue
}
f.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n")
f.WriteString(fmt.Sprintf("// %s\n", strings.TrimSuffix(filename, ".go")))
f.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n")
tmpls := make([]string, 0)
for _, route := range groupedHandlers[filename] {
if route.Api == nil || len(route.Api.Methods) == 0 {
continue
}
for i, method := range route.Api.Methods {
tmpl := ""
if method == "GET" {
getTemplate := strings.ReplaceAll(queryTemplate, "{handlerName}", strings.TrimPrefix(route.Name, "Handle"))
getTemplate = strings.ReplaceAll(getTemplate, "{groupName}", strings.ToUpper(strings.TrimSuffix(filename, ".go")))
getTemplate = strings.ReplaceAll(getTemplate, "{method}", "GET")
tmpl = getTemplate
}
if method == "POST" || method == "PATCH" || method == "PUT" || method == "DELETE" {
mutTemplate := strings.ReplaceAll(mutationTemplate, "{handlerName}", strings.TrimPrefix(route.Name, "Handle"))
mutTemplate = strings.ReplaceAll(mutTemplate, "{groupName}", strings.ToUpper(strings.TrimSuffix(filename, ".go")))
mutTemplate = strings.ReplaceAll(mutTemplate, "{method}", method)
tmpl = mutTemplate
}
tmpl = strings.ReplaceAll(tmpl, "%d", strconv.Itoa(i))
if len(route.Api.ReturnTypescriptType) == 0 {
tmpl = strings.ReplaceAll(tmpl, "{<}", "")
tmpl = strings.ReplaceAll(tmpl, "{TData}", "")
tmpl = strings.ReplaceAll(tmpl, "{TVar}", "")
tmpl = strings.ReplaceAll(tmpl, "{>}", "")
} else {
tmpl = strings.ReplaceAll(tmpl, "{<}", "<")
tmpl = strings.ReplaceAll(tmpl, "{TData}", route.Api.ReturnTypescriptType)
tmpl = strings.ReplaceAll(tmpl, "{>}", ">")
}
if len(route.Api.Params) == 0 {
tmpl = strings.ReplaceAll(tmpl, "{endpointSuffix}", "")
tmpl = strings.ReplaceAll(tmpl, "{props}", "")
} else {
props := ""
for _, param := range route.Api.Params {
props += fmt.Sprintf(`%s: %s, `, param.JsonName, param.TypescriptType)
}
tmpl = strings.ReplaceAll(tmpl, "{props}", props[:len(props)-2])
endpointSuffix := ""
for _, param := range route.Api.Params {
endpointSuffix += fmt.Sprintf(`.replace("{%s}", String(%s))`, param.JsonName, param.JsonName)
}
tmpl = strings.ReplaceAll(tmpl, "{endpointSuffix}", endpointSuffix)
}
if len(route.Api.BodyFields) == 0 {
tmpl = strings.ReplaceAll(tmpl, "{TVar}", "")
} else {
tmpl = strings.ReplaceAll(tmpl, "{TVar}", fmt.Sprintf(", %s", strings.TrimPrefix(route.Name, "Handle")+"_Variables"))
}
tmpls = append(tmpls, tmpl)
f.WriteString(tmpl)
}
}
tmpGroupTmpls[strings.TrimSuffix(filename, ".go")] = tmpls
}
//for filename, tmpls := range tmpGroupTmpls {
// hooksF, err := os.Create(filepath.Join("../seanime-web/src/api/hooks", filename+".hooks.ts"))
// if err != nil {
// panic(err)
// }
// defer hooksF.Close()
//
// for _, tmpl := range tmpls {
// hooksF.WriteString(tmpl)
// }
//}
}
func generateEventFile(eventDir string, endpointsMap map[string]string) {
fp := filepath.Join(eventDir, goEndpointsFileName)
file, err := os.Create(fp)
if err != nil {
panic(err)
}
defer file.Close()
file.WriteString("// This code was generated by codegen/main.go. DO NOT EDIT.\n")
file.WriteString("package events\n\n")
// file.WriteString(fmt"var Endpoint = map[string]string{\n")
endpoints := []string{}
for endpoint := range endpointsMap {
endpoints = append(endpoints, endpoint)
}
slices.SortStableFunc(endpoints, func(i, j string) int {
return strings.Compare(i, j)
})
goFmtSpacing := ""
file.WriteString("const (\n")
for _, endpoint := range endpoints {
file.WriteString(fmt.Sprintf(" %sEndpoint%s= \"%s\"\n", endpoint, goFmtSpacing, endpointsMap[endpoint]))
}
file.WriteString(")\n")
cmd := exec.Command("gofmt", "-w", fp)
cmd.Run()
}
func writeParamField(f *os.File, handler *RouteHandler, param *RouteHandlerParam) {
if len(param.Descriptions) > 0 {
writeLine(f, "\t/**")
for _, cmt := range param.Descriptions {
writeLine(f, fmt.Sprintf("\t * %s", strings.TrimSpace(cmt)))
}
writeLine(f, "\t */")
}
fieldSuffix := ""
if !param.Required {
fieldSuffix = "?"
}
writeLine(f, fmt.Sprintf("\t%s%s: %s", param.JsonName, fieldSuffix, param.TypescriptType))
}
func getEndpointKey(s string, groupName string) string {
s = strings.TrimPrefix(s, "Handle")
var result string
for i, v := range s {
if i > 0 && v >= 'A' && v <= 'Z' {
result += "-"
}
result += string(v)
}
result = strings.ToLower(result)
if strings.Contains(result, "t-v-d-b") {
result = strings.Replace(result, "t-v-d-b", "tvdb", 1)
}
if strings.Contains(result, "m-a-l") {
result = strings.Replace(result, "m-a-l", "mal", 1)
}
return strings.ReplaceAll(groupName, "_", "-") + "-" + result
}
func writeLine(file *os.File, template string) {
template = strings.ReplaceAll(template, "\t", space)
file.WriteString(fmt.Sprintf(template + "\n"))
}

View File

@@ -0,0 +1,334 @@
package codegen
import (
"cmp"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const (
typescriptFileName = "types.ts"
)
// Structs that are not directly referenced by the API routes but are needed for the Typescript file.
var additionalStructNames = []string{
"torrentstream.TorrentLoadingStatus",
"torrentstream.TorrentStatus",
"debrid_client.StreamState",
"extension_repo.TrayPluginExtensionItem",
"vendor_habari.Metadata",
"nativeplayer.PlaybackInfo",
"nativeplayer.ServerEvent",
"nativeplayer.ClientEvent",
"mkvparser.SubtitleEvent",
"nakama.NakamaStatus",
}
// GenerateTypescriptFile generates a Typescript file containing the types for the API routes parameters and responses based on the Docs struct.
func GenerateTypescriptFile(docsFilePath string, publicStructsFilePath string, outDir string, goStructStrs []string) {
handlers := LoadHandlers(docsFilePath)
goStructs := LoadPublicStructs(publicStructsFilePath)
// e.g. map["models.User"]*GoStruct
goStructsMap := make(map[string]*GoStruct)
for _, goStruct := range goStructs {
goStructsMap[goStruct.Package+"."+goStruct.Name] = goStruct
}
// Expand the structs with embedded structs
for _, goStruct := range goStructs {
for _, embeddedStructType := range goStruct.EmbeddedStructTypes {
if embeddedStructType != "" {
if usedStruct, ok := goStructsMap[embeddedStructType]; ok {
for _, usedField := range usedStruct.Fields {
goStruct.Fields = append(goStruct.Fields, usedField)
}
}
}
}
}
// Create the typescript file
_ = os.MkdirAll(outDir, os.ModePerm)
file, err := os.Create(filepath.Join(outDir, typescriptFileName))
if err != nil {
panic(err)
}
defer file.Close()
// Write the typescript file
file.WriteString("// This code was generated by codegen/main.go. DO NOT EDIT.\n\n")
// Get all the returned structs from the routes
// e.g. @returns models.User
structStrMap := make(map[string]int)
for _, str := range goStructStrs {
if _, ok := structStrMap[str]; ok {
structStrMap[str]++
} else {
structStrMap[str] = 1
}
}
for _, handler := range handlers {
if handler.Api != nil {
switch handler.Api.ReturnTypescriptType {
case "null", "string", "number", "boolean":
continue
}
if _, ok := structStrMap[handler.Api.ReturnGoType]; ok {
structStrMap[handler.Api.ReturnGoType]++
} else {
structStrMap[handler.Api.ReturnGoType] = 1
}
}
}
// Isolate the structs that are returned more than once
sharedStructStrs := make([]string, 0)
otherStructStrs := make([]string, 0)
for k, v := range structStrMap {
if v > 1 {
sharedStructStrs = append(sharedStructStrs, k)
} else {
otherStructStrs = append(otherStructStrs, k)
}
}
// Now that we have the returned structs, store them in slices
sharedStructs := make([]*GoStruct, 0)
otherStructs := make([]*GoStruct, 0)
for _, structStr := range sharedStructStrs {
// e.g. "models.User"
structStrParts := strings.Split(structStr, ".")
if len(structStrParts) != 2 {
continue
}
// Find the struct
goStruct, ok := goStructsMap[structStr]
if ok {
sharedStructs = append(sharedStructs, goStruct)
}
}
for _, structStr := range otherStructStrs {
// e.g. "models.User"
structStrParts := strings.Split(structStr, ".")
if len(structStrParts) != 2 {
continue
}
// Find the struct
goStruct, ok := goStructsMap[structStr]
if ok {
otherStructs = append(otherStructs, goStruct)
}
}
// Add additional structs to otherStructs
for _, structName := range additionalStructNames {
if goStruct, ok := goStructsMap[structName]; ok {
otherStructs = append(otherStructs, goStruct)
}
}
//-------------------------
referencedStructs, ok := getReferencedStructsRecursively(sharedStructs, otherStructs, goStructsMap)
if !ok {
panic("Failed to get referenced structs")
}
// Keep track of written Typescript types
// This is to avoid name collisions
writtenTypes := make(map[string]*GoStruct)
// Group the structs by package
structsByPackage := make(map[string][]*GoStruct)
for _, goStruct := range referencedStructs {
if _, ok := structsByPackage[goStruct.Package]; !ok {
structsByPackage[goStruct.Package] = make([]*GoStruct, 0)
}
structsByPackage[goStruct.Package] = append(structsByPackage[goStruct.Package], goStruct)
}
packages := make([]string, 0)
for k := range structsByPackage {
packages = append(packages, k)
}
slices.SortStableFunc(packages, func(i, j string) int {
return cmp.Compare(i, j)
})
file.WriteString("export type Nullish<T> = T | null | undefined\n\n")
for _, pkg := range packages {
file.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n")
file.WriteString(fmt.Sprintf("// %s\n", strings.ReplaceAll(cases.Title(language.English, cases.Compact).String(strings.ReplaceAll(pkg, "_", " ")), " ", "")))
file.WriteString("//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////\n\n")
structs := structsByPackage[pkg]
slices.SortStableFunc(structs, func(i, j *GoStruct) int {
return cmp.Compare(i.FormattedName, j.FormattedName)
})
// Write the shared structs first
for _, goStruct := range structs {
writeTypescriptType(file, goStruct, writtenTypes)
}
}
//for _, goStruct := range referencedStructs {
//
// writeTypescriptType(file, goStruct, writtenTypes)
//
//}
}
// getReferencedStructsRecursively returns a map of GoStructs that are referenced by the fields of sharedStructs and otherStructs.
func getReferencedStructsRecursively(sharedStructs, otherStructs []*GoStruct, goStructsMap map[string]*GoStruct) (map[string]*GoStruct, bool) {
allStructs := make(map[string]*GoStruct)
for _, sharedStruct := range sharedStructs {
allStructs[sharedStruct.Package+"."+sharedStruct.Name] = sharedStruct
}
for _, otherStruct := range otherStructs {
allStructs[otherStruct.Package+"."+otherStruct.Name] = otherStruct
}
// Keep track of the structs that have been visited
referencedStructs := make(map[string]*GoStruct)
for _, strct := range allStructs {
getReferencedStructs(strct, referencedStructs, goStructsMap)
}
return referencedStructs, true
}
func getReferencedStructs(goStruct *GoStruct, referencedStructs map[string]*GoStruct, goStructsMap map[string]*GoStruct) {
if _, ok := referencedStructs[goStruct.Package+"."+goStruct.Name]; ok {
return
}
referencedStructs[goStruct.Package+"."+goStruct.Name] = goStruct
for _, field := range goStruct.Fields {
if field.UsedStructType != "" {
if usedStruct, ok := goStructsMap[field.UsedStructType]; ok {
getReferencedStructs(usedStruct, referencedStructs, goStructsMap)
}
}
}
if goStruct.AliasOf != nil {
if usedStruct, ok := goStructsMap[goStruct.AliasOf.UsedStructType]; ok {
getReferencedStructs(usedStruct, referencedStructs, goStructsMap)
}
}
}
func writeTypescriptType(f *os.File, goStruct *GoStruct, writtenTypes map[string]*GoStruct) {
f.WriteString("/**\n")
f.WriteString(fmt.Sprintf(" * - Filepath: %s\n", strings.TrimPrefix(goStruct.Filepath, "../")))
f.WriteString(fmt.Sprintf(" * - Filename: %s\n", goStruct.Filename))
f.WriteString(fmt.Sprintf(" * - Package: %s\n", goStruct.Package))
if len(goStruct.Comments) > 0 {
f.WriteString(fmt.Sprintf(" * @description\n"))
for _, cmt := range goStruct.Comments {
f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt)))
}
}
f.WriteString(" */\n")
if len(goStruct.Fields) > 0 {
f.WriteString(fmt.Sprintf("export type %s = {\n", goStruct.FormattedName))
for _, field := range goStruct.Fields {
if field.JsonName == "" {
continue
}
fieldNameSuffix := ""
if !field.Required {
fieldNameSuffix = "?"
}
if len(field.Comments) > 0 {
f.WriteString(fmt.Sprintf(" /**\n"))
for _, cmt := range field.Comments {
f.WriteString(fmt.Sprintf(" * %s\n", strings.TrimSpace(cmt)))
}
f.WriteString(fmt.Sprintf(" */\n"))
}
typeText := field.TypescriptType
//if !field.Required {
// switch typeText {
// case "string", "number", "boolean":
// default:
// typeText = "Nullish<" + typeText + ">"
// }
//}
f.WriteString(fmt.Sprintf(" %s%s: %s\n", field.JsonName, fieldNameSuffix, typeText))
}
f.WriteString("}\n\n")
}
if goStruct.AliasOf != nil {
if goStruct.AliasOf.DeclaredValues != nil && len(goStruct.AliasOf.DeclaredValues) > 0 {
union := ""
if len(goStruct.AliasOf.DeclaredValues) > 5 {
union = strings.Join(goStruct.AliasOf.DeclaredValues, " |\n ")
} else {
union = strings.Join(goStruct.AliasOf.DeclaredValues, " | ")
}
f.WriteString(fmt.Sprintf("export type %s = %s\n\n", goStruct.FormattedName, union))
} else {
f.WriteString(fmt.Sprintf("export type %s = %s\n\n", goStruct.FormattedName, goStruct.AliasOf.TypescriptType))
}
}
// Add the struct to the written types
writtenTypes[goStruct.Package+"."+goStruct.Name] = goStruct
}
func getUnformattedGoType(goType string) string {
if strings.HasPrefix(goType, "[]") {
return getUnformattedGoType(goType[2:])
}
if strings.HasPrefix(goType, "*") {
return getUnformattedGoType(goType[1:])
}
if strings.HasPrefix(goType, "map[") {
s := strings.TrimPrefix(goType, "map[")
value := ""
for i, c := range s {
if c == ']' {
value = s[i+1:]
break
}
}
return getUnformattedGoType(value)
}
return goType
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -0,0 +1,34 @@
package codegen
import (
"encoding/json"
"os"
)
func LoadHandlers(path string) []*RouteHandler {
var handlers []*RouteHandler
docsContent, err := os.ReadFile(path)
if err != nil {
panic(err)
}
err = json.Unmarshal(docsContent, &handlers)
if err != nil {
panic(err)
}
return handlers
}
func LoadPublicStructs(path string) []*GoStruct {
var goStructs []*GoStruct
structsContent, err := os.ReadFile(path)
if err != nil {
panic(err)
}
err = json.Unmarshal(structsContent, &goStructs)
if err != nil {
panic(err)
}
return goStructs
}

View File

@@ -0,0 +1,56 @@
//go:generate go run main.go --skipHandlers=false --skipStructs=false --skipTypes=false --skipPluginEvents=false --skipHookEvents=false --skipHandlerHookEvents=false
package main
import (
"flag"
codegen "seanime/codegen/internal"
)
func main() {
var skipHandlers bool
flag.BoolVar(&skipHandlers, "skipHandlers", false, "Skip generating docs")
var skipStructs bool
flag.BoolVar(&skipStructs, "skipStructs", false, "Skip generating structs")
var skipTypes bool
flag.BoolVar(&skipTypes, "skipTypes", false, "Skip generating types")
var skipPluginEvents bool
flag.BoolVar(&skipPluginEvents, "skipPluginEvents", false, "Skip generating plugin events")
var skipHookEvents bool
flag.BoolVar(&skipHookEvents, "skipHookEvents", false, "Skip generating hook events")
var skipHandlerHookEvents bool
flag.BoolVar(&skipHandlerHookEvents, "skipHandlerHookEvents", false, "Skip generating handler hook events")
flag.Parse()
if !skipHandlers {
codegen.GenerateHandlers("../internal/handlers", "./generated")
}
if !skipStructs {
codegen.ExtractStructs("../internal", "./generated")
}
if !skipTypes {
goStructStrs := codegen.GenerateTypescriptEndpointsFile("./generated/handlers.json", "./generated/public_structs.json", "../seanime-web/src/api/generated", "../internal/events")
codegen.GenerateTypescriptFile("./generated/handlers.json", "./generated/public_structs.json", "../seanime-web/src/api/generated", goStructStrs)
}
// if !skipHandlerHookEvents {
// codegen.GenerateHandlerHookEvents("./generated/handlers.json", "../internal/handlers")
// }
if !skipPluginEvents {
codegen.GeneratePluginEventFile("../internal/plugin/ui/events.go", "../seanime-web/src/app/(main)/_features/plugin/generated")
}
if !skipHookEvents {
codegen.GeneratePluginHooksDefinitionFile("../internal/extension_repo/goja_plugin_types", "./generated/public_structs.json", "./generated")
}
}