diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 349d978..efa0685 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -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 \ No newline at end of file + name: tf2wikipricing + path: dist/ \ No newline at end of file diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index c071d53..68c2590 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -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 \ No newline at end of file diff --git a/__tests__/exchangeRateService.test.ts b/__tests__/exchangeRateService.test.ts index c9c153c..a9ac197 100644 --- a/__tests__/exchangeRateService.test.ts +++ b/__tests__/exchangeRateService.test.ts @@ -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; }); + const mockResponse = { + rates: mockRates, + time_next_update_utc: new Date(Date.now() + 100000).toISOString() + }; (GM_fetch as jest.Mock).mockResolvedValue({ ok: true, - json: async () => ({ - rates: mockRates, - time_next_update_utc: new Date(Date.now() + 100000).toISOString() - }) + 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(); diff --git a/__tests__/priceService.test.ts b/__tests__/priceService.test.ts index 2ee36f9..29b719a 100644 --- a/__tests__/priceService.test.ts +++ b/__tests__/priceService.test.ts @@ -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) }) diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts index bd1d516..5ba02b8 100644 --- a/__tests__/schema.test.ts +++ b/__tests__/schema.test.ts @@ -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); diff --git a/bun.lockb b/bun.lockb index 5aa90dd..c86c55f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/happydom.ts b/happydom.ts index 9cae201..984722e 100644 --- a/happydom.ts +++ b/happydom.ts @@ -1,3 +1,8 @@ import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { mock } from "bun:test"; -GlobalRegistrator.register(); \ No newline at end of file +GlobalRegistrator.register(); +Object.assign(global, require('jest-chrome')) + +// Mock GM_fetch +globalThis.GM_fetch = mock(async () => {}); diff --git a/package.json b/package.json index bdf0a82..4dda83f 100644 --- a/package.json +++ b/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" } } diff --git a/src/background/background.ts b/src/background/background.ts new file mode 100644 index 0000000..cfcc6ee --- /dev/null +++ b/src/background/background.ts @@ -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 { + 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; + } + } +); \ No newline at end of file diff --git a/src/content/content.ts b/src/content/content.ts index 5f2b11e..9d0afae 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -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(obj: Record, 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); - updateTime = new Date(data.update) - } catch { - log(`Australium ${itemName} is unpriced or unavailable, skipping...`) - } + promises.push(fetchPrice(token, `${itemIndex};11;australium`, currentTime).then(data => { + updateTime = new Date(data.update) + 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); - updateTime = new Date(data.update) - } catch { - log(`Festive ${itemName} is unpriced or unavailable, skipping...`) - } + promises.push(fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime).then(data => { + updateTime = new Date(data.update) + 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); - updateTime = new Date(data.update) - } catch { - log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`) - } + promises.push(fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime).then(data => { + updateTime = new Date(data.update) + 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 - 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); - updateTime = new Date(data.update) - } catch { - log(`${itemName} Killstreak Kit Tier ${tier} is unpriced or unavailable, skipping...`) - resolve() - return - } + let kitIndex: number + switch (tier) { + default: + case 1: kitIndex = 6527; break; + case 2: kitIndex = 6523; break; + case 3: kitIndex = 6526; break; + } + promises.push(fetchPrice(token, `${kitIndex};6;uncraftable;kt-${tier};td-${itemIndex}`, currentTime).then(data => { + updateTime = new Date(data.update) + 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); - updateTime = new Date(data.update) - } catch { - log(`${itemName} is unpriced or unavailable, skipping...`) - } + promises.push(fetchPrice(token, `${variantIndex};11`, currentTime).then(data => { + logDebug(`Saving price for ${itemName}`) + updateTime = new Date(data.update) 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('%@', 'prices.tf'); - const exchangeRateAttribution = `Rates By Exchange Rate API.`; - label.innerHTML = `${updateText}
${attributionText}
${exchangeRateAttribution}`; + const attributionHeader = $T("Acknowledgements"); + const pricesAttribution = `prices.tf`; + const exchangeRateAttribution = `Rates By Exchange Rate API`; + label.innerHTML = `${updateText}
${attributionHeader}
${pricesAttribution}
${exchangeRateAttribution}`; row.appendChild(label); priceProgressRow.insertAdjacentElement('afterend', row); @@ -437,10 +417,12 @@ async function inject() { } function addStyles() { - const head = document.head || document.getElementsByTagName('head')[0], + if(__ENV_USERSCRIPT) { + const head = document.head || document.getElementsByTagName('head')[0], style = document.createElement('style'); - head.appendChild(style); - style.innerHTML = styleCss; + head.appendChild(style); + style.innerHTML = require('./style.css'); + } } prepareSchema() diff --git a/src/content/exchangeRateService.ts b/src/content/exchangeRateService.ts index fe3a24a..ba6a645 100644 --- a/src/content/exchangeRateService.ts +++ b/src/content/exchangeRateService.ts @@ -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 -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 { } export async function prepareExchangeRates(): Promise { - 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 { 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 { 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) } } diff --git a/src/content/fetchWrap.ts b/src/content/fetchWrap.ts new file mode 100644 index 0000000..49a30ce --- /dev/null +++ b/src/content/fetchWrap.ts @@ -0,0 +1,10 @@ +declare let __ENV_USERSCRIPT: boolean; +declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise + +export function fetchWrap(input: string | URL | globalThis.Request, init?: RequestInit): Promise { + if(__ENV_USERSCRIPT) { + return GM_fetch(input, init) + } else { + return fetch(input, init) + } +} diff --git a/src/content/priceService.ts b/src/content/priceService.ts index 5aa675d..fac3b1a 100644 --- a/src/content/priceService.ts +++ b/src/content/priceService.ts @@ -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 { - 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) { - data.keys = response.keys - data.metal = response.metal + 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 } diff --git a/src/content/pricing/pricestf.ts b/src/content/pricing/pricestf.ts index 1d6989d..abeff72 100644 --- a/src/content/pricing/pricestf.ts +++ b/src/content/pricing/pricestf.ts @@ -1,10 +1,12 @@ -declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise -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 { - return new Promise((resolve, reject) => { - GM_fetch('https://api2.prices.tf/auth/access', { + if(__ENV_USERSCRIPT) { + return new Promise((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 { }) .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: - // 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); - } - } else { - reject("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.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 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`) + } } - }) + 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 } \ No newline at end of file diff --git a/src/content/schemaService.ts b/src/content/schemaService.ts index 0a95ca8..1709ff1 100644 --- a/src/content/schemaService.ts +++ b/src/content/schemaService.ts @@ -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 -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 | 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 { } export async function prepareSchema(): Promise { - 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 { 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 { } } 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 = 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 diff --git a/src/content/storage.ts b/src/content/storage.ts index d14fea8..6b1d2b7 100644 --- a/src/content/storage.ts +++ b/src/content/storage.ts @@ -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 { 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((resolve) => { resolve(defaultValue); }); } } -function setStorageValue(name: string, value: any): Promise { +function setStorageValue(name: string, value: unknown): Promise { 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((resolve, reject) => { + return new Promise((_, reject) => { reject(); }); } diff --git a/src/content/uiRenderer.ts b/src/content/uiRenderer.ts index f555759..3f60e3d 100644 --- a/src/content/uiRenderer.ts +++ b/src/content/uiRenderer.ts @@ -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 = `` + let source = `` source = source.replace("{link}", url.toString()) source = source.replace("{title}", $T("View listings on %@").replace('%@', storeName)) button.innerHTML = source diff --git a/src/content/utils/formatting.ts b/src/content/utils/formatting.ts index 4c27a1e..0418a18 100644 --- a/src/content/utils/formatting.ts +++ b/src/content/utils/formatting.ts @@ -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 { diff --git a/src/content/utils/localization.ts b/src/content/utils/localization.ts index 6b262b0..4706b8b 100644 --- a/src/content/utils/localization.ts +++ b/src/content/utils/localization.ts @@ -1,36 +1,45 @@ -const localizations: {[lang: string]: any} = { +import { logDebug } from "./log"; + +const localizations: Record = { '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; + 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); diff --git a/src/content/utils/log.ts b/src/content/utils/log.ts index 23ababe..ba32c97 100644 --- a/src/content/utils/log.ts +++ b/src/content/utils/log.ts @@ -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): 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): void { console.log(logHeader + message, optionalParams) } /** `console.error` with header */ -function logError(message?: any, ...optionalParams: any[]): void { +function logError(message?: string, ...optionalParams: Array): void { console.error(logHeader + message, optionalParams) } diff --git a/src/content/utils/url.ts b/src/content/utils/url.ts index 3aa388c..566fd4f 100644 --- a/src/content/utils/url.ts +++ b/src/content/utils/url.ts @@ -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('/')); diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs new file mode 100644 index 0000000..b3ef968 --- /dev/null +++ b/src/eslint.config.mjs @@ -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"] }, +]); \ No newline at end of file diff --git a/src/manifest.json b/src/manifest.json index 09c8e80..0d5fd6e 100755 --- a/src/manifest.json +++ b/src/manifest.json @@ -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" diff --git a/src/strings/ar.js b/src/strings/ar.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/ar.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/cs.js b/src/strings/cs.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/cs.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/da.js b/src/strings/da.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/da.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/de.js b/src/strings/de.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/de.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/en.js b/src/strings/en.js index 485fd95..dc30ea5 100644 --- a/src/strings/en.js +++ b/src/strings/en.js @@ -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 diff --git a/src/strings/es.js b/src/strings/es.js index 3eff563..d8ed300 100644 --- a/src/strings/es.js +++ b/src/strings/es.js @@ -6,14 +6,14 @@ 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 "%@ ref": "%@ ref", "%@ key": "%@ llave", "%@ keys": "%@ llaves", - + // Item quality names, all sourced from TF2 wiki "Normal": "de Calidad Normal", "Genuine": "de Calidad Genuina", @@ -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", } \ No newline at end of file diff --git a/src/strings/fi.js b/src/strings/fi.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/fi.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/fr.js b/src/strings/fr.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/fr.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/hu.js b/src/strings/hu.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/hu.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/it.js b/src/strings/it.js index 961fa57..734302e 100644 --- a/src/strings/it.js +++ b/src/strings/it.js @@ -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", } \ No newline at end of file diff --git a/src/strings/ja.js b/src/strings/ja.js index 01b1528..ffcc18d 100644 --- a/src/strings/ja.js +++ b/src/strings/ja.js @@ -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", } \ No newline at end of file diff --git a/src/strings/ko.js b/src/strings/ko.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/ko.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/nl.js b/src/strings/nl.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/nl.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/no.js b/src/strings/no.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/no.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/pl.js b/src/strings/pl.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/pl.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/pt-BR.js b/src/strings/pt-BR.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/pt-BR.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/pt.js b/src/strings/pt.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/pt.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/ro.js b/src/strings/ro.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/ro.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/ru.js b/src/strings/ru.js new file mode 100644 index 0000000..245f637 --- /dev/null +++ b/src/strings/ru.js @@ -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": "профессионального", + } \ No newline at end of file diff --git a/src/strings/sv.js b/src/strings/sv.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/sv.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/tr.js b/src/strings/tr.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/tr.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/zh-Hans.js b/src/strings/zh-Hans.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/zh-Hans.js @@ -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", +} \ No newline at end of file diff --git a/src/strings/zh-Hant.js b/src/strings/zh-Hant.js new file mode 100644 index 0000000..dc30ea5 --- /dev/null +++ b/src/strings/zh-Hant.js @@ -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", +} \ No newline at end of file diff --git a/src/userscript_header.js b/src/userscript_header.js index f8f443a..11d0f31 100644 --- a/src/userscript_header.js +++ b/src/userscript_header.js @@ -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 diff --git a/tsconfig.json b/tsconfig.json index 8282a68..4b38ead 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } } \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 5e87d44..151bb28 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -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,12 +90,13 @@ 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', + { from: './src/manifest.json', to: 'manifest.json', transform(content, absoluteFrom) { return allReplace(content.toString(), defines) }, @@ -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: [