From 12de4a71488a385f588e92c5bb9a8f50110f1e1c Mon Sep 17 00:00:00 2001 From: xenticore Date: Mon, 24 Mar 2025 15:39:28 -0400 Subject: [PATCH 01/16] refactor: moved components to separate modules `content.ts` is now half less than the size, exported functions can be unit tested --- src/content/config.ts | 12 + src/content/content.ts | 365 ++---------------------------- src/content/priceService.ts | 81 +++++++ src/content/schemaService.ts | 109 +++++++++ src/content/uiRenderer.ts | 33 +++ src/content/utils/dom.ts | 9 + src/content/utils/formatting.ts | 28 +++ src/content/utils/localization.ts | 40 ++++ src/content/{ => utils}/log.ts | 0 9 files changed, 326 insertions(+), 351 deletions(-) create mode 100644 src/content/config.ts create mode 100644 src/content/priceService.ts create mode 100644 src/content/schemaService.ts create mode 100644 src/content/uiRenderer.ts create mode 100644 src/content/utils/dom.ts create mode 100644 src/content/utils/formatting.ts create mode 100644 src/content/utils/localization.ts rename src/content/{ => utils}/log.ts (100%) diff --git a/src/content/config.ts b/src/content/config.ts new file mode 100644 index 0000000..41e0477 --- /dev/null +++ b/src/content/config.ts @@ -0,0 +1,12 @@ +// Constants +export const storage_lastUpdateTime = 'tf2wikipricing_lastUpdate'; +export const storage_schema = 'tf2wikipricing_schema'; +export const storage_version = 'tf2wikipricing_version'; +export const storage_priceprefix = 'tf2wikipricing_sku_'; +export const conversion_ref_usd = 0.0265; +export const defindex_key = 5021; +export const defindex_metal_refined = 5002; +export const defindex_metal_reclaimed = 5001; +export const defindex_metal_scrap = 5000; + +export * as config from './config'; \ No newline at end of file diff --git a/src/content/content.ts b/src/content/content.ts index aa983e7..f603810 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -1,57 +1,16 @@ -import { getStorageValue, setStorageValue } from './storage' import styleCss from './style.css' -declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise -import './GM_fetch' -import { logDebug, log, logError } from './log' +import { logDebug, log, logError } from './utils/log' import { getPricesToken, priceUsingPricesTF } from './pricing/pricestf' -const semver = require('semver') -// Globals import itemQualities from 'tf2-static-schema/static/qualities.json'; -var itemSchema: { [key: string]: {name: string, tradable: Boolean}; } | null; +import { getItemIndexByName, getTradableStatusByDefindex, ItemSchema, prepareSchema, wipeSchema } from './schemaService' +import { $T, extractLocaleFromURL } from './utils/localization' +import { fetchPrice, fetchKeyPrice, ItemPriceData } from './priceService' +import { createPriceRow, createStoreButton } from './uiRenderer' +import { findFirstElement, findFirstChildElement } from './utils/dom' +var itemSchema: ItemSchema | null; -declare const __VERSION__: string; - -// Constants -const storage_lastUpdateTime = 'tf2wikipricing_lastUpdate'; -const storage_schema = 'tf2wikipricing_schema'; -const storage_version = 'tf2wikipricing_version'; -const storage_priceprefix = 'tf2wikipricing_sku_'; -const conversion_ref_usd = 0.0265; -const defindex_key = 5021; -const defindex_metal_refined = 5002; -const defindex_metal_reclaimed = 5001; -const defindex_metal_scrap = 5000; - -/** Pricing data for a given TF2 item. */ -class ItemPriceData { - /** Item SKU. */ - sku: string - /** Date updated. */ - update: Date - /** TTL in milliseconds. */ - ttl: number - /** Price in keys. */ - keys: number - /** Price in refined metal. */ - metal: number - /** Steam Community Market price. */ - scmPrice: number - - toString(): string { - return `Price for ${this.sku}, fetched ${this.update} (expires ${this.update.getTime() + this.ttl})\n` + - JSON.stringify({ - keys: this.keys, - metal: this.metal, - scmPrice: this.scmPrice - }) - } -} - -class SteamMarketSearchResult { - name: string - sell_price: number -} +var pageLocale: string = 'en' /** Exclude these from the pricelist. */ const excludedQualities = new Set([ @@ -59,48 +18,7 @@ const excludedQualities = new Set([ 5, // Unusual ]); -const localizations: {[lang: string]: any} = { - 'en': require('../strings/en'), // English - 'es': require('../strings/es'), // Spanish - // 'ja': require('../strings/ja'), // Japanese - // 'it': require('../strings/it'), // Italian - // 'ar': require('../strings/ar.json') as object, // Arabic - // 'cs': require('../strings/cs.json') as object, // Czech - // 'da': require('../strings/da.json') as object, // Danish - // 'de': require('../strings/de.json') as object, // German - // 'fi': require('../strings/fi.json') as object, // Finnish - // 'fr': require('../strings/fr.json') as object, // French - // 'hu': require('../strings/hu.json') as object, // Hungarian - // 'ko': require('../strings/ko.json') as object, // Korean - // 'nl': require('../strings/nl.json') as object, // Dutch - // 'no': require('../strings/no.json') as object, // Norwegian Bokmål - // 'pl': require('../strings/pl.json') as object, // Polish - // 'pt': require('../strings/pt.json') as object, // Portuguese - // 'pt-BR': require('../strings/pt-BR.json') as object, // Brazilian Portuguese - // 'ro': require('../strings/ro.json') as object, // Romanian - // 'ru': require('../strings/ru.json') as object, // Russian - // 'sv': require('../strings/sv.json') as object, // Swedish - // 'tr': require('../strings/tr.json') as object, // Turkish - // 'zh-Hans': require('../strings/zh-Hans.json') as object, // Simplified Chinese - // 'zh-Hant': require('../strings/zh-Hant.json') as object, // Traditional Chinese -} - -function $T(s: string, locale?: Intl.LocalesArgument): string { - const code = locale ? locale.toString() : extractLocaleFromURL(document.URL) - return localizations.hasOwnProperty(code) ? (localizations[code as unknown as keyof object])[s] || s : s; -} - // Helper functions -function findFirstElement(selector: string): HTMLElement | null { - const elements = document.querySelectorAll(selector); - return elements.length > 0 ? elements[0] as HTMLElement : null; -} - -function findFirstChildElement(selector: string, root: Element): HTMLElement | null { - const elements = root.querySelectorAll(selector); - return elements.length > 0 ? elements[0] as HTMLElement : null; -} - function getKeyByValue(object: any, value: string) { return Object.keys(object).find(key => object[key] === value); } @@ -114,44 +32,6 @@ function extractPageTitleFromURL(url: string): string { return decodeURIComponent(split.replaceAll('_', ' ')); } -function extractLocaleFromURL(url: string): string { - var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length); - if (split.indexOf('/') != -1) { - // Remove language suffix e.g. `/es` - return split.substring(split.indexOf('/') + 1); - } else { - return 'en'; - } -} - -function isDateAfterOneDay(date1: Date, date2: Date): boolean { - var diff = date2.getTime() - date1.getTime(); - var diffDays = Math.round(diff / (1000 * 3600 * 24)); - return diffDays > 1; -} - -function getItemIndexByName(name: string) { - for (const [defindex, value] of Object.entries(itemSchema)) { - if (value['name'] == name) { - return parseInt(defindex) - } - } - return null -} - -function getTradableStatusByDefindex(defindex: number) { - return itemSchema[defindex.toString()].tradable -} - -function getTradableStatusByName(name: string) { - for (const [defindex, value] of Object.entries(itemSchema)) { - if (value['name'] == name) { - return value.tradable - } - } - return true -} - // Main function async function inject() { const itemInfobox = findFirstElement('.item-infobox'); @@ -196,7 +76,7 @@ async function inject() { const url = document.URL; if (itemName && !itemIndex) { - itemIndex = getItemIndexByName(itemName) + itemIndex = getItemIndexByName(itemSchema, itemName) } } @@ -213,7 +93,7 @@ async function inject() { log(`Starting lookup for ${itemName} (defindex ${itemIndex})`); - if(getTradableStatusByDefindex(itemIndex) == false) { + if(getTradableStatusByDefindex(itemSchema, itemIndex) == false) { log(`${itemName} is not tradable, exiting`) return; } @@ -329,45 +209,6 @@ async function inject() { const qualifiedName = ((quality != 6 ? itemQualities[quality as unknown as keyof typeof itemQualities].toString() : '') + ' ' + itemName).trim() // logDebug(`Fetching price for ${qualifiedName}`) - /* - var data: ItemPriceData | null; - const cached = await getStorageValue(storage_priceprefix + sku, null) - if (cached != null && 'keys' in cached && 'metal' in cached) { - data = cached - } - if (!data || 'update' in data && 'ttl' in data && Date.now() > (new Date(data.update).getTime() + data.ttl)) { - data = new ItemPriceData() - data.sku = sku - data.update = new Date() - data.ttl = (5 * 60 * 1000) // Cache results for 5 minutes - - // logDebug(JSON.stringify(steamMarketResults)) - // const scmResult = steamMarketResults.find((x: object) => { x['name' as keyof object] === qualifiedName}) - // logDebug(JSON.stringify(scmResult)) - // if(scmResult) { - // data.scmPrice = scmResult.sell_price / 100 - // } - - try { - const response = await priceUsingPricesTF(token, itemIndex, quality) - if (response) { - data.keys = response.keys - data.metal = response.metal - } - } catch (error) { - log(`Received ${error} error while pricing ${sku} using prices.tf`) - } - - if ('keys' in data && 'metal' in data) { - await setStorageValue(storage_priceprefix + sku, data) - } - logDebug(JSON.stringify(data)); - updateTime = new Date(data.update) - } else { - logDebug(`Using cached data for ${sku}, expires ${new Date(data.update).getTime() + data.ttl}`); - updateTime = new Date(data.update) - } - */ var data: ItemPriceData | null try { data = await fetchPrice(token, itemIndex, quality, currentTime); @@ -376,24 +217,8 @@ async function inject() { log(`${qualifiedName} is unpriced or unavailable, skipping...`) } - const priceRow = document.createElement("tr"); - - const priceLabel = document.createElement("td"); - priceLabel.className = "infobox-label"; - const priceLabelLink = document.createElement("a"); const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString() - priceLabelLink.href = locale === 'en' ? `https://wiki.teamfortress.com/wiki/${qualityName}` : `https://wiki.teamfortress.com/wiki/${qualityName}/${locale}` - priceLabelLink.innerText = $T(qualityName) - priceLabel.appendChild(priceLabelLink); - priceLabel.innerHTML += ':' - priceRow.appendChild(priceLabel); - - const priceData = document.createElement("td"); - const priceLink = document.createElement("span"); - const priceString = data ? formatPrice(data.keys, data.metal, keyPrice.metal).trim() : $T('Data unavailable') - priceLink.innerHTML = priceString // + `
$${data.scmPrice}` - priceData.appendChild(priceLink); - priceRow.appendChild(priceData); + const priceRow = createPriceRow(qualityName, data, keyPrice, locale) priceRows.push({quality: quality, row: priceRow}) }) @@ -428,170 +253,6 @@ async function inject() { }) } -function createStoreButton(storeName: string, url: URL) { - const button = document.createElement("tr") - var source = `` - source = source.replace("{link}", url.toString()) - source = source.replace("{title}", $T("View listings on %@").replace('%@', storeName)) - button.innerHTML = source - return button -} - -async function fetchKeyPrice(token: string) { - return fetchPrice(token, defindex_key, 6, new Date(), 86400000) -} - -/** - * Fetch a price for a given SKU, using cached values if available. - * @param token prices.tf access token. - * @param update Date retrieved. - * @param ttl Time to cache results in milliseconds. 30 minutes by default. - */ -async function fetchPrice(token: string, defIndex: number, quality: number, update: Date = new Date(), ttl: number = 30 * 60 * 1000): Promise { - return new Promise(async (resolve, reject) => { - const sku = defIndex.toString() + ";" + quality.toString(); - var data: ItemPriceData | null - - const cached = await getStorageValue(storage_priceprefix + sku, null) - if (cached != null && 'keys' in cached && 'metal' in cached) { - data = cached - } - - if (!data || data.sku != sku || 'update' in data && 'ttl' in data && Date.now() > (new Date(data.update).getTime() + data.ttl)) { - logDebug(`Fetching price data for ${sku}`) - if(!token) { - reject(401) - } - data = new ItemPriceData() - data.sku = sku - data.update = update - data.ttl = ttl - - try { - const response = await priceUsingPricesTF(token, defIndex, quality) - if (response) { - data.keys = response.keys - data.metal = response.metal - } - } catch (error) { - log(`Received ${error} error while pricing ${sku} using prices.tf`) - reject(error) - } - - if ('metal' in data && 'keys' in data) { - await setStorageValue(storage_priceprefix + sku, data) - } - } else { - logDebug(`Using cached price data for ${sku}`) - } - resolve(data) - }) -} - -async function getSteamResults(itemName: string) { - logDebug(`Making network request to Steam for ${itemName}`) - const response = await GM_fetch(`https://steamcommunity.com/market/search/render?appid=440&norender=true&count=10&query=${encodeURIComponent(itemName)}`, { - method: 'get', - headers: new Headers({ - 'Accept': 'application/json' - }) - }) - if (response.status === 200) { - const json = await response.json(); - return json['results'] - } - return [] -} - -function toFixed(num: number, fixed: number) { - var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?'); - return num.toString().match(re)[0]; -} - -var pageLocale: string = 'en' - -function formatPrice(keys: number, metal: number, keyPrice: number) { - const pureMetal = (keys * keyPrice) + metal; - const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2) - - var output: string = '' - if(keys > 0) { - output += (formattedKeys == 1.0 ? $T("%@ key") : $T("%@ keys")).replace('%@', formattedKeys.toLocaleString(pageLocale)) - } else { - output += `${(+toFixed(metal, 2)).toLocaleString(pageLocale)} ref` - } - const currencyFormatter = new Intl.NumberFormat(pageLocale, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - - // Round price up to nearest cent - const price = Math.ceil(pureMetal * conversion_ref_usd * 100) / 100 - output += ` (US$${currencyFormatter.format(price)})` - return output; -} - -async function prepareSchema() { - var needsUpdate: Boolean = false - - const storedVersion: string | null = await getStorageValue(storage_version, null) - if(!storedVersion || !semver.valid(storedVersion)) { - log(`Cache is from an unknown version of the extension. Updating for version ${__VERSION__}`); - needsUpdate = true - } else if(semver.valid(storedVersion) && semver.lt(storedVersion, __VERSION__)) { - log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`); - needsUpdate = true - } else { - itemSchema = await getStorageValue(storage_schema, null); - } - - const update = await getStorageValue(storage_lastUpdateTime, null) - if (update) { - log(`Item schema updated at ${new Date(update)}`); - const lastUpdateTime = new Date(update); - if (!itemSchema || isDateAfterOneDay(lastUpdateTime, new Date())) { - needsUpdate = true - } - } - - if(needsUpdate) { - log("Item Schema out of Date. Rebuilding..."); - const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json" - const response = await GM_fetch(url); - if (response.ok) { - await setStorageValue(storage_lastUpdateTime, new Date().getTime()); - - var cacheItems = {} - - var responseItems: any[] = await response.json() - // We want to keep the keys `defindex`, `item_name`, and `attributes` - responseItems.forEach((item: any) => { - const defindex = item['defindex'] - const name = item['item_name'] - var tradable: Boolean = true - try { - if(item['attributes'] != null) { - if(item['attributes'].find((attribute: {}) => (attribute as any)['class'] == "cannot_trade")) { - tradable = false - } - } - } catch(error) { - logError(error) - log(item) - } - (cacheItems as any)[defindex.toString()] = { "name": name, "tradable": tradable } - }); - - await setStorageValue(storage_schema, (cacheItems)); - itemSchema = cacheItems - await setStorageValue(storage_version, __VERSION__); - logDebug(`Item schema updated at ${new Date()}`) - } else { - logError("Could not fetch item schema."); - } - } -} - function addStyles() { const head = document.head || document.getElementsByTagName('head')[0], style = document.createElement('style'); @@ -599,9 +260,11 @@ function addStyles() { style.innerHTML = styleCss; } -prepareSchema().then(function () { +prepareSchema().then(function (schema) { + itemSchema = schema; if (!itemSchema) { logError("No item schema ready, exiting."); + wipeSchema(); // FIXME: ugly hack. requires additional page reload. if prepareSchema returns null, we should handle it properly return; } pageLocale = extractLocaleFromURL(document.URL) diff --git a/src/content/priceService.ts b/src/content/priceService.ts new file mode 100644 index 0000000..fd40e0d --- /dev/null +++ b/src/content/priceService.ts @@ -0,0 +1,81 @@ +import { defindex_key, storage_priceprefix } from "./config" +import { priceUsingPricesTF } from "./pricing/pricestf" +import { getStorageValue, setStorageValue } from "./storage" +import { logDebug, log } from "./utils/log" + +/** Pricing data for a given TF2 item. */ +export class ItemPriceData { + /** Item SKU. */ + sku: string + /** Date updated. */ + update: Date + /** TTL in milliseconds. */ + ttl: number + /** Price in keys. */ + keys: number + /** Price in refined metal. */ + metal: number + /** Steam Community Market price. */ + scmPrice: number + + toString(): string { + return `Price for ${this.sku}, fetched ${this.update} (expires ${this.update.getTime() + this.ttl})\n` + + JSON.stringify({ + keys: this.keys, + metal: this.metal, + scmPrice: this.scmPrice + }) + } +} + + +export async function fetchKeyPrice(token: string) { + return fetchPrice(token, defindex_key, 6, new Date(), 86400000) +} + +/** + * Fetch a price for a given SKU, using cached values if available. + * @param token prices.tf access token. + * @param update Date retrieved. + * @param ttl Time to cache results in milliseconds. 30 minutes by default. + */ +export async function fetchPrice(token: string, defIndex: number, quality: number, update: Date = new Date(), ttl: number = 30 * 60 * 1000): Promise { + return new Promise(async (resolve, reject) => { + const sku = defIndex.toString() + ";" + quality.toString(); + var data: ItemPriceData | null + + const cached = await getStorageValue(storage_priceprefix + sku, null) + if (cached != null && 'keys' in cached && 'metal' in cached) { + data = cached + } + + if (!data || data.sku != sku || 'update' in data && 'ttl' in data && Date.now() > (new Date(data.update).getTime() + data.ttl)) { + logDebug(`Fetching price data for ${sku}`) + if(!token) { + reject(401) + } + data = new ItemPriceData() + data.sku = sku + data.update = update + data.ttl = ttl + + try { + const response = await priceUsingPricesTF(token, defIndex, quality) + if (response) { + data.keys = response.keys + data.metal = response.metal + } + } catch (error) { + log(`Received ${error} error while pricing ${sku} using prices.tf`) + reject(error) + } + + if ('metal' in data && 'keys' in data) { + await setStorageValue(storage_priceprefix + sku, data) + } + } else { + logDebug(`Using cached price data for ${sku}`) + } + resolve(data) + }) +} diff --git a/src/content/schemaService.ts b/src/content/schemaService.ts new file mode 100644 index 0000000..219f7a6 --- /dev/null +++ b/src/content/schemaService.ts @@ -0,0 +1,109 @@ +import { getStorageValue, setStorageValue } from './storage' +import { logDebug, log, logError } from './utils/log' +import './config' +declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise +import './GM_fetch' +import { storage_version, storage_schema, storage_lastUpdateTime } from './config' +const semver = require('semver') + +export declare const __VERSION__: string; + +function isDateAfterOneDay(date1: Date, date2: Date): boolean { + var diff = date2.getTime() - date1.getTime(); + var diffDays = Math.round(diff / (1000 * 3600 * 24)); + return diffDays > 1; +} + +export class ItemSchema { [key: string]: {name: string, tradable: Boolean}; } + +export function getItemIndexByName(schema: ItemSchema, name: string) { + for (const [defindex, value] of Object.entries(schema)) { + if (value['name'] == name) { + return parseInt(defindex) + } + } + return null +} + +export function getTradableStatusByDefindex(schema: ItemSchema, defindex: number) { + return schema[defindex.toString()].tradable +} + +export function getTradableStatusByName(schema: ItemSchema, name: string) { + for (const [defindex, value] of Object.entries(schema)) { + if (value['name'] == name) { + return value.tradable + } + } + return true +} + +export async function wipeSchema(): Promise { + await setStorageValue(storage_version, __VERSION__) + await setStorageValue(storage_schema, null) + await setStorageValue(storage_lastUpdateTime, new Date().toISOString()) + logDebug(`Schema wiped`) +} + +export async function prepareSchema(): Promise { + var needsUpdate: Boolean = false + var itemSchema: ItemSchema | null = null + + const storedVersion: string | null = await getStorageValue(storage_version, null) + if(!storedVersion || !semver.valid(storedVersion)) { + log(`Cache is from an unknown version of the extension. Updating for version ${__VERSION__}`); + needsUpdate = true + } else if(semver.valid(storedVersion) && semver.lt(storedVersion, __VERSION__)) { + log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`); + needsUpdate = true + } else { + itemSchema = await getStorageValue(storage_schema, null); + } + + const update = await getStorageValue(storage_lastUpdateTime, null) + if (update) { + const lastUpdateTime = new Date(update); + log(`Item schema updated at ${lastUpdateTime}`); + if (itemSchema == null || Object.keys(itemSchema).length === 0 || isDateAfterOneDay(lastUpdateTime, new Date())) { + needsUpdate = true + } + } + + if(needsUpdate) { + log("Item Schema out of Date. Rebuilding..."); + const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json" + const response = await GM_fetch(url); + if (response.ok) { + await setStorageValue(storage_lastUpdateTime, new Date().getTime()); + + var cacheItems = {} + + var responseItems: any[] = await response.json() + // We want to keep the keys `defindex`, `item_name`, and `attributes` + responseItems.forEach((item: any) => { + const defindex = item['defindex'] + const name = item['item_name'] + var tradable: Boolean = true + try { + if(item['attributes'] != null) { + if(item['attributes'].find((attribute: {}) => (attribute as any)['class'] == "cannot_trade")) { + tradable = false + } + } + } catch(error) { + logError(error) + log(item) + } + (cacheItems as any)[defindex.toString()] = { "name": name, "tradable": tradable } + }); + + await setStorageValue(storage_schema, (cacheItems)); + itemSchema = cacheItems + await setStorageValue(storage_version, __VERSION__); + logDebug(`Item schema updated at ${new Date()}`) + } else { + logError("Could not fetch item schema."); + } + } + return itemSchema +} \ No newline at end of file diff --git a/src/content/uiRenderer.ts b/src/content/uiRenderer.ts new file mode 100644 index 0000000..38c30ce --- /dev/null +++ b/src/content/uiRenderer.ts @@ -0,0 +1,33 @@ +import { ItemPriceData } from "./priceService"; +import { formatPrice } from "./utils/formatting"; +import { $T } from "./utils/localization"; + +export function createPriceRow(qualityName: string, data: ItemPriceData, keyPrice: ItemPriceData, locale: string): HTMLTableRowElement { + const priceRow = document.createElement("tr"); + + const priceLabel = document.createElement("td"); + priceLabel.className = "infobox-label"; + const priceLabelLink = document.createElement("a"); + priceLabelLink.href = locale === 'en' ? `https://wiki.teamfortress.com/wiki/${qualityName}` : `https://wiki.teamfortress.com/wiki/${qualityName}/${locale}` + priceLabelLink.innerText = $T(qualityName) + priceLabel.appendChild(priceLabelLink); + priceLabel.innerHTML += ':' + priceRow.appendChild(priceLabel); + + const priceData = document.createElement("td"); + const priceLink = document.createElement("span"); + const priceString = data ? formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim() : $T('Data unavailable') + priceLink.innerHTML = priceString // + `
$${data.scmPrice}` + priceData.appendChild(priceLink); + priceRow.appendChild(priceData); + return priceRow; +} + +export function createStoreButton(storeName: string, url: URL) { + const button = document.createElement("tr") + var source = `` + source = source.replace("{link}", url.toString()) + source = source.replace("{title}", $T("View listings on %@").replace('%@', storeName)) + button.innerHTML = source + return button +} diff --git a/src/content/utils/dom.ts b/src/content/utils/dom.ts new file mode 100644 index 0000000..722c5d5 --- /dev/null +++ b/src/content/utils/dom.ts @@ -0,0 +1,9 @@ +export function findFirstElement(selector: string): HTMLElement | null { + const elements = document.querySelectorAll(selector); + return elements.length > 0 ? elements[0] as HTMLElement : null; +} + +export function findFirstChildElement(selector: string, root: Element): HTMLElement | null { + const elements = root.querySelectorAll(selector); + return elements.length > 0 ? elements[0] as HTMLElement : null; +} diff --git a/src/content/utils/formatting.ts b/src/content/utils/formatting.ts new file mode 100644 index 0000000..25083db --- /dev/null +++ b/src/content/utils/formatting.ts @@ -0,0 +1,28 @@ +import { conversion_ref_usd } from '../config'; +import { $T } from './localization' + +function toFixed(num: number, fixed: number) { + var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?'); + return num.toString().match(re)[0]; +} + +export function formatPrice(keys: number, metal: number, keyPrice: number, locale: string = 'en') { + const pureMetal = (keys * keyPrice) + metal; + const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2) + + var output: string = '' + if(keys > 0) { + output += (formattedKeys == 1.0 ? $T("%@ key") : $T("%@ keys")).replace('%@', formattedKeys.toLocaleString(locale)) + } else { + output += `${(+toFixed(metal, 2)).toLocaleString(locale)} ref` + } + const currencyFormatter = new Intl.NumberFormat(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + // Round price up to nearest cent + const price = Math.ceil(pureMetal * conversion_ref_usd * 100) / 100 + output += ` (US$${currencyFormatter.format(price)})` + return output; +} diff --git a/src/content/utils/localization.ts b/src/content/utils/localization.ts new file mode 100644 index 0000000..6b262b0 --- /dev/null +++ b/src/content/utils/localization.ts @@ -0,0 +1,40 @@ +const localizations: {[lang: string]: any} = { + 'en': require('../../strings/en'), // English + 'es': require('../../strings/es'), // Spanish + // 'ja': require('../../strings/ja'), // Japanese + // 'it': require('../../strings/it'), // Italian + // 'ar': require('../../strings/ar.json') as object, // Arabic + // 'cs': require('../../strings/cs.json') as object, // Czech + // 'da': require('../../strings/da.json') as object, // Danish + // 'de': require('../../strings/de.json') as object, // German + // 'fi': require('../../strings/fi.json') as object, // Finnish + // 'fr': require('../../strings/fr.json') as object, // French + // 'hu': require('../../strings/hu.json') as object, // Hungarian + // 'ko': require('../../strings/ko.json') as object, // Korean + // 'nl': require('../../strings/nl.json') as object, // Dutch + // 'no': require('../../strings/no.json') as object, // Norwegian Bokmål + // 'pl': require('../../strings/pl.json') as object, // Polish + // 'pt': require('../../strings/pt.json') as object, // Portuguese + // 'pt-BR': require('../../strings/pt-BR.json') as object, // Brazilian Portuguese + // 'ro': require('../../strings/ro.json') as object, // Romanian + // 'ru': require('../../strings/ru.json') as object, // Russian + // 'sv': require('../../strings/sv.json') as object, // Swedish + // 'tr': require('../../strings/tr.json') as object, // Turkish + // 'zh-Hans': require('../../strings/zh-Hans.json') as object, // Simplified Chinese + // 'zh-Hant': require('../../strings/zh-Hant.json') as object, // Traditional Chinese +} + +export function $T(s: string, locale?: Intl.LocalesArgument): string { + const code = locale ? locale.toString() : extractLocaleFromURL(document.URL) + return localizations.hasOwnProperty(code) ? (localizations[code as unknown as keyof object])[s] || s : s; +} + +export function extractLocaleFromURL(url: string): string { + var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length); + if (split.indexOf('/') != -1) { + // Remove language suffix e.g. `/es` + return split.substring(split.indexOf('/') + 1); + } else { + return 'en'; + } +} diff --git a/src/content/log.ts b/src/content/utils/log.ts similarity index 100% rename from src/content/log.ts rename to src/content/utils/log.ts From 6a3cbaaad9fdf54333365467402616de6e3fa6a2 Mon Sep 17 00:00:00 2001 From: xenticore Date: Mon, 24 Mar 2025 16:13:24 -0400 Subject: [PATCH 02/16] ci: run test on develop branch --- .gitea/workflows/build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 7e64112..84d63a9 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -7,6 +7,7 @@ on: push: branches: - main + - develop tags-ignore: - 'v*' From 1425b33e97ca2f9060195f61af385ef17f90362c Mon Sep 17 00:00:00 2001 From: xenticore Date: Mon, 24 Mar 2025 16:13:38 -0400 Subject: [PATCH 03/16] test: add localization tests --- __tests__/localization.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 __tests__/localization.test.ts diff --git a/__tests__/localization.test.ts b/__tests__/localization.test.ts new file mode 100644 index 0000000..e6699cf --- /dev/null +++ b/__tests__/localization.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, jest, beforeEach } from "bun:test"; +import { extractLocaleFromURL } from '../src/content/utils/localization' + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('localization', () => { + it('should assume `en` if no locale is specified', async () => { + // extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Team_Fortress_2') should return 'en' + expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Team_Fortress_2')).toBe('en') + }) + + it('should extract locale shortcode from URL correctly', async () => { + // extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Phlogistinator/de') should return 'de' + expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Phlogistinator/de')).toBe('de') + }) + + it('should extract locale shortcode with special characters from URL correctly', async () => { + // extractLocaleFromURL('https://wiki.teamfortress.com/wiki/%C3%9CberCharge/zh-hans') should return 'zh-hans' + expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/%C3%9CberCharge/zh-hans')).toBe('zh-hans') + }) +}) \ No newline at end of file From 3f51277bd5d2592d99a0d157b3480d695f1168da Mon Sep 17 00:00:00 2001 From: xenticore Date: Mon, 24 Mar 2025 19:38:05 -0400 Subject: [PATCH 04/16] test: remove unnecessary comments --- __tests__/localization.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/__tests__/localization.test.ts b/__tests__/localization.test.ts index e6699cf..0f929ed 100644 --- a/__tests__/localization.test.ts +++ b/__tests__/localization.test.ts @@ -7,17 +7,14 @@ beforeEach(() => { describe('localization', () => { it('should assume `en` if no locale is specified', async () => { - // extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Team_Fortress_2') should return 'en' expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Team_Fortress_2')).toBe('en') }) it('should extract locale shortcode from URL correctly', async () => { - // extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Phlogistinator/de') should return 'de' expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/Phlogistinator/de')).toBe('de') }) it('should extract locale shortcode with special characters from URL correctly', async () => { - // extractLocaleFromURL('https://wiki.teamfortress.com/wiki/%C3%9CberCharge/zh-hans') should return 'zh-hans' expect(extractLocaleFromURL('https://wiki.teamfortress.com/wiki/%C3%9CberCharge/zh-hans')).toBe('zh-hans') }) }) \ No newline at end of file From 60f90d6a37049dfdbcb477ab743a0b8904b4134e Mon Sep 17 00:00:00 2001 From: xenticore Date: Mon, 24 Mar 2025 19:38:14 -0400 Subject: [PATCH 05/16] test: add price formatting tests --- __tests__/formatting.test.ts | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 __tests__/formatting.test.ts diff --git a/__tests__/formatting.test.ts b/__tests__/formatting.test.ts new file mode 100644 index 0000000..7c4bf84 --- /dev/null +++ b/__tests__/formatting.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, test, jest, mock, beforeEach } from "bun:test"; +import { formatPrice } from '../src/content/utils/formatting'; +import { $T } from '../src/content/utils/localization'; + +mock.module('../src/content/utils/localization', () => ({ + $T: mock((str) => str) +})); + +mock.module('../src/content/config', () => ({ + conversion_ref_usd: 0.05 // Mock conversion rate +})) + +describe('formatPrice', () => { + beforeEach(() => { + jest.clearAllMocks() + }); + + test('formats price with keys and metal', () => { + expect(formatPrice(2, 10, 50)).toBe('2.2 keys (US$5.50)'); + expect($T).toHaveBeenCalledWith('%@ keys'); + }); + + test('formats price with metal only', () => { + expect(formatPrice(0, 15.75, 50)).toBe('15.75 ref (US$0.79)'); + }); + + test('formats price with metal only and whole number', () => { + expect(formatPrice(0, 3, 50)).toBe('3 ref (US$0.16)'); + }); + + test('uses singular key form', () => { + expect(formatPrice(1, 0, 50)).toBe('1 key (US$2.50)'); + expect($T).toHaveBeenCalledWith('%@ key'); + }); + + test('rounds USD up to nearest cent', () => { + expect(formatPrice(3, 7.33, 35)).toBe('3.21 keys (US$5.62)'); // (3*35 +7.33)*0.05 = 5.6165 → 5.62 + }); + + test('handles different locale formatting', () => { + expect(formatPrice(2, 10, 50, 'de')).toMatch(/2,2 keys \(US\$5,50\)/); + expect(formatPrice(0, 15.75, 50, 'de')).toMatch(/15,75 ref \(US\$0,79\)/); + }); + + test('handles zero values', () => { + expect(formatPrice(0, 0, 50)).toBe('0 ref (US$0.00)'); + expect(formatPrice(0, 0, 50, 'de')).toMatch(/0 ref \(US\$0,00\)/); + }); +}); \ No newline at end of file From 349ceacb4d627c678e8e14e0685c0d7c2a9c0764 Mon Sep 17 00:00:00 2001 From: xenticore Date: Mon, 24 Mar 2025 19:45:33 -0400 Subject: [PATCH 06/16] test: clean up calc comment --- __tests__/formatting.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/formatting.test.ts b/__tests__/formatting.test.ts index 7c4bf84..a2b2bbc 100644 --- a/__tests__/formatting.test.ts +++ b/__tests__/formatting.test.ts @@ -34,7 +34,7 @@ describe('formatPrice', () => { }); test('rounds USD up to nearest cent', () => { - expect(formatPrice(3, 7.33, 35)).toBe('3.21 keys (US$5.62)'); // (3*35 +7.33)*0.05 = 5.6165 → 5.62 + expect(formatPrice(3, 7.33, 35)).toBe('3.21 keys (US$5.62)'); // (3 * 35 + 7.33) * 0.05 = 5.6165 → 5.62 }); test('handles different locale formatting', () => { From 15841520fd8b591ef87ab29baaae2a43e8baaa66 Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 12:11:20 -0400 Subject: [PATCH 07/16] test: add mock DOM to test environment --- bun.lockb | Bin 165402 -> 166910 bytes bunfig.toml | 2 ++ happydom.ts | 3 +++ package.json | 1 + 4 files changed, 6 insertions(+) create mode 100644 bunfig.toml create mode 100644 happydom.ts diff --git a/bun.lockb b/bun.lockb index 4c684dc40400440a6d528b19409a25d1cd3d5a86..5aa90dda8ed1494f26e2cce6714a8d1e79ee50d6 100755 GIT binary patch delta 25092 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 R{;h#fQjc&p^GRjN{{nG;SEK*{ delta 23905 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 CF6M~< diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..986e271 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = "./happydom.ts" \ No newline at end of file diff --git a/happydom.ts b/happydom.ts new file mode 100644 index 0000000..9cae201 --- /dev/null +++ b/happydom.ts @@ -0,0 +1,3 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register(); \ No newline at end of file diff --git a/package.json b/package.json index 1a1e592..b76af1a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.3.0", "description": "Adds item pricing to the Team Fortress 2 wiki", "devDependencies": { + "@happy-dom/global-registrator": "^17.4.4", "@types/firefox-webext-browser": "^120.0.4", "@types/greasemonkey": "^4.0.7", "@types/html": "^1.0.4", From f6e92facbcd5cecc1a8c1b69d6a88b6dca62f79b Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 12:11:30 -0400 Subject: [PATCH 08/16] test: add item schema tests --- __tests__/schema.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 __tests__/schema.test.ts diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts new file mode 100644 index 0000000..5ccbc91 --- /dev/null +++ b/__tests__/schema.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { ItemSchema, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName} from '../src/content/schemaService' + +const mockSchema: ItemSchema = { + '5021': { name: 'Mann Co. Supply Crate Key', tradable: true }, + '15013': { name: 'Non-Tradable Item', tradable: false } +} + +describe('Schema Service', () => { + + test('getItemIndexByName returns correct defindex', () => { + expect(getItemIndexByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(5021) + expect(getItemIndexByName(mockSchema, 'Non-Existent Item')).toBeNull() + }) + + test('getTradableStatusByDefindex returns correct status', () => { + expect(getTradableStatusByDefindex(mockSchema, 5021)).toBe(true) + expect(getTradableStatusByDefindex(mockSchema, 15013)).toBe(false) + expect(() => getTradableStatusByDefindex(mockSchema, 999)).toThrow() + }) + + test('getTradableStatusByName returns correct status', () => { + expect(getTradableStatusByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(true) + expect(getTradableStatusByName(mockSchema, 'Non-Tradable Item')).toBe(false) + expect(getTradableStatusByName(mockSchema, 'Non-Existent Item')).toBe(true) + }) +}) \ No newline at end of file From 24f86b5fbce394e2bf746ba1657a50ab57d11896 Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 12:13:05 -0400 Subject: [PATCH 09/16] ci: declare global vars in test environment --- .gitea/workflows/build.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 84d63a9..348256d 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -18,10 +18,13 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4.1.2 + - name: Read package version + uses: tyankatsu0105/read-package-version-actions@v1 + id: version - name: Install dependencies run: bun install - name: Test project - run: bun test + run: bun test --define __VERSION__='${{ need.build.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' - name: Build project run: bun run build - name: Archive production artifacts From 305d702904b6a7790af2a0ab71f0d40cbfa0d1e2 Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 12:22:25 -0400 Subject: [PATCH 10/16] ci: fix incorrect output grab lol --- .gitea/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 348256d..349d978 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: bun install - name: Test project - run: bun test --define __VERSION__='${{ need.build.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' + run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' - name: Build project run: bun run build - name: Archive production artifacts From 335e45096fce148adb639ddbcd026fee5b637b7c Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 14:03:54 -0400 Subject: [PATCH 11/16] test: add price service and prices.tf tests --- __tests__/priceService.test.ts | 109 +++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 __tests__/priceService.test.ts diff --git a/__tests__/priceService.test.ts b/__tests__/priceService.test.ts new file mode 100644 index 0000000..b9c421d --- /dev/null +++ b/__tests__/priceService.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test, jest, mock, beforeEach } from "bun:test"; +import { ItemPriceData, fetchPrice, fetchKeyPrice } from '../src/content/priceService' +import { PricesResponse, priceUsingPricesTF } from '../src/content/pricing/pricestf' +import { getStorageValue, setStorageValue } from '../src/content/storage' +import { defindex_key } from "../src/content/config"; + +// Mock the storage module +mock.module('../src/content/storage', () => ({ + getStorageValue: jest.fn(), + setStorageValue: jest.fn() +})) + +// Mock the pricing module +mock.module('../src/content/pricing/pricestf', () => ({ + priceUsingPricesTF: jest.fn() +})) + +describe('Price Service', () => { + const mockToken = 'test-token' + const mockDefIndex = 105 // Brigade Helm + const mockQuality = 11 // Strange + const mockSku = `${mockDefIndex};${mockQuality}` + const mockDate = new Date() + const mockTtl = 30 * 60 * 1000 // 30 minutes + + const mockPriceResponse: PricesResponse = { + keys: 1, + metal: 21.33 + } + + const mockKeyPriceResponse: PricesResponse = { + keys: 0, + metal: 60.11 + } + + const mockCachedData: ItemPriceData = { + sku: mockSku, + update: new Date(Date.now() - 15 * 60 * 1000), // 15 minutes ago + ttl: mockTtl, + keys: 1, + metal: 21.11, + scmPrice: 0 + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('fetchPrice returns cached data if available and not expired', async () => { + (getStorageValue as jest.Mock).mockResolvedValue(mockCachedData) + + const result = await fetchPrice(mockToken, mockDefIndex, mockQuality) + + expect(getStorageValue).toHaveBeenCalledWith(expect.stringContaining(mockSku), null) + expect(result).toEqual(mockCachedData) + }) + + test('fetchPrice fetches new data when cache is expired', async () => { + const expiredCache: ItemPriceData = { ...mockCachedData, update: new Date(Date.now() - 2 * mockTtl) }; + (getStorageValue as jest.Mock).mockResolvedValue(expiredCache); + (priceUsingPricesTF as jest.Mock).mockResolvedValue(mockPriceResponse) + + const result = await fetchPrice(mockToken, mockDefIndex, mockQuality) + + expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, mockDefIndex, mockQuality) + expect(setStorageValue).toHaveBeenCalled() + expect(result.metal).not.toBe(mockCachedData.metal) + expect(result.metal).toBe(mockPriceResponse.metal) + }) + + test('fetchPrice rejects with 401 when no token provided', async () => { + await expect(fetchPrice('', mockDefIndex, mockQuality)).rejects.toBe(401) + }) + + test('fetchPrice handles pricing API errors', async () => { + const testError = 500; + (priceUsingPricesTF as jest.Mock).mockRejectedValue(testError); + (getStorageValue as jest.Mock).mockResolvedValue(null) + + await expect(fetchPrice(mockToken, mockDefIndex, mockQuality)).rejects.toBe(testError) + }) + + test('fetchKeyPrice uses correct parameters', async () => { + (getStorageValue as jest.Mock).mockResolvedValue(null); + (priceUsingPricesTF as jest.Mock).mockResolvedValue(mockKeyPriceResponse) + + const result = await fetchKeyPrice(mockToken) + + expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, defindex_key, 6) + expect(result.keys).toBe(0) // A key cannot cost a key :P + expect(result.metal).toBe(mockKeyPriceResponse.metal) + }) + + test('ItemPriceData.toString() returns formatted string', () => { + const data = new ItemPriceData() + data.sku = mockSku + data.update = mockDate + data.ttl = mockTtl + data.keys = 1 + data.metal = 10.66 + data.scmPrice = 2.68 + + const result = data.toString() + expect(result).toContain(`Price for ${mockSku}`) + expect(result).toContain(`"keys":${data.keys}`) + expect(result).toContain(`"metal":${data.metal}`) + expect(result).toContain(`"scmPrice":${data.scmPrice}`) + }) +}) From 7c7e58d0d2d2d0da9986b113954bba1856ed4d4a Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 14:51:13 -0400 Subject: [PATCH 12/16] refactor: move `extractPageTitleFromURL` to module, add tests --- __tests__/url.test.ts | 28 ++++++++++++++++++++++++++++ src/content/content.ts | 10 +--------- src/content/utils/url.ts | 8 ++++++++ 3 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 __tests__/url.test.ts create mode 100644 src/content/utils/url.ts diff --git a/__tests__/url.test.ts b/__tests__/url.test.ts new file mode 100644 index 0000000..4129b5d --- /dev/null +++ b/__tests__/url.test.ts @@ -0,0 +1,28 @@ +import { extractPageTitleFromURL } from '../src/content/utils/url'; + +describe('extractPageTitleFromURL', () => { + it('extracts simple title from URL', () => { + const url = 'https://wiki.teamfortress.com/wiki/Scattergun'; + expect(extractPageTitleFromURL(url)).toBe('Scattergun'); + }); + + it('replaces underscores with spaces', () => { + const url = 'https://wiki.teamfortress.com/wiki/Flame_Thrower'; + expect(extractPageTitleFromURL(url)).toBe('Flame Thrower'); + }); + + it('decodes URI components', () => { + const url = 'https://wiki.teamfortress.com/wiki/Dragon%27s_Fury'; + expect(extractPageTitleFromURL(url)).toBe("Dragon's Fury"); + }); + + it('handles special characters', () => { + const url = 'https://wiki.teamfortress.com/wiki/Ze_%C3%9Cbermensch'; + expect(extractPageTitleFromURL(url)).toBe("Ze Übermensch"); + }); + + it('removes language suffix', () => { + const url = 'https://wiki.teamfortress.com/wiki/Ze_%C3%9Cbermensch/pt-br'; + expect(extractPageTitleFromURL(url)).toBe('Ze Übermensch'); + }); +}); diff --git a/src/content/content.ts b/src/content/content.ts index f603810..35bbe5e 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -8,6 +8,7 @@ import { $T, extractLocaleFromURL } from './utils/localization' import { fetchPrice, fetchKeyPrice, ItemPriceData } from './priceService' import { createPriceRow, createStoreButton } from './uiRenderer' import { findFirstElement, findFirstChildElement } from './utils/dom' +import { extractPageTitleFromURL } from './utils/url'; var itemSchema: ItemSchema | null; var pageLocale: string = 'en' @@ -23,15 +24,6 @@ function getKeyByValue(object: any, value: string) { return Object.keys(object).find(key => object[key] === value); } -function extractPageTitleFromURL(url: string): string { - var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length); - if (split.indexOf('/') != -1) { - // Remove language suffix (/es) - split = split.substring(0, split.indexOf('/')); - } - return decodeURIComponent(split.replaceAll('_', ' ')); -} - // Main function async function inject() { const itemInfobox = findFirstElement('.item-infobox'); diff --git a/src/content/utils/url.ts b/src/content/utils/url.ts new file mode 100644 index 0000000..3aa388c --- /dev/null +++ b/src/content/utils/url.ts @@ -0,0 +1,8 @@ +export function extractPageTitleFromURL(url: string): string { + var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length); + if (split.indexOf('/') != -1) { + // Remove language suffix (/es) + split = split.substring(0, split.indexOf('/')); + } + return decodeURIComponent(split.replaceAll('_', ' ')); +} From b24cda98ad5522a1e50e9e1044f51edb5544b03d Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 15:28:53 -0400 Subject: [PATCH 13/16] doc: fix incomplete documentation of `priceUsingPricesTF` --- src/content/pricing/pricestf.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/content/pricing/pricestf.ts b/src/content/pricing/pricestf.ts index 4f83674..cdb273b 100644 --- a/src/content/pricing/pricestf.ts +++ b/src/content/pricing/pricestf.ts @@ -25,8 +25,17 @@ class PricesResponse { } /** - * Price the given item using https://prices.tf - * @return + * Fetches the current price data for Team Fortress 2 items from prices.tf. + * + * This function authenticates with the prices.tf API using the provided token, + * and uses it to fetch the latest pricing data for the given item in keys and metal. + * + * @example + * const price = await priceUsingPricesTF(token, 105, 11); + * console.log("Strange Brigade Helm price: ${price.keys} keys ${price.metal} metal") + * + * @returns {Promise} Object containing 'keys' and 'metal' prices + * @throws When authentication fails or API returns non-200 status code */ async function priceUsingPricesTF(token: string, defIndex: number, quality: number): Promise { // prices.tf @@ -37,7 +46,6 @@ async function priceUsingPricesTF(token: string, defIndex: number, quality: numb reject(401) } const sku = defIndex + ";" + quality; - // logDebug(`Making network request to prices.tf for ${sku}`) var response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, { method: 'get', headers: new Headers({ @@ -64,4 +72,4 @@ async function priceUsingPricesTF(token: string, defIndex: number, quality: numb }) } -export { getPricesToken, priceUsingPricesTF } \ No newline at end of file +export { getPricesToken, priceUsingPricesTF, PricesResponse } \ No newline at end of file From 967a32fc83c0bd3c145c2641feaaeb249b041527 Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 15:32:45 -0400 Subject: [PATCH 14/16] fix: prioritize non-stock/non-decorated items Changed schema lookups to prioritize non-stock/non-decorated item defindexes when names collide Modified unit tests to include example (stock Flame Thrower) --- __tests__/schema.test.ts | 10 ++++++++-- src/content/content.ts | 33 ++++++++++++++++++--------------- src/content/schemaService.ts | 12 +++++++++--- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts index 5ccbc91..2f34c1d 100644 --- a/__tests__/schema.test.ts +++ b/__tests__/schema.test.ts @@ -2,24 +2,30 @@ import { describe, expect, test } from "bun:test"; import { ItemSchema, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName} from '../src/content/schemaService' const mockSchema: ItemSchema = { + '21': { name: 'Flame Thrower', tradable: false }, + '208': { name: 'Flame Thrower', tradable: true }, '5021': { name: 'Mann Co. Supply Crate Key', tradable: true }, - '15013': { name: 'Non-Tradable Item', tradable: false } + '15141': { name: 'Flame Thrower', tradable: true }, + '69420': { name: 'Non-Tradable Item', tradable: false } } describe('Schema Service', () => { test('getItemIndexByName returns correct defindex', () => { + expect(getItemIndexByName(mockSchema, 'Flame Thrower')).toBe(208) expect(getItemIndexByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(5021) expect(getItemIndexByName(mockSchema, 'Non-Existent Item')).toBeNull() }) test('getTradableStatusByDefindex returns correct status', () => { + expect(getTradableStatusByDefindex(mockSchema, 208)).toBe(true) expect(getTradableStatusByDefindex(mockSchema, 5021)).toBe(true) - expect(getTradableStatusByDefindex(mockSchema, 15013)).toBe(false) + expect(getTradableStatusByDefindex(mockSchema, 69420)).toBe(false) expect(() => getTradableStatusByDefindex(mockSchema, 999)).toThrow() }) test('getTradableStatusByName returns correct status', () => { + expect(getTradableStatusByName(mockSchema, 'Flame Thrower')).toBe(true) expect(getTradableStatusByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(true) expect(getTradableStatusByName(mockSchema, 'Non-Tradable Item')).toBe(false) expect(getTradableStatusByName(mockSchema, 'Non-Existent Item')).toBe(true) diff --git a/src/content/content.ts b/src/content/content.ts index 35bbe5e..066b717 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -35,24 +35,10 @@ async function inject() { var itemIndex: number | null = null; var itemName: string | null = null; - // Try using buy buttons, if they exist + // Find buy buttons const buyButton = findFirstChildElement('.btn_buynow', itemInfobox); const marketButton = findFirstChildElement('.btn_buynow_market', itemInfobox); - if(buyButton) { - const link = (buyButton.parentElement as HTMLLinkElement); - if(link && link.href) { - itemIndex = parseInt(link.href.replace('https://store.steampowered.com/buyitem/440/', '')); - } - } - - if(!itemIndex && marketButton) { - const link = (marketButton.parentElement as HTMLLinkElement); - if(link && link.href) { - itemIndex = parseInt(link.href.replace('https://steamcommunity.com/market/search/?q=appid:440+prop_def_index:', '')); - } - } - // Try using item name const header = findFirstChildElement('.infobox-header', itemInfobox); if (!itemIndex && header) { @@ -72,6 +58,23 @@ async function inject() { } } + // Try using buy buttons, if they exist + if(!itemIndex) { + if(buyButton) { + const link = (buyButton.parentElement as HTMLLinkElement); + if(link && link.href) { + itemIndex = parseInt(link.href.replace('https://store.steampowered.com/buyitem/440/', '')); + } + } + + if(!itemIndex && marketButton) { + const link = (marketButton.parentElement as HTMLLinkElement); + if(link && link.href) { + itemIndex = parseInt(link.href.replace('https://steamcommunity.com/market/search/?q=appid:440+prop_def_index:', '')); + } + } + } + if (!itemIndex) { // Cannot continue without index logError(itemName ? `Could not find defindex for ${itemName}` : `Could not determine item defindex or name`); diff --git a/src/content/schemaService.ts b/src/content/schemaService.ts index 219f7a6..1c6e847 100644 --- a/src/content/schemaService.ts +++ b/src/content/schemaService.ts @@ -16,10 +16,13 @@ function isDateAfterOneDay(date1: Date, date2: Date): boolean { export class ItemSchema { [key: string]: {name: string, tradable: Boolean}; } -export function getItemIndexByName(schema: ItemSchema, name: string) { +export function getItemIndexByName(schema: ItemSchema, name: string, excludeStock: Boolean = true, excludeDecorated: Boolean = true) { for (const [defindex, value] of Object.entries(schema)) { if (value['name'] == name) { - return parseInt(defindex) + const index = parseInt(defindex) + if(excludeStock && index <= 30) continue + if(excludeDecorated && (index >= 15000 && index < 16000)) continue + return index } } return null @@ -29,9 +32,12 @@ export function getTradableStatusByDefindex(schema: ItemSchema, defindex: number return schema[defindex.toString()].tradable } -export function getTradableStatusByName(schema: ItemSchema, name: string) { +export function getTradableStatusByName(schema: ItemSchema, name: string, excludeStock: Boolean = true, excludeDecorated = true,) { for (const [defindex, value] of Object.entries(schema)) { if (value['name'] == name) { + const index = parseInt(defindex) + if(excludeStock && index <= 30) continue + if(excludeDecorated && (index >= 15000 && index < 16000)) continue return value.tradable } } From 87a6fc5d1ce88c3048444c9e96f6505ff6fed392 Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 16:57:17 -0400 Subject: [PATCH 15/16] ci: release builds with production mode --- .gitea/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 4d9cd91..08d1dce 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -22,7 +22,7 @@ jobs: - name: Test project run: bun test - name: Build project - run: bun run build + run: bun run build --mode production - name: Archive production artifacts uses: actions/upload-artifact@v3 with: From 507970fdde80600202b9c531d362eb28cd6c1329 Mon Sep 17 00:00:00 2001 From: xenticore Date: Thu, 27 Mar 2025 16:57:35 -0400 Subject: [PATCH 16/16] bump version to 0.3.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b76af1a..9d59646 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tf2wikipricing", - "version": "0.3.0", + "version": "0.3.1", "description": "Adds item pricing to the Team Fortress 2 wiki", "devDependencies": { "@happy-dom/global-registrator": "^17.4.4",