diff --git a/.gitattributes b/.gitattributes index dfe0770..413a1e2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ -# Auto detect text files and perform LF normalization -* text=auto +# Normalize all text files to LF in the repository and the working tree +* text=auto eol=lf + +# Windows batch scripts must keep CRLF to run correctly +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/bin/cli.js b/bin/cli.js index f22a917..38ebb3e 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -38,6 +38,10 @@ program .action(async (options, command) => { // Build the config entirely from flags when config-building flags are used (and no explicit --config file) if (!options.config && usedBuildingFlags(command)) { + // Flags take precedence over a config.json; warn if one is present so its silent omission is never a surprise + if (fs.existsSync('config.json')) { + console.warn('Warning: building flags were provided, so the "config.json" in this directory is being ignored. Pass --config config.json to use that file instead, or drop the building flags.'); + } const config = buildConfig(options); validateConfig(config); if (options.saveConfig) { diff --git a/config.schema.json b/config.schema.json index c9b06c6..20ef64e 100644 --- a/config.schema.json +++ b/config.schema.json @@ -352,7 +352,7 @@ "oneOf": [ { "const": "array", - "title": "The resulting data structure is an array with its entries being dictionaries holding separate game's proeprties." + "title": "The resulting data structure is an array with its entries being dictionaries holding separate game's properties." }, { "const": "productTitle", @@ -477,46 +477,55 @@ "Screenshot": { "description": "In-game screenshots. 16:9 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.4677.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.4344f692-1744-4c18-8024-270fd320f63c", "type": "integer", + "minimum": -1, "default": -1 }, "TitledHeroArt": { "description": "Banner featuring the game's name. 16:9 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.12688.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.ef4d4f2f-1865-4fa7-8ec7-8b914cd4dcc0", "type": "integer", + "minimum": -1, "default": -1 }, "Poster": { "description": "Banner featuring the game's name in portrait mode, such as for smartphones. 2:3 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.64810.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.fdefe49f-270c-44e5-b660-6d7764b37f0f", "type": "integer", + "minimum": -1, "default": -1 }, "SuperHeroArt": { "description": "Artwork without text. 16:9 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.62159.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.1405eb3a-6314-4e44-a822-7660d70a6ec5", "type": "integer", + "minimum": -1, "default": -1 }, "Hero": { "description": "Artwork without text. 2:1 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.28129.13672427983916579.274b1ffd-9cde-4bef-9a3e-6f37073d5ed0.5eb8b4f3-3575-4d13-b0a4-b60d6c64f392", "type": "integer", + "minimum": -1, "default": -1 }, "BoxArt": { "description": "Banner featuring the game's logo and name in a square \"box\" format. 1:1 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.4794.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.b156af1e-9796-48af-8d11-3461727280ea", "type": "integer", + "minimum": -1, "default": -1 }, "BrandedKeyArt": { "description": "Banner featuring the game's name with an \"XBOX\" logo on top. 73:100 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.27624.68326442227858632.21f49c7b-79d7-4647-b847-ecc7a34a7901.1aa31c66-2a52-45d6-8fed-badfb9f25ac6", "type": "integer", + "minimum": -1, "default": -1 }, "FeaturePromotionalSquareArt": { "description": "Banner featuring the game's logo (without name) in a square \"box\" format. 1:1 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.29819.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.322d4aa6-0a23-4565-a64f-743f0620a96e", "type": "integer", + "minimum": -1, "default": -1 }, "Logo": { "description": "Small, square game logo, to be used e.g. as a game library icon. 1:1 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.65119.13664397958929388.0e87ac81-8aa3-41f0-82dc-61a295fc5fe3.44bf032a-b113-4179-aa1b-f557dbcd3b19", "type": "integer", + "minimum": -1, "default": -1 } }, diff --git a/js/gamePass.js b/js/gamePass.js index 3795d2a..90ca22b 100644 --- a/js/gamePass.js +++ b/js/gamePass.js @@ -36,7 +36,7 @@ export async function run(config, { fromDirectory } = {}) { } } - const failures = (await Promise.all(tasks)).filter((result) => result && result.failed).length; + const failures = (await Promise.all(tasks)).filter((result) => result?.failed === true).length; if (failures > 0) { console.error(`\n${failures} of ${tasks.length} task(s) failed. See the errors above.`); process.exit(1); @@ -128,6 +128,9 @@ async function fetchGameProperties(gameIds, passType, market) { throw new Error(`Could not parse the Microsoft display catalog API response as JSON: ${error.message ?? error}`); } + if (data.Products !== undefined && !Array.isArray(data.Products)) { + throw new Error(`The Microsoft display catalog API returned an unexpected (non-array) Products field for ${passType} in market "${market}".`); + } products.push(...(data.Products ?? [])); } @@ -155,7 +158,13 @@ export function formatData(gameProperties, passType) { index = formattedData.length; break; case "productId": - index = game.ProductId; + // Fall back to a placeholder when a game has no ProductId, and disambiguate distinct games that share one so none is silently overwritten + index = game.ProductId ?? `unknown-${Object.keys(formattedData).length}`; + if (Object.prototype.hasOwnProperty.call(formattedData, index)) { + const disambiguated = `${index} (${game.LocalizedProperties?.[0]?.ProductTitle ?? Object.keys(formattedData).length})`; + console.warn(`Duplicate output key "${index}" - writing one entry as "${disambiguated}" to avoid dropping a game.`); + index = disambiguated; + } break; case "productTitle": // Fall back to the ProductId when a game has no localized title, and disambiguate distinct games that share a title so none is silently overwritten in the dictionary @@ -266,7 +275,7 @@ function getPublisherName(game, publisherNameProperty) { : emptyValue(); } -function getProductDescription(game, productDescriptionProperty) { +export function getProductDescription(game, productDescriptionProperty) { if (!productDescriptionProperty.enabled) { return undefined; } if (productDescriptionProperty.preferShort && game.LocalizedProperties?.[0]?.ShortDescription?.length > 0) { @@ -278,7 +287,7 @@ function getProductDescription(game, productDescriptionProperty) { } } -function getImages(game, imageProperty) { +export function getImages(game, imageProperty) { if (!imageProperty.enabled) { return undefined; } let images = {}; @@ -300,6 +309,10 @@ function getImages(game, imageProperty) { continue; } + // Skip an image entry with no URI rather than crashing the whole market's task + if (!image.Uri) { + continue; + } const uri = image.Uri.startsWith('https:') ? image.Uri : `https:${image.Uri}`; // Skip true duplicates - the same URL can appear more than once in the API response @@ -313,7 +326,7 @@ function getImages(game, imageProperty) { return images; } -function getReleaseDate(game, releaseDateProperty) { +export function getReleaseDate(game, releaseDateProperty) { if (!releaseDateProperty.enabled) { return undefined; } const releaseDate = game.MarketProperties?.[0]?.OriginalReleaseDate; @@ -332,7 +345,7 @@ function getReleaseDate(game, releaseDateProperty) { } } -function getUserRating(game, userRatingProperty) { +export function getUserRating(game, userRatingProperty) { if (!userRatingProperty.enabled) { return undefined; } // Select the requested interval by its label, not by array position, so a reordering or an inserted interval on Microsoft's side cannot silently return the wrong window's rating @@ -355,7 +368,7 @@ function getUserRating(game, userRatingProperty) { return userRating; } -function getPricing(game, pricingProperty) { +export function getPricing(game, pricingProperty) { if (!pricingProperty.enabled) { return undefined; } let missingPricePlaceholder; diff --git a/package.json b/package.json index 2950707..164ad48 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "game-pass-api", "type": "module", - "version": "2.0.0", + "version": "2.1.0", "description": "CLI to get all games on Xbox Game Pass (Console, PC and EA Play) with their properties formatted just the way you need.", "keywords": [ "xbox", diff --git a/test/format.test.mjs b/test/format.test.mjs index 8d54e56..9101930 100644 --- a/test/format.test.mjs +++ b/test/format.test.mjs @@ -6,7 +6,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { formatData, getStorePageUrl, getCategories } from '../js/gamePass.js'; +import { formatData, getStorePageUrl, getCategories, getImages, getReleaseDate, getUserRating, getPricing } from '../js/gamePass.js'; import { initConfig } from '../js/utils.js'; // A shared temp output directory so initConfig's setupOutput never writes into the repo @@ -55,6 +55,59 @@ describe('formatData output shaping', () => { const out = formatData({ Products: [product(), product({ ProductId: 'D2' })] }, 'console'); assert.deepEqual(Object.keys(out), ['0', '1']); }); + + it('disambiguates a duplicate productId instead of silently dropping a game', () => { + useConfig({ outputFormat: 'productId', includedProperties: { productTitle: true } }); + // Both products share ABC123; the second must not overwrite the first + const out = formatData({ Products: [product(), product({ LocalizedProperties: [{ ProductTitle: 'Halo 2' }] })] }, 'console'); + assert.equal(Object.keys(out).length, 2, 'no game may be dropped when ProductIds collide'); + assert.ok('ABC123' in out); + }); +}); + +describe('getImages', () => { + const prop = { enabled: true, imageTypes: { Logo: -1 } }; + + it('collects image URIs by purpose, prefixing bare //-URIs with https:', () => { + const game = { LocalizedProperties: [{ Images: [{ ImagePurpose: 'Logo', Uri: '//cdn/logo.png' }, { ImagePurpose: 'Logo', Uri: 'https://cdn/logo2.png' }] }] }; + assert.deepEqual(getImages(game, prop).Logo, ['https://cdn/logo.png', 'https://cdn/logo2.png']); + }); + + it('skips an image entry with no Uri instead of crashing the whole task (regression)', () => { + const game = { LocalizedProperties: [{ Images: [{ ImagePurpose: 'Logo' }, { ImagePurpose: 'Logo', Uri: 'https://cdn/ok.png' }] }] }; + assert.deepEqual(getImages(game, prop).Logo, ['https://cdn/ok.png']); + }); + + it('returns undefined when disabled', () => { + assert.equal(getImages({}, { enabled: false }), undefined); + }); +}); + +describe('getReleaseDate, getUserRating and getPricing extractors', () => { + it('formats a release date as a plain date', () => { + useConfig(); + const game = { MarketProperties: [{ OriginalReleaseDate: '2021-12-08T00:00:00.0000000Z' }] }; + assert.equal(getReleaseDate(game, { enabled: true, format: 'date' }).split('T')[0], '2021-12-08'); + }); + + it('uses the empty-value placeholder for a missing release date', () => { + useConfig({ treatEmptyStringsAsNull: true }); + assert.equal(getReleaseDate({}, { enabled: true, format: 'date' }), null); + }); + + it('selects the user rating by interval label and can format it as a percentage', () => { + useConfig(); + const game = { MarketProperties: [{ UsageData: [{ AggregateTimeSpan: 'AllTime', AverageRating: 4 }] }] }; + assert.equal(getUserRating(game, { enabled: true, aggregationInterval: 'AllTime', format: 'percentage' }), 0.8); + }); + + it('preserves a zero price and reads the currency code', () => { + useConfig(); + const game = { DisplaySkuAvailabilities: [{ Availabilities: [{ OrderManagementData: { Price: { CurrencyCode: 'USD', ListPrice: 0 } } }] }] }; + const out = getPricing(game, { enabled: true, priceTypes: ['ListPrice'], missingPricePolicy: 'useNull' }); + assert.equal(out.currencyCode, 'USD'); + assert.equal(out.ListPrice, 0); + }); }); describe('formatData property extraction', () => {