Skip to content

Commit

Permalink
First steps at in-browser parsing support
Browse files Browse the repository at this point in the history
  • Loading branch information
noahm committed Oct 17, 2023
1 parent 4e5ec22 commit 0cc0f60
Show file tree
Hide file tree
Showing 17 changed files with 438 additions and 65 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
cache: "yarn"

- name: Init project
run: yarn --frozen-lockfile
run: yarn --immutable

- name: Webpack Build
run: yarn build
Expand Down
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1 @@
nodejs 20.4.0
nodejs 20
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "simfile-parser",
"version": "0.6.1",
"version": "0.7.0",
"description": "Read stepmania charts with javascript!",
"type": "module",
"main": "./dist/main.js",
"browser": "./dist/browser.js",
"browser": "./dist/browser/index.js",
"types": "./dist/main.d.ts",
"bin": "./dist/cli.js",
"sideEffects": "false",
Expand Down Expand Up @@ -57,6 +57,7 @@
"devDependencies": {
"@types/jest": "^29.5.3",
"@types/node": "^20.4.2",
"@types/wicg-file-system-access": "^2023.10.1",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"confusing-browser-globals": "^1.0.11",
Expand All @@ -70,6 +71,9 @@
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
},
"peerDependencies": {
"@types/wicg-file-system-access": "*"
},
"resolutions": {
"semver": "^7.5.2"
},
Expand Down
1 change: 0 additions & 1 deletion src/browser.ts

This file was deleted.

123 changes: 123 additions & 0 deletions src/browser/index.ts
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,
};
}
194 changes: 194 additions & 0 deletions src/browser/parseSong.ts
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,
};
}
Loading

0 comments on commit 0cc0f60

Please sign in to comment.