finish extras functionality

This commit is contained in:
2026-02-15 16:25:04 -08:00
parent 2ce38bc85d
commit 0c7b060bd2
4 changed files with 215 additions and 9 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@sugoidogo/packwiz-extras-lib",
"version": "0.2.0",
"version": "0.1.0",
"description": "ts/js library for node and web for working with packwiz modpacks",
"keywords": [
"minecraft"
+5
View File
@@ -0,0 +1,5 @@
import { PackwizPack } from './packwiz.ts'
export type * from './types.ts'
export * from './packwiz.ts'
export default PackwizPack
+205 -6
View File
@@ -1,9 +1,13 @@
import type { FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemWriteChunkType, Response } from "@sugoidogo/importable-types-web"
import type { Hash, HashFormat, PackFileMetaData, PackIndex, PackMetaData, Path, Url } from "./types"
import type { Hash, HashFormat, PackFileMetaData, PackIndex, PackMetaData, Path, Side, Url } from "./types"
import { parseTOML, stringifyTOML } from "confbox"
import { forAsync } from "@sugoidogo/js-util"
import WorkerlessPool from "workerless"
import { CurseforgeV1Client, type Mod } from "@xmcl/curseforge"
import { ModrinthV2Client, type Project } from "@xmcl/modrinth"
const pathSeperatorRegex = /[\\\/]/
const minecrafCurseforgeGameID = 432
function assertOK(response: Response) {
if (!response.ok) {
@@ -78,7 +82,7 @@ export class PackwizPack {
packIndex: PackIndex
packFiles: { [key: Path]: PackFileMetaData | undefined }
constructor(packDirectoryHandle: FileSystemDirectoryHandle, packName: string, versions:PackMetaData["versions"]) {
constructor(packDirectoryHandle: FileSystemDirectoryHandle, packName: string, versions: PackMetaData["versions"]) {
this.packDirectoryHandle = packDirectoryHandle
this.packFileName = "pack.toml"
this.packMetaData = {
@@ -97,7 +101,7 @@ export class PackwizPack {
this.packFiles = {}
}
static async fetchPack(packDirectoryHandle: FileSystemDirectoryHandle, packUrl: Url) {
static async fetchPack(packDirectoryHandle: FileSystemDirectoryHandle, packUrl: Url): Promise<PackwizPack> {
const { packMetaData, packFileName } = await fetch(packUrl).then(async response => {
const packFileText = await assertOK(response).text()
const packMetaData = parseTOML(packFileText) as PackMetaData
@@ -139,7 +143,7 @@ export class PackwizPack {
return pack
}
static async loadPack(packDirectoryHandle: FileSystemDirectoryHandle, packFileName = "pack.toml") {
static async loadPack(packDirectoryHandle: FileSystemDirectoryHandle, packFileName = "pack.toml"): Promise<PackwizPack> {
const packMetaData = await getFileHandle(packDirectoryHandle, packFileName).then(async packDirectoryHandle => {
const packFile = await packDirectoryHandle.getFile()
const packFileText = await packFile.text()
@@ -175,7 +179,7 @@ export class PackwizPack {
return pack
}
static async refreshPack(packDirectoryHandle: FileSystemDirectoryHandle, packFileName = "pack.toml") {
static async refreshPack(packDirectoryHandle: FileSystemDirectoryHandle, packFileName = "pack.toml"): Promise<PackwizPack> {
const { packMetaData, packFileHandle } = await getFileHandle(packDirectoryHandle, packFileName).then(async packDirectoryHandle => {
const packFile = await packDirectoryHandle.getFile()
const packFileText = await packFile.text()
@@ -224,7 +228,7 @@ export class PackwizPack {
return pack
}
async savePack(hashes?: boolean) {
async savePack(hashes?: boolean): Promise<PackwizPack> {
if (typeof hashes !== "boolean") {
hashes = !this.packMetaData.options || !this.packMetaData.options["no-internal-hashes"]
}
@@ -263,5 +267,200 @@ export class PackwizPack {
})
await getFileHandle(this.packDirectoryHandle, this.packFileName, { "create": true })
.then(packFileHandle => writeFile(packFileHandle, stringifyTOML(this.packMetaData)))
return this
}
async findAndReplaceCurseforgeFiles(apiKey: string): Promise<PackwizPack> {
const workerlessPool = new WorkerlessPool()
const indexPathSegments = this.packMetaData.index.file.split(pathSeperatorRegex)
indexPathSegments.pop()
const indexDirname = indexPathSegments.join("/")
const indexDirectoryHandle = await getDirectoryHandle(this.packDirectoryHandle, indexDirname)
const hashPaths = new Map<number, Path>()
await forAsync(this.packIndex.files, async entry => {
if (entry.metafile) return
const fileHandle = await getFileHandle(indexDirectoryHandle, entry.file)
const file = await fileHandle.getFile()
if (file.size === 0) return
const bytes = await file.bytes()
const hash = await workerlessPool.run(getHash, bytes, "murmur2") as number
hashPaths.set(hash, entry.file)
})
workerlessPool.terminate()
const curseforge = new CurseforgeV1Client(apiKey)
const result = await curseforge.getFingerprintsMatchesByGameId(minecrafCurseforgeGameID, [...hashPaths.keys()])
const modIDs: number[] = []
for (const match of result.exactMatches) modIDs.push(match.file.modId)
const mods = new Map<number, Mod>()
for (const mod of await curseforge.getMods(modIDs)) mods.set(mod.id, mod)
const replacedFilePaths: string[] = []
await forAsync(result.exactMatches, async match => {
const filePath = hashPaths.get(match.file.fileFingerprint)
if (!filePath) return
const filePathSegments = filePath.split(pathSeperatorRegex)
const fileBasename = filePathSegments.pop()
const fileDirname = filePathSegments.join("/")
const fileDirectoryHandle = await getDirectoryHandle(indexDirectoryHandle, fileDirname)
await fileDirectoryHandle.removeEntry(fileBasename)
const mod = mods.get(match.file.modId)
for (const hash of match.file.hashes) {
if (hash.algo === 1) {
this.packFiles[fileDirname + "/" + mod.slug + ".pw.toml"] = {
"filename": match.file.fileName,
"name": mod.name,
"download": {
"hash": hash.value,
"hash-format": "sha1",
"mode": "metadata:curseforge"
},
"update": {
"curseforge": {
"file-id": match.file.id,
"project-id": match.file.modId
}
},
"option": {
"optional": false,
"description": mod.summary
}
}
break
}
}
replacedFilePaths.push(filePath)
})
for (let i = 0; i < this.packIndex.files.length; i++) {
if (replacedFilePaths.includes(this.packIndex.files[i].file)) {
this.packIndex.files.splice(i, 1)
}
}
return this.savePack()
}
async findAndReplaceModrinthFiles(): Promise<PackwizPack> {
const indexPathSegments = this.packMetaData.index.file.split(pathSeperatorRegex)
indexPathSegments.pop()
const indexDirname = indexPathSegments.join("/")
const indexDirectoryHandle = await getDirectoryHandle(this.packDirectoryHandle, indexDirname)
const hashPaths = new Map<Hash, Path>()
await forAsync(this.packIndex.files, async entry => {
if (entry.metafile) return
const fileHandle = await getFileHandle(indexDirectoryHandle, entry.file)
const file = await fileHandle.getFile()
if (file.size === 0) return
const bytes = await file.bytes()
const hash = await getHash(bytes, "sha512") as string
hashPaths.set(hash, entry.file)
})
const modrinth = new ModrinthV2Client()
const matches = await modrinth.getProjectVersionsByHash([...hashPaths.keys()])
const projectIDs: string[] = []
for (const match of Object.values(matches)) projectIDs.push(match.project_id)
const projects = new Map<string, Project>()
for (const project of await modrinth.getProjects(projectIDs)) projects.set(project.id, project)
const replacedFilePaths: string[] = []
await forAsync(Object.entries(matches), async entry => {
const [hash, match] = entry
const filePath = hashPaths.get(hash)
if (!filePath) return
const filePathSegments = filePath.split(pathSeperatorRegex)
const fileBasename = filePathSegments.pop()
const fileDirname = filePathSegments.join("/")
const fileDirectoryHandle = await getDirectoryHandle(indexDirectoryHandle, fileDirname)
await fileDirectoryHandle.removeEntry(fileBasename)
const mod = projects.get(match.project_id)
let side: Side = "both"
if (mod.server_side === "unsupported") {
side = "client"
} else if (mod.client_side === "unsupported") {
side = "server"
}
for (const file of match.files) {
if (file.hashes["sha512"] === hash) {
this.packFiles[fileDirname + "/" + mod.slug + ".pw.toml"] = {
"filename": file.filename,
"name": mod.title,
"download": {
"hash": file.hashes["sha1"],
"hash-format": "sha1",
"url": file.url
},
"update": {
"modrinth": {
"mod-id": mod.id,
"version": match.id
}
},
"option": {
"optional": false,
"description": mod.description
}
}
}
}
replacedFilePaths.push(filePath)
})
for (let i = 0; i < this.packIndex.files.length; i++) {
if (replacedFilePaths.includes(this.packIndex.files[i].file)) {
this.packIndex.files.splice(i, 1)
}
}
return this.savePack()
}
async cacheCurseforgeDownloadURLs(apiKey: string): Promise<PackwizPack> {
const fileIDPaths = new Map<number, Path>()
for (const [filePath, metafiledata] of Object.entries(this.packFiles)) {
if (!metafiledata.download.mode) continue
fileIDPaths.set(metafiledata.update.curseforge["file-id"], filePath)
}
const curseforge = new CurseforgeV1Client(apiKey)
for (const file of await curseforge.getFiles([...fileIDPaths.keys()])) {
if (file.downloadUrl) {
this.packFiles[fileIDPaths.get(file.id)].download.url = file.downloadUrl
delete this.packFiles[fileIDPaths.get(file.id)].download.mode
}
}
return this
}
async mergeModrinthMetadata(): Promise<PackwizPack> {
const hashPaths = new Map<Hash, Path>()
for (const [filePath, metafiledata] of Object.entries(this.packFiles)) {
if (metafiledata.update.modrinth) continue
hashPaths.set(metafiledata.download.hash, filePath)
}
const modrinth = new ModrinthV2Client()
const matches = await modrinth.getProjectVersionsByHash([...hashPaths.keys()])
const projectIDs: string[] = []
for (const match of Object.values(matches)) projectIDs.push(match.project_id)
const projects = new Map<string, Project>()
for (const project of await modrinth.getProjects(projectIDs)) projects.set(project.id, project)
for (const record of Object.entries(matches)) {
const [hash, match] = record
const filePath = hashPaths.get(hash)
if (!filePath) return
const mod = projects.get(match.project_id)
let side: Side = "both"
if (mod.server_side === "unsupported") {
side = "client"
} else if (mod.client_side === "unsupported") {
side = "server"
}
for (const file of match.files) {
if (file.hashes["sha1"] === hash) {
const metafiledata = this.packFiles[filePath]
delete metafiledata.download.mode
metafiledata.download.url = file.url
metafiledata.side = side
metafiledata.update.modrinth = {
"mod-id": mod.id,
"version": match.id
}
break
}
}
}
return this
}
}
+4 -2
View File
@@ -11,6 +11,8 @@
"outDir": "dist",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
}
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/index.ts"]
}