feat: scaffold workspace with search, fetch, and extract packages
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@tts/extract",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "echo \"no lint configured\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tts/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { AssetRef } from '@tts/shared';
|
||||
|
||||
/** Guess a MIME type from a URL's file extension. */
|
||||
export function guessMimeType(url: string): string {
|
||||
const ext = url.split('?')[0]!.split('.').pop()!.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'pdf':
|
||||
return 'application/pdf';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
/** Download a single asset as a `Blob`. */
|
||||
export async function downloadAsset(url: string): Promise<Blob> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download asset (${res.status}): ${url}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
concurrency?: number;
|
||||
onProgress?: (p: DownloadProgress) => void;
|
||||
}
|
||||
|
||||
export interface DownloadedAsset {
|
||||
ref: AssetRef;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple assets with limited concurrency and a progress callback.
|
||||
*/
|
||||
export async function downloadAll(
|
||||
refs: AssetRef[],
|
||||
options: DownloadOptions = {},
|
||||
): Promise<DownloadedAsset[]> {
|
||||
const concurrency = options.concurrency ?? 4;
|
||||
const results: DownloadedAsset[] = [];
|
||||
let done = 0;
|
||||
|
||||
const worker = async (ref: AssetRef): Promise<void> => {
|
||||
const blob = await downloadAsset(ref.url);
|
||||
results.push({ ref, blob });
|
||||
done += 1;
|
||||
options.onProgress?.({ done, total: refs.length, url: ref.url });
|
||||
};
|
||||
|
||||
const queue = [...refs];
|
||||
const workers: Promise<void>[] = [];
|
||||
for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
|
||||
workers.push(
|
||||
(async () => {
|
||||
while (queue.length > 0) {
|
||||
const ref = queue.shift()!;
|
||||
await worker(ref);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './traverse.js';
|
||||
export * from './objects.js';
|
||||
export * from './refs.js';
|
||||
export * from './download.js';
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TTSMod, TTSObject } from '@tts/shared';
|
||||
import { markParent, traverseMod } from './traverse.js';
|
||||
|
||||
/**
|
||||
* Flatten all objects in a save into an array.
|
||||
* Ensures parent links are set before returning.
|
||||
*/
|
||||
export function flattenObjects(mod: TTSMod): TTSObject[] {
|
||||
for (const state of mod.ObjectStates) {
|
||||
markParent(state);
|
||||
}
|
||||
return [...traverseMod(mod)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter objects by a predicate (name, GUID, type, etc.).
|
||||
*/
|
||||
export function filterObjects(
|
||||
mod: TTSMod,
|
||||
predicate: (o: TTSObject) => boolean,
|
||||
): TTSObject[] {
|
||||
return flattenObjects(mod).filter(predicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single object by GUID.
|
||||
*/
|
||||
export function findObject(mod: TTSMod, guid: string): TTSObject | undefined {
|
||||
return flattenObjects(mod).find((o) => o.GUID === guid);
|
||||
}
|
||||
|
||||
export { markParent, traverseMod } from './traverse.js';
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AssetRef, TTSMod, TTSObject } from '@tts/shared';
|
||||
import { markParent, traverseMod } from './traverse.js';
|
||||
|
||||
/**
|
||||
* Extract every external asset reference from a single object.
|
||||
*/
|
||||
export function extractRefs(o: TTSObject): AssetRef[] {
|
||||
const refs: AssetRef[] = [];
|
||||
const ownerGuid = o.GUID;
|
||||
|
||||
if (o.CustomPDF?.PDFUrl) {
|
||||
refs.push({ kind: 'pdf', url: o.CustomPDF.PDFUrl, ownerGuid });
|
||||
}
|
||||
|
||||
if (o.CustomDeck) {
|
||||
for (const deck of Object.values(o.CustomDeck)) {
|
||||
if (deck.FaceURL) {
|
||||
refs.push({ kind: 'deckFace', url: deck.FaceURL, ownerGuid });
|
||||
}
|
||||
if (deck.BackURL) {
|
||||
refs.push({ kind: 'deckBack', url: deck.BackURL, ownerGuid });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (o.CustomImage?.ImageURL) {
|
||||
refs.push({ kind: 'image', url: o.CustomImage.ImageURL, ownerGuid });
|
||||
}
|
||||
if (o.CustomImage?.ImageSecondaryURL) {
|
||||
refs.push({
|
||||
kind: 'imageSecondary',
|
||||
url: o.CustomImage.ImageSecondaryURL,
|
||||
ownerGuid,
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all asset references across a whole save, deduped by URL.
|
||||
*/
|
||||
export function collectRefs(mod: TTSMod): AssetRef[] {
|
||||
for (const state of mod.ObjectStates) {
|
||||
markParent(state);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const refs: AssetRef[] = [];
|
||||
for (const o of traverseMod(mod)) {
|
||||
for (const ref of extractRefs(o)) {
|
||||
if (!seen.has(ref.url)) {
|
||||
seen.add(ref.url);
|
||||
refs.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
export { markParent, traverseMod } from './traverse.js';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TTSMod, TTSObject } from '@tts/shared';
|
||||
|
||||
/**
|
||||
* Recursively set each object's `Parent` to its container.
|
||||
* Mutates the tree in place.
|
||||
*/
|
||||
export function markParent(o: TTSObject): void {
|
||||
if (o.ContainedObjects) {
|
||||
for (const each of o.ContainedObjects) {
|
||||
each.Parent = o;
|
||||
markParent(each);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield every object in a save, descending into Bags and Custom_Model_Bags.
|
||||
* Accepts either a full `TTSMod` or a single `TTSObject`.
|
||||
*/
|
||||
export function* traverseMod(
|
||||
mod: TTSMod | TTSObject,
|
||||
): Iterable<TTSObject> {
|
||||
if ('ObjectStates' in mod) {
|
||||
for (const one of mod.ObjectStates) {
|
||||
yield* traverseMod(one);
|
||||
}
|
||||
} else if (mod.Name === 'Bag' || mod.Name === 'Custom_Model_Bag') {
|
||||
for (const one of mod.ContainedObjects ?? []) {
|
||||
yield* traverseMod(one);
|
||||
}
|
||||
} else {
|
||||
yield mod;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@tts/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "echo \"no lint configured\""
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types.js';
|
||||
export * from './schemas.js';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Validate a Workshop item ID (a non-empty run of digits). */
|
||||
export const itemIdSchema = z
|
||||
.string()
|
||||
.regex(/^\d+$/, 'Item ID must be a number')
|
||||
.min(1);
|
||||
|
||||
/** Query params for `GET /search`. */
|
||||
export const searchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, 'Search query is required'),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
});
|
||||
|
||||
export const assetKindSchema = z.enum([
|
||||
'pdf',
|
||||
'deckFace',
|
||||
'deckBack',
|
||||
'image',
|
||||
'imageSecondary',
|
||||
]);
|
||||
|
||||
export const assetRefSchema = z.object({
|
||||
kind: assetKindSchema,
|
||||
url: z.string().url(),
|
||||
ownerGuid: z.string(),
|
||||
});
|
||||
|
||||
export const extractedObjectSchema = z.object({
|
||||
guid: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
parentGuid: z.string().optional(),
|
||||
childrenGuids: z.array(z.string()),
|
||||
refs: z.array(assetRefSchema),
|
||||
});
|
||||
|
||||
export const workshopItemSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
author: z.string(),
|
||||
previewImageUrl: z.string(),
|
||||
fileUrl: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
timeCreated: z.number().optional(),
|
||||
timeUpdated: z.number().optional(),
|
||||
});
|
||||
|
||||
export const searchResultSchema = z.object({
|
||||
items: z.array(workshopItemSchema),
|
||||
page: z.number(),
|
||||
hasMore: z.boolean(),
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* A single object inside a Tabletop Simulator save file.
|
||||
* Mirrors the structure produced by BSON-deserializing a TTS save.
|
||||
*/
|
||||
export interface TTSObject {
|
||||
Name: string;
|
||||
GUID: string;
|
||||
Description: string;
|
||||
/** Set by `markParent` during traversal; not present in the raw save. */
|
||||
Parent?: TTSObject;
|
||||
|
||||
CustomPDF?: {
|
||||
PDFUrl: string;
|
||||
};
|
||||
|
||||
ContainedObjects?: TTSObject[];
|
||||
|
||||
CardID?: number;
|
||||
CustomDeck?: {
|
||||
[key: number]: {
|
||||
BackURL: string;
|
||||
FaceURL: string;
|
||||
UniqueBack: boolean;
|
||||
NumHeight: number;
|
||||
NumWidth: number;
|
||||
};
|
||||
};
|
||||
CustomImage?: {
|
||||
ImageURL: string;
|
||||
ImageSecondaryURL: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** A full Tabletop Simulator save file. */
|
||||
export interface TTSMod {
|
||||
GameMode: string;
|
||||
Date: string;
|
||||
ObjectStates: TTSObject[];
|
||||
}
|
||||
|
||||
/** Metadata for a Workshop item, from the Steam Web API. */
|
||||
export interface WorkshopItem {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
previewImageUrl: string;
|
||||
fileUrl?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
timeCreated?: number;
|
||||
timeUpdated?: number;
|
||||
}
|
||||
|
||||
/** A page of Workshop search results. */
|
||||
export interface SearchResult {
|
||||
items: WorkshopItem[];
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** A reference to an external asset embedded in a TTS object. */
|
||||
export type AssetKind =
|
||||
| 'pdf'
|
||||
| 'deckFace'
|
||||
| 'deckBack'
|
||||
| 'image'
|
||||
| 'imageSecondary';
|
||||
|
||||
export interface AssetRef {
|
||||
kind: AssetKind;
|
||||
url: string;
|
||||
/** GUID of the object that owns this reference. */
|
||||
ownerGuid: string;
|
||||
}
|
||||
|
||||
/** A flattened, lightweight view of an object for inspection/rendering. */
|
||||
export interface ExtractedObject {
|
||||
guid: string;
|
||||
name: string;
|
||||
type: string;
|
||||
parentGuid?: string;
|
||||
childrenGuids: string[];
|
||||
refs: AssetRef[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@tts/tts",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "echo \"no lint configured\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tts/shared": "workspace:*",
|
||||
"bson": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Errors thrown by the TTS fetcher. */
|
||||
|
||||
export class TtsError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number = 500,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TtsError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The Steam API returned no details for the requested item. */
|
||||
export class ItemNotFoundError extends TtsError {
|
||||
constructor(id: string) {
|
||||
super(`No Workshop item found for id ${id}`, 404);
|
||||
this.name = 'ItemNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The item exists but has no downloadable `file_url`. */
|
||||
export class NoFileError extends TtsError {
|
||||
constructor(id: string) {
|
||||
super(`Workshop item ${id} has no downloadable save file`, 404);
|
||||
this.name = 'NoFileError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The Steam API rejected the request (bad key, rate limit, etc.). */
|
||||
export class SteamApiError extends TtsError {
|
||||
constructor(message: string, status = 502) {
|
||||
super(message, status);
|
||||
this.name = 'SteamApiError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { deserialize } from 'bson';
|
||||
import type { TTSMod } from '@tts/shared';
|
||||
import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js';
|
||||
|
||||
const STEAM_API_URL =
|
||||
'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/';
|
||||
|
||||
interface SteamPublishedFileDetails {
|
||||
publishedfileid: string;
|
||||
title: string;
|
||||
creator: string;
|
||||
preview_url: string;
|
||||
file_url?: string;
|
||||
description?: string;
|
||||
tags?: { tag: string }[];
|
||||
time_created?: number;
|
||||
time_updated?: number;
|
||||
}
|
||||
|
||||
interface SteamResponse {
|
||||
response: {
|
||||
result: number;
|
||||
resultcount: number;
|
||||
publishedfiledetails?: SteamPublishedFileDetails[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a full TTS save for a Workshop item and BSON-deserialize it.
|
||||
*
|
||||
* @param id Workshop item ID (digits only).
|
||||
* @param apiKey Steam Web API key.
|
||||
*/
|
||||
export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
|
||||
const fileUrl = await getFileUrl(id, apiKey);
|
||||
|
||||
const saveRes = await fetch(fileUrl);
|
||||
if (!saveRes.ok) {
|
||||
throw new SteamApiError(
|
||||
`Failed to download save file (${saveRes.status})`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await saveRes.arrayBuffer();
|
||||
return deserialize(new Uint8Array(buffer)) as TTSMod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a filename from a `content-disposition` header.
|
||||
* Parses `filename="..."`; falls back to the URL path, then a default.
|
||||
*/
|
||||
export function getFileName(url: string, disposition: string | null): string {
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename\*?=(?:"([^"]*)"|([^;\s]*))/i);
|
||||
const name = match?.[1] ?? match?.[2];
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return new URL(url).pathname.split('/').pop() || 'save.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw save file bytes for a Workshop item.
|
||||
* Returns the bytes plus a derived filename.
|
||||
*/
|
||||
export async function fetchModFile(
|
||||
id: string,
|
||||
apiKey: string,
|
||||
): Promise<{ data: ArrayBuffer; filename: string }> {
|
||||
const fileUrl = await getFileUrl(id, apiKey);
|
||||
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) {
|
||||
throw new SteamApiError(
|
||||
`Failed to download save file (${res.status})`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.arrayBuffer();
|
||||
const filename = getFileName(fileUrl, res.headers.get('content-disposition'));
|
||||
return { data, filename };
|
||||
}
|
||||
|
||||
/** Resolve the `file_url` for a Workshop item via the Steam API. */
|
||||
async function getFileUrl(id: string, apiKey: string): Promise<string> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('key', apiKey);
|
||||
params.append('itemcount', '1');
|
||||
params.append('publishedfileids[0]', id);
|
||||
|
||||
const res = await fetch(STEAM_API_URL, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new SteamApiError(`Steam API responded ${res.status}`, 502);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as SteamResponse;
|
||||
const details = json.response.publishedfiledetails?.[0];
|
||||
if (!details) {
|
||||
throw new ItemNotFoundError(id);
|
||||
}
|
||||
if (!details.file_url) {
|
||||
throw new NoFileError(id);
|
||||
}
|
||||
return details.file_url;
|
||||
}
|
||||
|
||||
export * from './errors.js';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user