merged libraries into one

This commit is contained in:
JB
2026-01-01 22:07:16 -05:00
parent a6642ac829
commit 6e31d40d49
185 changed files with 383 additions and 4013 deletions

5
packages/lib/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
*.log
.DS_Store
.env.keys

View File

@@ -0,0 +1,8 @@
trailingComma: all
tabWidth: 2
useTabs: false
semi: true
singleQuote: true
printWidth: 140
experimentalTernaries: true
quoteProps: consistent

23
packages/lib/README.md Normal file
View File

@@ -0,0 +1,23 @@
# tsdown-starter
A starter for creating a TypeScript package.
## Development
- Install dependencies:
```bash
npm install
```
- Run the unit tests:
```bash
npm run test
```
- Build the library:
```bash
npm run build
```

9
packages/lib/build.ts Normal file
View File

@@ -0,0 +1,9 @@
const bundle = await Bun.build({
entrypoints: ['./src/***.ts', '!./src/**/*.test.ts'],
outdir: 'dist',
minify: true,
});
if (!bundle.success) {
throw new AggregateError(bundle.logs);
}

7
packages/lib/bunfig.toml Normal file
View File

@@ -0,0 +1,7 @@
[test]
coverage = true
coverageSkipTestFiles = true
coverageReporter = ["text", "lcov"]
[run]
bun = true

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle",
});

View File

@@ -0,0 +1,8 @@
import type { CommandHandler } from '@/commands/command-handler.type';
const handler: CommandHandler<{ name: string; type: 1; description: string }> = {
definition: { name: 'test1', type: 1, description: 'Test command 1' },
execute: async () => {},
};
export default handler;

View File

@@ -0,0 +1,8 @@
import type { CommandHandler } from '@/commands/command-handler.type';
const handler: CommandHandler<{ name: string; type: 1; description: string }> = {
definition: { name: 'test2', type: 1, description: 'Test command 2' },
execute: async () => {},
};
export default handler;

View File

@@ -0,0 +1,30 @@
import * as StarKitten from '@star-kitten/lib/discord';
import type { ExecutableInteraction } from '@star-kitten/lib/discord';
import { createActionRow, createButton, createContainer, createTextDisplay } from '@star-kitten/lib/discord/components';
import type { PageContext } from '@star-kitten/lib/discord/pages';
import { type Appraisal } from '@star-kitten/lib/eve/third-party/janice.js';
import { formatNumberToShortForm } from '@star-kitten/lib/util/text.js';
export function renderAppraisal(appraisal: Appraisal, pageCtx: PageContext<any>, interaction: ExecutableInteraction) {
const formatter = new Intl.NumberFormat(interaction.locale || 'en-US', {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
});
const world = 'world';
return StarKitten.createElement(
'ActionRow',
{},
StarKitten.createElement(
'Container',
{ color: '0x1da57a' },
StarKitten.createElement('TextDisplay', {}, '' + `Hello ${world}` + ''),
pageCtx.state.currentPage !== 'share'
? StarKitten.createElement(
'ActionRow',
{},
StarKitten.createElement('Button', { key: 'share', disabled: '{!unknown}' }, 'Share in Channel'),
)
: undefined,
),
);
}

View File

@@ -0,0 +1,32 @@
[
{
"id": 1,
"name": "Alice",
"age": 30,
"department": "Engineering"
},
{
"id": 2,
"name": "Bob",
"age": 25,
"department": "Marketing"
},
{
"id": 3,
"name": "Charlie",
"age": 35,
"department": "Engineering"
},
{
"id": 4,
"name": "Diana",
"age": 28,
"department": "Sales"
},
{
"id": 5,
"name": "Eve",
"age": 32,
"department": "Engineering"
}
]

View File

@@ -0,0 +1,3 @@
{
"invalid": "json",
"missing": "closing brace"

View File

@@ -0,0 +1,32 @@
{
"users": {
"alice": {
"id": 1,
"name": "Alice",
"age": 30,
"department": "Engineering"
},
"bob": {
"id": 2,
"name": "Bob",
"age": 25,
"department": "Marketing"
}
},
"departments": {
"engineering": {
"name": "Engineering",
"budget": 1000000,
"headCount": 15
},
"marketing": {
"name": "Marketing",
"budget": 500000,
"headCount": 8
}
},
"config": {
"version": "1.0.0",
"environment": "test"
}
}

View File

@@ -0,0 +1,29 @@
{
"colors": ["red", "blue", "green", "yellow"],
"testText": {
"simple": "Hello World",
"multiline": "Line 1\nLine 2\nLine 3",
"withSpecialChars": "Text with !@#$%^&*()_+-=[]{}|;':\",./<>?",
"empty": "",
"unicode": "Unicode: 🌟 ❤️ 🔥",
"code": "function test() { return 'hello'; }"
},
"expected": {
"red": {
"simple": "```ansi\n\u001b[2;31mHello World\u001b[0m```\n",
"empty": "```ansi\n\u001b[2;31m\u001b[0m```\n"
},
"blue": {
"simple": "```ansi\n\u001b[2;32m\u001b[2;36m\u001b[2;34mHello World\u001b[0m\u001b[2;36m\u001b[0m\u001b[2;32m\u001b[0m```\n",
"empty": "```ansi\n\u001b[2;32m\u001b[2;36m\u001b[2;34m\u001b[0m\u001b[2;36m\u001b[0m\u001b[2;32m\u001b[0m```\n"
},
"green": {
"simple": "```ansi\n\u001b[2;36mHello World\u001b[0m```\n",
"empty": "```ansi\n\u001b[2;36m\u001b[0m```\n"
},
"yellow": {
"simple": "```ansi\n\u001b[2;33mHello World\u001b[0m```\n",
"empty": "```ansi\n\u001b[2;33m\u001b[0m```\n"
}
}
}

View File

@@ -0,0 +1,41 @@
{
"boldMarkup": {
"complete": "<b>bold text</b>",
"openOnly": "<b>bold text",
"closeOnly": "bold text</b>",
"nested": "<b>outer <b>inner</b> text</b>",
"empty": "<b></b>",
"multiple": "<b>first</b> and <b>second</b>",
"mixed": "<b>bold</b> with <i>italic</i> text"
},
"italicMarkup": {
"complete": "<i>italic text</i>",
"openOnly": "<i>italic text",
"closeOnly": "italic text</i>",
"nested": "<i>outer <i>inner</i> text</i>",
"empty": "<i></i>",
"multiple": "<i>first</i> and <i>second</i>",
"mixed": "<i>italic</i> with <b>bold</b> text"
},
"colorTags": {
"hex6": "<color=0xFF5733>colored text</color>",
"hex8": "<color=0xFF5733AA>colored text</color>",
"hexWithoutPrefix": "<color=FF5733>colored text</color>",
"namedColor": "<color=red>colored text</color>",
"nested": "<color=blue>outer <color=red>inner</color> text</color>",
"empty": "<color=green></color>",
"multiple": "<color=red>first</color> and <color=blue>second</color>"
},
"eveLinks": {
"simple": "<a href=showinfo:587>Rifter</a>",
"withSpaces": "<a href=showinfo:12345>Ship Name With Spaces</a>",
"multiple": "<a href=showinfo:587>Rifter</a> and <a href=showinfo:588>Merlin</a>",
"nested": "Check out <a href=showinfo:587>Rifter</a> for PvP",
"empty": "<a href=showinfo:587></a>"
},
"combined": {
"allMarkup": "<b>Bold</b> <i>italic</i> <color=red>colored</color> <a href=showinfo:587>linked</a>",
"nestedComplex": "<b><color=blue><a href=showinfo:587>Bold Blue Rifter</a></color></b>",
"realWorldExample": "The <b><color=0xFF5733>Rifter</color></b> is a <i>fast</i> <a href=showinfo:587>frigate</a> used in PvP."
}
}

View File

@@ -0,0 +1,34 @@
{
"milliseconds": {
"zero": 0,
"oneSecond": 1000,
"oneMinute": 60000,
"oneHour": 3600000,
"complex": 3661500,
"daysWorthMs": 86400000,
"fractionalSeconds": 1500,
"smallFraction": 100
},
"seconds": {
"zero": 0,
"oneSecond": 1,
"oneMinute": 60,
"oneHour": 3600,
"complex": 3661,
"daysWorthSec": 86400,
"fractionalInput": 3661.5
},
"expected": {
"zero": "0.0s",
"oneSecond": "1.0s",
"oneMinute": "1m",
"oneHour": "1h",
"complexMs": "1h 1m 1.5s",
"complexSec": "1h 1m 1s",
"daysMs": "24h",
"daysSec": "24h",
"fractionalSeconds": "1.5s",
"smallFraction": "0.1s",
"fractionalInputSec": "1h 1m 1s"
}
}

166
packages/lib/package.json Normal file
View File

@@ -0,0 +1,166 @@
{
"name": "@star-kitten/lib",
"version": "0.0.0",
"description": "Star Kitten Library.",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/author/library#readme",
"bugs": {
"url": "https://github.com/author/library/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/author/library.git"
},
"author": "JB <j-b-3.deviate267@passmail.net>",
"files": [
"dist",
"src"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index*.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index*.d.ts"
},
"./package.json": "./package.json",
"./util": {
"types": "./src/util/index.d.ts",
"require": "./src/util/index.js",
"import": "./dist/util/index.js"
},
"./util/*": {
"types": "./src/util/**/*.d.ts",
"require": "./src/util/*",
"import": "./dist/util/*"
},
"./eve": {
"types": "./src/eve/index.d.ts",
"require": "./src/eve/index.js",
"import": "./dist/eve/index.js"
},
"./eve/*": {
"types": "./src/eve/**/*.d.ts",
"require": "./src/eve/*",
"import": "./dist/eve/*"
},
"./eve/esi": {
"import": "./dist/eve/esi/index.js",
"types": "./src/eve/esi/index*.d.ts",
"require": "./src/eve/esi/index.js"
},
"./eve/db": {
"import": "./dist/eve/db/index.js",
"types": "./src/eve/db/index*.d.ts",
"require": "./src/eve/db/index.js"
},
"./eve/ref": {
"import": "./dist/eve/ref/index.js",
"types": "./src/eve/ref/index*.d.ts",
"require": "./src/eve/ref/index.js"
},
"./eve/third-party": {
"import": "./dist/eve/third-party/index.js",
"types": "./src/eve/third-party/index*.d.ts",
"require": "./src/eve/third-party/index.js"
},
"./eve/models": {
"import": "./dist/eve/models/index.js",
"types": "./src/eve/models/index*.d.ts",
"require": "./src/eve/models/index.js"
},
"./eve/data/*": "./data/*",
"./discord": {
"import": "./dist/discord/index.js",
"types": "./src/discord/index*.d.ts",
"require": "./src/discord/index.js"
},
"./discord/commands": {
"types": "./src/discord/commands/index*.d.ts",
"require": "./src/discord/commands/index.js",
"import": "./dist/discord/commands/index.js"
},
"./discord/components": {
"types": "./src/discord/components/index*.d.ts",
"require": "./src/discord/components/index.js",
"import": "./dist/discord/components/index.js"
},
"./discord/pages": {
"types": "./src/discord/pages/index*.d.ts",
"require": "./src/discord/pages/index.js",
"import": "./dist/discord/pages/index.js"
},
"./discord/common": {
"types": "./src/discord/common/index*.d.ts",
"require": "./src/discord/common/index.js",
"import": "./dist/discord/common/index.js"
},
"./discord/jsx": {
"types": "./src/discord/jsx/types.d.ts",
"import": "./dist/discord/jsx/index.js"
},
"./discord/jsx-runtime": {
"types": "./src/discord/jsx/types.d.ts",
"default": "./dist/discord/jsx/jsx-runtime.js"
},
"./discord/jsx-dev-runtime": {
"types": "./src/discord/jsx/types.d.ts",
"default": "./dist/discord/jsx/jsx-dev-runtime.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"test": "bun test",
"typecheck": "tsc --noEmit",
"release": "bumpp && npm publish",
"generate-migrations": "bunx drizzle-kit generate --dialect sqlite --schema ./src/db/schema.ts",
"migrate": "bun run ./src/db/migrate.ts",
"get-data": "bun refresh:reference-data && bun refresh:hoboleaks && bun static-export",
"refresh:reference-data": "bun run ../util/dist/download-and-extract.js https://data.everef.net/reference-data/reference-data-latest.tar.xz ./data/reference-data",
"refresh:hoboleaks": "bun run ../util/dist/download-and-extract.js https://data.everef.net/hoboleaks-sde/hoboleaks-sde-latest.tar.xz ./data/hoboleaks",
"static-export": "bun run ./scripts/export-solar-systems.ts"
},
"devDependencies": {
"@types/bun": "^1.3.5",
"@types/lodash": "^4.17.20",
"@types/node": "^22.15.17",
"@types/node-cache": "^4.2.5",
"@types/stream-chain": "^2.1.0",
"@types/stream-json": "^1.7.8",
"bumpp": "^10.1.0",
"tsdown": "^0.14.2",
"typescript": "^5.9.2",
"@vitest/coverage-v8": "^3.2.4",
"@types/jwk-to-pem": "^2.0.3",
"@types/jsonwebtoken": "^9.0.10",
"drizzle-kit": "^0.31.4",
"prettier-plugin-multiline-arrays": "^4.0.3"
},
"dependencies": {
"@projectdysnomia/dysnomia": "github:projectdysnomia/dysnomia#dev",
"acorn": "^8.14.0",
"acorn-jsx": "^5.3.2",
"html-dom-parser": "^5.1.1",
"cron-parser": "^5.3.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"node-cache": "^5.1.2",
"stream-chain": "^3.4.0",
"stream-json": "^1.9.1",
"winston": "^3.17.0",
"@orama/orama": "^3.1.13",
"@oslojs/encoding": "^1.1.0",
"drizzle-orm": "^0.44.5",
"fp-filters": "^0.5.4",
"jsonwebtoken": "^9.0.2",
"jwk-to-pem": "^2.0.7",
"jwt-decode": "^4.0.0"
}
}

View File

@@ -0,0 +1,19 @@
import { Database } from "bun:sqlite";
import { join } from "node:path";
const db = new Database(join(process.cwd(), 'data/evestatic.db'));
const query = db.query("SELECT * FROM mapSolarSystems");
const results = query.all();
const output = results.reduce((acc: any, system: any) => {
acc[system.solarSystemID] = system;
return acc;
}, {});
const jsonData = JSON.stringify(output, null, 2);
const fs = await import('fs/promises');
await fs.writeFile(join(process.cwd(), 'data/reference-data/solar_systems.json'), jsonData);
db.close();

View File

@@ -0,0 +1,14 @@
import type { Cache } from '@/discord/core/cache.type';
import type { KVStore } from '@/discord/core/kv-store.type.ts';
import type { Client } from '@projectdysnomia/dysnomia';
import type { CommandState } from './command-state';
export interface PartialContext<T = any> {
client: Client;
cache: Cache;
kv: KVStore;
id?: string; // unique id for this command instance
state?: CommandState<T>; // state associated with this command instance
}
export type CommandContext<T = any> = Required<PartialContext<T>>;

View File

@@ -0,0 +1,32 @@
import {
AutocompleteInteraction,
CommandInteraction,
ComponentInteraction,
Constants,
ModalSubmitInteraction,
type ApplicationCommandOptionAutocomplete,
type ApplicationCommandOptions,
type ApplicationCommandStructure,
type ChatInputApplicationCommandStructure,
} from '@projectdysnomia/dysnomia';
import type { CommandContext, PartialContext } from './command-context.type';
export interface CommandHandler<T extends ApplicationCommandStructure> {
definition: T;
execute: (interaction: ExecutableInteraction, ctx: CommandContext) => Promise<void>;
}
export type ExecutableInteraction = CommandInteraction | ModalSubmitInteraction | ComponentInteraction | AutocompleteInteraction;
export type ChatCommandDefinition = Omit<ChatInputApplicationCommandStructure, 'type'>;
export function createChatCommand(
definition: ChatCommandDefinition,
execute: (interaction: CommandInteraction, ctx: CommandContext) => Promise<void>,
): CommandHandler<ChatInputApplicationCommandStructure> {
const def = definition as ChatInputApplicationCommandStructure;
def.type = 1; // CHAT_INPUT
return {
definition: def,
execute,
};
}

View File

@@ -0,0 +1,45 @@
import {
Interaction,
CommandInteraction,
Constants,
ModalSubmitInteraction,
ComponentInteraction,
AutocompleteInteraction,
PingInteraction,
} from '@projectdysnomia/dysnomia';
import type { ExecutableInteraction } from './command-handler';
export function isApplicationCommand(interaction: Interaction): interaction is CommandInteraction {
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND;
}
export function isModalSubmit(interaction: Interaction): interaction is ModalSubmitInteraction {
return interaction.type === Constants.InteractionTypes.MODAL_SUBMIT;
}
export function isMessageComponent(interaction: Interaction): interaction is ComponentInteraction {
return interaction.type === Constants.InteractionTypes.MESSAGE_COMPONENT;
}
export function isAutocomplete(interaction: Interaction): interaction is AutocompleteInteraction {
return interaction.type === Constants.InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE;
}
export function isPing(interaction: Interaction): interaction is PingInteraction {
return interaction.type === Constants.InteractionTypes.PING;
}
export function commandHasName(interaction: Interaction, name: string): boolean {
return isApplicationCommand(interaction) && interaction.data.name === name;
}
export function commandHasIdPrefix(interaction: Interaction, prefix: string): boolean {
return (isModalSubmit(interaction) || isMessageComponent(interaction)) && interaction.data.custom_id.startsWith(prefix);
}
export function getCommandName(interaction: ExecutableInteraction): string | undefined {
if (isApplicationCommand(interaction) || isAutocomplete(interaction)) {
return interaction.data.name;
}
return undefined;
}

View File

@@ -0,0 +1,63 @@
import { type InteractionModalContent, type Component } from '@projectdysnomia/dysnomia';
import type { CommandContext, PartialContext } from './command-context.type';
import { isApplicationCommand, isMessageComponent } from './command-helpers';
import type { ExecutableInteraction } from './command-handler';
export function injectInteraction(interaction: ExecutableInteraction, ctx: PartialContext): [ExecutableInteraction, CommandContext] {
// Wrap the interaction methods to inject command tracking ids into all custom_ids for modals and components.
if (ctx.state.name && (isApplicationCommand(interaction) || isMessageComponent(interaction))) {
const _originalCreateModal = interaction.createModal.bind(interaction);
interaction.createModal = (content: InteractionModalContent) => {
validateCustomIdLength(content.custom_id);
content.custom_id = `${content.custom_id}_${ctx.state.id}`;
return _originalCreateModal(content);
};
const _originalCreateMessage = interaction.createMessage.bind(interaction);
interaction.createMessage = (content) => {
if (typeof content === 'string') return _originalCreateMessage(content);
if (content.components) {
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
}
return _originalCreateMessage(content);
};
const _originalEditMessage = interaction.editMessage.bind(interaction);
interaction.editMessage = (messageID, content) => {
if (typeof content === 'string') return _originalEditMessage(messageID, content);
if (content.components) {
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
}
return _originalEditMessage(messageID, content);
};
const _originalCreateFollowup = interaction.createFollowup.bind(interaction);
interaction.createFollowup = (content) => {
if (typeof content === 'string') return _originalCreateFollowup(content);
if (content.components) {
addCommandIdToComponentCustomIds(content.components, ctx.state.id);
}
return _originalCreateFollowup(content);
};
}
return [interaction, ctx as CommandContext];
}
function validateCustomIdLength(customId: string) {
if (customId.length > 80) {
throw new Error(`Custom ID too long: ${customId.length} characters (max 80) with this framework. Consider using shorter IDs.`);
}
}
function addCommandIdToComponentCustomIds(components: Component[], commandId: string) {
components.forEach((component) => {
if (!component) return;
if ('custom_id' in component) {
validateCustomIdLength(component.custom_id as string);
component.custom_id = `${component.custom_id}_${commandId}`;
}
if ('components' in component && Array.isArray(component.components)) {
addCommandIdToComponentCustomIds(component.components, commandId);
}
});
}

View File

@@ -0,0 +1,56 @@
import { createReactiveState } from '@/util/reactive-state.js';
import type { PartialContext } from './command-context.type';
import { isApplicationCommand, isAutocomplete } from './command-helpers';
import type { ExecutableInteraction } from './command-handler';
export interface CommandState<T = any> {
id: string; // unique id for this command instance
name: string; // command name
data: T; // internal data storage
}
export async function getCommandState<T>(interaction: ExecutableInteraction, ctx: PartialContext): Promise<CommandState<T>> {
const id = instanceIdFromInteraction(interaction);
let state: CommandState<T>;
// get state from kv store if possible
if (ctx.kv.has(`command-state:${id}`)) {
state = await ctx.kv.get<CommandState<T>>(`command-state:${id}`);
}
if (!state) {
state = { id: id, name: '', data: {} as T };
}
const [reactiveState, subscribe] = createReactiveState(state);
subscribe(async (newState) => {
if (ctx.kv) {
await ctx.kv.set(`command-state:${id}`, newState);
}
});
ctx.state = reactiveState;
return reactiveState;
}
function instanceIdFromInteraction(interaction: ExecutableInteraction) {
if (isAutocomplete(interaction)) {
// autocomplete should not be stateful, they get no id
return '';
}
if (isApplicationCommand(interaction)) {
// for application commands, we create a new instance id
const instance_id = crypto.randomUUID();
return instance_id;
}
const interact = interaction;
const customId: string = interact.data.custom_id;
const commandId = customId.split('_').pop();
interaction;
// command id should be a uuid
if (commandId && /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(commandId)) {
return commandId;
}
console.error(`Invalid command id extracted from interaction: ${customId}`);
return '';
}

View File

@@ -0,0 +1,59 @@
import { expect, test, mock, beforeEach, afterEach } from 'bun:test';
import { handleCommands } from './handle-commands';
import { CommandInteraction, Constants, ModalSubmitInteraction, type ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import type { CommandHandler } from './command-handler';
let commands: Record<string, CommandHandler<ApplicationCommandStructure>>;
beforeEach(() => {
commands = {};
});
afterEach(() => {
commands = {};
});
mock.module('./command-helpers', () => ({
getCommandName: () => 'testCommand',
}));
test('handleCommands executes command when interaction is CommandInteraction and command exists', async () => {
const mockExecute = mock(() => Promise.resolve());
const mockCommand = { definition: { name: 'testCommand' } as any, execute: mockExecute };
commands['testCommand'] = mockCommand;
const mockInteraction = {
type: Constants.InteractionTypes.APPLICATION_COMMAND,
data: { name: 'testCommand' },
} as any;
Object.setPrototypeOf(mockInteraction, CommandInteraction.prototype);
handleCommands(mockInteraction, commands, {} as any);
expect(mockExecute).toHaveBeenCalledWith(mockInteraction, expect.any(Object));
});
test('handleCommands executes command when interaction is CommandInteraction and command exists', async () => {
const mockExecute = mock(() => Promise.resolve());
const mockCommand = { definition: { name: 'testCommand' } as any, execute: mockExecute };
commands['testCommand'] = mockCommand;
const mockInteraction = {
type: Constants.InteractionTypes.MODAL_SUBMIT,
data: { name: 'testCommand' },
} as any;
Object.setPrototypeOf(mockInteraction, ModalSubmitInteraction.prototype);
handleCommands(mockInteraction, commands, {} as any);
expect(mockExecute).toHaveBeenCalledWith(mockInteraction, expect.any(Object));
});
test('handleCommands does nothing when interaction not a CommandInteraction, ModalSubmitInteraction, MessageComponentInteraction, or AutoCompleteInteraction', () => {
const mockInteraction = {
instanceof: (cls: any) => false,
} as any;
// Should not throw or do anything
expect(() => handleCommands(mockInteraction, commands, {} as any)).not.toThrow();
});

View File

@@ -0,0 +1,73 @@
import { type ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
import { getCommandName, isApplicationCommand, isAutocomplete, isMessageComponent, isModalSubmit } from './command-helpers';
import type { PartialContext } from './command-context.type';
import type { CommandHandler, ExecutableInteraction } from './command-handler';
import { injectInteraction } from './command-injection';
import { getCommandState } from './command-state';
export async function handleCommands(
interaction: ExecutableInteraction,
commands: Record<string, CommandHandler<ApplicationCommandStructure>>,
ctx: PartialContext,
) {
ctx.state = await getCommandState(interaction, ctx);
if (!ctx.state.name) {
ctx.state.name = getCommandName(interaction);
}
if (isAutocomplete(interaction) && ctx.state.name) {
const acCommand = commands[ctx.state.name];
return acCommand.execute(interaction, ctx as any);
}
if (!ctx.state.id) {
console.error(`No command ID found for interaction ${interaction.id}`);
return;
}
const command = commands[ctx.state.name || ''];
if (!command) {
console.warn(`No command found for interaction: ${JSON.stringify(interaction, undefined, 2)}`);
return;
}
cleanInteractionCustomIds(interaction, ctx.state.id);
const [injectedInteraction, fullContext] = await injectInteraction(interaction, ctx);
return command.execute(injectedInteraction, fullContext);
}
export function initializeCommandHandling(commands: Record<string, CommandHandler<ApplicationCommandStructure>>, ctx: PartialContext) {
ctx.client.on('interactionCreate', async (interaction) => {
if (isApplicationCommand(interaction) || isModalSubmit(interaction) || isMessageComponent(interaction) || isAutocomplete(interaction)) {
handleCommands(interaction, commands, ctx);
}
});
}
function cleanInteractionCustomIds(interaction: ExecutableInteraction, id: string) {
if ('components' in interaction && Array.isArray(interaction.components) && id) {
removeCommandIdFromComponentCustomIds(interaction.components, id);
}
if ('data' in interaction && id) {
if ('custom_id' in interaction.data && typeof interaction.data.custom_id === 'string') {
interaction.data.custom_id = interaction.data.custom_id.replace(`_${id}`, '');
}
if ('components' in interaction.data && Array.isArray(interaction.data.components)) {
removeCommandIdFromComponentCustomIds(interaction.data.components as any, id);
}
}
}
function removeCommandIdFromComponentCustomIds(components: { custom_id?: string; components?: any[] }[], commandId: string) {
components.forEach((component) => {
if ('custom_id' in component) {
component.custom_id = component.custom_id.replace(`_${commandId}`, '');
}
if ('components' in component && Array.isArray(component.components)) {
removeCommandIdFromComponentCustomIds(component.components, commandId);
}
if ('component' in component && 'custom_id' in (component as any).component && Array.isArray(component.components)) {
(component.component as any).custom_id = (component.component as any).custom_id.replace(`_${commandId}`, '');
}
});
}

View File

@@ -0,0 +1,19 @@
import { expect, test, mock } from 'bun:test';
import { importCommands } from './import-commands';
import path from 'node:path';
test('importCommands imports commands from files matching pattern', async () => {
const commands = await importCommands('**/*.command.{js,ts}', path.join(__dirname, '../../fixtures'));
expect(commands).toHaveProperty('test1');
expect(commands).toHaveProperty('test2');
expect(commands.test1.definition.name).toBe('test1');
expect(commands.test2.definition.name).toBe('test2');
});
test('importCommands uses default pattern and baseDir', async () => {
const commands = await importCommands();
// Since there are no command files in src, it should be empty
expect(commands).toEqual({});
});

View File

@@ -0,0 +1,19 @@
import { Glob } from 'bun';
import { join } from 'node:path';
import type { CommandHandler } from './command-handler';
import type { ApplicationCommandStructure } from '@projectdysnomia/dysnomia';
export async function importCommands(
pattern: string = '**/*.command.{js,ts}',
baseDir: string = join(process.cwd(), 'src'),
commandRegistry: Record<string, CommandHandler<ApplicationCommandStructure>> = {},
): Promise<Record<string, CommandHandler<ApplicationCommandStructure>>> {
const glob = new Glob(pattern);
for await (const file of glob.scan({ cwd: baseDir, absolute: true })) {
const command = (await import(file)).default as CommandHandler<ApplicationCommandStructure>;
commandRegistry[command.definition.name] = command;
}
return commandRegistry;
}

View File

@@ -0,0 +1,8 @@
export * from './command-handler';
export * from './import-commands';
export * from './handle-commands';
export * from './command-helpers';
export * from './register-commands';
export * from './command-context.type';
export * from './command-state';
export * from './option-builders';

View File

@@ -0,0 +1,80 @@
import {
Constants,
type ApplicationCommandOptions,
type ApplicationCommandOptionsBoolean,
type ApplicationCommandOptionsInteger,
type ApplicationCommandOptionsMentionable,
type ApplicationCommandOptionsNumber,
type ApplicationCommandOptionsRole,
type ApplicationCommandOptionsString,
type ApplicationCommandOptionsSubCommand,
type ApplicationCommandOptionsSubCommandGroup,
type ApplicationCommandOptionsUser,
} from '@projectdysnomia/dysnomia';
export type StringOptionDefinition = Omit<ApplicationCommandOptionsString, 'type'> & { autocomplete?: boolean };
export function stringOption(options: StringOptionDefinition): ApplicationCommandOptionsString {
const def = options as ApplicationCommandOptionsString;
def.type = Constants.ApplicationCommandOptionTypes.STRING;
return def;
}
export type IntegerOptionDefinition = Omit<ApplicationCommandOptionsInteger, 'type'> & { autocomplete?: boolean };
export function integerOption(options: IntegerOptionDefinition): ApplicationCommandOptionsInteger {
const def = options as ApplicationCommandOptionsInteger;
def.type = Constants.ApplicationCommandOptionTypes.INTEGER;
return def;
}
export type BooleanOptionDefinition = Omit<ApplicationCommandOptionsBoolean, 'type'>;
export function booleanOption(options: BooleanOptionDefinition): ApplicationCommandOptionsBoolean {
const def = options as ApplicationCommandOptionsBoolean;
def.type = Constants.ApplicationCommandOptionTypes.BOOLEAN;
return def;
}
export type UserOptionDefinition = Omit<ApplicationCommandOptionsUser, 'type'> & { autocomplete?: boolean };
export function userOption(options: UserOptionDefinition): ApplicationCommandOptionsUser {
const def = options as ApplicationCommandOptionsUser;
def.type = Constants.ApplicationCommandOptionTypes.USER;
return def;
}
export type ChannelOptionDefinition = Omit<ApplicationCommandOptions, 'type'> & { autocomplete?: boolean };
export function channelOption(options: ChannelOptionDefinition): ApplicationCommandOptions {
const def = options as ApplicationCommandOptions;
def.type = Constants.ApplicationCommandOptionTypes.CHANNEL;
return def;
}
export type RoleOptionDefinition = Omit<ApplicationCommandOptionsRole, 'type'> & { autocomplete?: boolean };
export function roleOption(options: RoleOptionDefinition): ApplicationCommandOptionsRole {
const def = options as ApplicationCommandOptionsRole;
def.type = Constants.ApplicationCommandOptionTypes.ROLE;
return def;
}
export type MentionableOptionDefinition = Omit<ApplicationCommandOptionsMentionable, 'type'>;
export function mentionableOption(options: MentionableOptionDefinition): ApplicationCommandOptionsMentionable {
const def = options as ApplicationCommandOptionsMentionable;
def.type = Constants.ApplicationCommandOptionTypes.MENTIONABLE;
return def;
}
export type NumberOptionDefinition = Omit<ApplicationCommandOptionsNumber, 'type'> & { autocomplete?: boolean };
export function numberOption(options: NumberOptionDefinition): ApplicationCommandOptionsNumber {
const def = options as ApplicationCommandOptionsNumber;
def.type = Constants.ApplicationCommandOptionTypes.NUMBER;
return def;
}
export type AttachmentOptionDefinition = Omit<ApplicationCommandOptions, 'type'>;
export function attachmentOption(options: AttachmentOptionDefinition): ApplicationCommandOptions {
const def = options as ApplicationCommandOptions;
def.type = Constants.ApplicationCommandOptionTypes.ATTACHMENT;
return def;
}
export type SubCommandOptionDefinition = Omit<ApplicationCommandOptionsSubCommand, 'type'>;
export function subCommandOption(options: SubCommandOptionDefinition): ApplicationCommandOptionsSubCommand {
const def = options as ApplicationCommandOptionsSubCommand;
def.type = Constants.ApplicationCommandOptionTypes.SUB_COMMAND;
return def;
}
export type SubCommandGroupOptionDefinition = Omit<ApplicationCommandOptionsSubCommandGroup, 'type'>;
export function subCommandGroupOption(options: SubCommandGroupOptionDefinition): ApplicationCommandOptionsSubCommandGroup {
const def = options as ApplicationCommandOptionsSubCommandGroup;
def.type = Constants.ApplicationCommandOptionTypes.SUB_COMMAND_GROUP;
return def;
}

View File

@@ -0,0 +1,11 @@
import type { ApplicationCommandStructure, Client } from '@projectdysnomia/dysnomia';
export async function registerCommands(client: Client, commands: ApplicationCommandStructure[]) {
if (!client) throw new Error('Client not initialized');
if (!(await client.getCommands()).length || process.env.RESET_COMMANDS === 'true' || process.env.NODE_ENV === 'development') {
console.debug('Registering commands...');
const response = await client.bulkEditCommands(commands);
console.debug(`Registered ${response.length} commands.`);
}
return commands;
}

View File

@@ -0,0 +1 @@
export * from './text';

View File

@@ -0,0 +1,2 @@
export const WHITE_SPACE = ' '; // non-breaking space
export const BREAKING_WHITE_SPACE = '\u200B';

View File

@@ -0,0 +1,314 @@
import {
Constants,
type ActionRow,
type Button,
type ChannelSelectMenu,
type GuildChannelTypes,
type MentionableSelectMenu,
type PartialEmoji,
type RoleSelectMenu,
type StringSelectMenu,
type TextInput,
type UserSelectMenu,
type LabelComponent,
type ContainerComponent,
type TextDisplayComponent,
type SectionComponent,
type MediaGalleryComponent,
type SeparatorComponent,
type FileComponent,
type InteractionButton,
type URLButton,
type PremiumButton,
type ThumbnailComponent,
} from '@projectdysnomia/dysnomia';
export type ActionRowItem = Button | StringSelectMenu | UserSelectMenu | RoleSelectMenu | MentionableSelectMenu | ChannelSelectMenu;
export const createActionRow = (...components: ActionRowItem[]): ActionRow => ({
type: Constants.ComponentTypes.ACTION_ROW,
components,
});
export enum ButtonStyle {
PRIMARY = 1,
SECONDARY = 2,
SUCCESS = 3,
DANGER = 4,
}
export interface ButtonOptions {
style?: ButtonStyle;
emoji?: PartialEmoji;
disabled?: boolean;
}
export const createButton = (label: string, custom_id: string, options?: ButtonOptions): InteractionButton => ({
type: Constants.ComponentTypes.BUTTON,
style: options?.style ?? Constants.ButtonStyles.PRIMARY,
label,
custom_id,
...options,
});
export interface URLButtonOptions {
emoji?: PartialEmoji;
disabled?: boolean;
}
export const createURLButton = (label: string, url: string, options?: URLButtonOptions): URLButton => ({
type: Constants.ComponentTypes.BUTTON,
style: Constants.ButtonStyles.LINK,
label,
url,
...options,
});
export interface PremiumButtonOptions {
emoji?: PartialEmoji;
disabled?: boolean;
}
export const createPremiumButton = (sku_id: string, options?: PremiumButtonOptions): PremiumButton => ({
type: Constants.ComponentTypes.BUTTON,
style: Constants.ButtonStyles.PREMIUM,
sku_id,
...options,
});
export interface StringSelectOpts {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
required?: boolean; // Note: not actually a property of StringSelectMenu, but useful for modals
}
export interface StringSelectOption {
label: string;
value: string;
description?: string;
emoji?: {
name?: string;
id?: string;
animated?: boolean;
};
default?: boolean;
}
export const createStringSelect = (
custom_id: string,
selectOpts: StringSelectOpts,
...options: StringSelectOption[]
): StringSelectMenu => ({
type: Constants.ComponentTypes.STRING_SELECT,
custom_id,
options,
placeholder: selectOpts.placeholder ?? '',
min_values: selectOpts.min_values ?? 1,
max_values: selectOpts.max_values ?? 1,
disabled: selectOpts.disabled ?? false,
required: selectOpts.required ?? false, // Note: not actually a property of StringSelectMenu, but useful for modals
});
export interface TextInputOptions {
isParagraph?: boolean;
label?: string;
min_length?: number;
max_length?: number;
required?: boolean;
value?: string;
placeholder?: string;
}
export const createTextInput = (custom_id: string, options?: TextInputOptions): TextInput => ({
type: Constants.ComponentTypes.TEXT_INPUT,
custom_id,
style: options.isParagraph ? Constants.TextInputStyles.PARAGRAPH : Constants.TextInputStyles.SHORT,
label: options?.label ?? '',
min_length: options?.min_length ?? 0,
max_length: options?.max_length ?? 4000,
required: options?.required ?? false,
value: options?.value ?? '',
placeholder: options?.placeholder ?? '',
});
export interface UserSelectOptions {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
default_values?: Array<{ id: string; type: 'user' }>;
}
export const createUserSelect = (custom_id: string, options?: UserSelectOptions): UserSelectMenu => ({
type: Constants.ComponentTypes.USER_SELECT,
custom_id,
placeholder: options?.placeholder ?? '',
min_values: options?.min_values ?? 1,
max_values: options?.max_values ?? 1,
disabled: options?.disabled ?? false,
default_values: options?.default_values ?? [],
});
export interface RoleSelectOptions {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
default_values?: Array<{ id: string; type: 'role' }>;
}
export const createRoleSelect = (custom_id: string, options?: RoleSelectOptions): RoleSelectMenu => ({
type: Constants.ComponentTypes.ROLE_SELECT,
custom_id,
placeholder: options?.placeholder ?? '',
min_values: options?.min_values ?? 1,
max_values: options?.max_values ?? 1,
disabled: options?.disabled ?? false,
default_values: options?.default_values ?? [],
});
export interface MentionableSelectOptions {
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
default_values?: Array<{ id: string; type: 'user' | 'role' }>;
}
export const createMentionableSelect = (custom_id: string, options?: MentionableSelectOptions): MentionableSelectMenu => ({
type: Constants.ComponentTypes.MENTIONABLE_SELECT,
custom_id,
placeholder: options?.placeholder ?? '',
min_values: options?.min_values ?? 1,
max_values: options?.max_values ?? 1,
disabled: options?.disabled ?? false,
default_values: options?.default_values ?? [],
});
export interface ChannelSelectOptions {
channel_types?: GuildChannelTypes[];
placeholder?: string;
min_values?: number;
max_values?: number;
disabled?: boolean;
default_values?: Array<{ id: string; type: 'channel' }>;
}
export const createChannelSelect = (custom_id: string, options?: ChannelSelectOptions): ChannelSelectMenu => ({
type: Constants.ComponentTypes.CHANNEL_SELECT,
custom_id,
channel_types: options?.channel_types ?? [],
placeholder: options?.placeholder ?? '',
min_values: options?.min_values ?? 1,
max_values: options?.max_values ?? 1,
disabled: options?.disabled ?? false,
default_values: options?.default_values ?? [],
});
export interface SectionOptions {
components: Array<TextDisplayComponent>;
accessory: Button | ThumbnailComponent;
}
export const createSection = (accessory: Button | ThumbnailComponent, ...components: Array<TextDisplayComponent>): SectionComponent => ({
type: Constants.ComponentTypes.SECTION,
accessory,
components,
});
/**
* Creates a text display component where the text will be displayed similar to a message: supports markdown
* @param content The text content to display.
* @returns The created text display component.
*/
export const createTextDisplay = (content: string) => ({
type: Constants.ComponentTypes.TEXT_DISPLAY,
content,
});
export interface ThumbnailOptions {
media: {
url: string; // Supports arbitrary urls and attachment://<filename> references
};
description?: string;
spoiler?: boolean;
}
export const createThumbnail = (url: string, description?: string, spoiler?: boolean): ThumbnailComponent => ({
type: Constants.ComponentTypes.THUMBNAIL,
media: {
url,
},
description,
spoiler,
});
export interface MediaItem {
url: string; // Supports arbitrary urls and attachment://<filename> references
description?: string;
spoiler?: boolean;
}
export const createMediaGallery = (...items: MediaItem[]): MediaGalleryComponent => ({
type: Constants.ComponentTypes.MEDIA_GALLERY,
items: items.map((item) => ({
type: Constants.ComponentTypes.FILE,
media: { url: item.url },
description: item.description,
spoiler: item.spoiler,
})),
});
export interface FileOptions {
url: string; // Supports only attachment://<filename> references
spoiler?: boolean;
}
export const createFile = (url: string, spoiler?: boolean): FileComponent => ({
type: Constants.ComponentTypes.FILE,
file: {
url,
},
spoiler,
});
export enum Padding {
SMALL = 1,
LARGE = 2,
}
export interface SeparatorOptions {
divider?: boolean;
spacing?: Padding;
}
export const createSeparator = (spacing?: Padding, divider?: boolean): SeparatorComponent => ({
type: Constants.ComponentTypes.SEPARATOR,
divider,
spacing: spacing ?? Padding.SMALL,
});
export interface ContainerOptions {
accent_color?: number;
spoiler?: boolean;
}
export type ContainerItems =
| ActionRow
| TextDisplayComponent
| SectionComponent
| MediaGalleryComponent
| SeparatorComponent
| FileComponent;
export const createContainer = (options: ContainerOptions, ...components: ContainerItems[]): ContainerComponent => ({
type: Constants.ComponentTypes.CONTAINER,
...options,
components,
});
export const createModalLabel = (label: string, component: TextInput | StringSelectMenu): LabelComponent => ({
type: Constants.ComponentTypes.LABEL,
label,
component,
});

View File

@@ -0,0 +1,23 @@
import {
Constants,
type ComponentBase,
type ModalSubmitInteractionDataLabelComponent,
type ModalSubmitInteractionDataStringSelectComponent,
type ModalSubmitInteractionDataTextInputComponent,
} from '@projectdysnomia/dysnomia';
export function isModalLabel(component: ComponentBase): component is ModalSubmitInteractionDataLabelComponent {
return component.type === Constants.ComponentTypes.LABEL;
}
export function isModalTextInput(component: ComponentBase): component is ModalSubmitInteractionDataTextInputComponent {
return component.type === Constants.ComponentTypes.TEXT_INPUT;
}
export function isModalSelect(component: ComponentBase): component is ModalSubmitInteractionDataStringSelectComponent {
return component.type === Constants.ComponentTypes.STRING_SELECT;
}
export function componentHasIdPrefix(component: ComponentBase, prefix: string): boolean {
return (isModalTextInput(component) || isModalSelect(component)) && component.custom_id.startsWith(prefix);
}

View File

@@ -0,0 +1,2 @@
export * from './helpers';
export * from './builders';

View File

@@ -0,0 +1,54 @@
import { importCommands, initializeCommandHandling, registerCommands } from '../commands';
import { Client } from '@projectdysnomia/dysnomia';
import kv, { asyncKV } from '@/util/kv.js';
import type { KVStore } from './kv-store.type.ts.ts';
import type { Cache } from './cache.type.ts';
export interface DiscordBotOptions {
token?: string;
intents?: number[];
commandPattern?: string;
commandBaseDir?: string;
keyStore?: KVStore;
cache?: Cache;
onError?: (error: Error) => void;
onReady?: () => void;
}
export function startDiscordBot({
token = process.env.DISCORD_BOT_TOKEN || '',
intents = [],
commandPattern = '**/*.command.{js,ts}',
commandBaseDir = 'src',
keyStore = asyncKV,
cache = kv,
onError,
onReady,
}: DiscordBotOptions = {}): Client {
const client = new Client(`Bot ${token}`, {
gateway: {
intents,
},
});
client.on('ready', async () => {
console.debug(`Logged in as ${client.user?.username}#${client.user?.discriminator}`);
onReady?.();
const commands = await importCommands(commandPattern, commandBaseDir);
await registerCommands(
client,
Object.values(commands).map((cmd) => cmd.definition),
);
initializeCommandHandling(commands, { client, cache, kv: keyStore });
console.debug('Bot is ready and command handling is initialized.');
});
client.on('error', (error) => {
console.error('An error occurred:', error);
onError?.(error);
});
client.connect().catch(console.error);
return client;
}

View File

@@ -0,0 +1,6 @@
export interface Cache {
get: <T>(key: string) => T | undefined;
set: <T>(key: string, value: T, ttl?: number | string) => boolean;
del: (key: string | string[]) => number;
has: (key: string) => boolean;
}

View File

@@ -0,0 +1,3 @@
export * from './bot.ts';
export * from './cache.type.ts';
export * from './kv-store.type.ts.ts';

View File

@@ -0,0 +1,7 @@
export interface KVStore {
get: <T>(key: string) => Promise<T | undefined>;
set: (key: string, value: any) => Promise<boolean>;
delete: (key: string) => Promise<number>;
has: (key: string) => Promise<boolean>;
clear: () => Promise<void>;
}

View File

@@ -0,0 +1,6 @@
export * from './locales';
export * from './commands';
export * from './core';
export * from './jsx';
export * from './components';
export * from './pages';

View File

@@ -0,0 +1,7 @@
export function createElement(tag: string, attrs: Record<string, any> = {}, ...children: any[]) {
return {
tag,
attrs,
children,
};
}

View File

@@ -0,0 +1,2 @@
export * from './parser';
export * from './createElement';

View File

@@ -0,0 +1,10 @@
import { describe, it, expect } from 'bun:test';
import { parseJSDFile } from './parser_new';
import path from 'node:path';
describe('parseJSDFile', () => {
it('should parse a JSD file', async () => {
const result = await parseJSDFile(path.join(__dirname, '../../fixtures/jsd/test.tsd'));
expect(result).toEqual(true);
});
});

View File

@@ -0,0 +1,97 @@
import fs from 'node:fs/promises';
import parse, { type DOMNode } from 'html-dom-parser';
import type { ChildNode } from 'domhandler';
const JSD_STRING = /\(\s*(<.*)>\s*\)/gs;
export async function parseJSDFile(filename: string) {
const content = (await fs.readFile(filename)).toString();
const matches = JSD_STRING.exec(content);
if (matches) {
let html = matches[1] + '>';
const root = parse(html);
const translated = translate(root[0]);
const str = content.replace(matches[1] + '>', translated);
await fs.writeFile(filename.replace('.tsd', '.ts'), str);
}
return true;
}
interface state {
inInterpolation?: boolean;
children?: string[][];
parent?: Text[];
}
function translate(root: DOMNode | ChildNode | null, state: state = {}): string | null {
if (!root || typeof root !== 'object') return null;
let children = [];
if ('children' in root && Array.isArray(root.children) && root.children.length > 0) {
for (const child of root.children) {
const translated = translate(child, state);
if (translated) {
if (state.inInterpolation && state.parent[state.children.length - 1] === child) {
state.children[state.children.length - 1].push(translated);
} else {
children.push(translated);
}
}
}
}
if ('nodeType' in root && root.nodeType === 3) {
if (root.data.trim() === '') return null;
return parseText(root.data.trim(), state, root);
}
if ('name' in root && root.name) {
let tagName = root.name || 'unknown';
let attrs = 'attribs' in root ? root.attribs : {};
return `StarKitten.createElement("${tagName}", ${JSON.stringify(attrs)}${children.length > 0 ? ', ' + children.join(', ') : ''})`;
}
}
const JSD_INTERPOLATION = /\{(.+)\}/gs;
const JSD_START_EXP_INTERPOLATION = /\{(.+)\(/gs;
const JSD_END_EXP_INTERPOLATION = /\)(.+)\}/gs;
function parseText(text: string, state: state = {}, parent: Text = {}): string {
let interpolations = text.match(JSD_INTERPOLATION);
if (!interpolations) {
if (text.match(JSD_START_EXP_INTERPOLATION)) {
state.inInterpolation = true;
state.children = state.children || [[]];
state.parent = state.parent || [];
state.parent.push(parent);
return text.substring(1, text.length - 1);
} else if (text.match(JSD_END_EXP_INTERPOLATION)) {
const combined = state.children?.[state.children.length - 1].join(' ');
state.children?.[state.children.length - 1].splice(0);
state.children?.pop();
state.parent?.pop();
if (state.children.length === 0) {
state.inInterpolation = false;
return combined + ' ' + text.substring(1, text.length - 1);
}
}
return `"${text}"`;
} else {
text = replaceInterpolations(text);
return `"${text}"`;
}
}
function replaceInterpolations(text: string, isOnJSON: boolean = false) {
let interpolations = null;
while ((interpolations = JSD_INTERPOLATION.exec(text))) {
if (isOnJSON) {
text = text.replace(`"{${interpolations[1]}}"`, interpolations[1]);
} else {
text = text.replace(`{${interpolations[1]}}`, `"+ ${interpolations[1]} +"`);
}
}
return text;
}

View File

@@ -0,0 +1,101 @@
import fs from 'node:fs/promises';
import * as acorn from 'acorn';
import jsx from 'acorn-jsx';
const JSD_STRING = /\(\s*(<.*)>\s*\)/gs;
const parser = acorn.Parser.extend(jsx());
export async function parseJSDFile(filename: string) {
const content = (await fs.readFile(filename)).toString();
const matches = JSD_STRING.exec(content);
if (matches) {
const jsxc = matches[1] + '>';
const ast = parser.parse(jsxc, { ecmaVersion: 2020, sourceType: 'module' });
const translated = traverseJSX((ast.body[0] as any).expression);
const str = content.replace(matches[1] + '>', translated);
await fs.writeFile(filename.replace('.tsd', '.ts'), str);
}
return true;
}
function traverseJSX(node: any): string {
if (node.type === 'JSXElement') {
const tag = node.openingElement.name.name;
const attrs: Record<string, any> = {};
for (const attr of node.openingElement.attributes) {
if (attr.type === 'JSXAttribute') {
const name = attr.name.name;
const value = attr.value;
if (value.type === 'Literal') {
attrs[name] = value.value;
} else if (value.type === 'JSXExpressionContainer') {
attrs[name] = `{${generateCode(value.expression)}}`;
} else if (value) {
attrs[name] = value.raw;
}
}
}
const children = [];
for (const child of node.children) {
const translated = traverseJSX(child);
if (translated) {
children.push(translated);
}
}
return `StarKitten.createElement("${tag}", ${JSON.stringify(attrs)}${children.length > 0 ? ', ' + children.join(', ') : ''})`;
} else if (node.type === 'JSXExpressionContainer') {
const expr = generateCode(node.expression);
if (node.expression.type === 'TemplateLiteral' || (node.expression.type === 'Literal' && typeof node.expression.value === 'string')) {
return `""+ ${expr} +""`;
} else {
return expr;
}
} else if (node.type === 'JSXText') {
const text = node.value.trim();
if (text) {
return `"${text}"`;
}
}
return '';
}
function generateCode(node: any): string {
if (node.type === 'JSXElement') {
return traverseJSX(node);
} else if (node.type === 'Identifier') {
return node.name;
} else if (node.type === 'Literal') {
return JSON.stringify(node.value);
} else if (node.type === 'TemplateLiteral') {
const quasis = node.quasis.map((q: any) => q.value.raw);
const expressions = node.expressions.map((e: any) => generateCode(e));
let result = quasis[0];
for (let i = 0; i < expressions.length; i++) {
result += '${' + expressions[i] + '}' + quasis[i + 1];
}
return '`' + result + '`';
} else if (node.type === 'MemberExpression') {
const op = node.optional ? '?.' : '.';
return generateCode(node.object) + op + (node.computed ? '[' + generateCode(node.property) + ']' : generateCode(node.property));
} else if (node.type === 'OptionalMemberExpression') {
return generateCode(node.object) + '?.' + (node.computed ? '[' + generateCode(node.property) + ']' : generateCode(node.property));
} else if (node.type === 'CallExpression') {
return generateCode(node.callee) + '(' + node.arguments.map((a: any) => generateCode(a)).join(', ') + ')';
} else if (node.type === 'BinaryExpression') {
return generateCode(node.left) + ' ' + node.operator + ' ' + generateCode(node.right);
} else if (node.type === 'ConditionalExpression') {
return generateCode(node.test) + ' ? ' + generateCode(node.consequent) + ' : ' + generateCode(node.alternate);
} else if (node.type === 'LogicalExpression') {
return generateCode(node.left) + ' ' + node.operator + ' ' + generateCode(node.right);
} else if (node.type === 'UnaryExpression') {
return node.operator + generateCode(node.argument);
} else if (node.type === 'ObjectExpression') {
return '{' + node.properties.map((p: any) => generateCode(p.key) + ': ' + generateCode(p.value)).join(', ') + '}';
} else if (node.type === 'ArrayExpression') {
return '[' + node.elements.map((e: any) => generateCode(e)).join(', ') + ']';
} else {
return node.raw || node.name || 'unknown';
}
}

View File

@@ -0,0 +1,5 @@
import { createActionRow } from '@/discord/components';
export function ActionRow(props: { children: any | any[] }) {
return createActionRow(...(Array.isArray(props.children) ? props.children : [props.children]));
}

View File

@@ -0,0 +1,6 @@
import { createButton, type ButtonStyle } from '@/discord/components';
import type { PartialEmoji } from '@projectdysnomia/dysnomia';
export function Button(props: { label: string; customId: string; style: ButtonStyle; emoji?: PartialEmoji; disabled?: boolean }) {
return createButton(props.label, props.customId, { style: props.style, emoji: props.emoji, disabled: props.disabled });
}

View File

@@ -0,0 +1,8 @@
import { createContainer } from '@/discord/components';
export function Container(props: { accent?: number; spoiler?: boolean; children: any | any[] }) {
return createContainer(
{ accent_color: props.accent, spoiler: props.spoiler },
...(Array.isArray(props.children) ? props.children : [props.children]),
);
}

View File

@@ -0,0 +1,4 @@
export * from './action-row';
export * from './button';
export * from './container';
export * from './text-display';

View File

@@ -0,0 +1,5 @@
import { createTextDisplay } from '@/discord/components/builders';
export function TextDisplay(props: { content: string }) {
return createTextDisplay(props.content);
}

View File

@@ -0,0 +1,3 @@
export * from './components';
export * from './jsx';
export * from './runtime';

View File

@@ -0,0 +1 @@
export { jsxDEV } from './runtime';

View File

@@ -0,0 +1 @@
export { jsx } from './runtime';

View File

@@ -0,0 +1,69 @@
import {
type ActionRow,
type Button,
type ChannelSelectMenu,
type MentionableSelectMenu,
type PartialEmoji,
type RoleSelectMenu,
type StringSelectMenu,
type TextInput,
type UserSelectMenu,
type LabelComponent,
type ContainerComponent,
type TextDisplayComponent,
type SectionComponent,
type MediaGalleryComponent,
type SeparatorComponent,
type FileComponent,
type InteractionButton,
type URLButton,
type PremiumButton,
type ThumbnailComponent,
} from '@projectdysnomia/dysnomia';
export type Component =
| ActionRow
| Button
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu
| TextInput
| LabelComponent
| ContainerComponent
| TextDisplayComponent
| SectionComponent
| MediaGalleryComponent
| SeparatorComponent
| FileComponent
| InteractionButton
| URLButton
| PremiumButton
| ThumbnailComponent;
export type Element = Component | Promise<Component>;
export interface ElementClass {
render: any;
}
export interface ElementAttributesProperty {
props: {};
}
export interface IntrinsicElements {
// Allow any element, but prefer known elements
// [elemName: string]: any;
// Known elements (forcing re-parse)
actionRow: { children: any | any[] };
button: {
label: string;
customId: string;
style: number;
emoji?: PartialEmoji;
disabled?: boolean;
};
container: { color?: string; accent?: number; spoiler?: boolean; children: any | any[] };
textDisplay: { content: string };
}

View File

@@ -0,0 +1,52 @@
import { ActionRow } from './components/action-row';
import { Button } from './components/button';
import { Container } from './components/container';
import { TextDisplay } from './components/text-display';
const intrinsicComponentMap: Record<string, (props: any) => any> = {
actionRow: ActionRow,
button: Button,
container: Container,
textDisplay: TextDisplay,
};
export function jsx(type: any, props: Record<string, any>) {
console.log('JSX', type, props);
if (typeof type === 'function') {
return type(props);
}
if (typeof type === 'string' && intrinsicComponentMap[type]) {
return intrinsicComponentMap[type](props);
}
return {
type,
props,
};
}
export function jsxDEV(
type: any,
props: Record<string, any>,
key: string | number | symbol,
isStaticChildren: boolean,
source: any,
self: any,
) {
console.log('JSX DEV', type, props);
if (typeof type === 'function') {
return type(props);
}
if (typeof type === 'string' && intrinsicComponentMap[type]) {
return intrinsicComponentMap[type](props);
}
return {
type,
props: { ...props, key },
_source: source,
_self: self,
};
}

View File

@@ -0,0 +1,8 @@
import type { Component, IntrinsicElements as StarKittenIntrinsicElements } from './jsx';
declare global {
namespace JSX {
type Element = Component;
interface IntrinsicElements extends StarKittenIntrinsicElements {}
}
}

View File

@@ -0,0 +1,26 @@
export type Locales = 'en' | 'ru' | 'de' | 'fr' | 'ja' | 'es' | 'zh' | 'ko';
export const ALL_LOCALES: Locales[] = ['en', 'ru', 'de', 'fr', 'ja', 'es', 'zh', 'ko'];
export const DEFAULT_LOCALE: Locales = 'en';
export const LOCALE_NAMES: { [key in Locales]: string } = {
en: 'English',
ru: 'Русский',
de: 'Deutsch',
fr: 'Français',
ja: '日本語',
es: 'Español',
zh: '中文',
ko: '한국어',
};
export function toDiscordLocale(locale: Locales): string {
switch (locale) {
case 'en': return 'en-US';
case 'ru': return 'ru';
case 'de': return 'de';
case 'fr': return 'fr';
case 'ja': return 'ja';
case 'es': return 'es-ES';
case 'zh': return 'zh-CN';
case 'ko': return 'ko';
default: return 'en-US';
}
}

View File

@@ -0,0 +1,2 @@
export * from './pages';
export * from './subroutes';

View File

@@ -0,0 +1,167 @@
import { isAutocomplete, isMessageComponent, isModalSubmit, isPing, type CommandContext } from '@/discord/commands';
import {
Constants,
type InteractionContentEdit,
type InteractionModalContent,
type CommandInteraction,
type ComponentInteraction,
type ModalSubmitInteraction,
Interaction,
} from '@projectdysnomia/dysnomia';
export type PagesInteraction = CommandInteraction | ModalSubmitInteraction | ComponentInteraction;
export enum PageType {
MODAL = 'modal',
MESSAGE = 'message',
FOLLOWUP = 'followup',
}
export interface Page<T> {
key: string;
type?: PageType; // defaults to MESSAGE
followUpFlags?: number;
render: (
ctx: PageContext<T>,
) => (InteractionModalContent | InteractionContentEdit) | Promise<InteractionModalContent | InteractionContentEdit>;
}
export interface PagesOptions<T> {
pages: Record<string, Page<T>>;
initialPage?: string;
timeout?: number; // in seconds
ephemeral?: boolean; // whether the initial message should be ephemeral
useEmbeds?: boolean; // will not enable components v2
initialStateData?: T; // initial state to merge with default state
router?: (ctx: PageContext<T>) => string; // function to determine the next page key
}
export interface PageState<T> {
currentPage: string;
timeoutAt: number; // timestamp in ms
lastInteractionAt?: number; // timestamp in ms
messageId?: string;
channelId?: string;
data: T;
}
export interface PageContext<T> {
state: PageState<T>;
custom_id: string; // current interaction custom_id
interaction: PagesInteraction;
goToPage: (pageKey: string) => Promise<InteractionContentEdit>;
}
function createPageContext<T>(interaction: PagesInteraction, options: PagesOptions<T>, state: PageState<T>): PageContext<T> {
return {
state,
interaction,
custom_id: 'custom_id' in interaction.data ? interaction.data.custom_id : options.initialPage ?? 'root',
goToPage: (pageKey: string) => {
const page = options.pages[pageKey];
this.state.currentPage = pageKey;
if (!page) {
throw new Error(`Page with key "${pageKey}" not found`);
}
return page.render(createPageContext(interaction, options, { ...state, currentPage: pageKey })) as Promise<InteractionContentEdit>;
},
};
}
function defaultPageState<T>(options: PagesOptions<T>): PageState<T> {
const timeoutAt = options.timeout ? Date.now() + options.timeout * 1000 : Infinity;
return {
currentPage: options.initialPage ?? options.pages[0].key,
timeoutAt,
lastInteractionAt: Date.now(),
data: options.initialStateData ?? ({} as T),
};
}
function getPageState<T>(options: PagesOptions<T>, cmdCtx: CommandContext & { state: { __pageState?: PageState<T> } }) {
const cmdState = cmdCtx.state;
if ('__pageState' in cmdState && cmdState.__pageState) {
return cmdState.__pageState as PageState<T>;
}
cmdState.__pageState = defaultPageState(options);
return cmdState.__pageState as PageState<T>;
}
function validateOptions<T>(options: PagesOptions<T>) {
const keys = Object.keys(options.pages);
const uniqueKeys = new Set(keys);
if (uniqueKeys.size !== keys.length) {
throw new Error('Duplicate page keys found');
}
}
function getFlags(options: PagesOptions<any>) {
let flags = 0;
if (options.ephemeral) {
flags |= Constants.MessageFlags.EPHEMERAL;
}
if (!options.useEmbeds) {
flags |= Constants.MessageFlags.IS_COMPONENTS_V2;
}
return flags;
}
export async function usePages<T>(options: PagesOptions<T>, interaction: Interaction, cmdCtx: CommandContext) {
if (isAutocomplete(interaction) || isPing(interaction)) {
throw new Error('usePages cannot be used with autocomplete or ping interactions');
}
const pagesInteraction = interaction as PagesInteraction;
validateOptions(options);
const pageState = getPageState(options, cmdCtx);
const pageContext = createPageContext(pagesInteraction, options, pageState);
const pageKey = options.router
? options.router(pageContext)
: pageContext.custom_id ?? options.initialPage ?? Object.keys(options.pages)[0];
// if we have subroutes, we only want the main route from the page key
const page = options.pages[pageKey.split(':')[0]] ?? options.pages[0];
pageContext.state.currentPage = page.key;
if (page.type === PageType.MODAL && !isModalSubmit(pagesInteraction)) {
// we don't defer modals and can't respond to a modal with a modal.
const cnt = page.render(pageContext);
const content = isPromise(cnt) ? await cnt : cnt;
return await pagesInteraction.createModal(content as InteractionModalContent);
}
if (page.type === PageType.FOLLOWUP) {
if (!pageState.messageId) {
throw new Error('Cannot send a followup message before an initial message has been sent');
}
const flags = page.type === PageType.FOLLOWUP ? page.followUpFlags ?? getFlags(options) : getFlags(options);
await pagesInteraction.defer(flags);
const cnt = page.render(pageContext);
const content = isPromise(cnt) ? await cnt : cnt;
return await pagesInteraction.createFollowup({
flags,
...(content as InteractionContentEdit),
});
}
if (pageState.messageId && isMessageComponent(pagesInteraction)) {
await pagesInteraction.deferUpdate();
const cnt = page.render(pageContext);
const content = isPromise(cnt) ? await cnt : cnt;
return await pagesInteraction.editMessage(pageState.messageId, content as InteractionContentEdit);
}
{
await pagesInteraction.defer(getFlags(options));
const cnt = page.render(pageContext);
const content = isPromise(cnt) ? await cnt : cnt;
const message = await pagesInteraction.createFollowup({
flags: getFlags(options),
...(content as InteractionContentEdit),
});
pageState.messageId = message.id;
pageState.channelId = message.channel?.id;
return message;
}
}
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as Promise<T>)?.then === 'function';
}

View File

@@ -0,0 +1,99 @@
import type { PartialEmoji } from '@projectdysnomia/dysnomia';
import { createActionRow, createButton, createMediaGallery, type ButtonOptions, type ContainerItems } from '@/discord/components';
import type { PageContext } from './pages';
export function getSubrouteKey(prefix: string, subroutes: string[]) {
return `${prefix}:${subroutes.join(':')}`;
}
export function parseSubrouteKey(key: string, expectedPrefix: string, expectedLength: number, defaults: string[] = []) {
const parts = key.split(':');
if (parts[0] !== expectedPrefix) {
throw new Error(`Unexpected prefix: ${parts[0]}`);
}
if (parts.length - 1 < expectedLength && defaults.length) {
// fill in defaults
parts.push(...defaults.slice(parts.length - 1));
}
if (parts.length !== expectedLength + 1) {
throw new Error(`Expected ${expectedLength} subroutes, but got ${parts.length - 1}`);
}
return parts.slice(1);
}
export function renderSubrouteButtons(
currentSubroute: string,
subRoutes: string[],
subrouteIndex: number,
prefix: string,
subroutes: { label: string; value: string; emoji?: PartialEmoji }[],
options?: Partial<ButtonOptions>,
) {
return subroutes
.filter((sr) => sr !== undefined)
.map(({ label, value, emoji }) => {
const routes = [...subRoutes];
routes[subrouteIndex] = currentSubroute == value ? '_' : value;
return createButton(label, getSubrouteKey(prefix, routes), {
...options,
disabled: value === currentSubroute,
emoji,
});
});
}
export interface SubrouteOptions {
label: string;
value: string;
emoji?: PartialEmoji;
}
export function renderSubroutes<T, CType = ContainerItems>(
context: PageContext<T>,
prefix: string,
subroutes: (SubrouteOptions & {
banner?: string;
actionRowPosition?: 'top' | 'bottom';
})[][],
render: (currentSubroute: string, ctx: PageContext<T>) => CType,
btnOptions?: Partial<ButtonOptions>,
defaultSubroutes?: string[], // if not provided, will use the first option of each subroute
): CType[] {
const currentSubroutes = parseSubrouteKey(
context.custom_id,
prefix,
subroutes.length,
defaultSubroutes || subroutes.map((s) => s[0].value),
);
const components = subroutes
.filter((sr) => sr.length > 0)
.map((srOpts, index) => {
const opts = srOpts.filter((sr) => sr !== undefined);
if (opts.length === 0) return undefined;
// find the current subroute, or default to the first
const sri = opts.findIndex((s) => s.value === currentSubroutes[index]);
const current = opts[sri] || opts[0];
const components = [];
const actionRow = createActionRow(...renderSubrouteButtons(current.value, currentSubroutes, index, prefix, opts, btnOptions));
if (current.banner) {
components.push(createMediaGallery({ url: current.banner }));
}
if (!current.actionRowPosition || current.actionRowPosition === 'top') {
components.push(actionRow);
}
components.push(render(current.value, context));
if (current.actionRowPosition === 'bottom') {
components.push(actionRow);
}
return components;
})
.flat()
.filter((c) => c !== undefined);
return components;
}

View File

@@ -0,0 +1,30 @@
import { esiFetch } from './fetch';
// PUBLIC APIS ---------------------------------------------------------------
interface AllianceData {
creator_corporation_id: number;
creator_id: number;
date_founded: string;
executor_corporation_id: number;
faction_id: number;
name: string;
ticker: string;
}
export async function getAllianceData(id: number) {
return await esiFetch<Partial<AllianceData>>(`/alliances/${id}/`);
}
export async function getAllianceCorporations(id: number) {
return await esiFetch<number[]>(`/alliances/${id}/corporations/`);
}
interface AllianceIcons {
px128x128: string;
px64x64: string;
}
export async function getAllianceIcons(id: number) {
return await esiFetch<Partial<AllianceIcons>>(`/alliances/${id}/icons/`);
}

View File

@@ -0,0 +1,102 @@
import { encodeBase64urlNoPadding } from '@oslojs/encoding';
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
import { jwtDecode } from 'jwt-decode';
import { options } from './options';
export interface EveTokens {
access_token: string;
expires_in: number;
refresh_token: string;
}
function generateState(): string {
const randomValues = new Uint8Array(32);
crypto.getRandomValues(randomValues);
return encodeBase64urlNoPadding(randomValues);
}
export async function createAuthorizationURL(scopes: string[] | string = 'publicData') {
const state = generateState();
const url = new URL('https://login.eveonline.com/v2/oauth/authorize/');
url.searchParams.set('response_type', 'code');
url.searchParams.set('redirect_uri', options.callback_url);
url.searchParams.set('client_id', options.client_id);
url.searchParams.set('state', state);
url.searchParams.set('scope', Array.isArray(scopes) ? scopes.join(' ') : scopes);
return {
url,
state,
};
}
export async function validateAuthorizationCode(code: string): Promise<EveTokens> {
try {
const response = await fetch('https://login.eveonline.com/v2/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${options.client_id}:${options.client_secret}`).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
}),
});
return (await response.json()) as EveTokens;
} catch (error) {
console.error(`failed to validate EVE authorization code`, error);
throw `${error}`;
}
}
// cache the public key for EVE Online's OAuth2 provider
let eveAuthPublicKey: any;
export async function validateToken(token: string) {
if (!eveAuthPublicKey) {
try {
const eveJWKS = (await (await fetch('https://login.eveonline.com/oauth/jwks')).json()) as { keys: any[] };
eveAuthPublicKey = jwkToPem(eveJWKS.keys[0]);
} catch (err) {
console.error(`failed to get EVE Auth public keys`, err);
}
}
try {
const decoded = jwt.verify(token, eveAuthPublicKey);
return decoded;
} catch (err) {
console.error(`failed to validate EVE token`, err);
return null;
}
}
export async function refresh(
{ refresh_token }: { refresh_token: string },
scopes?: string[] | string,
): Promise<EveTokens> {
const params = {
grant_type: 'refresh_token',
refresh_token,
scope: '' as string | string[],
};
if (scopes) {
params['scope'] = Array.isArray(scopes) ? scopes.join(' ') : scopes;
}
const response = await fetch('https://login.eveonline.com/v2/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(`${options.client_id}:${options.client_secret}`).toString('base64')}`,
},
body: new URLSearchParams(params),
});
return (await response.json()) as EveTokens;
}
export function characterIdFromToken(token: string) {
const payload = jwtDecode(token);
return parseInt(payload.sub!.split(':')[2]);
}

View File

@@ -0,0 +1,381 @@
import { CharacterHelper, type Character } from '@/eve/db';
import { esiFetch } from './fetch';
import { tokenHasScopes } from './scopes';
// PUBLIC APIS ---------------------------------------------------------------
export interface CharacterData {
alliance_id: number;
birthday: string;
bloodline_id: number;
corporation_id: number;
description: string;
faction_id: number;
gender: 'male' | 'female';
name: string;
race_id: number;
security_status: number;
title: string;
}
export function getCharacterPublicData(id: number) {
return esiFetch<Partial<CharacterData>>(`/characters/${id}/`);
}
export interface CharacterAffiliations {
alliance_id: number;
character_id: number;
corporation_id: number;
faction_id: number;
}
export function getCharacterAffiliations(ids: number[]) {
return esiFetch<Partial<CharacterAffiliations>[]>(`/characters/affiliation/`, undefined, {
method: 'POST',
body: JSON.stringify(ids),
})[0] as Partial<CharacterAffiliations>;
}
export interface CharacterPortraits {
px128x128: string;
px256x256: string;
px512x512: string;
px64x64: string;
}
export function getCharacterPortraits(id: number) {
return esiFetch<Partial<CharacterPortraits>>(`/characters/${id}/portrait/`);
}
export interface CharacterCorporationHistory {
corporation_id: number;
is_deleted: boolean;
record_id: number;
start_date: string;
}
export function getCharacterCorporationHistory(id: number) {
return esiFetch<Partial<CharacterCorporationHistory>[]>(`/characters/${id}/corporationhistory/`);
}
export function getPortraitURL(id: number) {
return `https://images.evetech.net/characters/${id}/portrait`;
}
// PRIVATE APIS --------------------------------------------------------------
export interface CharacterRoles {
roles: string[];
roles_at_base: string[];
roles_at_hq: string[];
roles_at_other: string[];
}
// required scope: esi-characters.read_corporation_roles.v1
export function getCharacterRoles(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_corporation_roles.v1')) return null;
return esiFetch<Partial<CharacterRoles>>(`/characters/${character.eveID}/roles/`, character);
}
export interface CharacterTitles {
titles: {
name: string;
title_id: number;
}[];
}
// required scope: esi-characters.read_titles.v1
export function getCharacterTitles(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_titles.v1')) return null;
return esiFetch<Partial<CharacterTitles>>(`/characters/${character.eveID}/titles/`, character);
}
export interface CharacterStandings {
from_id: number;
from_type: 'agent' | 'npc_corp' | 'faction';
standing: number;
}
// required scope: esi-characters.read_standings.v1
export function getCharacterStandings(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_standings.v1')) return null;
return esiFetch<Partial<CharacterStandings>[]>(`/characters/${character.eveID}/standings/`, character);
}
export interface Notification {
is_read: boolean;
sender_id: number;
sender_type: 'character' | 'corporation' | 'alliance' | 'faction' | 'system';
text: string;
timestamp: string;
type:
| 'character'
| 'corporation'
| 'alliance'
| 'faction'
| 'inventory'
| 'industry'
| 'loyalty'
| 'skills'
| 'sov'
| 'structures'
| 'war';
}
// required scope: esi-characters.read_notifications.v1
export function getCharacterNotifications(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_notifications.v1')) return null;
return esiFetch<Partial<Notification>[]>(`/characters/${character.eveID}/notifications/`, character);
}
export interface ContactNotification {
message: string;
notification_id: number;
send_date: string;
sender_character_id: number;
standing_level: number;
}
// required scope: esi-characters.read_notifications.v1
export function getCharacterContactNotifications(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_notifications.v1')) return null;
return esiFetch<Partial<ContactNotification>[]>(`/characters/${character.eveID}/notifications/contacts`, character);
}
export interface Medals {
corporation_id: number;
date: string;
description: string;
graphics: {
color: number;
graphic: number;
layer: number;
part: number;
}[];
issuer_id: number;
medal_id: number;
reason: string;
status: 'private' | 'public';
title: string;
}
// required scope: esi-characters.read_medals.v1
export function getCharacterMedals(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_medals.v1')) return null;
return esiFetch<Partial<Medals>[]>(`/characters/${character.eveID}/medals/`, character);
}
export interface JumpFatigue {
jump_fatigue_expire_date: string;
last_jump_date: string;
last_update_date: string;
}
// required scope: esi-characters.read_fatigue.v1
export function getCharacterJumpFatigue(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_fatigue.v1')) return null;
return esiFetch<Partial<JumpFatigue>>(`/characters/${character.eveID}/fatigue/`, character);
}
export interface Blueprint {
item_id: number;
location_flag: string;
location_id: number;
material_efficiency: number;
quantity: number;
runs: number;
time_efficiency: number;
type_id: number;
}
// required scope: esi-characters.read_blueprints.v1
export function getCharacterBlueprints(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_blueprints.v1')) return null;
return esiFetch<Partial<Blueprint>[]>(`/characters/${character.eveID}/blueprints/`, character);
}
export interface AgentResearch {
agent_id: number;
points_per_day: number;
remainder_points: number;
skill_type_id: number;
started_at: string;
}
// required scope: esi-characters.read_agents_research.v1
export function getCharacterAgentResearch(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-characters.read_agents_research.v1')) return null;
return esiFetch<Partial<AgentResearch>[]>(`/characters/${character.eveID}/agents_research/`, character);
}
// CLONES --------------------------------------------------------------------
export interface Clones {
home_location: {
location_id: number;
location_type: 'station' | 'structure';
};
jump_clones: {
implants: number[];
jump_clone_id: number;
location_id: number;
location_type: 'station' | 'structure';
name: string;
}[];
last_clone_jump_date: string;
last_station_change_date: string;
}
// required scope: esi-clones.read_clones.v1
export function getCharacterClones(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-clones.read_clones.v1')) return null;
return esiFetch<Partial<Clones>>(`/characters/${character.eveID}/clones/`, character);
}
// required scope: esi-clones.read_implants.v1
export function getCharacterImplants(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-clones.read_implants.v1')) return null;
return esiFetch<number[]>(`/characters/${character.eveID}/implants/`, character);
}
// ASSETS --------------------------------------------------------------------
export interface Asset {
is_blueprint_copy: boolean;
is_singleton: boolean;
item_id: number;
location_flag: string;
location_id: number;
location_type: 'station' | 'solar_system' | 'other';
quantity: number;
type_id: number;
}
// required scope: esi-assets.read_assets.v1
export function getCharacterAssets(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-assets.read_assets.v1')) return null;
return esiFetch<Partial<Asset>[]>(`/characters/${character.eveID}/assets/`, character);
}
export interface AssetLocation {
item_id: number;
position: {
x: number;
y: number;
z: number;
};
}
// required scope: esi-assets.read_assets.v1
export function getCharacterAssetLocations(character: Character, ids: number[]) {
if (!CharacterHelper.hasScope(character, 'esi-assets.read_assets.v1')) return null;
return esiFetch<Partial<AssetLocation>[]>(`/characters/${character.eveID}/assets/locations/`, character, {
method: 'POST',
body: JSON.stringify(ids),
});
}
export interface AssetNames {
item_id: number;
name: string;
}
// required scope: esi-assets.read_assets.v1
export function getCharacterAssetNames(character: Character, ids: number[]) {
if (!CharacterHelper.hasScope(character, 'esi-assets.read_assets.v1')) return null;
return esiFetch<Partial<AssetNames>[]>(`/characters/${character.eveID}/assets/names/`, character, {
method: 'POST',
body: JSON.stringify(ids),
});
}
// WALLET --------------------------------------------------------------------
// required scope: esi-wallet.read_character_wallet.v1
export function getCharacterWallet(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-wallet.read_character_wallet.v1')) return null;
return esiFetch<number>(`/characters/${character.eveID}/wallet/`, character);
}
export interface WalletTransaction {
client_id: number;
date: string;
is_buy: boolean;
is_personal: boolean;
journal_ref_id: number;
location_id: number;
quantity: number;
transaction_id: number;
type_id: number;
unit_price: number;
}
// required scope: esi-wallet.read_character_wallet.v1
export function getCharacterWalletTransactions(character: Character, fromId: number) {
if (!CharacterHelper.hasScope(character, 'esi-wallet.read_character_wallet.v1')) return null;
return esiFetch<Partial<WalletTransaction>[]>(`/characters/${character.eveID}/wallet/transactions/`, character, {
method: 'POST',
body: JSON.stringify(fromId),
});
}
export interface WalletJournalEntry {
amount: number; // The amount of ISK given or taken from the wallet as a result of the given transaction. Positive when ISK is deposited into the wallet and negative when ISK is withdrawn
balance: number; // Wallet balance after transaction occurred
context_id: number; // And ID that gives extra context to the particualr transaction. Because of legacy reasons the context is completely different per ref_type and means different things. It is also possible to not have a context_id
context_id_type: 'character' | 'corporation' | 'alliance' | 'faction'; // The type of the given context_id if present
date: string; // Date and time of transaction
description: string;
first_party_id: number;
id: number;
reason: string;
ref_type: 'agent' | 'assetSafety' | 'bounty' | 'bountyPrizes' | 'contract' | 'dividend' | 'marketTransaction' | 'other';
second_party_id: number;
tax: number;
tax_receiver_id: number;
}
// required scope: esi-wallet.read_character_wallet.v1
export function getCharacterWalletJournal(character: Character, page: number = 1) {
if (!CharacterHelper.hasScope(character, 'esi-wallet.read_character_wallet.v1')) return null;
return esiFetch<Partial<WalletJournalEntry>[]>(`/characters/${character.eveID}/wallet/journal/?page=${page}`, character);
}
// LOCATION --------------------------------------------------
export interface Location {
solar_system_id: number;
station_id: number;
structure_id: number;
}
// required scope: esi-location.read_location.v1
export function getCharacterLocation(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-location.read_location.v1')) return null;
return esiFetch<Partial<Location>>(`/characters/${character.eveID}/location/`, character);
}
export interface Online {
last_login: string;
last_logout: string;
logins: number;
online: boolean;
}
// required scope: esi-location.read_online.v1
export function getCharacterOnline(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-location.read_online.v1')) return null;
return esiFetch<Partial<Online>>(`/characters/${character.eveID}/online/`, character);
}
export interface CurrentShip {
ship_item_id: number;
ship_type_id: number;
ship_name: string;
}
// required scope: esi-location.read_ship_type.v1
export function getCharacterCurrentShip(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-location.read_ship_type.v1')) return null;
return esiFetch<Partial<CurrentShip>>(`/characters/${character.eveID}/ship/`, character);
}

View File

@@ -0,0 +1,97 @@
import { CharacterHelper, type Character } from '@/eve/db';
import { esiFetch } from './fetch';
// PUBLIC APIS ---------------------------------------------------------------
interface CorporationData {
alliance_id: number;
ceo_id: number;
creator_id: number;
date_founded: string;
description: string;
faction_id: number;
home_station_id: number;
member_count: number;
name: string;
shares: number;
tax_rate: number;
ticker: string;
url: string;
war_eligible: boolean;
}
export async function getCorporationData(id: number) {
return await esiFetch<Partial<CorporationData>>(`/corporations/${id}/`);
}
interface AllianceHistory {
alliance_id: number;
is_deleted: boolean;
record_id: number;
start_date: string;
}
export async function getCorporationAllianceHistory(id: number) {
return await esiFetch<Partial<AllianceHistory>[]>(`/corporations/${id}/alliancehistory/`);
}
interface CorporationIcons {
px256x256: string;
px128x128: string;
px64x64: string;
}
export async function getCorporationIcons(id: number) {
return await esiFetch<Partial<CorporationIcons>>(`/corporations/${id}/icons/`);
}
// ASSETS -------------------------------------------------------------------
export interface AssetData {
is_blueprint_copy: boolean;
is_singleton: boolean;
item_id: number;
location_flag: string;
location_id: number;
location_type: string;
quantity: number;
type_id: number;
}
// required scope: esi-assets.read_corporation_assets.v1
export async function getCorporationAssets(id: number, character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-assets.read_corporation_assets.v1')) return null;
return await esiFetch<Partial<AssetData>[]>(`/corporations/${id}/assets/`, character);
}
export interface AssetLocation {
item_id: number;
position: {
x: number;
y: number;
z: number;
};
}
// required scope: esi-assets.read_corporation_assets.v1
export async function getCorporationAssetLocations(id: number, character: Character, ids: number[]) {
if (!CharacterHelper.hasScope(character, 'esi-assets.read_corporation_assets.v1')) return null;
return await esiFetch<Partial<AssetLocation>[]>(`/corporations/${id}/assets/locations/`, character, {
method: 'POST',
body: JSON.stringify(ids),
});
}
export interface AssetNames {
item_id: number;
name: string;
}
// required scope: esi-assets.read_corporation_assets.v1
export async function getCorporationAssetNames(id: number, character: Character, ids: number[]) {
if (!CharacterHelper.hasScope(character, 'esi-assets.read_corporation_assets.v1')) return null;
return await esiFetch<Partial<AssetNames>[]>(`/corporations/${id}/assets/names/`, character, {
method: 'POST',
body: JSON.stringify(ids),
});
}

View File

@@ -0,0 +1,92 @@
import { type Character, CharacterHelper } from '@/eve/db/models';
import { options } from './options';
import { ESI_LATEST_URL } from './scopes';
const cache = new Map<string, CacheItem>();
interface RequestOptions extends RequestInit {
noCache?: boolean;
cacheDuration?: number; // default 30 minutes
}
interface CacheItem {
expires: number;
data: any;
}
function cleanCache() {
const now = Date.now();
for (const [key, value] of cache) {
if (value.expires < now) {
cache.delete(key);
}
}
}
setInterval(cleanCache, 1000 * 60 * 15); // clean cache every 15 minutes
const defaultCacheDuration = 1000 * 60 * 30; // 30 minutes
export async function esiFetch<T>(
path: string,
character?: Character,
{ method = 'GET', body, noCache = false, cacheDuration = defaultCacheDuration }: Partial<RequestOptions> = {},
) {
try {
const headers = {
'User-Agent': options.user_agent,
'Accept': 'application/json',
};
if (character) {
// check if the token is expired
if (!CharacterHelper.hasValidToken(character)) {
await CharacterHelper.refreshTokens(character);
if (!CharacterHelper.hasValidToken(character)) {
throw new Error(`Failed to refresh token for character: ${character.eveID}`);
}
}
headers['Authorization'] = `Bearer ${character.accessToken}`;
}
const init: RequestInit = {
headers,
method: method || 'GET',
body: body || undefined,
};
const url = new URL(`${ESI_LATEST_URL}${path.startsWith('/') ? path : '/' + path}`);
url.searchParams.set('datasource', 'tranquility');
if (!noCache && init.method === 'GET') {
const cached = cache.get(url.href);
if (cached && cached?.expires > Date.now()) {
return cached.data as T;
}
}
const res = await fetch(url, init);
const data = await res.json();
if (!res.ok) {
console.error(`ESI request failure at ${path} | ${res.status}:${res.statusText} => ${JSON.stringify(data)}`);
return null;
}
if (init.method === 'GET') {
cache.set(url.href, {
expires: Math.max(
(res.headers.get('expires') && new Date(Number(res.headers.get('expires') || '')).getTime()) || 0,
Date.now() + cacheDuration,
),
data,
});
}
return data as T;
} catch (err) {
console.error(`ESI request failure at ${path} | ${JSON.stringify(err)}`, err);
return null;
}
}

View File

@@ -0,0 +1,12 @@
export * from './scopes';
export * as CharacterAPI from './character';
export * as CorporationAPI from './corporation';
export * as AllianceAPI from './alliance';
export * as auth from './auth';
export * from './auth';
export * from './fetch';
export * from './skills';
export * from './options';
export * from './mail';
export * from './character';
export * from './alliance';

View File

@@ -0,0 +1,130 @@
import { esiFetch } from './fetch';
import { CharacterHelper, type Character } from '@/eve/db';
export interface MailHeader {
from: number; // From whom the mail was sent
is_read: boolean; // is_read boolean
labels: string[]; // maxItems: 25, minimum: 0, title: get_characters_character_id_mail_labels, uniqueItems: true, labels array
mail_id: number; // mail_id integer
recipients: {
recipient_id: number; // recipient_id integer
recipient_type: 'alliance' | 'character' | 'corporation' | 'mailing_list'; // recipient_type enum
}[]; // maxItems: 52, minimum: 0, title: get_characters_character_id_mail_recipients, uniqueItems: true, recipients of the mail
subject: string; // Mail subject
timestamp: string; // When the mail was sent
}
// requires scope: esi-mail.read_mail.v1
export function getMailHeaders(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-mail.read_mail.v1')) return null;
return esiFetch<MailHeader[]>(`/characters/${character.eveID}/mail/`, character);
}
export interface SendMail {
approved_cost?: number; // approved_cost number
body: string; // body string; max length 10000
recipients: {
recipient_id: number; // recipient_id integer
recipient_type: 'alliance' | 'character' | 'corporation' | 'mailing_list'; // recipient_type enum
}[]; // maxItems: 50, minimum: 1, title: post_characters_character_id_mail, recipients of the mail
subject: string; // subject string; max length 1000
}
// requires scope: esi-mail.send_mail.v1
export function sendMail(character: Character, mail: SendMail) {
if (!CharacterHelper.hasScope(character, 'esi-mail.send_mail.v1')) return null;
return esiFetch(`/characters/${character.eveID}/mail/`, character, {
method: 'POST',
body: JSON.stringify(mail),
});
}
// requires scope: esi-mail.read_mail.v1
export function deleteMail(character: Character, mailID: number) {
if (!CharacterHelper.hasScope(character, 'esi-mail.organize_mail.v1')) return null;
return esiFetch(`/characters/${character.eveID}/mail/${mailID}/`, character, {
method: 'DELETE',
});
}
export interface Mail {
body: string; // body string
from: number; // from integer
labels: string[]; // labels array
read: boolean; // read boolean
subject: string; // subject string
timestamp: string; // timestamp string
recipients: {
recipient_id: number; // recipient_id integer
recipient_type: 'alliance' | 'character' | 'corporation' | 'mailing_list'; // recipient_type enum
}[]; // recipients array
}
// requires scope: esi-mail.read_mail.v1
export function getMail(character: Character, mailID: number) {
if (!CharacterHelper.hasScope(character, 'esi-mail.read_mail.v1')) return null;
return esiFetch<Mail>(`/characters/${character.eveID}/mail/${mailID}/`, character);
}
export interface MailMetadata {
labels: string[]; // labels array
read: boolean; // read boolean
}
// requires scope: esi-mail.organize_mail.v1
export function updateMailMetadata(character: Character, mailID: number, metadata: MailMetadata) {
if (!CharacterHelper.hasScope(character, 'esi-mail.organize_mail.v1')) return null;
return esiFetch(`/characters/${character.eveID}/mail/${mailID}/`, character, {
method: 'PUT',
body: JSON.stringify(metadata),
});
}
export interface MailLabels {
labels: {
color: number; // color integer
label_id: number; // label_id integer
name: string; // name string
unread_count: number; // unread_count integer
}[]; // labels array
total_unread_count: number; // total_unread_count integer
}
// requires scope: esi-mail.read_mail.v1
export function getMailLabels(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-mail.read_mail.v1')) return null;
return esiFetch<MailLabels>(`/characters/${character.eveID}/mail/labels/`, character);
}
export interface CreateMailLabel {
color: number; // color integer
name: string; // name string
}
// requires scope: esi-mail.organize_mail.v1
export function createMailLabel(character: Character, label: CreateMailLabel) {
if (!CharacterHelper.hasScope(character, 'esi-mail.organize_mail.v1')) return null;
return esiFetch(`/characters/${character.eveID}/mail/labels/`, character, {
method: 'POST',
body: JSON.stringify(label),
});
}
// requires scope: esi-mail.organize_mail.v1
export function deleteMailLabel(character: Character, labelID: number) {
if (!CharacterHelper.hasScope(character, 'esi-mail.organize_mail.v1')) return null;
return esiFetch(`/characters/${character.eveID}/mail/labels/${labelID}/`, character, {
method: 'DELETE',
});
}
export interface MailingList {
mailing_list_id: number; // mailing_list_id integer
name: string; // name string
}
// requires scope: esi-mail.read_mail.v1
export function getMailingLists(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-mail.read_mail.v1')) return null;
return esiFetch<MailingList[]>(`/characters/${character.eveID}/mail/lists/`, character);
}

View File

@@ -0,0 +1,18 @@
export interface EveAuthOptions {
client_id: string;
client_secret: string;
callback_url: string;
user_agent: string;
}
const CLIENT_ID = process.env.EVE_CLIENT_ID || '';
const CLIENT_SECRET = process.env.EVE_CLIENT_SECRET || '';
const CALLBACK_URL = process.env.EVE_CALLBACK_URL || '';
const USER_AGENT = process.env.ESI_USER_AGENT || '';
export const options: EveAuthOptions = {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
callback_url: CALLBACK_URL,
user_agent: USER_AGENT,
};

View File

@@ -0,0 +1,91 @@
import { jwtDecode } from "jwt-decode";
export const EVE_JWKS_URL = 'https://login.eveonline.com/oauth/jwks';
export const EVE_ISSUER = 'login.eveonline.com';
export const EVE_AUDIENCE = 'eveonline';
export const ESI_LATEST_URL = 'https://esi.evetech.net/latest';
export const DATA_SOURCE = 'tranquility';
export function joinScopes(...scopes: string[]) {
return scopes.join(' ');
}
export enum SCOPES {
PUBLIC_DATA = 'publicData',
CALENDAR_RESPOND_CALENDAR_EVENTS = 'esi-calendar.respond_calendar_events.v1',
CALENDAR_READ_CALENDAR_EVENTS = 'esi-calendar.read_calendar_events.v1',
LOCATION_READ_LOCATION = 'esi-location.read_location.v1',
LOCATION_READ_SHIP_TYPE = 'esi-location.read_ship_type.v1',
MAIL_ORGANIZE_MAIL = 'esi-mail.organize_mail.v1',
MAIL_READ_MAIL = 'esi-mail.read_mail.v1',
MAIL_SEND_MAIL = 'esi-mail.send_mail.v1',
SKILLS_READ_SKILLS = 'esi-skills.read_skills.v1',
SKILLS_READ_SKILLQUEUE = 'esi-skills.read_skillqueue.v1',
WALLET_READ_CHARACTER_WALLET = 'esi-wallet.read_character_wallet.v1',
WALLET_READ_CORPORATION_WALLET = 'esi-wallet.read_corporation_wallet.v1',
SEARCH_SEARCH_STRUCTURES = 'esi-search.search_structures.v1',
CLONES_READ_CLONES = 'esi-clones.read_clones.v1',
CHARACTERS_READ_CONTACTS = 'esi-characters.read_contacts.v1',
UNIVERSE_READ_STRUCTURES = 'esi-universe.read_structures.v1',
KILLMAILS_READ_KILLMAILS = 'esi-killmails.read_killmails.v1',
CORPORATIONS_READ_CORPORATION_MEMBERSHIP = 'esi-corporations.read_corporation_membership.v1',
ASSETS_READ_ASSETS = 'esi-assets.read_assets.v1',
PLANETS_MANAGE_PLANETS = 'esi-planets.manage_planets.v1',
FLEETS_READ_FLEET = 'esi-fleets.read_fleet.v1',
FLEETS_WRITE_FLEET = 'esi-fleets.write_fleet.v1',
UI_OPEN_WINDOW = 'esi-ui.open_window.v1',
UI_WRITE_WAYPOINT = 'esi-ui.write_waypoint.v1',
CHARACTERS_WRITE_CONTACTS = 'esi-characters.write_contacts.v1',
FITTINGS_READ_FITTINGS = 'esi-fittings.read_fittings.v1',
FITTINGS_WRITE_FITTINGS = 'esi-fittings.write_fittings.v1',
MARKETS_STRUCTURE_MARKETS = 'esi-markets.structure_markets.v1',
CORPORATIONS_READ_STRUCTURES = 'esi-corporations.read_structures.v1',
CHARACTERS_READ_LOYALTY = 'esi-characters.read_loyalty.v1',
CHARACTERS_READ_OPPORTUNITIES = 'esi-characters.read_opportunities.v1',
CHARACTERS_READ_CHAT_CHANNELS = 'esi-characters.read_chat_channels.v1',
CHARACTERS_READ_MEDALS = 'esi-characters.read_medals.v1',
CHARACTERS_READ_STANDINGS = 'esi-characters.read_standings.v1',
CHARACTERS_READ_AGENTS_RESEARCH = 'esi-characters.read_agents_research.v1',
INDUSTRY_READ_CHARACTER_JOBS = 'esi-industry.read_character_jobs.v1',
MARKETS_READ_CHARACTER_ORDERS = 'esi-markets.read_character_orders.v1',
CHARACTERS_READ_BLUEPRINTS = 'esi-characters.read_blueprints.v1',
CHARACTERS_READ_CORPORATION_ROLES = 'esi-characters.read_corporation_roles.v1',
LOCATION_READ_ONLINE = 'esi-location.read_online.v1',
CONTRACTS_READ_CHARACTER_CONTRACTS = 'esi-contracts.read_character_contracts.v1',
CLONES_READ_IMPLANTS = 'esi-clones.read_implants.v1',
CHARACTERS_READ_FATIGUE = 'esi-characters.read_fatigue.v1',
KILLMAILS_READ_CORPORATION_KILLMAILS = 'esi-killmails.read_corporation_killmails.v1',
CORPORATIONS_TRACK_MEMBERS = 'esi-corporations.track_members.v1',
WALLET_READ_CORPORATION_WALLETS = 'esi-wallet.read_corporation_wallets.v1',
CHARACTERS_READ_NOTIFICATIONS = 'esi-characters.read_notifications.v1',
CORPORATIONS_READ_DIVISIONS = 'esi-corporations.read_divisions.v1',
CORPORATIONS_READ_CONTACTS = 'esi-corporations.read_contacts.v1',
ASSETS_READ_CORPORATION_ASSETS = 'esi-assets.read_corporation_assets.v1',
CORPORATIONS_READ_TITLES = 'esi-corporations.read_titles.v1',
CORPORATIONS_READ_BLUEPRINTS = 'esi-corporations.read_blueprints.v1',
CONTRACTS_READ_CORPORATION_CONTRACTS = 'esi-contracts.read_corporation_contracts.v1',
CORPORATIONS_READ_STANDINGS = 'esi-corporations.read_standings.v1',
CORPORATIONS_READ_STARBASES = 'esi-corporations.read_starbases.v1',
INDUSTRY_READ_CORPORATION_JOBS = 'esi-industry.read_corporation_jobs.v1',
MARKETS_READ_CORPORATION_ORDERS = 'esi-markets.read_corporation_orders.v1',
CORPORATIONS_READ_CONTAINER_LOGS = 'esi-corporations.read_container_logs.v1',
INDUSTRY_READ_CHARACTER_MINING = 'esi-industry.read_character_mining.v1',
INDUSTRY_READ_CORPORATION_MINING = 'esi-industry.read_corporation_mining.v1',
PLANETS_READ_CUSTOMS_OFFICES = 'esi-planets.read_customs_offices.v1',
CORPORATIONS_READ_FACILITIES = 'esi-corporations.read_facilities.v1',
CORPORATIONS_READ_MEDALS = 'esi-corporations.read_medals.v1',
CHARACTERS_READ_TITLES = 'esi-characters.read_titles.v1',
ALLIANCES_READ_CONTACTS = 'esi-alliances.read_contacts.v1',
CHARACTERS_READ_FW_STATS = 'esi-characters.read_fw_stats.v1',
CORPORATIONS_READ_FW_STATS = 'esi-corporations.read_fw_stats.v1',
}
export function tokenHasScopes(access_token: string, ...scopes: string[]) {
let tokenScopes = getScopesFromToken(access_token);
return scopes.every((scope) => tokenScopes.includes(scope));
}
export function getScopesFromToken(access_token: string) {
const decoded = jwtDecode(access_token) as { scp: string[] | string; };
return typeof decoded.scp === 'string' ? [decoded.scp] : decoded.scp;
}

View File

@@ -0,0 +1,66 @@
import { CharacterHelper, type Character } from '@/eve/db/models';
import { esiFetch } from './fetch';
export interface CharacterAttributes {
charisma: number;
intelligence: number;
memory: number;
perception: number;
willpower: number;
last_remap_date?: string;
bonus_remaps?: number;
accrued_remap_cooldown_date?: string;
}
// required scope: esi-skills.read_skills.v1
export function getCharacterAttributes(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-skills.read_skills.v1')) return null;
return esiFetch<CharacterAttributes>(`/characters/${character.eveID}/attributes`, character);
}
export interface SkillQueueItem {
finish_date?: string;
finished_level: number;
level_end_sp?: number;
level_start_sp?: number;
queue_position: number;
skill_id: number;
start_date?: string;
training_start_sp?: number;
}
// required scope: esi-skills.read_skillqueue.v1
export function getCharacterSkillQueue(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-skills.read_skillqueue.v1')) return null;
return esiFetch<SkillQueueItem[]>(`/characters/${character.eveID}/skillqueue`, character);
}
export interface APISkill {
active_skill_level: number;
skill_id: number;
skillpoints_in_skill: number;
trained_skill_level: number;
}
export interface CharacterSkills {
skills: APISkill[]; // max 1000
total_sp: number;
unallocated_sp?: number;
}
// required scope: esi-skills.read_skills.v1
export function getCharacterSkills(character: Character) {
if (!CharacterHelper.hasScope(character, 'esi-skills.read_skills.v1')) return null;
return esiFetch<CharacterSkills>(`/characters/${character.eveID}/skills`, character);
}
export function calculateTrainingPercentage(queuedSkill: SkillQueueItem) {
// percentage in when training started
const trainingStartPosition = (queuedSkill.training_start_sp! - queuedSkill.level_start_sp!) / queuedSkill.level_end_sp!;
// percentage completed between start and now
const timePosition =
(new Date().getTime() - new Date(queuedSkill.start_date!).getTime()) /
(new Date(queuedSkill.finish_date!).getTime() - new Date(queuedSkill.start_date!).getTime());
// percentage completed
return trainingStartPosition + (1 - trainingStartPosition) * timePosition;
}

View File

@@ -0,0 +1,4 @@
export * from './esi/index';
export * from './db';
export * from './ref';
export * from './third-party';

View File

@@ -0,0 +1,130 @@
import type { LocalizedString } from './shared-types';
import { dataSets, loadModels } from './loadModels';
export interface Attribute {
readonly attribute_id: number;
readonly category_id: number;
readonly data_type: number;
readonly default_value: number;
readonly description: LocalizedString;
readonly high_is_good: boolean;
readonly icon_id?: number;
readonly name: string;
readonly published: boolean;
readonly stackable: boolean;
readonly unit_id?: number;
readonly display_name: LocalizedString;
readonly tooltip_title?: LocalizedString;
readonly tooltip_description?: LocalizedString;
}
export const getAttribute = (id: number) => {
if (!dataSets.loaded) loadModels();
const data = dataSets.dogma_attributes[String(id)];
if (!data) throw new Error(`Attribute ID ${id} not found in reference data`);
return data;
};
export enum CommonAttribute {
// Structure
StructureHitpoints = 9,
CargoCapacity = 38,
DroneCapacity = 283,
DroneBandwidth = 1271,
Mass = 4,
Volume = 161,
InertiaModifier = 70,
StructureEMResistance = 113,
StructureThermalResistance = 110,
StructureKineticResistance = 109,
StructureExplosiveResistance = 111,
// Armor
ArmorHitpoints = 265,
ArmorEMResistance = 267,
ArmorThermalResistance = 270,
ArmorKineticResistance = 269,
ArmorExplosiveResistance = 268,
// Shield
ShieldCapacity = 263,
ShieldRechargeTime = 479,
ShieldEMResistance = 271,
ShieldThermalResistance = 274,
ShieldKineticResistance = 273,
ShieldExplosiveResistance = 272,
// Electronic Resistances
CapacitorWarfareResistance = 2045,
StasisWebifierResistance = 2115,
WeaponDisruptionResistance = 2113,
// Capacitor
CapacitorCapacity = 482,
CapacitorRechargeTime = 55,
// Targeting
MaxTargetRange = 76,
MaxLockedTargets = 192,
SignatureRadius = 552,
ScanResolution = 564,
RadarSensorStrength = 208,
MagnetometricSensorStrength = 210,
GravimetricSensorStrength = 211,
LadarSensorStrength = 209,
// Jump Drive Systems
HasJumpDrive = 861,
JumpDriveCapacitorNeed = 898,
MaxJumpRange = 867,
JumpDriveFuelNeed = 866,
JumpDriveConsumptionAmount = 868,
FuelBayCapacity = 1549,
ConduitJumpConsumptionAmount = 3131,
COnduitJumpPassengerCapacity = 3133,
// Propulsion
MaxVelocity = 37,
WarpSpeed = 600,
// FITTING
// Slots
HighSlots = 14,
MediumSlots = 13,
LowSlots = 12,
// Stats
PowergridOutput = 11,
CPUOutput = 48,
TurretHardpoints = 102,
LauncherHardpoints = 101,
// Rigging
RigSlots = 1137,
RigSize = 1547,
Calibration = 1132,
// Module
CPUUsage = 50,
PowergridUsage = 30,
ActivationCost = 6,
// EWAR
MaxVelocityBonus = 20,
WarpScrambleStrength = 105,
WarpDisruptionStrength = 2425,
WarpDisruptionRange = 103,
// Weapon
DamageMultiplier = 64,
AccuracyFalloff = 158,
OptimalRange = 54,
RateOfFire = 51,
TrackingSpeed = 160,
ReloadTime = 1795,
ActivationTime = 73,
UsedWithCharge1 = 604,
UsedWithCharge2 = 605,
ChargeSize = 128,
}

View File

@@ -0,0 +1,105 @@
import { ActivityType, type TypeIDQuantity } from './shared-types';
import type { Type } from './type';
import { getType } from './type';
import { dataSets, loadModels } from './loadModels';
export interface Activity {
time: number;
}
export interface ManufacturingActivity extends Activity {
time: number;
materials: { [type_id: string]: TypeIDQuantity };
products: { [type_id: string]: TypeIDQuantity };
}
export interface InventionActivity extends Activity {
time: number;
materials: { [type_id: string]: TypeIDQuantity };
products: { [type_id: string]: TypeIDQuantity };
skills: { [skill_type_id: string]: number }; // skill_type_id : level
}
export interface TypeQuantity {
type: Type;
quantity: number;
}
export interface Blueprint {
readonly blueprint_type_id: number;
readonly max_production_limit: number;
readonly activities: {
[ActivityType.MANUFACTURING]?: ManufacturingActivity;
[ActivityType.RESEARCH_MATERIAL]?: Activity;
[ActivityType.RESEARCH_TIME]?: Activity;
[ActivityType.COPYING]?: Activity;
[ActivityType.INVENTION]?: InventionActivity;
};
}
export function getBlueprint(blueprint_type_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.blueprints[String(blueprint_type_id)];
if (!data) throw new Error(`Blueprint Type ID ${blueprint_type_id} not found in reference data`);
return data;
}
export function getManufacturingMaterials(blueprint: Blueprint) {
const manufacturing = blueprint.activities[ActivityType.MANUFACTURING];
if (!manufacturing) return [];
return Promise.all(
Object.entries(manufacturing.materials).map(([type_id, { quantity }]) => ({
type: getType(parseInt(type_id)),
quantity,
})),
);
}
export function getManufacturingProducts(blueprint: Blueprint) {
const manufacturing = blueprint.activities[ActivityType.MANUFACTURING];
if (!manufacturing) return [];
return Promise.all(
Object.entries(manufacturing.products).map(([type_id, { quantity }]) => ({
type: getType(parseInt(type_id)),
quantity,
})),
);
}
export function getInventionMaterials(blueprint: Blueprint) {
const invention = blueprint.activities[ActivityType.INVENTION];
if (!invention) return [];
return Promise.all(
Object.entries(invention.materials).map(([type_id, { quantity }]) => ({
type: getType(parseInt(type_id)),
quantity,
})),
);
}
export function getInventionProducts(blueprint: Blueprint) {
const invention = blueprint.activities[ActivityType.INVENTION];
if (!invention) return [];
return Promise.all(
Object.entries(invention.products).map(([type_id, { quantity }]) => ({
type: getType(parseInt(type_id)),
quantity,
})),
);
}
export function getInventionSkills(blueprint: Blueprint) {
const invention = blueprint.activities[ActivityType.INVENTION];
if (!invention) return [];
return Promise.all(
Object.entries(invention.skills).map(([skill_type_id, level]) => ({
type: getType(parseInt(skill_type_id)),
level,
})),
);
}

View File

@@ -0,0 +1,35 @@
import type { LocalizedString } from './shared-types';
import { dataSets, loadModels } from './loadModels';
export enum CommonCategory {
CARGO = 5,
SHIP = 6,
MODULE = 7,
CHARGE = 8,
BLUEPRINT = 9,
SKILL = 16,
DRONE = 18,
IMPLANT = 20,
APPAREL = 30,
DEPLOYABLE = 22,
REACTION = 24,
SUBSYSTEM = 32,
STRUCTURE = 65,
STRUCTURE_MODULE = 66,
FIGHTER = 87,
}
export interface Category {
readonly category_id: number;
readonly name: LocalizedString;
readonly published: boolean;
readonly group_ids: number[];
readonly icon_id?: number;
}
export function getCategory(category_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.categories[String(category_id)];
if (!data) throw new Error(`Category ID ${category_id} not found in reference data`);
return data;
}

View File

@@ -0,0 +1,65 @@
import { getAttribute } from './attribute';
import type { LocalizedString } from './shared-types';
import { dataSets, loadModels } from './loadModels';
interface Modifier {
domain: number;
func: number;
group_id?: number;
modified_attribute_id: number;
modifying_attribute_id: number;
skill_type_id?: number;
operator: number;
}
export interface Effect {
readonly effect_id: number;
readonly disallow_auto_repeat: boolean;
readonly discharge_attribute_id?: number;
readonly distribution?: number;
readonly duration_attribute_id?: number;
readonly effect_category: number;
readonly effect_name: string;
readonly electronic_chance: boolean;
readonly falloff_attribute_id?: number;
readonly guid: string;
readonly is_assistance: boolean;
readonly is_offensive: boolean;
readonly is_warp_safe: boolean;
readonly propulsion_chance: boolean;
readonly published: boolean;
readonly range_attribute_id?: number;
readonly range_chance: boolean;
readonly modifiers: Modifier[];
readonly tracking_speed_attribute_id?: number;
readonly description: LocalizedString;
readonly display_name: LocalizedString;
readonly name: string;
}
export function getEffect(effect_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.dogma_effects[String(effect_id)];
if (!data) throw new Error(`Effect ID ${effect_id} not found in reference data`);
return data;
}
export function getDischargeAttribute(effect: Effect) {
return effect.discharge_attribute_id && getAttribute(effect.discharge_attribute_id);
}
export function getFalloffAttribute(effect: Effect) {
return effect.falloff_attribute_id && getAttribute(effect.falloff_attribute_id);
}
export function getDurationAttribute(effect: Effect) {
return effect.duration_attribute_id && getAttribute(effect.duration_attribute_id);
}
export function getRangeAttribute(effect: Effect) {
return effect.range_attribute_id && getAttribute(effect.range_attribute_id);
}
export function getTrackingSpeedAttribute(effect: Effect) {
return effect.tracking_speed_attribute_id && getAttribute(effect.tracking_speed_attribute_id);
}

View File

@@ -0,0 +1,29 @@
import type { LocalizedString } from './shared-types';
import { dataSets, loadModels } from './loadModels';
export interface Group {
readonly group_id: number;
readonly category_id: number;
readonly name: LocalizedString;
readonly published: boolean;
readonly icon_id?: number;
readonly anchorable: boolean;
readonly anchored: boolean;
readonly fittable_non_singleton: boolean;
readonly use_base_price: boolean;
readonly type_ids?: number[];
}
export function getGroup(group_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.groups[String(group_id)];
if (!data) throw new Error(`Group ID ${group_id} not found in reference data`);
return data;
}
export function groupEveRefLink(group_id: number) {
return `https://everef.net/groups/${group_id}`;
}
export function renderGroupEveRefLink(group: Group, locale: string = 'en') {
return `[${group.name[locale] ?? group.name.en}](${groupEveRefLink(group.group_id)})`;
}

View File

@@ -0,0 +1,41 @@
import { dataSets, loadModels } from './loadModels';
export enum IconSize {
SIZE_32 = 32,
SIZE_64 = 64,
SIZE_128 = 128,
SIZE_256 = 256,
SIZE_512 = 512,
}
export interface Icon {
readonly icon_id: number;
readonly description: string;
readonly file: string;
}
export function getIcon(icon_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.icons[String(icon_id)];
if (!data) throw new Error(`Icon ID ${icon_id} not found in reference data`);
return data;
}
export function getIconUrl(
icon_id: Icon,
{
size = IconSize.SIZE_64,
isBp = false,
isBpc = false,
}: {
size?: IconSize;
isBp?: boolean;
isBpc?: boolean;
} = {},
): string {
return `https://images.evetech.net/types/${icon_id}/icon${
isBp ? '/bp'
: isBpc ? '/bpc'
: ''
}?size=${size}`;
}

View File

@@ -0,0 +1,14 @@
export * from './attribute';
export * from './blueprint';
export * from './category';
export * from './effect';
export * from './group';
export * from './icon';
export * from './market-group';
export * from './meta-group';
export * from './region';
export * from './schematic';
export * from './skill';
export * from './solar-system';
export * from './type';
export * from './unit';

View File

@@ -0,0 +1,53 @@
import fs from 'node:fs';
import { join } from 'node:path';
import type { Unit } from './unit';
import type { SolarSystem } from './solar-system';
import type { Attribute } from './attribute';
import type { Blueprint } from './blueprint';
import type { Category } from './category';
import type { Effect } from './effect';
import type { Group } from './group';
import type { Icon } from './icon';
import type { MarketGroup } from './market-group';
import type { MetaGroup } from './meta-group';
import type { Region } from './region';
import type { Schematic } from './schematic';
import type { Skill } from './skill';
import type { Type } from './type';
const dataSets = {
loaded: false,
dogma_attributes: {} as Record<string, Attribute>,
blueprints: {} as Record<string, Blueprint>,
categories: {} as Record<string, Category>,
dogma_effects: {} as Record<string, Effect>,
groups: {} as Record<string, Group>,
icons: {} as Record<string, Icon>,
market_groups: {} as Record<string, MarketGroup>,
meta_groups: {} as Record<string, MetaGroup>,
regions: {} as Record<string, Region>,
schematics: {} as Record<string, Schematic>,
skills: {} as Record<string, Skill>,
solar_systems: {} as Record<string, SolarSystem>,
types: {} as Record<string, Type>,
units: {} as Record<string, Unit>,
};
export async function loadModels() {
dataSets.dogma_attributes = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/dogma_attributes.json')).toString());
dataSets.blueprints = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/blueprints.json')).toString());
dataSets.categories = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/categories.json')).toString());
dataSets.dogma_effects = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/dogma_effects.json')).toString());
dataSets.groups = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/groups.json')).toString());
dataSets.icons = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/icons.json')).toString());
dataSets.market_groups = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/market_groups.json')).toString());
dataSets.meta_groups = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/meta_groups.json')).toString());
dataSets.regions = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/regions.json')).toString());
dataSets.schematics = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/schematics.json')).toString());
dataSets.skills = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/skills.json')).toString());
dataSets.solar_systems = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/solar_systems.json')).toString());
dataSets.types = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/types.json')).toString());
dataSets.units = JSON.parse(fs.readFileSync(join(__dirname, '../data/reference-data/units.json')).toString());
dataSets.loaded = true;
}
export { dataSets };

View File

@@ -0,0 +1,24 @@
import type { LocalizedString } from './shared-types';
import { dataSets, loadModels } from './loadModels';
export interface MarketGroup {
readonly market_group_id: number;
readonly parent_group_id: number;
readonly name: LocalizedString;
readonly description: LocalizedString;
readonly child_market_group_ids: number[];
readonly icon_id: number;
readonly has_types: boolean;
}
export function getMarketGroup(market_group_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.market_groups[String(market_group_id)];
if (!data) throw new Error(`Market group ID ${market_group_id} not found in reference data`);
return data;
}
export async function getAllChildMarketGroups(marketGroup: MarketGroup): Promise<MarketGroup[]> {
const children = await Promise.all(marketGroup.child_market_group_ids.map((id) => getMarketGroup(id)));
return children.concat(...(await Promise.all(children.map((child) => getAllChildMarketGroups(child)))));
}

View File

@@ -0,0 +1,17 @@
import type { LocalizedString } from './shared-types';
import { dataSets, loadModels } from './loadModels';
export interface MetaGroup {
readonly meta_group_id: number;
readonly name: LocalizedString;
readonly type_ids: number[];
readonly icon_id?: number;
readonly icon_suffix?: string;
}
export function getMetaGroup(meta_group_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.meta_groups[String(meta_group_id)];
if (!data) throw new Error(`Meta group ID ${meta_group_id} not found in reference data`);
return data;
}

View File

@@ -0,0 +1,24 @@
import type { LocalizedString, Position } from './shared-types';
import { dataSets, loadModels } from './loadModels';
export interface Region {
readonly region_id: number;
readonly center: Position;
readonly description_id: number;
readonly faction_id: number;
readonly max: Position;
readonly min: Position;
readonly name_id: number;
readonly wormhole_class_id?: number;
readonly nebula_id?: number;
readonly universe_id: string;
readonly description: LocalizedString;
readonly name: LocalizedString;
}
export function getRegion(region_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.regions[String(region_id)];
if (!data) throw new Error(`Region ID ${region_id} not found in reference data`);
return data;
}

View File

@@ -0,0 +1,37 @@
import type { LocalizedString, TypeIDQuantity } from './shared-types';
import { getType } from './type';
import { dataSets, loadModels } from './loadModels';
export interface Schematic {
readonly schematic_id: number;
readonly cycle_time: number;
readonly name: LocalizedString;
readonly materials: { [type_id: string]: TypeIDQuantity };
readonly products: { [type_id: string]: TypeIDQuantity };
readonly pin_type_ids: number[];
}
export function getSchematic(schematic_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.schematics[String(schematic_id)];
if (!data) throw new Error(`Schematic ID ${schematic_id} not found in reference data`);
return data;
}
export function getMaterialQuantities(schematic: Schematic) {
return Object.entries(schematic.materials).map(([type_id, { quantity }]) => ({
type: getType(Number(type_id)),
quantity,
}));
}
export function getProductQuantities(schematic: Schematic) {
return Object.entries(schematic.products).map(([type_id, { quantity }]) => ({
type: getType(Number(type_id)),
quantity,
}));
}
export function getPinTypes(schematic: Schematic) {
return schematic.pin_type_ids.map(getType);
}

View File

@@ -0,0 +1,56 @@
import type { Type } from './type';
export interface LocalizedString {
de?: string;
en?: string;
es?: string;
fr?: string;
ja?: string;
ko?: string;
ru?: string;
zh?: string;
}
export interface TypeIDQuantity {
type_id: number;
quantity: number;
}
export interface TypeQuantity {
type: Type;
quantity: number;
}
export interface AttributeIDValue {
attribute_id: number;
value: number;
}
export interface EffectIDDefault {
effect_id: number;
is_default: boolean;
}
export interface MaterialIDQuantity {
material_type_id: number;
quantity: number;
}
export interface BlueprintTypeIDActivity {
blueprint_type_id: number;
blueprint_activity: ActivityType;
}
export enum ActivityType {
MANUFACTURING = 'manufacturing',
RESEARCH_MATERIAL = 'research_material',
RESEARCH_TIME = 'research_time',
COPYING = 'copying',
INVENTION = 'invention',
}
export interface Position {
x: number;
y: number;
z: number;
}

View File

@@ -0,0 +1,47 @@
import { type Attribute, getAttribute } from './attribute';
import { dataSets, loadModels } from './loadModels';
export interface Skill {
readonly type_id: number;
readonly primary_dogma_attribute_id: number;
readonly secondary_dogma_attribute_id: number;
readonly primary_character_attribute_id: number;
readonly secondary_character_attribute_id: number;
readonly training_time_multiplier: number;
readonly required_skills?: { [skill_type_id: string]: number }; // skill_type_id : level
}
export function getSkill(type_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.skills[String(type_id)];
if (!data) throw new Error(`Skill ID ${type_id} not found in reference data`);
return data;
}
export function getPrimaryDogmaAttribute(skill: Skill) {
return getAttribute(skill.primary_dogma_attribute_id);
}
export function getSecondaryDogmaAttribute(skill: Skill) {
return getAttribute(skill.secondary_dogma_attribute_id);
}
export function getPrimaryCharacterAttribute(skill: Skill) {
return getAttribute(skill.primary_character_attribute_id);
}
export function getSecondaryCharacterAttribute(skill: Skill) {
return getAttribute(skill.secondary_character_attribute_id);
}
export function getPrerequisites(skill: Skill): { skill: Skill; level: number }[] {
if (!skill.required_skills) return [];
return Object.entries(skill.required_skills).map(([skill_type_id, level]) => ({
skill: getSkill(parseInt(skill_type_id)),
level,
}));
}
export function skillpointsAtLevel(skill: Skill, level: number): number {
return Math.pow(2, 2.5 * (level - 1)) * 250 * skill.training_time_multiplier;
}

View File

@@ -0,0 +1,42 @@
import { dataSets, loadModels } from './loadModels';
export interface SolarSystem {
readonly regionID: number;
readonly constellationID: number;
readonly solarSystemID: number;
readonly solarSystemName: string;
readonly x: number;
readonly y: number;
readonly z: number;
readonly xMin: number;
readonly xMax: number;
readonly yMin: number;
readonly yMax: number;
readonly zMin: number;
readonly zMax: number;
readonly luminosity: number;
readonly border: boolean;
readonly fringe: boolean;
readonly corridor: boolean;
readonly hub: boolean;
readonly international: boolean;
readonly regional: boolean;
readonly security: number;
readonly factionID: number;
readonly radius: number;
readonly sunTypeID: number;
readonly securityClass: string;
}
export function getSolarSystem(solarSystemID: number): SolarSystem {
if (!dataSets.loaded) loadModels();
const data = dataSets.solar_systems[String(solarSystemID)] as any;
if (!data) throw new Error(`Solar System ID ${solarSystemID} not found in reference data`);
return {
...data,
security: parseFloat(data.security),
radius: parseFloat(data.radius),
sunTypeID: parseInt(data.sun_type_id, 10),
securityClass: data.security_class ?? 'nullsec', // Default to 'nullsec' if security_class is not present
};
}

View File

@@ -0,0 +1,197 @@
import type { AttributeIDValue, BlueprintTypeIDActivity, EffectIDDefault, LocalizedString, MaterialIDQuantity } from './shared-types';
import { IconSize } from './icon';
import { getUnit, type Unit } from './unit';
import { CommonAttribute, getAttribute } from './attribute';
import { getGroup } from './group';
import { getMetaGroup } from './meta-group';
import { dataSets, loadModels } from './loadModels';
interface Masteries {
'0': number[];
'1': number[];
'2': number[];
'3': number[];
'4': number[];
}
interface Bonus {
bonus: number;
bonus_text: LocalizedString;
importance: number;
unit_id: number;
}
interface Traits {
misc_bonuses: { [level: string]: Bonus };
role_bonuses: { [level: string]: Bonus };
types: { [skill_type_id: string]: { [order: string]: Bonus } };
}
export interface Type {
readonly type_id: number;
readonly name: LocalizedString;
readonly description: LocalizedString;
readonly published: boolean;
readonly group_id?: number;
readonly base_price?: number;
readonly capacity?: number;
readonly faction_id?: number;
readonly graphic_id?: number;
readonly market_group_id?: number;
readonly mass?: number;
readonly masteries?: Masteries;
readonly meta_group_id?: number;
readonly portion_size?: number;
readonly race_id?: number;
readonly radius?: number;
readonly sof_faction_name?: string;
readonly sound_id?: number;
readonly traits?: Traits;
readonly volume?: number;
readonly dogma_attributes?: {
[attribute_id: string]: AttributeIDValue;
};
readonly dogma_effects?: { [effect_id: string]: EffectIDDefault };
readonly packaged_volume?: number;
readonly type_materials?: { [type_id: string]: MaterialIDQuantity };
readonly required_skills?: { [skill_type_id: string]: number }; // skill_type_id : level
readonly type_variations?: { [meta_group_id: string]: number[] }; // meta_group_id : type_ids[]
readonly produced_by_blueprints?: {
[blueprint_type_id: string]: BlueprintTypeIDActivity;
}; // blueprint_type_id : blueprint_activity
readonly buildable_pin_type_ids?: number[];
readonly is_ore?: boolean;
readonly ore_variations?: { [variant: string]: number }; // variant : type_id
readonly produced_by_schematic_ids?: number[];
readonly used_by_schematic_ids?: number[];
readonly is_blueprint?: boolean;
}
export function getType(type_id: number) {
if (!dataSets.loaded) loadModels();
const data = dataSets.types[String(type_id)];
if (!data) throw new Error(`Type ID ${type_id} not found in reference data`);
return data;
}
export function getTypeIconUrl(type: Type, size: IconSize = IconSize.SIZE_64) {
return `https://images.evetech.net/types/${type.type_id}/icon${type.is_blueprint ? '/bp' : ''}?size=${size}`;
}
export function getSkillBonuses(type: Type): {
skill: Type;
bonuses: {
bonus: number;
bonus_text: LocalizedString;
importance: number;
unit: Unit;
}[];
}[] {
if (!type.traits) return [];
const skillBonuses: {
skill: Type;
bonuses: {
bonus: number;
bonus_text: LocalizedString;
importance: number;
unit: Unit;
}[];
}[] = [];
for (const skill_type_id in type.traits.types) {
skillBonuses.push({
skill: getType(Number(skill_type_id)),
bonuses: Object.keys(type.traits.types[skill_type_id]).map((order) => {
const bonus = type.traits!.types[skill_type_id][order];
return {
bonus: bonus.bonus,
bonus_text: bonus.bonus_text,
importance: bonus.importance,
unit: getUnit(bonus.unit_id),
};
}),
});
}
return skillBonuses;
}
export function getRoleBonuses(type: Type) {
if (!type.traits || !type.traits.role_bonuses) return [];
return Object.values(type.traits.role_bonuses).map((bonus) => ({
bonus: bonus.bonus,
bonus_text: bonus.bonus_text,
importance: bonus.importance,
unit: bonus.unit_id ? getUnit(bonus.unit_id) : undefined,
}));
}
export function eveRefLink(type_id: number) {
return `https://everef.net/types/${type_id}`;
}
export function renderTypeEveRefLink(type: Type, locale: string = 'en') {
return `[${type.name[locale] ?? type.name.en}](${eveRefLink(type.type_id)})`;
}
export function eveTycoonLink(type_id: number) {
return `https://evetycoon.com/market/${type_id}`;
}
export function getTypeAttributes(type: Type) {
if (!type.dogma_attributes) return [];
Object.keys(type.dogma_attributes).map((attribute_id) => ({
attribute: getAttribute(Number(attribute_id)),
value: type.dogma_attributes![attribute_id].value,
}));
}
export function typeHasAnyAttribute(type: Type, attribute_ids: CommonAttribute[]) {
if (!type.dogma_attributes) return false;
for (const attribute_id of attribute_ids) {
if (type.dogma_attributes[attribute_id]) return true;
}
return false;
}
export function getTypeSkills(type: Type) {
if (!type.required_skills) return [];
return Object.keys(type.required_skills).map((skill_type_id) => ({
skill: getType(Number(skill_type_id)),
level: type.required_skills![skill_type_id],
}));
}
export function typeGetAttribute(type: Type, attribute_id: number) {
if (!type.dogma_attributes || !type.dogma_attributes[attribute_id]) return null;
return {
attribute: getAttribute(attribute_id),
value: type.dogma_attributes[attribute_id].value,
};
}
export function getTypeBlueprints(type: Type) {
if (!type.produced_by_blueprints) return [];
return Object.values(type.produced_by_blueprints).map((blueprint) => ({
blueprint: getType(blueprint.blueprint_type_id),
activity: blueprint.blueprint_activity,
}));
}
export function getTypeSchematics(type: Type) {
return type.produced_by_schematic_ids?.map((schematic_id) => getType(schematic_id)) ?? [];
}
export function getTypeGroup(type: Type) {
if (!type.group_id) return null;
return getGroup(type.group_id);
}
export function getTypeVariants(type: Type) {
return Object.entries(type.type_variations || {}).map(([meta_group_id, variant_ids]) => ({
metaGroup: getMetaGroup(Number(meta_group_id)),
types: variant_ids.map((type_id) => getType(type_id)),
}));
}
export function typeHasAttributes(type: Type) {
return type.dogma_attributes && Object.keys(type.dogma_attributes).length > 0;
}

View File

@@ -0,0 +1,406 @@
import { test, it, expect, mock, beforeEach } from 'bun:test';
import { getUnit, renderUnit, isUnitInversePercentage, type Unit } from './unit';
test('unit.ts', () => {
test('getUnit', () => {
it('should return unit when found', async () => {
const mockUnit: Unit = {
unit_id: 123,
display_name: 'Test Unit',
description: { en: 'Test description' },
name: { en: 'Test Name' },
};
mock.module('@star-kitten/util/json-query.js', () => ({
queryJsonObject: () => Promise.resolve(mockUnit),
}));
const result = await getUnit(123);
expect(result).toEqual(mockUnit);
});
it('should throw error when unit not found', async () => {
mock.module('@star-kitten/util/json-query.js', () => ({
queryJsonObject: () => Promise.resolve(null),
}));
await expect(getUnit(999)).rejects.toThrow('Unit ID 999 not found in reference data');
});
});
test('renderUnit', () => {
const mockUnit: Unit = {
unit_id: 0,
display_name: 'Test Unit',
description: { en: 'Test description' },
name: { en: 'Test Name' },
};
test('inverse percentage units', () => {
it('should render unit 108 as inverse percentage', async () => {
const unit = { ...mockUnit, unit_id: 108 };
const result = await renderUnit(unit, 0.75);
expect(result).toBe('0.25 Test Unit');
});
it('should render unit 111 as inverse percentage', async () => {
const unit = { ...mockUnit, unit_id: 111 };
const result = await renderUnit(unit, 0.3);
expect(result).toBe('0.70 Test Unit');
});
it('should handle missing display_name for inverse percentage', async () => {
const unit = { ...mockUnit, unit_id: 108, display_name: '' };
const result = await renderUnit(unit, 0.5);
expect(result).toBe('0.50 ');
});
});
test('time units', () => {
it('should render unit 3 (seconds) using convertSecondsToTimeString', async () => {
const unit = { ...mockUnit, unit_id: 3 };
mock.module('@star-kitten/util/text.js', () => ({
convertSecondsToTimeString: (seconds: number) => {
if (seconds === 330) return '5m 30s';
return '0s';
},
}));
const result = await renderUnit(unit, 330);
expect(result).toBe('5m 30s');
});
it('should render unit 101 (milliseconds) using convertMillisecondsToTimeString', async () => {
const unit = { ...mockUnit, unit_id: 101 };
mock.module('@star-kitten/util/text.js', () => ({
convertMillisecondsToTimeString: (milliseconds: number) => {
if (milliseconds === 2500) return '2.5s';
return '0s';
},
}));
const result = await renderUnit(unit, 2500);
expect(result).toBe('2.5s');
});
});
test('size class unit (117)', () => {
it('should render size class 1 as Small', async () => {
const unit = { ...mockUnit, unit_id: 117 };
const result = await renderUnit(unit, 1);
expect(result).toBe('Small');
});
it('should render size class 2 as Medium', async () => {
const unit = { ...mockUnit, unit_id: 117 };
const result = await renderUnit(unit, 2);
expect(result).toBe('Medium');
});
it('should render size class 3 as Large', async () => {
const unit = { ...mockUnit, unit_id: 117 };
const result = await renderUnit(unit, 3);
expect(result).toBe('Large');
});
it('should render size class 4 as X-Large', async () => {
const unit = { ...mockUnit, unit_id: 117 };
const result = await renderUnit(unit, 4);
expect(result).toBe('X-Large');
});
it('should render unknown size class as Unknown', async () => {
const unit = { ...mockUnit, unit_id: 117 };
const result = await renderUnit(unit, 99);
expect(result).toBe('Unknown');
});
});
test('specialized units', () => {
it('should render unit 141 (hardpoints) as string', async () => {
const unit = { ...mockUnit, unit_id: 141 };
const result = await renderUnit(unit, 8);
expect(result).toBe('8');
});
it('should render unit 120 (calibration) with pts suffix', async () => {
const unit = { ...mockUnit, unit_id: 120 };
const result = await renderUnit(unit, 400);
expect(result).toBe('400 pts');
});
it('should render unit 116 (typeID) using type lookup', async () => {
const unit = { ...mockUnit, unit_id: 116 };
const mockType = {
type_id: 12345,
name: { en: 'Test Type' },
description: { en: 'Test description' },
published: true,
};
mock.module('./type', () => ({
getType: (type_id: number) => {
if (type_id === 12345) return Promise.resolve(mockType);
return Promise.resolve(null);
},
renderTypeEveRefLink: (type: any, lang: string) => {
if (type.type_id === 12345 && lang === 'en') return 'Type Link';
return null;
},
}));
const result = await renderUnit(unit, 12345, 'en');
expect(result).toBe('Type Link');
});
it('should render unit 116 (typeID) as Unknown when link is null', async () => {
const unit = { ...mockUnit, unit_id: 116 };
const mockType = {
type_id: 12345,
name: { en: 'Test Type' },
description: { en: 'Test description' },
published: true,
};
mock.module('./type', () => ({
getType: (type_id: number) => {
return Promise.resolve(null);
},
renderTypeEveRefLink: (type: any, lang: string) => {
return null;
},
}));
const result = await renderUnit(unit, 12345);
expect(result).toBe('Unknown');
});
it('should render unit 115 (groupID) using group lookup', async () => {
const unit = { ...mockUnit, unit_id: 115 };
const mockGroup = {
group_id: 67890,
name: { en: 'Test Group' },
category_id: 1,
published: true,
anchorable: false,
anchored: false,
fittable_non_singleton: false,
use_base_price: false,
};
mock.module('./group', () => ({
getGroup: () => Promise.resolve(mockGroup),
renderGroupEveRefLink: (group: any, lang: string) => {
if (group.group_id === 67890 && lang === 'fr') return 'Group Link';
return null;
},
}));
const result = await renderUnit(unit, 67890, 'fr');
expect(result).toBe('Group Link');
});
it('should render unit 115 (groupID) as Unknown when link is null', async () => {
const unit = { ...mockUnit, unit_id: 115 };
const mockGroup = {
group_id: 67890,
name: { en: 'Test Group' },
category_id: 1,
published: true,
anchorable: false,
anchored: false,
fittable_non_singleton: false,
use_base_price: false,
};
mock.module('./group', () => ({
getGroup: (group_id: number) => {
return Promise.resolve(null);
},
renderGroupEveRefLink: (group: any, lang: string) => {
return null;
},
}));
const result = await renderUnit(unit, 67890);
expect(result).toBe('Unknown');
});
});
test('physical units', () => {
it('should render unit 10 (m/s)', async () => {
const unit = { ...mockUnit, unit_id: 10 };
const result = await renderUnit(unit, 150);
expect(result).toBe('150 m/s');
});
it('should render unit 11 (m/s²)', async () => {
const unit = { ...mockUnit, unit_id: 11 };
const result = await renderUnit(unit, 9.8);
expect(result).toBe('9.8 m/s²');
});
it('should render unit 9 (m³)', async () => {
const unit = { ...mockUnit, unit_id: 9 };
const result = await renderUnit(unit, 1000);
expect(result).toBe('1000 m³');
});
it('should render unit 8 (m²)', async () => {
const unit = { ...mockUnit, unit_id: 8 };
const result = await renderUnit(unit, 50);
expect(result).toBe('50 m²');
});
it('should render unit 12 (m⁻¹)', async () => {
const unit = { ...mockUnit, unit_id: 12 };
const result = await renderUnit(unit, 0.1);
expect(result).toBe('0.1 m⁻¹');
});
it('should render unit 128 (Mbps)', async () => {
const unit = { ...mockUnit, unit_id: 128 };
const result = await renderUnit(unit, 100);
expect(result).toBe('100 Mbps');
});
});
test('default case', () => {
it('should render unknown unit with value and display_name', async () => {
const unit = { ...mockUnit, unit_id: 999, display_name: 'Custom Unit' };
const result = await renderUnit(unit, 42);
expect(result).toBe('42 Custom Unit');
});
it('should render unknown unit with empty display_name', async () => {
const unit = { ...mockUnit, unit_id: 999, display_name: '' };
const result = await renderUnit(unit, 42);
expect(result).toBe('42 ');
});
it('should handle undefined display_name', async () => {
const unit = { ...mockUnit, unit_id: 999, display_name: undefined as any };
const result = await renderUnit(unit, 42);
expect(result).toBe('42 ');
});
});
test('edge cases', () => {
it('should handle zero values', async () => {
const unit = { ...mockUnit, unit_id: 10 };
const result = await renderUnit(unit, 0);
expect(result).toBe('0 m/s');
});
it('should handle negative values', async () => {
const unit = { ...mockUnit, unit_id: 120 };
const result = await renderUnit(unit, -50);
expect(result).toBe('-50 pts');
});
it('should handle very large values', async () => {
const unit = { ...mockUnit, unit_id: 9 };
const result = await renderUnit(unit, 1e10);
expect(result).toBe('10000000000 m³');
});
it('should handle decimal values for inverse percentage', async () => {
const unit = { ...mockUnit, unit_id: 108 };
const result = await renderUnit(unit, 0.12345);
expect(result).toBe('0.88 Test Unit');
});
it('should default to "en" locale when not specified', async () => {
const unit = { ...mockUnit, unit_id: 116 };
const mockType = {
type_id: 12345,
name: { en: 'Test Type' },
description: { en: 'Test description' },
published: true,
};
mock.module('./type', () => ({
getType: (type_id: number) => {
if (type_id === 12345) return Promise.resolve(mockType);
return Promise.resolve(null);
},
renderTypeEveRefLink: (type: any, lang: string) => {
if (type.type_id === 12345 && lang === 'en') return 'Type Link';
return null;
},
}));
const result = await renderUnit(unit, 12345);
expect(result).toBe('Type Link');
});
});
test('error handling', () => {
it('should handle getType errors gracefully', async () => {
const unit = { ...mockUnit, unit_id: 116 };
mock.module('./type', () => ({
getType: (type_id: number) => {
throw new Error('Type not found');
},
}));
await expect(renderUnit(unit, 12345)).rejects.toThrow('Type not found');
});
it('should handle getGroup errors gracefully', async () => {
const unit = { ...mockUnit, unit_id: 115 };
mock.module('./group', () => ({
getGroup: (group_id: number) => {
throw new Error('Group not found');
},
}));
await expect(renderUnit(unit, 67890)).rejects.toThrow('Group not found');
});
});
});
test('isUnitInversePercentage', () => {
const mockUnit: Unit = {
unit_id: 0,
display_name: 'Test Unit',
description: { en: 'Test description' },
name: { en: 'Test Name' },
};
it('should return true for unit_id 108', () => {
const unit = { ...mockUnit, unit_id: 108 };
expect(isUnitInversePercentage(unit)).toBe(true);
});
it('should return true for unit_id 111', () => {
const unit = { ...mockUnit, unit_id: 111 };
expect(isUnitInversePercentage(unit)).toBe(true);
});
it('should return false for other unit_ids', () => {
const testCases = [0, 1, 3, 8, 9, 10, 11, 12, 101, 107, 109, 115, 116, 117, 120, 128, 141, 999];
testCases.forEach((unit_id) => {
const unit = { ...mockUnit, unit_id };
expect(isUnitInversePercentage(unit)).toBe(false);
});
});
it('should use loose equality (==) not strict equality (===)', () => {
// Test that the function uses == comparison by verifying it works with string unit_ids
const unit108 = { ...mockUnit, unit_id: 108 as any };
const unit111 = { ...mockUnit, unit_id: 111 as any };
expect(isUnitInversePercentage(unit108)).toBe(true);
expect(isUnitInversePercentage(unit111)).toBe(true);
});
});
});

View File

@@ -0,0 +1,66 @@
import { convertMillisecondsToTimeString, convertSecondsToTimeString } from '@/eve/utils/markdown.js';
import { getGroup, renderGroupEveRefLink } from './group';
import type { LocalizedString } from './shared-types';
import { getType, renderTypeEveRefLink } from './type';
import { dataSets, loadModels } from './loadModels';
const sizeMap = {
1: 'Small',
2: 'Medium',
3: 'Large',
4: 'X-Large',
};
export interface Unit {
readonly unit_id: number;
readonly display_name: string;
readonly description: LocalizedString;
readonly name: LocalizedString;
}
export function getUnit(unit_id: number) {
if (!dataSets.loaded) loadModels();
const unit = dataSets.units[String(unit_id)];
if (!unit) throw new Error(`Unit ID ${unit_id} not found in reference data`);
return unit;
}
export function renderUnit(unit: Unit, value: number, locale: string = 'en'): string {
switch (unit.unit_id) {
case 108: // inverse percentage
case 111: // Inverse percentage
return [(1 - value).toFixed(2), unit.display_name ?? ''].join(' ');
case 3: // seconds
return `${convertSecondsToTimeString(value)}`;
case 101: // milliseconds
return `${convertMillisecondsToTimeString(value)}`;
case 117: // size class
return sizeMap[value] ?? 'Unknown';
case 141: // hardpoints
return value + '';
case 120: // calibration
return value + ' pts';
case 116: // typeID
return getType(value).name[locale] ?? 'Unknown';
case 10: // m/s
return `${value} m/s`;
case 11: // meters per second squared
return `${value} m/s²`;
case 9: // cubic meters
return `${value}`;
case 8: // square meters
return `${value}`;
case 12: // reciprocal meters
return `${value} m⁻¹`;
case 128: // megabits per second
return `${value} Mbps`;
case 115: // groupID
return renderGroupEveRefLink(getGroup(value), locale) ?? 'Unknown';
default:
return [value, unit.display_name ?? ''].join(' ');
}
}
export function isUnitInversePercentage(unit: Unit) {
return unit.unit_id == 108 || unit.unit_id == 111;
}

View File

@@ -0,0 +1,7 @@
import attributeOrders from '@data/hoboleaks/attributeorders.json';
export const attributeOrdering = {
'11': attributeOrders['11'],
'87': attributeOrders['87'],
'default': attributeOrders.default,
};

View File

@@ -0,0 +1,25 @@
const base_url = 'https://evetycoon.com/api/v1';
export interface Price {
buyVolume: number;
sellVolume: number;
buyOrders: number;
sellOrders: number;
buyOutliers: number;
sellOutliers: number;
buyThreshold: number;
sellThreshold: number;
buyAvgFivePercent: number;
sellAvgFivePercent: number;
maxBuy: number;
minSell: number;
}
enum Region {
TheForge = 10000002,
}
export const fetchPrice = async (type_id: number, region_id: number = Region.TheForge): Promise<Price> => {
const response = await fetch(`${base_url}/market/stats/${region_id}/${type_id}`);
return (await response.json()) as Price;
};

View File

@@ -0,0 +1,2 @@
export * as evetycoon from './evetycoon';
export * as janice from './janice';

View File

@@ -0,0 +1,323 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import {
fetchPrice,
fetchPrices,
fetchAppraisal,
appraiseItems,
isPositiveNumber,
isNonEmptyString,
markets,
AppraisalDesignation,
AppraisalPricing,
AppraisalPricingVariant,
clearCache,
} from './janice.ts';
// Mock fetch globally
const originalFetch = global.fetch;
let mockFetch: any;
beforeEach(() => {
clearCache();
mockFetch = mock(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
}));
global.fetch = mockFetch;
});
afterEach(() => {
global.fetch = originalFetch;
// Clear cache between tests
// Note: Since cache is internal to the module, we can't directly clear it
// In a real scenario, you might want to expose a clearCache function
});
describe('Validation Functions', () => {
describe('isPositiveNumber', () => {
it('should return true for positive numbers', () => {
expect(isPositiveNumber(1)).toBe(true);
expect(isPositiveNumber(100)).toBe(true);
expect(isPositiveNumber(0.5)).toBe(true);
});
it('should return false for non-positive numbers', () => {
expect(isPositiveNumber(0)).toBe(false);
expect(isPositiveNumber(-1)).toBe(false);
expect(isPositiveNumber(-0.5)).toBe(false);
});
it('should return false for non-numbers', () => {
expect(isPositiveNumber('1')).toBe(false);
expect(isPositiveNumber(null)).toBe(false);
expect(isPositiveNumber(undefined)).toBe(false);
expect(isPositiveNumber(NaN)).toBe(false);
expect(isPositiveNumber(Infinity)).toBe(false);
});
});
describe('isNonEmptyString', () => {
it('should return true for non-empty strings', () => {
expect(isNonEmptyString('test')).toBe(true);
expect(isNonEmptyString(' a ')).toBe(true);
});
it('should return false for empty strings', () => {
expect(isNonEmptyString('')).toBe(false);
expect(isNonEmptyString(' ')).toBe(false);
});
it('should return false for non-strings', () => {
expect(isNonEmptyString(123)).toBe(false);
expect(isNonEmptyString(null)).toBe(false);
expect(isNonEmptyString(undefined)).toBe(false);
});
});
});
describe('Constants', () => {
it('should have correct markets array', () => {
expect(markets).toHaveLength(8);
expect(markets[0]).toEqual({ id: 2, name: 'Jita 4-4' });
expect(markets).toContainEqual({ id: 117, name: 'Dodixie' });
});
it('should have correct enum values', () => {
expect(AppraisalDesignation.Appraisal).toBe(AppraisalDesignation.Appraisal);
expect(AppraisalPricing.Buy).toBe(AppraisalPricing.Buy);
expect(AppraisalPricingVariant.Immediate).toBe(AppraisalPricingVariant.Immediate);
});
});
describe('fetchPrice', () => {
const mockPricerItem = {
date: '2024-01-01',
market: { id: 2, name: 'Jita 4-4' },
buyOrderCount: 10,
buyVolume: 100,
sellOrderCount: 5,
sellVolume: 50,
immediatePrices: {
buyPrice: 100,
splitPrice: 95,
sellPrice: 110,
buyPrice5DayMedian: 98,
splitPrice5DayMedian: 93,
sellPrice5DayMedian: 108,
buyPrice30DayMedian: 97,
splitPrice30DayMedian: 92,
sellPrice30DayMedian: 107,
},
top5AveragePrices: {
buyPrice: 99,
splitPrice: 94,
sellPrice: 109,
buyPrice5DayMedian: 97,
splitPrice5DayMedian: 92,
sellPrice5DayMedian: 106,
buyPrice30DayMedian: 96,
splitPrice30DayMedian: 91,
sellPrice30DayMedian: 105,
},
itemType: { eid: 34, name: 'Tritanium', volume: 0.01, packagedVolume: 0.01 },
};
beforeEach(() => {
mockFetch.mockImplementation(() => Promise.resolve({
ok: true,
json: () => Promise.resolve(mockPricerItem),
}));
});
it('should fetch price successfully', async () => {
const result = await fetchPrice(34, 2);
expect(result).toEqual(mockPricerItem);
expect(mockFetch).toHaveBeenCalledWith(
'https://janice.e-351.com/api/rest/v2/pricer/34?market=2',
expect.any(Object)
);
});
it('should throw error for invalid type_id', async () => {
await expect(fetchPrice(0)).rejects.toThrow('Invalid type_id: must be a positive number');
await expect(fetchPrice(-1)).rejects.toThrow('Invalid type_id: must be a positive number');
await expect(fetchPrice('34' as any)).rejects.toThrow('Invalid type_id: must be a positive number');
});
it('should throw error for invalid market_id', async () => {
await expect(fetchPrice(34, 0)).rejects.toThrow('Invalid market_id: must be a positive number');
});
it('should handle API errors', async () => {
mockFetch.mockImplementation(() => Promise.resolve({
ok: false,
status: 404,
statusText: 'Not Found',
}));
await expect(fetchPrice(34)).rejects.toThrow('API request failed: 404 Not Found');
});
it('should handle network errors', async () => {
mockFetch.mockImplementation(() => Promise.reject(new Error('Network error')));
await expect(fetchPrice(34)).rejects.toThrow('Network error');
});
});
describe('fetchPrices', () => {
const mockPricerItems = [
{
date: '2024-01-01',
market: { id: 2, name: 'Jita 4-4' },
buyOrderCount: 10,
buyVolume: 100,
sellOrderCount: 5,
sellVolume: 50,
immediatePrices: {
buyPrice: 100,
splitPrice: 95,
sellPrice: 110,
buyPrice5DayMedian: 98,
splitPrice5DayMedian: 93,
sellPrice5DayMedian: 108,
buyPrice30DayMedian: 97,
splitPrice30DayMedian: 92,
sellPrice30DayMedian: 107,
},
top5AveragePrices: {
buyPrice: 99,
splitPrice: 94,
sellPrice: 109,
buyPrice5DayMedian: 97,
splitPrice5DayMedian: 92,
sellPrice5DayMedian: 106,
buyPrice30DayMedian: 96,
splitPrice30DayMedian: 91,
sellPrice30DayMedian: 105,
},
itemType: { eid: 34, name: 'Tritanium', volume: 0.01, packagedVolume: 0.01 },
},
];
beforeEach(() => {
mockFetch.mockImplementation(() => Promise.resolve({
ok: true,
json: () => Promise.resolve(mockPricerItems),
}));
});
it('should fetch prices successfully', async () => {
const result = await fetchPrices([34, 35], 2);
expect(result).toEqual(mockPricerItems);
expect(mockFetch).toHaveBeenCalledWith(
'https://janice.e-351.com/api/rest/v2/pricer?market=2',
expect.objectContaining({
method: 'POST',
body: '34\n35',
})
);
});
it('should throw error for invalid type_ids', async () => {
await expect(fetchPrices([])).rejects.toThrow('Invalid type_ids: must be a non-empty array of positive numbers');
await expect(fetchPrices([0, 1])).rejects.toThrow('Invalid type_ids: must be a non-empty array of positive numbers');
await expect(fetchPrices(['34'] as any)).rejects.toThrow('Invalid type_ids: must be a non-empty array of positive numbers');
});
});
describe('fetchAppraisal', () => {
const mockAppraisal = {
id: 123,
created: '2024-01-01T00:00:00Z',
expires: '2024-01-02T00:00:00Z',
datasetTime: '2024-01-01T00:00:00Z',
code: 'ABC123',
designation: AppraisalDesignation.Appraisal,
pricing: AppraisalPricing.Buy,
pricingVariant: AppraisalPricingVariant.Immediate,
pricePercentage: 1,
isCompactized: true,
failures: '',
market: { id: 2, name: 'Jita 4-4' },
totalVolume: 100,
totalPackagedVolume: 100,
effectivePrices: { totalBuyPrice: 1000, totalSplitPrice: 950, totalSellPrice: 1100 },
immediatePrices: { totalBuyPrice: 1000, totalSplitPrice: 950, totalSellPrice: 1100 },
top5AveragePrices: { totalBuyPrice: 990, totalSplitPrice: 940, totalSellPrice: 1090 },
items: [],
};
beforeEach(() => {
mockFetch.mockImplementation(() => Promise.resolve({
ok: true,
json: () => Promise.resolve(mockAppraisal),
}));
});
it('should fetch appraisal successfully', async () => {
const result = await fetchAppraisal('ABC123');
expect(result).toEqual(mockAppraisal);
expect(mockFetch).toHaveBeenCalledWith(
'https://janice.e-351.com/api/rest/v2/appraisal/ABC123',
expect.any(Object)
);
});
it('should throw error for invalid code', async () => {
await expect(fetchAppraisal('')).rejects.toThrow('Invalid code: must be a non-empty string');
await expect(fetchAppraisal(' ')).rejects.toThrow('Invalid code: must be a non-empty string');
});
});
describe('appraiseItems', () => {
const mockAppraisal = {
id: 123,
created: '2024-01-01T00:00:00Z',
expires: '2024-01-02T00:00:00Z',
datasetTime: '2024-01-01T00:00:00Z',
code: 'ABC123',
designation: AppraisalDesignation.Appraisal,
pricing: AppraisalPricing.Buy,
pricingVariant: AppraisalPricingVariant.Immediate,
pricePercentage: 1,
isCompactized: true,
failures: '',
market: { id: 2, name: 'Jita 4-4' },
totalVolume: 100,
totalPackagedVolume: 100,
effectivePrices: { totalBuyPrice: 1000, totalSplitPrice: 950, totalSellPrice: 1100 },
immediatePrices: { totalBuyPrice: 1000, totalSplitPrice: 950, totalSellPrice: 1100 },
top5AveragePrices: { totalBuyPrice: 990, totalSplitPrice: 940, totalSellPrice: 1090 },
items: [],
};
beforeEach(() => {
mockFetch.mockImplementation(() => Promise.resolve({
ok: true,
json: () => Promise.resolve(mockAppraisal),
}));
});
it('should appraise items successfully', async () => {
const text = 'Tritanium 100\nPyerite 50';
const result = await appraiseItems(text, 2);
expect(result).toEqual(mockAppraisal);
expect(mockFetch).toHaveBeenCalledWith(
'https://janice.e-351.com/api/rest/v2/appraisal?market=2&persist=true&compactize=true&pricePercentage=1',
expect.objectContaining({
method: 'POST',
body: text,
})
);
});
it('should throw error for invalid text', async () => {
await expect(appraiseItems('')).rejects.toThrow('Invalid text: must be a non-empty string');
await expect(appraiseItems(' ')).rejects.toThrow('Invalid text: must be a non-empty string');
});
});
// Note: Caching tests would require either exposing the cache or using a different approach
// For now, we've tested the basic functionality. In a production environment,
// you might want to add integration tests that verify caching behavior.

View File

@@ -0,0 +1,407 @@
/**
* Janice API integration for EVE Online market appraisals and pricing.
* This module provides interfaces and functions to interact with the Janice API.
*/
const BASE_URL = 'https://janice.e-351.com/api/rest/v2';
/**
* Represents an appraisal from the Janice API.
*/
export interface Appraisal {
/** Unique identifier for the appraisal */
id: number;
/** Creation timestamp */
created: string;
/** Expiration timestamp */
expires: string;
/** Dataset timestamp */
datasetTime: string;
/** Appraisal code */
code: string;
/** Designation type */
designation: AppraisalDesignation;
/** Pricing strategy */
pricing: AppraisalPricing;
/** Pricing variant */
pricingVariant: AppraisalPricingVariant;
/** Price percentage */
pricePercentage: number;
/** Whether the appraisal is compactized */
isCompactized: boolean;
/** Failure messages */
failures: string;
/** Market information */
market: PricerMarket;
/** Total volume */
totalVolume: number;
/** Total packaged volume */
totalPackagedVolume: number;
/** Effective prices */
effectivePrices: AppraisalValues;
/** Immediate prices */
immediatePrices: AppraisalValues;
/** Top 5 average prices */
top5AveragePrices: AppraisalValues;
/** List of items in the appraisal */
items: AppraisalItem[];
}
/**
* Price values for an appraisal.
*/
export interface AppraisalValues {
/** Total buy price */
totalBuyPrice: number;
/** Total split price */
totalSplitPrice: number;
/** Total sell price */
totalSellPrice: number;
}
/**
* Represents an item in an appraisal.
*/
export interface AppraisalItem {
/** Item ID */
id: number;
/** Amount of the item */
amount: number;
/** Number of buy orders */
buyOrderCount: number;
/** Buy volume */
buyVolume: number;
/** Number of sell orders */
sellOrderCount: number;
/** Sell volume */
sellVolume: number;
/** Effective prices for the item */
effectivePrices: AppraisalItemValues;
/** Immediate prices for the item */
immediatePrices: AppraisalItemValues;
/** Top 5 average prices for the item */
top5AveragePrices: AppraisalItemValues;
/** Total volume */
totalVolume: number;
/** Total packaged volume */
totalPackagedVolume: number;
/** Item type information */
itemType: ItemType;
}
/**
* Represents a pricer item from the API.
*/
export interface PricerItem {
/** Date of the price data */
date: string;
/** Market information */
market: PricerMarket;
/** Number of buy orders */
buyOrderCount: number;
/** Buy volume */
buyVolume: number;
/** Number of sell orders */
sellOrderCount: number;
/** Sell volume */
sellVolume: number;
/** Immediate prices */
immediatePrices: PricerItemValues;
/** Top 5 average prices */
top5AveragePrices: PricerItemValues;
/** Item type information */
itemType: ItemType;
}
/**
* Price values for a pricer item.
*/
export interface PricerItemValues {
/** Buy price */
buyPrice: number;
/** Split price */
splitPrice: number;
/** Sell price */
sellPrice: number;
/** 5-day median buy price */
buyPrice5DayMedian: number;
/** 5-day median split price */
splitPrice5DayMedian: number;
/** 5-day median sell price */
sellPrice5DayMedian: number;
/** 30-day median buy price */
buyPrice30DayMedian: number;
/** 30-day median split price */
splitPrice30DayMedian: number;
/** 30-day median sell price */
sellPrice30DayMedian: number;
}
/**
* Extended price values for appraisal items.
*/
export interface AppraisalItemValues extends PricerItemValues {
/** Total buy price */
buyPriceTotal: number;
/** Total split price */
splitPriceTotal: number;
/** Total sell price */
sellPriceTotal: number;
}
/**
* Represents an item type.
*/
export interface ItemType {
/** EVE item ID */
eid: number;
/** Item name (optional) */
name?: string;
/** Item volume */
volume: number;
/** Packaged volume */
packagedVolume: number;
}
/**
* Enumeration for appraisal designations.
*/
export enum AppraisalDesignation {
Appraisal = 'appraisal',
WantToBuy = 'wtb',
WantToSell = 'wts',
}
/**
* Enumeration for appraisal pricing strategies.
*/
export enum AppraisalPricing {
Buy = 'buy',
Split = 'split',
Sell = 'sell',
Purchase = 'purchase',
}
/**
* Enumeration for appraisal pricing variants.
*/
export enum AppraisalPricingVariant {
Immediate = 'immediate',
Top5Percent = 'top5percent',
}
/**
* Represents a market in the pricer system.
*/
export interface PricerMarket {
/** Market ID */
id: number;
/** Market name */
name: string;
}
/**
* Predefined list of available markets.
*/
export const markets: PricerMarket[] = [
{ id: 2, name: 'Jita 4-4' },
{ id: 3, name: 'R1O-GN' },
{ id: 6, name: 'NPC' },
{ id: 114, name: 'MJ-5F9' },
{ id: 115, name: 'Amarr' },
{ id: 116, name: 'Rens' },
{ id: 117, name: 'Dodixie' },
{ id: 118, name: 'Hek' },
] as const;
/**
* Simple cache for API responses to improve performance.
*/
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Clears the internal cache. Useful for testing.
*/
export function clearCache(): void {
cache.clear();
}
/**
* Validates if a value is a positive number.
*/
export function isPositiveNumber(value: any): value is number {
return typeof value === 'number' && value > 0 && isFinite(value);
}
/**
* Validates if a value is a non-empty string.
*/
export function isNonEmptyString(value: any): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
/**
* Fetches price data for a single item type.
* @param type_id - The EVE item type ID
* @param market_id - The market ID (default: 2 for Jita)
* @returns Promise resolving to PricerItem
* @throws Error if API call fails or validation fails
*/
export const fetchPrice = async (type_id: number, market_id: number = 2): Promise<PricerItem> => {
if (!isPositiveNumber(type_id)) {
throw new Error('Invalid type_id: must be a positive number');
}
if (!isPositiveNumber(market_id)) {
throw new Error('Invalid market_id: must be a positive number');
}
const cacheKey = `price_${type_id}_${market_id}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
try {
const response = await fetch(`${BASE_URL}/pricer/${type_id}?market=${market_id}`, {
method: 'GET',
headers: {
'X-ApiKey': process.env.JANICE_KEY || '',
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
cache.set(cacheKey, { data, timestamp: Date.now() });
return data as PricerItem;
} catch (error) {
console.error(`Error fetching price for type_id ${type_id}:`, error);
throw error;
}
};
/**
* Fetches price data for multiple item types.
* @param type_ids - Array of EVE item type IDs
* @param market_id - The market ID (default: 2 for Jita)
* @returns Promise resolving to array of PricerItem
* @throws Error if API call fails or validation fails
*/
export const fetchPrices = async (type_ids: number[], market_id: number = 2): Promise<PricerItem[]> => {
if (!Array.isArray(type_ids) || type_ids.length === 0 || !type_ids.every(isPositiveNumber)) {
throw new Error('Invalid type_ids: must be a non-empty array of positive numbers');
}
if (!isPositiveNumber(market_id)) {
throw new Error('Invalid market_id: must be a positive number');
}
const cacheKey = `prices_${type_ids.sort().join('_')}_${market_id}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
try {
const response = await fetch(`${BASE_URL}/pricer?market=${market_id}`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'X-ApiKey': process.env.JANICE_KEY || '',
'Accept': 'application/json',
},
body: type_ids.join('\n'),
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
cache.set(cacheKey, { data, timestamp: Date.now() });
return data as PricerItem[];
} catch (error) {
console.error(`Error fetching prices for type_ids ${type_ids}:`, error);
throw error;
}
};
/**
* Fetches an appraisal by its code.
* @param code - The appraisal code
* @returns Promise resolving to Appraisal
* @throws Error if API call fails or validation fails
*/
export const fetchAppraisal = async (code: string): Promise<Appraisal> => {
if (!isNonEmptyString(code)) {
throw new Error('Invalid code: must be a non-empty string');
}
const cacheKey = `appraisal_${code}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
try {
const response = await fetch(`${BASE_URL}/appraisal/${code}`, {
method: 'GET',
headers: {
'X-ApiKey': process.env.JANICE_KEY || '',
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
cache.set(cacheKey, { data, timestamp: Date.now() });
return data as Appraisal;
} catch (error) {
console.error(`Error fetching appraisal for code ${code}:`, error);
throw error;
}
};
/**
* Appraises items from text input.
* @param text - The text containing items to appraise
* @param market_id - The market ID (default: 2 for Jita)
* @returns Promise resolving to Appraisal
* @throws Error if API call fails or validation fails
*/
export const appraiseItems = async (text: string, market_id: number = 2, apiKey: string | undefined = process.env.JANICE_KEY): Promise<Appraisal> => {
if (!isNonEmptyString(text)) {
throw new Error('Invalid text: must be a non-empty string');
}
if (!isPositiveNumber(market_id)) {
throw new Error('Invalid market_id: must be a positive number');
}
try {
const response = await fetch(`${BASE_URL}/appraisal?market=${market_id}&persist=true&compactize=true&pricePercentage=1`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'X-ApiKey': apiKey || '',
'Accept': 'application/json',
},
body: text,
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data as Appraisal;
} catch (error) {
console.error('Error appraising items:', error);
throw error;
}
};

View File

@@ -0,0 +1,427 @@
import { describe, it, expect, beforeAll } from 'bun:test';
import { cleanText, convertMillisecondsToTimeString, convertSecondsToTimeString, coloredTextCodeBlock } from './markdown';
import * as path from 'path';
import * as fs from 'fs';
// Load test fixtures
const basePath = path.join(__dirname, '../../fixtures/markdown');
const markupFixturesPath = path.join(basePath, 'test-data-markup.json');
const timeFixturesPath = path.join(basePath, 'test-data-time.json');
const colorFixturesPath = path.join(basePath, 'test-data-colors.json');
let markupFixtures: any;
let timeFixtures: any;
let colorFixtures: any;
describe('cleanText', () => {
beforeAll(() => {
// Fixtures are already loaded above
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
});
it('should handle basic bold markup', () => {
const input = markupFixtures.boldMarkup.complete;
const result = cleanText(input);
expect(result).toBe('**bold text**');
});
it('should handle incomplete bold markup - open only', () => {
const input = markupFixtures.boldMarkup.openOnly;
const result = cleanText(input);
expect(result).toBe('**bold text**');
});
it('should handle incomplete bold markup - close only', () => {
const input = markupFixtures.boldMarkup.closeOnly;
const result = cleanText(input);
expect(result).toBe('**bold text**');
});
it('should handle basic italic markup', () => {
const input = markupFixtures.italicMarkup.complete;
const result = cleanText(input);
expect(result).toBe('*italic text*');
});
it('should handle incomplete italic markup - open only', () => {
const input = markupFixtures.italicMarkup.openOnly;
const result = cleanText(input);
expect(result).toBe('*italic text*');
});
it('should handle incomplete italic markup - close only', () => {
const input = markupFixtures.italicMarkup.closeOnly;
const result = cleanText(input);
expect(result).toBe('*italic text*');
});
it('should remove color tags with hex colors', () => {
const input = markupFixtures.colorTags.hex6;
const result = cleanText(input);
expect(result).toBe('colored text');
});
it('should remove color tags with hex colors (8-digit)', () => {
const input = markupFixtures.colorTags.hex8;
const result = cleanText(input);
expect(result).toBe('colored text');
});
it('should remove color tags with named colors', () => {
const input = markupFixtures.colorTags.namedColor;
const result = cleanText(input);
expect(result).toBe('colored text');
});
it('should convert EVE links to Discord format', () => {
const input = markupFixtures.eveLinks.simple;
const result = cleanText(input);
expect(result).toBe('[Rifter](https://everef.net/types/587)');
});
it('should handle EVE links with spaces in text', () => {
const input = markupFixtures.eveLinks.withSpaces;
const result = cleanText(input);
expect(result).toBe('[Ship Name With Spaces](https://everef.net/types/12345)');
});
it('should handle complex markup combinations', () => {
const input = markupFixtures.combined.allMarkup;
const result = cleanText(input);
expect(result).toBe('**Bold** *italic* colored [linked](https://everef.net/types/587)');
});
it('should respect max length parameter', () => {
const longText = 'a'.repeat(100);
const input = `<b>${longText}</b>`;
const result = cleanText(input, 50);
expect(result.length).toBeLessThanOrEqual(53); // Account for ** markup + truncation
expect(result).toContain('**');
});
it('should use default max length when not specified', () => {
const veryLongText = 'a'.repeat(2000);
const input = `<b>${veryLongText}</b>`;
const result = cleanText(input);
expect(result.length).toBeLessThanOrEqual(1003); // Account for ** markup
});
it('should handle empty input', () => {
const result = cleanText('');
expect(result).toBe('');
});
it('should handle whitespace-only input', () => {
const result = cleanText(' \n\t ');
expect(result).toBe('');
});
it('should trim whitespace from input', () => {
const input = ' <b>text</b> ';
const result = cleanText(input);
expect(result).toBe('**text**');
});
it('should handle multiple bold tags', () => {
const input = markupFixtures.boldMarkup.multiple;
const result = cleanText(input);
expect(result).toBe('**first** and **second**');
});
it('should handle multiple italic tags', () => {
const input = markupFixtures.italicMarkup.multiple;
const result = cleanText(input);
expect(result).toBe('*first* and *second*');
});
it('should handle multiple color tags', () => {
const input = markupFixtures.colorTags.multiple;
const result = cleanText(input);
expect(result).toBe('first and second');
});
it('should handle multiple EVE links', () => {
const input = markupFixtures.eveLinks.multiple;
const result = cleanText(input);
expect(result).toBe('[Rifter](https://everef.net/types/587) and [Merlin](https://everef.net/types/588)');
});
it('should handle nested markup', () => {
const input = markupFixtures.boldMarkup.nested;
const result = cleanText(input);
expect(result).toContain('**');
});
it('should handle empty tags', () => {
const result1 = cleanText(markupFixtures.boldMarkup.empty);
const result2 = cleanText(markupFixtures.italicMarkup.empty);
const result3 = cleanText(markupFixtures.colorTags.empty);
// The regex doesn't match empty content between tags
expect(result1).toBe('<b></b>'); // No match, so unchanged
expect(result2).toBe('<i></i>'); // No match, so unchanged
expect(result3).toBe('');
});
});
describe('convertMillisecondsToTimeString', () => {
beforeAll(() => {
// Fixtures are already loaded above
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
});
it('should handle zero milliseconds', () => {
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.zero);
expect(result).toBe(timeFixtures.expected.zero);
});
it('should handle one second', () => {
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.oneSecond);
expect(result).toBe(timeFixtures.expected.oneSecond);
});
it('should handle one minute', () => {
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.oneMinute);
expect(result).toBe(timeFixtures.expected.oneMinute);
});
it('should handle one hour', () => {
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.oneHour);
expect(result).toBe(timeFixtures.expected.oneHour);
});
it('should handle complex time (1h 1m 1.5s)', () => {
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.complex);
expect(result).toBe(timeFixtures.expected.complexMs);
});
it('should handle large time values (24h)', () => {
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.daysWorthMs);
expect(result).toBe(timeFixtures.expected.daysMs);
});
it('should handle fractional seconds', () => {
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.fractionalSeconds);
expect(result).toBe(timeFixtures.expected.fractionalSeconds);
});
it('should handle small fractions', () => {
const result = convertMillisecondsToTimeString(timeFixtures.milliseconds.smallFraction);
expect(result).toBe(timeFixtures.expected.smallFraction);
});
it('should handle negative values', () => {
const result = convertMillisecondsToTimeString(-1000);
expect(result).toBe('-1.0s');
});
});
describe('convertSecondsToTimeString', () => {
beforeAll(() => {
// Fixtures are already loaded above
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
});
it('should handle zero seconds', () => {
const result = convertSecondsToTimeString(timeFixtures.seconds.zero);
expect(result).toBe('0s');
});
it('should handle one second', () => {
const result = convertSecondsToTimeString(timeFixtures.seconds.oneSecond);
expect(result).toBe('1s');
});
it('should handle one minute', () => {
const result = convertSecondsToTimeString(timeFixtures.seconds.oneMinute);
expect(result).toBe('1m');
});
it('should handle one hour', () => {
const result = convertSecondsToTimeString(timeFixtures.seconds.oneHour);
expect(result).toBe('1h');
});
it('should handle complex time (1h 1m 1s)', () => {
const result = convertSecondsToTimeString(timeFixtures.seconds.complex);
expect(result).toBe(timeFixtures.expected.complexSec);
});
it('should handle large time values (24h)', () => {
const result = convertSecondsToTimeString(timeFixtures.seconds.daysWorthSec);
expect(result).toBe(timeFixtures.expected.daysSec);
});
it('should handle fractional input (should floor to integer seconds)', () => {
const result = convertSecondsToTimeString(timeFixtures.seconds.fractionalInput);
expect(result).toBe('1h 1m 1.5s'); // Function doesn't actually floor, it preserves fractional seconds
});
it('should handle negative values', () => {
const result = convertSecondsToTimeString(-60);
expect(result).toBe('0s'); // Current implementation doesn't handle negatives properly
});
it('should not include seconds when there are hours and minutes but no remainder seconds', () => {
const result = convertSecondsToTimeString(3660); // 1h 1m 0s
expect(result).toBe('1h 1m');
});
});
describe('coloredTextCodeBlock', () => {
beforeAll(() => {
// Fixtures are already loaded above
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
});
it('should create red colored text block', () => {
const input = colorFixtures.testText.simple;
const result = coloredTextCodeBlock(input, 'red');
expect(result).toBe(colorFixtures.expected.red.simple);
});
it('should create blue colored text block', () => {
const input = colorFixtures.testText.simple;
const result = coloredTextCodeBlock(input, 'blue');
expect(result).toBe(colorFixtures.expected.blue.simple);
});
it('should create green colored text block', () => {
const input = colorFixtures.testText.simple;
const result = coloredTextCodeBlock(input, 'green');
expect(result).toBe(colorFixtures.expected.green.simple);
});
it('should create yellow colored text block', () => {
const input = colorFixtures.testText.simple;
const result = coloredTextCodeBlock(input, 'yellow');
expect(result).toBe(colorFixtures.expected.yellow.simple);
});
it('should handle empty text with red color', () => {
const result = coloredTextCodeBlock('', 'red');
expect(result).toBe(colorFixtures.expected.red.empty);
});
it('should handle empty text with blue color', () => {
const result = coloredTextCodeBlock('', 'blue');
expect(result).toBe(colorFixtures.expected.blue.empty);
});
it('should handle empty text with green color', () => {
const result = coloredTextCodeBlock('', 'green');
expect(result).toBe(colorFixtures.expected.green.empty);
});
it('should handle empty text with yellow color', () => {
const result = coloredTextCodeBlock('', 'yellow');
expect(result).toBe(colorFixtures.expected.yellow.empty);
});
it('should handle multiline text', () => {
const input = colorFixtures.testText.multiline;
const result = coloredTextCodeBlock(input, 'red');
expect(result).toContain('Line 1\nLine 2\nLine 3');
expect(result).toContain('```ansi');
expect(result).toContain('\u001B[2;31m');
});
it('should handle special characters', () => {
const input = colorFixtures.testText.withSpecialChars;
const result = coloredTextCodeBlock(input, 'green');
expect(result).toContain(input);
expect(result).toContain('```ansi');
expect(result).toContain('\u001B[2;36m');
});
it('should handle unicode characters', () => {
const input = colorFixtures.testText.unicode;
const result = coloredTextCodeBlock(input, 'yellow');
expect(result).toContain(input);
expect(result).toContain('```ansi');
expect(result).toContain('\u001B[2;33m');
});
it('should handle code-like text', () => {
const input = colorFixtures.testText.code;
const result = coloredTextCodeBlock(input, 'blue');
expect(result).toContain(input);
expect(result).toContain('```ansi');
expect(result).toContain('\u001B[2;32m\u001B[2;36m\u001B[2;34m');
});
it('should return original text for invalid color', () => {
const input = 'test text';
// TypeScript should prevent this, but testing runtime behavior
const result = coloredTextCodeBlock(input, 'invalid' as any);
expect(result).toBe(input);
});
});
describe('Edge cases and error handling', () => {
beforeAll(() => {
// Fixtures are already loaded above
markupFixtures = JSON.parse(fs.readFileSync(markupFixturesPath, 'utf8'));
timeFixtures = JSON.parse(fs.readFileSync(timeFixturesPath, 'utf8'));
colorFixtures = JSON.parse(fs.readFileSync(colorFixturesPath, 'utf8'));
});
it('should handle malformed markup gracefully', () => {
const input = '<b>unclosed bold <i>nested italic</b> text</i>';
const result = cleanText(input);
expect(result).toContain('**');
expect(result).toContain('*');
});
it('should handle deeply nested markup', () => {
const input = '<b><i><color=red><a href=showinfo:587>Deep Nesting</a></color></i></b>';
const result = cleanText(input);
expect(result).toBe('***[Deep Nesting](https://everef.net/types/587)***');
});
it('should handle very large time values', () => {
const largeValue = 1000 * 60 * 60 * 24 * 365; // 1 year in ms
const result = convertMillisecondsToTimeString(largeValue);
expect(result).toContain('h');
expect(result.length).toBeGreaterThan(0);
});
it('should handle very small time values', () => {
const result = convertMillisecondsToTimeString(1);
expect(result).toBe('0.0s');
});
it('should handle text with no markup', () => {
const input = 'Plain text with no markup';
const result = cleanText(input);
expect(result).toBe(input);
});
it('should handle only whitespace in markup', () => {
const input = '<b> </b>';
const result = cleanText(input);
expect(result).toBe('** **');
});
it('should handle EVE links with very large IDs', () => {
const input = '<a href=showinfo:999999999>Large ID Item</a>';
const result = cleanText(input);
expect(result).toBe('[Large ID Item](https://everef.net/types/999999999)');
});
it('should handle color tags with various hex formats', () => {
const testCases = [
{ input: '<color=0xABC123>hex with 0x</color>', expected: 'hex with 0x' },
{ input: '<color=ABC123>hex without 0x</color>', expected: 'hex without 0x' },
{ input: '<color=0xABC12345>8-digit hex</color>', expected: '8-digit hex' },
];
testCases.forEach(({ input, expected }) => {
const result = cleanText(input);
expect(result).toBe(expected);
});
});
});

View File

@@ -0,0 +1,103 @@
import { truncateText } from '@/util/text.js';
export function cleanText(input: string, maxLength: number = 1000): string {
return truncateText(replaceBoldTextMarkup(replaceItalicTextMarkup(removeColorTags(removeLinks(input.trim())))), maxLength);
}
function replaceBoldTextMarkup(input: string): string {
// replace all <b>name</b>, <b>name, and name</b> with **name** using regex
const regex = /<b>([^<]*)<\/b>|<b>([^<]*)|([^<]*)<\/b>/g;
return input.replace(regex, (match, p1, p2, p3) => {
if (p1) return `**${p1}**`;
if (p2) return `**${p2}**`;
if (p3) return `**${p3}**`;
return match;
});
}
function replaceItalicTextMarkup(input: string): string {
// replace all <i>name</i>, <i>name, and name</i> with *name* using regex
const regex = /<i>([^<]*)<\/i>|<i>([^<]*)|([^<]*)<\/i>/g;
return input.replace(regex, (match, p1, p2, p3) => {
if (p1) return `*${p1}*`;
if (p2) return `*${p2}*`;
if (p3) return `*${p3}*`;
return match;
});
}
function removeColorTags(input: string): string {
const regex = /<color=(?:0x)?([0-9a-fA-F]{6,8}|[a-zA-Z]+)>(.*?)<\/color>/g;
return input.replace(regex, '$2');
}
function convertToDiscordLinks(input: string): string {
const regex = /<a href=showinfo:(\d+)>(.*?)<\/a>/g;
return input.replace(regex, (match, number, text) => {
const eveRefLink = `https://everef.net/types/${number}`;
return `[${text}](${eveRefLink})`;
});
}
function removeLinks(input: string): string {
const regex = /<a href=showinfo:(\d+)>(.*?)<\/a>/g;
return input.replace(regex, (match, number, text) => {
return text;
});
}
export function convertMillisecondsToTimeString(milliseconds: number): string {
const totalSeconds = milliseconds / 1000;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
if (secs > 0 || parts.length === 0) {
// Include seconds if it's the only part
parts.push(`${secs.toFixed(1)}s`);
}
return parts.join(' ');
}
export function convertSecondsToTimeString(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
if (secs > 0 || parts.length === 0) {
// Include seconds if it's the only part
parts.push(`${secs}s`);
}
return parts.join(' ');
}
export function coloredTextCodeBlock(text: string, color: 'green' | 'blue' | 'red' | 'yellow'): string {
switch (color) {
case 'red':
return '```ansi\n' + text + '```\n';
case 'blue':
return '```ansi\n' + text + '```\n';
case 'yellow':
return '```ansi\n' + text + '```\n';
case 'green':
return '```ansi\n' + text + '```\n';
}
}

View File

@@ -0,0 +1,73 @@
import fs from 'node:fs';
import { chain } from 'stream-chain';
import { parser } from 'stream-json';
import { streamObject } from 'stream-json/streamers/StreamObject';
import { create, insert, search } from '@orama/orama';
import { normalize } from '@/util/text.js';
import { getType, type Type } from '@/eve/models/type';
const db = create({
schema: {
type_id: 'number',
name: {
en: 'string',
de: 'string',
fr: 'string',
ru: 'string',
ja: 'string',
zh: 'string',
},
},
});
export async function initializeTypeSearch() {
return new Promise((resolve, reject) => {
const pipeline = chain([fs.createReadStream('../../data/reference-data/types.json'), parser(), streamObject(), (data) => data]);
pipeline.on('data', async ({ value }) => {
if (value && value.market_group_id && value.published) {
try {
await addType(value);
} catch (e) {
reject(e);
}
}
});
pipeline.on('error', reject);
pipeline.on('end', resolve);
});
}
const addType = async (type: Type) =>
await insert(db, {
type_id: type.type_id,
name: type.name,
});
export async function typeSearch(name: string) {
let now = Date.now();
const normalizedName = normalize(name);
if (normalizedName.length > 100) return null;
const results = await search(db, {
term: normalizedName,
limit: 1,
tolerance: 0,
});
if (!results || results.count === 0) return null;
now = Date.now();
const type = await getType(results.hits[0].document.type_id);
return type;
}
export async function typeSearchAutoComplete(name: string) {
const normalizedName = normalize(name);
if (normalizedName.length > 100) return null;
const results = await search(db, {
term: normalizedName,
});
if (!results || results.count === 0) return null;
return results.hits.map((hit) => ({
name: hit.document.name.en,
value: hit.document.name.en,
}));
}

View File

@@ -0,0 +1,3 @@
export * as util from './util';
export * as eve from './eve';
export * as discord from './discord';

View File

@@ -0,0 +1,62 @@
import fs from 'fs';
import path from 'path';
import { Readable } from 'stream';
import { exec } from 'child_process';
export async function downloadAndExtract(url: string, outputDir: string): Promise<void> {
const response = await fetch(url);
if (!response.ok || !response.body) throw new Error(`Failed to download ${url}`);
const nodeStream = Readable.fromWeb(response.body as any);
const compressedFilePath = path.join(outputDir, 'archive.tar.xz');
const fileStream = fs.createWriteStream(compressedFilePath);
nodeStream.pipe(fileStream);
return new Promise((resolve, reject) => {
fileStream.on('finish', () => {
// Use native tar command to extract files
exec(`tar -xJf ${compressedFilePath} -C ${outputDir}`, (error, stdout, stderr) => {
if (error) {
console.error(`Extraction error: ${stderr}`);
reject(error);
} else {
console.log('Extraction complete');
// Clean up the archive file
fs.unlink(compressedFilePath, (err) => {
if (err) {
console.error(`Error removing archive: ${err.message}`);
reject(err);
} else {
console.log('Archive cleaned up');
resolve();
}
});
}
});
});
fileStream.on('error', (err) => {
console.error('File stream error', err);
reject(err);
});
});
}
// CLI execution (only runs when file is executed directly)
if (import.meta.main) {
const args = process.argv.slice(2);
if (args.length !== 2) {
console.error('Usage: bun run downloadAndExtract.ts <url> <outputDir>');
process.exit(1);
}
const [url, outputDir] = args;
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
downloadAndExtract(url, outputDir).catch((err) => console.error('Download failed', err));
}

View File

@@ -0,0 +1,7 @@
export * as scheduler from './scheduler';
export * from './json-query';
export * from './kv';
export * from './logger';
export * from './reactive-state';
export * from './text';
export * from './time';

View File

@@ -0,0 +1,262 @@
import { describe, it, expect, beforeEach } from 'bun:test';
import { queryJsonArray, queryJsonObject } from './json-query';
import * as path from 'path';
// Test data interfaces
interface TestUser {
id: number;
name: string;
age: number;
department: string;
}
interface TestKeyValue {
key: string;
value: any;
}
// Test file paths
const basePath = path.join(__dirname, '../fixtures/jsonQuery');
const testArrayFile = path.join(basePath, 'test-data-array.json');
const testObjectFile = path.join(basePath, 'test-data-object.json');
const testInvalidFile = path.join(basePath, 'test-data-invalid.json');
const nonExistentFile = path.join(basePath, 'non-existent.json');
describe('queryJsonArray', () => {
beforeEach(() => {
// Clear any existing cache before each test
const NodeCache = require('node-cache');
const cache = new NodeCache();
cache.flushAll();
});
it('should find a matching item in JSON array', async () => {
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Alice');
expect(result).not.toBeNull();
expect(result?.name).toBe('Alice');
expect(result?.id).toBe(1);
expect(result?.age).toBe(30);
expect(result?.department).toBe('Engineering');
});
it('should find the first matching item when multiple matches exist', async () => {
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.department === 'Engineering');
expect(result).not.toBeNull();
expect(result?.name).toBe('Alice'); // First engineering employee
expect(result?.id).toBe(1);
});
it('should return null when no match is found', async () => {
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'NonExistent');
expect(result).toBeNull();
});
it('should handle complex query conditions', async () => {
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.age > 30 && user.department === 'Engineering');
expect(result).not.toBeNull();
expect(result?.name).toBe('Charlie');
expect(result?.age).toBe(35);
});
it('should cache results when cacheKey is provided', async () => {
const cacheKey = 'test-alice-query';
// First call should hit the file
const result1 = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Alice', cacheKey);
// Second call should hit the cache (we can't directly verify this without mocking,
// but we can verify the result is consistent)
const result2 = await queryJsonArray<TestUser>(
testArrayFile,
(user) => user.name === 'Bob', // Different query, but should return cached Alice
cacheKey,
);
expect(result1).toEqual(result2);
expect(result1?.name).toBe('Alice');
expect(result2?.name).toBe('Alice');
});
it('should respect custom cache expiry', async () => {
const cacheKey = 'test-expiry-query';
const customExpiry = 1; // 1 second
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Bob', cacheKey, customExpiry);
expect(result).not.toBeNull();
expect(result?.name).toBe('Bob');
});
it('should handle file read errors gracefully', async () => {
await expect(queryJsonArray<TestUser>(nonExistentFile, (user) => user.name === 'Alice')).rejects.toThrow();
});
it('should handle invalid JSON gracefully', async () => {
await expect(queryJsonArray<TestUser>(testInvalidFile, (user) => user.name === 'Alice')).rejects.toThrow();
});
it('should work with numeric queries', async () => {
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.id === 3);
expect(result).not.toBeNull();
expect(result?.name).toBe('Charlie');
expect(result?.id).toBe(3);
});
it('should work with range queries', async () => {
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.age >= 30 && user.age <= 32);
expect(result).not.toBeNull();
expect(result?.name).toBe('Alice'); // First match: age 30
});
it('should handle empty query results', async () => {
const result = await queryJsonArray<TestUser>(
testArrayFile,
(user) => user.age > 100, // No one is over 100
);
expect(result).toBeNull();
});
});
describe('queryJsonObject', () => {
beforeEach(() => {
// Clear any existing cache before each test
const NodeCache = require('node-cache');
const cache = new NodeCache();
cache.flushAll();
});
it('should find a matching key-value pair in JSON object', async () => {
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'users');
expect(result).not.toBeNull();
expect(typeof result).toBe('object');
expect(result?.alice?.name).toBe('Alice');
expect(result?.bob?.name).toBe('Bob');
});
it('should find nested object values', async () => {
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'departments');
expect(result).not.toBeNull();
expect(result?.engineering?.name).toBe('Engineering');
expect(result?.marketing?.budget).toBe(500000);
});
it('should find specific configuration values', async () => {
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'config');
expect(result).not.toBeNull();
expect(result?.version).toBe('1.0.0');
expect(result?.environment).toBe('test');
});
it('should return null when no match is found', async () => {
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'nonexistent');
expect(result).toBeNull();
});
it('should handle complex query conditions on values', async () => {
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => {
if (item.key === 'departments' && typeof item.value === 'object') {
return Object.values(item.value).some((dept: any) => dept.budget > 800000);
}
return false;
});
expect(result).not.toBeNull();
expect(result?.engineering?.budget).toBe(1000000);
});
it('should cache results when cacheKey is provided', async () => {
const cacheKey = 'test-config-query';
// First call should hit the file
const result1 = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'config', cacheKey);
// Second call should hit the cache
const result2 = await queryJsonObject<any, TestKeyValue>(
testObjectFile,
(item) => item.key === 'users', // Different query, but should return cached config
cacheKey,
);
expect(result1).toEqual(result2);
expect(result1?.version).toBe('1.0.0');
expect(result2?.version).toBe('1.0.0');
});
it('should respect custom cache expiry', async () => {
const cacheKey = 'test-object-expiry-query';
const customExpiry = 2; // 2 seconds
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => item.key === 'users', cacheKey, customExpiry);
expect(result).not.toBeNull();
expect(result?.alice?.name).toBe('Alice');
});
it('should handle file read errors gracefully', async () => {
await expect(queryJsonObject<any, TestKeyValue>(nonExistentFile, (item) => item.key === 'config')).rejects.toThrow();
});
it('should handle invalid JSON gracefully', async () => {
await expect(queryJsonObject<any, TestKeyValue>(testInvalidFile, (item) => item.key === 'config')).rejects.toThrow();
});
it('should work with value-based queries', async () => {
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => {
return typeof item.value === 'object' && item.value?.version === '1.0.0';
});
expect(result).not.toBeNull();
expect(result?.version).toBe('1.0.0');
expect(result?.environment).toBe('test');
});
it('should handle queries that check both key and value', async () => {
const result = await queryJsonObject<any, TestKeyValue>(testObjectFile, (item) => {
return item.key.startsWith('dep') && typeof item.value === 'object';
});
expect(result).not.toBeNull();
expect(result?.engineering?.name).toBe('Engineering');
});
});
describe('Edge cases and error handling', () => {
it('should handle empty file paths', async () => {
await expect(queryJsonArray<any>('', () => true)).rejects.toThrow();
});
it('should handle null query functions gracefully', async () => {
await expect(queryJsonArray<TestUser>(testArrayFile, null as any)).rejects.toThrow();
});
it('should handle undefined query functions gracefully', async () => {
await expect(queryJsonArray<TestUser>(testArrayFile, undefined as any)).rejects.toThrow();
});
it('should work without caching parameters', async () => {
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Diana');
expect(result).not.toBeNull();
expect(result?.name).toBe('Diana');
expect(result?.department).toBe('Sales');
});
it('should work with minimal cache configuration', async () => {
const result = await queryJsonArray<TestUser>(testArrayFile, (user) => user.name === 'Eve', 'minimal-cache');
expect(result).not.toBeNull();
expect(result?.name).toBe('Eve');
expect(result?.age).toBe(32);
});
});

Some files were not shown because too many files have changed in this diff Show More