Implement Modrinth export
This commit is contained in:
parent
81f60d9471
commit
862048d169
12 changed files with 291 additions and 161 deletions
|
@ -23,6 +23,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^9.4.0",
|
"commander": "^9.4.0",
|
||||||
|
"cross-zip": "^4.0.0",
|
||||||
"dedent": "^0.7.0",
|
"dedent": "^0.7.0",
|
||||||
"env-paths": "^3.0.0",
|
"env-paths": "^3.0.0",
|
||||||
"figures": "^5.0.0",
|
"figures": "^5.0.0",
|
||||||
|
@ -43,6 +44,7 @@
|
||||||
"zod": "^3.18.0"
|
"zod": "^3.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cross-zip": "^4.0.0",
|
||||||
"@types/dedent": "^0.7.0",
|
"@types/dedent": "^0.7.0",
|
||||||
"@types/fs-extra": "^9.0.13",
|
"@types/fs-extra": "^9.0.13",
|
||||||
"@types/lodash-es": "^4.17.6",
|
"@types/lodash-es": "^4.17.6",
|
||||||
|
|
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
|
@ -1,6 +1,7 @@
|
||||||
lockfileVersion: 5.4
|
lockfileVersion: 5.4
|
||||||
|
|
||||||
specifiers:
|
specifiers:
|
||||||
|
'@types/cross-zip': ^4.0.0
|
||||||
'@types/dedent': ^0.7.0
|
'@types/dedent': ^0.7.0
|
||||||
'@types/fs-extra': ^9.0.13
|
'@types/fs-extra': ^9.0.13
|
||||||
'@types/lodash-es': ^4.17.6
|
'@types/lodash-es': ^4.17.6
|
||||||
|
@ -8,6 +9,7 @@ specifiers:
|
||||||
'@types/semver': ^7.3.12
|
'@types/semver': ^7.3.12
|
||||||
'@types/wrap-ansi': ^8.0.1
|
'@types/wrap-ansi': ^8.0.1
|
||||||
commander: ^9.4.0
|
commander: ^9.4.0
|
||||||
|
cross-zip: ^4.0.0
|
||||||
dedent: ^0.7.0
|
dedent: ^0.7.0
|
||||||
env-paths: ^3.0.0
|
env-paths: ^3.0.0
|
||||||
figures: ^5.0.0
|
figures: ^5.0.0
|
||||||
|
@ -32,6 +34,7 @@ specifiers:
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 9.4.0
|
commander: 9.4.0
|
||||||
|
cross-zip: 4.0.0
|
||||||
dedent: 0.7.0
|
dedent: 0.7.0
|
||||||
env-paths: 3.0.0
|
env-paths: 3.0.0
|
||||||
figures: 5.0.0
|
figures: 5.0.0
|
||||||
|
@ -52,6 +55,7 @@ dependencies:
|
||||||
zod: 3.18.0
|
zod: 3.18.0
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/cross-zip': 4.0.0
|
||||||
'@types/dedent': 0.7.0
|
'@types/dedent': 0.7.0
|
||||||
'@types/fs-extra': 9.0.13
|
'@types/fs-extra': 9.0.13
|
||||||
'@types/lodash-es': 4.17.6
|
'@types/lodash-es': 4.17.6
|
||||||
|
@ -115,6 +119,10 @@ packages:
|
||||||
'@types/responselike': 1.0.0
|
'@types/responselike': 1.0.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/cross-zip/4.0.0:
|
||||||
|
resolution: {integrity: sha512-jZRaaM3aib7qgVhCWeo76vVNJW+8+ujJzP/JHPPA4WUp5YBNMsqiyu5uYWg2eS05+jgSfb5NN8eqrAG72Ab2kA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/dedent/0.7.0:
|
/@types/dedent/0.7.0:
|
||||||
resolution: {integrity: sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A==}
|
resolution: {integrity: sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -263,6 +271,11 @@ packages:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/cross-zip/4.0.0:
|
||||||
|
resolution: {integrity: sha512-MEzGfZo0rqE10O/B+AEcCSJLZsrWuRUvmqJTqHNqBtALhaJc3E3ixLGLJNTRzEA2K34wbmOHC4fwYs9sVsdcCA==}
|
||||||
|
engines: {node: '>=12.10'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/currently-unhandled/0.4.1:
|
/currently-unhandled/0.4.1:
|
||||||
resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==}
|
resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
|
@ -2,23 +2,21 @@ import { Command } from "commander"
|
||||||
import { take } from "lodash-es"
|
import { take } from "lodash-es"
|
||||||
import { usePack } from "../pack.js"
|
import { usePack } from "../pack.js"
|
||||||
import kleur from "kleur"
|
import kleur from "kleur"
|
||||||
import { optionParsePositiveInteger, truncateWithEllipsis } from "../utils.js"
|
import { optionParsePositiveInteger, truncateWithEllipsis, zip } from "../utils.js"
|
||||||
import { default as wrapAnsi } from "wrap-ansi"
|
import { default as wrapAnsi } from "wrap-ansi"
|
||||||
import figures from "figures"
|
import figures from "figures"
|
||||||
import {
|
import {
|
||||||
addModrinthMod,
|
|
||||||
findModForModrinthMod,
|
|
||||||
getModFileDataForModrinthVersion, isModrinthVersionCompatible,
|
|
||||||
modrinthApi,
|
modrinthApi,
|
||||||
ModrinthMod,
|
ModrinthMod,
|
||||||
ModrinthVersion,
|
ModrinthVersion,
|
||||||
ModrinthVersionRelation,
|
ModrinthVersionRelation,
|
||||||
sortModrinthVersionsByPreference
|
} from "../modrinth/api.js"
|
||||||
} from "../modrinth.js"
|
|
||||||
import dedent from "dedent"
|
import dedent from "dedent"
|
||||||
import ago from "s-ago"
|
import ago from "s-ago"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { output } from "../output.js"
|
import { output } from "../output.js"
|
||||||
|
import fs from "fs-extra"
|
||||||
|
import { addModrinthMod, findModForModrinthMod, getModFileDataForModrinthVersion, isModrinthVersionCompatible, sortModrinthVersionsByPreference } from "../modrinth/utils.js"
|
||||||
|
|
||||||
const modrinthCommand = new Command("modrinth")
|
const modrinthCommand = new Command("modrinth")
|
||||||
.alias("mr")
|
.alias("mr")
|
||||||
|
@ -268,10 +266,60 @@ modrinthVersionCommand.command("activate <id>")
|
||||||
await handleActivate(modrinthMod, modrinthVersion, options.force)
|
await handleActivate(modrinthMod, modrinthVersion, options.force)
|
||||||
})
|
})
|
||||||
|
|
||||||
modrinthVersionCommand.command("export")
|
modrinthCommand.command("export")
|
||||||
.description("Generate a Modrinth pack file suitable for uploading")
|
.description("Generate a Modrinth pack file suitable for uploading.")
|
||||||
.action(async () => {
|
.option("-z, --no-zip", "Skip the creation of a zipped .mrpack file.")
|
||||||
// TODO: Implement export
|
.option("-c, --clear", "Remove the output directory afterwards.")
|
||||||
|
.action(async options => {
|
||||||
|
const pack = await usePack()
|
||||||
|
const loader = output.startLoading("Generating")
|
||||||
|
|
||||||
|
const outputDirectory = pack.rootDirectoryPath.resolve("modrinth-pack")
|
||||||
|
await fs.remove(outputDirectory.toString())
|
||||||
|
await fs.mkdirp(outputDirectory.toString())
|
||||||
|
|
||||||
|
await fs.writeJson(outputDirectory.resolve("modrinth.index.json").toString(), {
|
||||||
|
formatVersion: 1,
|
||||||
|
game: "minecraft",
|
||||||
|
versionId: pack.horizrFile.meta.version,
|
||||||
|
name: pack.horizrFile.meta.name,
|
||||||
|
summary: pack.horizrFile.meta.description,
|
||||||
|
dependencies: {
|
||||||
|
minecraft: pack.horizrFile.versions.minecraft,
|
||||||
|
[`${pack.horizrFile.loader}-loader`]: pack.horizrFile.versions.loader
|
||||||
|
},
|
||||||
|
files: pack.mods.map(mod => ({
|
||||||
|
path: `mods/${mod.modFile.file.name}`,
|
||||||
|
hashes: {
|
||||||
|
sha1: mod.modFile.file.hashes.sha1,
|
||||||
|
sha512: mod.modFile.file.hashes.sha512
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
client: mod.modFile.side === "client" || mod.modFile.side === "client+server" ? "required" : "unsupported",
|
||||||
|
server: mod.modFile.side === "server" || mod.modFile.side === "client+server" ? "required" : "unsupported"
|
||||||
|
},
|
||||||
|
downloads: [
|
||||||
|
mod.modFile.file.downloadUrl
|
||||||
|
],
|
||||||
|
fileSize: mod.modFile.file.size
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!options.clear) output.println(kleur.green(`Generated Modrinth pack in ${kleur.yellow("modrinth-pack")}`))
|
||||||
|
|
||||||
|
if (options.zip) {
|
||||||
|
loader.setText(`Creating ${kleur.yellow(".mrpack")} file`)
|
||||||
|
await zip(outputDirectory.toString(), pack.rootDirectoryPath.resolve("pack.mrpack").toString())
|
||||||
|
output.println(kleur.green(`Created ${kleur.yellow("pack.mrpack")}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.clear) {
|
||||||
|
loader.setText("Removing the output directory")
|
||||||
|
await fs.remove(outputDirectory.toString())
|
||||||
|
output.println(kleur.green(`Removed the ${kleur.yellow("modrinth-pack")} directory`))
|
||||||
|
}
|
||||||
|
|
||||||
|
loader.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleActivate(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, force: boolean) {
|
async function handleActivate(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion, force: boolean) {
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { usePack } from "../pack.js"
|
||||||
import fs from "fs-extra"
|
import fs from "fs-extra"
|
||||||
import dedent from "dedent"
|
import dedent from "dedent"
|
||||||
import kleur from "kleur"
|
import kleur from "kleur"
|
||||||
import { relative } from "path"
|
|
||||||
import { getSha512HexHash } from "../utils.js"
|
import { getSha512HexHash } from "../utils.js"
|
||||||
import { output } from "../output.js"
|
import { output } from "../output.js"
|
||||||
|
|
||||||
|
@ -25,9 +24,10 @@ packwizCommand.command("export")
|
||||||
|
|
||||||
const loader = output.startLoading("Generating")
|
const loader = output.startLoading("Generating")
|
||||||
|
|
||||||
const rootDirectoryPath = pack.resolvePath("packwiz")
|
const outputDirectoryPath = pack.rootDirectoryPath.resolve("packwiz")
|
||||||
await fs.remove(rootDirectoryPath)
|
const modsDirectoryPath = outputDirectoryPath.resolve("mods")
|
||||||
await fs.mkdirp(pack.resolvePath("packwiz/mods"))
|
await fs.remove(outputDirectoryPath.toString())
|
||||||
|
await fs.mkdirp(modsDirectoryPath.toString())
|
||||||
|
|
||||||
const indexedFiles: IndexedFile[] = []
|
const indexedFiles: IndexedFile[] = []
|
||||||
for (const mod of pack.mods) {
|
for (const mod of pack.mods) {
|
||||||
|
@ -40,16 +40,16 @@ packwizCommand.command("export")
|
||||||
side = "${mod.modFile.side.replace("client+server", "both")}"
|
side = "${mod.modFile.side.replace("client+server", "both")}"
|
||||||
|
|
||||||
[download]
|
[download]
|
||||||
hash-format = "${mod.modFile.file.hashAlgorithm}"
|
hash-format = "sha512"
|
||||||
hash = ${JSON.stringify(mod.modFile.file.hash)}
|
hash = ${JSON.stringify(mod.modFile.file.hashes.sha512)}
|
||||||
url = ${JSON.stringify(mod.modFile.file.downloadUrl)}
|
url = ${JSON.stringify(mod.modFile.file.downloadUrl)}
|
||||||
`
|
`
|
||||||
|
|
||||||
const path = pack.resolvePath("packwiz/mods", mod.id + ".toml")
|
const path = modsDirectoryPath.resolve(mod.id + ".toml")
|
||||||
await fs.writeFile(path, content)
|
await fs.writeFile(path.toString(), content)
|
||||||
|
|
||||||
indexedFiles.push({
|
indexedFiles.push({
|
||||||
path: relative(rootDirectoryPath, path),
|
path: outputDirectoryPath.relative(path).toString(),
|
||||||
isMeta: true,
|
isMeta: true,
|
||||||
sha512HashHex: await getSha512HexHash(content)
|
sha512HashHex: await getSha512HexHash(content)
|
||||||
})
|
})
|
||||||
|
@ -68,10 +68,10 @@ packwizCommand.command("export")
|
||||||
`).join("\n\n")}
|
`).join("\n\n")}
|
||||||
`
|
`
|
||||||
|
|
||||||
await fs.writeFile(pack.resolvePath("packwiz/index.toml"), index)
|
await fs.writeFile(outputDirectoryPath.resolve("index.toml").toString(), index)
|
||||||
const indexHash = await getSha512HexHash(index)
|
const indexHash = await getSha512HexHash(index)
|
||||||
|
|
||||||
await fs.writeFile(pack.resolvePath("packwiz/pack.toml"), dedent`
|
await fs.writeFile(outputDirectoryPath.resolve("pack.toml").toString(), dedent`
|
||||||
name = ${JSON.stringify(pack.horizrFile.meta.name)}
|
name = ${JSON.stringify(pack.horizrFile.meta.name)}
|
||||||
authors = ${JSON.stringify(pack.horizrFile.meta.authors.join(", "))}\
|
authors = ${JSON.stringify(pack.horizrFile.meta.authors.join(", "))}\
|
||||||
${pack.horizrFile.meta.description === undefined ? "" : "\n" + `description = ${JSON.stringify(pack.horizrFile.meta.description)}`}
|
${pack.horizrFile.meta.description === undefined ? "" : "\n" + `description = ${JSON.stringify(pack.horizrFile.meta.description)}`}
|
||||||
|
|
57
src/files.ts
57
src/files.ts
|
@ -2,49 +2,50 @@ import { SafeParseError, z, ZodRawShape } from "zod"
|
||||||
import kleur from "kleur"
|
import kleur from "kleur"
|
||||||
import fs from "fs-extra"
|
import fs from "fs-extra"
|
||||||
import * as process from "process"
|
import * as process from "process"
|
||||||
import { resolve, dirname } from "path"
|
import { dirname } from "path"
|
||||||
import { findUp } from "find-up"
|
import { findUp } from "find-up"
|
||||||
import { output } from "./output.js"
|
import { output } from "./output.js"
|
||||||
|
import { Path } from "./path.js"
|
||||||
|
|
||||||
export async function findPackDirectoryPath() {
|
export async function findPackDirectoryPath(): Promise<Path> {
|
||||||
if (process.argv0.endsWith("/node")) { // run using pnpm
|
if (process.argv0.endsWith("/node")) { // run using pnpm
|
||||||
return resolve(process.cwd(), "./test-pack")
|
return Path.createAbsolute("./test-pack")
|
||||||
} else {
|
} else {
|
||||||
const parent = await findUp("horizr.json")
|
const parent = await findUp("horizr.json")
|
||||||
if (parent === undefined) return output.failAndExit(`${kleur.yellow("horizr.json")} could not be found in the current working directory or any parent.`)
|
if (parent === undefined) return output.failAndExit(`${kleur.yellow("horizr.json")} could not be found in the current working directory or any parent.`)
|
||||||
|
|
||||||
return dirname(parent)
|
return Path.createAbsolute(dirname(parent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(
|
export async function readJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(
|
||||||
packPath: string,
|
packPath: Path,
|
||||||
filePath: string,
|
filePath: Path,
|
||||||
schema: S
|
schema: S
|
||||||
): Promise<z.output<S> | null> {
|
): Promise<z.output<S> | null> {
|
||||||
let data
|
let data
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data = await fs.readJson(resolve(packPath, filePath))
|
data = await fs.readJson(packPath.resolve(filePath).toString())
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath)} does not contain valid JSON.`)
|
if (e instanceof SyntaxError) return output.failAndExit(`${kleur.yellow(filePath.toString())} does not contain valid JSON.`)
|
||||||
else return null
|
else return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await schema.safeParseAsync(data)
|
const result = await schema.safeParseAsync(data)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const error = (result as SafeParseError<unknown>).error
|
const error = (result as SafeParseError<unknown>).error
|
||||||
return output.failAndExit(`${kleur.yellow(filePath)} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`)
|
return output.failAndExit(`${kleur.yellow(filePath.toString())} is invalid:\n${error.issues.map(issue => `- ${kleur.yellow(issue.path.join("/"))} — ${kleur.red(issue.message)}`).join("\n")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(packPath: string, filePath: string, schema: S, data: z.input<S>) {
|
export async function writeJsonFileInPack<S extends z.ZodObject<ZodRawShape>>(packPath: Path, filePath: Path, schema: S, data: z.input<S>) {
|
||||||
const absolutePath = resolve(packPath, filePath)
|
const absolutePath = packPath.resolve(filePath)
|
||||||
await fs.mkdirp(dirname(absolutePath))
|
await fs.mkdirp(absolutePath.getParent().toString())
|
||||||
|
|
||||||
await fs.writeJson(absolutePath, schema.parse(data), { spaces: 2 })
|
await fs.writeJson(absolutePath.toString(), schema.parse(data), { spaces: 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const horizrFileSchema = z.object({
|
const horizrFileSchema = z.object({
|
||||||
|
@ -65,8 +66,8 @@ const horizrFileSchema = z.object({
|
||||||
|
|
||||||
export type HorizrFile = z.output<typeof horizrFileSchema>
|
export type HorizrFile = z.output<typeof horizrFileSchema>
|
||||||
|
|
||||||
export async function readHorizrFile(packPath: string) {
|
export async function readHorizrFile(packPath: Path) {
|
||||||
const data = await readJsonFileInPack(packPath, "horizr.json", horizrFileSchema)
|
const data = await readJsonFileInPack(packPath, Path.create("horizr.json"), horizrFileSchema)
|
||||||
if (data === null) return output.failAndExit(`${kleur.yellow("horizr.json")} does not exist.`)
|
if (data === null) return output.failAndExit(`${kleur.yellow("horizr.json")} does not exist.`)
|
||||||
if (data.formatVersion !== 1) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`)
|
if (data.formatVersion !== 1) return output.failAndExit(`${kleur.yellow("horizr.json")} has unsupported format version: ${kleur.yellow(data.formatVersion)}`)
|
||||||
|
|
||||||
|
@ -86,8 +87,10 @@ const modFileDataSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
size: z.number().int().min(0).optional(),
|
size: z.number().int().min(0).optional(),
|
||||||
downloadUrl: z.string().url(),
|
downloadUrl: z.string().url(),
|
||||||
hashAlgorithm: z.enum(["sha1", "sha256", "sha512"]),
|
hashes: z.object({ // Adopted from Modrinth
|
||||||
hash: z.string()
|
sha1: z.string(),
|
||||||
|
sha512: z.string()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ModFileData = z.output<typeof modFileDataSchema>
|
export type ModFileData = z.output<typeof modFileDataSchema>
|
||||||
|
@ -107,22 +110,22 @@ const modFileSchema = z.object({
|
||||||
|
|
||||||
export type ModFile = z.output<typeof modFileSchema>
|
export type ModFile = z.output<typeof modFileSchema>
|
||||||
|
|
||||||
export async function readModFile(packPath: string, modId: string): Promise<ModFile | null> {
|
export async function readModFile(packPath: Path, modId: string): Promise<ModFile | null> {
|
||||||
return await readJsonFileInPack(packPath, `mods/${modId}.json`, modFileSchema)
|
return await readJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeModFile(packPath: string, modId: string, data: z.input<typeof modFileSchema>): Promise<void> {
|
export async function writeModFile(packPath: Path, modId: string, data: z.input<typeof modFileSchema>): Promise<void> {
|
||||||
await writeJsonFileInPack(packPath, `mods/${modId}.json`, modFileSchema, data)
|
await writeJsonFileInPack(packPath, Path.create("mods", `${modId}.json`), modFileSchema, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeModFile(packPath: string, modId: string): Promise<void> {
|
export async function removeModFile(packPath: Path, modId: string): Promise<void> {
|
||||||
await fs.remove(resolve(packPath, `mods/${modId}.json`))
|
await fs.remove(packPath.resolve("mods", `${modId}.json`).toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readModIds(packPath: string) {
|
export async function readModIds(packPath: Path) {
|
||||||
const modsPath = resolve(packPath, "./mods")
|
const modsPath = packPath.resolve("mods")
|
||||||
await fs.mkdirp(modsPath)
|
await fs.mkdirp(modsPath.toString())
|
||||||
const files = await fs.readdir(modsPath, { withFileTypes: true })
|
const files = await fs.readdir(modsPath.toString(), { withFileTypes: true })
|
||||||
|
|
||||||
return files.filter(file => file.isFile() && file.name.endsWith(".json")).map(file => file.name.slice(0, -5))
|
return files.filter(file => file.isFile() && file.name.endsWith(".json")).map(file => file.name.slice(0, -5))
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { default as wrapAnsi } from "wrap-ansi"
|
||||||
import { removeModFile } from "./files.js"
|
import { removeModFile } from "./files.js"
|
||||||
import { output } from "./output.js"
|
import { output } from "./output.js"
|
||||||
import figures from "figures"
|
import figures from "figures"
|
||||||
import { releaseChannelOrder } from "./modrinth.js"
|
import { releaseChannelOrder } from "./shared.js"
|
||||||
|
|
||||||
const program = new Command("horizr")
|
const program = new Command("horizr")
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ program.command("remove <code>")
|
||||||
const pack = await usePack()
|
const pack = await usePack()
|
||||||
const mod = pack.findModByCodeOrFail(code)
|
const mod = pack.findModByCodeOrFail(code)
|
||||||
|
|
||||||
await removeModFile(pack.path, mod.id)
|
await removeModFile(pack.rootDirectoryPath, mod.id)
|
||||||
|
|
||||||
output.println(`${mod.modFile.name} ${kleur.green("was removed from the pack.")}`)
|
output.println(`${mod.modFile.name} ${kleur.green("was removed from the pack.")}`)
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
import { IterableElement } from "type-fest"
|
|
||||||
import originalGot, { HTTPError, Response } from "got"
|
import originalGot, { HTTPError, Response } from "got"
|
||||||
import { sortBy } from "lodash-es"
|
|
||||||
import { Loader, Mod, Pack, usePack } from "./pack.js"
|
|
||||||
import { ModFile, ModFileData, ModFileModrinthSource } from "./files.js"
|
|
||||||
import { pathExists } from "fs-extra"
|
|
||||||
import kleur from "kleur"
|
import kleur from "kleur"
|
||||||
import { nanoid } from "nanoid/non-secure"
|
|
||||||
import { KeyvFile } from "keyv-file"
|
import { KeyvFile } from "keyv-file"
|
||||||
import { resolve } from "path"
|
import { delay } from "../utils.js"
|
||||||
import { delay, paths } from "./utils.js"
|
import { output } from "../output.js"
|
||||||
import { output } from "./output.js"
|
import { paths } from "../path.js"
|
||||||
|
import { dependencyToRelatedVersionType } from "./utils.js"
|
||||||
|
import { ModLoader, ReleaseChannel } from "../shared.js"
|
||||||
|
|
||||||
const keyvCache = new KeyvFile({
|
const keyvCache = new KeyvFile({
|
||||||
filename: resolve(paths.cache, "http.json"),
|
filename: paths.cache.resolve("http.json").toString(),
|
||||||
writeDelay: 50,
|
writeDelay: 50,
|
||||||
expiredCheckDelay: 24 * 3600 * 1000,
|
expiredCheckDelay: 24 * 3600 * 1000,
|
||||||
encode: JSON.stringify,
|
encode: JSON.stringify,
|
||||||
|
@ -58,84 +54,6 @@ async function getModrinthApi(url: string): Promise<any> {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
const dependencyToRelatedVersionType: Record<string, IterableElement<ModrinthVersion["relations"]>["type"]> = {
|
|
||||||
required: "hard_dependency",
|
|
||||||
optional: "soft_dependency",
|
|
||||||
embedded: "embedded_dependency",
|
|
||||||
incompatible: "incompatible"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ReleaseChannel = "alpha" | "beta" | "release"
|
|
||||||
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
|
|
||||||
|
|
||||||
export const sortModrinthVersionsByPreference = (versions: ModrinthVersion[]) => sortBy(versions, [v => releaseChannelOrder.indexOf(v.releaseChannel), "isFeatured", "publicationDate"]).reverse()
|
|
||||||
|
|
||||||
export async function findModForModrinthMod(modrinthMod: ModrinthMod): Promise<(Mod & { modFile: ModFile & { source: ModFileModrinthSource } }) | null> {
|
|
||||||
const pack = await usePack()
|
|
||||||
|
|
||||||
return (
|
|
||||||
pack.mods.find(
|
|
||||||
mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.modId === modrinthMod.id
|
|
||||||
) as (Mod & { modFile: Mod & { source: ModFileModrinthSource } }) | undefined
|
|
||||||
) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) =>
|
|
||||||
modrinthVersion.supportedMinecraftVersions.includes(pack.horizrFile.versions.minecraft) && modrinthVersion.supportedLoaders.includes(pack.horizrFile.loader)
|
|
||||||
|
|
||||||
export function getModFileDataForModrinthVersion(modrinthMod: ModrinthMod, modrinthModVersion: ModrinthVersion): ModFileData {
|
|
||||||
const modrinthVersionFile = findCorrectModVersionFile(modrinthModVersion.files)
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: modrinthModVersion.versionString,
|
|
||||||
hash: modrinthVersionFile.hashes.sha512,
|
|
||||||
hashAlgorithm: "sha512",
|
|
||||||
downloadUrl: modrinthVersionFile.url,
|
|
||||||
name: modrinthVersionFile.fileName,
|
|
||||||
size: modrinthVersionFile.sizeInBytes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion) {
|
|
||||||
const pack = await usePack()
|
|
||||||
let id = modrinthMod.slug
|
|
||||||
|
|
||||||
if (await pathExists(pack.resolvePath("mods", `${id}.json`))) {
|
|
||||||
const oldId = id
|
|
||||||
id = `${id}-${nanoid(5)}`
|
|
||||||
|
|
||||||
output.warn(
|
|
||||||
`There is already a mod file named ${kleur.yellow(`${oldId}.json`)} specifying a non-Modrinth mod.\n` +
|
|
||||||
`The file for this mod will therefore be named ${kleur.yellow(`${id}.json`)}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isClientSupported = modrinthMod.clientSide !== "unsupported"
|
|
||||||
const isServerSupported = modrinthMod.serverSide !== "unsupported"
|
|
||||||
|
|
||||||
await pack.addMod(id, {
|
|
||||||
name: modrinthMod.title,
|
|
||||||
enabled: true,
|
|
||||||
ignoreUpdates: false,
|
|
||||||
side: isClientSupported && isServerSupported ? "client+server" : isClientSupported ? "client" : "server",
|
|
||||||
file: getModFileDataForModrinthVersion(modrinthMod, modrinthVersion),
|
|
||||||
source: {
|
|
||||||
type: "modrinth",
|
|
||||||
modId: modrinthMod.id,
|
|
||||||
versionId: modrinthVersion.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findCorrectModVersionFile(files: ModrinthVersionFile[]) {
|
|
||||||
const primary = files.find(file => file.isPrimary)
|
|
||||||
|
|
||||||
if (primary !== undefined) return primary
|
|
||||||
|
|
||||||
// shortest file name
|
|
||||||
return files.sort((a, b) => a.fileName.length - b.fileName.length)[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformApiModVersion(raw: any): ModrinthVersion {
|
function transformApiModVersion(raw: any): ModrinthVersion {
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
|
@ -222,7 +140,7 @@ export interface ModrinthVersionFile {
|
||||||
export const modrinthApi = {
|
export const modrinthApi = {
|
||||||
clearCache: () => keyvCache.clear(),
|
clearCache: () => keyvCache.clear(),
|
||||||
async searchMods(
|
async searchMods(
|
||||||
loader: Loader,
|
loader: ModLoader,
|
||||||
minecraftVersion: string,
|
minecraftVersion: string,
|
||||||
query: string,
|
query: string,
|
||||||
pagination: PaginationOptions
|
pagination: PaginationOptions
|
||||||
|
@ -266,7 +184,7 @@ export const modrinthApi = {
|
||||||
updateDate: new Date(response.updated)
|
updateDate: new Date(response.updated)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async listVersions(idOrSlug: string, loader: Loader, minecraftVersion: string): Promise<ModrinthVersion[]> {
|
async listVersions(idOrSlug: string, loader: ModLoader, minecraftVersion: string): Promise<ModrinthVersion[]> {
|
||||||
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["${loader}"]&game_versions=["${minecraftVersion}"]`)
|
const response = await getModrinthApi(`v2/project/${idOrSlug}/version?loaders=["${loader}"]&game_versions=["${minecraftVersion}"]`)
|
||||||
|
|
||||||
return response.map(transformApiModVersion)
|
return response.map(transformApiModVersion)
|
87
src/modrinth/utils.ts
Normal file
87
src/modrinth/utils.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { IterableElement } from "type-fest"
|
||||||
|
import { sortBy } from "lodash-es"
|
||||||
|
import { Mod, Pack, usePack } from "../pack.js"
|
||||||
|
import { ModFile, ModFileData, ModFileModrinthSource } from "../files.js"
|
||||||
|
import { pathExists } from "fs-extra"
|
||||||
|
import { nanoid } from "nanoid/non-secure"
|
||||||
|
import { output } from "../output.js"
|
||||||
|
import kleur from "kleur"
|
||||||
|
import { ModrinthMod, ModrinthVersion, ModrinthVersionFile } from "./api.js"
|
||||||
|
import { releaseChannelOrder } from "../shared.js"
|
||||||
|
|
||||||
|
export const dependencyToRelatedVersionType: Record<string, IterableElement<ModrinthVersion["relations"]>["type"]> = {
|
||||||
|
required: "hard_dependency",
|
||||||
|
optional: "soft_dependency",
|
||||||
|
embedded: "embedded_dependency",
|
||||||
|
incompatible: "incompatible"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortModrinthVersionsByPreference = (versions: ModrinthVersion[]) => sortBy(versions, [v => releaseChannelOrder.indexOf(v.releaseChannel), "isFeatured", "publicationDate"]).reverse()
|
||||||
|
|
||||||
|
export async function findModForModrinthMod(modrinthMod: ModrinthMod): Promise<(Mod & { modFile: ModFile & { source: ModFileModrinthSource } }) | null> {
|
||||||
|
const pack = await usePack()
|
||||||
|
|
||||||
|
return (
|
||||||
|
pack.mods.find(
|
||||||
|
mod => mod.modFile.source.type === "modrinth" && mod.modFile.source.modId === modrinthMod.id
|
||||||
|
) as (Mod & { modFile: Mod & { source: ModFileModrinthSource } }) | undefined
|
||||||
|
) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isModrinthVersionCompatible = (modrinthVersion: ModrinthVersion, pack: Pack) =>
|
||||||
|
modrinthVersion.supportedMinecraftVersions.includes(pack.horizrFile.versions.minecraft) && modrinthVersion.supportedLoaders.includes(pack.horizrFile.loader)
|
||||||
|
|
||||||
|
export function getModFileDataForModrinthVersion(modrinthMod: ModrinthMod, modrinthModVersion: ModrinthVersion): ModFileData {
|
||||||
|
const modrinthVersionFile = findCorrectModVersionFile(modrinthModVersion.files)
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: modrinthModVersion.versionString,
|
||||||
|
hashes: {
|
||||||
|
sha1: modrinthVersionFile.hashes.sha1,
|
||||||
|
sha512: modrinthVersionFile.hashes.sha512
|
||||||
|
},
|
||||||
|
downloadUrl: modrinthVersionFile.url,
|
||||||
|
name: modrinthVersionFile.fileName,
|
||||||
|
size: modrinthVersionFile.sizeInBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addModrinthMod(modrinthMod: ModrinthMod, modrinthVersion: ModrinthVersion) {
|
||||||
|
const pack = await usePack()
|
||||||
|
let id = modrinthMod.slug
|
||||||
|
|
||||||
|
if (await pathExists(pack.rootDirectoryPath.resolve("mods", `${id}.json`).toString())) {
|
||||||
|
const oldId = id
|
||||||
|
id = `${id}-${nanoid(5)}`
|
||||||
|
|
||||||
|
output.warn(
|
||||||
|
`There is already a mod file named ${kleur.yellow(`${oldId}.json`)} specifying a non-Modrinth mod.\n` +
|
||||||
|
`The file for this mod will therefore be named ${kleur.yellow(`${id}.json`)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClientSupported = modrinthMod.clientSide !== "unsupported"
|
||||||
|
const isServerSupported = modrinthMod.serverSide !== "unsupported"
|
||||||
|
|
||||||
|
await pack.addMod(id, {
|
||||||
|
name: modrinthMod.title,
|
||||||
|
enabled: true,
|
||||||
|
ignoreUpdates: false,
|
||||||
|
side: isClientSupported && isServerSupported ? "client+server" : isClientSupported ? "client" : "server",
|
||||||
|
file: getModFileDataForModrinthVersion(modrinthMod, modrinthVersion),
|
||||||
|
source: {
|
||||||
|
type: "modrinth",
|
||||||
|
modId: modrinthMod.id,
|
||||||
|
versionId: modrinthVersion.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCorrectModVersionFile(files: ModrinthVersionFile[]) {
|
||||||
|
const primary = files.find(file => file.isPrimary)
|
||||||
|
|
||||||
|
if (primary !== undefined) return primary
|
||||||
|
|
||||||
|
// shortest file name
|
||||||
|
return files.sort((a, b) => a.fileName.length - b.fileName.length)[0]
|
||||||
|
}
|
31
src/pack.ts
31
src/pack.ts
|
@ -1,12 +1,12 @@
|
||||||
import { findPackDirectoryPath, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js"
|
import { findPackDirectoryPath, HorizrFile, ModFile, ModFileModrinthSource, readHorizrFile, readModFile, readModIds, writeModFile } from "./files.js"
|
||||||
import { resolve } from "path"
|
|
||||||
import { output } from "./output.js"
|
import { output } from "./output.js"
|
||||||
import pLimit from "p-limit"
|
import pLimit from "p-limit"
|
||||||
import kleur from "kleur"
|
import kleur from "kleur"
|
||||||
import { getModFileDataForModrinthVersion, modrinthApi, ReleaseChannel, sortModrinthVersionsByPreference } from "./modrinth.js"
|
import { modrinthApi } from "./modrinth/api.js"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
|
import { Path } from "./path.js"
|
||||||
export type Loader = "fabric" | "quilt"
|
import { ReleaseChannel } from "./shared.js"
|
||||||
|
import { getModFileDataForModrinthVersion, sortModrinthVersionsByPreference } from "./modrinth/utils.js"
|
||||||
|
|
||||||
export interface Update {
|
export interface Update {
|
||||||
mod: Mod
|
mod: Mod
|
||||||
|
@ -16,19 +16,15 @@ export interface Update {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pack {
|
export interface Pack {
|
||||||
path: string
|
rootDirectoryPath: Path
|
||||||
horizrFile: HorizrFile
|
horizrFile: HorizrFile
|
||||||
mods: Mod[]
|
mods: Mod[]
|
||||||
|
|
||||||
addMod(id: string, file: ModFile): Promise<void>
|
addMod(id: string, file: ModFile): Promise<void>
|
||||||
|
|
||||||
findModByCode(code: string): Mod | null
|
findModByCode(code: string): Mod | null
|
||||||
|
|
||||||
findModByCodeOrFail(code: string): Mod
|
findModByCodeOrFail(code: string): Mod
|
||||||
|
|
||||||
checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]>
|
checkForUpdates(allowedReleaseChannels: ReleaseChannel[]): Promise<Update[]>
|
||||||
|
|
||||||
resolvePath(...segments: string[]): string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Mod {
|
export interface Mod {
|
||||||
|
@ -44,17 +40,17 @@ let pack: Pack
|
||||||
|
|
||||||
export async function usePack(): Promise<Pack> {
|
export async function usePack(): Promise<Pack> {
|
||||||
if (pack === undefined) {
|
if (pack === undefined) {
|
||||||
const path = await findPackDirectoryPath()
|
const rootDirectoryPath = await findPackDirectoryPath()
|
||||||
|
|
||||||
pack = {
|
pack = {
|
||||||
path,
|
rootDirectoryPath,
|
||||||
horizrFile: await readHorizrFile(path),
|
horizrFile: await readHorizrFile(rootDirectoryPath),
|
||||||
mods: await Promise.all((await readModIds(path)).map(async id => {
|
mods: await Promise.all((await readModIds(rootDirectoryPath)).map(async id => {
|
||||||
const mod: Mod = {
|
const mod: Mod = {
|
||||||
id,
|
id,
|
||||||
modFile: (await readModFile(path, id))!,
|
modFile: (await readModFile(rootDirectoryPath, id))!,
|
||||||
async saveModFile() {
|
async saveModFile() {
|
||||||
await writeModFile(path, id, this.modFile)
|
await writeModFile(rootDirectoryPath, id, this.modFile)
|
||||||
},
|
},
|
||||||
async checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null> {
|
async checkForUpdate(allowedReleaseChannels: ReleaseChannel[]): Promise<Update | null> {
|
||||||
if (mod.modFile.ignoreUpdates) return null
|
if (mod.modFile.ignoreUpdates) return null
|
||||||
|
@ -106,7 +102,7 @@ export async function usePack(): Promise<Pack> {
|
||||||
return mod
|
return mod
|
||||||
})),
|
})),
|
||||||
async addMod(id: string, file: ModFile) {
|
async addMod(id: string, file: ModFile) {
|
||||||
await writeModFile(path, id, file)
|
await writeModFile(rootDirectoryPath, id, file)
|
||||||
},
|
},
|
||||||
findModByCode(code: string): Mod | null {
|
findModByCode(code: string): Mod | null {
|
||||||
if (code.startsWith("mrv:")) {
|
if (code.startsWith("mrv:")) {
|
||||||
|
@ -138,9 +134,6 @@ export async function usePack(): Promise<Pack> {
|
||||||
|
|
||||||
loader.stop()
|
loader.stop()
|
||||||
return updates.filter(info => info !== null) as Update[]
|
return updates.filter(info => info !== null) as Update[]
|
||||||
},
|
|
||||||
resolvePath(...segments): string {
|
|
||||||
return resolve(path, ...segments)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
62
src/path.ts
Normal file
62
src/path.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import pathModule from "path"
|
||||||
|
import envPaths from "env-paths"
|
||||||
|
|
||||||
|
export class Path {
|
||||||
|
constructor(private readonly value: string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an absolute path by resolving the last segment against the other segments, this path and the current working directory.
|
||||||
|
*/
|
||||||
|
resolve(...segments: (string | Path)[]) {
|
||||||
|
if (this.isAbsolute()) return this
|
||||||
|
else return new Path(pathModule.resolve(this.value, ...segments.map(s => s.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new path with this path and the segments joined together.
|
||||||
|
*/
|
||||||
|
join(...segments: (string | Path)[]) {
|
||||||
|
return new Path(pathModule.join(this.value, ...segments.map(s => s.toString())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the relative path from this path to the other path.
|
||||||
|
*/
|
||||||
|
relative(other: Path) {
|
||||||
|
return new Path(pathModule.relative(this.value, other.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
getParent() {
|
||||||
|
return new Path(pathModule.dirname(this.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
isAbsolute() {
|
||||||
|
return pathModule.isAbsolute(this.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.value
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(...segments: string[]) {
|
||||||
|
if (segments.length === 0) throw new Error("At least one segment is required")
|
||||||
|
|
||||||
|
return new Path(pathModule.join(...segments))
|
||||||
|
}
|
||||||
|
|
||||||
|
static createAbsolute(...segments: string[]) {
|
||||||
|
if (segments.length === 0) throw new Error("At least one segment is required")
|
||||||
|
|
||||||
|
return new Path(pathModule.resolve(...segments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPaths = envPaths("horizr", { suffix: "" })
|
||||||
|
export const paths = {
|
||||||
|
cache: new Path(rawPaths.cache),
|
||||||
|
config: new Path(rawPaths.config),
|
||||||
|
data: new Path(rawPaths.data),
|
||||||
|
log: new Path(rawPaths.log),
|
||||||
|
temp: new Path(rawPaths.temp)
|
||||||
|
}
|
3
src/shared.ts
Normal file
3
src/shared.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export type ModLoader = "fabric" | "quilt"
|
||||||
|
export type ReleaseChannel = "alpha" | "beta" | "release"
|
||||||
|
export const releaseChannelOrder: ReleaseChannel[] = ["alpha", "beta", "release"]
|
|
@ -1,8 +1,9 @@
|
||||||
import envPaths from "env-paths"
|
|
||||||
import { InvalidArgumentError } from "commander"
|
import { InvalidArgumentError } from "commander"
|
||||||
import hash, { HashaInput } from "hasha"
|
import hash, { HashaInput } from "hasha"
|
||||||
|
import { zip as zipWithCallback } from "cross-zip"
|
||||||
|
import { promisify } from "util"
|
||||||
|
|
||||||
export const paths = envPaths("horizr", { suffix: "" })
|
export const zip = promisify(zipWithCallback)
|
||||||
|
|
||||||
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue