GOG Wishlist - Sort by Price (Dropdown)

Enables sorting by price (ascending and descending) via the dropdown on a GOG wishlist page. Switching between "sort by price" and a native sorting option (title, date added, user reviews) automatically refreshes the page twice.

当前为 2025-03-21 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GOG Wishlist - Sort by Price (Dropdown)
  3. // @namespace https://github.com/idkicarus
  4. // @homepageURL https://github.com/idkicarus/GOG-wishlist-sort
  5. // @supportURL https://github.com/idkicarus/GOG-wishlist-sort/issues
  6. // @description Enables sorting by price (ascending and descending) via the dropdown on a GOG wishlist page. Switching between "sort by price" and a native sorting option (title, date added, user reviews) automatically refreshes the page twice.
  7. // @version 1.03
  8. // @license MIT
  9. // @match https://www.gog.com/account/wishlist*
  10. // @match https://www.gog.com/*/account/wishlist*
  11. // @run-at document-start
  12. // @grant none
  13. // ==/UserScript==
  14.  
  15.  
  16.  
  17. (function () {
  18. // Global flags to track whether the last sort was by price and the current sort order (ascending vs descending)
  19. let lastSortWasPrice = false;
  20. let ascendingOrder = true;
  21.  
  22. // Retrieve the refresh stage from sessionStorage.
  23. // This is used to control a two-step refresh process which minimizes visual glitches when switching sort methods.
  24. let refreshStage = sessionStorage.getItem("gog_sort_fix_stage");
  25.  
  26. /**
  27. * Hides the wishlist section by setting its opacity to 0 and disabling pointer events.
  28. * This method is used to prevent users from seeing the intermediate state during a refresh.
  29. */
  30. function hideWishlist() {
  31. // Select the wishlist container element
  32. let wishlistSection = document.querySelector(".account__product-lists");
  33. if (wishlistSection) {
  34. wishlistSection.style.opacity = "0"; // Make wishlist invisible but still present in the DOM
  35. wishlistSection.style.pointerEvents = "none"; // Disable interactions with the wishlist
  36. }
  37. }
  38.  
  39. /**
  40. * Shows the wishlist section by restoring its opacity and pointer events.
  41. * This is called after the refresh process to reveal the sorted content.
  42. */
  43. function showWishlist() {
  44. let wishlistSection = document.querySelector(".account__product-lists");
  45. if (wishlistSection) {
  46. wishlistSection.style.opacity = "1"; // Restore visibility
  47. wishlistSection.style.pointerEvents = "auto"; // Re-enable interactions
  48. }
  49. }
  50.  
  51. // If we are on the first refresh stage (i.e. refreshStage equals "1"),
  52. // wait for the DOM to be loaded and then hide the wishlist.
  53. // This ensures that during the refresh process, the user does not see an unsorted list.
  54. if (refreshStage === "1") {
  55. document.addEventListener("DOMContentLoaded", () => {
  56. hideWishlist();
  57. });
  58. }
  59.  
  60. /**
  61. * Sorts the wishlist products by price.
  62. * This function:
  63. * - Locates the wishlist container element.
  64. * - Extracts product rows and separates them into priced items and TBA (to be announced) items.
  65. * - Determines the sort order (ascending or descending) based on whether the last sort was by price.
  66. * - Rebuilds the DOM with sorted priced items followed by TBA items.
  67. */
  68. function sortByPrice() {
  69. console.log("[Sort By Price] Sorting Started.");
  70.  
  71. // Query all elements with class 'list-inner' and use the second one (index 1) which contains the wishlist
  72. let listInner = document.querySelectorAll('.list-inner')[1];
  73. if (!listInner) {
  74. console.error("[Sort By Price] ERROR: Wishlist list-inner element not found.");
  75. return;
  76. }
  77.  
  78. // Get all wishlist product rows as an array
  79. let productRows = Array.from(listInner.querySelectorAll('.product-row-wrapper'));
  80. console.log(`[Sort By Price] Found ${productRows.length} product rows.`);
  81.  
  82. // Separate items into those with a price (pricedItems) and those that are "TBA" or not priced (tbaItems)
  83. let pricedItems = [];
  84. let tbaItems = [];
  85.  
  86. // Process each product row.
  87. productRows.forEach(row => {
  88. // Extract the title from the product row.
  89. const titleElement = row.querySelector('.product-row__title');
  90. const title = titleElement ? titleElement.innerText.trim() : "Unknown Title";
  91.  
  92. // Extract the standard price and discounted price elements.
  93. const priceElement = row.querySelector('._price.product-state__price');
  94. const discountElement = row.querySelector('.price-text--discount span.ng-binding');
  95.  
  96. // Check if the game is flagged as "SOON" by inspecting a dedicated element.
  97. const soonFlag = row.querySelector('.product-title__flag--soon');
  98.  
  99. // Determine the price text: if a discount price exists, use it; otherwise, use the standard price.
  100. let priceText = discountElement ? discountElement.innerText : priceElement ? priceElement.innerText : null;
  101. // Convert the price text to a numeric value by stripping out non-numeric characters.
  102. let priceNumeric = priceText ? parseFloat(priceText.replace(/[^0-9.]/g, '').replace(/,/g, '')) : null;
  103.  
  104. // Retrieve the full text content of the price element in uppercase.
  105. // This is used to check for keywords like "TBA".
  106. const textContent = priceElement ? priceElement.textContent.toUpperCase() : "";
  107.  
  108. // Only consider text content and absence of a priceText to mark the item as TBA.
  109. // Note: We intentionally do NOT include the soonFlag here, so that a valid price isn't ignored.
  110. const isTBA = textContent.includes("TBA") || priceText === null;
  111.  
  112. // Special handling: if price is exactly 99.99 and a "SOON" flag is present, treat it as TBA.
  113. if (isTBA || (priceNumeric && priceNumeric === 99.99 && soonFlag)) {
  114. console.log(`[Sort By Price] Marked as TBA/SOON: ${title} (Original Text: '${textContent}')`);
  115. tbaItems.push(row);
  116. } else {
  117. // If there is no valid numeric price, log a warning and treat as TBA.
  118. if (!priceNumeric) {
  119. console.warn(`[Sort By Price] No valid price detected for: ${title}. Marking as TBA.`);
  120. tbaItems.push(row);
  121. } else {
  122. // Otherwise, log the extracted price and add the item to the pricedItems array for sorting.
  123. console.log(`[Sort By Price] ${title} - Extracted Price: ${priceNumeric} (Original Text: '${textContent}')`);
  124. pricedItems.push({ row, price: priceNumeric, title });
  125. }
  126. }
  127. });
  128.  
  129. // Toggle sorting order:
  130. // If the last sorting operation was by price, switch the order (toggle ascending/descending).
  131. // Otherwise, default to ascending order.
  132. ascendingOrder = lastSortWasPrice ? !ascendingOrder : true;
  133. pricedItems.sort((a, b) => (ascendingOrder ? a.price - b.price : b.price - a.price));
  134.  
  135. // Trigger a reflow by briefly hiding and showing the list container.
  136. listInner.style.display = "none";
  137. listInner.offsetHeight; // Force reflow
  138. listInner.style.display = "block";
  139.  
  140. // Clear all current child elements from the list container.
  141. while (listInner.firstChild) {
  142. listInner.removeChild(listInner.firstChild);
  143. }
  144.  
  145. // Append sorted priced items first.
  146. pricedItems.forEach(item => listInner.appendChild(item.row));
  147. // Append TBA items at the end in their original order.
  148. tbaItems.forEach(item => listInner.appendChild(item));
  149.  
  150. // Set flag indicating that sorting was done by price
  151. lastSortWasPrice = true;
  152. console.log("[Sort By Price] Sorting Completed.");
  153. }
  154.  
  155. /**
  156. * Handles clicks on native sort options.
  157. * When a native sort option is selected after a custom "Price" sort, a two-stage refresh is triggered:
  158. * - First refresh hides the wishlist.
  159. * - Second refresh restores visibility and applies the native sort.
  160. *
  161. * @param {string} option - The label of the native sort option clicked.
  162. */
  163. function handleNativeSortClick(option) {
  164. console.log(`[Sort By Price] Switching to native sort: ${option}`);
  165.  
  166. // If we're in the middle of the refresh process (first refresh already occurred)
  167. // then trigger the second refresh.
  168. if (refreshStage === "1") {
  169. console.log("[Sort By Price] Second refresh triggered to apply sorting.");
  170. sessionStorage.removeItem("gog_sort_fix_stage"); // Clear the refresh stage flag
  171. showWishlist(); // Reveal the wishlist
  172. return;
  173. }
  174.  
  175. // If this is the first time switching away from "Price" sort, set the refresh stage flag.
  176. sessionStorage.setItem("gog_sort_fix_stage", "1");
  177. console.log("[Sort By Price] First refresh (hiding only wishlist section).");
  178.  
  179. hideWishlist(); // Hide the wishlist section before refresh
  180. setTimeout(() => {
  181. // Reload the page after a short delay to let the UI update
  182. location.reload();
  183. }, 50); // 50ms delay is used to ensure the UI hides before the reload occurs
  184. }
  185.  
  186. /**
  187. * Adds a "Price" sorting option to the sort dropdown.
  188. * This function waits until the dropdown is available in the DOM and then adds:
  189. * - A new option to sort by price.
  190. * - Event listeners on native sort options to handle refresh if a previous "Price" sort was active.
  191. */
  192. function addSortByPriceOption() {
  193. // Find the dropdown container for sorting options
  194. const dropdown = document.querySelector(".header__dropdown ._dropdown__items");
  195. if (!dropdown) {
  196. console.log("[Sort By Price] WARNING: Dropdown not found. Retrying...");
  197. // If the dropdown is not found, try again after 500ms (wait for DOM elements to be available)
  198. setTimeout(addSortByPriceOption, 500);
  199. return;
  200. }
  201.  
  202. // If the "Price" sort option has already been added, exit early
  203. if (document.querySelector("#sort-by-price")) return;
  204.  
  205. // Create a new span element to serve as the "Price" sort option
  206. let sortPriceOption = document.createElement("span");
  207. sortPriceOption.id = "sort-by-price";
  208. sortPriceOption.className = "_dropdown__item";
  209. sortPriceOption.innerText = "Price";
  210. // When clicked, sort the wishlist by price and update the sort header text
  211. sortPriceOption.addEventListener("click", () => {
  212. sortByPrice();
  213. updateSortHeader("Price");
  214. });
  215.  
  216. // Append the new sort option to the dropdown list
  217. dropdown.appendChild(sortPriceOption);
  218. console.log("[Sort By Price] 'Price' option added to sort dropdown.");
  219.  
  220. // Add click event listeners to all other native sort options in the dropdown.
  221. // When any of these are clicked after a "Price" sort, trigger the native sort refresh process.
  222. document.querySelectorAll(".header__dropdown ._dropdown__item").forEach(item => {
  223. if (item.id !== "sort-by-price") {
  224. item.addEventListener("click", () => {
  225. // Only trigger the native sort refresh if the last sort was by price
  226. if (lastSortWasPrice) {
  227. handleNativeSortClick(item.innerText);
  228. }
  229. });
  230. }
  231. });
  232. }
  233.  
  234. /**
  235. * Updates the sort header displayed in the UI to reflect the currently active sort option.
  236. *
  237. * @param {string} option - The label of the sort option to display.
  238. */
  239. function updateSortHeader(option) {
  240. console.log(`[Sort By Price] Updating sort header to: ${option}`);
  241. // Find the container for the sort header pointer
  242. const sortHeader = document.querySelector(".header__dropdown ._dropdown__pointer-wrapper");
  243. if (!sortHeader) {
  244. console.log("[Sort By Price] ERROR: Sort header not found.");
  245. return;
  246. }
  247.  
  248. // Hide any existing sort labels that are controlled by Angular's ng-show directive
  249. document.querySelectorAll(".header__dropdown span[ng-show]").forEach(el => {
  250. el.style.display = "none";
  251. });
  252.  
  253. // Look for a custom header element we may have already created for the "Price" sort
  254. let customSortHeader = document.querySelector("#sort-by-price-header");
  255. if (!customSortHeader) {
  256. // If not found, create one and insert it at the beginning of the sort header container
  257. customSortHeader = document.createElement("span");
  258. customSortHeader.id = "sort-by-price-header";
  259. customSortHeader.className = "";
  260. sortHeader.insertBefore(customSortHeader, sortHeader.firstChild);
  261. }
  262.  
  263. // Update the header text and ensure it is visible
  264. customSortHeader.innerText = option;
  265. customSortHeader.style.display = "inline-block";
  266. }
  267.  
  268. /**
  269. * A MutationObserver is set up to monitor the document for when the sort dropdown is added to the DOM.
  270. * Once detected, the "Price" sort option is added and the observer disconnects to prevent further calls.
  271. */
  272. const observer = new MutationObserver((mutations, obs) => {
  273. // Check if the dropdown container for sort options is present
  274. if (document.querySelector(".header__dropdown ._dropdown__items")) {
  275. addSortByPriceOption(); // Add the "Price" option to the dropdown
  276. obs.disconnect(); // Stop observing since our work is done
  277. }
  278. });
  279.  
  280. // Begin observing the body for changes in child elements and subtree modifications
  281. observer.observe(document.body, { childList: true, subtree: true });
  282.  
  283. /**
  284. * If the script detects that it is in the first refresh stage (refreshStage equals "1"),
  285. * perform a second refresh after a short delay. This ensures that any changes made during
  286. * the refresh process are fully applied.
  287. */
  288. if (refreshStage === "1") {
  289. console.log("[Sort By Price] Performing second refresh to finalize sorting.");
  290. sessionStorage.removeItem("gog_sort_fix_stage"); // Clear the refresh flag
  291.  
  292. setTimeout(() => {
  293. // Reload the page after 50ms to allow any pending UI updates to complete
  294. location.reload();
  295. }, 50); // 50ms delay; honestly, could be longer to ensure no race conditions before reload
  296. }
  297. })();