You've already forked tf2wikipricing
bump version to 0.8.0
Reviewed-on: http://charon.local/git/xen/tf2wikipricing/pulls/21
This commit is contained in:
@@ -23,13 +23,14 @@ jobs:
|
||||
id: version
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Test project
|
||||
run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"'
|
||||
- name: Test UserScript build
|
||||
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
|
||||
run: bun run build
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tf2wikipricing.user.js
|
||||
path: |
|
||||
dist/userscript/tf2wikipricing.user.js
|
||||
name: tf2wikipricing
|
||||
path: dist/
|
||||
@@ -19,16 +19,17 @@ jobs:
|
||||
id: version
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Test project
|
||||
run: bun test --define __VERSION__='${{ steps.version.outputs.version }}' --define __EXTENSION_NAME='"tf2wikipricing"'
|
||||
- name: Test UserScript build
|
||||
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
|
||||
run: bun run build --mode production
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tf2wikipricing.user.js
|
||||
path: |
|
||||
dist/userscript/tf2wikipricing.user.js
|
||||
name: tf2wikipricing
|
||||
path: dist/
|
||||
deploy:
|
||||
runs-on: debian-latest
|
||||
needs: build
|
||||
@@ -36,8 +37,14 @@ jobs:
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: tf2wikipricing.user.js
|
||||
path: userscript
|
||||
name: tf2wikipricing
|
||||
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
|
||||
id: use-go-action
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
@@ -45,5 +52,6 @@ jobs:
|
||||
title: "v${{ need.build.outputs.version }}"
|
||||
name: "v${{ need.build.outputs.version }}"
|
||||
files: |
|
||||
userscript/**
|
||||
dist/tf2wikipricing.crx
|
||||
dist/tf2wikipricing.user.js
|
||||
sha256sum: true
|
||||
@@ -57,7 +57,7 @@ describe('prepareExchangeRates', () => {
|
||||
|
||||
const rates = await prepareExchangeRates();
|
||||
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 () => {
|
||||
@@ -66,17 +66,18 @@ describe('prepareExchangeRates', () => {
|
||||
if (key === storage_exchangerates_next) return new Date(Date.now() - 50000).toISOString();
|
||||
return null;
|
||||
});
|
||||
(GM_fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const mockResponse = {
|
||||
rates: mockRates,
|
||||
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();
|
||||
expect(rates).toEqual(mockRates);
|
||||
expect(GM_fetch).toHaveBeenCalled();
|
||||
expect(setStorageValue).toHaveBeenCalledWith(storage_exchangerates, mockRates);
|
||||
});
|
||||
|
||||
@@ -86,6 +87,7 @@ describe('prepareExchangeRates', () => {
|
||||
ok: false,
|
||||
status: 500
|
||||
} as Response);
|
||||
(chrome.runtime.sendMessage as jest.Fn).mockImplementation(() => {});
|
||||
|
||||
const rates = await prepareExchangeRates();
|
||||
expect(rates).toBeNull();
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('Price Service', () => {
|
||||
|
||||
const mockCachedData: ItemPriceData = {
|
||||
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,
|
||||
keys: 1,
|
||||
metal: 21.11,
|
||||
@@ -56,37 +56,37 @@ describe('Price Service', () => {
|
||||
})
|
||||
|
||||
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);
|
||||
(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)
|
||||
|
||||
expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, `${mockDefIndex};${mockQuality}`)
|
||||
expect(setStorageValue).toHaveBeenCalled()
|
||||
expect(result.metal).not.toBe(mockCachedData.metal)
|
||||
expect(result.metal).toBe(mockPriceResponse.metal)
|
||||
})
|
||||
|
||||
test('fetchPrice rejects with 401 when no token provided', async () => {
|
||||
await expect(fetchPrice('', mockDefIndex + ";" + mockQuality)).rejects.toBe(401)
|
||||
await expect(fetchPrice('', mockDefIndex + ";" + mockQuality)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('fetchPrice handles pricing API errors', async () => {
|
||||
const testError = 500;
|
||||
(priceUsingPricesTF as jest.Mock).mockRejectedValue(testError);
|
||||
(chrome.runtime.sendMessage as jest.Fn).mockResolvedValue(null);
|
||||
(priceUsingPricesTF as jest.Mock).mockImplementation(() => Promise.reject(new Error('500 Internal Server Error')));
|
||||
(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 () => {
|
||||
(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)
|
||||
|
||||
expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, `${defindex_key};6`)
|
||||
expect(result.keys).toBe(0) // A key cannot cost a key :P
|
||||
expect(result.metal).toBe(mockKeyPriceResponse.metal)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
// Mock the storage and log functions
|
||||
@@ -13,6 +13,24 @@ mock.module('../src/content/utils/log', () => ({
|
||||
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 = {
|
||||
'21': {
|
||||
name: 'Flame Thrower',
|
||||
@@ -89,6 +107,7 @@ const mockSchema: ItemSchema = {
|
||||
}
|
||||
|
||||
describe('Schema Service', () => {
|
||||
|
||||
test('getItemIndexByName returns correct defindex', () => {
|
||||
expect(getItemIndexByName(mockSchema, 'Flame Thrower')).toBe(208)
|
||||
expect(getItemIndexByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(5021)
|
||||
@@ -113,25 +132,12 @@ describe('Schema Service', () => {
|
||||
// Mock GM_fetch response
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
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 }
|
||||
}
|
||||
]
|
||||
json: async () => mockSchemaResponse
|
||||
};
|
||||
|
||||
// Mock Chrome runtime message
|
||||
(chrome.runtime.sendMessage as jest.Fn).mockImplementation(() => mockSchemaResponse);
|
||||
|
||||
// Mock GM_fetch
|
||||
globalThis.GM_fetch = mock(async () => mockResponse);
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
import { mock } from "bun:test";
|
||||
|
||||
GlobalRegistrator.register();
|
||||
Object.assign(global, require('jest-chrome'))
|
||||
|
||||
// Mock GM_fetch
|
||||
globalThis.GM_fetch = mock(async () => {});
|
||||
|
||||
18
package.json
18
package.json
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"name": "tf2wikipricing",
|
||||
"version": "0.7.1",
|
||||
"displayName": "TF2 Wiki Pricing",
|
||||
"version": "0.8.0",
|
||||
"description": "Adds item pricing to the Team Fortress 2 wiki",
|
||||
"author": "rapture.party",
|
||||
"devDependencies": {
|
||||
"@eslint/css": "^0.7.0",
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@eslint/json": "^0.12.0",
|
||||
"@happy-dom/global-registrator": "^17.4.4",
|
||||
"@types/firefox-webext-browser": "^120.0.4",
|
||||
"@types/greasemonkey": "^4.0.7",
|
||||
@@ -12,10 +17,13 @@
|
||||
"buffer": "^6.0.3",
|
||||
"bun-types": "^1.2.5",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"eslint": "^9.25.1",
|
||||
"globals": "^16.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"tf2-static-schema": "^1.74.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"typescript-eslint": "^8.31.0",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
@@ -35,12 +43,18 @@
|
||||
"dependencies": {
|
||||
"@types/chrome": "^0.0.270",
|
||||
"base64-inline-loader": "^2.0.1",
|
||||
"crx": "^5.0.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-to-string-loader": "^0.1.3",
|
||||
"extract-loader": "^5.1.0",
|
||||
"jest-chrome": "^0.8.0",
|
||||
"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",
|
||||
"to-string-loader": "^1.2.0",
|
||||
"url-loader": "^4.1.1"
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack-remove-empty-scripts": "^1.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
111
src/background/background.ts
Normal file
111
src/background/background.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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 { getPricesToken, priceUsingPricesTF } from './pricing/pricestf'
|
||||
import { getPricesToken } from './pricing/pricestf'
|
||||
import itemQualities from 'tf2-static-schema/static/qualities.json';
|
||||
import { getItemIndexByName, getTradableStatusByDefindex, ItemSchema, ItemSlot, prepareSchema, wipeSchema } from './schemaService'
|
||||
import { $T, extractLocaleFromURL } from './utils/localization'
|
||||
@@ -10,10 +11,10 @@ import { createPriceRow, createStoreButton } from './uiRenderer'
|
||||
import { findFirstElement, findFirstChildElement } from './utils/dom'
|
||||
import { extractPageTitleFromURL } from './utils/url';
|
||||
import { ExchangeRates, prepareExchangeRates } from './exchangeRateService';
|
||||
var itemSchema: ItemSchema | null;
|
||||
var exchangeRates: ExchangeRates | null;
|
||||
let itemSchema: ItemSchema | null;
|
||||
let exchangeRates: ExchangeRates | null;
|
||||
|
||||
var locale: string = 'en'
|
||||
let locale: string = 'en'
|
||||
|
||||
/** Exclude these from the pricelist. */
|
||||
const excludedQualities = new Set([
|
||||
@@ -22,8 +23,13 @@ const excludedQualities = new Set([
|
||||
]);
|
||||
|
||||
// Helper functions
|
||||
function getKeyByValue(object: any, value: string) {
|
||||
return Object.keys(object).find(key => object[key] === value);
|
||||
function getKeyByValue<K extends string | number | symbol, V>(obj: Record<K, V>, value: V): K | undefined {
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (val === value) {
|
||||
return key as unknown as K;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Main function
|
||||
@@ -33,8 +39,8 @@ async function inject() {
|
||||
// Not an item page
|
||||
return;
|
||||
}
|
||||
var itemIndex: number | null = null;
|
||||
var itemName: string | null = null;
|
||||
let itemIndex: number | null = null;
|
||||
let itemName: string | null = null;
|
||||
|
||||
// Find buy buttons
|
||||
const buyButton = findFirstChildElement('.btn_buynow', itemInfobox);
|
||||
@@ -52,8 +58,6 @@ async function inject() {
|
||||
}
|
||||
}
|
||||
|
||||
const url = document.URL;
|
||||
|
||||
if (itemName && !itemIndex) {
|
||||
itemIndex = getItemIndexByName(itemSchema, itemName)
|
||||
}
|
||||
@@ -94,7 +98,7 @@ async function inject() {
|
||||
return;
|
||||
}
|
||||
|
||||
var qualities: number[] = []
|
||||
const qualities: number[] = []
|
||||
|
||||
const firstQualityTag = findFirstChildElement('.quality-tag', itemInfobox);
|
||||
|
||||
@@ -122,7 +126,7 @@ async function inject() {
|
||||
// th.infobox-header (Basic Information)
|
||||
// ...
|
||||
|
||||
var storeButtons: HTMLTableRowElement[] = [];
|
||||
const storeButtons: HTMLTableRowElement[] = [];
|
||||
|
||||
// backpack.tf button
|
||||
storeButtons.push(createStoreButton("backpack.tf", new URL(`https://backpack.tf/classifieds?item=${encodeURIComponent(itemName)}`)));
|
||||
@@ -171,7 +175,7 @@ async function inject() {
|
||||
priceProgressRow.appendChild(priceProgressData);
|
||||
priceInfoboxHeadingRow.insertAdjacentElement('afterend', priceProgressRow);
|
||||
|
||||
var token: string | null;
|
||||
let token: string | null;
|
||||
|
||||
// Steam Community Market
|
||||
// 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);
|
||||
}
|
||||
|
||||
var updateTime: Date | null = null;
|
||||
let updateTime: Date | null = null;
|
||||
|
||||
enum PriceRowCategory {
|
||||
None,
|
||||
@@ -201,19 +205,19 @@ async function inject() {
|
||||
row: HTMLTableRowElement
|
||||
category: PriceRowCategory
|
||||
}
|
||||
var priceRows: PriceRow[]= [];
|
||||
const priceRows: PriceRow[]= [];
|
||||
|
||||
// Get current key price
|
||||
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 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 {
|
||||
data = await fetchPrice(token, itemIndex + ";" + quality, currentTime);
|
||||
updateTime = new Date(data.update)
|
||||
@@ -229,25 +233,21 @@ async function inject() {
|
||||
|
||||
// Check item schema for Australium variant of current defindex
|
||||
if(itemSchema[itemIndex].hasAustraliumVariant) {
|
||||
promises.push(new Promise(async (resolve) => {
|
||||
logDebug(`Fetching price for Australium ${itemName}`)
|
||||
var data: ItemPriceData | null
|
||||
try {
|
||||
data = await fetchPrice(token, `${itemIndex};11;australium`, currentTime);
|
||||
promises.push(fetchPrice(token, `${itemIndex};11;australium`, currentTime).then(data => {
|
||||
updateTime = new Date(data.update)
|
||||
} catch {
|
||||
log(`Australium ${itemName} is unpriced or unavailable, skipping...`)
|
||||
}
|
||||
logDebug(`Saving price for Australium ${itemName}`)
|
||||
|
||||
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})
|
||||
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
|
||||
if(itemSchema[itemIndex].festiveVariant != null) {
|
||||
/// Create subheading
|
||||
@@ -261,41 +261,33 @@ async function inject() {
|
||||
festiveHeadingRow.style.display = 'none';
|
||||
festiveHeadingRow.appendChild(festiveHeading);
|
||||
|
||||
promises.push(new Promise(async (resolve) => {
|
||||
logDebug(`Fetching price for Festive ${itemName}`)
|
||||
var data: ItemPriceData | null
|
||||
try {
|
||||
data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime);
|
||||
promises.push(fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime).then(data => {
|
||||
updateTime = new Date(data.update)
|
||||
} catch {
|
||||
log(`Festive ${itemName} is unpriced or unavailable, skipping...`)
|
||||
}
|
||||
logDebug(`Saving price for Festive ${itemName}`)
|
||||
|
||||
const priceRow = createPriceRow($T("Unique"), data, keyPrice, exchangeRates, locale)
|
||||
|
||||
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) => {
|
||||
logDebug(`Fetching price for Strange Festive ${itemName}`)
|
||||
var data: ItemPriceData | null
|
||||
try {
|
||||
data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime);
|
||||
promises.push(fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime).then(data => {
|
||||
updateTime = new Date(data.update)
|
||||
} catch {
|
||||
log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`)
|
||||
}
|
||||
logDebug(`Saving price for Strange Festive ${itemName}`)
|
||||
|
||||
const priceRow = createPriceRow($T("Strange"), data, keyPrice, exchangeRates, locale)
|
||||
|
||||
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
|
||||
if(itemSchema[itemIndex].slot == ItemSlot.Primary ||
|
||||
itemSchema[itemIndex].slot == ItemSlot.Secondary ||
|
||||
@@ -312,30 +304,23 @@ async function inject() {
|
||||
killstreakKitHeadingRow.style.display = 'none';
|
||||
killstreakKitHeadingRow.appendChild(heading);
|
||||
[1,2,3].map((tier) => {
|
||||
promises.push(new Promise(async (resolve) => {
|
||||
logDebug(`Fetching price for ${itemName} Killstreak Kit Tier ${tier}`)
|
||||
var data: ItemPriceData | null
|
||||
try {
|
||||
var kitIndex: number
|
||||
let kitIndex: number
|
||||
switch (tier) {
|
||||
default:
|
||||
case 1: kitIndex = 6527; break;
|
||||
case 2: kitIndex = 6523; 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)
|
||||
} catch {
|
||||
log(`${itemName} Killstreak Kit Tier ${tier} is unpriced or unavailable, skipping...`)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
logDebug(`Saving price for ${itemName} Killstreak Kit Tier ${tier}`)
|
||||
|
||||
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})
|
||||
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",
|
||||
"Gold Mk.II",
|
||||
]
|
||||
var botKillerHeadingRow: HTMLTableRowElement | null
|
||||
let botKillerHeadingRow: HTMLTableRowElement | null
|
||||
if(itemSchema[itemIndex].botkillerVariants != null && itemSchema[itemIndex].botkillerVariants.length > 0) {
|
||||
/// Create subheading
|
||||
botKillerHeadingRow = document.createElement("tr")
|
||||
@@ -366,25 +351,18 @@ async function inject() {
|
||||
|
||||
itemSchema[itemIndex].botkillerVariants.map((variantIndex) => {
|
||||
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]
|
||||
promises.push(new Promise(async (resolve) => {
|
||||
logDebug(`Fetching price for ${itemName}`)
|
||||
var data: ItemPriceData | null
|
||||
try {
|
||||
data = await fetchPrice(token, `${variantIndex};11`, currentTime);
|
||||
promises.push(fetchPrice(token, `${variantIndex};11`, currentTime).then(data => {
|
||||
logDebug(`Saving price for ${itemName}`)
|
||||
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")
|
||||
|
||||
// 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})
|
||||
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");
|
||||
label.colSpan = 2;
|
||||
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 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 exchangeRateAttribution = `<a rel="nofollow" class="external text" href="https://www.exchangerate-api.com">Rates By Exchange Rate API</a>.`;
|
||||
label.innerHTML = `${updateText}<br>${attributionText}<br>${exchangeRateAttribution}`;
|
||||
const attributionHeader = $T("Acknowledgements");
|
||||
const pricesAttribution = `<a rel="nofollow" class="external text" href="https://prices.tf">prices.tf</a>`;
|
||||
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);
|
||||
|
||||
priceProgressRow.insertAdjacentElement('afterend', row);
|
||||
@@ -437,10 +417,12 @@ async function inject() {
|
||||
}
|
||||
|
||||
function addStyles() {
|
||||
if(__ENV_USERSCRIPT) {
|
||||
const head = document.head || document.getElementsByTagName('head')[0],
|
||||
style = document.createElement('style');
|
||||
head.appendChild(style);
|
||||
style.innerHTML = styleCss;
|
||||
style.innerHTML = require('./style.css');
|
||||
}
|
||||
}
|
||||
|
||||
prepareSchema()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getStorageValue, setStorageValue } from './storage'
|
||||
import { logDebug, log, logError } from './utils/log'
|
||||
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 './GM_fetch'
|
||||
import { fetchWrap } from './fetchWrap';
|
||||
declare const __ENV_WEBEXTENSION: boolean;
|
||||
declare const __ENV_USERSCRIPT: boolean;
|
||||
|
||||
export interface ExchangeRates {
|
||||
[key: string]: number;
|
||||
@@ -16,8 +17,8 @@ export async function wipeExchangeRates(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function prepareExchangeRates(): Promise<ExchangeRates> {
|
||||
var needsUpdate: Boolean = false
|
||||
var rates: ExchangeRates | null = null
|
||||
let needsUpdate: boolean = false
|
||||
let rates: ExchangeRates | null = null
|
||||
|
||||
rates = await getStorageValue(storage_exchangerates, 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 nextUpdateTime = new Date(nextUpdate);
|
||||
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
|
||||
}
|
||||
} else {
|
||||
@@ -35,19 +36,35 @@ export async function prepareExchangeRates(): Promise<ExchangeRates> {
|
||||
|
||||
if(needsUpdate) {
|
||||
log("Exchange rates out of Date. Rebuilding...");
|
||||
const url = "https://open.er-api.com/v6/latest/USD"
|
||||
const response = await GM_fetch(url);
|
||||
if (response.ok) {
|
||||
await setStorageValue(storage_exchangerates_update, new Date().toISOString())
|
||||
var json = await response.json()
|
||||
if(json != null){
|
||||
rates = json['rates']
|
||||
await setStorageValue(storage_exchangerates, rates)
|
||||
await setStorageValue(storage_exchangerates_next, json['time_next_update_utc'])
|
||||
let exchangeResponse: {
|
||||
rates: ExchangeRates,
|
||||
time_next_update_utc: string
|
||||
}
|
||||
if(__ENV_USERSCRIPT) {
|
||||
const url = "https://open.er-api.com/v6/latest/USD"
|
||||
try {
|
||||
const response: Response = await fetchWrap(url)
|
||||
if(response.ok) {
|
||||
exchangeResponse = await response.json()
|
||||
}
|
||||
} catch (e) {
|
||||
logDebug(e);
|
||||
throw e;
|
||||
}
|
||||
logDebug(`Exchange rates updated at ${new Date()}`)
|
||||
} 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
10
src/content/fetchWrap.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { defindex_key, storage_priceprefix } from "./config"
|
||||
import { priceUsingPricesTF } from "./pricing/pricestf"
|
||||
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. */
|
||||
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.
|
||||
*/
|
||||
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) => {
|
||||
var data: ItemPriceData | null
|
||||
let data: ItemPriceData | null
|
||||
|
||||
const cached: ItemPriceData = await getStorageValue(storage_priceprefix + sku, null)
|
||||
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)) {
|
||||
logDebug(`Fetching price data for ${sku}`)
|
||||
if(!token) {
|
||||
reject(401)
|
||||
if(!token || token === '') {
|
||||
throw new Error('No token provided')
|
||||
}
|
||||
data = new ItemPriceData()
|
||||
data.sku = sku
|
||||
@@ -59,14 +60,19 @@ export async function fetchPrice(token: string, sku: string, update: Date = new
|
||||
data.ttl = ttl
|
||||
|
||||
try {
|
||||
const response = await priceUsingPricesTF(token, sku)
|
||||
if (response) {
|
||||
let response: PricesResponse
|
||||
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.metal = response.metal
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Received ${error} error while pricing ${sku} using prices.tf`)
|
||||
reject(error)
|
||||
throw new Error(`Received "${error}" error while pricing ${sku} using prices.tf`)
|
||||
}
|
||||
|
||||
if ('metal' in data && 'keys' in data) {
|
||||
@@ -75,6 +81,5 @@ export async function fetchPrice(token: string, sku: string, update: Date = new
|
||||
} else {
|
||||
logDebug(`Using cached price data for ${sku}`)
|
||||
}
|
||||
resolve(data)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
|
||||
import '../GM_fetch'
|
||||
import { logDebug } from '../utils/log'
|
||||
import { fetchWrap } from '../fetchWrap'
|
||||
import { logDebug, logError } from '../utils/log'
|
||||
declare const __ENV_WEBEXTENSION: boolean;
|
||||
declare const __ENV_USERSCRIPT: boolean;
|
||||
|
||||
async function getPricesToken(): Promise<string> {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
GM_fetch('https://api2.prices.tf/auth/access', {
|
||||
if(__ENV_USERSCRIPT) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
fetchWrap('https://api2.prices.tf/auth/access', {
|
||||
method: 'post',
|
||||
headers: new Headers({
|
||||
'Accept': 'application/json'
|
||||
@@ -18,6 +20,9 @@ async function getPricesToken(): Promise<string> {
|
||||
})
|
||||
.then((responseData) => resolve(responseData['accessToken']))
|
||||
})
|
||||
} else {
|
||||
return chrome.runtime.sendMessage({contentScriptQuery: 'getPricesTFToken'})
|
||||
}
|
||||
}
|
||||
|
||||
class PricesResponse {
|
||||
@@ -42,63 +47,45 @@ async function priceUsingPricesTF(token: string, sku: string, retries: number =
|
||||
// prices.tf
|
||||
// https://api2.prices.tf/prices/${sku}
|
||||
// Authorization: Bearer ${token}
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if (!token) {
|
||||
reject(401)
|
||||
}
|
||||
var response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, {
|
||||
try {
|
||||
const response = await fetchWrap(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, {
|
||||
method: 'get',
|
||||
headers: new Headers({
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
'Authorization': `Bearer ${token}`,
|
||||
}
|
||||
})
|
||||
})
|
||||
if (response.status === 404 && sku.includes(';')) {
|
||||
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
|
||||
response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku + ';uncraftable')}`, {
|
||||
method: 'get',
|
||||
headers: new Headers({
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
})
|
||||
})
|
||||
return priceUsingPricesTF(token, sku + ';uncraftable');
|
||||
}
|
||||
}
|
||||
switch (response.status) {
|
||||
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:
|
||||
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) {
|
||||
logDebug(`Cloudflare rate limit exceeded, trying again after 2 seconds, ${retries} retries left`)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
try {
|
||||
const retryResult = await priceUsingPricesTF(token, sku, retries - 1);
|
||||
resolve(retryResult);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
if(retries >= 0) {
|
||||
logDebug(`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 {
|
||||
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 }
|
||||
@@ -1,10 +1,11 @@
|
||||
declare const __ENV_WEBEXTENSION: boolean;
|
||||
declare const __ENV_USERSCRIPT: boolean;
|
||||
import { getStorageValue, setStorageValue } from './storage'
|
||||
import { logDebug, log, logError } from './utils/log'
|
||||
import './config'
|
||||
declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>
|
||||
import './GM_fetch'
|
||||
import { storage_version, storage_schema, storage_lastUpdateTime } from './config'
|
||||
import Australiums from '../resources/australiums.json'
|
||||
import { fetchWrap } from './fetchWrap'
|
||||
const semver = require('semver')
|
||||
|
||||
export function checkAustraliumVariant(defindex: number): boolean {
|
||||
@@ -14,8 +15,8 @@ export function checkAustraliumVariant(defindex: number): boolean {
|
||||
export declare const __VERSION__: string;
|
||||
|
||||
function isDateAfterOneDay(date1: Date, date2: Date): boolean {
|
||||
var diff = date2.getTime() - date1.getTime();
|
||||
var diffDays = Math.round(diff / (1000 * 3600 * 24));
|
||||
const diff = date2.getTime() - date1.getTime();
|
||||
const diffDays = Math.round(diff / (1000 * 3600 * 24));
|
||||
return diffDays > 1;
|
||||
}
|
||||
|
||||
@@ -36,15 +37,53 @@ export class ItemSchema {
|
||||
[key: string]: {
|
||||
name: string,
|
||||
slot: ItemSlot,
|
||||
tradable: Boolean,
|
||||
hasAustraliumVariant: Boolean,
|
||||
tradable: boolean,
|
||||
hasAustraliumVariant: boolean,
|
||||
festiveVariant: 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)) {
|
||||
if (value['name'] == name) {
|
||||
const index = parseInt(defindex)
|
||||
@@ -60,7 +99,7 @@ export function getTradableStatusByDefindex(schema: ItemSchema, defindex: number
|
||||
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)) {
|
||||
if (value['name'] == name) {
|
||||
const index = parseInt(defindex)
|
||||
@@ -119,12 +158,12 @@ export async function wipeSchema(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function prepareSchema(): Promise<ItemSchema> {
|
||||
var needsUpdate: Boolean = false
|
||||
var itemSchema: ItemSchema | null = null
|
||||
let needsUpdate: boolean = false
|
||||
let itemSchema: ItemSchema | null = null
|
||||
|
||||
const storedVersion: string | null = await getStorageValue(storage_version, null)
|
||||
if(!storedVersion || !semver.valid(storedVersion)) {
|
||||
log(`Cache is from an unknown version of the extension. Updating for version ${__VERSION__}`);
|
||||
log(`Preparing the extension for the first time.`);
|
||||
needsUpdate = true
|
||||
} else if(semver.valid(storedVersion) && semver.lt(storedVersion, __VERSION__)) {
|
||||
log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`);
|
||||
@@ -145,31 +184,38 @@ export async function prepareSchema(): Promise<ItemSchema> {
|
||||
|
||||
if(needsUpdate) {
|
||||
log("Item Schema out of Date. Rebuilding...");
|
||||
const url = "https://raw.githubusercontent.com/danocmx/node-tf2-static-schema/master/static/items.json"
|
||||
const response = await GM_fetch(url);
|
||||
if (response.ok) {
|
||||
try {
|
||||
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`
|
||||
responseItems.forEach((item: any) => {
|
||||
responseItems.forEach((item: SchemaResponseItem) => {
|
||||
const defindex: number = item['defindex']
|
||||
|
||||
var tradable: Boolean = true
|
||||
let tradable: boolean = true
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
} catch(error) {
|
||||
logError(error)
|
||||
log(item)
|
||||
}
|
||||
|
||||
var canKillstreakify: Boolean = false
|
||||
let canKillstreakify: boolean = false
|
||||
try {
|
||||
if(item['capabilities'] != null) {
|
||||
if(item['capabilities']['can_killstreakify'] != null && item['capabilities']['can_killstreakify'] == true) {
|
||||
@@ -178,10 +224,9 @@ export async function prepareSchema(): Promise<ItemSchema> {
|
||||
}
|
||||
} catch(error) {
|
||||
logError(error)
|
||||
log(item)
|
||||
}
|
||||
|
||||
(cacheItems as any)[defindex.toString()] = {
|
||||
cacheItems[defindex.toString()] = {
|
||||
"name": item['item_name'],
|
||||
"slot": item['item_slot'],
|
||||
"tradable": tradable,
|
||||
@@ -197,8 +242,8 @@ export async function prepareSchema(): Promise<ItemSchema> {
|
||||
itemSchema = cacheItems
|
||||
await setStorageValue(storage_version, __VERSION__);
|
||||
logDebug(`Item schema updated at ${new Date()}`)
|
||||
} else {
|
||||
logError("Could not fetch item schema.");
|
||||
} catch (e) {
|
||||
logError("Could not fetch item schema.", e);
|
||||
}
|
||||
}
|
||||
return itemSchema
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
declare var __ENV_USERSCRIPT: boolean;
|
||||
declare var __ENV_WEBEXTENSION: boolean;
|
||||
declare let __ENV_USERSCRIPT: boolean;
|
||||
declare let __ENV_WEBEXTENSION: boolean;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getStorageValue(name: string, defaultValue: string): Promise<any> {
|
||||
if(__ENV_USERSCRIPT) {
|
||||
return GM.getValue(name, defaultValue);
|
||||
} else if(__ENV_WEBEXTENSION) {
|
||||
return browser.storage.local.get(name);
|
||||
return chrome.storage.local.get(name)
|
||||
.then((result) => result[name])
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Promise<any>((resolve) => {
|
||||
resolve(defaultValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setStorageValue(name: string, value: any): Promise<any> {
|
||||
function setStorageValue(name: string, value: unknown): Promise<void> {
|
||||
if(__ENV_USERSCRIPT) {
|
||||
return GM.setValue(name, value);
|
||||
return GM.setValue(name, value as GM.Value);
|
||||
} else if(__ENV_WEBEXTENSION) {
|
||||
return browser.storage.local.set({name, value});
|
||||
return chrome.storage.local.set({[name]: value});
|
||||
} else {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
return new Promise<void>((_, reject) => {
|
||||
reject();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric
|
||||
|
||||
const priceData = document.createElement("td");
|
||||
const priceLink = document.createElement("span");
|
||||
var priceString: string = ''
|
||||
let priceString: string = ''
|
||||
|
||||
if(data) {
|
||||
const gamePrice = formatPrice(data.keys, data.metal, keyPrice.metal, locale).trim()
|
||||
const realPriceUSD = convertTF2PriceToUSD(data.keys, data.metal, keyPrice.metal)
|
||||
|
||||
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'
|
||||
if(currency !== 'USD') {
|
||||
try {
|
||||
@@ -56,7 +56,7 @@ export function createPriceRow(qualityName: string, data: ItemPriceData, keyPric
|
||||
|
||||
export function createStoreButton(storeName: string, url: URL) {
|
||||
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("{title}", $T("View listings on %@").replace('%@', storeName))
|
||||
button.innerHTML = source
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { conversion_ref_usd } from '../config';
|
||||
import { $T } from './localization'
|
||||
|
||||
function toFixed(num: number, fixed: number) {
|
||||
var re = new RegExp('^-?\\d+(?:\.\\d{0,' + (fixed || -1) + '})?');
|
||||
const re = new RegExp('^-?\\d+(?:.\\d{0,' + (fixed || -1) + '})?');
|
||||
return num.toString().match(re)[0];
|
||||
}
|
||||
|
||||
export function formatPrice(keys: number, metal: number, keyPrice: number, locale: string = 'en') {
|
||||
const formattedKeys = +(keys + (metal / keyPrice)).toFixed(2)
|
||||
|
||||
var output: string = ''
|
||||
let output: string = ''
|
||||
if(keys > 0) {
|
||||
output += (formattedKeys == 1.0 ? $T("%@ key") : $T("%@ keys")).replace('%@', formattedKeys.toLocaleString(locale))
|
||||
} else {
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
const localizations: {[lang: string]: any} = {
|
||||
import { logDebug } from "./log";
|
||||
|
||||
const localizations: Record<string, object> = {
|
||||
'en': require('../../strings/en'), // English
|
||||
'es': require('../../strings/es'), // Spanish
|
||||
// 'ja': require('../../strings/ja'), // Japanese
|
||||
// 'it': require('../../strings/it'), // Italian
|
||||
// 'ar': require('../../strings/ar.json') as object, // Arabic
|
||||
// 'cs': require('../../strings/cs.json') as object, // Czech
|
||||
// 'da': require('../../strings/da.json') as object, // Danish
|
||||
// 'de': require('../../strings/de.json') as object, // German
|
||||
// 'fi': require('../../strings/fi.json') as object, // Finnish
|
||||
// 'fr': require('../../strings/fr.json') as object, // French
|
||||
// 'hu': require('../../strings/hu.json') as object, // Hungarian
|
||||
// 'ko': require('../../strings/ko.json') as object, // Korean
|
||||
// 'nl': require('../../strings/nl.json') as object, // Dutch
|
||||
// 'no': require('../../strings/no.json') as object, // Norwegian Bokmål
|
||||
// 'pl': require('../../strings/pl.json') as object, // Polish
|
||||
// 'pt': require('../../strings/pt.json') as object, // Portuguese
|
||||
// 'pt-BR': require('../../strings/pt-BR.json') as object, // Brazilian Portuguese
|
||||
// 'ro': require('../../strings/ro.json') as object, // Romanian
|
||||
// 'ru': require('../../strings/ru.json') as object, // Russian
|
||||
// 'sv': require('../../strings/sv.json') as object, // Swedish
|
||||
// 'tr': require('../../strings/tr.json') as object, // Turkish
|
||||
// 'zh-Hans': require('../../strings/zh-Hans.json') as object, // Simplified Chinese
|
||||
// 'zh-Hant': require('../../strings/zh-Hant.json') as object, // Traditional Chinese
|
||||
'ja': require('../../strings/ja'), // Japanese
|
||||
'it': require('../../strings/it'), // Italian
|
||||
'ar': require('../../strings/ar'), // Arabic
|
||||
'cs': require('../../strings/cs'), // Czech
|
||||
'da': require('../../strings/da'), // Danish
|
||||
'de': require('../../strings/de'), // German
|
||||
'fi': require('../../strings/fi'), // Finnish
|
||||
'fr': require('../../strings/fr'), // French
|
||||
'hu': require('../../strings/hu'), // Hungarian
|
||||
'ko': require('../../strings/ko'), // Korean
|
||||
'nl': require('../../strings/nl'), // Dutch
|
||||
'no': require('../../strings/no'), // Norwegian Bokmål
|
||||
'pl': require('../../strings/pl'), // Polish
|
||||
'pt': require('../../strings/pt'), // Portuguese
|
||||
'pt-BR': require('../../strings/pt-BR'), // Brazilian Portuguese
|
||||
'ro': require('../../strings/ro'), // Romanian
|
||||
'ru': require('../../strings/ru'), // Russian
|
||||
'sv': require('../../strings/sv'), // Swedish
|
||||
'tr': require('../../strings/tr'), // Turkish
|
||||
'zh-Hans': require('../../strings/zh-Hans'), // Simplified Chinese
|
||||
'zh-Hant': require('../../strings/zh-Hant'), // Traditional Chinese
|
||||
}
|
||||
|
||||
export function $T(s: string, locale?: Intl.LocalesArgument): string {
|
||||
const code = locale ? locale.toString() : extractLocaleFromURL(document.URL)
|
||||
return localizations.hasOwnProperty(code) ? (localizations[code as unknown as keyof object])[s] || s : s;
|
||||
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 {
|
||||
var split = url.substring(url.indexOf("/wiki/") + "/wiki/".length);
|
||||
const split = url.substring(url.indexOf("/wiki/") + "/wiki/".length);
|
||||
if (split.indexOf('/') != -1) {
|
||||
// Remove language suffix e.g. `/es`
|
||||
return split.substring(split.indexOf('/') + 1);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
declare var __PRODUCTION: boolean;
|
||||
declare var __EXTENSION_NAME: string;
|
||||
declare let __EXTENSION_NAME: string;
|
||||
const logHeader = `[${__EXTENSION_NAME}] `;
|
||||
|
||||
/** `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);
|
||||
}
|
||||
|
||||
/** `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.error` with header */
|
||||
function logError(message?: any, ...optionalParams: any[]): void {
|
||||
function logError(message?: string, ...optionalParams: Array<unknown>): void {
|
||||
console.error(logHeader + message, optionalParams)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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) {
|
||||
// Remove language suffix (/es)
|
||||
split = split.substring(0, split.indexOf('/'));
|
||||
|
||||
27
src/eslint.config.mjs
Normal file
27
src/eslint.config.mjs
Normal 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"] },
|
||||
]);
|
||||
@@ -4,14 +4,34 @@
|
||||
"author": EXTENSION_AUTHOR,
|
||||
"manifest_version": 3,
|
||||
"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": [
|
||||
{
|
||||
"matches": ["*://wiki.teamfortress.com/wiki/*"],
|
||||
"run_at": "document_start",
|
||||
"all_frames": true,
|
||||
"css": ["lib/style.css"],
|
||||
"js": ["content/content.js"]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"icons": {
|
||||
"48": "icons/icon-48.png",
|
||||
"96": "icons/icon-96.png"
|
||||
|
||||
44
src/strings/ar.js
Normal file
44
src/strings/ar.js
Normal 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
44
src/strings/cs.js
Normal 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
44
src/strings/da.js
Normal 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
44
src/strings/de.js
Normal 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",
|
||||
}
|
||||
@@ -6,7 +6,7 @@ module.exports = {
|
||||
"Community Pricing": "Community Pricing",
|
||||
// Itembox footer
|
||||
"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
|
||||
"Data unavailable": "Data unavailable", // sourced from AppleGlot
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = {
|
||||
"Community Pricing": "Precios de la comunidad",
|
||||
// Itembox footer
|
||||
"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
|
||||
"Data unavailable": "Datos no disponibles", // sourced from AppleGlot
|
||||
@@ -38,7 +38,7 @@ module.exports = {
|
||||
|
||||
// Killstreak tiers sourced from TF2 wiki
|
||||
"Killstreak Kit": "Kit Cuentarrachas",
|
||||
"kt-1": "Standard",
|
||||
"kt-1": "Estándar",
|
||||
"kt-2": "Especializado",
|
||||
"kt-3": "Profesional",
|
||||
}
|
||||
44
src/strings/fi.js
Normal file
44
src/strings/fi.js
Normal 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
44
src/strings/fr.js
Normal 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
44
src/strings/hu.js
Normal 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",
|
||||
}
|
||||
@@ -3,16 +3,16 @@ module.exports = {
|
||||
"View listings on %@": "Voir les offres sur %@",
|
||||
|
||||
// Itembox header
|
||||
"Community Pricing": "Community Pricing",
|
||||
"Community Pricing": "Prezzo Comunitario",
|
||||
// Itembox footer
|
||||
"Updated %@": "Updated %@.",
|
||||
"Trade prices sourced from %@. Currency conversions are approximate.": "Trade prices sourced from %@. Currency conversions are approximate.", // %@ is always a URL, (eg. prices.tf)
|
||||
"Updated %@.": "Aggiornato il %@.",
|
||||
"Acknowledgements": "Note legali", // sourced from AppleGlot
|
||||
|
||||
// Price strings
|
||||
"Data unavailable": "Data unavailable", // sourced from AppleGlot
|
||||
"%@ ref": "%@ ref",
|
||||
"%@ key": "%@ key",
|
||||
"%@ keys": "%@ keys",
|
||||
"%@ ref": "%@ raf",
|
||||
"%@ key": "%@ chiave",
|
||||
"%@ keys": "%@ chiavi",
|
||||
|
||||
// Item quality names, all sourced from TF2 wiki
|
||||
"Normal": "Normale",
|
||||
@@ -22,5 +22,23 @@ module.exports = {
|
||||
"Strange": "Strano",
|
||||
"Collector's": "Da collezione",
|
||||
"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",
|
||||
}
|
||||
@@ -3,10 +3,10 @@ module.exports = {
|
||||
"View listings on %@": "%@で検索結果を見る",
|
||||
|
||||
// Itembox header
|
||||
"Community Pricing": "Community Pricing",
|
||||
"Community Pricing": "共同体価格",
|
||||
// Itembox footer
|
||||
"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
|
||||
"Data unavailable": "データがありません", // sourced from AppleGlot
|
||||
@@ -22,5 +22,23 @@ module.exports = {
|
||||
"Strange": "ストレンジ",
|
||||
"Collector's": "Collector's",
|
||||
"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
44
src/strings/ko.js
Normal 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
44
src/strings/nl.js
Normal 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
44
src/strings/no.js
Normal 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
44
src/strings/pl.js
Normal 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
44
src/strings/pt-BR.js
Normal 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
44
src/strings/pt.js
Normal 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
44
src/strings/ro.js
Normal 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
44
src/strings/ru.js
Normal 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
44
src/strings/sv.js
Normal 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
44
src/strings/tr.js
Normal 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
44
src/strings/zh-Hans.js
Normal 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
44
src/strings/zh-Hant.js
Normal 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",
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// @name EXTENSION_NAME
|
||||
// @description EXTENSION_DESCRIPTION
|
||||
// @version EXTENSION_VERSION
|
||||
// @author EXTENSION_AUTHOR
|
||||
// @match *://wiki.teamfortress.com/wiki/*
|
||||
// @run-at document-start
|
||||
// @inject-into content
|
||||
@@ -9,6 +10,8 @@
|
||||
// @domain steamcommunity.com
|
||||
// @connect prices.tf
|
||||
// @domain prices.tf
|
||||
// @connect open.er-api.com
|
||||
// @domain open.er-api.com
|
||||
// @grant GM.setValue
|
||||
// @grant GM_setValue
|
||||
// @grant GM.getValue
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["bun-types", "jest", "greasemonkey", "firefox-webext-browser"]
|
||||
"types": ["bun-types", "jest", "greasemonkey", "chrome", "firefox-webext-browser"]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
var path = require('path');
|
||||
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 fs = require('fs');
|
||||
var package = require('./package.json');
|
||||
@@ -12,7 +14,7 @@ function allReplace(str, obj, quote = true) {
|
||||
};
|
||||
|
||||
const defines = {
|
||||
EXTENSION_NAME: package.name,
|
||||
EXTENSION_NAME: package.displayName,
|
||||
__EXTENSION_NAME: JSON.stringify(package.name),
|
||||
EXTENSION_AUTHOR: package.author,
|
||||
EXTENSION_DESCRIPTION: package.description,
|
||||
@@ -21,32 +23,65 @@ const defines = {
|
||||
}
|
||||
|
||||
module.exports = [
|
||||
/*
|
||||
// WebExtension
|
||||
{
|
||||
entry: {
|
||||
content: './src/content/content.ts'
|
||||
content: './src/content/content.ts',
|
||||
background: './src/background/background.ts',
|
||||
style: './src/content/style.css'
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
exclude: /node_modules|GM_fetch/,
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|gif|svg)$/i,
|
||||
test: /\.css$/i,
|
||||
use: [
|
||||
{
|
||||
loader: 'url-loader',
|
||||
loader: MiniCssExtractPlugin.loader,
|
||||
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: {
|
||||
minimize: true
|
||||
},
|
||||
@@ -55,10 +90,11 @@ module.exports = [
|
||||
filename: "[name]/[name].js"
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js", ".json", ".css"]
|
||||
extensions: [".ts", ".tsx", ".js", ".json"]
|
||||
},
|
||||
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: [
|
||||
{ from: './src/manifest.json', to: 'manifest.json',
|
||||
transform(content, absoluteFrom) {
|
||||
@@ -68,14 +104,17 @@ module.exports = [
|
||||
]}),
|
||||
new CopyPlugin({ patterns: [
|
||||
{ from: './src/icons', to: 'icons/[file]'},
|
||||
{ from: './src/resources/*.png', to: 'resources/[name][ext]' },
|
||||
]}),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'lib/style.css'
|
||||
}),
|
||||
],
|
||||
},
|
||||
*/
|
||||
// Userscript
|
||||
{
|
||||
entry: {
|
||||
content: './src/content/content.ts'
|
||||
content: ['./src/content/content.ts', './src/content/GM_fetch/index.js' ]
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
|
||||
Reference in New Issue
Block a user