Google Maps Details Checker

Highlights a place if it has missing information and adds it to a shared list on Google Maps.

// ==UserScript==
// @name         Google Maps Details Checker
// @namespace    https://github.com/gncnpk/google-maps-details-checker
// @author       Gavin Canon-Phratsachack (https://github.com/gncnpk)
// @version      0.0.2
// @description  Highlights a place if it has missing information and adds it to a shared list on Google Maps.
// @match        https://*.google.com/maps/*@*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=google.com/maps
// @run-at       document-start
// @license      MIT
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  let currentPlaceId = null;
  let isProcessing = false;
  let pendingOperations = new Set();
  let cachedElements = new Map();

  // Optimized configuration
  const CONFIG = {
    retryDelay: 500, // Reduced from 1000ms
    maxRetries: 3,
    checkDelay: 500, // Reduced from 2000ms
    operationDelay: 150, // Reduced from 300ms
    dialogDelay: 200, // Reduced from 500ms
    colors: {
      pass: "rgba(0,255,0,0.1)",
      not_checked: "rgba(255,255,0,0.1)",
      fail: "rgba(255,0,0,0.1)",
    },
    missingInfoText: {
      "Add hours": "Missing hours",
      "Add place's phone number": "Missing phone number",
      "Add website": "Missing website",
      "Add a photo": "Missing photo",
    },
    selectors: {
      placeHeader: ".lMbq3e",
      headerChildren: ".RWPxGd",
      expandArrow: ".Cw1rxd.google-symbols.SwaGS",
      savedElements: ".Io6YTe.fontBodyMedium.kR99db.fdkmkc",
      saveButton: ".etWJQ.jym1ob.kdfrQc.k17Vqe.WY7ZIb [aria-label*='Save']",
      saveDialog: ".MMWRwe.fxNQSd",
      backdrop: ".RveJvd.snByac",
      missingInfoSection: ".zSdcRe",
      actionButtons: ".MngOvd.fontBodyMedium.Hk4XGb.zWArOe",
    },
  };

  function extractPlaceId(url) {
    try {
      const match = url.match(/\/place\/([^\/\?#]+)/);
      if (!match) return null;

      const encoded = match[1];
      const withSpaces = encoded.replace(/\+/g, " ");
      return decodeURIComponent(withSpaces);
    } catch (error) {
      console.warn("Error extracting place ID:", error);
      return null;
    }
  }

  function hasPlaceChanged() {
    const newPlaceId = extractPlaceId(document.location.href);
    const changed = newPlaceId !== currentPlaceId;

    if (changed) {
      console.log(`Place changed: "${currentPlaceId}" -> "${newPlaceId}"`);
      currentPlaceId = newPlaceId;
      cachedElements.clear(); // Clear cache on place change
    }

    return changed;
  }

  function isOnPlacePage() {
    return currentPlaceId !== null;
  }

  function cancelPendingOperations() {
    console.log(`Cancelling ${pendingOperations.size} pending operations`);
    pendingOperations.clear();
  }

  // Optimized element waiting with early resolution
  function waitForElement(selector, timeout = 3000) {
    return new Promise((resolve, reject) => {
      // Check cache first
      const cached = cachedElements.get(selector);
      if (cached && document.contains(cached)) {
        resolve(cached);
        return;
      }

      const element = document.querySelector(selector);
      if (element) {
        cachedElements.set(selector, element);
        resolve(element);
        return;
      }

      let timeoutId;
      const observer = new MutationObserver(() => {
        const element = document.querySelector(selector);
        if (element) {
          observer.disconnect();
          clearTimeout(timeoutId);
          cachedElements.set(selector, element);
          resolve(element);
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });

      timeoutId = setTimeout(() => {
        observer.disconnect();
        reject(new Error(`Element ${selector} not found within ${timeout}ms`));
      }, timeout);
    });
  }

  function setPlaceStatus(status) {
    const placeHeader = document.querySelector(CONFIG.selectors.placeHeader);
    if (!placeHeader) return;

    const color = CONFIG.colors[status] || CONFIG.colors.not_checked;
    placeHeader.style.background = color;

    // Batch style updates
    requestAnimationFrame(() => {
      try {
        const headerChildren = document.querySelector(
          CONFIG.selectors.headerChildren
        );
        if (headerChildren) {
          Array.from(headerChildren.children).forEach((element) => {
            element.style.background = color;
          });
        }
      } catch (error) {
        if (placeHeader.nextSibling) {
          placeHeader.nextSibling.style.background = color;
        }
      }
    });
  }

  async function toggleSavedLists(forceState = null) {
    const expandArrow = document.querySelector(CONFIG.selectors.expandArrow);
    if (!expandArrow) return false;

    const ariaLabel = expandArrow.parentElement.parentElement.ariaLabel;

    if (forceState === "expand" && ariaLabel === "Hide place lists details")
      return true;
    if (forceState === "collapse" && ariaLabel === "Show place lists details")
      return true;

    if (
      ariaLabel === "Show place lists details" ||
      ariaLabel === "Hide place lists details"
    ) {
      console.log(
        `${
          ariaLabel === "Show place lists details" ? "Expanding" : "Collapsing"
        } saved lists section...`
      );
      expandArrow.click();

      // Wait for animation with shorter delay
      await new Promise((resolve) =>
        setTimeout(resolve, CONFIG.operationDelay)
      );
      return true;
    }

    return false;
  }

  async function getSavedLists() {
    await toggleSavedLists("expand");

    const savedElements = Array.from(
      document.querySelectorAll(CONFIG.selectors.savedElements)
    ).filter((e) => e.innerText.includes("Saved"));

    if (savedElements.length === 0) return [];

    const savedLists = [];

    savedElements.forEach((element) => {
      const savedText = element.innerText.trim();
      if (
        savedText &&
        savedText !== "Not saved" &&
        !savedText.includes("more lists")
      ) {
        let listsText = savedText.replace(/^Saved (?:to|in) /, "");

        const lists = listsText
          .split(/[,&]/)
          .map((list) => list.trim())
          .filter((list) => list.length > 0);

        savedLists.push(...lists);
      }
    });

    return [...new Set(savedLists)];
  }

  async function openSaveDialog() {
    try {
      // Check if dialog is already open
      if (document.querySelector(CONFIG.selectors.saveDialog)) {
        return true;
      }

      const saveButton = document.querySelector(CONFIG.selectors.saveButton);
      if (!saveButton) {
        throw new Error("Save button not found");
      }

      saveButton.children[0].click();
      await waitForElement(CONFIG.selectors.saveDialog, 2000);

      // Shorter delay for dialog stabilization
      await new Promise((resolve) =>
        setTimeout(resolve, CONFIG.dialogDelay)
      );

      return true;
    } catch (error) {
      console.warn("Failed to open save dialog:", error);
      return false;
    }
  }

  async function getAvailableLists() {
    // Micro delay instead of 200ms
    await new Promise((resolve) => setTimeout(resolve, 50));

    const listElements = document.querySelectorAll(CONFIG.selectors.saveDialog);
    const lists = [];

    listElements.forEach((element) => {
      const lines = element.innerText.split("\n");
      const listName = lines[lines.length - 1];

      if (listName && listName.trim()) {
        lists.push({
          name: listName.trim(),
          element: element,
        });
      }
    });

    return lists;
  }

  async function toggleListMembership(listName) {
    try {
      const dialogOpened = await openSaveDialog();
      if (!dialogOpened) {
        throw new Error("Failed to open save dialog");
      }

      const availableLists = await getAvailableLists();
      const targetList = availableLists.find((list) => list.name === listName);

      if (targetList) {
        console.log(`Toggling list: ${listName}`);
        targetList.element.click();
        await new Promise((resolve) =>
          setTimeout(resolve, CONFIG.operationDelay)
        );
        await closeSaveDialog();
        return true;
      } else {
        console.warn(`List not found: ${listName}`);
        await closeSaveDialog();
        return false;
      }
    } catch (error) {
      console.warn(`Failed to toggle list ${listName}:`, error);
      await closeSaveDialog();
      return false;
    }
  }

  async function closeSaveDialog() {
    try {
      const dialogElement = document.querySelector(CONFIG.selectors.saveDialog);
      if (!dialogElement) return;

      const backdrop = document.querySelector(CONFIG.selectors.backdrop);
      if (backdrop) {
        backdrop.click();
      } else {
        document.dispatchEvent(
          new KeyboardEvent("keydown", {
            key: "Escape",
            keyCode: 27,
            bubbles: true,
          })
        );
      }

      // Reduced dialog close delay
      await new Promise((resolve) =>
        setTimeout(resolve, CONFIG.operationDelay)
      );
    } catch (error) {
      console.warn("Error closing dialog:", error);
    }
  }

  async function manageMissingLists(
    requiredMissingItems,
    operationId,
    retries = 0
  ) {
    if (!pendingOperations.has(operationId)) {
      console.log("Operation cancelled, aborting list management");
      return;
    }

    if (retries >= CONFIG.maxRetries) {
      console.warn("Max retries reached for managing lists");
      pendingOperations.delete(operationId);
      return;
    }

    try {
      const currentLists = await getSavedLists();
      console.log("Current saved lists:", currentLists);
      console.log("Required missing items:", requiredMissingItems);

      if (!pendingOperations.has(operationId)) {
        console.log("Operation cancelled during execution, aborting");
        return;
      }

      const currentMissingLists = currentLists.filter((list) =>
        list.startsWith("Missing")
      );

      const listsToAdd = requiredMissingItems.filter(
        (item) => !currentMissingLists.includes(item)
      );
      const listsToRemove = currentMissingLists.filter(
        (list) => !requiredMissingItems.includes(list)
      );

      if (listsToAdd.length === 0 && listsToRemove.length === 0) {
        console.log("Missing lists are already up to date");
        await toggleSavedLists("collapse");
        pendingOperations.delete(operationId);
        return;
      }

      console.log("Missing lists to add:", listsToAdd);
      console.log("Missing lists to remove:", listsToRemove);

      // Batch operations for better performance
      const allOperations = [
        ...listsToAdd.map((list) => ({ action: "add", list })),
        ...listsToRemove.map((list) => ({ action: "remove", list })),
      ];

      for (const op of allOperations) {
        if (!pendingOperations.has(operationId)) {
          console.log("Operation cancelled during batch operations, aborting");
          return;
        }

        console.log(`${op.action === "add" ? "Adding to" : "Removing from"} list: ${op.list}`);
        await toggleListMembership(op.list);
        // Micro delay between operations instead of 300ms
        await new Promise((resolve) => setTimeout(resolve, 100));
      }

      await toggleSavedLists("collapse");
      pendingOperations.delete(operationId);
    } catch (error) {
      console.warn(`Manage lists attempt ${retries + 1} failed:`, error);
      await closeSaveDialog();

      if (pendingOperations.has(operationId)) {
        setTimeout(
          () =>
            manageMissingLists(requiredMissingItems, operationId, retries + 1),
          CONFIG.retryDelay
        );
      }
    }
  }

  function checkPlace() {
    if (isProcessing || !isOnPlacePage()) {
      return;
    }

    cancelPendingOperations();
    isProcessing = true;

    // Use requestAnimationFrame for better performance
    requestAnimationFrame(() => {
      let isDetailsComplete = true;
      let missingItems = [];

      try {
        setPlaceStatus("not_checked");

        // Batch DOM queries
        const missingInfoElements = document.querySelectorAll(
          CONFIG.selectors.missingInfoSection
        );
        const actionButtons = document.querySelectorAll(
          CONFIG.selectors.actionButtons
        );

        // Check for missing information section
        missingInfoElements.forEach((element) => {
          const text = element.innerText.split("\n")[0];
          if (text === "Add missing information") {
            isDetailsComplete = false;
          }
        });

        // Check for specific missing information buttons
        actionButtons.forEach((element) => {
          const lines = element.innerText.split("\n");
          const buttonText = lines[1] || lines[0];

          if (buttonText && CONFIG.missingInfoText[buttonText]) {
            isDetailsComplete = false;
            missingItems.push(CONFIG.missingInfoText[buttonText]);
          }
        });

        const operationId = Date.now() + Math.random();
        pendingOperations.add(operationId);

        if (isDetailsComplete) {
          setPlaceStatus("pass");
          console.log(
            `${currentPlaceId} is complete - removing from any missing lists`
          );

          // Reduced delay before list management
          setTimeout(() => {
            manageMissingLists([], operationId);
          }, CONFIG.checkDelay);
        } else {
          setPlaceStatus("fail");
          console.log(`${currentPlaceId} has missing info:`, missingItems);

          setTimeout(() => {
            manageMissingLists(missingItems, operationId);
          }, CONFIG.checkDelay);
        }
      } catch (error) {
        console.error("Error checking place:", error);
        setPlaceStatus("not_checked");
      } finally {
        isProcessing = false;
      }
    });
  }

  function handleUrlChange() {
    if (hasPlaceChanged()) {
      console.log(`New place detected: "${currentPlaceId}"`);
      // Reduced initial delay
      setTimeout(checkPlace, CONFIG.checkDelay);
    }
  }

  function initialize() {
    currentPlaceId = extractPlaceId(document.location.href);

    if (isOnPlacePage()) {
      setTimeout(checkPlace, CONFIG.checkDelay);
    }

    // Use passive listeners for better performance
    const observer = new MutationObserver(handleUrlChange);
    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    window.addEventListener("popstate", handleUrlChange, { passive: true });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initialize);
  } else {
    initialize();
  }
})();