{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://chriopter.github.io/pspdx/schema/catalog-v1.json",
  "title": "PSPDX catalog v1",
  "description": "Aggregated homebrew metadata, releases and optional media. Individual manifests use schema/pspdx-v1.json; every field an app shares with it follows the same rule. Fields this schema does not name are allowed and ignored by the console, so a list can carry its own. All text is UTF-8. A console shows the characters its font has and skips the rest.",
  "type": "object",
  "required": [
    "schema",
    "generated_at",
    "apps"
  ],
  "properties": {
    "schema": {
      "const": "https://chriopter.github.io/pspdx/schema/catalog-v1.json"
    },
    "generated_at": {
      "type": "string",
      "format": "date-time",
      "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z(?![\\s\\S])",
      "description": "required; when this snapshot was published, UTC. Some apps may be missing if their source failed."
    },
    "apps": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/app"
      }
    }
  },
  "$defs": {
    "app": {
      "type": "object",
      "required": [
        "source",
        "name",
        "releases"
      ],
      "allOf": [
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "enum": [
                  "plugin",
                  "iso"
                ]
              }
            }
          },
          "then": {
            "not": {
              "required": [
                "installdir"
              ]
            }
          }
        },
        {
          "if": {
            "required": [
              "source"
            ],
            "properties": {
              "source": {
                "type": "string",
                "not": {
                  "pattern": "^https://github\\.com/"
                }
              }
            }
          },
          "then": {
            "properties": {
              "name": {
                "pattern": "[A-Za-z0-9]"
              },
              "source": {
                "pattern": "^https://(?:[^/?#\\\\\\u0000-\\u001f]*@)?(?![Ww][Ww][Ww]\\.(?:[:/?#]|(?![\\s\\S])))(?:-*[A-Za-z0-9][A-Za-z0-9-]*\\.)*-*[A-Za-z0-9][A-Za-z0-9-]*\\.?(?::[0-9]*)?(?:[/?#][^\\\\\\u0000-\\u001f]*)?(?![\\s\\S])",
                "not": {
                  "pattern": "^https://(?:[^/?#\\\\]*@)?(?![^/?#\\\\]*@)(?:[A-Za-z0-9-]*\\.)*-*[Gg]-*[Ii]-*[Tt]-*[Hh]-*[Uu]-*[Bb]-*\\.-*[Ii]-*[Oo]-*\\.?(?:[:/?#]|(?![\\s\\S]))"
                }
              }
            }
          }
        }
      ],
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1,
          "maxLength": 159,
          "pattern": "^[^\\u0000-\\u001f]*(?![\\s\\S])",
          "description": "optional; whatever name the catalog gives the entry. PSPDX does not rely on it: it makes its own id from the source, io.github.<owner>.<repo> on GitHub, or from the host of the source and the name elsewhere."
        },
        "source": {
          "type": "string",
          "maxLength": 255,
          "pattern": "^https://[^\\u0000-\\u001f]+(?![\\s\\S])",
          "if": {
            "pattern": "^https://github\\.com/"
          },
          "then": {
            "pattern": "^https://github\\.com/(?=[._-]*[A-Za-z0-9])[A-Za-z0-9_.-]{1,39}/(?=[._-]*[A-Za-z0-9])(?![._-]+\\.git/?(?![\\s\\S]))(?![A-Za-z0-9_.-]+\\.git\\.git/?(?![\\s\\S]))[A-Za-z0-9_.-]{1,100}/?(?![\\s\\S])"
          },
          "description": "required; where the project lives: an https:// URL, and on github.com a repository URL, https://github.com/<owner>/<repository>, a .git or a slash after it allowed. The owner is 1 to 39 and the repository 1 to 100 of [A-Za-z0-9_.-], each with a letter or digit besides a .git it ends in, so neither is . or ..; and a repository does not end in .git.git. Only a GitHub repository has releases the console can ask for in version 1. Any other source's host and the name make its id: the host without www., backwards, and the name, each part reduced to its ASCII letters and digits in lower case. So there the host is ASCII letters, digits and hyphens, with a letter or digit in every label, any other name written in punycode (xn--), the URL holds no backslash, and a host under github.io, whose ids would start io.github., is refused."
        },
        "name": {
          "type": "string",
          "minLength": 1,
          "maxLength": 40,
          "pattern": "^[^\\u0000-\\u001f]*(?![\\s\\S])",
          "description": "required; what the list shows"
        },
        "type": {
          "enum": [
            "homebrew",
            "plugin",
            "iso"
          ],
          "default": "homebrew",
          "description": "optional; homebrew when missing. homebrew is an EBOOT installed under PSP/GAME, plugin a .prx for custom firmware, iso a disc image."
        },
        "category": {
          "type": "string",
          "minLength": 1,
          "maxLength": 24,
          "pattern": "^[^\\u0000-\\u001f]*(?![\\s\\S])",
          "description": "optional; the one group the app belongs in. game, emulator, app and demo are the ones the console has a tab for; without a category, a tab word in tags decides."
        },
        "tags": {
          "type": "array",
          "maxItems": 8,
          "uniqueItems": true,
          "items": {
            "type": "string",
            "minLength": 1,
            "maxLength": 24,
            "pattern": "^[^\\u0000-\\u001f]*(?![\\s\\S])"
          },
          "description": "optional; up to 8 different words for what the app is, each 1 to 24 characters. game, emulator, app and demo are the ones the console has a tab for."
        },
        "languages": {
          "type": "array",
          "maxItems": 16,
          "uniqueItems": true,
          "items": {
            "type": "string",
            "pattern": "^[a-z]{2,3}(?:-[a-z0-9]{2,8})?(?![\\s\\S])"
          },
          "description": "optional; the languages the app speaks, as lowercase ISO 639-1 codes, or 639-2 where a language has no two-letter code, a region after a hyphen where it matters: en, de, ja, pt-br. Up to 16, none twice. A console may show its user's language first."
        },
        "installdir": {
          "type": "string",
          "pattern": "^PSP/GAME/(?![.][Pp][Ss][Pp][Dd][Xx]-[Ss][Tt][Aa][Gg][Ee](?![\\s\\S]))[A-Za-z0-9_.-]{0,31}[A-Za-z0-9_-](?![\\s\\S])",
          "description": "optional; only for type homebrew. When missing the console uses PSP/GAME/<repository name> for a GitHub source and PSP/GAME/<name> otherwise, the name reduced to [A-Za-z0-9_.-], cut at 32 and without the dots it then ends in. The directory in the zip that holds the EBOOT.PBP, with everything beside and below it, is copied here. The folder is 1 to 32 of [A-Za-z0-9_.-], the name the XMB shows; it does not end in a dot, which FAT drops, so it is never . or .., and it is not .pspdx-stage in any case."
        },
        "summary": {
          "type": "string",
          "maxLength": 60,
          "pattern": "^[^\\u0000-\\u001f]*(?![\\s\\S])",
          "description": "optional; one line; the repository's description otherwise"
        },
        "author": {
          "type": "string",
          "maxLength": 60,
          "pattern": "^[^\\u0000-\\u001f]*(?![\\s\\S])",
          "description": "optional; a name; the repository's owner otherwise"
        },
        "license": {
          "type": "string",
          "maxLength": 60,
          "pattern": "^[^\\u0000-\\u001f]*(?![\\s\\S])",
          "description": "optional; free text, an SPDX identifier where there is one; what GitHub reports otherwise"
        },
        "description": {
          "type": "string",
          "maxLength": 2500,
          "pattern": "^[^\\u0000-\\u0009\\u000b-\\u001f]*(?![\\s\\S])",
          "description": "optional; plain text, newlines allowed"
        },
        "website": {
          "type": "string",
          "maxLength": 255,
          "pattern": "^https://[^\\u0000-\\u001f]+(?![\\s\\S])",
          "description": "optional; the project's own web page"
        },
        "media": {
          "type": "object",
          "description": "optional; EBOOT media and pictures, URLs absolute or relative to the catalog URL",
          "properties": {
            "icon": {
              "type": "string",
              "minLength": 1,
              "format": "uri-reference",
              "description": "ICON0.PNG, 144x80"
            },
            "screenshots": {
              "type": "array",
              "minItems": 1,
              "items": {
                "type": "string",
                "minLength": 1,
                "format": "uri-reference"
              },
              "description": "PIC1.PNG or pictures of the app, 480x272; the console shows the first"
            },
            "video": {
              "type": "string",
              "minLength": 1,
              "format": "uri-reference",
              "description": "ICON1.PMF"
            },
            "sound": {
              "type": "string",
              "minLength": 1,
              "format": "uri-reference",
              "description": "SND0.AT3"
            }
          }
        },
        "releases": {
          "type": "array",
          "minItems": 1,
          "maxItems": 20,
          "items": {
            "$ref": "#/$defs/release"
          },
          "description": "required; up to the 20 newest usable releases, newest first, or only the one a .pspdx pins with release. A release is usable with one zip, or of several exactly one with psp in its name, in any case. The console installs and compares releases[0] only."
        }
      }
    },
    "release": {
      "type": "object",
      "required": [
        "tag",
        "published_at",
        "url",
        "size",
        "sha256"
      ],
      "properties": {
        "tag": {
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "pattern": "^[^\\u0000-\\u001f]*(?![\\s\\S])",
          "description": "required; the release's own tag, as the host names it"
        },
        "published_at": {
          "type": "string",
          "anyOf": [
            {
              "format": "date-time",
              "pattern": "^(?!1970-01-01T00:00:00Z)(?:19[7-9][0-9]|[2-9][0-9]{3})-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z(?![\\s\\S])"
            },
            {
              "format": "date",
              "pattern": "^(?!1970-01-01(?![\\s\\S]))(?:19[7-9][0-9]|[2-9][0-9]{3})-[0-9]{2}-[0-9]{2}(?![\\s\\S])"
            }
          ],
          "description": "required; when the release was published, UTC: 2024-12-20T14:03:00Z, or only the date 2024-12-20 where no time is known; after the start of 1970, so 1970-01-01T00:00:01Z at the earliest and 1970-01-02 as a date, since a console counts seconds from then. It orders and dates releases; the console finds an update by releases[0] having another sha256 than the installed one, not by this time."
        },
        "url": {
          "type": "string",
          "maxLength": 512,
          "format": "uri",
          "pattern": "^https://[\\u0021-\\u007e]+(?![\\s\\S])",
          "description": "required; the release ZIP"
        },
        "size": {
          "type": "integer",
          "minimum": 1,
          "description": "required; ZIP size in bytes"
        },
        "sha256": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}(?![\\s\\S])",
          "description": "required; SHA-256 of the ZIP bytes"
        },
        "eboot_md5": {
          "type": "string",
          "pattern": "^[0-9a-f]{32}(?![\\s\\S])",
          "description": "optional; MD5 of the EBOOT.PBP inside, to recognise a version already on a stick"
        },
        "changelog": {
          "type": "string",
          "maxLength": 2500,
          "pattern": "^[^\\u0000-\\u0009\\u000b-\\u001f]*(?![\\s\\S])",
          "description": "optional; plain text, newlines allowed"
        }
      }
    }
  }
}
