generated from raulanatol/template-ts-package
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
First steps at in-browser parsing support
- Loading branch information
Showing
17 changed files
with
438 additions
and
65 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
nodejs 20.4.0 | ||
nodejs 20 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"typescript.tsdk": "node_modules/typescript/lib" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
import { Pack, Simfile } from "../types.js"; | ||
import { reportError, printMaybeError } from "../util.js"; | ||
import { parseSong } from "./parseSong.js"; | ||
import { isDirectoryEntry, isDirectoryHandle } from "./shared.js"; | ||
|
||
/** | ||
* @param dir directory handle | ||
* @yields FileSystemDirectoryHandle for each subdir of the given dir | ||
* @returns nothing | ||
*/ | ||
async function* getDirectories( | ||
dir: FileSystemDirectoryHandle | FileSystemDirectoryEntry | ||
) { | ||
if ("createReader" in dir) { | ||
const dirs = await getDirectoriesFromEntry(dir); | ||
yield* dirs; | ||
} else { | ||
for await (const child of dir.values()) { | ||
if (child.kind === "directory") { | ||
yield child; | ||
} | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* @param dir file system entry | ||
* @returns only subdirectories of the given directory | ||
*/ | ||
function getDirectoriesFromEntry(dir: FileSystemDirectoryEntry) { | ||
const dirReader = dir.createReader(); | ||
return new Promise<FileSystemDirectoryEntry[]>((resolve, reject) => { | ||
dirReader.readEntries((results) => { | ||
resolve(results.filter(isDirectoryEntry)); | ||
}, reject); | ||
}); | ||
} | ||
|
||
declare global { | ||
interface DataTransferItem { | ||
// optionalize this to avoid incorrect type narrowing below | ||
getAsFileSystemHandle?(): Promise<FileSystemHandle | null>; | ||
} | ||
} | ||
|
||
/** | ||
* Parse a pack drag/dropped by a user in a browser | ||
* @param item a DataTransferItem from a drop event | ||
* @returns parsed pack | ||
*/ | ||
export async function parsePack(item: DataTransferItem | HTMLInputElement) { | ||
let dir: FileSystemDirectoryEntry | FileSystemDirectoryHandle; | ||
if (item instanceof HTMLInputElement) { | ||
if ("webkitEntries" in item) { | ||
const entries = item.webkitEntries; | ||
if (entries.length !== 1) { | ||
throw new Error("expected exactly one selected file"); | ||
} | ||
const entry = entries[0]; | ||
if (!isDirectoryEntry(entry)) { | ||
throw new Error("expected folder to be dropped, but got file"); | ||
} | ||
dir = entry; | ||
} else { | ||
throw new Error("entries property not available on provided input"); | ||
} | ||
} else { | ||
if (item.kind !== "file") { | ||
throw new Error("expected file to be dropped, but it was not a file"); | ||
} | ||
if (item.getAsFileSystemHandle) { | ||
const dirHandle = await item.getAsFileSystemHandle(); | ||
if (!dirHandle) { | ||
throw new Error("could not get file handle from drop item"); | ||
} | ||
if (!isDirectoryHandle(dirHandle)) { | ||
throw new Error("expected folder to be dropped, but got file"); | ||
} | ||
dir = dirHandle; | ||
} else if ("webkitGetAsEntry" in item) { | ||
const entry = item.webkitGetAsEntry(); | ||
if (!entry) { | ||
throw new Error("could not get a file entry from drop item"); | ||
} | ||
if (!isDirectoryEntry(entry)) { | ||
throw new Error("expected folder to be dropped, but got file"); | ||
} | ||
dir = entry; | ||
} else { | ||
throw new Error("no supported file drop mechanism supported"); | ||
} | ||
} | ||
|
||
const pack: Pack = { | ||
name: dir.name.replace(/-/g, " "), | ||
dir: dir.name, | ||
songCount: 0, | ||
}; | ||
|
||
const simfiles: Simfile[] = []; | ||
for await (const songFolder of getDirectories(dir)) { | ||
try { | ||
const songData = await parseSong(songFolder); | ||
if (songData) { | ||
simfiles.push({ | ||
...songData, | ||
pack, | ||
}); | ||
} | ||
} catch (e) { | ||
reportError( | ||
`parseStepchart failed for '${songFolder.name}': ${printMaybeError(e)}` | ||
); | ||
} | ||
} | ||
|
||
pack.songCount = simfiles.length; | ||
|
||
return { | ||
...pack, | ||
simfiles, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,194 @@ | ||
import { parsers, supportedExtensions } from "../parsers/index.js"; | ||
import { ParsedImages, RawSimfile } from "../parsers/types.js"; | ||
import { Simfile } from "../types.js"; | ||
import { extname, isFileEntry } from "./shared.js"; | ||
|
||
/** | ||
* Find a simfile in a given directory | ||
* @param songDir directory handle | ||
* @returns file handle for the song file | ||
*/ | ||
async function getSongFile( | ||
songDir: FileSystemDirectoryHandle | FileSystemDirectoryEntry | ||
) { | ||
if ("createReader" in songDir) { | ||
return getSongFileFromEntry(songDir); | ||
} | ||
for await (const handle of songDir.values()) { | ||
if (handle.kind === "file") { | ||
if (supportedExtensions.some((ext) => handle.name.endsWith(ext))) { | ||
return handle; | ||
} | ||
} | ||
} | ||
return null; | ||
} | ||
|
||
/** | ||
* @param songDir legacy file system entry | ||
* @returns promise of the found file entry or null | ||
*/ | ||
async function getSongFileFromEntry(songDir: FileSystemDirectoryEntry) { | ||
const dirReader = songDir.createReader(); | ||
return new Promise<FileSystemFileEntry | null>((resolve, reject) => { | ||
dirReader.readEntries((results) => { | ||
for (const result of results) { | ||
if (isFileEntry(result)) { | ||
if (supportedExtensions.some((ext) => result.name.endsWith(ext))) { | ||
resolve(result); | ||
return; | ||
} | ||
} | ||
} | ||
resolve(null); | ||
}, reject); | ||
}); | ||
} | ||
|
||
const imageExts = new Set([".png", ".jpg"]); | ||
|
||
/** | ||
* Get all image files in a given directory | ||
* @param songDir directory | ||
* @yields file handles filtered to supported image extentions | ||
*/ | ||
async function* getImages( | ||
songDir: FileSystemDirectoryHandle | FileSystemDirectoryEntry | ||
) { | ||
let files: | ||
| AsyncIterable<FileSystemDirectoryHandle | FileSystemFileHandle> | ||
| Iterable<FileSystemEntry>; | ||
if ("values" in songDir) { | ||
files = songDir.values(); | ||
} else { | ||
files = await new Promise<FileSystemEntry[]>((res, rej) => | ||
songDir.createReader().readEntries(res, rej) | ||
); | ||
} | ||
for await (const file of files) { | ||
const ext = extname(file.name); | ||
if (!ext) { | ||
continue; | ||
} | ||
if ("kind" in file && file.kind === "directory") { | ||
continue; | ||
} | ||
if ("isFile" in file && !isFileEntry(file)) { | ||
continue; | ||
} | ||
if (imageExts.has(ext)) { | ||
yield file as FileSystemFileEntry | FileSystemFileHandle; | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Make some best guesses about which images should be used for which fields | ||
* @param songDir path to a song directory | ||
* @param tagged image metadata found in simfile | ||
* @returns final image metadata | ||
*/ | ||
async function guessImages( | ||
songDir: FileSystemDirectoryHandle | FileSystemDirectoryEntry, | ||
tagged: ParsedImages | ||
) { | ||
let jacket = tagged.jacket; | ||
let bg = tagged.bg; | ||
let banner = tagged.banner; | ||
for await (const { name: imageName } of getImages(songDir)) { | ||
const ext = extname(imageName)!; | ||
if ( | ||
(!jacket && imageName.endsWith("-jacket" + ext)) || | ||
imageName.startsWith("jacket.") | ||
) { | ||
jacket = imageName; | ||
} | ||
if ( | ||
(!bg && imageName.endsWith("-bg" + ext)) || | ||
imageName.startsWith("bg.") | ||
) { | ||
bg = imageName; | ||
} | ||
if ( | ||
(!banner && imageName.endsWith("-bn" + ext)) || | ||
imageName.startsWith("bn.") | ||
) { | ||
banner = imageName; | ||
} | ||
} | ||
return { jacket, bg, banner }; | ||
} | ||
|
||
/** | ||
* get individual bpms of each chart | ||
* @param sm simfile | ||
* @returns list of found bpms, one per chart | ||
*/ | ||
function getBpms(sm: Pick<RawSimfile, "charts">): number[] { | ||
const chart = Object.values(sm.charts)[0]; | ||
return chart.bpm.map((b) => b.bpm); | ||
} | ||
|
||
/** | ||
* Parse a single simfile. Automatically determines which parser to use depending on chart definition type. | ||
* @param songDir path to song folder (contains a chart definition file [dwi/sm], images, etc) | ||
* @returns a simfile object without mix info or null if no sm/ssc file was found | ||
*/ | ||
export async function parseSong( | ||
songDir: FileSystemDirectoryHandle | FileSystemDirectoryEntry | ||
): Promise<Omit<Simfile, "mix"> | null> { | ||
const songFileHandleOrEntry = await getSongFile(songDir); | ||
if (!songFileHandleOrEntry) { | ||
return null; | ||
} | ||
const extension = extname(songFileHandleOrEntry.name); | ||
if (!extension) return null; | ||
|
||
const parser = parsers[extension]; | ||
|
||
if (!parser) { | ||
throw new Error(`No parser registered for extension: ${extension}`); | ||
} | ||
|
||
let fileContents: File; | ||
if ("getFile" in songFileHandleOrEntry) { | ||
fileContents = await songFileHandleOrEntry.getFile(); | ||
} else { | ||
fileContents = await new Promise((resolve, reject) => | ||
songFileHandleOrEntry.file(resolve, reject) | ||
); | ||
} | ||
|
||
const { images, ...rawStepchart } = parser(await fileContents.text(), ""); | ||
|
||
if (!Object.keys(rawStepchart.charts).length) { | ||
throw new Error( | ||
`Failed to parse any charts from song: ${rawStepchart.title}` | ||
); | ||
} | ||
|
||
const bpms = getBpms(rawStepchart); | ||
const minBpm = Math.round(Math.min(...bpms)); | ||
const maxBpm = Math.round(Math.max(...bpms)); | ||
|
||
let displayBpm = rawStepchart.displayBpm; | ||
if (!displayBpm) { | ||
displayBpm = minBpm === maxBpm ? minBpm.toString() : `${minBpm}-${maxBpm}`; | ||
} | ||
|
||
const finalImages = await guessImages(songDir, images); | ||
|
||
return { | ||
...rawStepchart, | ||
title: { | ||
titleName: rawStepchart.title, | ||
translitTitleName: rawStepchart.titletranslit ?? null, | ||
titleDir: songDir.name, | ||
...finalImages, | ||
}, | ||
minBpm, | ||
maxBpm, | ||
displayBpm, | ||
stopCount: Object.values(rawStepchart.charts)[0].stops.length, | ||
}; | ||
} |
Oops, something went wrong.