bump version to 0.8.0

Reviewed-on: http://charon.local/git/xen/tf2wikipricing/pulls/21
This commit is contained in:
Xen
2025-05-01 19:29:06 -04:00
49 changed files with 1487 additions and 323 deletions

View File

@@ -23,13 +23,14 @@ jobs:
id: version id: version
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Test project - name: Test UserScript build
run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' --define __ENV_USERSCRIPT=1 --define __ENV_WEBEXTENSION=0
- name: Test WebExtension build
run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' --define __ENV_USERSCRIPT=0 --define __ENV_WEBEXTENSION=1
- name: Build project - name: Build project
run: bun run build run: bun run build
- name: Archive production artifacts - name: Archive production artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: tf2wikipricing.user.js name: tf2wikipricing
path: | path: dist/
dist/userscript/tf2wikipricing.user.js

View File

@@ -19,16 +19,17 @@ jobs:
id: version id: version
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Test project - name: Test UserScript build
run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' --define __ENV_USERSCRIPT=1 --define __ENV_WEBEXTENSION=0
- name: Test WebExtension build
run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"' --define __ENV_USERSCRIPT=0 --define __ENV_WEBEXTENSION=1
- name: Build project - name: Build project
run: bun run build --mode production run: bun run build --mode production
- name: Archive production artifacts - name: Archive production artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: tf2wikipricing.user.js name: tf2wikipricing
path: | path: dist/
dist/userscript/tf2wikipricing.user.js
deploy: deploy:
runs-on: debian-latest runs-on: debian-latest
needs: build needs: build
@@ -36,8 +37,14 @@ jobs:
- name: Download release artifacts - name: Download release artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: tf2wikipricing.user.js name: tf2wikipricing
path: userscript path: dist/
- name: Package Chrome extension
run: |
cd dist/
echo "${{ secrets.CRX_PRIVATE_KEY }}" > private.pem
bun x crx pack -p private.pem -o tf2wikipricing.crx extension/
rm -f private.pem
- name: Create release - name: Create release
id: use-go-action id: use-go-action
uses: akkuman/gitea-release-action@v1 uses: akkuman/gitea-release-action@v1
@@ -45,5 +52,6 @@ jobs:
title: "v${{ need.build.outputs.version }}" title: "v${{ need.build.outputs.version }}"
name: "v${{ need.build.outputs.version }}" name: "v${{ need.build.outputs.version }}"
files: | files: |
userscript/** dist/tf2wikipricing.crx
dist/tf2wikipricing.user.js
sha256sum: true sha256sum: true

View File

@@ -57,7 +57,7 @@ describe('prepareExchangeRates', () => {
const rates = await prepareExchangeRates(); const rates = await prepareExchangeRates();
expect(rates).toEqual(mockRates); expect(rates).toEqual(mockRates);
expect(GM_fetch).not.toHaveBeenCalled(); expect(GM_fetch as jest.Mock).not.toHaveBeenCalled();
}); });
it('should fetch new rates when they are expired', async () => { it('should fetch new rates when they are expired', async () => {
@@ -66,17 +66,18 @@ describe('prepareExchangeRates', () => {
if (key === storage_exchangerates_next) return new Date(Date.now() - 50000).toISOString(); if (key === storage_exchangerates_next) return new Date(Date.now() - 50000).toISOString();
return null; return null;
}); });
(GM_fetch as jest.Mock).mockResolvedValue({ const mockResponse = {
ok: true,
json: async () => ({
rates: mockRates, rates: mockRates,
time_next_update_utc: new Date(Date.now() + 100000).toISOString() time_next_update_utc: new Date(Date.now() + 100000).toISOString()
}) };
(GM_fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => (mockResponse)
}); });
(chrome.runtime.sendMessage as jest.Fn).mockImplementation(() => mockResponse);
const rates = await prepareExchangeRates(); const rates = await prepareExchangeRates();
expect(rates).toEqual(mockRates); expect(rates).toEqual(mockRates);
expect(GM_fetch).toHaveBeenCalled();
expect(setStorageValue).toHaveBeenCalledWith(storage_exchangerates, mockRates); expect(setStorageValue).toHaveBeenCalledWith(storage_exchangerates, mockRates);
}); });
@@ -86,6 +87,7 @@ describe('prepareExchangeRates', () => {
ok: false, ok: false,
status: 500 status: 500
} as Response); } as Response);
(chrome.runtime.sendMessage as jest.Fn).mockImplementation(() => {});
const rates = await prepareExchangeRates(); const rates = await prepareExchangeRates();
expect(rates).toBeNull(); expect(rates).toBeNull();

View File

@@ -35,7 +35,7 @@ describe('Price Service', () => {
const mockCachedData: ItemPriceData = { const mockCachedData: ItemPriceData = {
sku: mockSku, sku: mockSku,
update: new Date(Date.now() - 15 * 60 * 1000), // 15 minutes ago update: new Date(Date.now() - 15 * 60 * 1000).getTime(), // 15 minutes ago
ttl: mockTtl, ttl: mockTtl,
keys: 1, keys: 1,
metal: 21.11, metal: 21.11,
@@ -56,37 +56,37 @@ describe('Price Service', () => {
}) })
test('fetchPrice fetches new data when cache is expired', async () => { test('fetchPrice fetches new data when cache is expired', async () => {
const expiredCache: ItemPriceData = { ...mockCachedData, update: new Date(Date.now() - 2 * mockTtl) }; const expiredCache: ItemPriceData = { ...mockCachedData, update: new Date(Date.now() - 2 * mockTtl).getTime() };
(getStorageValue as jest.Mock).mockResolvedValue(expiredCache); (getStorageValue as jest.Mock).mockResolvedValue(expiredCache);
(priceUsingPricesTF as jest.Mock).mockResolvedValue(mockPriceResponse) (priceUsingPricesTF as jest.Mock).mockResolvedValue(mockPriceResponse);
(chrome.runtime.sendMessage as jest.Fn).mockImplementation(() => mockPriceResponse);
const result = await fetchPrice(mockToken, mockDefIndex + ";" + mockQuality) const result = await fetchPrice(mockToken, mockDefIndex + ";" + mockQuality)
expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, `${mockDefIndex};${mockQuality}`)
expect(setStorageValue).toHaveBeenCalled() expect(setStorageValue).toHaveBeenCalled()
expect(result.metal).not.toBe(mockCachedData.metal) expect(result.metal).not.toBe(mockCachedData.metal)
expect(result.metal).toBe(mockPriceResponse.metal) expect(result.metal).toBe(mockPriceResponse.metal)
}) })
test('fetchPrice rejects with 401 when no token provided', async () => { test('fetchPrice rejects with 401 when no token provided', async () => {
await expect(fetchPrice('', mockDefIndex + ";" + mockQuality)).rejects.toBe(401) await expect(fetchPrice('', mockDefIndex + ";" + mockQuality)).rejects.toThrow()
}) })
test('fetchPrice handles pricing API errors', async () => { test('fetchPrice handles pricing API errors', async () => {
const testError = 500; (chrome.runtime.sendMessage as jest.Fn).mockResolvedValue(null);
(priceUsingPricesTF as jest.Mock).mockRejectedValue(testError); (priceUsingPricesTF as jest.Mock).mockImplementation(() => Promise.reject(new Error('500 Internal Server Error')));
(getStorageValue as jest.Mock).mockResolvedValue(null) (getStorageValue as jest.Mock).mockResolvedValue(null)
await expect(fetchPrice(mockToken, mockDefIndex + ";" + mockQuality)).rejects.toBe(testError) await expect(fetchPrice(mockToken, mockDefIndex + ";" + mockQuality)).rejects.toThrow()
}) })
test('fetchKeyPrice uses correct parameters', async () => { test('fetchKeyPrice uses correct parameters', async () => {
(getStorageValue as jest.Mock).mockResolvedValue(null); (getStorageValue as jest.Mock).mockResolvedValue(null);
(priceUsingPricesTF as jest.Mock).mockResolvedValue(mockKeyPriceResponse) (priceUsingPricesTF as jest.Mock).mockResolvedValue(mockKeyPriceResponse);
(chrome.runtime.sendMessage as jest.Fn).mockImplementation(() => mockKeyPriceResponse);
const result = await fetchKeyPrice(mockToken) 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.keys).toBe(0) // A key cannot cost a key :P
expect(result.metal).toBe(mockKeyPriceResponse.metal) expect(result.metal).toBe(mockKeyPriceResponse.metal)
}) })

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, mock } from "bun:test"; import { describe, expect, test, mock, beforeEach, jest } from "bun:test";
import { ItemSchema, ItemSlot, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName, linkBotkillerVariants, linkFestiveVariants, prepareSchema } from '../src/content/schemaService' import { ItemSchema, ItemSlot, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName, linkBotkillerVariants, linkFestiveVariants, prepareSchema } from '../src/content/schemaService'
// Mock the storage and log functions // Mock the storage and log functions
@@ -13,6 +13,24 @@ mock.module('../src/content/utils/log', () => ({
logError: mock(() => {}) logError: mock(() => {})
})); }));
const mockSchemaResponse = [
{
defindex: 1,
item_name: 'Test Item',
item_slot: 'misc',
attributes: [
{ "name": "cannot trade", "class": "cannot_trade", "value": 1 }
],
capabilities: {}
},
{
defindex: 208,
item_name: 'Flame Thrower',
item_slot: 'primary',
capabilities: { can_killstreakify: true }
}
]
const mockSchema: ItemSchema = { const mockSchema: ItemSchema = {
'21': { '21': {
name: 'Flame Thrower', name: 'Flame Thrower',
@@ -89,6 +107,7 @@ const mockSchema: ItemSchema = {
} }
describe('Schema Service', () => { describe('Schema Service', () => {
test('getItemIndexByName returns correct defindex', () => { test('getItemIndexByName returns correct defindex', () => {
expect(getItemIndexByName(mockSchema, 'Flame Thrower')).toBe(208) expect(getItemIndexByName(mockSchema, 'Flame Thrower')).toBe(208)
expect(getItemIndexByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(5021) expect(getItemIndexByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(5021)
@@ -113,25 +132,12 @@ describe('Schema Service', () => {
// Mock GM_fetch response // Mock GM_fetch response
const mockResponse = { const mockResponse = {
ok: true, ok: true,
json: async () => [ json: async () => mockSchemaResponse
{
defindex: 1,
item_name: 'Test Item',
item_slot: 'misc',
attributes: [
{ "name": "cannot trade", "class": "cannot_trade", "value": 1 }
],
capabilities: {}
},
{
defindex: 208,
item_name: 'Flame Thrower',
item_slot: 'primary',
capabilities: { can_killstreakify: true }
}
]
}; };
// Mock Chrome runtime message
(chrome.runtime.sendMessage as jest.Fn).mockImplementation(() => mockSchemaResponse);
// Mock GM_fetch // Mock GM_fetch
globalThis.GM_fetch = mock(async () => mockResponse); globalThis.GM_fetch = mock(async () => mockResponse);

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,3 +1,8 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { mock } from "bun:test";
GlobalRegistrator.register(); GlobalRegistrator.register();
Object.assign(global, require('jest-chrome'))
// Mock GM_fetch
globalThis.GM_fetch = mock(async () => {});

View File

@@ -1,8 +1,13 @@
{ {
"name": "tf2wikipricing", "name": "tf2wikipricing",
"version": "0.7.1", "displayName": "TF2 Wiki Pricing",
"version": "0.8.0",
"description": "Adds item pricing to the Team Fortress 2 wiki", "description": "Adds item pricing to the Team Fortress 2 wiki",
"author": "rapture.party",
"devDependencies": { "devDependencies": {
"@eslint/css": "^0.7.0",
"@eslint/js": "^9.25.1",
"@eslint/json": "^0.12.0",
"@happy-dom/global-registrator": "^17.4.4", "@happy-dom/global-registrator": "^17.4.4",
"@types/firefox-webext-browser": "^120.0.4", "@types/firefox-webext-browser": "^120.0.4",
"@types/greasemonkey": "^4.0.7", "@types/greasemonkey": "^4.0.7",
@@ -12,10 +17,13 @@
"buffer": "^6.0.3", "buffer": "^6.0.3",
"bun-types": "^1.2.5", "bun-types": "^1.2.5",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^12.0.2",
"eslint": "^9.25.1",
"globals": "^16.0.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"tf2-static-schema": "^1.74.0", "tf2-static-schema": "^1.74.0",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"typescript-eslint": "^8.31.0",
"webpack": "^5.94.0", "webpack": "^5.94.0",
"webpack-cli": "^5.1.4" "webpack-cli": "^5.1.4"
}, },
@@ -35,12 +43,18 @@
"dependencies": { "dependencies": {
"@types/chrome": "^0.0.270", "@types/chrome": "^0.0.270",
"base64-inline-loader": "^2.0.1", "base64-inline-loader": "^2.0.1",
"crx": "^5.0.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"css-to-string-loader": "^0.1.3", "css-to-string-loader": "^0.1.3",
"extract-loader": "^5.1.0", "extract-loader": "^5.1.0",
"jest-chrome": "^0.8.0",
"jsonc-loader": "^0.1.1", "jsonc-loader": "^0.1.1",
"mini-css-extract-plugin": "^2.9.2",
"postcss-loader": "^8.1.1",
"postcss-url": "^10.1.3",
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"to-string-loader": "^1.2.0", "to-string-loader": "^1.2.0",
"url-loader": "^4.1.1" "url-loader": "^4.1.1",
"webpack-remove-empty-scripts": "^1.0.4"
} }
} }

View File

@@ -0,0 +1,111 @@
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.contentScriptQuery == "queryExchangeRates") {
const url = "https://open.er-api.com/v6/latest/USD";
fetch(url)
.then(response => response.json())
.then(json => sendResponse(json))
.catch(error => {
console.error("Failed to get exchange rates", error);
})
return true;
}
})
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.contentScriptQuery == "querySchema") {
const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json";
fetch(url)
.then(response => response.json())
.then(json => sendResponse(json))
.catch(error => {
console.error("Failed to get schema", error);
})
return true;
}
}
);
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.contentScriptQuery == "getPricesTFToken") {
fetch('https://api2.prices.tf/auth/access', {
method: 'post',
headers: new Headers({
'Accept': 'application/json'
})
})
.then(response => response.json())
.then(json => sendResponse(json['accessToken']))
.catch(error => {
console.error("Failed to get access token", error);
})
return true;
}
}
)
class PricesResponse {
keys: number
metal: number
}
async function priceUsingPricesTF(token: string, sku: string, retries: number = 3): Promise<PricesResponse> {
const url = `https://api2.prices.tf/prices/${encodeURIComponent(sku)}`;
const response = await fetch(url, {
method: 'get',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${token}`,
}
})
if (response.status === 404 && sku.includes(';') && !sku.includes(';uncraftable')) {
const quality: number = parseInt(sku.split(';')[1], 10);
if(quality === 6) {
// Try uncraftable variant if unique weapon
return priceUsingPricesTF(token, sku + ';uncraftable');
}
}
if(response.status === 503) {
// Happens if we send too many requests in a short period of time
// Retry after a few seconds
if(retries >= 0) {
console.log(`Cloudflare rate limit exceeded, trying again after 1 second, ${retries} retries left`)
await new Promise(resolve => setTimeout(resolve, 1000));
return priceUsingPricesTF(token, sku, retries - 1);
} else {
throw new Error(`Cloudflare rate limit exceeded, stopping`)
}
}
const data = await response.json();
const prices = new PricesResponse();
prices.keys = data['sellKeys']
prices.metal = data['sellHalfScrap'] / 18.0;
return prices;
}
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.contentScriptQuery == "priceSKU") {
const sku: string = request.sku
const service: string = request.service
const token: string = request.token
if(token === "" || !token) {
sendResponse(new Error("No token provided"))
return false;
}
switch (service) {
case "prices.tf": {
priceUsingPricesTF(token, sku)
.then((response) => sendResponse({response}))
.catch(error => {
sendResponse(error);
return false;
})
}
}
return true;
}
}
);

View File

@@ -1,7 +1,8 @@
import styleCss from './style.css' declare const __ENV_WEBEXTENSION: boolean;
declare const __ENV_USERSCRIPT: boolean;
import { logDebug, log, logError } from './utils/log' import { logDebug, log, logError } from './utils/log'
import { getPricesToken, priceUsingPricesTF } from './pricing/pricestf' import { getPricesToken } from './pricing/pricestf'
import itemQualities from 'tf2-static-schema/static/qualities.json'; import itemQualities from 'tf2-static-schema/static/qualities.json';
import { getItemIndexByName, getTradableStatusByDefindex, ItemSchema, ItemSlot, prepareSchema, wipeSchema } from './schemaService' import { getItemIndexByName, getTradableStatusByDefindex, ItemSchema, ItemSlot, prepareSchema, wipeSchema } from './schemaService'
import { $T, extractLocaleFromURL } from './utils/localization' import { $T, extractLocaleFromURL } from './utils/localization'
@@ -10,10 +11,10 @@ import { createPriceRow, createStoreButton } from './uiRenderer'
import { findFirstElement, findFirstChildElement } from './utils/dom' import { findFirstElement, findFirstChildElement } from './utils/dom'
import { extractPageTitleFromURL } from './utils/url'; import { extractPageTitleFromURL } from './utils/url';
import { ExchangeRates, prepareExchangeRates } from './exchangeRateService'; import { ExchangeRates, prepareExchangeRates } from './exchangeRateService';
var itemSchema: ItemSchema | null; let itemSchema: ItemSchema | null;
var exchangeRates: ExchangeRates | null; let exchangeRates: ExchangeRates | null;
var locale: string = 'en' let locale: string = 'en'
/** Exclude these from the pricelist. */ /** Exclude these from the pricelist. */
const excludedQualities = new Set([ const excludedQualities = new Set([
@@ -22,8 +23,13 @@ const excludedQualities = new Set([
]); ]);
// Helper functions // Helper functions
function getKeyByValue(object: any, value: string) { function getKeyByValue<K extends string | number | symbol, V>(obj: Record<K, V>, value: V): K | undefined {
return Object.keys(object).find(key => object[key] === value); for (const [key, val] of Object.entries(obj)) {
if (val === value) {
return key as unknown as K;
}
}
return undefined;
} }
// Main function // Main function
@@ -33,8 +39,8 @@ async function inject() {
// Not an item page // Not an item page
return; return;
} }
var itemIndex: number | null = null; let itemIndex: number | null = null;
var itemName: string | null = null; let itemName: string | null = null;
// Find buy buttons // Find buy buttons
const buyButton = findFirstChildElement('.btn_buynow', itemInfobox); const buyButton = findFirstChildElement('.btn_buynow', itemInfobox);
@@ -52,8 +58,6 @@ async function inject() {
} }
} }
const url = document.URL;
if (itemName && !itemIndex) { if (itemName && !itemIndex) {
itemIndex = getItemIndexByName(itemSchema, itemName) itemIndex = getItemIndexByName(itemSchema, itemName)
} }
@@ -94,7 +98,7 @@ async function inject() {
return; return;
} }
var qualities: number[] = [] const qualities: number[] = []
const firstQualityTag = findFirstChildElement('.quality-tag', itemInfobox); const firstQualityTag = findFirstChildElement('.quality-tag', itemInfobox);
@@ -122,7 +126,7 @@ async function inject() {
// th.infobox-header (Basic Information) // th.infobox-header (Basic Information)
// ... // ...
var storeButtons: HTMLTableRowElement[] = []; const storeButtons: HTMLTableRowElement[] = [];
// backpack.tf button // backpack.tf button
storeButtons.push(createStoreButton("backpack.tf", new URL(`https://backpack.tf/classifieds?item=${encodeURIComponent(itemName)}`))); storeButtons.push(createStoreButton("backpack.tf", new URL(`https://backpack.tf/classifieds?item=${encodeURIComponent(itemName)}`)));
@@ -171,7 +175,7 @@ async function inject() {
priceProgressRow.appendChild(priceProgressData); priceProgressRow.appendChild(priceProgressData);
priceInfoboxHeadingRow.insertAdjacentElement('afterend', priceProgressRow); priceInfoboxHeadingRow.insertAdjacentElement('afterend', priceProgressRow);
var token: string | null; let token: string | null;
// Steam Community Market // Steam Community Market
// TODO: Change this to lazy-load, so that it doesn't make network requests when we have cached data. // TODO: Change this to lazy-load, so that it doesn't make network requests when we have cached data.
@@ -187,7 +191,7 @@ async function inject() {
log('Failed to get an access token for prices.tf: ' + err); log('Failed to get an access token for prices.tf: ' + err);
} }
var updateTime: Date | null = null; let updateTime: Date | null = null;
enum PriceRowCategory { enum PriceRowCategory {
None, None,
@@ -201,19 +205,19 @@ async function inject() {
row: HTMLTableRowElement row: HTMLTableRowElement
category: PriceRowCategory category: PriceRowCategory
} }
var priceRows: PriceRow[]= []; const priceRows: PriceRow[]= [];
// Get current key price // Get current key price
const keyPrice = await fetchKeyPrice(token); const keyPrice = await fetchKeyPrice(token);
var currentTime = new Date() const currentTime = new Date()
const promises = qualities.filter(x => !excludedQualities.has(x)).map(async (quality) => { const promises = qualities.filter(x => !excludedQualities.has(x)).map(async (quality) => {
const qualifiedName = ((quality != 6 ? itemQualities[quality as unknown as keyof typeof itemQualities].toString() : '') + ' ' + itemName).trim() const qualifiedName = ((quality != 6 ? itemQualities[quality as unknown as keyof typeof itemQualities].toString() : '') + ' ' + itemName).trim()
// logDebug(`Fetching price for ${qualifiedName}`) logDebug(`Saving price for ${qualifiedName}`)
var data: ItemPriceData | null let data: ItemPriceData | null
try { try {
data = await fetchPrice(token, itemIndex + ";" + quality, currentTime); data = await fetchPrice(token, itemIndex + ";" + quality, currentTime);
updateTime = new Date(data.update) updateTime = new Date(data.update)
@@ -229,25 +233,21 @@ async function inject() {
// Check item schema for Australium variant of current defindex // Check item schema for Australium variant of current defindex
if(itemSchema[itemIndex].hasAustraliumVariant) { if(itemSchema[itemIndex].hasAustraliumVariant) {
promises.push(new Promise(async (resolve) => { promises.push(fetchPrice(token, `${itemIndex};11;australium`, currentTime).then(data => {
logDebug(`Fetching price for Australium ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${itemIndex};11;australium`, currentTime);
updateTime = new Date(data.update) updateTime = new Date(data.update)
} catch { logDebug(`Saving price for Australium ${itemName}`)
log(`Australium ${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T("Australium"), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons") const priceRow = createPriceRow($T("Australium"), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons")
priceRows.push({order: 99, row: priceRow, category: PriceRowCategory.None}) priceRows.push({order: 99, row: priceRow, category: PriceRowCategory.None})
resolve() })
return .catch((error) => {
logError(error)
log(`Australium ${itemName} is unpriced or unavailable, skipping...`)
})) }))
} }
var festiveHeadingRow: HTMLTableRowElement | null let festiveHeadingRow: HTMLTableRowElement | null
// Check item schema for Festive variant of current defindex // Check item schema for Festive variant of current defindex
if(itemSchema[itemIndex].festiveVariant != null) { if(itemSchema[itemIndex].festiveVariant != null) {
/// Create subheading /// Create subheading
@@ -261,41 +261,33 @@ async function inject() {
festiveHeadingRow.style.display = 'none'; festiveHeadingRow.style.display = 'none';
festiveHeadingRow.appendChild(festiveHeading); festiveHeadingRow.appendChild(festiveHeading);
promises.push(new Promise(async (resolve) => { promises.push(fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime).then(data => {
logDebug(`Fetching price for Festive ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime);
updateTime = new Date(data.update) updateTime = new Date(data.update)
} catch { logDebug(`Saving price for Festive ${itemName}`)
log(`Festive ${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T("Unique"), data, keyPrice, exchangeRates, locale) const priceRow = createPriceRow($T("Unique"), data, keyPrice, exchangeRates, locale)
priceRows.push({order: -1, row: priceRow, category: PriceRowCategory.Festive}) priceRows.push({order: -1, row: priceRow, category: PriceRowCategory.Festive})
resolve() })
return .catch((error) => {
logError(error)
log(`Festive ${itemName} is unpriced or unavailable, skipping...`)
})) }))
promises.push(new Promise(async (resolve) => { promises.push(fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime).then(data => {
logDebug(`Fetching price for Strange Festive ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime);
updateTime = new Date(data.update) updateTime = new Date(data.update)
} catch { logDebug(`Saving price for Strange Festive ${itemName}`)
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T("Strange"), data, keyPrice, exchangeRates, locale) const priceRow = createPriceRow($T("Strange"), data, keyPrice, exchangeRates, locale)
priceRows.push({order: 11, row: priceRow, category: PriceRowCategory.Festive}) priceRows.push({order: 11, row: priceRow, category: PriceRowCategory.Festive})
resolve() })
return .catch((error) => {
logError(error)
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`)
})) }))
} }
var killstreakKitHeadingRow: HTMLTableRowElement | null let killstreakKitHeadingRow: HTMLTableRowElement | null
// Check for Killstreak Kits // Check for Killstreak Kits
if(itemSchema[itemIndex].slot == ItemSlot.Primary || if(itemSchema[itemIndex].slot == ItemSlot.Primary ||
itemSchema[itemIndex].slot == ItemSlot.Secondary || itemSchema[itemIndex].slot == ItemSlot.Secondary ||
@@ -312,30 +304,23 @@ async function inject() {
killstreakKitHeadingRow.style.display = 'none'; killstreakKitHeadingRow.style.display = 'none';
killstreakKitHeadingRow.appendChild(heading); killstreakKitHeadingRow.appendChild(heading);
[1,2,3].map((tier) => { [1,2,3].map((tier) => {
promises.push(new Promise(async (resolve) => { let kitIndex: number
logDebug(`Fetching price for ${itemName} Killstreak Kit Tier ${tier}`)
var data: ItemPriceData | null
try {
var kitIndex: number
switch (tier) { switch (tier) {
default: default:
case 1: kitIndex = 6527; break; case 1: kitIndex = 6527; break;
case 2: kitIndex = 6523; break; case 2: kitIndex = 6523; break;
case 3: kitIndex = 6526; break; case 3: kitIndex = 6526; break;
} }
data = await fetchPrice(token, `${kitIndex};6;uncraftable;kt-${tier};td-${itemIndex}`, currentTime); promises.push(fetchPrice(token, `${kitIndex};6;uncraftable;kt-${tier};td-${itemIndex}`, currentTime).then(data => {
updateTime = new Date(data.update) updateTime = new Date(data.update)
} catch { logDebug(`Saving price for ${itemName} Killstreak Kit Tier ${tier}`)
log(`${itemName} Killstreak Kit Tier ${tier} is unpriced or unavailable, skipping...`)
resolve()
return
}
const priceRow = createPriceRow($T(`kt-${tier}`), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Killstreak_Kit") const priceRow = createPriceRow($T(`kt-${tier}`), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Killstreak_Kit")
priceRows.push({order: tier, row: priceRow, category: PriceRowCategory.KillstreakKit}) priceRows.push({order: tier, row: priceRow, category: PriceRowCategory.KillstreakKit})
resolve() })
return .catch((error) => {
logError(`Failed to fetch price for ${itemName} Killstreak Kit Tier ${tier}`, error)
})) }))
}) })
} }
@@ -351,7 +336,7 @@ async function inject() {
"Silver Mk.II", "Silver Mk.II",
"Gold Mk.II", "Gold Mk.II",
] ]
var botKillerHeadingRow: HTMLTableRowElement | null let botKillerHeadingRow: HTMLTableRowElement | null
if(itemSchema[itemIndex].botkillerVariants != null && itemSchema[itemIndex].botkillerVariants.length > 0) { if(itemSchema[itemIndex].botkillerVariants != null && itemSchema[itemIndex].botkillerVariants.length > 0) {
/// Create subheading /// Create subheading
botKillerHeadingRow = document.createElement("tr") botKillerHeadingRow = document.createElement("tr")
@@ -366,25 +351,18 @@ async function inject() {
itemSchema[itemIndex].botkillerVariants.map((variantIndex) => { itemSchema[itemIndex].botkillerVariants.map((variantIndex) => {
const itemName = itemSchema[variantIndex].name const itemName = itemSchema[variantIndex].name
// FIXME: variantName should match wiki display name
const variantName = itemName.includes('Mk.II') ? itemName.split(' ')[0] + ' Mk.II' : itemName.split(' ')[0] const variantName = itemName.includes('Mk.II') ? itemName.split(' ')[0] + ' Mk.II' : itemName.split(' ')[0]
promises.push(new Promise(async (resolve) => { promises.push(fetchPrice(token, `${variantIndex};11`, currentTime).then(data => {
logDebug(`Fetching price for ${itemName}`) logDebug(`Saving price for ${itemName}`)
var data: ItemPriceData | null
try {
data = await fetchPrice(token, `${variantIndex};11`, currentTime);
updateTime = new Date(data.update) updateTime = new Date(data.update)
} catch {
log(`${itemName} is unpriced or unavailable, skipping...`)
}
const priceRow = createPriceRow($T(variantName), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Botkiller_weapons") const priceRow = createPriceRow($T(variantName), data, keyPrice, exchangeRates, locale, "https://wiki.teamfortress.com/wiki/Botkiller_weapons")
// FIXME: order should be by release
// Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II
priceRows.push({order: botkillerOrder.indexOf(variantName), row: priceRow, category: PriceRowCategory.Botkiller}) priceRows.push({order: botkillerOrder.indexOf(variantName), row: priceRow, category: PriceRowCategory.Botkiller})
resolve() })
return .catch((error) => {
logError(error)
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`)
})) }))
}) })
} }
@@ -425,10 +403,12 @@ async function inject() {
const label = document.createElement("td"); const label = document.createElement("td");
label.colSpan = 2; label.colSpan = 2;
label.style.fontSize = "85%"; label.style.fontSize = "85%";
label.style.textAlign = "center";
const updateText = $T("Updated %@.", locale).replace('%@', updateTime.toLocaleString(locale, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })) const updateText = $T("Updated %@.", locale).replace('%@', updateTime.toLocaleString(locale, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }))
const attributionText = $T("Trade prices sourced from %@. Currency conversions are approximate.", locale).replace('%@', '<a rel="nofollow" class="external text" href="https://prices.tf">prices.tf</a>'); const attributionHeader = $T("Acknowledgements");
const exchangeRateAttribution = `<a rel="nofollow" class="external text" href="https://www.exchangerate-api.com">Rates By Exchange Rate API</a>.`; const pricesAttribution = `<a rel="nofollow" class="external text" href="https://prices.tf">prices.tf</a>`;
label.innerHTML = `${updateText}<br>${attributionText}<br>${exchangeRateAttribution}`; const exchangeRateAttribution = `<a rel="nofollow" class="external text" href="https://www.exchangerate-api.com">Rates By Exchange Rate API</a>`;
label.innerHTML = `${updateText}<br><b>${attributionHeader}</b><br>${pricesAttribution}<br>${exchangeRateAttribution}`;
row.appendChild(label); row.appendChild(label);
priceProgressRow.insertAdjacentElement('afterend', row); priceProgressRow.insertAdjacentElement('afterend', row);
@@ -437,10 +417,12 @@ async function inject() {
} }
function addStyles() { function addStyles() {
if(__ENV_USERSCRIPT) {
const head = document.head || document.getElementsByTagName('head')[0], const head = document.head || document.getElementsByTagName('head')[0],
style = document.createElement('style'); style = document.createElement('style');
head.appendChild(style); head.appendChild(style);
style.innerHTML = styleCss; style.innerHTML = require('./style.css');
}
} }
prepareSchema() prepareSchema()

View File

@@ -1,8 +1,9 @@
import { getStorageValue, setStorageValue } from './storage' import { getStorageValue, setStorageValue } from './storage'
import { logDebug, log, logError } from './utils/log' import { logDebug, log, logError } from './utils/log'
import { storage_exchangerates, storage_exchangerates_next, storage_exchangerates_update } from './config' import { storage_exchangerates, storage_exchangerates_next, storage_exchangerates_update } from './config'
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> import { fetchWrap } from './fetchWrap';
import './GM_fetch' declare const __ENV_WEBEXTENSION: boolean;
declare const __ENV_USERSCRIPT: boolean;
export interface ExchangeRates { export interface ExchangeRates {
[key: string]: number; [key: string]: number;
@@ -16,8 +17,8 @@ export async function wipeExchangeRates(): Promise<void> {
} }
export async function prepareExchangeRates(): Promise<ExchangeRates> { export async function prepareExchangeRates(): Promise<ExchangeRates> {
var needsUpdate: Boolean = false let needsUpdate: boolean = false
var rates: ExchangeRates | null = null let rates: ExchangeRates | null = null
rates = await getStorageValue(storage_exchangerates, null); rates = await getStorageValue(storage_exchangerates, null);
const update = await getStorageValue(storage_exchangerates_update, null) const update = await getStorageValue(storage_exchangerates_update, null)
@@ -26,7 +27,7 @@ export async function prepareExchangeRates(): Promise<ExchangeRates> {
const lastUpdateTime = new Date(update); const lastUpdateTime = new Date(update);
const nextUpdateTime = new Date(nextUpdate); const nextUpdateTime = new Date(nextUpdate);
log(`Exchange rates updated at ${lastUpdateTime}`); log(`Exchange rates updated at ${lastUpdateTime}`);
if (rates == null || Object.keys(rates).length === 0 || lastUpdateTime.getTime() > nextUpdateTime.getTime()) { if (rates == null || Object.keys(rates).length === 0 || Date.now() > nextUpdateTime.getTime()) {
needsUpdate = true needsUpdate = true
} }
} else { } else {
@@ -35,19 +36,35 @@ export async function prepareExchangeRates(): Promise<ExchangeRates> {
if(needsUpdate) { if(needsUpdate) {
log("Exchange rates out of Date. Rebuilding..."); log("Exchange rates out of Date. Rebuilding...");
const url = "https://open.er-api.com/v6/latest/USD" let exchangeResponse: {
const response = await GM_fetch(url); rates: ExchangeRates,
if (response.ok) { time_next_update_utc: string
await setStorageValue(storage_exchangerates_update, new Date().toISOString()) }
var json = await response.json() if(__ENV_USERSCRIPT) {
if(json != null){ const url = "https://open.er-api.com/v6/latest/USD"
rates = json['rates'] try {
await setStorageValue(storage_exchangerates, rates) const response: Response = await fetchWrap(url)
await setStorageValue(storage_exchangerates_next, json['time_next_update_utc']) if(response.ok) {
exchangeResponse = await response.json()
}
} catch (e) {
logDebug(e);
throw e;
} }
logDebug(`Exchange rates updated at ${new Date()}`)
} else { } else {
logError(`Failed to fetch exchange rates. Status code: ${response.status}`, response) exchangeResponse = await chrome.runtime.sendMessage({contentScriptQuery: "queryExchangeRates"})
}
try {
if(exchangeResponse == null) {
throw new Error("Rates are null")
}
rates = exchangeResponse.rates
await setStorageValue(storage_exchangerates_update, new Date().toISOString())
await setStorageValue(storage_exchangerates, exchangeResponse.rates)
await setStorageValue(storage_exchangerates_next, exchangeResponse.time_next_update_utc)
logDebug(`Exchange rates updated at ${new Date()}`)
} catch(e) {
logError(`Failed to store exchange rates.`, e)
} }
} }

10
src/content/fetchWrap.ts Normal file
View File

@@ -0,0 +1,10 @@
declare let __ENV_USERSCRIPT: boolean;
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
export function fetchWrap(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> {
if(__ENV_USERSCRIPT) {
return GM_fetch(input, init)
} else {
return fetch(input, init)
}
}

View File

@@ -1,7 +1,9 @@
import { defindex_key, storage_priceprefix } from "./config" import { defindex_key, storage_priceprefix } from "./config"
import { priceUsingPricesTF } from "./pricing/pricestf" import { priceUsingPricesTF } from "./pricing/pricestf"
import { getStorageValue, setStorageValue } from "./storage" import { getStorageValue, setStorageValue } from "./storage"
import { logDebug, log } from "./utils/log" import { logDebug } from "./utils/log"
declare const __ENV_WEBEXTENSION: boolean;
declare const __ENV_USERSCRIPT: boolean;
/** Pricing data for a given TF2 item. */ /** Pricing data for a given TF2 item. */
export class ItemPriceData { export class ItemPriceData {
@@ -40,8 +42,7 @@ export async function fetchKeyPrice(token: string) {
* @param ttl Time to cache results in milliseconds. 30 minutes by default. * @param ttl Time to cache results in milliseconds. 30 minutes by default.
*/ */
export async function fetchPrice(token: string, sku: string, update: Date = new Date(), ttl: number = 30 * 60 * 1000): Promise<ItemPriceData> { export async function fetchPrice(token: string, sku: string, update: Date = new Date(), ttl: number = 30 * 60 * 1000): Promise<ItemPriceData> {
return new Promise(async (resolve, reject) => { let data: ItemPriceData | null
var data: ItemPriceData | null
const cached: ItemPriceData = await getStorageValue(storage_priceprefix + sku, null) const cached: ItemPriceData = await getStorageValue(storage_priceprefix + sku, null)
if (cached != null && 'keys' in cached && 'metal' in cached && !isNaN(cached.update)) { if (cached != null && 'keys' in cached && 'metal' in cached && !isNaN(cached.update)) {
@@ -50,8 +51,8 @@ export async function fetchPrice(token: string, sku: string, update: Date = new
if (!data || data.sku != sku || 'update' in data && 'ttl' in data && Date.now() > (new Date(data.update).getTime() + data.ttl)) { 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}`) logDebug(`Fetching price data for ${sku}`)
if(!token) { if(!token || token === '') {
reject(401) throw new Error('No token provided')
} }
data = new ItemPriceData() data = new ItemPriceData()
data.sku = sku data.sku = sku
@@ -59,14 +60,19 @@ export async function fetchPrice(token: string, sku: string, update: Date = new
data.ttl = ttl data.ttl = ttl
try { try {
const response = await priceUsingPricesTF(token, sku) let response: PricesResponse
if (response) { if(__ENV_USERSCRIPT) {
response = await priceUsingPricesTF(token, sku)
} else {
response = await chrome.runtime.sendMessage({contentScriptQuery: "priceSKU", service: "prices.tf", sku: sku, token: token});
}
if (!response || response instanceof Error) {
throw new Error(`Bad response: ${response}`)
}
data.keys = response.keys data.keys = response.keys
data.metal = response.metal data.metal = response.metal
}
} catch (error) { } catch (error) {
log(`Received ${error} error while pricing ${sku} using prices.tf`) throw new Error(`Received "${error}" error while pricing ${sku} using prices.tf`)
reject(error)
} }
if ('metal' in data && 'keys' in data) { if ('metal' in data && 'keys' in data) {
@@ -75,6 +81,5 @@ export async function fetchPrice(token: string, sku: string, update: Date = new
} else { } else {
logDebug(`Using cached price data for ${sku}`) logDebug(`Using cached price data for ${sku}`)
} }
resolve(data) return data
})
} }

View File

@@ -1,10 +1,12 @@
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> import { fetchWrap } from '../fetchWrap'
import '../GM_fetch' import { logDebug, logError } from '../utils/log'
import { logDebug } from '../utils/log' declare const __ENV_WEBEXTENSION: boolean;
declare const __ENV_USERSCRIPT: boolean;
async function getPricesToken(): Promise<string> { async function getPricesToken(): Promise<string> {
return new Promise<any>((resolve, reject) => { if(__ENV_USERSCRIPT) {
GM_fetch('https://api2.prices.tf/auth/access', { return new Promise<string>((resolve, reject) => {
fetchWrap('https://api2.prices.tf/auth/access', {
method: 'post', method: 'post',
headers: new Headers({ headers: new Headers({
'Accept': 'application/json' 'Accept': 'application/json'
@@ -18,6 +20,9 @@ async function getPricesToken(): Promise<string> {
}) })
.then((responseData) => resolve(responseData['accessToken'])) .then((responseData) => resolve(responseData['accessToken']))
}) })
} else {
return chrome.runtime.sendMessage({contentScriptQuery: 'getPricesTFToken'})
}
} }
class PricesResponse { class PricesResponse {
@@ -42,63 +47,45 @@ async function priceUsingPricesTF(token: string, sku: string, retries: number =
// prices.tf // prices.tf
// https://api2.prices.tf/prices/${sku} // https://api2.prices.tf/prices/${sku}
// Authorization: Bearer ${token} // Authorization: Bearer ${token}
return new Promise(async (resolve, reject) => { try {
if (!token) { const response = await fetchWrap(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, {
reject(401)
}
var response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, {
method: 'get', method: 'get',
headers: new Headers({ headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`,
}
}) })
}) if (response.status === 404 && sku.includes(';') && !sku.includes(';uncraftable')) {
if (response.status === 404 && sku.includes(';')) {
const quality: number = parseInt(sku.split(';')[1], 10); const quality: number = parseInt(sku.split(';')[1], 10);
if(quality === 6) { if(quality === 6) {
// Try uncraftable variant if unique weapon // Try uncraftable variant if unique weapon
response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku + ';uncraftable')}`, { return priceUsingPricesTF(token, sku + ';uncraftable');
method: 'get',
headers: new Headers({
'Accept': 'application/json',
'Authorization': `Bearer ${token}`
})
})
} }
} }
switch (response.status) { if(response.status === 503) {
case 200:
const json = await response.json()
resolve({ keys: json['sellKeys'], metal: json['sellHalfScrap'] / 18.0 })
break;
case 404:
reject("Not found in prices.tf")
break;
case 429:
case 503:
// Happens if we send too many requests in a short period of time // Happens if we send too many requests in a short period of time
// Retry after a few seconds // Retry after a few seconds
if(retries > 0) { if(retries >= 0) {
logDebug(`Cloudflare rate limit exceeded, trying again after 2 seconds, ${retries} retries left`) logDebug(`Cloudflare rate limit exceeded, trying again after 1 second, ${retries} retries left`)
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 1000));
try { return priceUsingPricesTF(token, sku, retries - 1);
const retryResult = await priceUsingPricesTF(token, sku, retries - 1);
resolve(retryResult);
} catch (err) {
reject(err);
}
} else { } else {
reject("Cloudflare rate limit exceeded, stopping") throw new Error(`Cloudflare rate limit exceeded, stopping`)
} }
break;
default:
// Something went wrong
logDebug(`Received ${response.status} error while pricing ${sku}`)
logDebug(`${JSON.stringify(response.headers)}`)
reject("Unknown error")
break;
} }
}) if (!response.ok) {
throw new Error(`Pricing request for ${sku} failed with status code: ${response.status}`);
}
const data = await response.json();
const prices = new PricesResponse();
prices.keys = data['sellKeys']
prices.metal = data['sellHalfScrap'] / 18.0;
return prices;
}
catch(error) {
logError(`Failed to fetch prices from prices.tf for item ${sku}`)
throw error;
}
} }
export { getPricesToken, priceUsingPricesTF, PricesResponse } export { getPricesToken, priceUsingPricesTF, PricesResponse }

View File

@@ -1,10 +1,11 @@
declare const __ENV_WEBEXTENSION: boolean;
declare const __ENV_USERSCRIPT: boolean;
import { getStorageValue, setStorageValue } from './storage' import { getStorageValue, setStorageValue } from './storage'
import { logDebug, log, logError } from './utils/log' import { logDebug, log, logError } from './utils/log'
import './config' import './config'
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
import './GM_fetch'
import { storage_version, storage_schema, storage_lastUpdateTime } from './config' import { storage_version, storage_schema, storage_lastUpdateTime } from './config'
import Australiums from '../resources/australiums.json' import Australiums from '../resources/australiums.json'
import { fetchWrap } from './fetchWrap'
const semver = require('semver') const semver = require('semver')
export function checkAustraliumVariant(defindex: number): boolean { export function checkAustraliumVariant(defindex: number): boolean {
@@ -14,8 +15,8 @@ export function checkAustraliumVariant(defindex: number): boolean {
export declare const __VERSION__: string; export declare const __VERSION__: string;
function isDateAfterOneDay(date1: Date, date2: Date): boolean { function isDateAfterOneDay(date1: Date, date2: Date): boolean {
var diff = date2.getTime() - date1.getTime(); const diff = date2.getTime() - date1.getTime();
var diffDays = Math.round(diff / (1000 * 3600 * 24)); const diffDays = Math.round(diff / (1000 * 3600 * 24));
return diffDays > 1; return diffDays > 1;
} }
@@ -36,15 +37,53 @@ export class ItemSchema {
[key: string]: { [key: string]: {
name: string, name: string,
slot: ItemSlot, slot: ItemSlot,
tradable: Boolean, tradable: boolean,
hasAustraliumVariant: Boolean, hasAustraliumVariant: boolean,
festiveVariant: number | null festiveVariant: number | null
botkillerVariants: Array<number> | null botkillerVariants: Array<number> | null
canKillstreakify: Boolean canKillstreakify: boolean
}; };
} }
export function getItemIndexByName(schema: ItemSchema, name: string, excludeStock: Boolean = true, excludeDecorated: Boolean = true) { interface SchemaResponseItem {
name: string;
defindex: number;
item_class: string;
item_type_name: string;
item_name: string;
item_description: string;
proper_name: boolean;
item_slot: ItemSlot;
model_player: string;
item_quality: number;
image_inventory: string;
min_ilevel: number;
max_ilevel: number;
image_url: string;
image_url_large: string;
drop_type: string;
craft_class: string;
craft_material_type: string;
capabilities: {
decodable?: boolean,
can_be_restored?: boolean;
can_card_upgrade?: boolean;
can_consume?: boolean;
can_craft_mark?: boolean;
can_gift_wrap?: boolean;
can_killstreakify?: boolean;
can_strangify?: boolean;
paintable?: boolean;
strange_parts?: boolean;
};
attributes: Array<{
name: string;
class: string;
value: number | string; // The value can sometimes be a string, but example uses numbers
}>;
}
export function getItemIndexByName(schema: ItemSchema, name: string, excludeStock: boolean = true, excludeDecorated: boolean = true) {
for (const [defindex, value] of Object.entries(schema)) { for (const [defindex, value] of Object.entries(schema)) {
if (value['name'] == name) { if (value['name'] == name) {
const index = parseInt(defindex) const index = parseInt(defindex)
@@ -60,7 +99,7 @@ export function getTradableStatusByDefindex(schema: ItemSchema, defindex: number
return schema[defindex.toString()].tradable return schema[defindex.toString()].tradable
} }
export function getTradableStatusByName(schema: ItemSchema, name: string, excludeStock: Boolean = true, excludeDecorated = true,) { export function getTradableStatusByName(schema: ItemSchema, name: string, excludeStock: boolean = true, excludeDecorated = true,) {
for (const [defindex, value] of Object.entries(schema)) { for (const [defindex, value] of Object.entries(schema)) {
if (value['name'] == name) { if (value['name'] == name) {
const index = parseInt(defindex) const index = parseInt(defindex)
@@ -119,12 +158,12 @@ export async function wipeSchema(): Promise<void> {
} }
export async function prepareSchema(): Promise<ItemSchema> { export async function prepareSchema(): Promise<ItemSchema> {
var needsUpdate: Boolean = false let needsUpdate: boolean = false
var itemSchema: ItemSchema | null = null let itemSchema: ItemSchema | null = null
const storedVersion: string | null = await getStorageValue(storage_version, null) const storedVersion: string | null = await getStorageValue(storage_version, null)
if(!storedVersion || !semver.valid(storedVersion)) { if(!storedVersion || !semver.valid(storedVersion)) {
log(`Cache is from an unknown version of the extension. Updating for version ${__VERSION__}`); log(`Preparing the extension for the first time.`);
needsUpdate = true needsUpdate = true
} else if(semver.valid(storedVersion) && semver.lt(storedVersion, __VERSION__)) { } else if(semver.valid(storedVersion) && semver.lt(storedVersion, __VERSION__)) {
log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`); log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`);
@@ -145,31 +184,38 @@ export async function prepareSchema(): Promise<ItemSchema> {
if(needsUpdate) { if(needsUpdate) {
log("Item Schema out of Date. Rebuilding..."); log("Item Schema out of Date. Rebuilding...");
const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json" try {
const response = await GM_fetch(url);
if (response.ok) {
await setStorageValue(storage_lastUpdateTime, new Date().getTime()); await setStorageValue(storage_lastUpdateTime, new Date().getTime());
var cacheItems = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any
const cacheItems: any = {}
var responseItems: any[] = await response.json() let responseItems: SchemaResponseItem[];
if(__ENV_USERSCRIPT) {
const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json"
const response = await fetchWrap(url);
if(response.ok) {
responseItems = await response.json();
}
} else {
responseItems = await chrome.runtime.sendMessage({contentScriptQuery: "querySchema"})
}
// We want to keep the keys `defindex`, `item_name`, and `attributes` // We want to keep the keys `defindex`, `item_name`, and `attributes`
responseItems.forEach((item: any) => { responseItems.forEach((item: SchemaResponseItem) => {
const defindex: number = item['defindex'] const defindex: number = item['defindex']
var tradable: Boolean = true let tradable: boolean = true
try { try {
if(item['attributes'] != null) { if(item['attributes'] != null) {
if(item['attributes'].find((attribute: {}) => (attribute as any)['class'] == "cannot_trade")) { if(item['attributes'].find((attribute) => attribute['class'] == "cannot_trade")) {
tradable = false tradable = false
} }
} }
} catch(error) { } catch(error) {
logError(error) logError(error)
log(item)
} }
var canKillstreakify: Boolean = false let canKillstreakify: boolean = false
try { try {
if(item['capabilities'] != null) { if(item['capabilities'] != null) {
if(item['capabilities']['can_killstreakify'] != null && item['capabilities']['can_killstreakify'] == true) { if(item['capabilities']['can_killstreakify'] != null && item['capabilities']['can_killstreakify'] == true) {
@@ -178,10 +224,9 @@ export async function prepareSchema(): Promise<ItemSchema> {
} }
} catch(error) { } catch(error) {
logError(error) logError(error)
log(item)
} }
(cacheItems as any)[defindex.toString()] = { cacheItems[defindex.toString()] = {
"name": item['item_name'], "name": item['item_name'],
"slot": item['item_slot'], "slot": item['item_slot'],
"tradable": tradable, "tradable": tradable,
@@ -197,8 +242,8 @@ export async function prepareSchema(): Promise<ItemSchema> {
itemSchema = cacheItems itemSchema = cacheItems
await setStorageValue(storage_version, __VERSION__); await setStorageValue(storage_version, __VERSION__);
logDebug(`Item schema updated at ${new Date()}`) logDebug(`Item schema updated at ${new Date()}`)
} else { } catch (e) {
logError("Could not fetch item schema."); logError("Could not fetch item schema.", e);
} }
} }
return itemSchema return itemSchema

View File

@@ -1,25 +1,28 @@
declare var __ENV_USERSCRIPT: boolean; declare let __ENV_USERSCRIPT: boolean;
declare var __ENV_WEBEXTENSION: boolean; declare let __ENV_WEBEXTENSION: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getStorageValue(name: string, defaultValue: string): Promise<any> { function getStorageValue(name: string, defaultValue: string): Promise<any> {
if(__ENV_USERSCRIPT) { if(__ENV_USERSCRIPT) {
return GM.getValue(name, defaultValue); return GM.getValue(name, defaultValue);
} else if(__ENV_WEBEXTENSION) { } else if(__ENV_WEBEXTENSION) {
return browser.storage.local.get(name); return chrome.storage.local.get(name)
.then((result) => result[name])
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Promise<any>((resolve) => { return new Promise<any>((resolve) => {
resolve(defaultValue); resolve(defaultValue);
}); });
} }
} }
function setStorageValue(name: string, value: any): Promise<any> { function setStorageValue(name: string, value: unknown): Promise<void> {
if(__ENV_USERSCRIPT) { if(__ENV_USERSCRIPT) {
return GM.setValue(name, value); return GM.setValue(name, value as GM.Value);
} else if(__ENV_WEBEXTENSION) { } else if(__ENV_WEBEXTENSION) {
return browser.storage.local.set({name, value}); return chrome.storage.local.set({[name]: value});
} else { } else {
return new Promise<any>((resolve, reject) => { return new Promise<void>((_, reject) => {
reject(); reject();
}); });
} }

View File

@@ -22,14 +22,14 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric
const priceData = document.createElement("td"); const priceData = document.createElement("td");
const priceLink = document.createElement("span"); const priceLink = document.createElement("span");
var priceString: string = '' let priceString: string = ''
if(data) { if(data) {
const gamePrice = formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim() const gamePrice = formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim()
const realPriceUSD = convertTF2PriceToUSD(data.keys, data.metal, keyPrice.metal) const realPriceUSD = convertTF2PriceToUSD(data.keys, data.metal, keyPrice.metal)
const USDFormatter = new Intl.NumberFormat(locale, { style: "currency", currency: 'USD' }) const USDFormatter = new Intl.NumberFormat(locale, { style: "currency", currency: 'USD' })
var realPriceString = USDFormatter.format(realPriceUSD) let realPriceString = USDFormatter.format(realPriceUSD)
const currency = defaultCurrencyForPageLocale(locale) ?? 'USD' const currency = defaultCurrencyForPageLocale(locale) ?? 'USD'
if(currency !== 'USD') { if(currency !== 'USD') {
try { try {
@@ -56,7 +56,7 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric
export function createStoreButton(storeName: string, url: URL) { export function createStoreButton(storeName: string, url: URL) {
const button = document.createElement("tr") const button = document.createElement("tr")
var source = `<td colspan="2" class="infobox-data" style="text-align:center"><div class="plainlinks btn_wrapper" style="width:100%"><a rel="nofollow" class="external text" href="{link}" target="_blank"><span class="btn_buynow_addon_${storeName.replaceAll('.', '')}">{title}<span></span></span></a></div></td>` let source = `<td colspan="2" class="infobox-data" style="text-align:center"><div class="plainlinks btn_wrapper" style="width:100%"><a rel="nofollow" class="external text" href="{link}" target="_blank"><span class="btn_buynow_addon_${storeName.replaceAll('.', '')}">{title}<span></span></span></a></div></td>`
source = source.replace("{link}", url.toString()) source = source.replace("{link}", url.toString())
source = source.replace("{title}", $T("View listings on %@").replace('%@', storeName)) source = source.replace("{title}", $T("View listings on %@").replace('%@', storeName))
button.innerHTML = source button.innerHTML = source

View File

@@ -1,15 +1,14 @@
import { conversion_ref_usd } from '../config';
import { $T } from './localization' import { $T } from './localization'
function toFixed(num: number, fixed: number) { function toFixed(num: number, fixed: number) {
var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?'); const re = new RegExp('^-?\\d+(?:.\\d{0,' + (fixed || -1) + '})?');
return num.toString().match(re)[0]; return num.toString().match(re)[0];
} }
export function formatPrice(keys: number, metal: number, keyPrice: number, locale: string = 'en') { export function formatPrice(keys: number, metal: number, keyPrice: number, locale: string = 'en') {
const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2) const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2)
var output: string = '' let output: string = ''
if(keys > 0) { if(keys > 0) {
output += (formattedKeys == 1.0 ? $T("%@ key") : $T("%@ keys")).replace('%@', formattedKeys.toLocaleString(locale)) output += (formattedKeys == 1.0 ? $T("%@ key") : $T("%@ keys")).replace('%@', formattedKeys.toLocaleString(locale))
} else { } else {

View File

@@ -1,36 +1,45 @@
const localizations: {[lang: string]: any} = { import { logDebug } from "./log";
const localizations: Record<string, object> = {
'en': require('../../strings/en'), // English 'en': require('../../strings/en'), // English
'es': require('../../strings/es'), // Spanish 'es': require('../../strings/es'), // Spanish
// 'ja': require('../../strings/ja'), // Japanese 'ja': require('../../strings/ja'), // Japanese
// 'it': require('../../strings/it'), // Italian 'it': require('../../strings/it'), // Italian
// 'ar': require('../../strings/ar.json') as object, // Arabic 'ar': require('../../strings/ar'), // Arabic
// 'cs': require('../../strings/cs.json') as object, // Czech 'cs': require('../../strings/cs'), // Czech
// 'da': require('../../strings/da.json') as object, // Danish 'da': require('../../strings/da'), // Danish
// 'de': require('../../strings/de.json') as object, // German 'de': require('../../strings/de'), // German
// 'fi': require('../../strings/fi.json') as object, // Finnish 'fi': require('../../strings/fi'), // Finnish
// 'fr': require('../../strings/fr.json') as object, // French 'fr': require('../../strings/fr'), // French
// 'hu': require('../../strings/hu.json') as object, // Hungarian 'hu': require('../../strings/hu'), // Hungarian
// 'ko': require('../../strings/ko.json') as object, // Korean 'ko': require('../../strings/ko'), // Korean
// 'nl': require('../../strings/nl.json') as object, // Dutch 'nl': require('../../strings/nl'), // Dutch
// 'no': require('../../strings/no.json') as object, // Norwegian Bokmål 'no': require('../../strings/no'), // Norwegian Bokmål
// 'pl': require('../../strings/pl.json') as object, // Polish 'pl': require('../../strings/pl'), // Polish
// 'pt': require('../../strings/pt.json') as object, // Portuguese 'pt': require('../../strings/pt'), // Portuguese
// 'pt-BR': require('../../strings/pt-BR.json') as object, // Brazilian Portuguese 'pt-BR': require('../../strings/pt-BR'), // Brazilian Portuguese
// 'ro': require('../../strings/ro.json') as object, // Romanian 'ro': require('../../strings/ro'), // Romanian
// 'ru': require('../../strings/ru.json') as object, // Russian 'ru': require('../../strings/ru'), // Russian
// 'sv': require('../../strings/sv.json') as object, // Swedish 'sv': require('../../strings/sv'), // Swedish
// 'tr': require('../../strings/tr.json') as object, // Turkish 'tr': require('../../strings/tr'), // Turkish
// 'zh-Hans': require('../../strings/zh-Hans.json') as object, // Simplified Chinese 'zh-Hans': require('../../strings/zh-Hans'), // Simplified Chinese
// 'zh-Hant': require('../../strings/zh-Hant.json') as object, // Traditional Chinese 'zh-Hant': require('../../strings/zh-Hant'), // Traditional Chinese
} }
export function $T(s: string, locale?: Intl.LocalesArgument): string { export function $T(s: string, locale?: Intl.LocalesArgument): string {
const code = locale ? locale.toString() : extractLocaleFromURL(document.URL) const code = locale ? locale.toString() : extractLocaleFromURL(document.URL)
return localizations.hasOwnProperty(code) ? (localizations[code as unknown as keyof object])[s] || s : s; if (code in localizations) {
const translation = localizations[code] as Record<string, string>;
const result = translation[s] ?? s;
logDebug(`Translating "${s}" to locale "${code}": ${result}`);
return result;
}
logDebug(`Untranslated string "${s}" in locale "${code}`);
return s;
} }
export function extractLocaleFromURL(url: string): string { export function extractLocaleFromURL(url: string): string {
var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length); const split = url.substring(url.indexOf("/wiki/") + "/wiki/".length);
if (split.indexOf('/') != -1) { if (split.indexOf('/') != -1) {
// Remove language suffix e.g. `/es` // Remove language suffix e.g. `/es`
return split.substring(split.indexOf('/') + 1); return split.substring(split.indexOf('/') + 1);

View File

@@ -1,19 +1,18 @@
declare var __PRODUCTION: boolean; declare let __EXTENSION_NAME: string;
declare var __EXTENSION_NAME: string;
const logHeader = `[${__EXTENSION_NAME}] `; const logHeader = `[${__EXTENSION_NAME}] `;
/** `console.debug` with header; automatically NO-OP on production build */ /** `console.debug` with header; automatically NO-OP on production build */
function logDebug(message?: any, ...optionalParams: any[]): void { function logDebug(message?: string, ...optionalParams: Array<unknown>): void {
if(process.env.NODE_ENV !== 'production') console.debug(logHeader + message, optionalParams); if(process.env.NODE_ENV !== 'production') console.debug(logHeader + message, optionalParams);
} }
/** `console.log` with header */ /** `console.log` with header */
function log(message?: any, ...optionalParams: any[]): void { function log(message?: string, ...optionalParams: Array<unknown>): void {
console.log(logHeader + message, optionalParams) console.log(logHeader + message, optionalParams)
} }
/** `console.error` with header */ /** `console.error` with header */
function logError(message?: any, ...optionalParams: any[]): void { function logError(message?: string, ...optionalParams: Array<unknown>): void {
console.error(logHeader + message, optionalParams) console.error(logHeader + message, optionalParams)
} }

View File

@@ -1,5 +1,5 @@
export function extractPageTitleFromURL(url: string): string { export function extractPageTitleFromURL(url: string): string {
var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length); let split = url.substring(url.indexOf("/wiki/") + "/wiki/".length);
if (split.indexOf('/') != -1) { if (split.indexOf('/') != -1) {
// Remove language suffix (/es) // Remove language suffix (/es)
split = split.substring(0, split.indexOf('/')); split = split.substring(0, split.indexOf('/'));

27
src/eslint.config.mjs Normal file
View File

@@ -0,0 +1,27 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import css from "@eslint/css";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ ignores: ["**/GM_fetch/**/*.js"] },
{ files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] },
tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-require-imports": "off"
},
languageOptions: { globals: globals.browser }
},
{ files: ["**/strings/*.js"], plugins: { js }, extends: ["js/recommended"] },
tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-require-imports": "off"
},
languageOptions: { sourceType: "commonjs" }
},
{ files: ["**/*.css"], plugins: { css }, language: "css/css", extends: ["css/recommended"] },
]);

View File

@@ -4,14 +4,34 @@
"author": EXTENSION_AUTHOR, "author": EXTENSION_AUTHOR,
"manifest_version": 3, "manifest_version": 3,
"version": EXTENSION_VERSION, "version": EXTENSION_VERSION,
"permissions": [
"storage",
"scripting"
],
"host_permissions": [
"https://wiki.teamfortress.com/wiki/*",
"https://*.prices.tf/*",
"https://open.er-api.com/*"
],
"web_accessible_resources": [
{
"resources": ["lib/style.css", "resources/*"],
"matches": ["https://wiki.teamfortress.com/*"]
}
],
"content_scripts": [ "content_scripts": [
{ {
"matches": ["*://wiki.teamfortress.com/wiki/*"], "matches": ["*://wiki.teamfortress.com/wiki/*"],
"run_at": "document_start", "run_at": "document_start",
"all_frames": true, "all_frames": true,
"css": ["lib/style.css"],
"js": ["content/content.js"] "js": ["content/content.js"]
} }
], ],
"background": {
"service_worker": "background/background.js",
"type": "module"
},
"icons": { "icons": {
"48": "icons/icon-48.png", "48": "icons/icon-48.png",
"96": "icons/icon-96.png" "96": "icons/icon-96.png"

44
src/strings/ar.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/cs.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/da.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/de.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

View File

@@ -6,7 +6,7 @@ module.exports = {
"Community Pricing": "Community Pricing", "Community Pricing": "Community Pricing",
// Itembox footer // Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot "Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Trade prices sourced from %@. Currency conversions are approximate.": "Trade prices sourced from %@. Currency conversions are approximate.", // %@ is always a URL, (eg. prices.tf) "Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings // Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot "Data unavailable": "Data unavailable", // sourced from AppleGlot

View File

@@ -6,7 +6,7 @@ module.exports = {
"Community Pricing": "Precios de la comunidad", "Community Pricing": "Precios de la comunidad",
// Itembox footer // Itembox footer
"Updated %@.": "Actualizado %@.", // %@ is a date string, sourced from AppleGlot "Updated %@.": "Actualizado %@.", // %@ is a date string, sourced from AppleGlot
"Trade prices sourced from %@. Currency conversions are approximate.": "Precios comerciales obtenidos de %@. Las conversiones de divisas son aproximadas.", // %@ is always a URL, (eg. prices.tf) "Acknowledgements": "Agradecimientos", // sourced from AppleGlot
// Price strings // Price strings
"Data unavailable": "Datos no disponibles", // sourced from AppleGlot "Data unavailable": "Datos no disponibles", // sourced from AppleGlot
@@ -38,7 +38,7 @@ module.exports = {
// Killstreak tiers sourced from TF2 wiki // Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Kit Cuentarrachas", "Killstreak Kit": "Kit Cuentarrachas",
"kt-1": "Standard", "kt-1": "Estándar",
"kt-2": "Especializado", "kt-2": "Especializado",
"kt-3": "Profesional", "kt-3": "Profesional",
} }

44
src/strings/fi.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/fr.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/hu.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

View File

@@ -3,16 +3,16 @@ module.exports = {
"View listings on %@": "Voir les offres sur %@", "View listings on %@": "Voir les offres sur %@",
// Itembox header // Itembox header
"Community Pricing": "Community Pricing", "Community Pricing": "Prezzo Comunitario",
// Itembox footer // Itembox footer
"Updated %@": "Updated %@.", "Updated %@.": "Aggiornato il %@.",
"Trade prices sourced from %@. Currency conversions are approximate.": "Trade prices sourced from %@. Currency conversions are approximate.", // %@ is always a URL, (eg. prices.tf) "Acknowledgements": "Note legali", // sourced from AppleGlot
// Price strings // Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot "Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref", "%@ ref": "%@ raf",
"%@ key": "%@ key", "%@ key": "%@ chiave",
"%@ keys": "%@ keys", "%@ keys": "%@ chiavi",
// Item quality names, all sourced from TF2 wiki // Item quality names, all sourced from TF2 wiki
"Normal": "Normale", "Normal": "Normale",
@@ -22,5 +22,23 @@ module.exports = {
"Strange": "Strano", "Strange": "Strano",
"Collector's": "Da collezione", "Collector's": "Da collezione",
"Haunted": "Stregato", "Haunted": "Stregato",
"Australium": "Australium" "Australium": "Australium",
"Festive": "Festivo",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Ammazzabot",
"Silver": "Argentato",
"Gold": "Dorato",
"Rust": "Arrugginito",
"Blood": "Insanguinato",
"Carbonado": "Carbonado",
"Diamond": "Diamante",
"Silver Mk.II": "Argentato Mk.II",
"Gold Mk.II": "Dorato Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Kit per Serie omicide",
"kt-1": "Standard",
"kt-2": "Specializzati",
"kt-3": "Professionali",
} }

View File

@@ -3,10 +3,10 @@ module.exports = {
"View listings on %@": "%@で検索結果を見る", "View listings on %@": "%@で検索結果を見る",
// Itembox header // Itembox header
"Community Pricing": "Community Pricing", "Community Pricing": "共同体価格",
// Itembox footer // Itembox footer
"Updated %@.": "アップデート: %@。", // %@ is a date string, sourced from AppleGlot "Updated %@.": "アップデート: %@。", // %@ is a date string, sourced from AppleGlot
"Trade prices sourced from %@. Currency conversions are approximate.": "Trade prices sourced from %@. Currency conversions are approximate.", // %@ is always a URL, (eg. prices.tf) "Acknowledgements": "謝辞", // sourced from AppleGlot
// Price strings // Price strings
"Data unavailable": "データがありません", // sourced from AppleGlot "Data unavailable": "データがありません", // sourced from AppleGlot
@@ -22,5 +22,23 @@ module.exports = {
"Strange": "ストレンジ", "Strange": "ストレンジ",
"Collector's": "Collector's", "Collector's": "Collector's",
"Haunted": "Haunted", "Haunted": "Haunted",
"Australium": "オーストラリウム" "Australium": "オーストラリウム",
"Festive": "フェスティブ",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "ボットキラー",
"Silver": "シルバー",
"Gold": "ゴールド",
"Rust": "さびた",
"Blood": "ブラッド",
"Carbonado": "黒ダイヤ",
"Diamond": "ダイヤモンド",
"Silver Mk.II": "シルバー Mk.II",
"Gold Mk.II": "ゴールド Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "キルストリークキット",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
} }

44
src/strings/ko.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/nl.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/no.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/pl.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/pt-BR.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/pt.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/ro.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/ru.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "Просмотреть объявления на %@",
// Itembox header
"Community Pricing": "Цены сообщества",
// Itembox footer
"Updated %@.": "Обновлено %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Уведомления", // sourced from AppleGlot
// Price strings
"Data unavailable": "Данные недоступны", // sourced from AppleGlot
"%@ ref": "%@ реф",
"%@ key": "%@ ключ",
"%@ keys": "%@ ключей",
// Item quality names, all sourced from TF2 wiki
"Normal": "Обычное",
"Genuine": "высшей пробы",
"Vintage": "старой закалки",
"Unique": "Уникальный",
"Strange": "странного типа",
"Collector's": "из коллекции",
"Haunted": "призрачного вида",
"Australium": "из австралия",
"Festive": "Праздничный",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Боткиллер",
"Silver": "Серебряный",
"Gold": "Золотой",
"Rust": "Ржавый",
"Blood": "Кровавый",
"Carbonado": "Карбонадо",
"Diamond": "Алмазный",
"Silver Mk.II": "Серебряный вер. 2.0",
"Gold Mk.II": "Золотой вер. 2.0",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Набор убийцы",
"kt-1": "серийного",
"kt-2": "особо опасного",
"kt-3": "профессионального",
}

44
src/strings/sv.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/tr.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/zh-Hans.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

44
src/strings/zh-Hant.js Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
// Generic button text, %@ is always a URL (eg. backpack.tf)
"View listings on %@": "View listings on %@",
// Itembox header
"Community Pricing": "Community Pricing",
// Itembox footer
"Updated %@.": "Updated %@.", // %@ is a date string, sourced from AppleGlot
"Acknowledgements": "Acknowledgements", // sourced from AppleGlot
// Price strings
"Data unavailable": "Data unavailable", // sourced from AppleGlot
"%@ ref": "%@ ref",
"%@ key": "%@ key",
"%@ keys": "%@ keys",
// Item quality names, all sourced from TF2 wiki
"Normal": "Normal",
"Genuine": "Genuine",
"Vintage": "Vintage",
"Unique": "Unique",
"Strange": "Strange",
"Collector's": "Collector's",
"Haunted": "Haunted",
"Australium": "Australium",
"Festive": "Festive",
// Botkiller names, all sourced from TF2 wiki
"Botkiller": "Botkiller",
"Silver": "Silver",
"Gold": "Gold",
"Rust": "Rust",
"Blood": "Blood",
"Carbonado": "Carbonado",
"Diamond": "Diamond",
"Silver Mk.II": "Silver Mk.II",
"Gold Mk.II": "Gold Mk.II",
// Killstreak tiers sourced from TF2 wiki
"Killstreak Kit": "Killstreak Kit",
"kt-1": "Standard",
"kt-2": "Specialized",
"kt-3": "Professional",
}

View File

@@ -2,6 +2,7 @@
// @name EXTENSION_NAME // @name EXTENSION_NAME
// @description EXTENSION_DESCRIPTION // @description EXTENSION_DESCRIPTION
// @version EXTENSION_VERSION // @version EXTENSION_VERSION
// @author EXTENSION_AUTHOR
// @match *://wiki.teamfortress.com/wiki/* // @match *://wiki.teamfortress.com/wiki/*
// @run-at document-start // @run-at document-start
// @inject-into content // @inject-into content
@@ -9,6 +10,8 @@
// @domain steamcommunity.com // @domain steamcommunity.com
// @connect prices.tf // @connect prices.tf
// @domain prices.tf // @domain prices.tf
// @connect open.er-api.com
// @domain open.er-api.com
// @grant GM.setValue // @grant GM.setValue
// @grant GM_setValue // @grant GM_setValue
// @grant GM.getValue // @grant GM.getValue

View File

@@ -11,6 +11,6 @@
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"types": ["bun-types", "jest", "greasemonkey", "firefox-webext-browser"] "types": ["bun-types", "jest", "greasemonkey", "chrome", "firefox-webext-browser"]
} }
} }

View File

@@ -1,5 +1,7 @@
var path = require('path'); var path = require('path');
var CopyPlugin = require('copy-webpack-plugin'); var CopyPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const RemoveEmptyScriptsPlugin = require('webpack-remove-empty-scripts');
var webpack = require('webpack'); var webpack = require('webpack');
var fs = require('fs'); var fs = require('fs');
var package = require('./package.json'); var package = require('./package.json');
@@ -12,7 +14,7 @@ function allReplace(str, obj, quote = true) {
}; };
const defines = { const defines = {
EXTENSION_NAME: package.name, EXTENSION_NAME: package.displayName,
__EXTENSION_NAME: JSON.stringify(package.name), __EXTENSION_NAME: JSON.stringify(package.name),
EXTENSION_AUTHOR: package.author, EXTENSION_AUTHOR: package.author,
EXTENSION_DESCRIPTION: package.description, EXTENSION_DESCRIPTION: package.description,
@@ -21,32 +23,65 @@ const defines = {
} }
module.exports = [ module.exports = [
/*
// WebExtension // WebExtension
{ {
entry: { entry: {
content: './src/content/content.ts' content: './src/content/content.ts',
background: './src/background/background.ts',
style: './src/content/style.css'
}, },
module: { module: {
rules: [ rules: [
{ {
test: /\.tsx?$/, test: /\.tsx?$/,
use: 'ts-loader', use: 'ts-loader',
exclude: /node_modules/, exclude: /node_modules|GM_fetch/,
}, },
{ {
test: /\.(png|jpg|gif|svg)$/i, test: /\.css$/i,
use: [ use: [
{ {
loader: 'url-loader', loader: MiniCssExtractPlugin.loader,
options: { options: {
limit: true, publicPath: '/', // Adjust if needed for relative path resolution
},
},
{
loader: 'css-loader',
options: {
url: true, // Ensures url() in CSS is processed
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: {
'postcss-url': {
url: (asset) => {
// Transform relative URLs to extension-style URLs
const relativePath = asset.url.replace(/^\.\.\//, '') // Remove leading ../resources part
return `chrome-extension://__MSG_@@extension_id__/${relativePath}`;
},
},
},
},
}, },
}, },
], ],
}, },
{
test: /\.(jpe?g|png|ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
type: 'asset/resource',
generator: {
filename: 'resources/[name][ext]',
},
},
], ],
}, },
externals: {
'./src/content/GM_fetch': 'commonjs2 null'
},
optimization: { optimization: {
minimize: true minimize: true
}, },
@@ -55,10 +90,11 @@ module.exports = [
filename: "[name]/[name].js" filename: "[name]/[name].js"
}, },
resolve: { resolve: {
extensions: [".ts", ".tsx", ".js", ".json", ".css"] extensions: [".ts", ".tsx", ".js", ".json"]
}, },
plugins: [ plugins: [
new webpack.DefinePlugin({__ENV_WEBEXTENSION: true, __ENV_USERSCRIPT: false}), new RemoveEmptyScriptsPlugin(),
new webpack.DefinePlugin({ ...defines, __ENV_WEBEXTENSION: true, __ENV_USERSCRIPT: false}),
new CopyPlugin({ patterns: [ new CopyPlugin({ patterns: [
{ from: './src/manifest.json', to: 'manifest.json', { from: './src/manifest.json', to: 'manifest.json',
transform(content, absoluteFrom) { transform(content, absoluteFrom) {
@@ -68,14 +104,17 @@ module.exports = [
]}), ]}),
new CopyPlugin({ patterns: [ new CopyPlugin({ patterns: [
{ from: './src/icons', to: 'icons/[file]'}, { from: './src/icons', to: 'icons/[file]'},
{ from: './src/resources/*.png', to: 'resources/[name][ext]' },
]}), ]}),
new MiniCssExtractPlugin({
filename: 'lib/style.css'
}),
], ],
}, },
*/
// Userscript // Userscript
{ {
entry: { entry: {
content: './src/content/content.ts' content: ['./src/content/content.ts', './src/content/GM_fetch/index.js' ]
}, },
module: { module: {
rules: [ rules: [