Danbooru Theme Toggle (SunCalc + IP Geolocation)

Automatically toggles native dark mode based on local sunset/sunrise times

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Danbooru Theme Toggle (SunCalc + IP Geolocation)
// @namespace    Danbooru
// @version      1.7
// @description  Automatically toggles native dark mode based on local sunset/sunrise times
// @author       Dramorian
// @match        https://danbooru.donmai.us/*
// @match        https://aibooru.online/*
// @run-at       document-end
// @require      https://cdn.jsdelivr.net/npm/[email protected]/suncalc.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/global.min.js
// @license      MIT
// ==/UserScript==

/**
 * Manages automatic theme toggling for Danbooru based on sunrise/sunset times.
 * @class
 */
class DanbooruThemeToggle {
  #config = {
    debug: true,
    cacheKey: 'danbooru_theme_location',
    cacheDuration: 12 * 60 * 60 * 1000, // 12 hours in milliseconds
  };

  /**
   * Logs messages if debug mode is enabled.
   * @param {...any} args - Values to log.
   */
  #log(...args) {
    if (this.#config.debug) console.log('[DanbooruThemeToggle]', ...args);
  }

  /**
   * Logs errors if debug mode is enabled.
   * @param {...any} args - Error details to log.
   */
  #logError(...args) {
    if (this.#config.debug) console.error('[DanbooruThemeToggle]', ...args);
  }

  /**
   * Retrieves or fetches location data, with caching.
   * @returns {Promise<{latitude: number, longitude: number, timezone: string}>}
   */
  async #getLocationData() {
    this.#log('Checking for cached location data...');
    const cachedData = localStorage.getItem(this.#config.cacheKey);

    if (cachedData) {
      const { timestamp, location } = JSON.parse(cachedData);
      const cacheAge = Temporal.Now.instant().since(
        Temporal.Instant.fromEpochMilliseconds(timestamp)
      ).total({ unit: 'millisecond' });

      if (cacheAge < this.#config.cacheDuration) {
        this.#log('Using cached location:', location);
        return location;
      }
      this.#log('Cache expired, fetching new location data');
    } else {
      this.#log('No cached data found, fetching new location data');
    }

    try {
      this.#log('Fetching location from ipapi.co...');
      const response = await fetch('https://ipapi.co/json/', {
        signal: AbortSignal.timeout(5000), // ES15 AbortSignal timeout
      });
      const location = await response.json();

      this.#log('Received location data:', {
        lat: location.latitude,
        lng: location.longitude,
        timezone: location.timezone,
      });

      localStorage.setItem(
        this.#config.cacheKey,
        JSON.stringify({
          timestamp: Temporal.Now.instant().epochMilliseconds,
          location,
        })
      );

      return location;
    } catch (error) {
      this.#logError('Location fetch failed:', error);
      throw new Error('Failed to fetch location data', { cause: error });
    }
  }

  /**
   * Updates the theme based on sunrise/sunset times.
   * @returns {Promise<void>}
   */
  async #updateThemeBasedOnSun() {
    this.#log('Starting theme update...');

    if (typeof Danbooru === 'undefined' || !Danbooru?.CurrentUser) {
      this.#logError('Danbooru API unavailable');
      return;
    }

    try {
      const { latitude, longitude, timezone } = await this.#getLocationData();
      const lat = Number.parseFloat(latitude);
      const lng = Number.parseFloat(longitude);

      // Calculate sun times using Temporal API
      const now = Temporal.Now.zonedDateTimeISO(timezone);
      const { sunrise, sunset } = SunCalc.getTimes(
        new Date(now.toInstant().epochMilliseconds),
        lat,
        lng
      );

      this.#log('Sun times:', {
        sunrise: sunrise.toUTCString(),
        sunset: sunset.toUTCString(),
      });

      // Format times using Temporal
      const formatter = (date) =>
        Temporal.Instant.fromEpochMilliseconds(date.getTime())
          .toZonedDateTimeISO(timezone)
          .toLocaleString('en-US', {
            hour: '2-digit',
            minute: '2-digit',
            hourCycle: 'h24',
          });

      const currentTime = now.toLocaleString('en-US', {
        hour: '2-digit',
        minute: '2-digit',
        hourCycle: 'h24',
      });
      const sunriseTime = formatter(sunrise);
      const sunsetTime = formatter(sunset);

      this.#log('Formatted times:', { currentTime, sunriseTime, sunsetTime });

      // Determine theme
      const isNightTime =
        currentTime >= sunsetTime || currentTime < sunriseTime;
      const currentThemeIsDark = Danbooru.CurrentUser.darkMode();

      if (isNightTime !== currentThemeIsDark) {
        const newTheme = isNightTime ? 'dark' : 'light';
        this.#log(`Updating to ${newTheme} mode...`);
        await Danbooru.CurrentUser.update({ theme: newTheme });
        Danbooru.Utility.notice(`Theme updated to ${newTheme} mode.`);
        this.#log('Theme updated, reloading page');
        window.location.reload();
      } else {
        this.#log('Theme matches time of day, no update needed');
      }
    } catch (error) {
      this.#logError('Theme update failed:', error);
    }
  }

  /**
   * Initializes the theme toggle script.
   * @returns {Promise<void>}
   */
  async init() {
    this.#log('Script initialized');
    await this.#updateThemeBasedOnSun();
  }
}

// Start the script
new DanbooruThemeToggle().init().catch((error) =>
  console.error('[DanbooruThemeToggle] Initialization failed:', error)
);