From 67ff6d989ca91b323a9c53b07c9bf1104749b085 Mon Sep 17 00:00:00 2001 From: Sae Date: Mon, 2 Nov 2026 12:23:35 +0530 Subject: [PATCH] Apply PitchScript runtime and compatibility fixes --- README.md | 128 +++++++------ src/commands/eval.ts | 231 +++++++++++------------ src/commands/file.ts | 185 +++++-------------- src/commands/filter.ts | 289 +++++++++++++---------------- src/commands/global.ts | 367 +++++++++++++++++-------------------- src/commands/template.ts | 65 +++---- src/discord.ts | 39 +++- src/fcdex-host.ts | 86 ++++++++- src/fcdex-models.ts | 2 +- src/fcdex-runtime.ts | 7 + src/host.ts | 19 ++ src/index.ts | 2 + src/parser.ts | 5 +- src/utils.ts | 61 ++++-- src/version.ts | 33 +++- tests/fcdex-models.test.ts | 4 +- 16 files changed, 757 insertions(+), 766 deletions(-) diff --git a/README.md b/README.md index 32a26b3..3f05184 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,103 @@ # PitchScript -A TypeScript implementation of the DexScript DSL for FCDex. +PitchScript is a TypeScript port of the DexScript command DSL, adapted for the FCDex Prisma models. The syntax stays command-oriented; scripts are still plain text and are not JavaScript or TypeScript source code. -PitchScript preserves DexScript's command-oriented syntax while -providing integration with a TypeScript and Prisma-based codebase. +## Current support -## Development status +PitchScript includes: -PitchScript is under development. The parser, command handlers, -and FCDex adapter must pass the build and compatibility tests -before the package is considered ready for production. +- The original command parser and sequential dispatcher. +- Global model commands: `CREATE`, `DELETE`, `UPDATE`, `VIEW`, and `ATTRIBUTES`. +- `FILTER` bulk update, delete, and view commands. +- Saved PitchScript presets through `EVAL > SAVE`, `REMOVE`, `LIST`, and `RUN`. +- `FILE` read, write, clear, list-directory, and delete commands. +- The `TEMPLATE > CREATE` helper for the FCDex `CLUB` model. +- A Discord.js message-command adapter and a Prisma-backed FCDex host. -## Commands +The FCDex registry currently exposes `CLUB` (mapped to Prisma's `Ball` model) and `SPECIAL`. `REGIME` and `ECONOMY` are not registered because they are not present as Prisma models in the supplied FCDex schema. -### Global commands +## Syntax ```text -CREATE > MODEL > IDENTIFIER -DELETE > MODEL > IDENTIFIER -UPDATE > MODEL > IDENTIFIER > ATTRIBUTE > VALUE(?) -VIEW > MODEL > IDENTIFIER > ATTRIBUTE(?) -ATTRIBUTES > MODEL > FILTER(?) -``` - -### Filter commands - -```text -FILTER > UPDATE > MODEL > ATTRIBUTE > OLD_VALUE > NEW_VALUE > TORTOISE_OPERATOR(?) -FILTER > DELETE > MODEL > ATTRIBUTE > VALUE > TORTOISE_OPERATOR(?) -FILTER > VIEW > MODEL > ATTRIBUTE > VALUE > TORTOISE_OPERATOR(?) -``` +CREATE > CLUB > Manchester City +UPDATE > CLUB > Manchester City > RARITY > T5 +VIEW > CLUB > Manchester City > RARITY +ATTRIBUTES > CLUB -### Saved evals +FILTER > UPDATE > CLUB > RARITY > T5 > T3 +FILTER > DELETE > CLUB > RARITY > T5 > GT +FILTER > VIEW > CLUB > RARITY > T5 -```text -EVAL > SAVE > NAME -EVAL > REMOVE > NAME +EVAL > SAVE > rarity-update +EVAL > RUN > rarity-update EVAL > LIST -EVAL > RUN > NAME -``` - -Saved evals contain PitchScript source and are executed by the -PitchScript interpreter. +EVAL > REMOVE > rarity-update -### File commands +FILE > READ > README.md +FILE > WRITE > output.txt +FILE > CLEAR > output.txt +FILE > LISTDIR +FILE > DELETE > output.txt -```text -FILE > READ > FILE_PATH -FILE > WRITE > FILE_PATH -FILE > CLEAR > FILE_PATH -FILE > LISTDIR > FILE_PATH(?) -FILE > DELETE > FILE_PATH +TEMPLATE > CREATE > CLUB > Manchester City ``` -### Templates +To execute a script in Discord, mention the bot and use `run`, for example: ```text -TEMPLATE > CREATE > MODEL > ARGUMENT(?) +@FCDex run VIEW > CLUB > Manchester City > RARITY ``` -## TypeScript integration - -PitchScript exposes a parser, dispatcher, command handlers, -and an FCDex adapter through its package entry point. - -The database adapter must be configured against the target -application's actual Prisma schema. +The Discord adapter restricts commands to the configured `ownerIds`, matching the owner-only behavior of the original DexScript command. -## Development +## Installation and build -Install dependencies: +Node.js 20 or newer is required. ```bash npm install +npm run build +npm test ``` -Build the package: - -```bash -npm run build +## FCDex integration + +Create the Prisma-backed engine and register the message handler: + +```ts +import { Client } from "discord.js"; +import { PrismaClient } from "./generated/prisma/client.js"; +import { + createFCDexPitchScript, + registerPitchScriptDiscord, +} from "pitchscript"; + +const client = new Client({ intents: [/* your configured intents */] }); +const prisma = new PrismaClient(); + +const pitchScript = createFCDexPitchScript(prisma, { + host: { + createDefaults: { + // capacity_logic is required by the supplied Prisma schema. + // Replace this empty object with the capacity logic your bot uses. + Club: { capacity_logic: {} }, + }, + }, +}); + +registerPitchScriptDiscord(client, pitchScript, { + ownerIds: ["YOUR_DISCORD_USER_ID"], + repository: "YOUR_GITHUB_OWNER/PitchScript", +}); ``` -Run tests: +The host stores uploaded images in the configured media directory. You can override the `saveAttachment` host option to match your deployment's storage layout. -```bash -npm test -``` +**Schema note:** the supplied FCDex files disagree about `Ball.regime_id`: `prisma/schema.prisma` declares `regimeId BigInt?`, while `prisma/schema.sql` declares `regime_id bigint NOT NULL`. PitchScript follows the Prisma model and treats this field as optional; if your live database was created from the SQL snapshot, reconcile the mismatch before using `CREATE > CLUB`. The SQL snapshot also has no `Regime` table/model, so PitchScript does not invent a regime record or ID. PitchScript does not migrate your database. + +## Scope + +This repository contains the PitchScript library and Discord integration. It is not the FCDex web admin-panel UI itself; the admin panel must integrate the engine and host with its own routes and permissions. ## License diff --git a/src/commands/eval.ts b/src/commands/eval.ts index 99cb944..b9af738 100644 --- a/src/commands/eval.ts +++ b/src/commands/eval.ts @@ -1,171 +1,146 @@ -import { - access, - mkdir, - readFile, - readdir, - unlink, - writeFile, -} from "node:fs/promises"; -import { resolve } from "node:path"; - +import { access, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { PitchScriptValue } from "../types.js"; +import type { PitchScriptCommandContext } from "../host.js"; import type { PitchScriptCommandFactory, + PitchScriptSharedState, } from "../dispatcher.js"; -import type { - PitchScriptCommandContext, -} from "../host.js"; -import { removeCodeMarkdown } from "../runner.js"; +import { removeCodeMarkdown } from "../utils.js"; -const EVAL_DIRECTORY = resolve( - process.cwd(), - "eval_presets", -); +export const EVAL_PRESETS_DIRECTORY = "eval_presets"; +const NAME_LIMIT = 100; + +function presetName(token: PitchScriptValue | undefined, method: string): string { + if (!token) { + throw new TypeError(`Argument missing when calling '${method}'.`); + } + + const name = String(token.value ?? token.name); + if ( + name.length === 0 || + name.length > NAME_LIMIT || + name === "." || + name === ".." || + !/^[\p{L}\p{N}_ -]+$/u.test(name) + ) { + throw new Error(`'${name}' is not a valid eval preset name.`); + } + return name; +} -const EVAL_TIMEOUT_MS = 20_000; -const EVAL_NAME_LIMIT = 100; +function presetPath(name: string): string { + return join(EVAL_PRESETS_DIRECTORY, `${name}.pitchscript`); +} async function fileExists(path: string): Promise { try { await access(path); return true; - } catch { - return false; + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + return false; + } + throw error; } } -export const createEvalCommands: - PitchScriptCommandFactory = - () => ({ - loaded: async () => { - await mkdir(EVAL_DIRECTORY, { recursive: true }); +export const createEvalCommands: PitchScriptCommandFactory = + (_shared: PitchScriptSharedState) => ({ + async loaded() { + await mkdir(EVAL_PRESETS_DIRECTORY, { recursive: true }); }, methods: { - save: async (ctx, name) => { - if (!name) { - throw new Error( - "Argument missing when calling 'save'.", - ); - } - - if (name.name.length > EVAL_NAME_LIMIT) { - throw new Error( - `\`${name.name}\` exceeds the ${EVAL_NAME_LIMIT}-character limit ` + - `(${name.name.length} > ${EVAL_NAME_LIMIT})`, - ); - } - - const path = resolve( - EVAL_DIRECTORY, - `${name.name}.pitchscript`, - ); + async save( + context: PitchScriptCommandContext, + nameToken?: PitchScriptValue, + ) { + const name = presetName(nameToken, "save"); + const path = presetPath(name); if (await fileExists(path)) { - throw new Error(`\`${name.name}\` already exists.`); - } - - if (!ctx.waitForMessage) { - throw new Error( - "The host must implement waitForMessage to save eval presets.", - ); + throw new Error(`\`${name}\` already exists.`); } - await ctx.send( - "Please send the eval command below...", - ); + await context.send("Please send the eval command below..."); - const message = await ctx.waitForMessage( - EVAL_TIMEOUT_MS, - ); + if (!context.waitForMessage) { + throw new Error("Message collection is not configured for this context."); + } - if (message === null) { - await ctx.send( - "Eval preset saving has timed out.", - ); + const message = await context.waitForMessage(20_000); + if (!message) { + await context.send("Eval preset saving has timed out."); return; } - await writeFile( - path, - removeCodeMarkdown(message.content), - "utf8", - ); - - await ctx.send( - `\`${name.name}\` eval preset has been saved!`, - ); + await writeFile(path, removeCodeMarkdown(message.content), "utf8"); + await context.send(`\`${name}\` eval preset has been saved!`); }, - remove: async (ctx, name) => { - if (!name) { - throw new Error( - "Argument missing when calling 'remove'.", - ); + async remove( + context: PitchScriptCommandContext, + nameToken?: PitchScriptValue, + ) { + const name = presetName(nameToken, "remove"); + const path = presetPath(name); + + try { + await rm(path); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + throw new Error(`\`${name}\` does not exists`); + } + throw error; } - const path = resolve( - EVAL_DIRECTORY, - `${name.name}.pitchscript`, - ); - - if (!(await fileExists(path))) { - throw new Error( - `\`${name.name}\` does not exist`, - ); - } - - await unlink(path); - - await ctx.send( - `Removed \`${name.name}\` preset.`, - ); + await context.send(`Removed \`${name}\` preset.`); }, - list: async (ctx) => { - const files = await readdir(EVAL_DIRECTORY); - - const presets = files - .filter((file) => file.endsWith(".pitchscript")) - .map((file) => file.slice(0, -".pitchscript".length)); - + async list(context: PitchScriptCommandContext) { + await mkdir(EVAL_PRESETS_DIRECTORY, { recursive: true }); + const presets = await readdir(EVAL_PRESETS_DIRECTORY); if (presets.length === 0) { - await ctx.send( - "You have no eval presets saved.", - ); + await context.send("You have no eval presets saved."); return; } - - await ctx.sendList(presets); + await context.sendList(presets); }, - run: async (ctx, name) => { - if (!name) { - throw new Error( - "Argument missing when calling 'run'.", - ); - } - - const path = resolve( - EVAL_DIRECTORY, - `${name.name}.pitchscript`, - ); - - if (!(await fileExists(path))) { - throw new Error( - `\`${name.name}\` does not exist`, - ); + async run( + context: PitchScriptCommandContext, + nameToken?: PitchScriptValue, + ) { + const name = presetName(nameToken, "run"); + let source: string; + try { + source = await readFile(presetPath(name), "utf8"); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + throw new Error(`\`${name}\` does not exists`); + } + throw error; } - if (!ctx.executeScript) { - throw new Error( - "The host must implement executeScript to run eval presets.", - ); + if (!context.executeScript) { + throw new Error("PitchScript execution is not configured for this context."); } - const source = await readFile(path, "utf8"); - - await ctx.executeScript(source); - await ctx.addReaction?.("✅"); + await context.executeScript(source); + await context.addReaction?.("✅"); }, }, }); diff --git a/src/commands/file.ts b/src/commands/file.ts index fd85b8f..e732092 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -1,172 +1,73 @@ -import { - readFile, - writeFile, - readdir, - stat, - unlink, - rm, -} from "node:fs/promises"; -import { resolve } from "node:path"; - +import { access, readdir, rm, stat, writeFile } from "node:fs/promises"; +import type { PitchScriptValue } from "../types.js"; +import type { PitchScriptCommandContext } from "../host.js"; import type { PitchScriptCommandFactory, - PitchScriptCommandInstance, + PitchScriptSharedState, } from "../dispatcher.js"; -import type { - PitchScriptCommandContext, -} from "../host.js"; -import type { PitchScriptValue } from "../types.js"; - -async function readAttachment( - attachment: unknown, -): Promise { - if (typeof attachment !== "object" || attachment === null) { - throw new Error("The attachment is invalid."); - } - - const item = attachment as Record; - - if (typeof item.url === "string") { - const response = await fetch(item.url); - - if (!response.ok) { - throw new Error( - `Failed to download attachment: HTTP ${response.status}.`, - ); - } - - return new Uint8Array(await response.arrayBuffer()); - } - - if (typeof item.arrayBuffer === "function") { - const data = await ( - item.arrayBuffer as () => Promise - )(); - return new Uint8Array(data); - } - - if (item.data instanceof Uint8Array) { - return item.data; +function filePath(token: PitchScriptValue | undefined, method: string): string { + if (!token) { + throw new TypeError(`Argument missing when calling '${method}'.`); } + return String(token.value ?? token.name); +} - if (item.attachment instanceof Uint8Array) { - return item.attachment; +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; } - - throw new Error( - "The host attachment cannot be read as binary data.", - ); } -export const createFileCommands: - PitchScriptCommandFactory = - () => ({ +export const createFileCommands: PitchScriptCommandFactory = + (_shared: PitchScriptSharedState) => ({ methods: { - read: async (ctx, filePath) => { - if (!filePath) { - throw new Error( - "Argument missing when calling 'read'.", - ); - } - - await ctx.send("", [filePath.name]); + async read(context: PitchScriptCommandContext, pathToken?: PitchScriptValue) { + const path = filePath(pathToken, "read"); + await context.send("", [path]); }, - write: async (ctx, filePath) => { - if (!filePath) { - throw new Error( - "Argument missing when calling 'write'.", - ); - } - - const attachment = ctx.attachments?.[0]; - + async write(context: PitchScriptCommandContext, pathToken?: PitchScriptValue) { + const path = filePath(pathToken, "write"); + const attachment = context.attachments?.[0]; if (attachment === undefined) { - throw new Error( - "FILE > WRITE requires an attached file.", - ); + throw new Error("An attachment is required to write a file."); } - - const contents = await readAttachment(attachment); - - await writeFile( - filePath.name, - Buffer.from(contents).toString("utf8"), - "utf8", - ); - - await ctx.send( - `Wrote to \`${filePath.name}\``, - ); - }, - - clear: async (ctx, filePath) => { - if (!filePath) { - throw new Error( - "Argument missing when calling 'clear'.", - ); + if (!context.readAttachment) { + throw new Error("Attachment reading is not configured for this context."); } - const path = filePath.name; - - try { - const details = await stat(path); - - if (!details.isFile()) { - throw new Error( - `'${path}' does not exist`, - ); - } - } catch (error) { - if ( - error instanceof Error && - error.message === `'${path}' does not exist` - ) { - throw error; - } + const contents = await context.readAttachment(attachment); + await writeFile(path, contents, "utf8"); + context.attachments?.shift(); + await context.send(`Wrote to \`${pathToken!.name}\``); + }, + async clear(context: PitchScriptCommandContext, pathToken?: PitchScriptValue) { + const path = filePath(pathToken, "clear"); + if (!(await exists(path))) { throw new Error(`'${path}' does not exist`); } - await writeFile(path, "", "utf8"); - - await ctx.send(`Cleared \`${path}\``); + await context.send(`Cleared \`${pathToken!.name}\``); }, - listdir: async (ctx, filePath) => { - const path = filePath?.name ?? "."; + async listdir(context: PitchScriptCommandContext, pathToken?: PitchScriptValue) { + const path = pathToken ? filePath(pathToken, "listdir") : "."; const entries = await readdir(path); - - await ctx.sendList(entries); + await context.sendList(entries); }, - delete: async (ctx, filePath) => { - if (!filePath) { - throw new Error( - "Argument missing when calling 'delete'.", - ); - } - - const path = filePath.name; + async delete(context: PitchScriptCommandContext, pathToken?: PitchScriptValue) { + const path = filePath(pathToken, "delete"); const details = await stat(path); const isDirectory = details.isDirectory(); - - if (isDirectory) { - await rm(path, { - recursive: true, - force: false, - }); - } else { - await unlink(path); - } - - const fileType = isDirectory - ? "directory" - : "file"; - - await ctx.send( - `Deleted \`${path}\` ${fileType}`, + await rm(path, { recursive: isDirectory }); + await context.send( + `Deleted \`${pathToken!.name}\` ${isDirectory ? "directory" : "file"}`, ); }, }, diff --git a/src/commands/filter.ts b/src/commands/filter.ts index 20f6806..96d3630 100644 --- a/src/commands/filter.ts +++ b/src/commands/filter.ts @@ -1,26 +1,25 @@ import { ValueType, type PitchScriptValue } from "../types.js"; +import type { PitchScriptCommandContext } from "../host.js"; import type { PitchScriptCommandFactory, - PitchScriptCommandInstance, + PitchScriptSharedState, } from "../dispatcher.js"; -import type { PitchScriptCommandContext } from "../host.js"; -import type { PitchScriptHost } from "../host.js"; -export interface PitchScriptFilterHost - extends Pick { +export interface PitchScriptFilterHost { + hasAttribute(model: PitchScriptValue, attribute: string): boolean; + getModel(model: PitchScriptValue, identifier: string): Promise; + getPrimaryKey(record: unknown): unknown; filterUpdate( model: PitchScriptValue, field: string, oldValue: unknown, newValue: unknown, ): Promise; - filterDelete( model: PitchScriptValue, field: string, value: unknown, ): Promise; - filterValues( model: PitchScriptValue, field: string, @@ -29,174 +28,134 @@ export interface PitchScriptFilterHost ): Promise; } -export function createFilterCommands( - host: PitchScriptFilterHost, -): PitchScriptCommandFactory { - return (shared): PitchScriptCommandInstance => { - void shared; - - function validateAttribute( - model: PitchScriptValue, - attribute: PitchScriptValue, - ): string { - const attributeName = attribute.name.toLowerCase(); - - if ( - model.value !== null && - !host.hasAttribute(model, attributeName) - ) { - throw new Error( - `'${attributeName}' is not a valid ${model.name} attribute\n` + - `Run \`ATTRIBUTES > ${model.name}\` to see a list of ` + - "all attributes for that model", - ); - } - - return attributeName; - } - - async function resolveValue( - attribute: PitchScriptValue, - value: PitchScriptValue, - ): Promise { - if (attribute.type === ValueType.MODEL) { - return host.getModel(attribute, String(value.value)); - } - - return value.value; - } - - function queryField( - attributeName: string, - operator?: PitchScriptValue, - ): string { - if (operator === undefined) { - return attributeName; - } - - return `${attributeName}__${operator.name.toLowerCase()}`; - } - - return { - methods: { - update: async (ctx, ...args: PitchScriptValue[]) => { - const model = args[0]; - const attribute = args[1]; - const oldValue = args[2]; - const newValue = args[3]; - const operator = args[4]; - - if (!model || !attribute || !oldValue || !newValue) { - throw new Error( - "Argument missing when calling 'update'.", - ); - } - - const attributeName = validateAttribute(model, attribute); - const field = queryField(attributeName, operator); - - const resolvedOldValue = await resolveValue( - attribute, - oldValue, - ); - - const resolvedNewValue = await resolveValue( - attribute, - newValue, - ); - - await host.filterUpdate( - model, - field, - resolvedOldValue, - resolvedNewValue, - ); - - await ctx.send( - `Updated all \`${model.name}\` instances from a ` + - `\`${attribute.name}\` value of \`${oldValue.name}\` ` + - `to \`${newValue.name}\``, - ); - }, - - delete: async (ctx, ...args: PitchScriptValue[]) => { - const model = args[0]; - const attribute = args[1]; - const value = args[2]; - const operator = args[3]; - - if (!model || !attribute || !value) { - throw new Error( - "Argument missing when calling 'delete'.", - ); - } - - const attributeName = validateAttribute(model, attribute); - const field = queryField(attributeName, operator); - - const resolvedValue = await resolveValue( - attribute, - value, - ); +function required(value: T | undefined, method: string): T { + if (value === undefined) { + throw new TypeError(`Argument missing when calling '${method}'.`); + } + return value; +} - await host.filterDelete( - model, - field, - resolvedValue, - ); +function rawValue(value: PitchScriptValue): unknown { + return value.value; +} - await ctx.send( - `Deleted all \`${model.name}\` instances with a ` + - `\`${attribute.name}\` value of \`${value.name}\``, - ); - }, +function asText(value: PitchScriptValue): string { + return String(value.value ?? value.name); +} - view: async (ctx, ...args: PitchScriptValue[]) => { - const model = args[0]; - const attribute = args[1]; - const value = args[2]; - const operator = args[3]; +function modelIdentifierField(model: PitchScriptValue): string { + const field = model.extraData[0]; + if (typeof field !== "string") { + throw new Error(`Missing identifier field for ${model.name}.`); + } + return field; +} - if (!model || !attribute || !value) { - throw new Error( - "Argument missing when calling 'view'.", - ); - } +function filterField( + host: PitchScriptFilterHost, + model: PitchScriptValue, + attribute: PitchScriptValue, + operator?: PitchScriptValue, +): string { + const name = attribute.name.toLowerCase(); + if (!host.hasAttribute(model, name)) { + throw new Error(`'${name}' is not a valid ${model.name} attribute`); + } + return operator + ? `${name}__${operator.name.toLowerCase()}` + : name; +} - const attributeName = validateAttribute(model, attribute); - const field = queryField(attributeName, operator); +async function filterValue( + host: PitchScriptFilterHost, + attribute: PitchScriptValue, + value: PitchScriptValue, +): Promise { + if (attribute.type === ValueType.MODEL) { + const related = await host.getModel(attribute, asText(value)); + return host.getPrimaryKey(related); + } + return rawValue(value); +} - const resolvedValue = await resolveValue( - attribute, - value, - ); +export function createFilterCommands( + host: PitchScriptFilterHost, +): PitchScriptCommandFactory { + return (_shared: PitchScriptSharedState) => ({ + methods: { + async update( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + attributeToken?: PitchScriptValue, + oldValueToken?: PitchScriptValue, + newValueToken?: PitchScriptValue, + operatorToken?: PitchScriptValue, + ) { + const model = required(modelToken, "update"); + const attribute = required(attributeToken, "update"); + const oldValue = required(oldValueToken, "update"); + const newValue = required(newValueToken, "update"); + const field = filterField(host, model, attribute, operatorToken); + await host.filterUpdate( + model, + field, + await filterValue(host, attribute, oldValue), + await filterValue(host, attribute, newValue), + ); + await context.send( + `Updated all \`${model.name}\` instances from a \`${attribute.name}\` value of ` + + `\`${oldValue.name}\` to \`${newValue.name}\``, + ); + }, - const identifierField = model.extraData[0]; + async delete( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + attributeToken?: PitchScriptValue, + valueToken?: PitchScriptValue, + operatorToken?: PitchScriptValue, + ) { + const model = required(modelToken, "delete"); + const attribute = required(attributeToken, "delete"); + const value = required(valueToken, "delete"); + const field = filterField(host, model, attribute, operatorToken); + await host.filterDelete( + model, + field, + await filterValue(host, attribute, value), + ); + await context.send( + `Deleted all \`${model.name}\` instances with a \`${attribute.name}\` value of \`${value.name}\``, + ); + }, - if (typeof identifierField !== "string") { - throw new Error( - `Could not resolve the identifier field for ${model.name}.`, - ); - } + async view( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + attributeToken?: PitchScriptValue, + valueToken?: PitchScriptValue, + operatorToken?: PitchScriptValue, + ) { + const model = required(modelToken, "view"); + const attribute = required(attributeToken, "view"); + const value = required(valueToken, "view"); + const field = filterField(host, model, attribute, operatorToken); + const values = await host.filterValues( + model, + field, + await filterValue(host, attribute, value), + modelIdentifierField(model), + ); - const instances = await host.filterValues( - model, - field, - resolvedValue, - identifierField, + if (values.length === 0) { + await context.send( + `No ${model.name}s found with a \`${attribute.name}\` value of \`${value.name}\``, ); + return; + } - if (instances.length === 0) { - await ctx.send( - `No ${model.name}s found with a ` + - `\`${attribute.name}\` value of \`${value.name}\``, - ); - return; - } - - await ctx.sendList(instances); - }, + await context.sendList(values); }, - }; - }; + }, + }); } diff --git a/src/commands/global.ts b/src/commands/global.ts index faca7a3..e4bd5b3 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -1,235 +1,202 @@ import { ValueType, type PitchScriptValue } from "../types.js"; -import type { - PitchScriptCommandFactory, - PitchScriptCommandInstance, -} from "../dispatcher.js"; import type { PitchScriptCommandContext, PitchScriptHost, } from "../host.js"; +import type { + PitchScriptCommandFactory, + PitchScriptSharedState, +} from "../dispatcher.js"; -export function createGlobalCommands( - host: PitchScriptHost, -): PitchScriptCommandFactory { - return (shared): PitchScriptCommandInstance => { - function attributeError( - model: PitchScriptValue, - attribute: string, - ): void { - if ( - model.value === null || - host.hasAttribute(model, attribute) - ) { - return; - } - - throw new Error( - `'${attribute}' is not a valid ${model.name} attribute\n` + - `Run \`ATTRIBUTES > ${model.name}\` to see a list of ` + - `all attributes for that model`, - ); - } - - return { - methods: { - create: async (ctx, ...args: PitchScriptValue[]) => { - const model = args[0]!; - const identifier = args[1]!; - - await host.createModel(model, identifier.name); - - await ctx.send( - `Created \`${identifier.name}\` ${model.name.toLowerCase()}`, - ); - }, - - delete: async (ctx, ...args: PitchScriptValue[]) => { - const model = args[0]!; - const identifier = args[1]!; +function valueOf(token: PitchScriptValue): unknown { + return token.value; +} - const record = await host.getModel( - model, - identifier.name, - ); +function textOf(token: PitchScriptValue): string { + return String(token.value ?? token.name); +} - await host.deleteRecord(record); +function missingArgument(name: string): never { + throw new TypeError(`Argument missing when calling '${name}'.`); +} - await ctx.send( - `Deleted \`${identifier.name}\` ${model.name.toLowerCase()}`, - ); - }, +function ensureModel(model: PitchScriptValue | undefined): PitchScriptValue { + if (!model || model.type !== ValueType.MODEL) { + throw new TypeError("A model argument is required."); + } + return model; +} - update: async (ctx, ...args: PitchScriptValue[]) => { - const model = args[0]!; - const identifier = args[1]!; - const attribute = args[2]!; - const value = args[3]; +export function createGlobalCommands( + host: PitchScriptHost, +): PitchScriptCommandFactory { + return (_shared: PitchScriptSharedState) => ({ + methods: { + async create( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + identifier?: PitchScriptValue, + ) { + const model = ensureModel(modelToken); + if (!identifier) missingArgument("create"); + + const identifierText = textOf(identifier); + await host.createModel(model, identifierText); + await context.send( + `Created \`${identifierText}\` ${model.name.toLowerCase()}`, + ); + }, - let attributeName = attribute.name.toLowerCase(); - let newValue: unknown = - value === undefined ? null : value.value; + async delete( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + identifier?: PitchScriptValue, + ) { + const model = ensureModel(modelToken); + if (!identifier) missingArgument("delete"); + + const identifierText = textOf(identifier); + const record = await host.getModel(model, identifierText); + await host.deleteRecord(record); + await context.send( + `Deleted \`${identifierText}\` ${model.name.toLowerCase()}`, + ); + }, - const record = await host.getModel( - model, - identifier.name, + async update( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + identifier?: PitchScriptValue, + attributeToken?: PitchScriptValue, + valueToken?: PitchScriptValue, + ) { + const model = ensureModel(modelToken); + if (!identifier || !attributeToken) missingArgument("update"); + + const identifierText = textOf(identifier); + const attributeName = attributeToken.name.toLowerCase(); + if (!host.hasAttribute(model, attributeName)) { + throw new Error( + `'${attributeName}' is not a valid ${model.name} attribute\n` + + `Run \`ATTRIBUTES > ${model.name}\` to see a list of all attributes for that model`, ); - - attributeError(model, attributeName); - - const imageFields = host - .getFields(model) - .filter( - (field) => - field.typeName === "CharField" && - field.maxLength === 200, - ) - .map((field) => field.name); - - if ( - value === undefined && - shared.attachments.length > 0 && - imageFields.includes(attributeName) - ) { - const attachment = shared.attachments.shift(); - - if (attachment !== undefined) { - const savedPath = - await host.saveAttachment(attachment); - - newValue = host.uploadedImageValue(savedPath); - } - } - - if (attribute.type === ValueType.MODEL) { - attributeName = `${attribute.name.toLowerCase()}_id`; - - const relatedRecord = await host.getModel( - attribute, - value!.name, + } + + const record = await host.getModel(model, identifierText); + let fieldName = attributeName; + let newValue: unknown = valueToken ? valueOf(valueToken) : null; + + if ( + valueToken === undefined && + (host.getFields(model).find((field) => field.name.toLowerCase() === attributeName)?.typeName === "CharField") && + host.getFields(model).find((field) => field.name.toLowerCase() === attributeName)?.maxLength === 200 && + (_shared.attachments.length > 0) + ) { + const attachment = _shared.attachments.shift(); + const savedPath = await host.saveAttachment(attachment); + newValue = host.uploadedImageValue(savedPath); + } + + if (attributeToken.type === ValueType.MODEL) { + fieldName = `${attributeName}_id`; + if (valueToken) { + const related = await host.getModel( + attributeToken, + textOf(valueToken), ); - - newValue = host.getPrimaryKey(relatedRecord); + newValue = host.getPrimaryKey(related); } + } - host.setRecordValue( - record, - attributeName, - newValue, - ); + host.setRecordValue(record, fieldName, newValue); + await host.saveRecord(record, [fieldName]); - await host.saveRecord(record, [attributeName]); - - const suffix = - value === undefined - ? "" - : ` to \`${value.name}\``; + const suffix = valueToken + ? ` to \`${valueToken.name}\`` + : ""; + await context.send( + `Updated \`${identifierText}'s\` ${attributeToken.name}${suffix}`, + ); + }, - await ctx.send( - `Updated \`${identifier.name}'s\` ${attribute.name}${suffix}`, - ); - }, + async view( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + identifier?: PitchScriptValue, + attributeToken?: PitchScriptValue, + ) { + const model = ensureModel(modelToken); + if (!identifier) missingArgument("view"); - view: async (ctx, ...args: PitchScriptValue[]) => { - const model = args[0]!; - const identifier = args[1]!; - const attribute = args[2]; + const record = await host.getModel(model, textOf(identifier)); - const record = await host.getModel( - model, - identifier.name, - ); + if (!attributeToken) { + const output: string[] = ["```"]; + const files: string[] = []; - if (attribute === undefined) { - const lines = ["```"]; - const files: string[] = []; - - for (const [key, value] of host.getRecordEntries(record)) { - if (key.startsWith("_")) { - continue; - } - - lines.push( - `${key}: ${host.formatValue(value)}`, - ); - - if ( - typeof value === "string" && - host.isImage(value) - ) { - files.push(host.imagePath(value)); - } + for (const [key, value] of host.getRecordEntries(record)) { + output.push(`${key}: ${host.formatValue(value)}`); + if (typeof value === "string" && host.isImage(value)) { + files.push(host.imagePath(value)); } - - lines.push("```"); - - await ctx.send(lines.join("\n"), files); - return; } - const attributeName = attribute.name.toLowerCase(); - - attributeError(model, attributeName); + output.push("```"); + await context.send(output.join("\n"), files); + return; + } - let newAttribute = host.getRecordValue( - record, - attributeName, + const attributeName = attributeToken.name.toLowerCase(); + if (!host.hasAttribute(model, attributeName)) { + throw new Error( + `'${attributeName}' is not a valid ${model.name} attribute\n` + + `Run \`ATTRIBUTES > ${model.name}\` to see a list of all attributes for that model`, ); + } - if ( - typeof newAttribute === "string" && - host.isImage(newAttribute) - ) { - await ctx.send( - `\`\`\`${newAttribute}\`\`\``, - [host.imagePath(newAttribute)], - ); - return; - } + let result = host.getRecordValue(record, attributeName); - if (attribute.type === ValueType.MODEL) { - newAttribute = await host.getRelatedIdentifiers( - newAttribute, - attribute.extraData[0]!, - ); + if (attributeToken.type === ValueType.MODEL) { + const identifierField = attributeToken.extraData[0]; + if (typeof identifierField === "string") { + result = await host.getRelatedIdentifiers(result, identifierField); } + } - await ctx.send( - `\`\`\`${host.formatValue(newAttribute)}\`\`\``, + if (typeof result === "string" && host.isImage(result)) { + await context.send( + `\`\`\`${result}\`\`\``, + [host.imagePath(result)], ); - }, - - attributes: async (ctx, ...args: PitchScriptValue[]) => { - const model = args[0]!; - const filter = args[1]; - - const fields = host.getFields(model) - .filter((field) => { - if (field.typeName === "BackwardFKRelation") { - return false; - } + return; + } - if (filter === undefined) { - return true; - } - - switch (String(filter.value).toLowerCase()) { - case "null": - return field.nullable; - - case "valid": - return !field.nullable; - - default: - return true; - } - }) - .map((field) => `- ${field.name.toUpperCase()}`); - - fields.unshift(`${model.name.toUpperCase()} ATTRIBUTES:\n`); + await context.send(`\`\`\`${host.formatValue(result)}\`\`\``); + }, - await ctx.sendList(fields); - }, + async attributes( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + filterToken?: PitchScriptValue, + ) { + const model = ensureModel(modelToken); + const filter = filterToken?.value; + + const fields = host + .getFields(model) + .filter((field) => field.typeName !== "BackwardFKRelation") + .filter((field) => { + if (filter === undefined || filter === null) return true; + const normalized = String(filter).toLowerCase(); + if (normalized === "null") return field.nullable; + if (normalized === "valid") return !field.nullable; + return true; + }) + .map((field) => `- ${field.name.toUpperCase()}`); + + fields.unshift(`${model.name.toUpperCase()} ATTRIBUTES:`); + await context.sendList(fields); }, - }; - }; + }, + }); } diff --git a/src/commands/template.ts b/src/commands/template.ts index 3d03fa2..1eb8f59 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -1,49 +1,44 @@ +import type { PitchScriptValue } from "../types.js"; +import type { PitchScriptCommandContext } from "../host.js"; import type { PitchScriptCommandFactory, + PitchScriptSharedState, } from "../dispatcher.js"; -import type { - PitchScriptCommandContext, -} from "../host.js"; -import type { PitchScriptValue } from "../types.js"; -export const createTemplateCommands: - PitchScriptCommandFactory = - () => ({ +export const createTemplateCommands: PitchScriptCommandFactory = + (_shared: PitchScriptSharedState) => ({ methods: { - create: async ( - ctx, - model: PitchScriptValue, - argument?: PitchScriptValue, - ) => { - if (!model) { - throw new Error( - "Argument missing when calling 'create'.", - ); + async create( + context: PitchScriptCommandContext, + modelToken?: PitchScriptValue, + argumentToken?: PitchScriptValue, + ) { + if (!modelToken) { + throw new TypeError("Argument missing when calling 'create'."); } - if (model.name.toLowerCase() !== "club") { - return; + // FCDex's CLUB DSL model is the BallsDex Ball model used by + // the original DexScript template. + if (modelToken.name.toLowerCase() !== "club" && modelToken.name.toLowerCase() !== "ball") { + throw new Error(`No creation template is defined for '${modelToken.name}'.`); } - const identifier = argument?.name ?? "..."; - - const templateCommands = [ - `CREATE > CLUB > ${identifier}`, - `UPDATE > CLUB > ${identifier} > HEALTH > ...`, - `UPDATE > CLUB > ${identifier} > ATTACK > ...`, - `UPDATE > CLUB > ${identifier} > RARITY > ...`, - `UPDATE > CLUB > ${identifier} > EMOJI_ID > ...`, - `UPDATE > CLUB > ${identifier} > WILD_CARD > ...`, - `UPDATE > CLUB > ${identifier} > COLLECTION_CARD > ...`, - `UPDATE > CLUB > ${identifier} > CREDITS > ...`, - `UPDATE > CLUB > ${identifier} > CAPACITY_NAME > ...`, - `UPDATE > CLUB > ${identifier} > CAPACITY_DESCRIPTION > ...`, - `UPDATE > CLUB > ${identifier} > REGIME_ID > ...`, + const argument = argumentToken?.name ?? "..."; + const lines = [ + `CREATE > CLUB > ${argument}`, + `UPDATE > CLUB > ${argument} > HEALTH > ...`, + `UPDATE > CLUB > ${argument} > ATTACK > ...`, + `UPDATE > CLUB > ${argument} > RARITY > ...`, + `UPDATE > CLUB > ${argument} > EMOJI_ID > ...`, + `UPDATE > CLUB > ${argument} > WILD_CARD > ...`, + `UPDATE > CLUB > ${argument} > COLLECTION_CARD > ...`, + `UPDATE > CLUB > ${argument} > CREDITS > ...`, + `UPDATE > CLUB > ${argument} > CAPACITY_NAME > ...`, + `UPDATE > CLUB > ${argument} > CAPACITY_DESCRIPTION > ...`, + `UPDATE > CLUB > ${argument} > CAPACITY_LOGIC > ...`, ]; - await ctx.send( - `\`\`\`sql\n${templateCommands.join("\n")}\n\`\`\``, - ); + await context.send(`\`\`\`sql\n${lines.join("\n")}\n\`\`\``); }, }, }); diff --git a/src/discord.ts b/src/discord.ts index bc23296..0ee32df 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -135,6 +135,7 @@ function createMessageWaiter( function createContext( client: Client, message: Message, + engine: FCDexPitchScript, ): PitchScriptCommandContext { const channel = getSendableChannel(message); @@ -197,7 +198,9 @@ function createContext( }, }; - return { + let context: PitchScriptCommandContext; + + context = { attachments: [...message.attachments.values()], async send(content, files) { @@ -223,10 +226,36 @@ function createContext( : null; }, + async executeScript(source) { + const result = await engine.execute(source, context); + if (result !== undefined) { + throw new Error(result); + } + }, + + async readAttachment(attachment) { + if ( + typeof attachment !== "object" || + attachment === null || + !("url" in attachment) || + typeof attachment.url !== "string" + ) { + throw new Error("The uploaded attachment has no downloadable URL."); + } + + const response = await fetch(attachment.url); + if (!response.ok) { + throw new Error(`Could not download attachment (${response.status}).`); + } + return response.text(); + }, + async addReaction(emoji) { await message.react(emoji); }, }; + + return context; } export function registerPitchScriptDiscord( @@ -258,6 +287,8 @@ export function registerPitchScriptDiscord( } try { + const channel = getSendableChannel(message); + switch (command.name) { case "run": { if (!command.arguments.trim()) { @@ -276,12 +307,12 @@ export function registerPitchScriptDiscord( ); if (notice) { - await message.channel.send({ + await channel.send({ content: `-# ${notice}`, }); } - const context = createContext(client, message); + const context = createContext(client, message, engine); const result = await engine.execute( command.arguments, context, @@ -374,7 +405,7 @@ export function registerPitchScriptDiscord( : String(error); try { - await message.channel.send({ + await getSendableChannel(message).send({ content: `\`\`\`ERROR: ${detail}\`\`\``, }); } catch (sendError) { diff --git a/src/fcdex-host.ts b/src/fcdex-host.ts index 1e0a43e..8f67a76 100644 --- a/src/fcdex-host.ts +++ b/src/fcdex-host.ts @@ -10,7 +10,7 @@ import type { ModelFieldDefinition, } from "./model-utils.js"; -import { autocorrect, imagePath, isImage } from "./utils.js"; +import { autocorrect, imagePath, isImage, saveAttachment as saveUploadedAttachment, type SaveableAttachment } from "./utils.js"; import type { PitchScriptValue, @@ -186,6 +186,62 @@ export class FCDexHost return record; } + private coerceValue( + model: RuntimeModelDefinition, + attribute: string, + value: unknown, + ): unknown { + if (value === null || value === undefined) return value; + + const field = this.field(model, attribute); + switch (field.typeName) { + case "BigIntField": + return typeof value === "bigint" ? value : BigInt(String(value)); + case "IntField": { + const number = Number(value); + if (!Number.isInteger(number)) { + throw new TypeError(`'${value}' is not a valid integer for '${attribute}'.`); + } + return number; + } + case "FloatField": { + const number = Number(value); + if (!Number.isFinite(number)) { + throw new TypeError(`'${value}' is not a valid number for '${attribute}'.`); + } + return number; + } + case "BooleanField": + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + } + return Boolean(value); + case "DatetimeField": + case "DateTimeField": { + const date = value instanceof Date ? value : new Date(String(value)); + if (Number.isNaN(date.getTime())) { + throw new TypeError(`'${value}' is not a valid date for '${attribute}'.`); + } + return date; + } + case "JSONField": + if (typeof value !== "string") return value; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } + case "CharField": + case "TextField": + return String(value); + default: + return value; + } + } + private whereClause( model: RuntimeModelDefinition, field: string, @@ -193,9 +249,10 @@ export class FCDexHost ): Record { const [attribute, operator] = field.split("__"); const nativeName = this.nativeField(model, attribute!); + const coercedValue = this.coerceValue(model, attribute!, value); if (!operator) { - return { [nativeName]: value }; + return { [nativeName]: coercedValue }; } const normalizedOperator = operator.toLowerCase(); @@ -239,7 +296,7 @@ export class FCDexHost return { [nativeName]: { - [prismaOperator]: value, + [prismaOperator]: coercedValue, ...(caseInsensitive ? { mode: "insensitive" } : {}), }, }; @@ -461,12 +518,13 @@ export class FCDexHost ): void { const definition = this.recordDefinition(record); const nativeName = this.nativeField(definition, attribute); + const coercedValue = this.coerceValue(definition, attribute, value); if (!isRecord(record)) { throw new Error("Invalid model record."); } - record[nativeName] = value; + record[nativeName] = coercedValue; } async saveRecord( @@ -524,13 +582,21 @@ export class FCDexHost } async saveAttachment(attachment: unknown): Promise { - if (!this.options.saveAttachment) { - throw new Error( - "Attachment storage is not configured for the FCDex host.", - ); + if (this.options.saveAttachment) { + return this.options.saveAttachment(attachment); + } + + if ( + typeof attachment !== "object" || + attachment === null || + !("filename" in attachment) || + typeof attachment.filename !== "string" + ) { + throw new TypeError("The uploaded attachment is invalid."); } - return this.options.saveAttachment(attachment); + const candidate = attachment as SaveableAttachment; + return saveUploadedAttachment(candidate); } uploadedImageValue(relativePath: string): string { @@ -567,7 +633,7 @@ export class FCDexHost return this.delegate(definition) .updateMany({ where: this.whereClause(definition, field, oldValue), - data: { [nativeField]: newValue }, + data: { [nativeField]: this.coerceValue(definition, attribute!, newValue) }, }) .then(() => undefined); } diff --git a/src/fcdex-models.ts b/src/fcdex-models.ts index 50e1c08..d0c2eef 100644 --- a/src/fcdex-models.ts +++ b/src/fcdex-models.ts @@ -56,7 +56,7 @@ export const fcdexModels: readonly RuntimeModelDefinition[] { name: "endDate", dslName: "end_date", typeName: "DatetimeField", nullable: true }, { name: "rarity", typeName: "FloatField", nullable: false }, { name: "emoji", typeName: "CharField", nullable: true, maxLength: 20 }, - { name: "background", typeName: "CharField", nullable: true }, + { name: "background", typeName: "CharField", nullable: true, maxLength: 200 }, { name: "tradeable", typeName: "BooleanField", nullable: false }, { name: "hidden", typeName: "BooleanField", nullable: false }, { name: "credits", typeName: "TextField", nullable: true }, diff --git a/src/fcdex-runtime.ts b/src/fcdex-runtime.ts index f8d246b..4df2cb5 100644 --- a/src/fcdex-runtime.ts +++ b/src/fcdex-runtime.ts @@ -72,6 +72,13 @@ export function createFCDexPitchScript( } : {}), + ...(context.readAttachment + ? { + readAttachment: (attachment: unknown) => + context.readAttachment!(attachment), + } + : {}), + executeScript: async (savedSource: string) => { const result = await executeSource( savedSource, diff --git a/src/host.ts b/src/host.ts index e29c239..616834e 100644 --- a/src/host.ts +++ b/src/host.ts @@ -7,6 +7,15 @@ export interface PitchScriptFieldInfo { readonly maxLength?: number; } +export interface PitchScriptMessage { + readonly content: string; +} + +/** + * Host services supplied by the application running PitchScript. + * The additional interaction hooks are optional so the core command + * handlers can also be used with non-Discord transports. + */ export interface PitchScriptCommandContext { send( content: string, @@ -14,6 +23,16 @@ export interface PitchScriptCommandContext { ): Promise; sendList(messages: readonly string[]): Promise; + + attachments?: unknown[]; + + waitForMessage?(timeoutMs: number): Promise; + + executeScript?(source: string): Promise; + + addReaction?(emoji: string): Promise; + + readAttachment?(attachment: unknown): Promise; } export interface PitchScriptHost { diff --git a/src/index.ts b/src/index.ts index 556ab77..1e3c74b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ export { imagePath, isImage, isDate, + parseDate, pascalCase, saveAttachment, MEDIA_PATH, @@ -63,6 +64,7 @@ export { } from "./fcdex-runtime.js"; export { ValueType } from "./types.js"; +export { EVAL_PRESETS_DIRECTORY } from "./commands/eval.js"; export type { ModelDefinition, diff --git a/src/parser.ts b/src/parser.ts index 51efe12..cde69cc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,3 +1,4 @@ +import { parseDate } from "./utils.js"; import { ValueType, type ModelDefinition, @@ -66,9 +67,9 @@ export class PitchScriptParser { ).length; if (hyphenCount >= 2) { - const parsedDate = new Date(line); + const parsedDate = parseDate(line); - if (!Number.isNaN(parsedDate.getTime())) { + if (parsedDate) { value.type = ValueType.DATETIME; value.value = parsedDate; return value; diff --git a/src/utils.ts b/src/utils.ts index 2b7c748..e917e97 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,6 +5,8 @@ import { import { access, + mkdir, + writeFile, } from "node:fs/promises"; import { @@ -25,7 +27,8 @@ const FILENAME_RE = /^(.+)(\.\S+)$/; export interface SaveableAttachment { readonly filename: string; - save(path: string): Promise; + readonly url?: string; + save?(path: string): Promise; } function removeMatchingBlocks( @@ -54,10 +57,12 @@ function removeMatchingBlocks( continue; } - current[j - bStart + 1] = - previous[j - bStart] + 1; + const currentIndex = j - bStart + 1; + const previousIndex = j - bStart; + current[currentIndex] = + (previous[previousIndex] ?? 0) + 1; - const size = current[j - bStart + 1]; + const size = current[currentIndex] ?? 0; if (size > bestSize) { bestSize = size; @@ -187,14 +192,33 @@ export function isImage(path: string): boolean { } } -export function isDate(input: string): boolean { - if (input.trim() === "") { - return false; - } +export function parseDate(input: string): Date | undefined { + const value = input.trim(); + if (value === "") return undefined; + + const nativeDate = new Date(value); + if (!Number.isNaN(nativeDate.getTime())) return nativeDate; - const date = new Date(input); + // dateutil (used by the Python source) accepts day-first dates too. + const dayFirst = /^(\d{1,2})-(\d{1,2})-(\d{4})$/.exec(value); + if (!dayFirst) return undefined; - return !Number.isNaN(date.getTime()); + const day = Number(dayFirst[1]); + const month = Number(dayFirst[2]); + const year = Number(dayFirst[3]); + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return undefined; + } + return date; +} + +export function isDate(input: string): boolean { + return parseDate(input) !== undefined; } export function fetchModel( @@ -224,7 +248,10 @@ export async function saveAttachment( ); } - const [, filenameStem, extension] = match; + const filenameStem = match[1]!; + const extension = match[2]!; + + await mkdir(mediaDirectory, { recursive: true }); let path = join(mediaDirectory, filename); let index = 1; @@ -247,7 +274,17 @@ export async function saveAttachment( index += 1; } - await attachment.save(path); + if (typeof attachment.save === "function") { + await attachment.save(path); + } else if (typeof attachment.url === "string") { + const response = await fetch(attachment.url); + if (!response.ok) { + throw new Error(`Could not download attachment (${response.status}).`); + } + await writeFile(path, new Uint8Array(await response.arrayBuffer())); + } else { + throw new TypeError("The attachment cannot be saved by this host."); + } return relative(mediaDirectory, path); } diff --git a/src/version.ts b/src/version.ts index f98044e..d2ee486 100644 --- a/src/version.ts +++ b/src/version.ts @@ -10,6 +10,20 @@ interface GitHubContentResponse { } function extractVersion(content: string): string | undefined { + try { + const parsed: unknown = JSON.parse(content); + if ( + typeof parsed === "object" && + parsed !== null && + "version" in parsed && + typeof parsed.version === "string" + ) { + return parsed.version; + } + } catch { + // Keep compatibility with repositories still publishing a pyproject.toml. + } + return content.match( /^\s*version\s*=\s*["']([^"']+)["']/m, )?.[1]; @@ -51,13 +65,18 @@ export async function checkPitchScriptVersion( const url = `https://api.github.com/repos/${owner}/${repository}` + - `/contents/pyproject.toml${query}`; - - const response = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - }, - }); + `/contents/package.json${query}`; + + let response: Response; + try { + response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + }, + }); + } catch { + return null; + } if (!response.ok) { return null; diff --git a/tests/fcdex-models.test.ts b/tests/fcdex-models.test.ts index e6492ee..e107cd7 100644 --- a/tests/fcdex-models.test.ts +++ b/tests/fcdex-models.test.ts @@ -77,7 +77,7 @@ describe("FCDex model registry", () => { expect(registeredNames).not.toContain("Economy"); }); - it("includes the required regimeId field", () => { + it("matches the optional regimeId in schema.prisma", () => { const club = fcdexModels.find( (model) => model.name === "Club", ); @@ -89,7 +89,7 @@ describe("FCDex model registry", () => { expect(field).toMatchObject({ name: "regimeId", typeName: "BigIntField", - nullable: false, + nullable: true, }); }); });