├── .nvmrc ├── tests ├── jest.setup.js ├── environment │ ├── getLocales.js │ └── getApp.js ├── listingsmanager.test.js ├── jestconfig.js ├── globals.js ├── localization.test.js ├── parse.listings.test.js └── money.test.js ├── css ├── views │ ├── view │ │ ├── index.css │ │ ├── item.css │ │ └── totals.css │ ├── preferences.css │ ├── listings.css │ ├── load.css │ └── popup.css └── content │ └── steamcommunity.market.css ├── js ├── app │ ├── layout │ │ ├── README.md │ │ ├── listings │ │ │ ├── filters │ │ │ │ └── index.js │ │ │ ├── buildFilters.js │ │ │ ├── index.js │ │ │ ├── external │ │ │ │ ├── buildLink.js │ │ │ │ └── buildThirdPartyLinks.js │ │ │ ├── hovers │ │ │ │ └── hovers.js │ │ │ └── buildChart.js │ │ ├── loadstate.js │ │ ├── getLayoutOptions.js │ │ └── tooltip.js │ ├── dexie.js │ ├── enums │ │ ├── ETransactionType.js │ │ ├── ELangCode.js │ │ └── ECurrencyCode.js │ ├── steam │ │ ├── requests │ │ │ ├── helpers │ │ │ │ └── getXHR.js │ │ │ └── post.js │ │ └── index.js │ ├── helpers │ │ ├── fetchjson.js │ │ └── dropdown.js │ ├── readyState.js │ ├── data │ │ └── applist.js │ ├── error.js │ ├── app.js │ ├── workerState.js │ ├── browser.js │ ├── storage │ │ ├── LocalStorage.js │ │ └── DatabaseSettingsManager.js │ ├── db │ │ ├── account.js │ │ └── listing.js │ ├── preferences.js │ ├── models │ │ ├── totals │ │ │ ├── DailyTotal.js │ │ │ ├── AnnualTotal.js │ │ │ ├── AppTotal.js │ │ │ ├── MonthlyTotal.js │ │ │ └── helpers │ │ │ │ └── initializers.js │ │ ├── GameItem.js │ │ └── helpers │ │ │ └── createClass.js │ ├── account.js │ ├── manager │ │ └── PurchaseHistoryManager.js │ ├── json_typedefs.js │ └── workers │ │ └── ListingWorker.js ├── content │ ├── steamcommunity.market.js │ ├── helpers │ │ ├── injectScript.js │ │ └── utils.js │ ├── steampowered.js │ ├── steamcommunity.js │ ├── injects │ │ └── steamcommunity.market.js │ └── modules │ │ └── ListingFiltering.js ├── views │ ├── ui.base.js │ ├── ui.view.js │ ├── view │ │ ├── totals.js │ │ ├── index.js │ │ └── item.js │ ├── load_purchase_history.js │ ├── background.js │ ├── load_listings.js │ └── popup.js ├── lib │ └── README.md └── README.md ├── images ├── icon.png ├── icon128.png ├── icon16.png ├── icon48.png ├── icon_loading.png └── layout │ └── item_tooltip.png ├── screenshots ├── csv.png ├── item.png ├── json.png ├── view-filters.png └── view-tooltip.png ├── json ├── README.md └── locales │ ├── zh-CN │ └── strings.json │ ├── zh-TW │ └── strings.json │ ├── ja │ └── strings.json │ ├── ko │ └── strings.json │ └── fi │ └── strings.json ├── package.sh ├── views ├── load_purchase_history.html ├── test.html ├── view │ ├── index.html │ ├── item.html │ └── totals.html ├── load_listings.html ├── popup.html └── palette.html ├── package.json ├── .gitignore ├── manifest.json ├── eslint.config.js ├── CHANGELOG.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.12.2 -------------------------------------------------------------------------------- /tests/jest.setup.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /css/views/view/index.css: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /js/app/layout/README.md: -------------------------------------------------------------------------------- 1 | This folder contains code used only for rendering views. -------------------------------------------------------------------------------- /js/app/layout/listings/filters/index.js: -------------------------------------------------------------------------------- 1 | export { buildFilters } from './buildFilters.js'; -------------------------------------------------------------------------------- /js/content/steamcommunity.market.js: -------------------------------------------------------------------------------- 1 | injectScript('js/content/injects/steamcommunity.market.js'); -------------------------------------------------------------------------------- /js/app/dexie.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // Re-export 4 | 5 | export { Dexie } from '../lib/dexie.js'; -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/images/icon.png -------------------------------------------------------------------------------- /images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/images/icon128.png -------------------------------------------------------------------------------- /images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/images/icon16.png -------------------------------------------------------------------------------- /images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/images/icon48.png -------------------------------------------------------------------------------- /js/app/layout/listings/buildFilters.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | export { buildFilters } from './filters/index.js'; -------------------------------------------------------------------------------- /js/views/ui.base.js: -------------------------------------------------------------------------------- 1 | // this is included in all views, including the popup view, but not including the background script -------------------------------------------------------------------------------- /screenshots/csv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/screenshots/csv.png -------------------------------------------------------------------------------- /screenshots/item.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/screenshots/item.png -------------------------------------------------------------------------------- /screenshots/json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/screenshots/json.png -------------------------------------------------------------------------------- /images/icon_loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/images/icon_loading.png -------------------------------------------------------------------------------- /css/views/preferences.css: -------------------------------------------------------------------------------- 1 | 2 | label { 3 | float: left; 4 | } 5 | 6 | #data-preferences input { 7 | float: none; 8 | } -------------------------------------------------------------------------------- /css/views/view/item.css: -------------------------------------------------------------------------------- 1 | 2 | #item-name .appname, #item-name .divider { 3 | font-size: 0.8em; 4 | font-weight: normal; 5 | } -------------------------------------------------------------------------------- /screenshots/view-filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/screenshots/view-filters.png -------------------------------------------------------------------------------- /screenshots/view-tooltip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/screenshots/view-tooltip.png -------------------------------------------------------------------------------- /images/layout/item_tooltip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juliarose/Steam-Market-History-Cataloger/HEAD/images/layout/item_tooltip.png -------------------------------------------------------------------------------- /js/app/layout/listings/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | export { buildChart } from './buildChart.js'; 4 | export { buildFilters } from './buildFilters.js'; 5 | export { buildSummaries } from './buildSummaries.js'; 6 | -------------------------------------------------------------------------------- /tests/environment/getLocales.js: -------------------------------------------------------------------------------- 1 | import { Localization } from '../../js/app/models/Localization.js'; 2 | 3 | async function getLocales(language = 'english') { 4 | return await Localization.get(language); 5 | } 6 | 7 | module.exports = getLocales; -------------------------------------------------------------------------------- /js/lib/README.md: -------------------------------------------------------------------------------- 1 | This folder contains all the third-party libraries used in the project. 2 | 3 | Dexie, StreamSaver, EventEmitter, and DomParser have been modified to be able to be imported as modules in the project. 4 | 5 | The rest are imported as scripts in the HTML file. 6 | -------------------------------------------------------------------------------- /css/views/view/totals.css: -------------------------------------------------------------------------------- 1 | 2 | table .year { 3 | width: 6em; 4 | } 5 | 6 | table .number { 7 | width: 16%; 8 | } 9 | 10 | table .more { 11 | width: 16%; 12 | } 13 | 14 | .table-wrapper { 15 | margin-bottom: 2em; 16 | } 17 | 18 | .table-wrapper:last-child { 19 | margin-bottom: 0; 20 | } -------------------------------------------------------------------------------- /js/views/ui.view.js: -------------------------------------------------------------------------------- 1 | // this script is included in all "view" scripts 2 | // i.e. pages appearing in the main browser window 3 | // it is called right before the page-specific view script 4 | 5 | import * as Layout from '../app/layout/index.js'; 6 | 7 | // ready 8 | { 9 | // add page loading indicator 10 | Layout.addPageLoader(); 11 | } 12 | -------------------------------------------------------------------------------- /js/content/helpers/injectScript.js: -------------------------------------------------------------------------------- 1 | function injectScript(location) { 2 | const script = document.createElement('script'); 3 | script.src = chrome.runtime.getURL(location); 4 | script.type = 'module'; 5 | script.onload = function() { 6 | this.remove(); 7 | }; 8 | (document.head || document.documentElement).appendChild(script); 9 | } -------------------------------------------------------------------------------- /js/app/enums/ETransactionType.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @enum ETransactionType 3 | */ 4 | export const ETransactionType = Object.freeze({ 5 | 'MarketTransaction': 1, 6 | 'InGamePurchase': 2, 7 | 'Purchase': 3, 8 | 'GiftPurchase': 4, 9 | 'Refund': 5, 10 | 1: 'MarketTransaction', 11 | 2: 'InGamePurchase', 12 | 3: 'Purchase', 13 | 4: 'GiftPurchase', 14 | 5: 'Refund' 15 | }); 16 | -------------------------------------------------------------------------------- /js/app/steam/requests/helpers/getXHR.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Creates an XHR request. 5 | * @param {string} url - URL of location. 6 | * @param {RequestInit} [settings] - Settings for request. 7 | * @returns {Promise} Fetch promise. 8 | */ 9 | export async function getXHR(url, settings) { 10 | console.log(`${settings && settings.method || 'GET'} %s`, url); 11 | 12 | return fetch(url, settings); 13 | } 14 | -------------------------------------------------------------------------------- /js/app/layout/loadstate.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { setIcon } from '../browser.js'; 4 | 5 | /** 6 | * Updates extension icon based on loading state. 7 | * @param {boolean} isLoading - Load state to set. 8 | */ 9 | export function setLoadState(isLoading) { 10 | const iconPath = ( 11 | isLoading ? 12 | '/images/icon_loading.png' : 13 | '/images/icon.png' 14 | ); 15 | 16 | setIcon({ 17 | path: iconPath 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /js/app/helpers/fetchjson.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { AppError } from '../error.js'; 4 | 5 | /** 6 | * Fetches and parses a JSON file. 7 | * @param {string} uri - Location of JSON file. 8 | * @returns {Promise} Resolves when done. 9 | */ 10 | export async function fetchJSON(uri) { 11 | const response = await fetch(uri); 12 | 13 | if (!response.ok) { 14 | throw new AppError(`Failed to load ${uri}: ${response.statusText}`); 15 | } 16 | 17 | return response.json(); 18 | } 19 | -------------------------------------------------------------------------------- /js/app/readyState.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { buildApp } from './app.js'; 4 | 5 | /** 6 | * Builds the app and calls the onApp callback with the app instance. 7 | * @param {Function} onApp - Callback to call with the app instance. 8 | * @param {Function} onError - Callback to call with the error if the app fails to build. 9 | */ 10 | export async function readyState(onApp, onError) { 11 | try { 12 | onApp(await buildApp()); 13 | } catch (error) { 14 | onError(error); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/listingsmanager.test.js: -------------------------------------------------------------------------------- 1 | import { ListingManager } from '../js/app/manager/ListingManager.js'; 2 | const getApp = require('./environment/getApp'); 3 | const steamid = '10000000000000000'; 4 | 5 | let listingManager; 6 | 7 | beforeAll(async () => { 8 | const app = await getApp(steamid); 9 | 10 | listingManager = new ListingManager(app); 11 | 12 | await listingManager.setup(); 13 | 14 | return; 15 | }); 16 | 17 | it('Listing manager is setup properly', () => { 18 | expect(typeof listingManager.setup).toBe('function'); 19 | }); -------------------------------------------------------------------------------- /js/README.md: -------------------------------------------------------------------------------- 1 | The JavaScript for this project rests in 4 subdirectories: 2 | 3 | - `app/` - Contains the main application code. 4 | - `lib/` - Contains third-party libraries. 5 | - `content/` - Contains content scripts that are injected into web pages on Steam to add functionality. The `manifest.json` file in the root of the project specifies which pages these scripts are injected into (see the [`content_scripts`](https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts) field). 6 | - `views/` - Contains code for the various views within the extension, and the popup. -------------------------------------------------------------------------------- /css/views/listings.css: -------------------------------------------------------------------------------- 1 | 2 | /* Listing table related styles */ 3 | 4 | .query { 5 | text-align: right; 6 | margin-bottom: 0.6em; 7 | } 8 | 9 | .query .item { 10 | text-align: left; 11 | display: inline-block; 12 | margin-right: 0.6em; 13 | } 14 | 15 | .query .item * { 16 | margin: 0; 17 | } 18 | 19 | .query .item:last-child { 20 | margin-right: 0; 21 | } 22 | 23 | .date-box label { 24 | padding: 0 1em; 25 | font-size: 0.85em; 26 | } 27 | 28 | .query .item.date-box { 29 | text-align: right; 30 | } 31 | 32 | .query .dates { 33 | margin-bottom: 0.6em; 34 | } -------------------------------------------------------------------------------- /tests/jestconfig.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export default { 4 | // go up one 5 | rootDir: path.join('..'), 6 | testEnvironment: 'node', 7 | setupFiles: [ 8 | // mock localStorage 9 | '/node_modules/regenerator-runtime/runtime', 10 | 'jest-localstorage-mock', 11 | '/tests/globals.js', 12 | '/tests/jestconfig.js' 13 | ], 14 | moduleFileExtensions: [ 15 | 'js' 16 | ], 17 | testPathIgnorePatterns: [ 18 | '/node_modules/' 19 | ], 20 | testMatch: [ 21 | '**/*.test.js' 22 | ], 23 | transform: { 24 | '.*': '/node_modules/babel-jest' 25 | } 26 | }; -------------------------------------------------------------------------------- /json/README.md: -------------------------------------------------------------------------------- 1 | This folder contains a list of apps and localization files. 2 | 3 | The list of apps is generated by running the following code in the browser console on the [Steam Community Market](https://steamcommunity.com/market/) page: 4 | 5 | ```javascript 6 | JSON.stringify( 7 | Array.from(document.getElementsByClassName('game_button')) 8 | .reduce((apps, buttonEl) => { 9 | const url = buttonEl.getAttribute('href'); 10 | const appid = url.match(/appid=(\d+)/)[1]; 11 | const name = buttonEl.querySelector('span.game_button_game_name').textContent.trim(); 12 | 13 | apps[appid] = name; 14 | 15 | return apps; 16 | }, {}) 17 | ); 18 | ``` -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This script is used to package the extension into a zip file for distribution 4 | rm -rf ./dist/marketcataloger 5 | mkdir -p dist 6 | mkdir ./dist/marketcataloger 7 | 8 | cp -a ./js ./dist/marketcataloger/js 9 | cp -a ./images ./dist/marketcataloger/images 10 | cp -a ./css ./dist/marketcataloger/css 11 | cp -a ./json ./dist/marketcataloger/json 12 | cp -a ./views ./dist/marketcataloger/views 13 | cp -a ./manifest.json ./dist/marketcataloger/manifest.json 14 | cp -a ./README.md ./dist/marketcataloger/README.md 15 | cp -a ./EXPORTS.md ./dist/marketcataloger/EXPORTS.md 16 | 17 | cd ./dist/marketcataloger 18 | 19 | zip -r marketcataloger.zip ./* 20 | 21 | cd - 22 | 23 | mv ./dist/marketcataloger/marketcataloger.zip ./dist/marketcataloger.zip -------------------------------------------------------------------------------- /css/views/load.css: -------------------------------------------------------------------------------- 1 | 2 | .heading { 3 | padding-top: 1.6em; 4 | } 5 | 6 | .counts { 7 | width: 100%; 8 | float: right; 9 | text-align: right; 10 | margin-bottom: 1em; 11 | } 12 | 13 | .history-controls { 14 | width: 100%; 15 | float: right; 16 | text-align: right; 17 | } 18 | 19 | .counts .item { 20 | display: block; 21 | width: 14em; 22 | padding-bottom: 0.6em; 23 | line-height: 1.7em; 24 | margin: 0; 25 | float: right; 26 | } 27 | 28 | .counts .item:last-child { 29 | margin-bottom: 0; 30 | } 31 | 32 | .counts .label { 33 | text-transform: uppercase; 34 | } 35 | 36 | .counts .value { 37 | font-family: "Open Sans Condensed", sans-serif; 38 | font-size: 3.2em; 39 | font-weight: bold; 40 | line-height: 0.9em; 41 | } 42 | 43 | #load_controls { 44 | height: 70px; 45 | } -------------------------------------------------------------------------------- /js/app/data/applist.js: -------------------------------------------------------------------------------- 1 | import { fetchJSON } from '../helpers/fetchjson.js'; 2 | import { getExtensionURL } from '../browser.js'; 3 | 4 | /** 5 | * Loads/stores app data. 6 | */ 7 | export const applist = { 8 | /** 9 | * Get app list from stored JSON file. 10 | * @param {Function} callback - Called when finished loading. 11 | * @returns {Promise>} Resolves with applist when done, reject on error. 12 | */ 13 | async get() { 14 | const uri = getExtensionURL('/json/applist.json'); 15 | const json = await fetchJSON(uri); 16 | 17 | this.set(json); 18 | 19 | return json; 20 | }, 21 | /** 22 | * Set app list. 23 | * @param {Object} apps - JSON object containing apps. 24 | */ 25 | set(apps) { 26 | Object.assign(this, apps); 27 | } 28 | }; -------------------------------------------------------------------------------- /tests/environment/getApp.js: -------------------------------------------------------------------------------- 1 | import { createListingDatabase } from '../../js/app/db/listing.js'; 2 | import { createAccountDatabase } from '../../js/app/db/account.js'; 3 | import { getCurrency } from '../../js/app/currency.js'; 4 | 5 | const getLocales = require('./getLocales'); 6 | 7 | async function getApp(steamid, language = 'english', currencyCode = 1) { 8 | const currency = getCurrency(currencyCode); 9 | const locales = await getLocales(language); 10 | const account = { 11 | steamid, 12 | language, 13 | locales, 14 | wallet: { 15 | currency 16 | } 17 | }; 18 | const AccountDB = createAccountDatabase(); 19 | const ListingDB = createListingDatabase(account.steamid); 20 | 21 | return { 22 | account, 23 | AccountDB, 24 | ListingDB 25 | }; 26 | } 27 | 28 | module.exports = getApp; -------------------------------------------------------------------------------- /js/app/enums/ELangCode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @enum Enum for language codes. 3 | */ 4 | export const ELangCode = Object.freeze({ 5 | 'bulgarian': 'bg', 6 | 'czech': 'cs', 7 | 'danish': 'da', 8 | 'dutch': 'nl', 9 | 'finnish': 'fi', 10 | 'french': 'fr', 11 | 'greek': 'el', 12 | 'german': 'de', 13 | 'hungarian': 'hu', 14 | 'italian': 'it', 15 | 'japanese': 'ja', 16 | 'koreana': 'ko', 17 | 'norwegian': 'no', 18 | 'polish': 'pl', 19 | 'portuguese': 'pt-PT', 20 | 'brazilian': 'pt-BR', 21 | 'russian': 'ru', 22 | 'romanian': 'ro', 23 | 'schinese': 'zh-CN', 24 | 'swedish': 'sv-SE', 25 | 'tchinese': 'zh-TW', 26 | 'thai': 'th', 27 | 'turkish': 'tr', 28 | 'ukrainian': 'uk', 29 | 'english': 'en', 30 | // Spanish (Spain) 31 | 'spanish': 'es-ES', 32 | // Spanish (Latin America and the Caribbean) 33 | 'latam': 'es-419' 34 | }); 35 | -------------------------------------------------------------------------------- /js/app/error.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** Used to identify errors. */ 4 | export const ERROR_TYPE = Object.freeze({ 5 | APP_ERROR: 'AppError', 6 | APP_SUCCESS: 'AppSuccess' 7 | }); 8 | 9 | /** 10 | * Error class for handling error messages. 11 | */ 12 | export class AppError extends Error { 13 | /** 14 | * Creates an instance of AppError. 15 | * @param {string} message - The error message. 16 | */ 17 | constructor(message) { 18 | super(message); 19 | this.name = ERROR_TYPE.APP_ERROR; 20 | } 21 | } 22 | 23 | /** 24 | * Error class for handling success messages. 25 | */ 26 | export class AppSuccess extends Error { 27 | /** 28 | * Creates an instance of AppSuccess. 29 | * @param {string} message - The success message. 30 | */ 31 | constructor(message) { 32 | super(message); 33 | this.name = ERROR_TYPE.APP_SUCCESS; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /js/app/layout/listings/external/buildLink.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Builds an external link. 5 | * @param {Object} data - Data for link. 6 | * @param {string} data.url - URL for link. 7 | * @param {string} data.title - Text for link. 8 | * @param {boolean} [data.placeholder] - Whether this is a placeholder link or not. 9 | * @returns {HTMLElement} Element object. 10 | */ 11 | export function buildLink(data) { 12 | const buttonEl = document.createElement('div'); 13 | const linkEl = document.createElement('a'); 14 | 15 | linkEl.setAttribute('href', data.url); 16 | linkEl.setAttribute('target', '_blank'); 17 | linkEl.setAttribute('rel', 'noreferrer'); 18 | linkEl.append(buttonEl); 19 | 20 | if (data.placeholder) { 21 | linkEl.classList.add('placeholder'); 22 | } 23 | 24 | buttonEl.classList.add('button'); 25 | buttonEl.textContent = data.title; 26 | 27 | return linkEl; 28 | } 29 | -------------------------------------------------------------------------------- /css/content/steamcommunity.market.css: -------------------------------------------------------------------------------- 1 | 2 | #tabContentsMyMarketHistoryRowsContents { 3 | padding-right: 6px; 4 | max-height: 500px !important; 5 | overflow-y: scroll; 6 | } 7 | 8 | #tabContentsMyMarketHistoryRowsContents::-webkit-scrollbar { 9 | width: 12px; 10 | } 11 | 12 | #tabContentsMyMarketHistoryRowsContents::-webkit-scrollbar-track { 13 | background: #16202D; 14 | } 15 | 16 | #tabContentsMyMarketHistoryRowsContents::-webkit-scrollbar-thumb { 17 | background: #0A0E14 18 | } 19 | 20 | #tabContentsMyMarketHistoryRows .market_listing_table_header { 21 | padding-right: 18px; 22 | } 23 | 24 | #tabContentsMyMarketHistoryRows .market_listing_item_img, 25 | #tabContentsMyMarketHistoryRows .market_listing_gainorloss { 26 | cursor: pointer; 27 | } 28 | 29 | .gotofield { 30 | width: 66px; 31 | } 32 | 33 | .optionbtn { 34 | margin-right: 4px; 35 | box-sizing: border-box; 36 | } 37 | 38 | .market_listing_row:first-child { 39 | margin-top: 0 !important; 40 | } 41 | -------------------------------------------------------------------------------- /css/views/popup.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | width: 240px; 4 | padding: 0.8em; 5 | } 6 | 7 | .main { 8 | padding: 0; 9 | } 10 | 11 | .large.button { 12 | text-align: left; 13 | margin: 0; 14 | margin-bottom: 0.4em; 15 | padding-top: 0.3em; 16 | padding-bottom: 0.3em; 17 | padding-left: 0.6em; 18 | padding-right: 0.6em; 19 | font-size: 1.2em; 20 | } 21 | 22 | .button:last-child { 23 | margin-bottom: 0; 24 | } 25 | 26 | .grid-container { 27 | width: 100%; 28 | min-width: 0; 29 | padding: 0; 30 | } 31 | 32 | #user { 33 | margin-top: 1em; 34 | text-align: left; 35 | line-height: 1.4em; 36 | } 37 | 38 | #user .name { 39 | font-weight: bold; 40 | } 41 | 42 | #user .avatar img { 43 | border-radius: 50%; 44 | display: block; 45 | } 46 | 47 | #user .text { 48 | margin-top: 2px; 49 | vertical-align:top; 50 | display: inline-block 51 | } 52 | 53 | #user .avatar { 54 | vertical-align:top; 55 | display: inline-block; 56 | margin-right: 0.6em; 57 | } 58 | 59 | .app-error { 60 | font-weight: bold; 61 | } 62 | -------------------------------------------------------------------------------- /views/load_purchase_history.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Load Purchase History - Steam Market History Cataloger 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 |
Load
17 |
18 |
19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /js/app/app.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { loadAccount } from './account.js'; 4 | import { createListingDatabase } from './db/listing.js'; 5 | import { createAccountDatabase } from './db/account.js'; 6 | import { applist } from './data/applist.js'; 7 | 8 | /** 9 | * @typedef {import('./account.js').Account} Account 10 | */ 11 | 12 | /** 13 | * App. 14 | * @typedef {Object} App 15 | * @property {Account} account - Account manager. 16 | * @property {Object} AccountDB - The Dexie database object for the account data. 17 | * @property {Object} ListingDB - The Dexie database object for the listing data. 18 | */ 19 | 20 | /** 21 | * Builds the app object. 22 | * @returns {Promise} Resolves with app. 23 | */ 24 | export async function buildApp() { 25 | const account = await loadAccount(); 26 | 27 | // load json data 28 | await applist.get(); 29 | 30 | // create the account db 31 | const AccountDB = createAccountDatabase(); 32 | // create the listing db 33 | const ListingDB = createListingDatabase(account.steamid); 34 | 35 | // all together now... 36 | return { 37 | account, 38 | AccountDB, 39 | ListingDB 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /js/app/layout/getLayoutOptions.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @typedef {import('../account.js').Account} Account 5 | * @typedef {import('../preferences.js').Preferences} Preferences 6 | * @typedef {import('../models/Localization.js').Localization} Localization 7 | * @typedef {import('../currency.js').Currency} Currency 8 | */ 9 | 10 | /** 11 | * Object containing options to use in formatting. 12 | * @typedef {Object} LayoutOptions 13 | * @property {number} count - Number of items for pagination results. 14 | * @property {Localization} locales - Object containing localizations. 15 | * @property {Currency} currency - Currency related to account. 16 | */ 17 | 18 | /** 19 | * Gets options to pass to a layout method. 20 | * @param {Object} options - Options. 21 | * @param {Account} options.account - Account. 22 | * @param {Preferences} options.preferences - Preferences. 23 | * @returns {LayoutOptions} Object containing options to use in formatting. 24 | */ 25 | export function getLayoutOptions({ account, preferences }) { 26 | return { 27 | count: preferences.pagination_count, 28 | locales: account.locales, 29 | currency: account.wallet.currency 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /views/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Market Databaser - Steam Market History Cataloger 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 | 18 |
19 |
20 |
21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /views/view/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Viewing Listings - Steam Market History Cataloger 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /js/app/workerState.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { LocalStorage } from './storage/LocalStorage.js'; 4 | 5 | const WORKER_STATE_SETTINGS_NAME = 'worker_state'; 6 | 7 | /** 8 | * Used for loading and saving worker state. 9 | */ 10 | export const workerStateStorage = new LocalStorage(WORKER_STATE_SETTINGS_NAME); 11 | 12 | /** 13 | * @typedef {Object} WorkerState 14 | * @property {number} listing_count - Number of listings collected by the worker. 15 | */ 16 | 17 | /** 18 | * Gets worker state. 19 | * @returns {Promise} WorkerState. 20 | */ 21 | export async function getWorkerState() { 22 | const workerState = await workerStateStorage.getSettings() || {}; 23 | 24 | return Object.assign({ 25 | listing_count: 0 26 | }, workerState); 27 | } 28 | 29 | /** 30 | * Saves worker state. 31 | * @param {WorkerState} workerState - State to save. 32 | * @returns {Promise} Resolves when done. 33 | */ 34 | export async function saveWorkerState(workerState) { 35 | return workerStateStorage.saveSettings(workerState); 36 | } 37 | 38 | /** 39 | * Adds worker state to existing worker state. 40 | * @param {Object} workerState - State to add. 41 | * @returns {Promise} Resolves when done. 42 | */ 43 | export async function addWorkerState(workerState) { 44 | const currentWorkerState = await getWorkerState(); 45 | // merge the worker state 46 | const mergedWorkerState = Object.assign({}, currentWorkerState, workerState); 47 | 48 | return saveWorkerState(mergedWorkerState); 49 | } 50 | -------------------------------------------------------------------------------- /js/app/steam/requests/post.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { queryString, omitEmpty } from '../../helpers/utils.js'; 4 | import { getXHR } from './helpers/getXHR.js'; 5 | 6 | /* Example response: 7 | { 8 | "html": "" 9 | "cursor": { 10 | "wallet_txnid": "19029764231", 11 | "timestamp_newest": 1509869782, 12 | "balance": "37140", 13 | "currency": 1 14 | } 15 | } 16 | */ 17 | 18 | /** 19 | * Gets page of purchase history. 20 | * @param {Object} options - Form data. 21 | * @param {string} options.sessionid - Session ID. 22 | * @param {Object} [options.cursor] - Cursor object from Steam. 23 | * @param {string} [options.l] - Language. 24 | * @returns {Promise} Fetch promise. 25 | */ 26 | export async function getPurchaseHistory(options) { 27 | const query = queryString(omitEmpty(options)); 28 | const uri = 'https://store.steampowered.com/account/AjaxLoadMoreHistory'; 29 | const params = { 30 | method: 'POST', 31 | /** @type {HeadersInit} Request headers. */ 32 | headers: { 33 | 'Content-Type': 'application/x-www-form-urlencoded', 34 | 'Content-Length': query.length.toString() 35 | }, 36 | /** @type {RequestCredentials} Request credentials. */ 37 | credentials: 'include', 38 | /** @type {RequestMode} Request credentials. */ 39 | mode: 'cors', 40 | referrer: 'no-referrer', 41 | body: query 42 | }; 43 | 44 | return getXHR(uri, params); 45 | } 46 | -------------------------------------------------------------------------------- /js/content/steampowered.js: -------------------------------------------------------------------------------- 1 | 2 | // UNUSED 3 | // this script collects data about the current user 4 | // we can also see whether the user is logged in or not 5 | 6 | function collectAndStoreInfo() { 7 | function storeLoggedInUser(data) { 8 | data.steampowered_date = new Date().toString(); 9 | 10 | // add to current settings, overwriting any overlapping properties 11 | Settings.addTo('logged_in_user', data); 12 | } 13 | 14 | const { steamid } = collectInfo({ 15 | steamid(content) { 16 | function getAccountid(content) { 17 | return (content.match(/g_AccountID\s*=\s*(\d+);$/m) || [])[1]; 18 | } 19 | 20 | /** 21 | * Converts a 32-bit account id to steamid64. 22 | * @param {string} accountid - Accountid to convert. 23 | * @returns {string} Steamid64 in string format. 24 | */ 25 | function to64(accountid) { 26 | return (BigInt(accountid) + BigInt('76561197960265728')).toString(); 27 | } 28 | 29 | const accountid = getAccountid(content); 30 | 31 | if (!accountid) { 32 | return null; 33 | } 34 | 35 | return to64(accountid); 36 | } 37 | }); 38 | const sessionid = getCookie('sessionid'); 39 | 40 | storeLoggedInUser({ 41 | steampowered: steamid || null, 42 | steampowered_sessionid: sessionid 43 | }); 44 | } 45 | 46 | collectAndStoreInfo(); 47 | -------------------------------------------------------------------------------- /views/load_listings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Load Listings - Steam Market History Cataloger 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 |
15 |
16 |
17 |
18 |
19 |
20 |
Transactions
21 |
22 |
23 |
24 |
25 |
Update
26 | 27 |
28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /js/app/enums/ECurrencyCode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @enum ECurrencyCode 3 | */ 4 | export const ECurrencyCode = Object.freeze({ 5 | 1: 'USD', 6 | 2: 'GBP', 7 | 3: 'EUR', 8 | 4: 'CHF', 9 | 5: 'RUB', 10 | 6: 'PLN', 11 | 7: 'BRL', 12 | 8: 'JPY', 13 | 9: 'NOK', 14 | 10: 'IDR', 15 | 11: 'MYR', 16 | 12: 'PHP', 17 | 13: 'SGD', 18 | 14: 'THB', 19 | 15: 'VND', 20 | 16: 'KRW', 21 | 17: 'TRY', 22 | 18: 'UAH', 23 | 19: 'MXN', 24 | 20: 'CAD', 25 | 21: 'AUD', 26 | 22: 'NZD', 27 | 23: 'CNY', 28 | 24: 'INR', 29 | 25: 'CLP', 30 | 26: 'PEN', 31 | 27: 'COP', 32 | 28: 'ZAR', 33 | 29: 'HKD', 34 | 30: 'TWD', 35 | 31: 'SAR', 36 | 32: 'AED', 37 | 34: 'ARS', 38 | 35: 'ILS', 39 | 37: 'KZT', 40 | 38: 'KWD', 41 | 39: 'QAR', 42 | 40: 'CRC', 43 | 41: 'UYU', 44 | 'USD': 1, 45 | 'GBP': 2, 46 | 'EUR': 3, 47 | 'CHF': 4, 48 | 'RUB': 5, 49 | 'PLN': 6, 50 | 'BRL': 7, 51 | 'JPY': 8, 52 | 'NOK': 9, 53 | 'IDR': 10, 54 | 'MYR': 11, 55 | 'PHP': 12, 56 | 'SGD': 13, 57 | 'THB': 14, 58 | 'VND': 15, 59 | 'KRW': 16, 60 | 'TRY': 17, 61 | 'UAH': 18, 62 | 'MXN': 19, 63 | 'CAD': 20, 64 | 'AUD': 21, 65 | 'NZD': 22, 66 | 'CNY': 23, 67 | 'INR': 24, 68 | 'CLP': 25, 69 | 'PEN': 26, 70 | 'COP': 27, 71 | 'ZAR': 28, 72 | 'HKD': 29, 73 | 'TWD': 30, 74 | 'SAR': 31, 75 | 'AED': 32, 76 | 'ARS': 34, 77 | 'ILS': 35, 78 | 'KZT': 37, 79 | 'KWD': 38, 80 | 'QAR': 39, 81 | 'CRC': 40, 82 | 'UYU': 41 83 | }); 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "steam-market-history-cataloger", 3 | "version": "1.3.0", 4 | "author": "Julia", 5 | "license": "GPL-2.0", 6 | "description": "Indexes your Steam Community Market transactions into a local storage database. Data can be viewed within the extension and is exportable to JSON and CSV.", 7 | "babel": { 8 | "presets": [ 9 | "@babel/env" 10 | ] 11 | }, 12 | "scripts": { 13 | "test": "jest --verbose --config=tests/jestconfig.js", 14 | "watch": "jest --verbose --watch --config=tests/jestconfig.js .js", 15 | "docs": "documentation readme js/app/json_typedefs.js --readme-file=EXPORTS.md --section=Models --markdown-toc=false", 16 | "lint": "npx eslint" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.24.7", 20 | "@babel/eslint-parser": "^7.24.7", 21 | "@babel/plugin-transform-runtime": "^7.8.3", 22 | "@babel/preset-env": "^7.24.7", 23 | "@eslint/js": "^9.4.0", 24 | "babel-jest": "^24.9.0", 25 | "chrome-mock": "0.0.9", 26 | "dexie": "^2.0.4", 27 | "documentation": "^14.0.3", 28 | "eslint": "^9.4.0", 29 | "eslint-plugin-jsdoc": "^48.2", 30 | "fake-indexeddb": "^2.1.1", 31 | "fetch-mock": "^7.7.3", 32 | "globals": "^15.4.0", 33 | "isomorphic-fetch": "^2.2.1", 34 | "jest": "^25.1", 35 | "jest-fetch-mock": "^2.1.2", 36 | "jest-localstorage-mock": "^2.4.0", 37 | "jest-webextension-mock": "^3.5.0", 38 | "jsdom": "^24.1.0", 39 | "jsdom-global": "^3.0.2", 40 | "nx": "^19.2.1", 41 | "sinon-chrome": "^3.0.1", 42 | "whatwg-fetch": "^3.0.0" 43 | }, 44 | "dependencies": { 45 | "dom-parser": "^1.1.5" 46 | }, 47 | "type": "module" 48 | } 49 | -------------------------------------------------------------------------------- /views/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Steam Market History Cataloger 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
Update listings
16 |
View all
17 |
View totals
18 |
Purchase history
19 |
Preferences
20 |
21 |
22 |
23 |
24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /js/app/browser.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // browser utilities 4 | 5 | const browser = chrome; 6 | const browserAction = browser.action; 7 | 8 | export const tabs = browser.tabs; 9 | export const storage = browser.storage.sync || browser.storage.local; 10 | export const onMessage = browser.runtime.onMessage; 11 | 12 | /** 13 | * Sets browser icon. 14 | * @param {Object} details - Details. 15 | * @param {function(): void} [callback=function(){}] - Callback function. 16 | */ 17 | export function setIcon(details, callback = function() {}) { 18 | browserAction.setIcon(details, callback); 19 | } 20 | 21 | /** 22 | * Sets browser badge text. 23 | * @param {Object} details - Details. 24 | * @param {function(): void} [callback=function(){}] - Callback function. 25 | */ 26 | export function setBadgeText(details, callback = function() {}) { 27 | browserAction.setBadgeText(details, callback); 28 | } 29 | 30 | /** 31 | * Gets browser badge text. 32 | * @param {Object} details - Details. 33 | * @param {function(): void} [callback=function(){}] - Callback function. 34 | */ 35 | export function getBadgeText(details, callback = function() {}) { 36 | browserAction.getBadgeText(details, callback); 37 | } 38 | 39 | /** 40 | * Sends a runtime message. 41 | * @param {Object} details - Details. 42 | * @returns {Promise} Resolves with message. 43 | */ 44 | export async function sendMessage(details) { 45 | return new Promise((resolve) => { 46 | browser.runtime.sendMessage(details, resolve); 47 | }); 48 | } 49 | 50 | /** 51 | * Gets a URL of an extension resource. 52 | * @param {string} url - URL of resource relative to extension's root. 53 | * @returns {string} Absolute extension URL. 54 | */ 55 | export function getExtensionURL(url) { 56 | return browser.runtime.getURL(url); 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .nx 3 | 4 | dist/ 5 | dev/ 6 | 7 | # https://github.com/github/gitignore/blob/master/Node.gitignore 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # TypeScript v1 declaration files 52 | typings/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # next.js build output 80 | .next 81 | 82 | # nuxt.js build output 83 | .nuxt 84 | 85 | # vuepress build output 86 | .vuepress/dist 87 | 88 | # Serverless directories 89 | .serverless/ 90 | 91 | # FuseBox cache 92 | .fusebox/ 93 | 94 | # DynamoDB Local files 95 | .dynamodb/ -------------------------------------------------------------------------------- /views/view/item.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Viewing Item - Steam Market History Cataloger 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |

16 | Viewing item 17 |

18 |
19 | 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /js/app/storage/LocalStorage.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { storage } from '../browser.js'; 4 | 5 | /** 6 | * Interface for working with localStorage. 7 | */ 8 | export class LocalStorage { 9 | /** 10 | * Name for settings. 11 | * @type {string} 12 | */ 13 | #settingsName; 14 | 15 | /** 16 | * Creates a new local storage interface. 17 | * @param {string} settingsName 18 | */ 19 | constructor(settingsName) { 20 | this.#settingsName = settingsName; 21 | } 22 | 23 | /** 24 | * Gets the settings name. 25 | * @returns {string} Name for settings. 26 | */ 27 | settingsName() { 28 | return this.#settingsName; 29 | } 30 | 31 | /** 32 | * Gets the settings. 33 | * @returns {Promise<(Object | undefined)>} Resolves with settings when done. 34 | */ 35 | async getSettings() { 36 | return new Promise((resolve) => { 37 | const name = this.settingsName(); 38 | 39 | storage.get(name, (settings) => { 40 | if (settings) { 41 | settings = settings[name]; 42 | } 43 | 44 | resolve(settings); 45 | }); 46 | }); 47 | } 48 | 49 | /** 50 | * Saves the settings. 51 | * @param {Object} settings - Object to save. 52 | * @returns {Promise} Resolves when done. 53 | */ 54 | async saveSettings(settings) { 55 | return new Promise((resolve) => { 56 | const name = this.settingsName(); 57 | 58 | storage.set({ 59 | [name]: settings 60 | }, resolve); 61 | }); 62 | } 63 | 64 | /** 65 | * Deletes the settings. 66 | * @returns {Promise} Resolves when done. 67 | */ 68 | async deleteSettings() { 69 | return this.saveSettings({}); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /js/views/view/totals.js: -------------------------------------------------------------------------------- 1 | import { readyState } from '../../app/readyState.js'; 2 | import * as Layout from '../../app/layout/index.js'; 3 | import { getPreferences } from '../../app/preferences.js'; 4 | 5 | const page = { 6 | results: document.getElementById('results') 7 | }; 8 | 9 | async function map(collection, fn) { 10 | const result = []; 11 | 12 | return collection 13 | .each((row) => { 14 | result.push(fn(row)); 15 | }) 16 | .then(() => { 17 | return result; 18 | }); 19 | } 20 | 21 | async function onApp(app) { 22 | function buildSummaries(records) { 23 | const options = Object.assign({}, Layout.getLayoutOptions({ 24 | account, 25 | preferences 26 | }), { 27 | count: 1e3 28 | }); 29 | const tablesEl = Layout.listings.buildSummaries(records || [], options); 30 | 31 | Layout.render(page.results, tablesEl); 32 | } 33 | 34 | function onRecords(records) { 35 | buildSummaries(records); 36 | } 37 | 38 | const collection = app.ListingDB.listings; 39 | // we want to pick only certain keys to save memory 40 | // this can be improved by iterating over each record to add to the totals displayed 41 | // source - https://github.com/dfahlander/Dexie.js/issues/468#issuecomment-276961594 42 | let records = await map(collection, (record) => { 43 | return { 44 | is_credit: record.is_credit, 45 | index: record.index, 46 | appid: record.appid, 47 | date_acted: record.date_acted, 48 | price: record.price 49 | }; 50 | }); 51 | const { account } = app; 52 | const preferences = await getPreferences(); 53 | 54 | records = records.sort((a, b) => b.index - a.index); 55 | onRecords(records); 56 | Layout.ready(); 57 | } 58 | 59 | // ready 60 | { 61 | readyState(onApp, Layout.error); 62 | } 63 | -------------------------------------------------------------------------------- /tests/globals.js: -------------------------------------------------------------------------------- 1 | import 'fake-indexeddb/auto'; 2 | import path from 'path'; 3 | import util from 'util'; 4 | import fs from 'fs'; 5 | import chrome from 'chrome-mock'; 6 | import Dexie from 'dexie'; 7 | import jestFetch from 'jest-fetch-mock'; 8 | 9 | const rootPath = path.join(__dirname, '..'); 10 | const promisify = util.promisify; 11 | const readFile = promisify(fs.readFile); 12 | 13 | // fake fetch with local files and http requests 14 | const fetch = async function(url) { 15 | const fetchLocal = async (url) => { 16 | // create a fake fetch response object from data 17 | const createFakeResponse = (data, status = 200) => { 18 | return { 19 | status, 20 | data, 21 | ok: true, 22 | json() { 23 | return Promise.resolve(JSON.parse(data)); 24 | }, 25 | }; 26 | }; 27 | 28 | return readFile(url, 'utf8') 29 | .then(createFakeResponse); 30 | }; 31 | // check whether the url appears to be for a local file 32 | const isLocal = /^\//.test(url); 33 | // pick fetch function 34 | const fetch = isLocal ? fetchLocal : jestFetch; 35 | 36 | return fetch(url); 37 | }; 38 | 39 | if (!chrome.runtime) { 40 | chrome.runtime = {}; 41 | } 42 | 43 | chrome.runtime.getURL = function(url) { 44 | // return the absolute url of this resource 45 | return path.join(rootPath, url); 46 | }; 47 | 48 | const { performance } = require('perf_hooks'); 49 | 50 | // fake AbortController needed for Dexie 51 | class AbortController { 52 | constructor() { 53 | 54 | } 55 | 56 | abort() { 57 | 58 | } 59 | } 60 | 61 | global.AbortController = AbortController; 62 | global.fetch = fetch; 63 | global.performance = performance; 64 | global.Dexie = Dexie; 65 | global.chrome = chrome; 66 | 67 | require('jsdom-global')(); 68 | 69 | global.DOMParser = window.DOMParser; 70 | -------------------------------------------------------------------------------- /js/app/db/account.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { Dexie } from '../dexie.js'; 4 | 5 | /** 6 | * Configures database - should be called after locales have been loaded. 7 | * @returns {Object} Configured Dexie instance. 8 | */ 9 | export function createAccountDatabase() { 10 | const db = new Dexie('listings.accounts'); 11 | 12 | db.version(1).stores({ 13 | listings: [ 14 | '&steamid', 15 | 'current_index', 16 | 'total_count', 17 | 'last_index', 18 | 'last_fetched_index', 19 | 'recorded_count', 20 | 'session', 21 | 'language', 22 | 'date' 23 | ].join(',') 24 | }); 25 | 26 | db.version(2).stores({ 27 | listings: [ 28 | '&steamid', 29 | 'current_index', 30 | 'total_count', 31 | 'last_index', 32 | 'last_fetched_index', 33 | 'recorded_count', 34 | 'session', 35 | 'language', 36 | // add this 37 | 'is_loading', 38 | 'date' 39 | ].join(',') 40 | }); 41 | 42 | /* 43 | dbv.version(3).stores({ 44 | listings: [ 45 | '&steamid', 46 | 'current_index', 47 | 'total_count', 48 | 'last_index', 49 | 'last_fetched_index', 50 | 'recorded_count', 51 | 'session', 52 | 'language', 53 | 'is_loading', 54 | 'date' 55 | ].join(','), 56 | preferences: [ 57 | 'market_per_page', 58 | 'market_poll_interval_seconds', 59 | 'background_poll_boolean', 60 | 'background_poll_interval_minutes', 61 | 'show_new_listing_count', 62 | 'pagination_count' 63 | ], 64 | account: [ 65 | 'steamid', 66 | 'username', 67 | 'language' 68 | ] 69 | }); 70 | */ 71 | 72 | return db; 73 | } -------------------------------------------------------------------------------- /js/views/view/index.js: -------------------------------------------------------------------------------- 1 | import { readyState } from '../../app/readyState.js'; 2 | import * as Layout from '../../app/layout/index.js'; 3 | import { Listing } from '../../app/models/Listing.js'; 4 | import { getPreferences } from '../../app/preferences.js'; 5 | 6 | const page = { 7 | query: document.getElementById('query'), 8 | results: document.getElementById('results') 9 | }; 10 | 11 | async function onApp(app) { 12 | // builds the table to show the listings loaded 13 | function buildTable(records, collection) { 14 | const options = Object.assign({}, Layout.getLayoutOptions({ 15 | account, 16 | preferences 17 | }), { 18 | table, 19 | collection 20 | }); 21 | const tableEl = Layout.buildTable(records || [], Listing, options); 22 | 23 | Layout.render(page.results, tableEl); 24 | } 25 | 26 | function onRecords(records, collection) { 27 | buildTable(records, collection); 28 | } 29 | 30 | // builds the index for filters 31 | async function buildIndex(records) { 32 | const options = Object.assign({}, Layout.getLayoutOptions({ 33 | account, 34 | preferences 35 | }), { 36 | limit, 37 | onChange: onRecords 38 | }); 39 | const indexEl = await Layout.listings.buildFilters(table, records, Listing, options); 40 | 41 | page.query.appendChild(indexEl); 42 | } 43 | 44 | const { account, ListingDB } = app; 45 | const preferences = await getPreferences(); 46 | const limit = preferences.search_results_count || 1000; 47 | const table = ListingDB.listings; 48 | const collection = table.orderBy('index').reverse(); 49 | const records = await collection.clone().limit(limit).toArray(); 50 | 51 | await buildIndex(records); 52 | onRecords(records, collection); 53 | Layout.ready(); 54 | } 55 | 56 | // ready 57 | { 58 | readyState(onApp, Layout.error); 59 | } 60 | -------------------------------------------------------------------------------- /js/app/helpers/dropdown.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // require this module anywhere to enable dropdowns 4 | 5 | window.addEventListener('click', hideDropdown); 6 | document.body.addEventListener('click', dropdownDelegator); 7 | 8 | /** 9 | * Hides dropdown when conditions are met. 10 | * @param {Event} e - Event object. 11 | */ 12 | function hideDropdown(e) { 13 | const target = e.target; 14 | // @ts-ignore 15 | const parent = target.parentNode; 16 | // @ts-ignore 17 | const isButton = target.matches('.dropdown .button'); 18 | // get current dropdown found in parent 19 | const currentDropdown = parent.getElementsByClassName('dropdown-content')[0]; 20 | const dropdowns = document.getElementsByClassName('dropdown-content'); 21 | 22 | Array.from(dropdowns).forEach((dropdown) => { 23 | const isHidden = dropdown.classList.contains('hidden'); 24 | const isCurrentDropdown = currentDropdown === dropdown; 25 | const canHide = (!isButton || !isCurrentDropdown) && !isHidden; 26 | 27 | if (canHide) { 28 | dropdown.classList.add('hidden'); 29 | } 30 | }); 31 | } 32 | 33 | /** 34 | * Shows dropdown of target. 35 | * @param {Event} e - Event object. 36 | */ 37 | function showDropdown(e) { 38 | const target = e.target; 39 | // @ts-ignore 40 | const parent = target.parentNode; 41 | const dropdowns = parent.getElementsByClassName('dropdown-content'); 42 | 43 | Array.from(dropdowns).forEach((dropdown) => { 44 | if (dropdown.classList.contains('hidden')) { 45 | dropdown.classList.remove('hidden'); 46 | } else { 47 | dropdown.classList.add('hidden'); 48 | } 49 | }); 50 | } 51 | 52 | /** 53 | * Checks if event target is a dropdown button. 54 | * @param {Event} e - Event object. 55 | */ 56 | function dropdownDelegator(e) { 57 | // is a dropdown button 58 | // @ts-ignore 59 | if (e.target.matches('.dropdown .button')) { 60 | // a dropdown button was clicked 61 | showDropdown(e); 62 | } 63 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Steam Market History Cataloger", 4 | "description": "Indexes your Steam Market transactions to be viewable within the extension and exportable to JSON and CSV.", 5 | "version": "1.3.0", 6 | "version_name": "1.3.0", 7 | "icons": { 8 | "16": "images/icon16.png", 9 | "48": "images/icon48.png", 10 | "128": "images/icon128.png" 11 | }, 12 | "action": { 13 | "default_icon": "images/icon.png", 14 | "default_popup": "views/popup.html" 15 | }, 16 | "content_scripts": [ 17 | { 18 | "matches": [ 19 | "https://steamcommunity.com/*" 20 | ], 21 | "exclude_matches": [ 22 | "https://steamcommunity.com/login/*" 23 | ], 24 | "js": [ 25 | "js/content/helpers/utils.js", 26 | "js/content/steamcommunity.js" 27 | ], 28 | "run_at": "document_end" 29 | }, 30 | { 31 | "matches": [ 32 | "https://steamcommunity.com/market*" 33 | ], 34 | "js": [ 35 | "js/content/helpers/injectScript.js", 36 | "js/content/steamcommunity.market.js" 37 | ], 38 | "css": [ 39 | "css/content/steamcommunity.market.css" 40 | ], 41 | "run_at": "document_end" 42 | } 43 | ], 44 | "web_accessible_resources": [ 45 | { 46 | "resources": [ 47 | "js/content/injects/steamcommunity.market.js", 48 | "js/content/modules/ListingFiltering.js", 49 | "js/content/modules/MyBetterCAjaxPagingControls.js", 50 | "js/app/money.js", 51 | "js/app/currency.js", 52 | "js/app/helpers/utils.js", 53 | "js/lib/dom-parser.js" 54 | ], 55 | "matches": [ 56 | "https://steamcommunity.com/*", 57 | "https://store.steampowered.com/*" 58 | ] 59 | } 60 | ], 61 | "background": { 62 | "service_worker": "js/views/background.js", 63 | "type": "module" 64 | }, 65 | "permissions": [ 66 | "storage", 67 | "unlimitedStorage", 68 | "alarms" 69 | ], 70 | "host_permissions": [ 71 | "https://steamcommunity.com/*", 72 | "https://store.steampowered.com/*" 73 | ] 74 | } -------------------------------------------------------------------------------- /js/app/preferences.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { LocalStorage } from './storage/LocalStorage.js'; 4 | 5 | const PREFERENCES_SETTINGS_NAME = 'preferences'; 6 | 7 | /** 8 | * Used for loading and saving preferences. 9 | */ 10 | export const preferencesStorage = new LocalStorage(PREFERENCES_SETTINGS_NAME); 11 | 12 | /** 13 | * @typedef {Object} Preferences 14 | * @property {number} market_per_page - Number of items per page. 15 | * @property {number} market_poll_interval_seconds - Poll interval in seconds. 16 | * @property {boolean} background_poll_boolean - Background poll boolean. 17 | * @property {number} background_poll_interval_minutes - Background poll interval in minutes. 18 | * @property {boolean} show_new_listing_count - Show new listing count. 19 | * @property {number} pagination_count - Pagination count. 20 | * @property {number} search_results_count - Search results count. 21 | */ 22 | 23 | /** 24 | * Gets preferences. 25 | * @returns {Promise} Preferences. 26 | */ 27 | export async function getPreferences() { 28 | const preferences = await preferencesStorage.getSettings() || {}; 29 | 30 | return Object.assign({ 31 | market_per_page: 100, 32 | market_poll_interval_seconds: 5, 33 | background_poll_boolean: true, 34 | background_poll_interval_minutes: 60, 35 | show_new_listing_count: true, 36 | pagination_count: 20, 37 | search_results_count: 1000 38 | }, preferences); 39 | } 40 | 41 | /** 42 | * Saves preferences. 43 | * @param {Preferences} preferences - Preferences to save. 44 | * @returns {Promise} Resolves when done. 45 | */ 46 | export async function savePreferences(preferences) { 47 | return preferencesStorage.saveSettings(preferences); 48 | } 49 | 50 | /** 51 | * Adds preferences to existing preferences. 52 | * @param {Object} preferences - Preferences to add. 53 | * @returns {Promise} Resolves when done. 54 | */ 55 | export async function addPreferences(preferences) { 56 | const currentPreferences = await getPreferences(); 57 | // merge the preferences 58 | const mergedPreferences = Object.assign({}, currentPreferences, preferences); 59 | 60 | return savePreferences(mergedPreferences); 61 | } 62 | -------------------------------------------------------------------------------- /js/app/models/totals/DailyTotal.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { types, makeTotalDisplay } from './helpers/initializers.js'; 4 | 5 | /** 6 | * @typedef {import('../helpers/createClass.js').DisplayOptions} DisplayOptions 7 | * @typedef {import('../helpers/createClass.js').DisplayableTypes} DisplayableTypes 8 | * @typedef {import('../Localization.js').Localization} Localization 9 | */ 10 | 11 | const tableColumns = [ 12 | 'date', 13 | 'sale', 14 | 'sale_count', 15 | 'purchase', 16 | 'purchase_count' 17 | ]; 18 | 19 | /** 20 | * Daily total properties. 21 | * @typedef {Object} DailyTotalProperties 22 | * @property {Date} date - Date. 23 | * @property {number} sale - Sale total. 24 | * @property {number} sale_count - Number of sales. 25 | * @property {number} purchase - Purchase total. 26 | * @property {number} purchase_count - Number of purchases. 27 | */ 28 | 29 | /** 30 | * Daily total. 31 | */ 32 | export class DailyTotal { 33 | /** 34 | * Identifier for daily totals. 35 | * @type {string} 36 | * @static 37 | */ 38 | static identifier = 'dailytotals'; 39 | /** 40 | * Types for daily totals. 41 | * @type {DisplayableTypes} 42 | * @static 43 | */ 44 | static types = types; 45 | /** 46 | * Date. 47 | * @type {Date} 48 | */ 49 | date; 50 | /** 51 | * Sale total. 52 | * @type {number} 53 | */ 54 | sale; 55 | /** 56 | * Number of sales. 57 | * @type {number} 58 | */ 59 | sale_count; 60 | /** 61 | * Purchase total. 62 | * @type {number} 63 | */ 64 | purchase; 65 | /** 66 | * Number of purchases. 67 | * @type {number} 68 | */ 69 | purchase_count; 70 | 71 | /** 72 | * Creates a new daily total. 73 | * @param {DailyTotalProperties} properties - Properties. 74 | */ 75 | constructor(properties) { 76 | Object.assign(this, properties); 77 | } 78 | 79 | /** 80 | * Builds the display attributes. 81 | * @param {Localization} locales - Localization strings. 82 | * @returns {DisplayOptions} Display options. 83 | */ 84 | static makeDisplay(locales) { 85 | return makeTotalDisplay(locales, tableColumns); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /views/view/totals.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Viewing Totals - Steam Market History Cataloger 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |

Date

22 |
23 |
24 |
25 |
26 |

Sales

27 |
28 |
29 |
30 |

Purchases

31 |
32 |
33 |
34 |
35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /js/app/models/totals/AnnualTotal.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { types, makeTotalDisplay } from './helpers/initializers.js'; 4 | 5 | /** 6 | * @typedef {import('../helpers/createClass.js').DisplayOptions} DisplayOptions 7 | * @typedef {import('../helpers/createClass.js').DisplayableTypes} DisplayableTypes 8 | * @typedef {import('../Localization.js').Localization} Localization 9 | */ 10 | 11 | const tableColumns = [ 12 | 'year', 13 | 'sale', 14 | 'sale_count', 15 | 'purchase', 16 | 'purchase_count' 17 | ]; 18 | 19 | /** 20 | * Annual total properties. 21 | * @typedef {Object} AnnualTotalProperties 22 | * @property {number} year - Year. 23 | * @property {number} sale - Sale total. 24 | * @property {number} sale_count - Number of sales. 25 | * @property {number} purchase - Purchase total. 26 | * @property {number} purchase_count - Number of purchases. 27 | */ 28 | 29 | /** 30 | * Annual total. 31 | */ 32 | export class AnnualTotal { 33 | /** 34 | * Identifier for annual totals. 35 | * @type {string} 36 | * @static 37 | */ 38 | static identifier = 'annualtotals'; 39 | /** 40 | * Types for annual totals. 41 | * @type {DisplayableTypes} 42 | * @static 43 | */ 44 | static types = types; 45 | /** 46 | * Year. 47 | * @type {number} 48 | */ 49 | year; 50 | /** 51 | * Sale total. 52 | * @type {number} 53 | */ 54 | sale; 55 | /** 56 | * Number of sales. 57 | * @type {number} 58 | */ 59 | sale_count; 60 | /** 61 | * Purchase total. 62 | * @type {number} 63 | */ 64 | purchase; 65 | /** 66 | * Number of purchases. 67 | * @type {number} 68 | */ 69 | purchase_count; 70 | 71 | /** 72 | * Creates a new annual total. 73 | * @param {AnnualTotalProperties} properties - Properties. 74 | */ 75 | constructor(properties) { 76 | this.year = properties.year; 77 | this.sale = properties.sale; 78 | this.sale_count = properties.sale_count; 79 | this.purchase = properties.purchase; 80 | this.purchase_count = properties.purchase_count; 81 | } 82 | 83 | /** 84 | * Builds the display attributes. 85 | * @param {Localization} locales - Localization strings. 86 | * @returns {DisplayOptions} Display options. 87 | */ 88 | static makeDisplay(locales) { 89 | return makeTotalDisplay(locales, tableColumns); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /js/app/account.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { AppError } from './error.js'; 4 | import { LocalStorage } from './storage/LocalStorage.js'; 5 | import { getCurrency } from './currency.js'; 6 | import { Localization } from './models/Localization.js'; 7 | 8 | /** 9 | * @typedef {import('./currency.js').Currency} Currency 10 | */ 11 | 12 | /** 13 | * Wallet. 14 | * @typedef {Object} Wallet 15 | * @property {Currency} currency - The currency of the wallet. 16 | */ 17 | 18 | /** 19 | * Account. 20 | * @typedef {Object} Account 21 | * @property {Localization} locales - The localization data. 22 | * @property {string} steamid - The steamid of the user. 23 | * @property {string} username - The username of the user. 24 | * @property {string} avatar - The avatar of the user. 25 | * @property {Wallet} wallet - The wallet of the user. 26 | * @property {string} language - The language of the user. 27 | */ 28 | 29 | /** 30 | * Loads account. 31 | * @returns {Promise} Account data. 32 | */ 33 | export async function loadAccount() { 34 | const accountLocalStorage = new LocalStorage('logged_in_user'); 35 | const accountData = await accountLocalStorage.getSettings(); 36 | const steamid = accountData.steamcommunity; 37 | const { avatar, username } = accountData; 38 | const accountInfoLocalStorage = new LocalStorage(`${steamid}_accountinfo`); 39 | 40 | if (steamid == null) { 41 | throw new AppError('No steamcommunity.com login detected. Either login or view a page on steamcommunity.com to configure login.'); 42 | } 43 | 44 | const accountInfoData = await accountInfoLocalStorage.getSettings(); 45 | const { language } = accountInfoData; 46 | 47 | if (!language) { 48 | throw new AppError('No language detected'); 49 | } 50 | 51 | if (!accountInfoData.wallet_currency) { 52 | throw new AppError('No wallet detected.'); 53 | } 54 | 55 | const currency = getCurrency(accountInfoData.wallet_currency); 56 | 57 | if (!currency) { 58 | // currency was not found on sotrage 59 | throw new AppError(`No currency detected with ID "${accountInfoData.wallet_currency}"`); 60 | } 61 | 62 | const locales = await Localization.get(language); 63 | 64 | return { 65 | locales, 66 | steamid, 67 | username, 68 | avatar, 69 | language, 70 | wallet: { 71 | currency 72 | } 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /js/app/storage/DatabaseSettingsManager.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Database settings manager. 5 | */ 6 | export class DatabaseSettingsManager { 7 | /** 8 | * The Dexie database. 9 | * @type {Object} 10 | */ 11 | #db; 12 | /** 13 | * The table name to use from `db`. 14 | * @type {string} 15 | */ 16 | #tableName; 17 | /** 18 | * The primary key of the table. 19 | * @type {string} 20 | */ 21 | #primaryKey; 22 | /** 23 | * The default settings. 24 | * @type {Object} 25 | */ 26 | #defaults; 27 | /** 28 | * The table to use. 29 | * @type {Object} 30 | */ 31 | #table; 32 | 33 | /** 34 | * Creates a new database settings manager. 35 | * @param {Object} db - The Dexie database. 36 | * @param {string} tableName - The table name to use from `db`. 37 | * @param {string} primaryKey - The primary key of the table. 38 | * @param {Object} defaults - The default settings. 39 | */ 40 | constructor(db, tableName, primaryKey, defaults = {}) { 41 | this.#db = db; 42 | this.#tableName = tableName; 43 | this.#primaryKey = primaryKey; 44 | this.#defaults = defaults; 45 | this.#table = db[tableName]; 46 | } 47 | 48 | /** 49 | * Gets the settings. 50 | * @returns {Promise} Resolves with settings when done. 51 | */ 52 | async getSettings() { 53 | const record = await this.#table.get(this.#primaryKey); 54 | 55 | if (record) { 56 | return Object.assign({}, this.#defaults, record); 57 | } 58 | 59 | return this.#defaults; 60 | } 61 | 62 | /** 63 | * Saves the settings. 64 | * @param {Object} settingsToSave - The settings to save. 65 | * @returns {Promise} Resolves when done. 66 | */ 67 | async saveSettings(settingsToSave) { 68 | const primKey = this.#table.schema.primKey.keyPath; 69 | // the full data set 70 | const data = Object.assign({}, { 71 | [primKey]: this.#primaryKey 72 | }, settingsToSave); 73 | 74 | // add or update the data on the database 75 | return this.#table.put(data); 76 | } 77 | 78 | /** 79 | * Deletes the settings. 80 | * @returns {Promise} Resolves when done. 81 | */ 82 | async deleteSettings() { 83 | return this.#db[this.#tableName].delete(this.#primaryKey); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import jsdoc from "eslint-plugin-jsdoc"; 2 | import pluginJs from "@eslint/js"; 3 | import globals from "globals"; 4 | import babelParser from "@babel/eslint-parser"; 5 | 6 | export default [ 7 | pluginJs.configs.recommended, 8 | { 9 | "ignores": [ 10 | "eslint.config.js", 11 | "node_modules/**", 12 | "js/lib/**", 13 | "js/content/**", 14 | "tests/*", 15 | "dist/**", 16 | "dev/**", 17 | ] 18 | }, 19 | { 20 | "languageOptions": { 21 | "ecmaVersion": 13, 22 | "sourceType": "module", 23 | "parser": babelParser, 24 | "globals": { 25 | ...globals.browser, 26 | "BigInt": false, 27 | "moment": false, 28 | "Velocity": false, 29 | "chrome": false, 30 | "Chartist": false, 31 | "Dexie": false 32 | } 33 | }, 34 | "plugins": { 35 | jsdoc 36 | }, 37 | "rules": { 38 | "jsdoc/require-description": "error", 39 | "jsdoc/check-values": "error", 40 | "jsdoc/require-description-complete-sentence": "error", 41 | "indent": [ 42 | "error", 43 | 4, 44 | { 45 | "SwitchCase": 1 46 | } 47 | ], 48 | "no-unused-vars": "warn", 49 | "object-curly-spacing": [ 50 | "error", 51 | "always" 52 | ], 53 | "no-empty": [ 54 | "error", 55 | { 56 | "allowEmptyCatch": true 57 | } 58 | ], 59 | "linebreak-style": [ 60 | "error", 61 | "unix" 62 | ], 63 | "quotes": [ 64 | "error", 65 | "single" 66 | ], 67 | "no-cond-assign": [ 68 | "off" 69 | ], 70 | "no-useless-escape": [ 71 | "off" 72 | ], 73 | "semi": [ 74 | "error", 75 | "always" 76 | ], 77 | "no-redeclare": [ 78 | 2, 79 | { 80 | "builtinGlobals": true 81 | } 82 | ], 83 | "no-console": 0, 84 | "no-undef": [ 85 | "error" 86 | ] 87 | } 88 | } 89 | ]; 90 | -------------------------------------------------------------------------------- /js/app/layout/tooltip.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Gets the offset of an element. 5 | * @param {Element} element - Element to get offset of. 6 | * @returns {{top: number, left: number}} Offset. 7 | */ 8 | function offset(element) { 9 | const bounds = element.getBoundingClientRect(); 10 | const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; 11 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 12 | 13 | return { 14 | top: bounds.top + scrollTop, 15 | left: bounds.left + scrollLeft 16 | }; 17 | } 18 | 19 | /** 20 | * Fades in an element. 21 | * @param {Element} element - Element to fade in. 22 | * @param {number} duration - Duration of fade, in milliseconds. 23 | */ 24 | function fadeIn(element, duration) { 25 | // @ts-ignore 26 | Velocity(element, 'fadeIn', { 27 | duration 28 | }); 29 | } 30 | 31 | /** 32 | * Builds a tooltip. 33 | * @param {HTMLElement} element - Element to position next to. 34 | * @param {string} contents - HTML contents. 35 | * @param {Object} [options={}] - Options. 36 | * @param {Object} [options.borderColor] - Hexadecimal color for border. 37 | * @returns {Object} DOM element of table. 38 | */ 39 | export function tooltip(element, contents, options = {}) { 40 | const found = document.getElementById('tooltip'); 41 | const toolTipEl = found || document.createElement('div'); 42 | const bounds = offset(element); 43 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 44 | const scrollBottom = window.innerHeight + scrollTop; 45 | 46 | toolTipEl.setAttribute('id', 'tooltip'); 47 | toolTipEl.classList.add('tooltip'); 48 | toolTipEl.innerHTML = contents; 49 | 50 | if (options.borderColor) { 51 | toolTipEl.style.borderColor = '#' + options.borderColor; 52 | } 53 | 54 | if (!found) { 55 | document.body.appendChild(toolTipEl); 56 | } 57 | 58 | let x = bounds.left + element.offsetWidth + 20; 59 | let y = bounds.top - 60; 60 | const difference = scrollBottom - (y + toolTipEl.offsetHeight); 61 | 62 | if (difference < 0) { 63 | y = Math.max(20, y + (difference - 20)); 64 | } 65 | 66 | toolTipEl.style.left = x + 'px'; 67 | toolTipEl.style.top = y + 'px'; 68 | fadeIn(toolTipEl, 150); 69 | } 70 | 71 | /** 72 | * Removes tooltip. 73 | */ 74 | export function removeTooltip() { 75 | const found = document.getElementById('tooltip'); 76 | 77 | if (found != null) { 78 | found.remove(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /js/app/layout/listings/external/buildThirdPartyLinks.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getClassinfo } from '../../../steam/index.js'; 4 | import { buildLink } from './buildLink.js'; 5 | 6 | export const buildThirdPartyLinks = { 7 | /** 8 | * Fetches asset data from Steam to display links. 9 | * @param {Object} record - Record to fetch asset for. 10 | * @returns {Promise} Resolves with array of links when done. 11 | */ 12 | async withAsset(record) { 13 | const asset = await getClassinfo(record.appid, record.classid, record.instanceid); 14 | 15 | switch (record.appid) { 16 | case '440': { 17 | const appData = asset.app_data; 18 | const defindex = appData.def_index; 19 | const quality = appData.quality; 20 | 21 | return [ 22 | buildLink({ 23 | url: `http://wiki.teamfortress.com/scripts/itemredirect.php?id=${defindex}`, 24 | title: 'Wiki' 25 | }), 26 | buildLink({ 27 | url: `https://backpack.tf/stats/${quality}/${defindex}/1/1`, 28 | title: 'backpack.tf' 29 | }), 30 | buildLink({ 31 | url: `https://marketplace.tf/items/${defindex};${quality}`, 32 | title: 'Marketplace.tf' 33 | }) 34 | ]; 35 | } 36 | } 37 | 38 | return []; 39 | }, 40 | /** 41 | * Generates placeholders for links. 42 | * @param {Object} item - Item to generate placeholders for. 43 | * @returns {Object[]} Array of placeholder links. 44 | */ 45 | placeholder(item) { 46 | switch (item.appid) { 47 | case '440': 48 | return [ 49 | buildLink({ 50 | url: '#', 51 | title: 'Wiki', 52 | placeholder: true 53 | }), 54 | buildLink({ 55 | url: '#', 56 | title: 'backpack.tf', 57 | placeholder: true 58 | }), 59 | buildLink({ 60 | url: '#', 61 | title: 'marketplace.tf', 62 | placeholder: true 63 | }) 64 | ]; 65 | default: 66 | return []; 67 | } 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /js/app/models/GameItem.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @typedef {import('./helpers/createClass.js').Displayable} Displayable 5 | * @typedef {import('./helpers/createClass.js').DisplayOptions} DisplayOptions 6 | * @typedef {import('./helpers/createClass.js').DisplayableTypes} DisplayableTypes 7 | * @typedef {import('./Localization.js').Localization} Localization 8 | */ 9 | 10 | /** 11 | * Game item properties. 12 | * @typedef {Object} GameItemProperties 13 | * @property {string} app - App name. 14 | * @property {number} count - Number of this particular item. 15 | * @property {string} name - Name of item. 16 | */ 17 | 18 | const types = { 19 | app: String, 20 | count: Number, 21 | name: String 22 | }; 23 | 24 | /** 25 | * Game item. 26 | * @namespace GameItem 27 | */ 28 | export class GameItem { 29 | /** 30 | * Identifier for game items. 31 | * @type {string} 32 | * @static 33 | */ 34 | static identifier = 'gameitems'; 35 | /** 36 | * Types for game items. 37 | * @type {DisplayableTypes} 38 | * @static 39 | */ 40 | static types = types; 41 | /** 42 | * App name. 43 | * @type {string} 44 | */ 45 | app; 46 | /** 47 | * Number of this particular item. 48 | * @type {number} 49 | */ 50 | count; 51 | /** 52 | * Name of item. 53 | * @type {string} 54 | */ 55 | name; 56 | 57 | /** 58 | * Creates a new game item. 59 | * @param {GameItemProperties} properties - Properties. 60 | */ 61 | constructor(properties) { 62 | this.app = properties.app; 63 | this.count = properties.count; 64 | this.name = properties.name; 65 | } 66 | 67 | /** 68 | * Builds the display attributes. 69 | * @static 70 | * @param {Localization} locales - Localization strings. 71 | * @returns {DisplayOptions} Display options. 72 | */ 73 | static makeDisplay(locales) { 74 | return { 75 | names: locales.db.gameitems.names, 76 | identifiers: {}, 77 | currency_fields: [ 78 | 'price' 79 | ], 80 | number_fields: [ 81 | 'price' 82 | ], 83 | boolean_fields: [] 84 | }; 85 | } 86 | 87 | /** 88 | * Converts game item to JSON format. 89 | * @returns {Object} JSON representation of the game item. 90 | */ 91 | toJSON() { 92 | return { 93 | app: this.app, 94 | count: this.count, 95 | name: this.name 96 | }; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /js/app/models/totals/AppTotal.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { types, makeTotalDisplay } from './helpers/initializers.js'; 4 | 5 | /** 6 | * @typedef {import('../helpers/createClass.js').DisplayOptions} DisplayOptions 7 | * @typedef {import('../helpers/createClass.js').DisplayableTypes} DisplayableTypes 8 | * @typedef {import('../Localization.js').Localization} Localization 9 | */ 10 | 11 | const tableColumns = [ 12 | 'appid', 13 | 'sale', 14 | 'sale_count', 15 | 'purchase', 16 | 'purchase_count' 17 | ]; 18 | 19 | /** 20 | * App total properties. 21 | * @typedef {Object} AppTotalProperties 22 | * @property {string} appname - App name. 23 | * @property {number} appid - App ID. 24 | * @property {number} sale - Sale total. 25 | * @property {number} sale_count - Number of sales. 26 | * @property {number} purchase - Purchase total. 27 | * @property {number} purchase_count - Number of purchases. 28 | */ 29 | 30 | /** 31 | * App total. 32 | */ 33 | export class AppTotal { 34 | /** 35 | * Identifier for app totals. 36 | * @type {string} 37 | * @static 38 | */ 39 | static identifier = 'apptotals'; 40 | /** 41 | * Types for app totals. 42 | * @type {DisplayableTypes} 43 | * @static 44 | */ 45 | static types = types; 46 | /** 47 | * App name. 48 | * @type {string} 49 | */ 50 | appname; 51 | /** 52 | * App ID. 53 | * @type {number} 54 | */ 55 | appid; 56 | /** 57 | * Sale total. 58 | * @type {number} 59 | */ 60 | sale; 61 | /** 62 | * Number of sales. 63 | * @type {number} 64 | */ 65 | sale_count; 66 | /** 67 | * Purchase total. 68 | * @type {number} 69 | */ 70 | purchase; 71 | /** 72 | * Number of purchases. 73 | * @type {number} 74 | */ 75 | purchase_count; 76 | 77 | /** 78 | * Creates a new app total. 79 | * @param {AppTotalProperties} properties - Properties. 80 | */ 81 | constructor(properties) { 82 | this.appname = properties.appname; 83 | this.appid = properties.appid; 84 | this.sale = properties.sale; 85 | this.sale_count = properties.sale_count; 86 | this.purchase = properties.purchase; 87 | this.purchase_count = properties.purchase_count; 88 | } 89 | 90 | /** 91 | * Builds the display attributes. 92 | * @param {Localization} locales - Localization strings. 93 | * @returns {DisplayOptions} Display options. 94 | */ 95 | static makeDisplay(locales) { 96 | return makeTotalDisplay(locales, tableColumns); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /js/app/models/totals/MonthlyTotal.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { types, makeTotalDisplay } from './helpers/initializers.js'; 4 | 5 | /** 6 | * @typedef {import('../helpers/createClass.js').DisplayOptions} DisplayOptions 7 | * @typedef {import('../helpers/createClass.js').DisplayableTypes} DisplayableTypes 8 | * @typedef {import('../Localization.js').Localization} Localization 9 | */ 10 | 11 | const tableColumns = [ 12 | 'year', 13 | 'month', 14 | 'sale', 15 | 'sale_count', 16 | 'purchase', 17 | 'purchase_count' 18 | ]; 19 | 20 | /** 21 | * Monthly total properties. 22 | * @typedef {Object} MonthlyTotalProperties 23 | * @property {number} year - Year. 24 | * @property {number} month - Month. 25 | * @property {number} sale - Sale total. 26 | * @property {number} sale_count - Number of sales. 27 | * @property {number} purchase - Purchase total. 28 | * @property {number} purchase_count - Number of purchases. 29 | */ 30 | 31 | /** 32 | * Monthly total. 33 | */ 34 | export class MonthlyTotal { 35 | /** 36 | * Identifier for monthly totals. 37 | * @type {string} 38 | * @static 39 | */ 40 | static identifier = 'monthlytotals'; 41 | /** 42 | * Types for monthly totals. 43 | * @type {DisplayableTypes} 44 | * @static 45 | */ 46 | static types = types; 47 | /** 48 | * Year. 49 | * @type {number} 50 | */ 51 | year; 52 | /** 53 | * Month. 54 | * @type {number} 55 | */ 56 | month; 57 | /** 58 | * Sale total. 59 | * @type {number} 60 | */ 61 | sale; 62 | /** 63 | * Number of sales. 64 | * @type {number} 65 | */ 66 | sale_count; 67 | /** 68 | * Purchase total. 69 | * @type {number} 70 | */ 71 | purchase; 72 | /** 73 | * Number of purchases. 74 | * @type {number} 75 | */ 76 | purchase_count; 77 | 78 | /** 79 | * Creates a new monthly total. 80 | * @param {MonthlyTotalProperties} properties - Properties. 81 | */ 82 | constructor(properties) { 83 | this.year = properties.year; 84 | this.month = properties.month; 85 | this.sale = properties.sale; 86 | this.sale_count = properties.sale_count; 87 | this.purchase = properties.purchase; 88 | this.purchase_count = properties.purchase_count; 89 | } 90 | 91 | /** 92 | * Builds the display attributes. 93 | * @param {Localization} locales - Localization strings. 94 | * @returns {DisplayOptions} Display options. 95 | */ 96 | static makeDisplay(locales) { 97 | return makeTotalDisplay(locales, tableColumns); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.3.1] - 2025-09-24 4 | 5 | ### Added 6 | - More currencies. 7 | 8 | ### Fixed 9 | - IDR currency not displaying decimal places. 10 | - INR currency displaying decimal places. 11 | 12 | ## [1.3.0] - 2025-06-12 13 | ### Added 14 | - Amount column. 15 | 16 | ### Fixed 17 | - INR currency not displaying decimal places. 18 | 19 | ## [1.2.2] - 2024-07-08 20 | ### Fixed 21 | - web_accessible_resources not having access to dom-parser which caused content scripts to not work. 22 | 23 | ## [1.2.1] - 2024-06-24 24 | 25 | ### Added 26 | - Dark theme. This is determined by the system theme. 27 | - More detailed error messages when parsing fails. 28 | - CHF currency. 29 | 30 | ### Changed 31 | - The applist to include up-to-date apps. 32 | 33 | ### Fixed 34 | - Parsing purchase history results. 35 | - A bug where filtering queries would return results from previous queries. 36 | 37 | (unreleased) 38 | 39 | ## [1.2.0] - 2024-06-07 40 | ### Fixed 41 | - Parsing listings as a result of history no longer including seller information. 42 | - Parsing Polish złoty (PLN) currency. 43 | 44 | ### Changed 45 | - Updated extension to Manifest V3. There are some limitations to Manifest V3 which required many changes to the codebase but shouldn't affect the user experience. Notably, background tasks are changed in favor of service workers which run only when the service worker is actively doing something. 46 | - Significant refactor of the codebase. Many factory-style functions were replaced in favor of ES6 classes. This makes it much easier to reason about as well as document. However, this may have introduced new bugs. 47 | - Updated ESLint to use the flat configuration format. 48 | - Updated the localization files. 49 | 50 | ## [1.1.3] - 2021-02-23 51 | ### Fixed 52 | - Issue with download streams limiting results to 5000. 53 | 54 | ### Changed 55 | - JPY shows 2 decimals rather than 0. 56 | 57 | ## [1.1.2] - 2021-02-11 58 | ### Fixed 59 | - Last week/month filters not always working due to improper date formats. 60 | 61 | ### Changed 62 | - Titles on pages to include "Steam Market History Cataloger". 63 | 64 | ## [1.1.1] - 2021-01-22 65 | ### Added 66 | - Last week quick-select button on filters for selecting date from the past week. 67 | - Last month quick-select button on filters for selecting date from the past month. 68 | 69 | ### Fixed 70 | - Bug with not showing new listing count. 71 | - Using search results limit from preferences. 72 | 73 | ### Changed 74 | - Date filters will now additionally select dates that are equal to the date selected. 75 | - Updated the localization files. 76 | 77 | ## [1.1.0] - 2021-01-03 78 | ### Changed 79 | - Viewing records no longer loads all listings at once. This is to limit consumption of resources. 80 | - Records are fetched from the database as-needed based on filter results. 81 | - Downloading records where the results are greater than the limit will result in records being streamed to the file. 82 | 83 | ### Removed 84 | - "View recent" button as it is no longer needed. This was intended as a quicker option to view your most recent transactions, but with the new search functionality listed above it is no longer necessary. 85 | 86 | ## [1.0.4] - 2019-05-06 87 | ### Added 88 | - This changelog file. 89 | 90 | ### Fixed 91 | - Parsing data from listing responses that would include empty attributes (empty string '') for name, market_name, and market_hash_name. These responses are ignored and the page is re-fetched. 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Steam Market History Cataloger 2 | 3 | Steam Market History Cataloger is a Chrome extension used for loading and storing your listings from the [Steam Community Market](https://steamcommunity.com/market). Steam does not offer this data in a format that is easily consumible for those that need extensive information of their transactions. This tool aims to fulfill that role in a user-friendly way. 4 | 5 | ## Installation 6 | 7 | To use this extension, please install it from the [Chrome Web Store](https://chrome.google.com/webstore/detail/dhpcikljplaooekklhbjohojbjbinega). 8 | 9 | ## Features 10 | 11 | * Loads transactions from the [Steam Community Market](https://steamcommunity.com/market) in any language or currency. Listings are stored to a local database in your browser for use at any time. 12 | * Allows viewing and filtering of all data loaded in a neat and responsive format within the extension. 13 | * Transaction data can be exported to JSON and CSV. Data models for JSON can be found [below](#models). 14 | * Loads purchase history from your [Steam account history](https://store.steampowered.com/account/history). However, this data is not persisted to the extension. 15 | * Allows background loading. Not enabled by default. Enable this option from the preferences page. Loads are polled at an interval of 1 hour. 16 | * History pages at are displayed 100 results per page. More options are provided which allow you to move around your history easier. 17 | 18 | ## Exports 19 | 20 | The extension allows you to export your listings to JSON and CSV. 21 | 22 | More information about the data models can be found in the [EXPORTS.md](EXPORTS.md) file. 23 | 24 | ## Known Issues 25 | 26 | * Since history results do not usually include the year of the date, the extension must make a best guess for the year. If you have gaps larger than a year in your history results you may experience issues. This issue may or may not be resolved in the future. 27 | * Any refunded transactions will persist if they were not refunded at the time of loading. There is no way to remove them from your results at this point unless you clear your entire listing data. 28 | * Pending transactions are treated as completed transactions. More than 99% of the time these are completed successfully. In the event they don't go through they shouldn't be recorded. Keeping track of pending transactions would add a bit of complexity and it's not something I have time for now. 29 | 30 | ## Contributing 31 | 32 | You may contribute to this project by opening an [issue](https://github.com/juliarose/Steam-Market-History-Cataloger/issues) to file a bug report. At this time new features are not a priority and are unlikely to be added, unless you want to contribute them yourself though this does not guarantee they will be merged. 33 | 34 | ## Legal 35 | 36 | Offered under the [GNU General Public License v2.0](LICENSE). It is offered as-is and without liability or warranty. You are free to modify and redistribute this extension as long as the source is disclosed and it is published under the same license. 37 | 38 | Steam Market History Cataloger is not affiliated with Steam or Valve. 39 | 40 | ## Privacy Policy 41 | 42 | This extension requires permissions to and to load data about your Steam account, as well as data storage to your disk. Stored data is entirely local and not shared anywhere outside of the extension. 43 | -------------------------------------------------------------------------------- /js/app/manager/PurchaseHistoryManager.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getSteamPoweredSession } from '../steam/index.js'; 4 | import { sleep } from '../helpers/utils.js'; 5 | import { getPurchaseHistory } from '../steam/requests/post.js'; 6 | import { parseTransactions } from '../parsers/parseTransactions.js'; 7 | import { AppError } from '../error.js'; 8 | 9 | /** 10 | * @typedef {import('../models/AccountTransaction.js').AccountTransaction} AccountTransaction 11 | * @typedef {import('../account.js').Account} Account 12 | */ 13 | 14 | /** 15 | * Load result. 16 | * @typedef {Object} PurchaseHistoryManagerLoadResponse 17 | * @property {AccountTransaction[]} records - Array of transactions. 18 | * @property {Object} [cursor] - Cursor for next load. 19 | */ 20 | 21 | /** 22 | * Used for managing purchase history requests. 23 | */ 24 | export class PurchaseHistoryManager { 25 | /** 26 | * Current Steam session data. 27 | * @type {Object} sessionid - Logged in sessionid. 28 | */ 29 | #session = { 30 | /** 31 | * Logged in sessionid. 32 | * @type {(string | null)} sessionid - Logged in sessionid. 33 | */ 34 | sessionid: null, 35 | }; 36 | /** 37 | * Account. Should contain wallet currency. 38 | * @type {Account} 39 | */ 40 | #account; 41 | 42 | /** 43 | * Creates a PurchaseHistoryManager. 44 | * @param {Object} deps - Dependencies. 45 | * @param {Account} deps.account - Account to loading listings from. Should contain wallet currency. 46 | */ 47 | constructor({ account }) { 48 | this.#account = account; 49 | } 50 | 51 | /** 52 | * Loads data from Steam. 53 | * @param {Object} data - Request parameters. 54 | * @returns {Promise} Response JSON from Steam on resolve, error with details on reject. 55 | */ 56 | async #getPurchaseHistory(data) { 57 | const response = await getPurchaseHistory(data); 58 | 59 | if (!response.ok) { 60 | throw new AppError(response.statusText); 61 | } 62 | 63 | return response.json(); 64 | } 65 | 66 | /** 67 | * Configures the module. 68 | * @returns {Promise} Resolves when done. 69 | */ 70 | async setup() { 71 | this.#session = await getSteamPoweredSession(); 72 | } 73 | 74 | /** 75 | * Loads Steam transaction history. 76 | * @param {Object} cursor - Position from last fetched result (provided by response from Steam). 77 | * @param {number} [delay=0] - Delay in Seconds to load. 78 | * @returns {Promise} Resolves with response when done. 79 | */ 80 | async load(cursor, delay = 0) { 81 | // session for store.steampowered must be present 82 | const { sessionid } = this.#session; 83 | 84 | if (!sessionid) { 85 | throw new AppError('No login'); 86 | } 87 | 88 | await sleep(delay * 1000); 89 | 90 | const response = await this.#getPurchaseHistory({ 91 | sessionid, 92 | cursor 93 | }); 94 | // parse the transaction 95 | const records = parseTransactions( 96 | response, 97 | this.#account.wallet.currency, 98 | this.#account.locales 99 | ); 100 | 101 | return { 102 | records, 103 | cursor: response.cursor 104 | }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /js/app/models/helpers/createClass.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @typedef {import('../../currency.js').Currency} Currency 5 | * @typedef {import('../Localization.js').Localization} Localization 6 | * @typedef {import('../../helpers/download.js').DownloadCollectionOptions} DownloadCollectionOptions 7 | */ 8 | 9 | /** 10 | * Types for each column in the model. 11 | * @typedef {Object.} DisplayableTypes 12 | */ 13 | 14 | /** 15 | * Contains attributes for streaming records from the database for download. 16 | * @typedef {Object} StreamDisplayOptions 17 | * @property {string} order - The column to order the records by. 18 | * @property {number} direction - Direction of order. 1 for descending, -1 for ascending. 19 | */ 20 | 21 | /** 22 | * Contains attributes for displaying attributes related to model. 23 | * @typedef {Object} DisplayOptions 24 | * @property {Object.} [names] - Textual display property of each column name e.g. "appname" is mapped to "App". 25 | * @property {Object.>} [identifiers] - Object containing exact strings to test against for key data. 26 | * @property {StreamDisplayOptions} [stream] - Options for streaming records from the database for download. 27 | * @property {string[]} [currency_fields] - Array of columns that are currencies. 28 | * @property {string[]} [boolean_fields] - Array of columns that are booleans. 29 | * @property {string[]} [number_fields] - Array of columns that are numbers. 30 | * @property {DisplayContext} [csv] - Context for displaying CSV data. 31 | * @property {DisplayContext} [table] - Context for displaying HTML tabular data. 32 | */ 33 | 34 | /** 35 | * Function for building a cell value. 36 | * @typedef {function((string | number | boolean | Date | null | undefined), Object): string} CellValueFunction 37 | */ 38 | 39 | /** 40 | * Function for binding an event. 41 | * @typedef {function(Event, Object): void} EventFunction 42 | */ 43 | 44 | /** 45 | * @typedef {Object} DisplayContext 46 | * @property {string[]} [columns] - Names of each columns to display. 47 | * @property {Object.} [column_names] - Maps for each column to get each column's name. 48 | * @property {Object.} [sorts] - Maps columns to sort against when displaying tabular data. 49 | * @property {Object.} [column_class] - Maps for assigning columns classes to each column when displaying tabular data. 50 | * @property {Function} [row_class] - Function which returns an array of class names to apply to row on specific record when displaying tabular data. 51 | * @property {Object.} [cell_value] - Maps for assigning contents for each cell when displaying tabular data. 52 | * @property {Object.} [events] - Maps for events to bind to each row when displaying tabular data. 53 | **/ 54 | 55 | /** 56 | * Generic class for models. 57 | * @typedef {Object} Displayable 58 | * @property {string} identifier - String to uniquely identify this model. 59 | * @property {string} [primary_key] - The primary key for the data set in the database. 60 | * @property {DisplayableTypes} types - Maps for defining the data type of each column. 61 | * @property {function(Localization): DisplayOptions} makeDisplay - Function to build the display attributes. 62 | */ 63 | 64 | /** 65 | * This actually does nothing but VSCode seems to only recognize the typedefs in the file if it is 66 | * imported somewhere in the project? 67 | */ 68 | export function createClass() { 69 | return; 70 | } 71 | -------------------------------------------------------------------------------- /tests/localization.test.js: -------------------------------------------------------------------------------- 1 | import { Localization } from '../js/app/models/Localization.js'; 2 | import { valuesAsKeys } from '../js/app/helpers/utils.js'; 3 | import { ELangCode } from '../js/app/enums/ELangCode.js'; 4 | 5 | const path = require('path'); 6 | const fs = require('fs'); 7 | 8 | function loadLocales() { 9 | const getLocales = async (code) => { 10 | const languages = valuesAsKeys(ELangCode); 11 | const language = languages[code]; 12 | 13 | return Localization.get(language); 14 | }; 15 | const parentPath = path.join(__dirname, '..'); 16 | const jsonPath = path.join(parentPath, 'json', 'locales'); 17 | const codes = fs.readdirSync(jsonPath); 18 | const promises = codes.map(getLocales); 19 | 20 | return Promise.all(promises); 21 | } 22 | 23 | function findLanguage(language) { 24 | return languages.find((locales) => { 25 | return locales.language === language; 26 | }); 27 | } 28 | 29 | let english; 30 | let languages = []; 31 | 32 | beforeAll(async () => { 33 | languages = await loadLocales(); 34 | english = findLanguage('english'); 35 | 36 | return; 37 | }); 38 | 39 | it('Gets a localization', async () => { 40 | expect.assertions(1); 41 | 42 | const language = 'english'; 43 | const locales = await Localization.get(language); 44 | 45 | return expect(locales.db).toBeDefined(); 46 | }); 47 | 48 | it('Fails to get a localization that does not exist', async () => { 49 | expect.assertions(1); 50 | 51 | try { 52 | await Localization.get('meows'); 53 | } catch (error) { 54 | expect(error).toBeDefined(); 55 | } 56 | }); 57 | 58 | it('Converts date to string properly', () => { 59 | const date = new Date(2019, 8, 20); 60 | const converted = english.toDateString(date); 61 | 62 | expect(converted).toBe('Sep 20'); 63 | }); 64 | 65 | it('Parses date string properly in English', () => { 66 | const language = findLanguage('english'); 67 | const dateString = 'Oct 2'; 68 | const { 69 | month, 70 | day 71 | } = language.parseDateString(dateString); 72 | 73 | expect(month).toBe(9); 74 | expect(day).toBe(2); 75 | }); 76 | 77 | it('Parses date string properly in Japanese', () => { 78 | const language = findLanguage('japanese'); 79 | const dateString = '10月2日'; 80 | const { 81 | month, 82 | day 83 | } = language.parseDateString(dateString); 84 | 85 | expect(month).toBe(9); 86 | expect(day).toBe(2); 87 | }); 88 | 89 | it('Parses date string properly in Finnish', () => { 90 | const language = findLanguage('finnish'); 91 | const dateString = '2.10.'; 92 | const { 93 | month, 94 | day 95 | } = language.parseDateString(dateString); 96 | 97 | expect(month).toBe(9); 98 | expect(day).toBe(2); 99 | }); 100 | 101 | it('Parses date string properly in German', () => { 102 | const language = findLanguage('german'); 103 | const dateString = '2. Okt.'; 104 | const { 105 | month, 106 | day 107 | } = language.parseDateString(dateString); 108 | 109 | expect(month).toBe(9); 110 | expect(day).toBe(2); 111 | }); 112 | 113 | it('Parses date string properly in Russian', () => { 114 | const language = findLanguage('russian'); 115 | const dateString = '2 окт'; 116 | const { 117 | month, 118 | day 119 | } = language.parseDateString(dateString); 120 | 121 | expect(month).toBe(9); 122 | expect(day).toBe(2); 123 | }); -------------------------------------------------------------------------------- /js/views/load_purchase_history.js: -------------------------------------------------------------------------------- 1 | import { readyState } from '../app/readyState.js'; 2 | import * as Layout from '../app/layout/index.js'; 3 | import { AccountTransaction } from '../app/models/AccountTransaction.js'; 4 | import { PurchaseHistoryManager } from '../app/manager/PurchaseHistoryManager.js'; 5 | import { getPreferences } from '../app/preferences.js'; 6 | 7 | const page = { 8 | results: document.getElementById('results'), 9 | contentLoader: document.getElementById('content-loader'), 10 | progress: document.getElementById('load-progress'), 11 | progressBar: document.getElementById('load-progress').firstElementChild, 12 | buttons: { 13 | getHistory: document.getElementById('get-history') 14 | } 15 | }; 16 | 17 | async function onApp(app) { 18 | function renderTable(records) { 19 | const options = Object.assign({}, Layout.getLayoutOptions({ 20 | account, 21 | preferences 22 | }), { 23 | keep_page: true 24 | }); 25 | const tableEl = Layout.buildTable(records || [], AccountTransaction, options); 26 | 27 | Layout.render(page.results, tableEl); 28 | } 29 | 30 | // begins loading data 31 | function load() { 32 | page.progressBar.style.width = '100%'; 33 | page.results.innerHTML = '
Loading...
'; 34 | 35 | function done(error) { 36 | page.progress.style.visibility = 'hidden'; 37 | Layout.alert(error || 'All done!', page.results); 38 | } 39 | 40 | function loadTransactions(cursor, delay = 0) { 41 | // we've received a response and now want to get more 42 | function getMore({ records, cursor }) { 43 | onRecords(records); 44 | 45 | // if the response contained the cursor for the next page 46 | if (cursor) { 47 | // call the load function again 48 | loadTransactions(cursor, 3); 49 | } else { 50 | // otherwise we have nothing more to load 51 | done('All done!'); 52 | } 53 | } 54 | 55 | purchaseHistoryManager.load(cursor, delay) 56 | .then(getMore) 57 | .catch(done); 58 | } 59 | 60 | Layout.alert( 61 | 'Loading started! Your purchase history is not stored to the extension but ' + 62 | 'can be loaded and viewed here. This page will load until no more results can be loaded.', 63 | page.results, 64 | 'active' 65 | ); 66 | loadTransactions(); 67 | } 68 | 69 | function onRecords(records) { 70 | total = total.concat(records); 71 | renderTable(total); 72 | } 73 | 74 | // array that will hold all of our collected records from loading 75 | let total = []; 76 | const purchaseHistoryManager = new PurchaseHistoryManager(app); 77 | const preferences = await getPreferences(); 78 | const { account } = app; 79 | 80 | // add listeners 81 | (function() { 82 | page.buttons.getHistory.addEventListener('click', (e) => { 83 | e.target.parentNode.remove(); 84 | // start loading 85 | load(); 86 | }); 87 | }()); 88 | 89 | purchaseHistoryManager.setup() 90 | .then(Layout.ready) 91 | .catch(Layout.error); 92 | } 93 | 94 | // ready 95 | { 96 | readyState(onApp, Layout.error); 97 | } 98 | -------------------------------------------------------------------------------- /js/app/json_typedefs.js: -------------------------------------------------------------------------------- 1 | // typedefs for JSON exports 2 | // Used for user documentation purposes 3 | 4 | /** 5 | * The output data when exporting listings to JSON. 6 | * @typedef {Object} ExportedListings 7 | * @property {Currency} currency - The currency of your Steam wallet. 8 | * @property {Listing[]} listings - An array of listings. 9 | */ 10 | 11 | /** 12 | * A listing from your [Steam Community Market](https://steamcommunity.com/market) history. 13 | * @typedef {Object} Listing 14 | * @property {string} transaction_id - Transaction ID. 15 | * @property {string} appid - App ID. 16 | * @property {string} contextid - Context ID. 17 | * @property {string} classid - Class ID. 18 | * @property {string} instanceid - Instance ID. 19 | * @property {number} index - Index of listing in history. 20 | * @property {number} price - Integer value of price formatted to the precision defined by its currency e,g. 100 for $1.00. 21 | * @property {boolean} is_credit - Whether the transaction resulted in credit or not. 22 | * @property {string} name - Name. 23 | * @property {string} market_name - Market name. 24 | * @property {string} market_hash_name - Market hash name. 25 | * @property {string} icon - Icon path on Steam's CDN. 26 | * @property {string} [name_color] - 6-digit hexademical color for name. 27 | * @property {string} [background_color] - 6-digit hexademical color for background. 28 | * @property {Date} date_acted - Date acted. 29 | * @property {Date} date_listed - Date listed. 30 | */ 31 | 32 | /** 33 | * The output data when exporting account transactions to JSON. 34 | * @typedef {Object} ExportedAccountTransactions 35 | * @property {Currency} currency - The currency of your Steam wallet. 36 | * @property {AccountTransaction[]} accounttransactions - An array of account transactions. 37 | */ 38 | 39 | /** 40 | * A row from your Steam account purchase history at . 41 | * @typedef {Object} AccountTransaction 42 | * @property {number} transaction_type - A value from [ETransactionType](js/app/enums/ETransactionType.js). 43 | * @property {Date} date - Date of transaction. 44 | * @property {number} count - Number of this type of transaction. 45 | * @property {number} price - Integer value of price formatted to the precision defined by its currency e,g. 100 for $1.00. 46 | * @property {boolean} is_credit - Whether the transaction resulted in credit or not. 47 | * @property {GameItem[]} [items] - Items from transaction, if any. 48 | */ 49 | 50 | /** 51 | * An item from an in-game purchase belonging to an account transaction. 52 | * @typedef {Object} GameItem 53 | * @property {string} app - App name. 54 | * @property {number} count - Number of this particular item. 55 | * @property {string} name - Name. 56 | */ 57 | 58 | /** 59 | * A currency used for prices. 60 | * @typedef {Object} Currency 61 | * @property {number} wallet_code - The ID of the currency from Steam. 62 | * @property {string} code - ISO 4217 currency code e.g. "USD". 63 | * @property {string} symbol - Currency symbol e.g. "$". 64 | * @property {number} precision - Decimal place precision e.g. 2 decimal places for USD. 65 | * @property {string} thousand - Thousand place character. 66 | * @property {string} decimal - Decimal place character. 67 | * @property {boolean} [spacer] - Whether the amount should be displayed with a space between the number and symbol. 68 | * @property {boolean} [after] - Whether the symbol should be displayed after the number. 69 | * @property {boolean} [trim_trailing] - Whether trailing zeroes should be trimmed on whole values. 70 | * @property {number} [format_precision] - Decimal place precision used in formatting. 71 | */ -------------------------------------------------------------------------------- /js/views/background.js: -------------------------------------------------------------------------------- 1 | import { setBadgeText } from '../app/browser.js'; 2 | import { ListingWorker } from '../app/workers/ListingWorker.js'; 3 | import { onMessage } from '../app/browser.js'; 4 | import { getPreferences } from '../app/preferences.js'; 5 | 6 | const LOAD_LISTINGS_ALARM_KEY = 'load-listings'; 7 | const listingWorker = new ListingWorker(); 8 | 9 | function addListeners() { 10 | onMessage.addListener(({ name }, _sender, sendResponse) => { 11 | switch (name) { 12 | case 'startLoading': { 13 | // force load 14 | load(true); 15 | sendResponse(); 16 | break; 17 | } 18 | case 'resumeLoading': { 19 | load(); 20 | sendResponse(); 21 | break; 22 | } 23 | case 'clearListingCount': { 24 | listingWorker.clearListingCount(); 25 | updateCount(0); 26 | sendResponse(); 27 | break; 28 | } 29 | case 'getListingIndex': { 30 | sendResponse(); 31 | break; 32 | } 33 | default: { 34 | sendResponse(); 35 | } 36 | } 37 | }); 38 | 39 | chrome.runtime.onInstalled.addListener(({ reason }) => { 40 | if (reason === 'install') { 41 | // this will load the initial data it needs 42 | chrome.tabs.create({ url: 'https://steamcommunity.com/market?installation=1' }, () => { 43 | 44 | }); 45 | } 46 | }); 47 | 48 | chrome.alarms.onAlarm.addListener((alarm) => { 49 | switch (alarm.name) { 50 | case LOAD_LISTINGS_ALARM_KEY: { 51 | load(); 52 | break; 53 | } 54 | default: { 55 | console.warn('Unknown alarm:', alarm.name); 56 | } 57 | } 58 | }); 59 | } 60 | 61 | // updates the count on using the badge text 62 | function updateCount(count) { 63 | if (count >= 1000) { 64 | // truncate to fit 65 | count = '999+'; 66 | } else if (count === 0) { 67 | count = ''; 68 | } else { 69 | // must be string 70 | count = count.toString(); 71 | } 72 | 73 | setBadgeText({ 74 | text: count 75 | }); 76 | } 77 | 78 | async function checkAlarmState() { 79 | const alarm = await chrome.alarms.get(LOAD_LISTINGS_ALARM_KEY); 80 | 81 | if (!alarm) { 82 | // 1 minute - don't call it immediately 83 | startAlarm(1); 84 | } 85 | } 86 | 87 | async function startAlarm(delayInMinutes) { 88 | await chrome.alarms.create(LOAD_LISTINGS_ALARM_KEY, { 89 | delayInMinutes 90 | }); 91 | } 92 | 93 | async function load(force = false) { 94 | if (listingWorker.isLoading) { 95 | // do nothing 96 | return; 97 | } 98 | 99 | async function complete(count) { 100 | const preferences = await getPreferences(); 101 | 102 | if (preferences.show_new_listing_count) { 103 | updateCount(count); 104 | } 105 | 106 | return next(); 107 | } 108 | 109 | async function next() { 110 | const pollIntervalMinutes = await listingWorker.getPollIntervalMinutes(); 111 | 112 | return startAlarm(pollIntervalMinutes); 113 | } 114 | 115 | return listingWorker.start(force) 116 | .then(complete) 117 | .catch((err) => { 118 | console.warn('Error getting listings:', err); 119 | return next(); 120 | }); 121 | } 122 | 123 | // ready 124 | { 125 | addListeners(); 126 | updateCount(0); 127 | checkAlarmState(); 128 | } 129 | -------------------------------------------------------------------------------- /js/app/models/totals/helpers/initializers.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { applist } from '../../../data/applist.js'; 4 | 5 | /** 6 | * @typedef {import('../../Localization.js').Localization} Localization 7 | * @typedef {import('../../helpers/createClass.js').DisplayOptions} DisplayOptions 8 | */ 9 | 10 | export const types = { 11 | year: Number, 12 | month: Number, 13 | appid: String, 14 | appname: String, 15 | date: Date, 16 | sale: Number, 17 | sale_count: Number, 18 | purchase: Number, 19 | purchase_count: Number 20 | }; 21 | 22 | /** 23 | * Builds the display attributes. 24 | * @param {Localization} locales - Localization strings. 25 | * @param {string[]} tableColumns - Columns to display. 26 | * @returns {DisplayOptions} Display options. 27 | */ 28 | export function makeTotalDisplay(locales, tableColumns) { 29 | const names = locales.ui.names; 30 | 31 | return { 32 | names, 33 | currency_fields: [ 34 | 'sale', 35 | 'purchase' 36 | ], 37 | number_fields: [ 38 | 'year', 39 | 'month', 40 | 'sale', 41 | 'purchase' 42 | ], 43 | csv: { 44 | cell_value: { 45 | month(_value, record) { 46 | if (typeof record.year === 'number' && typeof record.month === 'number') { 47 | // @ts-ignore 48 | return moment() 49 | .year(record.year) 50 | .month(record.month) 51 | .format('MMMM'); 52 | } 53 | 54 | return ''; 55 | }, 56 | appid(value) { 57 | return applist[value] || value; 58 | } 59 | }, 60 | }, 61 | table: { 62 | columns: tableColumns, 63 | cell_value: { 64 | date(value) { 65 | if (value instanceof Date) { 66 | // @ts-ignore 67 | return moment(value).format('MMMM Do, YYYY'); 68 | } 69 | 70 | return ''; 71 | }, 72 | month(_value, record) { 73 | if (typeof record.year === 'number' && typeof record.month === 'number') { 74 | // @ts-ignore 75 | return moment() 76 | .year(record.year) 77 | .month(record.month) 78 | .format('MMMM'); 79 | } 80 | 81 | return ''; 82 | }, 83 | appid: function(value) { 84 | return applist[value] || value; 85 | } 86 | }, 87 | column_names: names, 88 | column_class: { 89 | year: [ 90 | 'year' 91 | ], 92 | sale: [ 93 | 'price', 94 | 'more' 95 | ], 96 | sale_count: [ 97 | 'number' 98 | ], 99 | purchase: [ 100 | 'price', 101 | 'more' 102 | ], 103 | purchase_count: [ 104 | 'number' 105 | ] 106 | }, 107 | sorts: { 108 | year: 'year', 109 | month: 'month', 110 | // sort by appname instead 111 | appid: 'appname', 112 | date: 'date', 113 | sale: 'sale', 114 | sale_count: 'sale_count', 115 | purchase: 'purchase', 116 | purchase_count: 'purchase_count' 117 | } 118 | } 119 | }; 120 | } 121 | -------------------------------------------------------------------------------- /js/app/layout/listings/hovers/hovers.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getClassinfo } from '../../../steam/index.js'; 4 | import { escapeHTML } from '../../../helpers/utils.js'; 5 | import { AppError } from '../../../error.js'; 6 | 7 | // id for current hover 8 | // since requests are done through AJAX, 9 | // this aids in preventing hovers from popping up when they are no longer needed 10 | let hoverID = 0; 11 | 12 | /** 13 | * Gets a hover asset depending on hover ID. 14 | * @param {string} appid - Appid for asset. 15 | * @param {string} classid - Classid for asset. 16 | * @param {string} instanceid - Instanceid for asset. 17 | * @param {string} [language='english'] - Language. 18 | * @returns {Promise} Resolves with asset when done, reject on failure. 19 | */ 20 | export async function getHoverAsset(appid, classid, instanceid, language = 'english') { 21 | addToHoverState(); 22 | 23 | const id = hoverID; 24 | const asset = await getClassinfo(appid, classid, instanceid, language); 25 | 26 | // if the hover ID has changed, then we don't want to display this asset anymore 27 | if (id !== hoverID) { 28 | throw new AppError('No asset'); 29 | } 30 | 31 | return asset; 32 | } 33 | 34 | /** 35 | * Gets HTML for hover describing asset. 36 | * @param {Object} asset - Asset to display. 37 | * @returns {string} HTML string describing asset. 38 | */ 39 | export function getHover(asset) { 40 | const isEmpty = (description) => { 41 | return Boolean( 42 | description && 43 | description.value.trim() === '' 44 | ); 45 | }; 46 | const name = asset.name; 47 | // the image size of the icon in pixels 48 | const imgSize = 192; 49 | // the color we'll use for display the name 50 | const nameColor = asset.name_color || 'FFFFFF'; 51 | // the icon for the item 52 | const iconURL =`https://steamcommunity-a.akamaihd.net/economy/image/${asset.icon_url_large}/300x${imgSize}`; 53 | // the item "type" e.g. "Level 30 Hat" 54 | const type = `
${asset.type}
`; 55 | // the header for the item, which holds the name 56 | const header = `

${name}

`; 57 | // the image for the item 58 | const img = ``; 59 | // the image wrapped in its own div 60 | const imgWrapper = `
${img}
`; 61 | // item descriptions 62 | const assetDescriptions = asset.descriptions || []; 63 | // filtered descriptions 64 | const filteredDescriptions = assetDescriptions.filter((description, i) => { 65 | const prev = assetDescriptions[i - 1]; 66 | const prevPrev = assetDescriptions[i - 2]; 67 | const isRepeatingEmptyDescription = Boolean( 68 | isEmpty(prev) && 69 | isEmpty(prevPrev) && 70 | isEmpty(description) 71 | ); 72 | 73 | if (isRepeatingEmptyDescription) { 74 | // this will trim repeating empty descriptions of 3 or more 75 | // e.g. 3+ descriptions in a row with a value of " " 76 | return false; 77 | } 78 | 79 | return true; 80 | }); 81 | // the html for descriptions 82 | const descriptions = filteredDescriptions.map((description) => { 83 | const attributes = description.color ? `style="color: #${description.color}"` : ''; 84 | let value = description.value.trim(); 85 | 86 | if (description.type !== 'html') { 87 | value = escapeHTML(value) || ' '; 88 | } 89 | 90 | return `
${value}
`; 91 | }).join(''); 92 | // the html for this asset 93 | const html = [ 94 | imgWrapper, 95 | header, 96 | type, 97 | descriptions 98 | ].join(''); 99 | 100 | return html; 101 | } 102 | 103 | /** 104 | * Increments the hover state. 105 | */ 106 | export function addToHoverState() { 107 | hoverID += 1; 108 | } 109 | -------------------------------------------------------------------------------- /js/content/helpers/utils.js: -------------------------------------------------------------------------------- 1 | 2 | // utility functions used in content scripts 3 | 4 | /** 5 | * Pick keys from an object. 6 | * @param {Object} object - Object to pick values from. 7 | * @param {string[]} keys - Array of keys to pick. 8 | * @returns {Object} Object with picked keys. 9 | */ 10 | function pickKeys(object, keys) { 11 | let result = {}; 12 | 13 | for (let i = 0; i < keys.length; i++) { 14 | result[keys[i]] = object[keys[i]]; 15 | } 16 | 17 | return result; 18 | } 19 | 20 | /** 21 | * Get a cookie's value. 22 | * @param {string} name - Name of cookie. 23 | * @returns {(string|null)} Value of cookie. 24 | */ 25 | function getCookie(name) { 26 | // unpack pairs 27 | const pairs = document.cookie.split(/;\s*/); 28 | const header = `${name}=`; 29 | 30 | for (let i = 0; i < pairs.length; i++) { 31 | const pair = pairs[i]; 32 | 33 | if (pair.indexOf(header) === 0) { 34 | return pair.substring(header.length, pair.length); 35 | } 36 | } 37 | 38 | return null; 39 | } 40 | 41 | /** 42 | * Collect info from page scripts. 43 | * @param {Object} obj - Object with functions to extract info. 44 | * @returns {Object} Object with values mapped from functions. 45 | */ 46 | function collectInfo(obj) { 47 | const isEmpty = (value) => { 48 | return Boolean( 49 | value === '' || 50 | value == null 51 | ); 52 | }; 53 | const keys = Object.keys(obj); 54 | const scripts = Array.from(document.body.getElementsByTagName('script')); 55 | 56 | // loop over scripts 57 | // adding values collect from each script 58 | return scripts.reduce((result, script) => { 59 | const content = script.textContent; 60 | 61 | keys.forEach((key) => { 62 | const value = obj[key](content); 63 | 64 | if (!isEmpty(value)) { 65 | // remove the key 66 | keys.splice(keys.indexOf(key), 1); 67 | result[key] = value; 68 | } 69 | }); 70 | 71 | return result; 72 | }, {}); 73 | } 74 | 75 | /** 76 | * Get and set settings from chrome.storage. 77 | * @namespace Settings 78 | */ 79 | const Settings = (function() { 80 | const storage = chrome.storage.sync || chrome.storage.local; 81 | 82 | /** 83 | * Store settings. 84 | * @memberOf Settings 85 | * @param {string} key - Settings key. 86 | * @param {Object} data - Data to save. 87 | * @returns {Promise} Promise as a callback. 88 | */ 89 | async function store(key, data) { 90 | return new Promise((resolve) => { 91 | let obj = {}; 92 | 93 | obj[key] = data; 94 | storage.set(obj, resolve); 95 | }); 96 | } 97 | 98 | /** 99 | * Get settings. 100 | * @memberOf Settings 101 | * @param {string} key - Settings key. 102 | * @param {boolean} [noWrapper] - Whether data should not be wrapped by key. 103 | * @returns {Promise} Promise with settings. 104 | */ 105 | async function get(key, noWrapper) { 106 | return new Promise((resolve) => { 107 | storage.get(key, (settings) => { 108 | if (noWrapper && settings) { 109 | settings = settings[key]; 110 | } 111 | 112 | resolve(settings || {}); 113 | }); 114 | }); 115 | } 116 | 117 | /** 118 | * Add to settings. 119 | * @memberOf Settings 120 | * @param {string} key - Settings key. 121 | * @param {Object} data - Data to add. 122 | * @returns {Promise} Promise with settings. 123 | */ 124 | async function addTo(key, data) { 125 | return get(key, true) 126 | .then((settings) => { 127 | settings = Object.assign(settings, data); 128 | 129 | return store(key, settings); 130 | }); 131 | } 132 | 133 | return { 134 | get, 135 | store, 136 | addTo 137 | }; 138 | }()); -------------------------------------------------------------------------------- /js/content/steamcommunity.js: -------------------------------------------------------------------------------- 1 | 2 | // this script collects data about the current user 3 | // we can also see whether the user is logged in or not 4 | 5 | function collectAndStoreInfo() { 6 | function storeLoggedInUser(steamid, data) { 7 | if (steamid && !data.avatar) { 8 | // elements not available on page 9 | delete data.username; 10 | delete data.avatar; 11 | } 12 | 13 | data.steamcommunity_date = new Date().toString(); 14 | // add to current settings, overwriting any overlapping properties 15 | Settings.addTo('logged_in_user', data); 16 | } 17 | 18 | function storeAccountInfo(steamid, data) { 19 | // key includes account's steamid 20 | const key = [steamid, 'accountinfo'].join('_'); 21 | 22 | // add the current date 23 | data.date = new Date().toString(); 24 | // override current settings 25 | Settings.store(key, data); 26 | } 27 | 28 | function getUsername() { 29 | const element = document.querySelector('.responsive_menu_user_area a[data-miniprofile]'); 30 | 31 | if (!element) { 32 | return null; 33 | } 34 | 35 | return element.textContent.trim(); 36 | } 37 | 38 | function getAvatar() { 39 | const element = document.querySelector('.responsive_menu_user_area img'); 40 | 41 | if (!element) { 42 | return null; 43 | } 44 | 45 | return element.getAttribute('src'); 46 | } 47 | 48 | const { steamid, info } = collectInfo({ 49 | steamid(content) { 50 | // Extract the steamid64 from the page 51 | return (content.match(/g_steamID\s*=\s*"(\d{17})";$/m) || [])[1]; 52 | }, 53 | info(content) { 54 | function getWalletInfo(content) { 55 | const match = content.match(/g_rgWalletInfo\s*=\s*({.*});$/m); 56 | const json = match && JSON.parse(match[1]); 57 | 58 | if (!json) { 59 | return null; 60 | } 61 | 62 | // we only want to store these keys 63 | return pickKeys(json, [ 64 | 'wallet_currency', 65 | 'wallet_country', 66 | 'wallet_fee', 67 | 'wallet_fee_minimum', 68 | 'wallet_fee_percent', 69 | 'wallet_publisher_fee_percent_default', 70 | 'wallet_fee_base', 71 | 'wallet_max_balance', 72 | 'wallet_trade_max_balance' 73 | ]); 74 | } 75 | 76 | function getCountry(content) { 77 | return (content.match(/g_strCountryCode\s*=\s*"(\w+)";$/m) || [])[1]; 78 | } 79 | 80 | function getLanguage(content) { 81 | return (content.match(/g_strLanguage\s*=\s*"(\w+)";$/m) || [])[1]; 82 | } 83 | 84 | const wallet = getWalletInfo(content); 85 | const country = getCountry(content); 86 | const language = getLanguage(content); 87 | const hasData = Boolean( 88 | wallet && 89 | country && 90 | language 91 | ); 92 | 93 | if (!hasData) { 94 | return; 95 | } 96 | 97 | // add country and language to wallet object to condense this down 98 | return Object.assign(wallet, { 99 | country, 100 | language 101 | }); 102 | } 103 | }); 104 | const sessionid = getCookie('sessionid'); 105 | const username = getUsername(); 106 | const avatar = getAvatar(); 107 | 108 | storeLoggedInUser(steamid, { 109 | username: username, 110 | avatar: avatar, 111 | steamcommunity: steamid || null, 112 | steamcommunity_sessionid: sessionid 113 | }); 114 | 115 | if (steamid && info) { 116 | storeAccountInfo(steamid, info); 117 | } 118 | } 119 | 120 | collectAndStoreInfo(); 121 | -------------------------------------------------------------------------------- /js/app/layout/listings/buildChart.js: -------------------------------------------------------------------------------- 1 | import { partition, groupBy, arrAverage } from '../../helpers/utils.js'; 2 | import { formatMoney } from '../../money.js'; 3 | 4 | /** 5 | * @typedef {import('../../currency.js').Currency} Currency 6 | * @typedef {import('../../models/Localization.js').Localization} Localization 7 | */ 8 | 9 | /** 10 | * Clusters records by date. 11 | * @param {Object[]} records - Objects to cluster. 12 | * @param {Function} [sum] - Sum function. 13 | * @returns {Object[]} Clustered records. 14 | */ 15 | function cluster(records, sum) { 16 | const groups = groupBy(records, (item) => { 17 | return Math.round(item.date_acted / 1000); 18 | }); 19 | const times = Object.keys(groups); 20 | const total = function(values) { 21 | return values.reduce((a, b) => a + b); 22 | }; 23 | const fn = sum ? total : arrAverage; 24 | 25 | return times.map((time) => { 26 | const dayRecords = groups[time]; 27 | // create new record from first record in group 28 | const record = Object.assign({}, dayRecords[0]); 29 | const values = dayRecords.map(a => a.price); 30 | 31 | // get number of records on that day 32 | record.count = dayRecords.length; 33 | record.price = fn(values); 34 | 35 | return record; 36 | }); 37 | } 38 | 39 | /** 40 | * Builds chart for listings. 41 | * @param {Object[]} records - Records to chart. 42 | * @param {HTMLElement} element - DOM element to render inside. 43 | * @param {Object} options - Options. 44 | * @param {Currency} options.currency - Currency to use for displaying prices. 45 | * @param {Localization} options.locales - Locale strings. 46 | */ 47 | export function buildChart(records, element, options) { 48 | function getPlot(record) { 49 | return { 50 | x: record.date_acted, 51 | y: record.price 52 | }; 53 | } 54 | 55 | function onZoom(_chart, reset) { 56 | resetFn = reset; 57 | } 58 | 59 | function resetZoom() { 60 | if (resetFn) resetFn(); 61 | } 62 | 63 | let resetFn; 64 | const { currency, locales } = options; 65 | const uiLocales = locales.ui; 66 | const keyNames = [1, 0]; 67 | const keys = keyNames.map((name) => uiLocales.values.is_credit[name]); 68 | const classNames = ['sales', 'purchases']; 69 | const split = partition(records, (record) => { 70 | return record.is_credit; 71 | }); 72 | const series = split.map((records, i) => { 73 | return { 74 | name: keys[i], 75 | className: classNames[i], 76 | data: cluster(records).map(getPlot) 77 | }; 78 | }); 79 | const chartData = { 80 | labels: keys, 81 | series: series 82 | }; 83 | const chart = new Chartist.Line(element, chartData, { 84 | showPoint: true, 85 | lineSmooth: false, 86 | chartPadding: 0, 87 | height: 300, 88 | labels: keys, 89 | axisX: { 90 | type: Chartist.FixedScaleAxis, 91 | divisor: 12, 92 | labelInterpolationFnc: function(value) { 93 | return moment(value).format('MMM YYYY'); 94 | } 95 | }, 96 | axisY: { 97 | // should fit most labels 98 | offset: 54, 99 | type: Chartist.AutoScaleAxis, 100 | labelInterpolationFnc: function(value) { 101 | return formatMoney(value, currency); 102 | } 103 | }, 104 | plugins: [ 105 | Chartist.plugins.legend({ 106 | legendNames: keys.filter((_key, i) => { 107 | return split[i].length > 0; 108 | }), 109 | classNames: classNames.filter((_className, i) => { 110 | return split[i].length > 0; 111 | }), 112 | position: 'bottom' 113 | }), 114 | Chartist.plugins.zoom({ 115 | onZoom, 116 | // if set to true, a right click in the zoom area, will reset zoom. 117 | resetOnRightMouseBtn: true 118 | }) 119 | ] 120 | }); 121 | const chartContainer = chart.container; 122 | 123 | chartContainer.addEventListener('dblclick', resetZoom); 124 | } 125 | -------------------------------------------------------------------------------- /views/palette.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Colors - Steam Market History Cataloger 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | 28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | 57 |
58 |
59 |
60 | 61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | 69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | 77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /js/app/db/listing.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // this is the only class tied to the database 4 | import { Listing } from '../models/Listing.js'; 5 | import { Dexie } from '../dexie.js'; 6 | 7 | /** 8 | * Configures database - should be called after locales have been loaded. 9 | * @param {string} steamid - SteamID64 this database would belong to. 10 | * @returns {Object} Configured Dexie instance. 11 | */ 12 | export function createListingDatabase(steamid) { 13 | // uses steamid for db name 14 | const db = new Dexie(`listings${steamid}`); 15 | 16 | db.version(1).stores({ 17 | listings: [ 18 | '&transaction_id', 19 | 'index', 20 | 'sale_type', 21 | 'appid', 22 | 'contextid', 23 | 'assetid', 24 | 'classid', 25 | 'instanceid', 26 | 'name', 27 | 'market_name', 28 | 'market_hash_name', 29 | 'name_color', 30 | 'background_color', 31 | 'icon_url', 32 | 'date_acted', 33 | 'date_listed', 34 | 'date_acted_raw', 35 | 'date_listed_raw', 36 | 'price', 37 | 'price_raw', 38 | 'seller' 39 | ].join(','), 40 | ingame: [ 41 | '&transaction_id', 42 | 'appid', 43 | 'market_name', 44 | 'price', 45 | 'price_raw', 46 | 'date' 47 | ].join(',') 48 | }); 49 | 50 | // Converts sale_type to is_credit 51 | db.version(2).stores({ 52 | listings: [ 53 | '&transaction_id', 54 | 'index', 55 | 'is_credit', 56 | 'appid', 57 | 'contextid', 58 | 'assetid', 59 | 'classid', 60 | 'instanceid', 61 | 'name', 62 | 'market_name', 63 | 'market_hash_name', 64 | 'name_color', 65 | 'background_color', 66 | 'icon_url', 67 | 'date_acted', 68 | 'date_listed', 69 | 'date_acted_raw', 70 | 'date_listed_raw', 71 | 'price', 72 | 'price_raw', 73 | 'seller' 74 | ].join(',') 75 | }).upgrade((trans) => { 76 | return trans.listings.toCollection().modify((record) => { 77 | record.is_credit = record.sale_type == 1 ? 1 : 0; 78 | 79 | delete record.sale_type; 80 | }); 81 | }); 82 | 83 | // Removes seller from listings 84 | db.version(3).stores({ 85 | listings: [ 86 | '&transaction_id', 87 | 'index', 88 | 'is_credit', 89 | 'appid', 90 | 'contextid', 91 | 'assetid', 92 | 'classid', 93 | 'instanceid', 94 | 'name', 95 | 'market_name', 96 | 'market_hash_name', 97 | 'name_color', 98 | 'background_color', 99 | 'icon_url', 100 | 'date_acted', 101 | 'date_listed', 102 | 'date_acted_raw', 103 | 'date_listed_raw', 104 | 'price', 105 | 'price_raw' 106 | ].join(',') 107 | }).upgrade((trans) => { 108 | return trans.listings.toCollection().modify((record) => { 109 | delete record.seller; 110 | }); 111 | }); 112 | 113 | // Adds amount and is_pending to listings 114 | db.version(4).stores({ 115 | listings: [ 116 | '&transaction_id', 117 | 'index', 118 | 'is_credit', 119 | 'appid', 120 | 'contextid', 121 | 'assetid', 122 | 'classid', 123 | 'instanceid', 124 | 'amount', 125 | 'name', 126 | 'market_name', 127 | 'market_hash_name', 128 | 'name_color', 129 | 'background_color', 130 | 'icon_url', 131 | 'date_acted', 132 | 'date_listed', 133 | 'date_acted_raw', 134 | 'date_listed_raw', 135 | 'price', 136 | 'price_raw', 137 | 'is_pending' 138 | ].join(',') 139 | }).upgrade((trans) => { 140 | return trans.listings.toCollection().modify((record) => { 141 | if (record.amount === null || record.amount === undefined) { 142 | // if amount is not set, set it to 1 143 | record.amount = 1; 144 | } 145 | }); 146 | }); 147 | 148 | // @ts-ignore 149 | db.listings.mapToClass(Listing); 150 | 151 | return db; 152 | } -------------------------------------------------------------------------------- /js/content/injects/steamcommunity.market.js: -------------------------------------------------------------------------------- 1 | import { MyBetterCAjaxPagingControls } from '/js/content/modules/MyBetterCAjaxPagingControls.js'; 2 | 3 | (function() { 4 | /** 5 | * Get a URL parameter. 6 | * @param {string} name - Name of parameter. 7 | * @returns {(string|null)} The value of parameter, if found. 8 | */ 9 | function getUrlParam(name) { 10 | return new URL(location.href).searchParams.get(name); 11 | } 12 | 13 | if (getUrlParam('installation')) { 14 | alert('Installation successful!'); 15 | } 16 | 17 | // change pagesize to 100 18 | window.LoadMarketHistory = function() { 19 | if (g_bBusyLoadingMarketHistory) { 20 | return; 21 | } 22 | 23 | const count = 100; 24 | const start = goTo.start || 0; 25 | const page = goTo.page || 0; 26 | const goToTransaction = goTo.id; 27 | 28 | g_bBusyLoadingMarketHistory = true; 29 | new Ajax.Request('https://steamcommunity.com/market/myhistory', { 30 | method: 'get', 31 | parameters: { 32 | count, 33 | start 34 | }, 35 | onSuccess: function(transport) { 36 | if (transport.responseJSON) { 37 | const prefix = 'tabContentsMyMarketHistory'; 38 | const response = transport.responseJSON; 39 | const elMyHistoryContents = $(prefix); 40 | 41 | elMyHistoryContents.innerHTML = response.results_html; 42 | MergeWithAssetArray(response.assets); 43 | eval(response.hovers); 44 | 45 | g_oMyHistory = new MyBetterCAjaxPagingControls({ 46 | query: '', 47 | total_count: response.total_count, 48 | pagesize: response.pagesize, 49 | page: page || 0, 50 | prefix: prefix, 51 | class_prefix: 'market' 52 | }, 'https://steamcommunity.com/market/myhistory/'); 53 | g_oMyHistory.SetResponseHandler(function(response) { 54 | MergeWithAssetArray(response.assets); 55 | eval(response.hovers); 56 | }); 57 | g_oMyHistory.ModifyMarketHistoryContents(response); 58 | 59 | if (goToTransaction) { 60 | g_oMyHistory.AddFilter('transaction_id', goToTransaction); 61 | } 62 | 63 | const total_count = response.total_count; 64 | const bigDifference = Boolean( 65 | total_count && 66 | (response.total_count - total_count) > response.pagesize 67 | ); 68 | const canGoTo = Boolean( 69 | goToTransaction && 70 | bigDifference 71 | ); 72 | 73 | if (canGoTo) { 74 | const start = total_count - goTo.index; 75 | const page = Math.floor(start / response.pagesize); 76 | 77 | g_oMyHistory.GoToPage(page, true); 78 | } else if (goToTransaction) { 79 | g_oMyHistory.UpdateFilter(); 80 | } 81 | } 82 | }, 83 | onComplete: function() { 84 | g_bBusyLoadingMarketHistory = false; 85 | } 86 | }); 87 | }; 88 | 89 | function openHistory() { 90 | const buttonEl = document.getElementById('tabMyMarketHistory'); 91 | const event = new Event('click'); 92 | 93 | buttonEl.dispatchEvent(event); 94 | } 95 | 96 | function getGoToParams() { 97 | const total_count = localStorage.getItem('totalcount') || 0; 98 | const pagesize = localStorage.getItem('pagesize') || 100; 99 | let params = { 100 | index: getUrlParam('index'), 101 | id: getUrlParam('transaction_id') 102 | }; 103 | 104 | if (total_count && params.index) { 105 | params.start = total_count - params.index; 106 | params.page = Math.floor(params.start / pagesize); 107 | } 108 | 109 | return params; 110 | } 111 | 112 | let goTo = getGoToParams(); 113 | 114 | if (goTo.index) { 115 | openHistory(); 116 | } 117 | }()); 118 | -------------------------------------------------------------------------------- /tests/parse.listings.test.js: -------------------------------------------------------------------------------- 1 | import { parseListings } from '../js/app/parsers/parseListings.js'; 2 | import { getCurrency } from '../js/app/currency.js'; 3 | 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | const getLocales = require('./environment/getLocales'); 7 | 8 | const getResponseJSON = (location) => { 9 | const responsePath = path.join(__dirname, location); 10 | const response = fs.readFileSync(responsePath, 'utf8'); 11 | // the json contains 10 completed transactions 12 | const responseJSON = JSON.parse(response); 13 | 14 | return responseJSON; 15 | }; 16 | const jsons = [ 17 | '/fixtures/market/myhistory/response-1.json', 18 | '/fixtures/market/myhistory/response-2.json' 19 | ].map(getResponseJSON); 20 | // USD 21 | const currency = getCurrency(1); 22 | const current = { 23 | date: { 24 | year: 2024, 25 | month: 6 26 | } 27 | }; 28 | let locales; 29 | 30 | beforeAll(async () => { 31 | locales = await getLocales(); 32 | 33 | return; 34 | }); 35 | 36 | it('Localizations are prepared', () => { 37 | expect(locales.language).toBe('english'); 38 | expect(locales.code).toBe('en'); 39 | }); 40 | 41 | it('DOM parser exists', () => { 42 | expect(DOMParser).toBeDefined(); 43 | }); 44 | 45 | it('Parses response data successfully', () => { 46 | const response = jsons[0]; 47 | const { 48 | records 49 | } = parseListings(response, current, currency, locales); 50 | 51 | expect(records).toBeDefined(); 52 | }); 53 | 54 | // These are still useful, but they need to be reworked to use the new responses 55 | 56 | // import { Listing } from '../js/app/models/Listing.js'; 57 | // const listings = JSON.parse(require('fs').readFileSync(__dirname + '/fixtures/listings.json', 'utf8')).items.map(withDate); 58 | // const account = {}; 59 | // const createResponse = require('./helpers/createTestResponse'); 60 | // const withDate = (listing) => { 61 | // [ 62 | // 'date_acted', 63 | // 'date_listed' 64 | // ].forEach((key) => { 65 | // listing[key] = new Date(listing[key]); 66 | // }); 67 | 68 | // delete listing.icon; 69 | 70 | // return new Listing(listing); 71 | // }; 72 | 73 | // it('Cuts off at last transaction id', () => { 74 | // const response = jsons[0]; 75 | // const store = Object.assign({ 76 | // last_fetched: { 77 | // transaction_id: '1933655612887727060-1933655612887727061' 78 | // } 79 | // }, current); 80 | // const { 81 | // records 82 | // } = parseListings(response, store, currency, locales); 83 | 84 | // expect(records.length).toBe(49); 85 | // }); 86 | 87 | // it('Breaks at last indexed transaction id', () => { 88 | // const response = jsons[0]; 89 | // const store = Object.assign({ 90 | // last_indexed: { 91 | // transaction_id: '3106844045478361185-3106844045478361186' 92 | // } 93 | // }, current); 94 | // const { 95 | // records 96 | // } = parseListings(response, store, currency, locales); 97 | 98 | // expect(records.length).toBe(8); 99 | // }); 100 | 101 | // it('Transitions year', () => { 102 | // const { 103 | // dateStore 104 | // } = parseListings(jsons[0], current, currency, locales); 105 | // const next = Object.assign({}, current, { date: dateStore }); 106 | // const { 107 | // records 108 | // } = parseListings(jsons[1], next, currency, locales); 109 | // const record = records[0]; 110 | // const date = record.date_acted; 111 | // const year = date.getFullYear(); 112 | 113 | // expect(year).toBe(2018); 114 | // }); 115 | 116 | // it('Creates a test response', () => { 117 | // const listing = listings[0]; 118 | // const response = createResponse([listing], account); 119 | 120 | // expect(response).toBeDefined(); 121 | // }); 122 | 123 | // it('Parses a test response', () => { 124 | // const listing = listings[0]; 125 | // const response = createResponse([listing], account, { 126 | // total_count: 374999, 127 | // start: 0 128 | // }); 129 | // const store = current; 130 | // const { 131 | // records 132 | // } = parseListings(response, store, currency, locales); 133 | // const record = records[0]; 134 | 135 | // expect(record).toEqual(listing); 136 | // }); 137 | 138 | it('Parses the price correctly', () => { 139 | const response = jsons[0]; 140 | const { 141 | records 142 | } = parseListings(response, current, currency, locales); 143 | const record = records[0]; 144 | 145 | expect(record.price).toBe(10); 146 | }); 147 | -------------------------------------------------------------------------------- /js/views/view/item.js: -------------------------------------------------------------------------------- 1 | import { readyState } from '../../app/readyState.js'; 2 | import * as Layout from '../../app/layout/index.js'; 3 | import { Listing } from '../../app/models/Listing.js'; 4 | import { applist } from '../../app/data/applist.js'; 5 | import { getUrlParam } from '../../app/helpers/utils.js'; 6 | import { buildThirdPartyLinks } from '../../app/layout/listings/external/buildThirdPartyLinks.js'; 7 | import { buildLink } from '../../app/layout/listings/external/buildLink.js'; 8 | import { getPreferences } from '../../app/preferences.js'; 9 | 10 | const page = { 11 | chart: document.getElementById('chart-transactions'), 12 | results: document.getElementById('results'), 13 | title: document.getElementById('item-name'), 14 | startingAt: document.getElementById('starting-at'), 15 | externalLinks: document.getElementById('item-external-links') 16 | }; 17 | // item url query parameters for loading stored listings 18 | let item = { 19 | appid: getUrlParam('appid'), 20 | market_name: getUrlParam('market_name'), 21 | market_hash_name: getUrlParam('market_hash_name') 22 | }; 23 | 24 | async function onApp(app) { 25 | function onRecords(records) { 26 | // newest to oldest 27 | records = records.reverse(); 28 | getThirdPartyLinks(records[0]); 29 | buildChart(records); 30 | buildTable(records); 31 | } 32 | 33 | // gets the item data from the URL parameters 34 | async function getItem() { 35 | // database table 36 | const table = app.ListingDB.listings; 37 | const itemRecords = await table.where('market_hash_name').equals(item.market_hash_name).sortBy('index'); 38 | // filter to appid 39 | const appItemRecords = itemRecords.filter((record) => record.appid == item.appid); 40 | 41 | onRecords(appItemRecords); 42 | } 43 | 44 | function removePlaceHolders() { 45 | let placeHoldersList = page.externalLinks.getElementsByClassName('placeholder'); 46 | 47 | Array.from(placeHoldersList).forEach((el) => { 48 | el.remove(); 49 | }); 50 | } 51 | 52 | // gets links for 3rd party resources based on data from record 53 | function getThirdPartyLinks(record) { 54 | if (record) { 55 | buildThirdPartyLinks.withAsset(record).then((linksList) => { 56 | removePlaceHolders(); 57 | linksList.forEach((el) => { 58 | page.externalLinks.appendChild(el); 59 | }); 60 | }).catch(removePlaceHolders); 61 | } else { 62 | removePlaceHolders(); 63 | } 64 | } 65 | 66 | function buildChart(records) { 67 | const options = Object.assign({}, Layout.getLayoutOptions({ 68 | account, 69 | preferences 70 | }), {}); 71 | 72 | Layout.listings.buildChart(records, page.chart, options); 73 | } 74 | 75 | function buildTable(records) { 76 | const options = Object.assign({}, Layout.getLayoutOptions({ 77 | account, 78 | preferences 79 | }), {}); 80 | const tableEl = Layout.buildTable(records || [], Listing, options); 81 | 82 | Layout.render(page.results, tableEl); 83 | } 84 | 85 | // sets the title of the page 86 | function setTitle() { 87 | const appname = applist[item.appid] || 'Unknown app'; 88 | const titleHTML = ( 89 | `${item.market_name || item.market_hash_name}` + 90 | '/' + 91 | `${appname}` 92 | ); 93 | 94 | page.title.innerHTML = titleHTML; 95 | } 96 | 97 | // adds a link to the market page 98 | function addMarketListingLink() { 99 | const marketListingLinkEl = buildLink({ 100 | title: 'Steam', 101 | url: `https://steamcommunity.com/market/listings/${item.appid}/${item.market_hash_name}` 102 | }); 103 | const placeHoldersList = buildThirdPartyLinks.placeholder(item); 104 | 105 | page.externalLinks.appendChild(marketListingLinkEl); 106 | placeHoldersList.forEach((el) => { 107 | page.externalLinks.appendChild(el); 108 | }); 109 | } 110 | 111 | function render() { 112 | setTitle(); 113 | addMarketListingLink(); 114 | 115 | return getItem(); 116 | } 117 | 118 | const { account } = app; 119 | const preferences = await getPreferences(); 120 | 121 | render().then(Layout.ready); 122 | } 123 | 124 | // ready 125 | { 126 | readyState(onApp, Layout.error); 127 | } 128 | -------------------------------------------------------------------------------- /js/views/load_listings.js: -------------------------------------------------------------------------------- 1 | import { readyState } from '../app/readyState.js'; 2 | import * as Layout from '../app/layout/index.js'; 3 | import { Listing } from '../app/models/Listing.js'; 4 | import { ListingManager } from '../app/manager/ListingManager.js'; 5 | import { sendMessage } from '../app/browser.js'; 6 | import { getPreferences } from '../app/preferences.js'; 7 | 8 | const page = { 9 | results: document.getElementById('results'), 10 | progress: document.getElementById('load-progress'), 11 | progressBar: document.getElementById('load-progress').firstElementChild, 12 | loadDisplay: document.getElementById('load-display'), 13 | buttons: { 14 | getHistory: document.getElementById('get-history') 15 | }, 16 | counts: { 17 | listingCount: document.getElementById('listing-count').querySelector('.value') 18 | } 19 | }; 20 | 21 | async function onApp(app) { 22 | async function render() { 23 | await updateCount(); 24 | // database table 25 | const table = app.ListingDB.listings; 26 | // create table with x newest listings 27 | const mostRecentListings = await table.orderBy('index').limit(10).reverse().toArray(); 28 | 29 | renderTable(mostRecentListings); 30 | 31 | return; 32 | } 33 | 34 | // Updates count of listings. 35 | async function updateCount() { 36 | // get total number of listings in db 37 | const count = await app.ListingDB.listings.count(); 38 | 39 | page.counts.listingCount.textContent = count; 40 | } 41 | 42 | // Renders table of listings. 43 | function renderTable(records) { 44 | const options = Object.assign({}, Layout.getLayoutOptions({ 45 | account, 46 | preferences, 47 | }), { 48 | keep_page: true, 49 | no_download: true 50 | }); 51 | const tableEl = Layout.buildTable(records || [], Listing, options); 52 | 53 | Layout.render(page.results, tableEl); 54 | } 55 | 56 | function onRecords(records) { 57 | total = total.concat(records); 58 | updateCount(); 59 | renderTable(total); 60 | } 61 | 62 | function showProgress(percent) { 63 | Velocity(page.progressBar, { 64 | width: percent + '%' 65 | }, { 66 | duration: 400 67 | }); 68 | } 69 | 70 | // Starting loading listings 71 | function load() { 72 | let hasAlerted = false; 73 | 74 | function loadListings(now) { 75 | function done(error) { 76 | isLoading = false; 77 | page.progress.style.visibility = 'hidden'; 78 | 79 | Layout.alert(error || 'All done!', page.results); 80 | } 81 | 82 | // we've received a response and now want to get more 83 | function getMore({ records, progress }) { 84 | const { step, total } = progress; 85 | const percent = Math.round((step / total) * 10000) / 100; 86 | 87 | showProgress(percent); 88 | onRecords(records); 89 | 90 | // call the load function again 91 | loadListings(); 92 | } 93 | 94 | // make sure this is only called once... 95 | if (!hasAlerted) { 96 | Layout.alert( 97 | 'Loading started! Loading will resume in background if you close this page at any point.', 98 | page.results, 99 | 'active' 100 | ); 101 | hasAlerted = true; 102 | } 103 | 104 | listingManager.load(0, now).then(getMore).catch(done); 105 | } 106 | 107 | listingManager.setup().then(() => { 108 | isLoading = true; 109 | loadListings(true); 110 | }); 111 | } 112 | 113 | function addListeners() { 114 | window.addEventListener('beforeunload', () => { 115 | // if the page is closed while loading is in progress 116 | if (isLoading) { 117 | // continue loading in background 118 | sendMessage({ 119 | name: 'resumeLoading' 120 | }); 121 | } 122 | }); 123 | 124 | page.buttons.getHistory.addEventListener('click', (e) => { 125 | e.target.parentNode.remove(); 126 | load(); 127 | }); 128 | } 129 | 130 | // array that will hold all of our collected records from loading 131 | let total = []; 132 | let isLoading; 133 | const listingManager = new ListingManager(app); 134 | const { account } = app; 135 | const preferences = await getPreferences(); 136 | 137 | addListeners(); 138 | render().then(Layout.ready); 139 | } 140 | 141 | // ready 142 | { 143 | readyState(onApp, Layout.error); 144 | } 145 | -------------------------------------------------------------------------------- /js/app/steam/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getAccountHistory, getClassinfo as getHoverClassinfo, getMarketHome } from './requests/get.js'; 4 | import { AppError } from '../error.js'; 5 | 6 | // this stores assets that were fetched so that we do need to re-fetch them everytime 7 | const assetCache = {}; 8 | 9 | /** 10 | * Steam session. 11 | * @typedef {Object} SteamSession 12 | * @property {string} steamid - Steamid64. 13 | * @property {string} sessionid - Sessionid. 14 | */ 15 | 16 | /** 17 | * Gets Steam session. 18 | * @returns {Promise} Resolves with session details when done, reject on error. 19 | */ 20 | export async function getSteamPoweredSession() { 21 | function parseText(text) { 22 | /** 23 | * Converts a 32-bit account id to steamid64. 24 | * @param {string} accountid - Accountid to convert. 25 | * @returns {string} Steamid64 in string format. 26 | */ 27 | function to64(accountid) { 28 | return (BigInt(accountid) + BigInt('76561197960265728')).toString(); 29 | } 30 | 31 | const sessionid = (text.match(/g_sessionID\s*=\s*"([A-z0-9]+)";$/m) || [])[1]; 32 | const accountid = (text.match(/g_AccountID\s*=\s*(\d+);$/m) || [])[1]; 33 | const steamid = ( 34 | accountid && 35 | to64(accountid) 36 | ); 37 | 38 | return { 39 | steamid, 40 | sessionid 41 | }; 42 | } 43 | 44 | const response = await getAccountHistory(); 45 | 46 | if (!response.ok) { 47 | throw new AppError(response.statusText); 48 | } 49 | 50 | const responseText = await response.text(); 51 | const data = parseText(responseText); 52 | const hasData = Boolean( 53 | data.steamid && 54 | data.sessionid 55 | ); 56 | 57 | if (!hasData) { 58 | throw new AppError( 59 | 'No session detected. You must be logged into ' + 60 | 'https://steampowered.com ' + 61 | 'to check your account\'s purchase history.' 62 | ); 63 | } 64 | 65 | return data; 66 | } 67 | 68 | /** 69 | * Gets class info. 70 | * @param {string} appid - Appid of item. 71 | * @param {string} classid - Classid of item. 72 | * @param {string} instanceid - Instanceid of item. 73 | * @param {string} [language='english'] - Language. 74 | * @returns {Promise} Resolves with asset when done, reject on error. 75 | */ 76 | export async function getClassinfo(appid, classid, instanceid, language = 'english') { 77 | function parseResponseText(text) { 78 | // extract the json for item with pattern... 79 | const match = text.match(/BuildHover\(\s+?\'economy_item_[A-z0-9]+\',\s*?(.*)\s\);/); 80 | 81 | try { 82 | // then parse it 83 | return JSON.parse(match[1].trim()); 84 | } catch { 85 | return null; 86 | } 87 | } 88 | 89 | // Get the cache 90 | const cache = assetCache && 91 | assetCache[appid] && 92 | assetCache[appid][classid] && 93 | assetCache[appid][classid][instanceid] && 94 | assetCache[appid][classid][instanceid][language];; 95 | 96 | if (cache) { 97 | return Promise.resolve(cache); 98 | } 99 | 100 | const response = await getHoverClassinfo(appid, classid, instanceid, language); 101 | 102 | if (!response.ok) { 103 | throw new AppError(response.statusText); 104 | } 105 | 106 | const responseText = await response.text(); 107 | const asset = parseResponseText(responseText); 108 | 109 | if (!asset) { 110 | throw new AppError('Failed to parse asset from response'); 111 | } 112 | 113 | // cache it 114 | // build the object if it does not exist 115 | if (!assetCache[appid]) { 116 | assetCache[appid] = {}; 117 | } 118 | 119 | if (!assetCache[appid][classid]) { 120 | assetCache[appid][classid] = {}; 121 | } 122 | 123 | if (!assetCache[appid][classid][instanceid]) { 124 | assetCache[appid][classid][instanceid] = {}; 125 | } 126 | 127 | // add the asset to the cache 128 | assetCache[appid][classid][instanceid][language] = asset; 129 | 130 | return asset; 131 | } 132 | 133 | /** 134 | * Verifies that we are logged in. 135 | * @returns {Promise} Resolves when done, reject if we are not logged in or there is an error. 136 | */ 137 | export async function verifyLogin() { 138 | const response = await getMarketHome(); 139 | 140 | if (!response.ok) { 141 | throw new AppError(response.statusText); 142 | } 143 | 144 | const responseText = await response.text(); 145 | const isLoggedIn = /g_bLoggedIn = true;/.test(responseText); 146 | 147 | if (!isLoggedIn) { 148 | // and reject with an error that we are not logged in 149 | throw new AppError('Not logged in'); 150 | } 151 | 152 | // everything is alright 153 | return; 154 | } 155 | -------------------------------------------------------------------------------- /js/views/popup.js: -------------------------------------------------------------------------------- 1 | import { readyState } from '../app/readyState.js'; 2 | import { formatLocaleNumber } from '../app/money.js'; 3 | import { ListingManager } from '../app/manager/ListingManager.js'; 4 | import { escapeHTML, truncate } from '../app/helpers/utils.js'; 5 | import { tabs, sendMessage } from '../app/browser.js'; 6 | 7 | const page = { 8 | contents: document.getElementById('contents'), 9 | profile: document.getElementById('user'), 10 | loggedInButtons: document.querySelectorAll('.logged-in'), 11 | buttons: { 12 | loadListings: document.getElementById('load-listings-page-btn'), 13 | loadPurchases: document.getElementById('load-purchases-page-btn'), 14 | view: document.getElementById('view-page-btn'), 15 | viewTotals: document.getElementById('view-totals-page-btn'), 16 | preferences: document.getElementById('preferences-page-btn') 17 | } 18 | }; 19 | 20 | async function onApp(app) { 21 | // add listeners 22 | (function() { 23 | page.buttons.loadListings.addEventListener('click', () => { 24 | tabs.create({ 25 | url: '/views/load_listings.html' 26 | }); 27 | }, false); 28 | 29 | page.buttons.loadPurchases.addEventListener('click', () => { 30 | tabs.create({ 31 | url: '/views/load_purchase_history.html' 32 | }); 33 | }, false); 34 | 35 | page.buttons.view.addEventListener('click', () => { 36 | tabs.create({ 37 | url: '/views/view/index.html' 38 | }); 39 | }); 40 | 41 | page.buttons.viewTotals.addEventListener('click', () => { 42 | tabs.create({ 43 | url: '/views/view/totals.html' 44 | }); 45 | }); 46 | 47 | page.buttons.preferences.addEventListener('click', () => { 48 | tabs.create({ 49 | 'url': '/views/preferences.html' 50 | }); 51 | }); 52 | }()); 53 | 54 | // updates the listing count on the page 55 | (function() { 56 | // clear the listing count 57 | sendMessage({ 58 | name: 'clearListingCount' 59 | }); 60 | }()); 61 | 62 | // changes localization text on page 63 | (function() { 64 | const buttonLocaleKeys = { 65 | loadListings: 'update_listings', 66 | loadPurchases: 'purchase_history', 67 | view: 'view_all', 68 | viewTotals: 'view_totals', 69 | preferences: 'preferences', 70 | updateListings: 'update_listings' 71 | }; 72 | const localeValues = app.account.locales.ui.titles; 73 | const buttons = page.buttons; 74 | 75 | for (let k in buttons) { 76 | const spanEl = buttons[k].querySelector('span'); 77 | const value = localeValues[buttonLocaleKeys[k]]; 78 | 79 | if (spanEl && value) { 80 | spanEl.textContent = value; 81 | } 82 | } 83 | }()); 84 | 85 | try { 86 | // adds details related to account (name, profile picture, listing count) 87 | (function() { 88 | if (!app.account.steamid) { 89 | return; 90 | } 91 | 92 | const { account } = app; 93 | const profileUrl = `https://steamcommunity.com/profiles/${account.steamid}`; 94 | const html = ` 95 | 102 |
103 |
${truncate(escapeHTML(account.username), 24, '...')}
104 |
--- listings
105 |
106 | `; 107 | 108 | page.profile.classList.remove('hidden'); 109 | page.profile.innerHTML = html; 110 | 111 | const profileLinkEl = page.profile.querySelector('a'); 112 | 113 | profileLinkEl.addEventListener('click', () => { 114 | tabs.create({ 115 | 'url': profileUrl 116 | }); 117 | }); 118 | }()); 119 | 120 | // get total number of listings in db 121 | const settings = await new ListingManager(app).getSettings(); 122 | const { account } = app; 123 | const count = settings.recorded_count; 124 | 125 | document.getElementById('listing-count').textContent = `${formatLocaleNumber(count || 0, account.wallet.currency)} listings`; 126 | } catch { 127 | // error 128 | } 129 | } 130 | 131 | // ready 132 | { 133 | readyState(onApp, (error) => { 134 | page.loggedInButtons.forEach((el) => { 135 | el.remove(); 136 | }); 137 | page.profile.innerHTML = `

${escapeHTML(error)}

`; 138 | }); 139 | } 140 | -------------------------------------------------------------------------------- /json/locales/zh-CN/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "months": { 3 | "abbreviations": [] 4 | }, 5 | "ui": { 6 | "tables": { 7 | "empty": "无内容显示。", 8 | "next": "下一个", 9 | "previous": "以前", 10 | "download": "下载" 11 | }, 12 | "names": { 13 | "appname": "应用", 14 | "date": "日期", 15 | "month": "月", 16 | "type": "类型", 17 | "sale": "销售总额", 18 | "sale_count": "销售数量", 19 | "purchase": "购买总计", 20 | "purchase_count": "购买计数", 21 | "name_color": "颜色", 22 | "background_color": "背景颜色", 23 | "name": "名称", 24 | "market_name": "名称", 25 | "market_hash_name": "名称", 26 | "year": "年", 27 | "appid": "应用", 28 | "is_credit": "销售类型", 29 | "index": "指数", 30 | "after_date": "日期之后", 31 | "before_date": "在日期之前", 32 | "last_week": "上个星期", 33 | "last_month": "上个月", 34 | "before": "之前", 35 | "after": "后", 36 | "start": "开始", 37 | "end": "结束" 38 | }, 39 | "values": { 40 | "is_credit": { 41 | "0": "采购", 42 | "1": "拍卖" 43 | }, 44 | "name_color": { 45 | "476291": "蓝色", 46 | "38F3AB": "绿松石", 47 | "4D7455": "绿色", 48 | "7D6D00": "黄色", 49 | "8650AC": "紫色", 50 | "AA0000": "红色", 51 | "CF6A32": "橙子", 52 | "D2D2D2": "灰色", 53 | "FAFAFA": "白色", 54 | "32CD32": "翠", 55 | "ADE55C": "春天的绿色", 56 | "8847FF": "紫色", 57 | "4682B4": "钢蓝" 58 | } 59 | }, 60 | "titles": { 61 | "update": "更新", 62 | "load": "加载", 63 | "annual": "全年", 64 | "app": "应用", 65 | "last_n_days": "最后%s天", 66 | "showing_last_n_days": "显示最近%s天", 67 | "steam_market": "蒸汽市场", 68 | "start_loading": "立即开始加载", 69 | "view_all": "查看全部", 70 | "view_recent": "查看最近", 71 | "view_totals": "查看总计", 72 | "update_listings": "更新列表", 73 | "purchase_history": "购买历史", 74 | "preferences": "喜好", 75 | "monthly": "每月一次" 76 | } 77 | }, 78 | "db": { 79 | "listings": { 80 | "names": { 81 | "transaction_id": "交易ID", 82 | "appid": "应用程序ID", 83 | "contextid": "上下文ID", 84 | "assetid": "资产ID", 85 | "classid": "班级号", 86 | "instanceid": "实例ID", 87 | "price": "价钱", 88 | "is_credit": "信用", 89 | "date_listed": "上市", 90 | "date_acted": "采取行动", 91 | "name_color": "颜色", 92 | "background_color": "背景颜色", 93 | "name": "名称", 94 | "market_name": "名称", 95 | "market_hash_name": "名称", 96 | "index": "指数", 97 | "amount": "金额" 98 | }, 99 | "column_names": { 100 | "transaction_id": "交易ID", 101 | "appid": "应用程序ID", 102 | "contextid": "上下文ID", 103 | "assetid": "资产ID", 104 | "classid": "班级号", 105 | "instanceid": "实例ID", 106 | "price": "价钱", 107 | "is_credit": "​", 108 | "date_listed": "上市", 109 | "date_acted": "采取行动", 110 | "name_color": "颜色", 111 | "background_color": "背景颜色", 112 | "name": "名称", 113 | "market_name": "名称", 114 | "market_hash_name": "名称", 115 | "index": "指数", 116 | "amount": "金额" 117 | } 118 | }, 119 | "accounttransactions": { 120 | "names": { 121 | "transaction_id": "交易ID", 122 | "transaction_type": "类型", 123 | "price": "总", 124 | "is_credit": "信用", 125 | "count": "计数", 126 | "date": "日期", 127 | "amount": "金额" 128 | }, 129 | "column_names": { 130 | "transaction_id": "交易ID", 131 | "transaction_type": "类型", 132 | "price": "总", 133 | "is_credit": "​", 134 | "count": "计数", 135 | "date": "日期", 136 | "amount": "金额" 137 | }, 138 | "identifiers": { 139 | "transaction_type": { 140 | "Market Transaction": 1, 141 | "In-Game Purchase": 2, 142 | "Purchase": 3, 143 | "Gift Purchase": 4, 144 | "Refund": 5 145 | } 146 | } 147 | }, 148 | "gameitems": { 149 | "names": { 150 | "app": "应用", 151 | "name": "名称", 152 | "price": "价钱", 153 | "count": "计数" 154 | } 155 | } 156 | } 157 | } -------------------------------------------------------------------------------- /json/locales/zh-TW/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "months": { 3 | "abbreviations": [] 4 | }, 5 | "ui": { 6 | "tables": { 7 | "empty": "无内容显示。", 8 | "next": "下一个", 9 | "previous": "以前", 10 | "download": "下载" 11 | }, 12 | "names": { 13 | "appname": "应用", 14 | "date": "日期", 15 | "month": "月", 16 | "type": "类型", 17 | "sale": "销售总额", 18 | "sale_count": "销售数量", 19 | "purchase": "购买总计", 20 | "purchase_count": "购买计数", 21 | "name_color": "颜色", 22 | "background_color": "背景颜色", 23 | "name": "名称", 24 | "market_name": "名称", 25 | "market_hash_name": "名称", 26 | "year": "年", 27 | "appid": "应用", 28 | "is_credit": "销售类型", 29 | "index": "指数", 30 | "after_date": "日期之后", 31 | "before_date": "在日期之前", 32 | "last_week": "上个星期", 33 | "last_month": "上个月", 34 | "before": "之前", 35 | "after": "后", 36 | "start": "开始", 37 | "end": "结束" 38 | }, 39 | "values": { 40 | "is_credit": { 41 | "0": "采购", 42 | "1": "拍卖" 43 | }, 44 | "name_color": { 45 | "476291": "蓝色", 46 | "38F3AB": "绿松石", 47 | "4D7455": "绿色", 48 | "7D6D00": "黄色", 49 | "8650AC": "紫色", 50 | "AA0000": "红色", 51 | "CF6A32": "橙子", 52 | "D2D2D2": "灰色", 53 | "FAFAFA": "白色", 54 | "32CD32": "翠", 55 | "ADE55C": "春天的绿色", 56 | "8847FF": "紫色", 57 | "4682B4": "钢蓝" 58 | } 59 | }, 60 | "titles": { 61 | "update": "更新", 62 | "load": "加载", 63 | "annual": "全年", 64 | "app": "应用", 65 | "last_n_days": "最后%s天", 66 | "showing_last_n_days": "显示最近%s天", 67 | "steam_market": "蒸汽市场", 68 | "start_loading": "立即开始加载", 69 | "view_all": "查看全部", 70 | "view_recent": "查看最近", 71 | "view_totals": "查看总计", 72 | "update_listings": "更新列表", 73 | "purchase_history": "购买历史", 74 | "preferences": "喜好", 75 | "monthly": "每月一次" 76 | } 77 | }, 78 | "db": { 79 | "listings": { 80 | "names": { 81 | "transaction_id": "交易ID", 82 | "appid": "应用程序ID", 83 | "contextid": "上下文ID", 84 | "assetid": "资产ID", 85 | "classid": "班级号", 86 | "instanceid": "实例ID", 87 | "price": "价钱", 88 | "is_credit": "信用", 89 | "date_listed": "上市", 90 | "date_acted": "采取行动", 91 | "name_color": "颜色", 92 | "background_color": "背景颜色", 93 | "name": "名称", 94 | "market_name": "名称", 95 | "market_hash_name": "名称", 96 | "index": "指数", 97 | "amount": "金額" 98 | }, 99 | "column_names": { 100 | "transaction_id": "交易ID", 101 | "appid": "应用程序ID", 102 | "contextid": "上下文ID", 103 | "assetid": "资产ID", 104 | "classid": "班级号", 105 | "instanceid": "实例ID", 106 | "price": "价钱", 107 | "is_credit": "​", 108 | "date_listed": "上市", 109 | "date_acted": "采取行动", 110 | "name_color": "颜色", 111 | "background_color": "背景颜色", 112 | "name": "名称", 113 | "market_name": "名称", 114 | "market_hash_name": "名称", 115 | "index": "指数", 116 | "amount": "金額" 117 | } 118 | }, 119 | "accounttransactions": { 120 | "names": { 121 | "transaction_id": "交易ID", 122 | "transaction_type": "类型", 123 | "price": "总", 124 | "is_credit": "信用", 125 | "count": "计数", 126 | "date": "日期", 127 | "amount": "金額" 128 | }, 129 | "column_names": { 130 | "transaction_id": "交易ID", 131 | "transaction_type": "类型", 132 | "price": "总", 133 | "is_credit": "​", 134 | "count": "计数", 135 | "date": "日期", 136 | "amount": "金額" 137 | }, 138 | "identifiers": { 139 | "transaction_type": { 140 | "Market Transaction": 1, 141 | "In-Game Purchase": 2, 142 | "Purchase": 3, 143 | "Gift Purchase": 4, 144 | "Refund": 5 145 | } 146 | } 147 | }, 148 | "gameitems": { 149 | "names": { 150 | "app": "应用", 151 | "name": "名称", 152 | "price": "价钱", 153 | "count": "计数" 154 | } 155 | } 156 | } 157 | } -------------------------------------------------------------------------------- /json/locales/ja/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "months": { 3 | "abbreviations": [] 4 | }, 5 | "ui": { 6 | "tables": { 7 | "empty": "何も映っていない。", 8 | "next": "次", 9 | "previous": "前", 10 | "download": "ダウンロード" 11 | }, 12 | "names": { 13 | "appname": "アプリ", 14 | "date": "日付", 15 | "month": "月", 16 | "type": "タイプ", 17 | "sale": "販売合計", 18 | "sale_count": "セール数", 19 | "purchase": "購入合計", 20 | "purchase_count": "購入数", 21 | "name_color": "色", 22 | "background_color": "背景色", 23 | "name": "名", 24 | "market_name": "名", 25 | "market_hash_name": "名", 26 | "year": "年", 27 | "appid": "アプリ", 28 | "is_credit": "販売タイプ", 29 | "index": "索引", 30 | "after_date": "日付後", 31 | "before_date": "日付の前に", 32 | "last_week": "先週", 33 | "last_month": "先月", 34 | "before": "前", 35 | "after": "後に", 36 | "start": "開始", 37 | "end": "終わり" 38 | }, 39 | "values": { 40 | "is_credit": { 41 | "0": "購入", 42 | "1": "販売" 43 | }, 44 | "name_color": { 45 | "476291": "青", 46 | "38F3AB": "ターコイズ", 47 | "4D7455": "緑", 48 | "7D6D00": "黄", 49 | "8650AC": "紫の", 50 | "AA0000": "赤", 51 | "CF6A32": "オレンジ", 52 | "D2D2D2": "グレー", 53 | "FAFAFA": "白", 54 | "32CD32": "エメラルド", 55 | "ADE55C": "春の緑", 56 | "8847FF": "バイオレット", 57 | "4682B4": "スチールブルー" 58 | } 59 | }, 60 | "titles": { 61 | "update": "更新", 62 | "load": "負荷", 63 | "annual": "年次", 64 | "app": "アプリ", 65 | "last_n_days": "最後の%s日", 66 | "showing_last_n_days": "最近の%s日を表示中", 67 | "steam_market": "蒸気市場", 68 | "start_loading": "今すぐロードを開始", 69 | "view_all": "全て見る", 70 | "view_recent": "最近見ます", 71 | "view_totals": "合計を見る", 72 | "update_listings": "リストを更新する", 73 | "purchase_history": "購入履歴", 74 | "preferences": "設定", 75 | "monthly": "毎月" 76 | } 77 | }, 78 | "db": { 79 | "listings": { 80 | "names": { 81 | "transaction_id": "トランザクションID", 82 | "appid": "アプリID", 83 | "contextid": "コンテキストID", 84 | "assetid": "資産ID", 85 | "classid": "クラスID", 86 | "instanceid": "インスタンスID", 87 | "price": "価格", 88 | "is_credit": "クレジット", 89 | "date_listed": "出品日", 90 | "date_acted": "作用日", 91 | "name_color": "色", 92 | "background_color": "背景色", 93 | "name": "名", 94 | "market_name": "名", 95 | "market_hash_name": "名", 96 | "index": "索引", 97 | "amount": "金額" 98 | }, 99 | "column_names": { 100 | "transaction_id": "トランザクションID", 101 | "appid": "アプリID", 102 | "contextid": "コンテキストID", 103 | "assetid": "資産ID", 104 | "classid": "クラスID", 105 | "instanceid": "インスタンスID", 106 | "price": "価格", 107 | "is_credit": "​", 108 | "date_listed": "出品日", 109 | "date_acted": "に作用", 110 | "name_color": "色", 111 | "background_color": "背景色", 112 | "name": "名", 113 | "market_name": "名", 114 | "market_hash_name": "名", 115 | "index": "索引", 116 | "amount": "金額" 117 | } 118 | }, 119 | "accounttransactions": { 120 | "names": { 121 | "transaction_id": "トランザクションID", 122 | "transaction_type": "タイプ", 123 | "price": "合計", 124 | "is_credit": "クレジット", 125 | "count": "カウント", 126 | "date": "日付", 127 | "amount": "金額" 128 | }, 129 | "column_names": { 130 | "transaction_id": "トランザクションID", 131 | "transaction_type": "タイプ", 132 | "price": "合計", 133 | "is_credit": "​", 134 | "count": "カウント", 135 | "date": "日付", 136 | "amount": "金額" 137 | }, 138 | "identifiers": { 139 | "transaction_type": { 140 | "Market Transaction": 1, 141 | "In-Game Purchase": 2, 142 | "Purchase": 3, 143 | "Gift Purchase": 4, 144 | "Refund": 5 145 | } 146 | } 147 | }, 148 | "gameitems": { 149 | "names": { 150 | "app": "アプリ", 151 | "name": "名", 152 | "price": "価格", 153 | "count": "カウント" 154 | } 155 | } 156 | } 157 | } -------------------------------------------------------------------------------- /js/app/workers/ListingWorker.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // This is intended for use within the background service worker script. 4 | 5 | import { buildApp } from '../app.js'; 6 | import { verifyLogin } from '../steam/index.js'; 7 | import { ListingManager } from '../manager/ListingManager.js'; 8 | import { setLoadState } from '../layout/loadstate.js'; 9 | import { getPreferences } from '../preferences.js'; 10 | import { AppError, ERROR_TYPE } from '../error.js'; 11 | import { getWorkerState, addWorkerState, saveWorkerState } from '../workerState.js'; 12 | 13 | /** 14 | * Used for polling listings. 15 | */ 16 | export class ListingWorker { 17 | /** 18 | * Whether we are currently loading or not. 19 | * @type {boolean} 20 | */ 21 | #isLoading = false; 22 | 23 | /** 24 | * Creates a new listing worker. 25 | */ 26 | constructor() { 27 | 28 | } 29 | 30 | /** 31 | * Starts polling. 32 | * @param {boolean} [force=false] - Whether to force loading. 33 | */ 34 | async start(force = false) { 35 | return this.#checkStateThenLoad(force); 36 | } 37 | 38 | /** 39 | * Gets whether we are currently loading or not. 40 | * @type {boolean} 41 | * @readonly 42 | * @public 43 | */ 44 | get isLoading() { 45 | return this.#isLoading; 46 | } 47 | 48 | /** 49 | * Gets the current poll interval in minutes as defined in preferences. 50 | * @returns {Promise} Resolves with the poll interval in minutes. 51 | */ 52 | async getPollIntervalMinutes() { 53 | const preferences = await getPreferences(); 54 | 55 | return preferences.background_poll_interval_minutes; 56 | } 57 | 58 | /** 59 | * Clears the listing count. 60 | * @returns {Promise} Resolves when done. 61 | */ 62 | async clearListingCount() { 63 | return addWorkerState({ 64 | listing_count: 0 65 | }); 66 | } 67 | 68 | /** 69 | * Gets the listing count. This is the number of listings collected by the worker since the 70 | * last clear. 71 | * 72 | * This is stored in local storage since service workers are not meant for storing long- 73 | * running state. 74 | * @returns {Promise} Resolves with the listing count. 75 | */ 76 | async getListingCount() { 77 | const { listing_count } = await getWorkerState(); 78 | 79 | return listing_count; 80 | } 81 | 82 | /** 83 | * Increments the listing count. 84 | * @param {number} count - Number to increment by. 85 | * @returns {Promise} Resolves when done. 86 | */ 87 | async incrementListingCount(count) { 88 | const state = await getWorkerState(); 89 | 90 | state.listing_count += count; 91 | return saveWorkerState(state); 92 | } 93 | 94 | /** 95 | * Loads listings. 96 | * @returns {Promise} Resolves with listing count when done. 97 | */ 98 | async #load() { 99 | await verifyLogin(); 100 | // will return app to create listing manager 101 | const app = await buildApp(); 102 | // creates the listing manager 103 | const listingManager = new ListingManager(app); 104 | // we're done 105 | const loadListingsDone = async (error) => { 106 | if (this.#isLoading) { 107 | this.#updateLoadState(false); 108 | } 109 | 110 | if (error.name != ERROR_TYPE.APP_SUCCESS_ERROR) { 111 | console.warn('Error loading listings:', error.message); 112 | return Promise.reject(error); 113 | } 114 | 115 | // return the current count 116 | return this.getListingCount(); 117 | }; 118 | // we've received a response and now want to get more 119 | const getMore = async ({ records }) => { 120 | await this.incrementListingCount(records.length); 121 | 122 | // call the load function again 123 | return loadListings(); 124 | }; 125 | const loadListings = async () => { 126 | return listingManager.load() 127 | .then(getMore) 128 | .catch(loadListingsDone); 129 | }; 130 | 131 | this.#updateLoadState(true); 132 | await listingManager.setup(); 133 | 134 | return loadListings(); 135 | } 136 | 137 | /** 138 | * Checks the current state of the application then loads if everything is OK. 139 | * @param {boolean} [force=false] - Whether to force loading. 140 | * @returns {Promise} Resolves when done. 141 | */ 142 | async #checkStateThenLoad(force = false) { 143 | // already loading 144 | if (this.#isLoading) { 145 | throw new AppError('Already loading listings.'); 146 | } 147 | 148 | const preferences = await getPreferences(); 149 | 150 | if (!force && !preferences.background_poll_boolean) { 151 | throw new AppError('Background polling is disabled.'); 152 | } 153 | 154 | await this.#load(); 155 | } 156 | 157 | /** 158 | * Sets the loading state. 159 | * @param {boolean} loading - Whether we are loading or not. 160 | */ 161 | #updateLoadState(loading) { 162 | this.#isLoading = loading; 163 | setLoadState(loading); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /tests/money.test.js: -------------------------------------------------------------------------------- 1 | import { getCurrency } from '../js/app/currency.js'; 2 | import { ECurrencyCode } from '../js/app/enums/ECurrencyCode.js'; 3 | import { 4 | parseMoney, 5 | formatMoney, 6 | formatLocaleNumber, 7 | toDecimal 8 | } from '../js/app/money.js'; 9 | 10 | it('Gets a currency', () => { 11 | const currency = getCurrency(ECurrencyCode.USD); 12 | 13 | expect(currency).toBeDefined(); 14 | }); 15 | 16 | it('Converts money integer to decimal', () => { 17 | const currency = getCurrency(ECurrencyCode.USD); 18 | const value = 100; 19 | const converted = toDecimal(value, currency.precision); 20 | 21 | expect(converted).toBe(1.00); 22 | }); 23 | 24 | it('Formats money value', () => { 25 | const currency = getCurrency(ECurrencyCode.USD); 26 | const value = 100000; 27 | const formatted = formatMoney(value, currency); 28 | 29 | expect(formatted).toBe('$1,000.00'); 30 | }); 31 | 32 | it('Formats money value in Euros', () => { 33 | const currency = getCurrency(ECurrencyCode.EUR); 34 | const value = 100000; 35 | const formatted = formatMoney(value, currency); 36 | 37 | expect(formatted).toBe('1 000,00€'); 38 | }); 39 | 40 | it('Parses money value in Euros', () => { 41 | const currency = getCurrency(ECurrencyCode.EUR); 42 | const value = '99,--€'; 43 | const parsed = parseMoney(value, currency); 44 | 45 | expect(parsed).toBe(9900); 46 | }); 47 | 48 | it('Parses money value in Russian rubles', () => { 49 | const currency = getCurrency(ECurrencyCode.RUB); 50 | const value = '483,34 pуб.'; 51 | const parsed = parseMoney(value, currency); 52 | 53 | expect(parsed).toBe(48334); 54 | }); 55 | 56 | it('Parses money value in Ukrainian hryvnia', () => { 57 | const currency = getCurrency(ECurrencyCode.UAH); 58 | const value = '244₴'; 59 | const parsed = parseMoney(value, currency); 60 | 61 | expect(parsed).toBe(24400); 62 | }); 63 | 64 | it('Parses money value in Canadian dollars', () => { 65 | const currency = getCurrency(ECurrencyCode.CAD); 66 | const value = 'CDN$ 6.99'; 67 | const parsed = parseMoney(value, currency); 68 | 69 | expect(parsed).toBe(699); 70 | }); 71 | 72 | it('Parses money value in Peruvian sol', () => { 73 | const currency = getCurrency(ECurrencyCode.PEN); 74 | const value = 'S/.23.00'; 75 | const parsed = parseMoney(value, currency); 76 | 77 | expect(parsed).toBe(2300); 78 | }); 79 | 80 | it('Parses money value in Brazilian real', () => { 81 | const currency = getCurrency(ECurrencyCode.BRL); 82 | const value = 'R$ 37,00'; 83 | const parsed = parseMoney(value, currency); 84 | 85 | expect(parsed).toBe(3700); 86 | }); 87 | 88 | it('Parses money value in Indian rupees', () => { 89 | const currency = getCurrency(ECurrencyCode.INR); 90 | const value = '₹ 690'; 91 | const parsed = parseMoney(value, currency); 92 | 93 | expect(parsed).toBe(69000); 94 | }); 95 | 96 | it('Parses money value in Japanese yen', () => { 97 | const currency = getCurrency(ECurrencyCode.JPY); 98 | const value = '¥ 3,657'; 99 | const parsed = parseMoney(value, currency); 100 | 101 | expect(parsed).toBe(365700); 102 | }); 103 | 104 | it('Parses money value in Chilean pesos', () => { 105 | const currency = getCurrency(ECurrencyCode.CLP); 106 | const value = 'CLP$ 34.500'; 107 | const parsed = parseMoney(value, currency); 108 | 109 | expect(parsed).toBe(3450000); 110 | }); 111 | 112 | it('Parses money value in Kazakhstani tenges', () => { 113 | const currency = getCurrency(ECurrencyCode.KZT); 114 | const value = '4 399,99₸'; 115 | const parsed = parseMoney(value, currency); 116 | 117 | expect(parsed).toBe(439999); 118 | }); 119 | 120 | it('Parses money value in Polish złoty', () => { 121 | const currency = getCurrency(ECurrencyCode.KZT); 122 | const value = '29,00zł'; 123 | const parsed = parseMoney(value, currency); 124 | 125 | expect(parsed).toBe(2900); 126 | }); 127 | 128 | it('Formats money value with currency symbol after number', () => { 129 | const currency = Object.assign({}, getCurrency(ECurrencyCode.USD), { 130 | after: true 131 | }); 132 | const value = 100; 133 | const formatted = formatMoney(value, currency); 134 | 135 | expect(formatted).toBe('1.00$'); 136 | }); 137 | 138 | it('Formats money value with spacer', () => { 139 | const currency = Object.assign({}, getCurrency(ECurrencyCode.USD), { 140 | spacer: true 141 | }); 142 | const value = 100; 143 | const formatted = formatMoney(value, currency); 144 | 145 | expect(formatted).toBe('$ 1.00'); 146 | }); 147 | 148 | it('Formats money value with trailing zeros trimmed', () => { 149 | const currency = Object.assign({}, getCurrency(ECurrencyCode.USD), { 150 | trim_trailing: true 151 | }); 152 | const value = 100; 153 | const formatted = formatMoney(value, currency); 154 | 155 | expect(formatted).toBe('$1'); 156 | }); 157 | 158 | it('Formats locale number', () => { 159 | const currency = getCurrency(ECurrencyCode.USD); 160 | const value = 100; 161 | const formatted = formatLocaleNumber(value, currency); 162 | 163 | expect(formatted).toBe('100'); 164 | }); 165 | 166 | it('Parses money value', () => { 167 | const currency = getCurrency(ECurrencyCode.USD); 168 | const value = '$1.00'; 169 | const parsed = parseMoney(value, currency); 170 | 171 | expect(parsed).toBe(100); 172 | }); 173 | 174 | -------------------------------------------------------------------------------- /json/locales/ko/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "months": { 3 | "abbreviations": [ 4 | "Jan", 5 | "Feb", 6 | "Mar", 7 | "Apr", 8 | "May", 9 | "June|Jun", 10 | "July|Jul", 11 | "Aug", 12 | "Sept", 13 | "Oct", 14 | "Nov", 15 | "Dec" 16 | ] 17 | }, 18 | "ui": { 19 | "tables": { 20 | "empty": "표시 할 내용이 없습니다.", 21 | "next": "다음 것", 22 | "previous": "너무 이른", 23 | "download": "다운로드" 24 | }, 25 | "names": { 26 | "appname": "앱", 27 | "date": "날짜", 28 | "month": "달", 29 | "type": "유형", 30 | "sale": "판매 합계", 31 | "sale_count": "판매 횟수", 32 | "purchase": "구매 합계", 33 | "purchase_count": "구매 횟수", 34 | "name_color": "색깔", 35 | "background_color": "배경색", 36 | "name": "이름", 37 | "market_name": "이름", 38 | "market_hash_name": "이름", 39 | "year": "년", 40 | "appid": "앱", 41 | "is_credit": "판매 유형", 42 | "index": "색인", 43 | "after_date": "날짜 이후", 44 | "before_date": "이전 날짜", 45 | "last_week": "지난주", 46 | "last_month": "지난 달", 47 | "before": "전에", 48 | "after": "후", 49 | "start": "스타트", 50 | "end": "종료" 51 | }, 52 | "values": { 53 | "is_credit": { 54 | "0": "매수", 55 | "1": "판매" 56 | }, 57 | "name_color": { 58 | "476291": "푸른", 59 | "38F3AB": "터키 옥", 60 | "4D7455": "녹색", 61 | "7D6D00": "노랑", 62 | "8650AC": "자", 63 | "AA0000": "빨간", 64 | "CF6A32": "주황색", 65 | "D2D2D2": "회색", 66 | "FAFAFA": "화이트", 67 | "32CD32": "에메랄드", 68 | "ADE55C": "봄 그린", 69 | "8847FF": "제비꽃", 70 | "4682B4": "스틸 블루" 71 | } 72 | }, 73 | "titles": { 74 | "update": "최신 정보", 75 | "load": "하중", 76 | "annual": "일년생 식물", 77 | "app": "앱", 78 | "last_n_days": "지난 % s 일", 79 | "showing_last_n_days": "지난 % s 일 표시", 80 | "steam_market": "증기 시장", 81 | "start_loading": "지금로드 시작", 82 | "view_all": "모두보기", 83 | "view_recent": "최근보기", 84 | "view_totals": "총계보기", 85 | "update_listings": "목록 업데이트", 86 | "purchase_history": "구매 내역", 87 | "preferences": "환경 설정", 88 | "monthly": "월간 간행물" 89 | } 90 | }, 91 | "db": { 92 | "listings": { 93 | "names": { 94 | "transaction_id": "거래 ID", 95 | "appid": "앱 ID", 96 | "contextid": "컨텍스트 ID", 97 | "assetid": "애셋 ID", 98 | "classid": "클래스 ID", 99 | "instanceid": "인스턴스 ID", 100 | "price": "가격", 101 | "is_credit": "신용", 102 | "date_listed": "상장 된", 103 | "date_acted": "위에 행동 한", 104 | "name_color": "색깔", 105 | "background_color": "배경색", 106 | "name": "이름", 107 | "market_name": "이름", 108 | "market_hash_name": "이름", 109 | "index": "색인", 110 | "amount": "금액" 111 | }, 112 | "column_names": { 113 | "transaction_id": "거래 ID", 114 | "appid": "앱 ID", 115 | "contextid": "컨텍스트 ID", 116 | "assetid": "애셋 ID", 117 | "classid": "클래스 ID", 118 | "instanceid": "인스턴스 ID", 119 | "price": "가격", 120 | "is_credit": "​", 121 | "date_listed": "상장 된", 122 | "date_acted": "위에 행동 한", 123 | "name_color": "색깔", 124 | "background_color": "배경색", 125 | "name": "이름", 126 | "market_name": "이름", 127 | "market_hash_name": "이름", 128 | "index": "색인", 129 | "amount": "금액" 130 | } 131 | }, 132 | "accounttransactions": { 133 | "names": { 134 | "transaction_id": "거래 ID", 135 | "transaction_type": "유형", 136 | "price": "합계", 137 | "is_credit": "신용", 138 | "count": "카운트", 139 | "date": "날짜", 140 | "amount": "금액" 141 | }, 142 | "column_names": { 143 | "transaction_id": "거래 ID", 144 | "transaction_type": "유형", 145 | "price": "합계", 146 | "is_credit": "​", 147 | "count": "카운트", 148 | "date": "날짜", 149 | "amount": "금액" 150 | }, 151 | "identifiers": { 152 | "transaction_type": { 153 | "Market Transaction": 1, 154 | "In-Game Purchase": 2, 155 | "Purchase": 3, 156 | "Gift Purchase": 4, 157 | "Refund": 5 158 | } 159 | } 160 | }, 161 | "gameitems": { 162 | "names": { 163 | "app": "앱", 164 | "name": "이름", 165 | "price": "가격", 166 | "count": "카운트" 167 | } 168 | } 169 | } 170 | } -------------------------------------------------------------------------------- /json/locales/fi/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "months": { 3 | "abbreviations": [] 4 | }, 5 | "ui": { 6 | "tables": { 7 | "empty": "Ei mitään esitettävää.", 8 | "next": "Seuraava", 9 | "previous": "Edellinen", 10 | "download": "ladata" 11 | }, 12 | "names": { 13 | "appname": "Sovellus", 14 | "date": "Treffi", 15 | "month": "Kuukausi", 16 | "type": "Tyyppi", 17 | "sale": "Myynti yhteensä", 18 | "sale_count": "Myynnin määrä", 19 | "purchase": "Ostot yhteensä", 20 | "purchase_count": "Ostoluku", 21 | "name_color": "Väri", 22 | "background_color": "Taustaväri", 23 | "name": "Nimi", 24 | "market_name": "Nimi", 25 | "market_hash_name": "Nimi", 26 | "year": "vuosi", 27 | "appid": "Sovellus", 28 | "is_credit": "Myyntityyppi", 29 | "index": "Indeksi", 30 | "after_date": "Päivämäärän jälkeen", 31 | "before_date": "Ennen päivämäärää", 32 | "last_week": "Viime viikko", 33 | "last_month": "Viime kuukausi", 34 | "before": "Ennen", 35 | "after": "Jälkeen", 36 | "start": "alkaa", 37 | "end": "pää" 38 | }, 39 | "values": { 40 | "is_credit": { 41 | "0": "Ostaa", 42 | "1": "myynti" 43 | }, 44 | "name_color": { 45 | "476291": "Sininen", 46 | "38F3AB": "Turkoosi", 47 | "4D7455": "Vihreä", 48 | "7D6D00": "Keltainen", 49 | "8650AC": "Violetti", 50 | "AA0000": "Punainen", 51 | "CF6A32": "Oranssi", 52 | "D2D2D2": "Harmaa", 53 | "FAFAFA": "Valkoinen", 54 | "32CD32": "Smaragdi", 55 | "ADE55C": "Keväänvihreä", 56 | "8847FF": "Violetti", 57 | "4682B4": "Teräs sininen" 58 | } 59 | }, 60 | "titles": { 61 | "update": "Päivittää", 62 | "load": "Ladata", 63 | "annual": "vuotuinen", 64 | "app": "Sovellus", 65 | "last_n_days": "Viimeiset% s päivää", 66 | "showing_last_n_days": "Näytetään viimeiset% s päivää", 67 | "steam_market": "Höyrymarkkinat", 68 | "start_loading": "Aloita lataaminen nyt", 69 | "view_all": "Näytä kaikki", 70 | "view_recent": "Näytä viimeisin", 71 | "view_totals": "Näytä yhteensä", 72 | "update_listings": "Päivitä luettelot", 73 | "purchase_history": "Ostohistoria", 74 | "preferences": "Asetukset", 75 | "monthly": "Kuukausittain" 76 | } 77 | }, 78 | "db": { 79 | "listings": { 80 | "names": { 81 | "transaction_id": "Transaktiotunnus", 82 | "appid": "Sovelluksen tunnus", 83 | "contextid": "Kontekstitunnus", 84 | "assetid": "Omaisuuden tunnus", 85 | "classid": "Luokan tunnus", 86 | "instanceid": "Esimerkkitunnus", 87 | "price": "Hinta", 88 | "is_credit": "Luotto", 89 | "date_listed": "Listattu päällä", 90 | "date_acted": "Toiminut", 91 | "name_color": "Väri", 92 | "background_color": "Taustaväri", 93 | "name": "Nimi", 94 | "market_name": "Nimi", 95 | "market_hash_name": "Nimi", 96 | "index": "Indeksi", 97 | "amount": "Määrä" 98 | }, 99 | "column_names": { 100 | "transaction_id": "Transaktiotunnus", 101 | "appid": "Sovelluksen tunnus", 102 | "contextid": "Kontekstitunnus", 103 | "assetid": "Omaisuuden tunnus", 104 | "classid": "Luokan tunnus", 105 | "instanceid": "Esimerkkitunnus", 106 | "price": "Hinta", 107 | "is_credit": "​", 108 | "date_listed": "Listattu päällä", 109 | "date_acted": "Toiminut", 110 | "name_color": "Väri", 111 | "background_color": "Taustaväri", 112 | "name": "Nimi", 113 | "market_name": "Nimi", 114 | "market_hash_name": "Nimi", 115 | "index": "Indeksi", 116 | "amount": "Määrä" 117 | } 118 | }, 119 | "accounttransactions": { 120 | "names": { 121 | "transaction_id": "Transaktiotunnus", 122 | "transaction_type": "Tyyppi", 123 | "price": "Kaikki yhteensä", 124 | "is_credit": "Luotto", 125 | "count": "Kreivi", 126 | "date": "Treffi", 127 | "amount": "Määrä" 128 | }, 129 | "column_names": { 130 | "transaction_id": "Transaktiotunnus", 131 | "transaction_type": "Tyyppi", 132 | "price": "Kaikki yhteensä", 133 | "is_credit": "​", 134 | "count": "Kreivi", 135 | "date": "Treffi", 136 | "amount": "Määrä" 137 | }, 138 | "identifiers": { 139 | "transaction_type": { 140 | "Market Transaction": 1, 141 | "In-Game Purchase": 2, 142 | "Purchase": 3, 143 | "Gift Purchase": 4, 144 | "Refund": 5 145 | } 146 | } 147 | }, 148 | "gameitems": { 149 | "names": { 150 | "app": "Sovellus", 151 | "name": "Nimi", 152 | "price": "Hinta", 153 | "count": "Kreivi" 154 | } 155 | } 156 | } 157 | } -------------------------------------------------------------------------------- /js/content/modules/ListingFiltering.js: -------------------------------------------------------------------------------- 1 | import { getCurrency } from '/js/app/currency.js'; 2 | import { parseMoney } from '/js/app/money.js'; 3 | 4 | function ListingFiltering(currency_id) { 5 | this.store = { 6 | game: {}, 7 | listings: {}, 8 | total: [] 9 | }; 10 | this.current = []; 11 | this.options = {}; 12 | 13 | // currency will usually not be available when logged out, 14 | // in that case the currency is not really necessary 15 | if (currency_id) { 16 | this.currency = getCurrency(currency_id); 17 | } 18 | } 19 | 20 | ListingFiltering.prototype.updateIndex = function(listingsList) { 21 | this.current = Array.from(listingsList).map((listingEl) => { 22 | return getListingData(listingEl, this.currency); 23 | }); 24 | this.current.filter((data) => { 25 | return !this.store.total[data.transaction_id]; 26 | }).forEach((data) => { 27 | const { 28 | game, 29 | item_type, 30 | sale_type, 31 | price, 32 | transaction_id 33 | } = data; 34 | const store = this.store; 35 | 36 | if (game && item_type && sale_type) { 37 | if (!store.listings[game]) { 38 | store.listings[game] = {}; 39 | } 40 | 41 | if (!store.listings[game][item_type]) { 42 | store.listings[game][item_type] = {}; 43 | } 44 | 45 | if (!store.listings[game][item_type][sale_type]) { 46 | store.listings[game][item_type][sale_type] = { 47 | price: price, 48 | quantity: 1 49 | }; 50 | } else { 51 | store.listings[game][item_type][sale_type].price += price; 52 | store.listings[game][item_type][sale_type].quantity += 1; 53 | } 54 | 55 | if (!store.game) { 56 | store.game[game] = 1; 57 | } else { 58 | store.game[game] += 1; 59 | } 60 | } 61 | 62 | // add to total 63 | store.total[transaction_id] = data; 64 | }); 65 | }; 66 | 67 | ListingFiltering.prototype.update = function(contentsEl) { 68 | const fragment = document.createDocumentFragment(); 69 | const options = this.options; 70 | 71 | this.current.forEach((listing) => { 72 | const keys = Object.keys(options); 73 | const isFiltered = keys.all((k) => { 74 | return listing[k] === options[k]; 75 | }); 76 | 77 | if (isFiltered) { 78 | fragment.appendChild(listing.el); 79 | } 80 | }); 81 | 82 | contentsEl.innerHTML = ''; 83 | contentsEl.appendChild(fragment); 84 | }; 85 | 86 | ListingFiltering.prototype.clear = function() { 87 | this.options = {}; 88 | }; 89 | 90 | ListingFiltering.prototype.hasOptions = function() { 91 | return Object.keys(this.options).length > 0; 92 | }; 93 | 94 | ListingFiltering.prototype.setFilter = function(key, val) { 95 | if (this.options[key] === val) { 96 | delete this.options[key]; 97 | } else { 98 | this.options[key] = val; 99 | } 100 | }; 101 | 102 | ListingFiltering.prototype.getListingValue = function(listingEl, key) { 103 | let listing = this.store.total[getTransactionId(listingEl)]; 104 | let value = listing && listing[key]; 105 | 106 | return value; 107 | }; 108 | 109 | /** 110 | * Get 111 | * @param {Object} listingEl - Listing element 112 | * @returns {string} Transaction ID of listing 113 | */ 114 | function getTransactionId(listingEl) { 115 | return listingEl.id.replace('history_row_', '').replace('_', '-'); 116 | } 117 | 118 | function getListingData(listingEl, currency) { 119 | const getPrice = (text) => { 120 | return parseMoney(text, currency); 121 | }; 122 | const getSaleType = (text) => { 123 | return { 124 | '-': 1, 125 | '+': 2 126 | }[text]; 127 | }; 128 | // get the associated elements for this listing 129 | const itemImgEl = listingEl.getElementsByClassName('market_listing_item_img')[0]; 130 | const gainOrLossEl = listingEl.getElementsByClassName('market_listing_gainorloss')[0]; 131 | const priceEl = listingEl.getElementsByClassName('market_listing_price')[0]; 132 | const gameNameEl = listingEl.getElementsByClassName('market_listing_game_name')[0]; 133 | const itemNameEl = listingEl.getElementsByClassName('market_listing_item_name')[0]; 134 | // item color 135 | const color = rgb2hex(itemImgEl.style.borderColor || '0'); 136 | 137 | return { 138 | el: listingEl, 139 | transaction_id: getTransactionId(listingEl), 140 | game: gameNameEl.textContent.trim(), 141 | market_name: itemNameEl.textContent.trim(), 142 | sale_type: getSaleType(gainOrLossEl.textContent.trim()), 143 | price: getPrice(priceEl.textContent), 144 | color: color, 145 | item_type: color 146 | }; 147 | } 148 | 149 | /** 150 | * Convert "rgba(0, 0, 0)" string to hex. 151 | * @param {number} str - Rgba string. 152 | * @returns {(string|null)} Hexadecimal number. 153 | */ 154 | function rgb2hex(str) { 155 | // rgba(0, 0, 0) 156 | const match = str.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*/i); 157 | 158 | if (match) { 159 | /** 160 | * Convert a decimal number to a hexadecimal number in a 2-digit format. 161 | * @param {number} decimal - Decimal number. 162 | * @returns {string} Hexadecimal number. 163 | */ 164 | const toHex = (decimal) => { 165 | return ('0' + decimal.toString(16).toUpperCase()).slice(-2); 166 | }; 167 | const colors = match.slice(1); 168 | const hex = colors.map(a => parseInt(a)).map(toHex).join(''); 169 | 170 | return hex; 171 | } else { 172 | return null; 173 | } 174 | } 175 | 176 | export { ListingFiltering }; 177 | --------------------------------------------------------------------------------