Greasy Fork 还支持 简体中文。

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-02-26 提交的版本,檢視 最新版本

  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.02
  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. // Attempt to find the standard price element and the discounted price element if available
  89. const priceElement = row.querySelector('._price.product-state__price');
  90. const discountElement = row.querySelector('.price-text--discount span.ng-binding');
  91. // Check if there is a flag indicating the product is "coming soon"
  92. const soonFlag = row.querySelector('.product-title__flag--soon');
  93.  
  94. // Determine the price text: use discount text if available, otherwise standard price text
  95. let priceText = discountElement
  96. ? discountElement.innerText
  97. : priceElement
  98. ? priceElement.innerText
  99. : null;
  100.  
  101. // Convert the extracted text into a numeric value by stripping non-numeric characters
  102. let priceNumeric = priceText
  103. ? parseFloat(priceText.replace(/[^0-9.]/g, '').replace(/,/g, ''))
  104. : null;
  105.  
  106. // Create a text content check to mark items that are "TBA"
  107. // Also consider soonFlag and absence of priceText as indicators for TBA status
  108. const textContent = priceElement ? priceElement.textContent.toUpperCase() : "";
  109. const isTBA = textContent.includes("TBA") || soonFlag || priceText === null;
  110.  
  111. // Special case: if the price is exactly 99.99 and the "soon" flag is present, treat it as TBA
  112. // because the 99.99 is a placeholder for the real price
  113. if (isTBA || (priceNumeric && priceNumeric === 99.99 && soonFlag)) {
  114. tbaItems.push(row);
  115. } else {
  116. // For items with valid prices, push an object with the row and its numeric price
  117. pricedItems.push({ row, price: priceNumeric });
  118. }
  119. });
  120.  
  121. // Toggle sorting order:
  122. // If the last sorting operation was by price, switch the order (toggle ascending/descending).
  123. // Otherwise, default to ascending order.
  124. ascendingOrder = lastSortWasPrice ? !ascendingOrder : true;
  125. pricedItems.sort((a, b) => (ascendingOrder ? a.price - b.price : b.price - a.price));
  126.  
  127. // Trigger a reflow by briefly hiding and showing the list container.
  128. listInner.style.display = "none";
  129. listInner.offsetHeight; // Force reflow
  130. listInner.style.display = "block";
  131.  
  132. // Clear all current child elements from the list container.
  133. while (listInner.firstChild) {
  134. listInner.removeChild(listInner.firstChild);
  135. }
  136.  
  137. // Append sorted priced items first.
  138. pricedItems.forEach(item => listInner.appendChild(item.row));
  139. // Append TBA items at the end in their original order.
  140. tbaItems.forEach(item => listInner.appendChild(item));
  141.  
  142. // Set flag indicating that sorting was done by price
  143. lastSortWasPrice = true;
  144. console.log("[Sort By Price] Sorting Completed.");
  145. }
  146.  
  147. /**
  148. * Handles clicks on native sort options.
  149. * When a native sort option is selected after a custom "Price" sort, a two-stage refresh is triggered:
  150. * - First refresh hides the wishlist.
  151. * - Second refresh restores visibility and applies the native sort.
  152. *
  153. * @param {string} option - The label of the native sort option clicked.
  154. */
  155. function handleNativeSortClick(option) {
  156. console.log(`[Sort By Price] Switching to native sort: ${option}`);
  157.  
  158. // If we're in the middle of the refresh process (first refresh already occurred)
  159. // then trigger the second refresh.
  160. if (refreshStage === "1") {
  161. console.log("[Sort By Price] Second refresh triggered to apply sorting.");
  162. sessionStorage.removeItem("gog_sort_fix_stage"); // Clear the refresh stage flag
  163. showWishlist(); // Reveal the wishlist
  164. return;
  165. }
  166.  
  167. // If this is the first time switching away from "Price" sort, set the refresh stage flag.
  168. sessionStorage.setItem("gog_sort_fix_stage", "1");
  169. console.log("[Sort By Price] First refresh (hiding only wishlist section).");
  170.  
  171. hideWishlist(); // Hide the wishlist section before refresh
  172. setTimeout(() => {
  173. // Reload the page after a short delay to let the UI update
  174. location.reload();
  175. }, 50); // 50ms delay is used to ensure the UI hides before the reload occurs
  176. }
  177.  
  178. /**
  179. * Adds a "Price" sorting option to the sort dropdown.
  180. * This function waits until the dropdown is available in the DOM and then adds:
  181. * - A new option to sort by price.
  182. * - Event listeners on native sort options to handle refresh if a previous "Price" sort was active.
  183. */
  184. function addSortByPriceOption() {
  185. // Find the dropdown container for sorting options
  186. const dropdown = document.querySelector(".header__dropdown ._dropdown__items");
  187. if (!dropdown) {
  188. console.log("[Sort By Price] WARNING: Dropdown not found. Retrying...");
  189. // If the dropdown is not found, try again after 500ms (wait for DOM elements to be available)
  190. setTimeout(addSortByPriceOption, 500);
  191. return;
  192. }
  193.  
  194. // If the "Price" sort option has already been added, exit early
  195. if (document.querySelector("#sort-by-price")) return;
  196.  
  197. // Create a new span element to serve as the "Price" sort option
  198. let sortPriceOption = document.createElement("span");
  199. sortPriceOption.id = "sort-by-price";
  200. sortPriceOption.className = "_dropdown__item";
  201. sortPriceOption.innerText = "Price";
  202. // When clicked, sort the wishlist by price and update the sort header text
  203. sortPriceOption.addEventListener("click", () => {
  204. sortByPrice();
  205. updateSortHeader("Price");
  206. });
  207.  
  208. // Append the new sort option to the dropdown list
  209. dropdown.appendChild(sortPriceOption);
  210. console.log("[Sort By Price] 'Price' option added to sort dropdown.");
  211.  
  212. // Add click event listeners to all other native sort options in the dropdown.
  213. // When any of these are clicked after a "Price" sort, trigger the native sort refresh process.
  214. document.querySelectorAll(".header__dropdown ._dropdown__item").forEach(item => {
  215. if (item.id !== "sort-by-price") {
  216. item.addEventListener("click", () => {
  217. // Only trigger the native sort refresh if the last sort was by price
  218. if (lastSortWasPrice) {
  219. handleNativeSortClick(item.innerText);
  220. }
  221. });
  222. }
  223. });
  224. }
  225.  
  226. /**
  227. * Updates the sort header displayed in the UI to reflect the currently active sort option.
  228. *
  229. * @param {string} option - The label of the sort option to display.
  230. */
  231. function updateSortHeader(option) {
  232. console.log(`[Sort By Price] Updating sort header to: ${option}`);
  233. // Find the container for the sort header pointer
  234. const sortHeader = document.querySelector(".header__dropdown ._dropdown__pointer-wrapper");
  235. if (!sortHeader) {
  236. console.log("[Sort By Price] ERROR: Sort header not found.");
  237. return;
  238. }
  239.  
  240. // Hide any existing sort labels that are controlled by Angular's ng-show directive
  241. document.querySelectorAll(".header__dropdown span[ng-show]").forEach(el => {
  242. el.style.display = "none";
  243. });
  244.  
  245. // Look for a custom header element we may have already created for the "Price" sort
  246. let customSortHeader = document.querySelector("#sort-by-price-header");
  247. if (!customSortHeader) {
  248. // If not found, create one and insert it at the beginning of the sort header container
  249. customSortHeader = document.createElement("span");
  250. customSortHeader.id = "sort-by-price-header";
  251. customSortHeader.className = "";
  252. sortHeader.insertBefore(customSortHeader, sortHeader.firstChild);
  253. }
  254.  
  255. // Update the header text and ensure it is visible
  256. customSortHeader.innerText = option;
  257. customSortHeader.style.display = "inline-block";
  258. }
  259.  
  260. /**
  261. * A MutationObserver is set up to monitor the document for when the sort dropdown is added to the DOM.
  262. * Once detected, the "Price" sort option is added and the observer disconnects to prevent further calls.
  263. */
  264. const observer = new MutationObserver((mutations, obs) => {
  265. // Check if the dropdown container for sort options is present
  266. if (document.querySelector(".header__dropdown ._dropdown__items")) {
  267. addSortByPriceOption(); // Add the "Price" option to the dropdown
  268. obs.disconnect(); // Stop observing since our work is done
  269. }
  270. });
  271.  
  272. // Begin observing the body for changes in child elements and subtree modifications
  273. observer.observe(document.body, { childList: true, subtree: true });
  274.  
  275. /**
  276. * If the script detects that it is in the first refresh stage (refreshStage equals "1"),
  277. * perform a second refresh after a short delay. This ensures that any changes made during
  278. * the refresh process are fully applied.
  279. */
  280. if (refreshStage === "1") {
  281. console.log("[Sort By Price] Performing second refresh to finalize sorting.");
  282. sessionStorage.removeItem("gog_sort_fix_stage"); // Clear the refresh flag
  283.  
  284. setTimeout(() => {
  285. // Reload the page after 50ms to allow any pending UI updates to complete
  286. location.reload();
  287. }, 50); // 50ms delay; honestly, could be longer to ensure no race conditions before reload
  288. }
  289. })();