saihgupr/HomeAssistantTimeMachine/main 64k tokens More Tools
```
├── .github/
   ├── FUNDING.yml
├── .gitignore
├── LICENSE (omitted)
├── README.md (2.3k tokens)
├── homeassistant-time-machine/
   ├── .dockerignore
   ├── .gitignore
   ├── CHANGELOG.md (300 tokens)
   ├── Dockerfile (100 tokens)
   ├── README.md (2.4k tokens)
   ├── app.js (13.8k tokens)
   ├── config.yaml (200 tokens)
   ├── data/
      ├── docker-app-settings.json
      ├── scheduled-jobs.json (100 tokens)
   ├── docker-compose.yml
   ├── icon.png
   ├── package-lock.json (7k tokens)
   ├── package.json (100 tokens)
   ├── public/
      ├── css/
         ├── style.css (9.2k tokens)
      ├── images/
         ├── favicon.ico
         ├── icon.png
      ├── js/
         ├── strings.js (7.2k tokens)
   ├── run.sh (100 tokens)
   ├── views/
      ├── index.ejs (21.3k tokens)
├── images/
   ├── 1.png
   ├── 2.png
   ├── 3.png
   ├── 4.png
   ├── 5.png
   ├── history.svg (100 tokens)
   ├── icon.png
├── repository.json
```


## /.github/FUNDING.yml

```yml path="/.github/FUNDING.yml" 
ko_fi: saihgupr

```

## /.gitignore

```gitignore path="/.gitignore" 
.DS_Store 
config.js
node_modules/
*.log
.env

```

## /README.md

# <img src="https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/icon.png" alt="Home Assistant Time Machine icon" width="40" height="40" align="center"> Home Assistant Time Machine

Home Assistant Time Machine is a web-based tool that acts as a "Time Machine" for your Home Assistant configuration. Browse YAML backups across automations, scripts, Lovelace dashboards, ESPHome files, and packages, then restore individual items back to your live setup with confidence.

## What's New!

*   **Ingress Support:** Full support for Home Assistant ingress, allowing seamless access through the Home Assistant UI without port forwarding.
*   **Lovelace Backup Support:** Comprehensive backup and restore functionality for your Lovelace UI configurations, ensuring your dashboards are always safe.
*   **ESPHome & Packages Backup Support:** Enable backups for ESPHome and Packages via a toggle in the add-on configuration.
*   **Backup Now Button:** Trigger an immediate backup of your Home Assistant configuration directly from the UI with a single click. This utilizes a new API for programmatic backups, shared with the scheduled backup feature.
*   **Max Backups:** Set a limit on how many backups are kept.
*   **Authentication:** Secure access with Home Assistant authentication integration, automatically proxying through the Supervisor when available.
*   **Docker Container Installation:** Simplified installation process with a dedicated Docker container option, providing more flexibility for users without the Home Assistant add-on store.
*   **Optimized Size & Performance:** The add-on is now 4X smaller and uses 6X less memory, making it faster to download and run.  
*   **Dark/Light Mode:** Choose between dark and light themes in the configuration.
*   **Flexible Backup Locations:** Backups can now be stored in `/share` `/backup` `/config` or `/media`. Folders are created automatically, and remote share backups are supported.
*   **REST API:** Comprehensive API for managing backups, restores, and configurations.

## Screenshots

![Screenshot 1](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/1.png)
![Screenshot 2](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/2.png)
![Screenshot 3](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/3.png)
![Screenshot 4](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/4.png)
![Screenshot 5](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/5.png)

## Features

*   **Browse Backups:** Easily browse through your Home Assistant backup YAML files.
*   **View Changes:** See a side-by-side diff of the changes between a backed-up item and the live version.
*   **Restore Individual Items:** Restore individual automations or scripts without having to restore an entire backup.
*   **Safety first:** It automatically creates a backup of your YAML files in your backups folder before restoring anything.
*   **Reload Home Assistant:** Reload automations or scripts in Home Assistant directly from the UI after a restore.
*   **Scheduled Backups:** Configure automatic backups of your Home Assistant configuration directly from the UI.

## Installation

There are two ways to install Home Assistant Time Machine: as a Home Assistant add-on or as a standalone Docker container.

### 1. Home Assistant add-on (Recommended for most users)

1.  Navigate to the Add-on Store in your Home Assistant instance.
2.  Click on the three dots in the top right corner and select "Repositories".
3.  Paste the URL of this repository and click "Add":
    ```
    https://github.com/saihgupr/HomeAssistantTimeMachine
    ```
4.  The "Home Assistant Time Machine" add-on will now appear in the store. Click on it and then click "Install".

### 2. Standalone Docker Installation

Build and run the container locally when you aren’t using the Home Assistant add-on.

**Clone, build, and start (recommended):**

```bash
git clone https://github.com/saihgupr/HomeAssistantTimeMachine.git
cd HomeAssistantTimeMachine/homeassistant-time-machine
docker build -t ha-time-machine .

docker run -d \
  -p 54000:54000 \
  -e HOME_ASSISTANT_URL="http://your-ha-instance:8123" \
  -e LONG_LIVED_ACCESS_TOKEN="your-long-lived-access-token" \
  -v /path/to/your/ha/config:/config \
  -v /path/to/your/backups:/media \
  -v ha-time-machine-data:/data \
  --name ha-time-machine \
  ha-time-machine
```

Supplying the URL and token keeps credentials out of the UI. These environment variables are optional—if you set them, the settings fields are read-only; if you omit them, you can enter credentials in the web UI instead. They are stored in `/data/docker-ha-credentials.json`.

#### Changing Options in Docker

After the container is running, you can toggle ESPHome support, adjust text style, and switch light/dark modes by POSTing to the app settings API. This persists the value in `/data/homeassistant-time-machine/docker-app-settings.json` so the UI reflects it on reload:

```bash
curl -X POST http://localhost:54000/api/app-settings \
  -H 'Content-Type: application/json' \
  -d '{
        "liveConfigPath": "/config",
        "backupFolderPath": "/media/timemachine",
        "textStyle": "default",
        "theme": "dark",
        "esphomeEnabled": true,
        "packagesEnabled": true
      }'
```

Adjust the payload if you need different paths, theme, text style, or want to enable/disable features (`"esphomeEnabled": true|false`, `"packagesEnabled": true|false`, `"theme": light|dark`, `"textStyle": default|pirate|hacker|noir_detective|personal_trainer|scooby_doo`).

#### Accessing the Web Interface

After starting the container, access the web interface at `http://localhost:54000` (or your server's IP/port).

**Note:** The HA URL and token fields in settings will be read-only if configured via environment variables, or editable if configured through the web UI.

## Usage

### Home Assistant add-on

1.  **Configure the add-on:** In the add-on's configuration tab, set the Home Assistant URL and Long-Lived Access Token.
2.  **Start the add-on.**
3.  **Open the Web UI:**
    *   Use **Open Web UI** from the add-on panel to launch ingress (default recommended when the external port is disabled).
    *   Or, if you've enabled port `54000/tcp` in the add-on configuration, browse to `http://homeassistant.local:54000` (or your configured host/port).
4.  **In-app setup:**
    *   In the web UI, go to the settings menu.
    *   **Live Home Assistant Folder Path:** Set the path to your Home Assistant configuration directory (e.g., `/config`).
    *   **Backup Folder Path:** Set the path to the directory where your backups are stored (e.g., `/media/timemachine`).

### Docker Container

1.  **Start the container** with the required volume mounts (see Docker installation above).
2.  **Open the Web UI** at `http://localhost:54000` (or your server's IP/port).
3.  **In-app setup:**
    *   In the web UI, go to the settings menu.
    *   **Live Home Assistant Folder Path:** Set to `/config` (this is the mounted volume).
    *   **Backup Folder Path:** Set to `/media/timemachine` (this is the mounted volume).

## Backup to Remote Share

To configure backups to a remote share, first set up network storage within Home Assistant (Settings > System > Storage > 'Add network storage'). Name the share 'backups' and set its usage to 'Media'. Once configured, you can then specify the backup path in Home Assistant Time Machine settings as '/media/backups', which will direct backups to your remote share.

## Creating Backups

This add-on relies on having file-based backups of your Home Assistant configuration. You can now set up a scheduled backup directly within the UI. If you prefer to manage backups externally, here is an example of a simple shell script that you can use to create timestamped backups of your YAML files:

> **Important:** The paths in this script (for example, `/homeassistant`) are placeholders. Adjust them to match your actual Home Assistant configuration directory (such as `/config` on HAOS).

```bash
#!/bin/bash

DATE=$(date +%Y-%m-%d-%H%M%S)

YEAR=$(date +%Y)

MONTH=$(date +%m)

### HOME ASSISTANT ###
mkdir -p  /media/timemachine/$YEAR/$MONTH/"$DATE"
cp /homeassistant/*.yaml /media/timemachine/$YEAR/$MONTH/"$DATE"

### Lovelace ###
mkdir -p  /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage
cp /homeassistant/.storage/lovelace /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage
cp /homeassistant/.storage/lovelace_dashboards /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage
cp /homeassistant/.storage/lovelace_resources /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage
cp /homeassistant/.storage/lovelace.* /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage

### ESPHOME ###
mkdir -p  /media/timemachine/$YEAR/$MONTH/"$DATE"/esphome
cp /homeassistant/esphome/*.yaml /media/timemachine/$YEAR/$MONTH/"$DATE"/esphome

### PACKAGES ###
mkdir -p  /media/timemachine/$YEAR/$MONTH/"$DATE"/packages
cp /homeassistant/packages/*.yaml /media/timemachine/$YEAR/$MONTH/"$DATE"/packages
```
**Important:**
*   Run this script at a regular interval (e.g., every 24 hours) to keep backups current. You can use a `cron` job on your host machine or a Home Assistant automation with a `shell_command` integration to automate it.

## API Endpoints

- **POST /api/backup-now**: Trigger an immediate backup (requires `liveFolderPath` and `backupFolderPath`).
- **POST /api/restore-automation** / **POST /api/restore-script**: Restore a single automation or script after creating a safety backup.
- **POST /api/restore-lovelace-file** / **POST /api/restore-esphome-file** / **POST /api/restore-packages-file**: Restore Lovelace, ESPHome, or package files with automatic pre-restore backups.
- **POST /api/get-backup-* ** & **/api/get-live-* ** families: Fetch specific items from backups or the live config (automations, scripts, Lovelace, ESPHome, packages).
- **GET /api/schedule-backup** / **POST /api/schedule-backup**: Inspect or update scheduled backup jobs.
- **POST /api/scan-backups**: Scan the backup directory tree and list discovered backups.
- **POST /api/validate-path** / **POST /api/validate-backup-path**: Verify that provided directories exist and contain Home Assistant data/backups.
- **POST /api/test-home-assistant-connection**: Confirm stored Home Assistant credentials work before saving.
- **POST /api/reload-home-assistant**: Invoke a Home Assistant reload service (e.g., `automation.reload`).
- **GET /api/health**: Simple status endpoint exposing version, ingress state, and timestamp.

Example usage:
```bash
# Trigger backup
curl -X POST http://localhost:54000/api/backup-now \
  -H "Content-Type: application/json" \
  -d '{"liveFolderPath": "/config", "backupFolderPath": "/media/timemachine"}'

# Get scheduled jobs
curl http://localhost:54000/api/schedule-backup

# Scan backups
curl -X POST http://localhost:54000/api/scan-backups \
  -H "Content-Type: application/json" \
  -d '{"backupRootPath": "/media/timemachine"}'
```

## Support, Feedback & Contributing

- File issues or feature requests at [GitHub Issues](https://github.com/saihgupr/HomeAssistantTimeMachine/issues).
- Pull requests are welcome—check existing issues or propose enhancements.
- Share feedback on usability so we can keep refining backup workflows.

## /homeassistant-time-machine/.dockerignore

```dockerignore path="/homeassistant-time-machine/.dockerignore" 
# Docker
Dockerfile
.dockerignore

# Git
.git
.gitignore

# Node
node_modules
.next
npm-debug.log
yarn-error.log

# Editor
.vscode
.idea

# OS
.DS_Store

```

## /homeassistant-time-machine/.gitignore

```gitignore path="/homeassistant-time-machine/.gitignore" 
.DS_Store
config.js 
node_modules/
*.log
.env
.next
.env.local/

```

## /homeassistant-time-machine/CHANGELOG.md

# v2.0.2

## Changelog
- Minor tweaks and bug fixes.

# v2.0

## What's New!
- Added full **Ingress support**, allowing direct access through the Home Assistant UI — no port forwarding required.  
- Introduced **Lovelace dashboard backup and restore**, now included automatically in all backups.  
- Added configurable **ESPHome** and **Packages** backup support — enable these in the add-on configuration.  
- Implemented a **Backup Now** button in the UI for instant manual backups.  
- Added **Max Backups** retention setting to manage storage limits.  
- Integrated **proper authentication** using Home Assistant tokens, automatically proxied through the Supervisor.  
- Added **Docker container option** for running standalone outside the add-on store.  
- Optimized image to be **4× smaller and faster**, significantly reducing size and memory usage.  
- Introduced **Dark and Light mode themes** for the web UI.  
- Enabled **flexible backup locations**, supporting `/share`, `/backup`, `/config`, `/media`, and remote mounts.  
- Exposed a **full REST API** for automation of backups and restores.

## Updating
If you’re updating from **v1**, note that this release is a **complete rebuild**.  

After updating:
1. **Restart the add-on.**  
2. **Re-enter your backup path** in the settings menu.  
3. **Reconfigure your schedule** in the settings menu.  

Some users reported seeing **“Error 503: Service Unavailable”** right after updating to v2.  
- In most cases, a **restart** of the add-on fixes it.  
- If it persists, click **Rebuild**

## /homeassistant-time-machine/Dockerfile

``` path="/homeassistant-time-machine/Dockerfile" 
FROM node:20-alpine

# Install git and other dependencies
RUN apk add --no-cache git

# Create app directory
WORKDIR /app

# Copy package files and install dependencies
COPY package*.json ./
RUN npm install

# Copy the rest of the application
COPY . .

# Make run script executable
RUN chmod +x run.sh

# Expose the port
EXPOSE 54000

# Set environment variables
ENV NODE_ENV=development

# Start the application
CMD ["/bin/sh", "run.sh"]
```

## /homeassistant-time-machine/README.md

# Home Assistant Time Machine

Home Assistant Time Machine is a web-based tool that acts as a "Time Machine" for your Home Assistant configuration. Browse YAML backups across automations, scripts, Lovelace dashboards, ESPHome files, and packages, then restore individual items back to your live setup with confidence.

## What's New!

*   **Ingress Support:** Full support for Home Assistant ingress, allowing seamless access through the Home Assistant UI without port forwarding.
*   **Lovelace Backup Support:** Comprehensive backup and restore functionality for your Lovelace UI configurations, ensuring your dashboards are always safe.
*   **ESPHome & Packages Backup Support:** Enable backups for ESPHome and Packages via a toggle in the add-on configuration.
*   **Backup Now Button:** Trigger an immediate backup of your Home Assistant configuration directly from the UI with a single click. This utilizes a new API for programmatic backups, shared with the scheduled backup feature.
*   **Max Backups:** Set a limit on how many backups are kept.
*   **Authentication:** Secure access with Home Assistant authentication integration, automatically proxying through the Supervisor when available.
*   **Docker Container Installation:** Simplified installation process with a dedicated Docker container option, providing more flexibility for users without the Home Assistant add-on store.
*   **Optimized Size & Performance:** The add-on is now 4X smaller and uses 6X less memory, making it faster to download and run.  
*   **Dark/Light Mode:** Choose between dark and light themes in the configuration.
*   **Flexible Backup Locations:** Backups can now be stored in `/share` `/backup` `/config` or `/media`. Folders are created automatically, and remote share backups are supported.
*   **REST API:** Comprehensive API for managing backups, restores, and configurations.

## Screenshots

![Screenshot 1](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/1.png)
![Screenshot 2](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/2.png)
![Screenshot 3](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/3.png)
![Screenshot 4](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/4.png)
![Screenshot 5](https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/main/images/5.png)

## Features

*   **Browse Backups:** Easily browse through your Home Assistant backup YAML files.
*   **View Changes:** See a side-by-side diff of the changes between a backed-up item and the live version.
*   **Restore Individual Items:** Restore individual automations or scripts without having to restore an entire backup.
*   **Safety first:** It automatically creates a backup of your YAML files in your backups folder before restoring anything.
*   **Reload Home Assistant:** Reload automations or scripts in Home Assistant directly from the UI after a restore.
*   **Scheduled Backups:** Configure automatic backups of your Home Assistant configuration directly from the UI.

## Installation

There are two ways to install Home Assistant Time Machine: as a Home Assistant add-on or as a standalone Docker container.

### 1. Home Assistant add-on (Recommended for most users)

1.  Navigate to the Add-on Store in your Home Assistant instance.
2.  Click on the three dots in the top right corner and select "Repositories".
3.  Paste the URL of this repository and click "Add":
    ```
    https://github.com/saihgupr/HomeAssistantTimeMachine
    ```
4.  The "Home Assistant Time Machine" add-on will now appear in the store. Click on it and then click "Install".

### 2. Standalone Docker Installation

Build and run the container locally when you aren’t using the Home Assistant add-on.

**Clone, build, and start (recommended):**

```bash
git clone https://github.com/saihgupr/HomeAssistantTimeMachine.git
cd HomeAssistantTimeMachine/homeassistant-time-machine
docker build -t ha-time-machine .

docker run -d \
  -p 54000:54000 \
  -e HOME_ASSISTANT_URL="http://your-ha-instance:8123" \
  -e LONG_LIVED_ACCESS_TOKEN="your-long-lived-access-token" \
  -v /path/to/your/ha/config:/config \
  -v /path/to/your/backups:/media \
  -v ha-time-machine-data:/data \
  --name ha-time-machine \
  ha-time-machine
```

Supplying the URL and token keeps credentials out of the UI. These environment variables are optional—if you set them, the settings fields are read-only; if you omit them, you can enter credentials in the web UI instead.

**Alternative:** omit the environment variables, start the container with the same volumes, then visit `http://localhost:54000` to enter credentials in the settings modal. They are stored in `/data/docker-ha-credentials.json`.

#### Changing Options in Docker

After the container is running, you can toggle ESPHome support, adjust text style, and switch light/dark modes by POSTing to the app settings API. This persists the value in `/data/homeassistant-time-machine/docker-app-settings.json` so the UI reflects it on reload:

```bash
curl -X POST http://localhost:54000/api/app-settings \
  -H 'Content-Type: application/json' \
  -d '{
        "liveConfigPath": "/config",
        "backupFolderPath": "/media/timemachine",
        "textStyle": "default",
        "theme": "dark",
        "esphomeEnabled": true,
        "packagesEnabled": true
      }'
```

Adjust the payload if you need different paths, theme, text style, or want to enable/disable features (`"esphomeEnabled": true|false`, `"packagesEnabled": true|false`, `"theme": light|dark`, `"textStyle": default|pirate|hacker|noir_detective|personal_trainer|scooby_doo`).

#### Accessing the Web Interface

After starting the container, access the web interface at `http://localhost:54000` (or your server's IP/port).

**Note:** The HA URL and token fields in settings will be read-only if configured via environment variables, or editable if configured through the web UI.

## Usage

> **Tip:** If you expose port `54000/tcp` (for example, via the add-on's Configuration tab), you can open the UI directly at `http://your-host:54000` without relying on ingress.

### Home Assistant add-on

1.  **Configure the add-on:** In the add-on's configuration tab, set the Home Assistant URL and Long-Lived Access Token.
2.  **Start the add-on.**
3.  **Open the Web UI:**
    *   Use **Open Web UI** from the add-on panel to launch ingress (default recommended when the external port is disabled).
    *   Or, if you've enabled port `54000/tcp` in the add-on configuration, browse to `http://homeassistant.local:54000` (or your configured host/port).
4.  **In-app setup:**
    *   In the web UI, go to the settings menu.
    *   **Live Home Assistant Folder Path:** Set the path to your Home Assistant configuration directory (e.g., `/config`).
    *   **Backup Folder Path:** Set the path to the directory where your backups are stored (e.g., `/media/timemachine`).

### Docker Container

1.  **Start the container** with the required volume mounts (see Docker installation above).
2.  **Open the Web UI** at `http://localhost:54000` (or your server's IP/port).
3.  **In-app setup:**
    *   In the web UI, go to the settings menu.
    *   **Live Home Assistant Folder Path:** Set to `/config` (this is the mounted volume).
    *   **Backup Folder Path:** Set to `/media/timemachine` (this is the mounted volume).

## Backup to Remote Share

To configure backups to a remote share, first set up network storage within Home Assistant (Settings > System > Storage > 'Add network storage'). Name the share 'backups' and set its usage to 'Media'. Once configured, you can then specify the backup path in Home Assistant Time Machine settings as '/media/backups', which will direct backups to your remote share.

## Creating Backups

This add-on relies on having file-based backups of your Home Assistant configuration. You can now set up a scheduled backup directly within the UI. If you prefer to manage backups externally, here is an example of a simple shell script that you can use to create timestamped backups of your YAML files:

> **Important:** The paths in this script (for example, `/homeassistant`) are placeholders. Adjust them to match your actual Home Assistant configuration directory (such as `/config` on HAOS).

> **Important:** The paths in this script (for example, `/homeassistant`) are placeholders. Adjust them to match your actual Home Assistant configuration directory (such as `/config` on HAOS).

```bash
#!/bin/bash

DATE=$(date +%Y-%m-%d-%H%M%S)

YEAR=$(date +%Y)

MONTH=$(date +%m)

### HOME ASSISTANT ###
mkdir -p  /media/timemachine/$YEAR/$MONTH/"$DATE"
cp /homeassistant/*.yaml /media/timemachine/$YEAR/$MONTH/"$DATE"

### Lovelace ###
mkdir -p  /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage
cp /homeassistant/.storage/lovelace /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage
cp /homeassistant/.storage/lovelace_dashboards /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage
cp /homeassistant/.storage/lovelace_resources /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage
cp /homeassistant/.storage/lovelace.* /media/timemachine/$YEAR/$MONTH/"$DATE"/.storage

### ESPHOME ###
mkdir -p  /media/timemachine/$YEAR/$MONTH/"$DATE"/esphome
cp /homeassistant/esphome/*.yaml /media/timemachine/$YEAR/$MONTH/"$DATE"/esphome

### PACKAGES ###
mkdir -p  /media/timemachine/$YEAR/$MONTH/"$DATE"/packages
cp /homeassistant/packages/*.yaml /media/timemachine/$YEAR/$MONTH/"$DATE"/packages
```

**Important:**
*   Run this script at a regular interval (e.g., every 24 hours) to keep backups current. You can use a `cron` job on your host machine or a Home Assistant automation with a `shell_command` integration to automate it.



## API Endpoints

- **POST /api/backup-now**: Trigger an immediate backup (requires `liveFolderPath` and `backupFolderPath`).
- **POST /api/restore-automation** / **POST /api/restore-script**: Restore a single automation or script after creating a safety backup.
- **POST /api/restore-lovelace-file** / **POST /api/restore-esphome-file** / **POST /api/restore-packages-file**: Restore Lovelace, ESPHome, or package files with automatic pre-restore backups.
- **POST /api/get-backup-* ** & **/api/get-live-* ** families: Fetch specific items from backups or the live config (automations, scripts, Lovelace, ESPHome, packages).
- **GET /api/schedule-backup** / **POST /api/schedule-backup**: Inspect or update scheduled backup jobs.
- **POST /api/scan-backups**: Scan the backup directory tree and list discovered backups.
- **POST /api/validate-path** / **POST /api/validate-backup-path**: Verify that provided directories exist and contain Home Assistant data/backups.
- **POST /api/test-home-assistant-connection**: Confirm stored Home Assistant credentials work before saving.
- **POST /api/reload-home-assistant**: Invoke a Home Assistant reload service (e.g., `automation.reload`).
- **GET /api/health**: Simple status endpoint exposing version, ingress state, and timestamp.

Example usage:
```bash
# Trigger backup
curl -X POST http://localhost:54000/api/backup-now \
  -H "Content-Type: application/json" \
  -d '{"liveFolderPath": "/config", "backupFolderPath": "/media/timemachine"}'

# Get scheduled jobs
curl http://localhost:54000/api/schedule-backup

# Scan backups
curl -X POST http://localhost:54000/api/scan-backups \
  -H "Content-Type: application/json" \
  -d '{"backupRootPath": "/media/timemachine"}'
```

## Support, Feedback & Contributing

- File issues or feature requests at [GitHub Issues](https://github.com/saihgupr/HomeAssistantTimeMachine/issues).
- Pull requests are welcome—check existing issues or propose enhancements.
- Share feedback on usability so we can keep refining backup workflows.

## /homeassistant-time-machine/app.js

```js path="/homeassistant-time-machine/app.js" 
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const yaml = require('js-yaml');
const cron = require('node-cron');
const fetch = require('node-fetch');
const https = require('https');

const version = '2.0.1';
const DEBUG_LOGS = process.env.DEBUG_LOGS === 'true';
const debugLog = (...args) => {
  if (DEBUG_LOGS) {
    console.log(...args);
  }
};

const TLS_CERT_ERROR_CODES = new Set([
  'SELF_SIGNED_CERT_IN_CHAIN',
  'DEPTH_ZERO_SELF_SIGNED_CERT',
  'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
  'ERR_TLS_CERT_ALTNAME_INVALID',
  'ERR_TLS_CERT_SIGNATURE_ALGORITHM_UNSUPPORTED',
]);

const TLS_ERROR_TEXT_PATTERN = /self signed certificate|unable to verify the first certificate/i;

const isTlsCertificateError = (error) => {
  if (!error) return false;

  const nestedCandidates = [
    error,
    error.cause,
    error.reason,
    error.cause?.cause,
  ].filter(Boolean);

  for (const candidate of nestedCandidates) {
    if (candidate.code && TLS_CERT_ERROR_CODES.has(candidate.code)) {
      return true;
    }
    if (typeof candidate.message === 'string' && TLS_ERROR_TEXT_PATTERN.test(candidate.message)) {
      return true;
    }
  }

  return false;
};

const app = express();
const PORT = process.env.PORT || 54000;
const HOST = process.env.HOST || '0.0.0.0';
const INGRESS_PATH = process.env.INGRESS_ENTRY || '';
const basePath = INGRESS_PATH || '';
const BODY_SIZE_LIMIT = '50mb';

const DATA_DIR = (() => {
  const addonDataRoot = '/data';
  if (fsSync.existsSync(addonDataRoot)) {
    const dir = path.join(addonDataRoot, 'homeassistant-time-machine');
    try {
      fsSync.mkdirSync(dir, { recursive: true });
    } catch (error) {
      console.error('[data-dir] Failed to ensure addon data directory exists:', error);
    }
    return dir;
  }

  const fallback = path.join(__dirname, 'data');
  try {
    fsSync.mkdirSync(fallback, { recursive: true });
  } catch (error) {
    console.error('[data-dir] Failed to ensure local data directory exists:', error);
  }
  return fallback;
})();

console.log('[data-dir] Using persistent data directory:', DATA_DIR);

// Log ingress configuration immediately
console.log('[INIT] INGRESS_ENTRY env var:', process.env.INGRESS_ENTRY || '(not set)');
console.log('[INIT] basePath will be:', basePath || '(empty - direct access)');

// Middleware
app.use(express.json({ limit: BODY_SIZE_LIMIT }));
app.use(express.urlencoded({ extended: true, limit: BODY_SIZE_LIMIT }));

// Error handling middleware for payload size errors
app.use((err, req, res, next) => {
  if (err.type === 'entity.too.large') {
    return res.status(413).json({ 
      error: `Payload too large: ${err.message}`,
      limit: BODY_SIZE_LIMIT
    });
  }
  next(err);
});

// Ingress path detection and URL rewriting middleware
app.use((req, res, next) => {
  debugLog(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
  
  // Detect ingress path from headers
  const ingressPath = req.headers['x-ingress-path'] || 
                      req.headers['x-forwarded-prefix'] || 
                      req.headers['x-external-url'] ||
                      '';
  
  // Make ingress path available to templates  
  res.locals.ingressPath = ingressPath;
  res.locals.url = (path) => ingressPath + path;
  
  if (ingressPath) {
    debugLog(`[ingress] Detected: ${ingressPath}, Original URL: ${req.originalUrl}`);
    
    // Strip ingress prefix from URL for routing
    if (req.originalUrl.startsWith(ingressPath)) {
      req.url = req.originalUrl.substring(ingressPath.length) || '/';
      debugLog(`[ingress] Rewritten URL: ${req.url}`);
    }
  }
  
  next();
});

// Set up view engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// Static files - serve at both root and any ingress path
app.use('/static', express.static(path.join(__dirname, 'public')));
// Also handle ingress paths like /api/hassio_ingress/TOKEN/static
app.use('*/static', express.static(path.join(__dirname, 'public')));
console.log(`[static] Static files configured for direct and ingress access`);

// Favicon routes
app.get('/favicon.ico', (req, res) => {
  res.sendFile(path.join(__dirname, 'public/images/favicon.ico'));
});

// Home page
app.get('/', async (req, res) => {
  try {
    const [esphomeEnabled, packagesEnabled] = await Promise.all([
      isEsphomeEnabled(),
      isPackagesEnabled()
    ]);
    res.render('index', {
      title: 'Home Assistant Time Machine',
      version,
      currentMode: 'automations',
      esphomeEnabled,
      packagesEnabled
    });
  } catch (error) {
    console.error('[home] Failed to determine feature status:', error);
    res.render('index', {
      title: 'Home Assistant Time Machine',
      version,
      currentMode: 'automations',
      esphomeEnabled: false,
      packagesEnabled: false
    });
  }
});

const normalizeHomeAssistantUrl = (url) => {
  if (!url) return null;
  return url.replace(/\/$/, '').replace(/\/+$/, '');
};

const toApiBase = (url) => {
  const normalized = normalizeHomeAssistantUrl(url);
  if (!normalized) return null;
  return normalized.endsWith('/api') ? normalized : `${normalized}/api`;
};

const resolveSupervisorToken = () => {
  const possibleTokens = [process.env.SUPERVISOR_TOKEN, process.env.HASSIO_TOKEN];
  for (const token of possibleTokens) {
    if (token && token.trim()) {
      return token.trim();
    }
  }
  return null;
};

const YAML_EXTENSIONS = new Set(['.yaml', '.yml']);

async function listYamlFilesRecursive(rootDir) {
  const results = [];

  async function walk(currentDir, relativePrefix) {
    let entries;
    try {
      entries = await fs.readdir(currentDir, { withFileTypes: true });
    } catch (err) {
      if (err.code === 'ENOENT') {
        return;
      }
      throw err;
    }

    for (const entry of entries) {
      if (entry.name.startsWith('._')) {
        continue;
      }

      const entryRelativePath = relativePrefix ? path.join(relativePrefix, entry.name) : entry.name;
      const fullPath = path.join(currentDir, entry.name);

      if (entry.isSymbolicLink()) {
        continue;
      }

      if (entry.isDirectory()) {
        await walk(fullPath, entryRelativePath);
      } else if (entry.isFile() && YAML_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
        results.push(entryRelativePath);
      }
    }
  }

  await walk(rootDir, '');
  results.sort((a, b) => a.localeCompare(b));
  return results;
}

function resolveWithinDirectory(baseDir, relativePath) {
  if (typeof relativePath !== 'string') {
    const error = new Error('Invalid path');
    error.code = 'INVALID_PATH';
    throw error;
  }

  const trimmed = relativePath.trim();
  if (!trimmed) {
    const error = new Error('Invalid path');
    error.code = 'INVALID_PATH';
    throw error;
  }

  const base = path.resolve(baseDir);
  const target = path.resolve(baseDir, trimmed);
  const baseWithSep = base.endsWith(path.sep) ? base : `${base}${path.sep}`;

  if (target === base || !target.startsWith(baseWithSep)) {
    const error = new Error('Invalid path');
    error.code = 'INVALID_PATH';
    throw error;
  }

  return target;
}

// Get addon options (addon mode) or use environment variables (Docker mode)
async function getAddonOptions() {
  const supervisorToken = resolveSupervisorToken();

  // Check if running in addon mode (has /data/options.json)
  try {
    await fs.access('/data/options.json');
    debugLog('[options] Running in addon mode, reading /data/options.json');
    const options = await fs.readFile('/data/options.json', 'utf-8');
    debugLog('[options] Successfully read /data/options.json');
    const parsedOptions = JSON.parse(options);
    debugLog('[options] text_style configured as:', parsedOptions?.text_style || 'default');
    debugLog('[options] theme configured as:', parsedOptions?.theme || 'dark');

    let esphomeEnabled = parsedOptions?.esphome ?? false;
    let packagesEnabled = parsedOptions?.packages ?? false;
    try {
      const dockerSettings = await loadDockerSettings();
      if (dockerSettings.__loadedFromFile) {
        if (typeof dockerSettings.packagesEnabled === 'boolean') {
          packagesEnabled = dockerSettings.packagesEnabled;
        }
      }
    } catch (settingsError) {
      debugLog('[options] Failed to load Docker settings:', settingsError.message);
    }

    return {
      mode: 'addon',
      home_assistant_url: null,
      long_lived_access_token: null,
      supervisor_token: supervisorToken,
      credentials_source: supervisorToken ? 'supervisor' : 'none',
      text_style: parsedOptions?.text_style || 'default',
      theme: parsedOptions?.theme || 'dark',
      esphome: esphomeEnabled,
      packages: packagesEnabled,
    };
  } catch (error) {
    debugLog('[options] Running in Docker/local mode, checking for environment variables or saved settings');
    let dockerSettings = {};
    try {
      dockerSettings = await loadDockerSettings();
    } catch (settingsError) {
      debugLog('[options] Failed to load Docker settings for ESPHome flag:', settingsError.message);
    }

    // First try environment variables
    if (process.env.HOME_ASSISTANT_URL && process.env.LONG_LIVED_ACCESS_TOKEN) {
      return {
        mode: 'docker',
        home_assistant_url: process.env.HOME_ASSISTANT_URL,
        long_lived_access_token: process.env.LONG_LIVED_ACCESS_TOKEN,
        supervisor_token: supervisorToken,
        credentials_source: 'env',
        text_style: 'default',
        theme: process.env.THEME || 'dark',
        esphome: dockerSettings.esphomeEnabled ?? false,
        packages: dockerSettings.packagesEnabled ?? false,
      };
    }

    // Fall back to saved HA credentials for Docker/local
    try {
      const savedCreds = await fs.readFile(path.join(DATA_DIR, 'docker-ha-credentials.json'), 'utf-8');
      const parsed = JSON.parse(savedCreds);
      const hasSavedCreds = !!(parsed.home_assistant_url && parsed.long_lived_access_token);
      return {
        mode: 'docker',
        home_assistant_url: parsed.home_assistant_url || null,
        long_lived_access_token: parsed.long_lived_access_token || null,
        supervisor_token: supervisorToken,
        credentials_source: hasSavedCreds ? 'stored' : 'none',
        text_style: parsed.text_style || 'default',
        theme: process.env.THEME || parsed.theme || 'dark',
        esphome: dockerSettings.esphomeEnabled ?? false,
        packages: dockerSettings.packagesEnabled ?? false,
      };
    } catch (credError) {
      // No credentials configured
      return {
        mode: 'docker',
        home_assistant_url: null,
        long_lived_access_token: null,
        supervisor_token: supervisorToken,
        credentials_source: 'none',
        text_style: 'default',
        theme: process.env.THEME || 'dark',
        esphome: dockerSettings.esphomeEnabled ?? false,
        packages: dockerSettings.packagesEnabled ?? false,
      };
    }
  }
}

async function getHomeAssistantAuth(optionsOverride, manualOverride) {
  if (manualOverride?.haUrl && manualOverride?.haToken) {
    return {
      baseUrl: toApiBase(manualOverride.haUrl),
      token: manualOverride.haToken,
      source: 'manual',
      options: optionsOverride || await getAddonOptions(),
    };
  }

  const options = optionsOverride || await getAddonOptions();

  if (options.supervisor_token) {
    console.log('[auth] Using supervisor proxy for Home Assistant requests');
    return {
      baseUrl: 'http://supervisor/core/api',
      token: options.supervisor_token,
      source: 'supervisor',
      options,
    };
  }

  if (options.home_assistant_url && options.long_lived_access_token) {
    return {
      baseUrl: toApiBase(options.home_assistant_url),
      token: options.long_lived_access_token,
      source: options.credentials_source || 'options',
      options,
    };
  }

  return {
    baseUrl: null,
    token: null,
    source: 'none',
    options,
  };
}

async function isEsphomeEnabled() {
  try {
    const options = await getAddonOptions();
    return !!(options?.esphome);
  } catch (error) {
    console.error('[esphome] Failed to determine ESPHome status:', error);
    return false;
  }
}

async function isPackagesEnabled() {
  try {
    const options = await getAddonOptions();
    return !!(options?.packages);
  } catch (error) {
    console.error('[packages] Failed to determine Packages status:', error);
    return false;
  }
}

// App settings endpoint (expose config to frontend, excluding sensitive data)
app.get('/api/app-settings', async (req, res) => {
  try {
    debugLog('[app-settings] --- Start ESPHome Flag Resolution ---');

    const options = await getAddonOptions();
    debugLog('[app-settings] Addon options loaded:', { 
      esphome: options.esphome, 
      mode: options.mode 
    });

    let esphomeEnabled = !!(options.esphome);
    debugLog(`[app-settings] Initial esphomeEnabled from options: ${esphomeEnabled}`);

    let storedSettings = null;
    if (options.mode === 'addon') {
      try {
        storedSettings = await loadDockerSettings();
        debugLog('[app-settings] Loaded stored settings (docker-app-settings.json):', {
          esphomeEnabled: storedSettings.esphomeEnabled,
          __loadedFromFile: storedSettings.__loadedFromFile
        });
      } catch (settingsError) {
        debugLog('[app-settings] Failed to load saved settings for ESPHome flag:', settingsError.message);
      }
    }
    const auth = await getHomeAssistantAuth(options);

    const packagesEnabled = await isPackagesEnabled();
    const baseResponse = {
      mode: options.mode,
      haUrl: options.home_assistant_url,
      haToken: options.long_lived_access_token ? 'configured' : null,
      haAuthMode: auth.source,
      haAuthConfigured: !!auth.token,
      haCredentialsSource: options.credentials_source || null,
      textStyle: options.text_style || 'default',
      theme: options.theme || 'dark',
      esphomeEnabled,
      packagesEnabled,
    };
    debugLog('[app-settings] Base response object created:', { esphomeEnabled: baseResponse.esphomeEnabled });

    if (options.mode === 'addon') {
      const savedSettings = storedSettings || await loadDockerSettings();
      debugLog('[app-settings] Addon mode: final check of savedSettings for merge:', {
        esphomeEnabled: savedSettings.esphomeEnabled
      });

      const finalEsphomeEnabled = baseResponse.esphomeEnabled;
      debugLog(`[app-settings] Addon mode: finalEsphomeEnabled resolved to: ${finalEsphomeEnabled}`);

      const finalPackagesEnabled = typeof savedSettings.packagesEnabled === 'boolean'
        ? savedSettings.packagesEnabled
        : packagesEnabled;

      const mergedSettings = {
        liveConfigPath: savedSettings.liveConfigPath || '/config',
        backupFolderPath: savedSettings.backupFolderPath || '/media/backups/yaml',
        textStyle: options.text_style || savedSettings.textStyle || 'default',
        theme: options.theme || savedSettings.theme || baseResponse.theme || 'dark',
        esphomeEnabled: options.esphome ?? finalEsphomeEnabled,
        packagesEnabled: finalPackagesEnabled,
      };

      global.dockerSettings = { ...global.dockerSettings, ...mergedSettings };
      debugLog('[app-settings] Addon mode: global.dockerSettings updated:', { esphomeEnabled: global.dockerSettings.esphomeEnabled });

      const finalResponse = {
        ...baseResponse,
        backupFolderPath: mergedSettings.backupFolderPath,
        liveConfigPath: mergedSettings.liveConfigPath,
        textStyle: mergedSettings.textStyle,
        theme: mergedSettings.theme,
        esphomeEnabled: mergedSettings.esphomeEnabled,
      };
      debugLog('[app-settings] Addon mode: Final response payload:', { esphomeEnabled: finalResponse.esphomeEnabled });
      debugLog('[app-settings] --- End ESPHome Flag Resolution ---');
      res.json(finalResponse);
      return;
    }

    debugLog('[app-settings] Docker mode detected.');
    const dockerSettings = await loadDockerSettings();
    debugLog('[app-settings] Docker mode: loaded dockerSettings:', { esphomeEnabled: dockerSettings.esphomeEnabled });

    const finalEsphomeEnabled = dockerSettings.esphomeEnabled ?? baseResponse.esphomeEnabled;
    debugLog(`[app-settings] Docker mode: finalEsphomeEnabled resolved to: ${finalEsphomeEnabled}`);

    const effectiveTheme = process.env.THEME || dockerSettings.theme || baseResponse.theme || 'dark';
    const finalResponse = {
      ...baseResponse,
      backupFolderPath: dockerSettings.backupFolderPath || '/media/timemachine',
      liveConfigPath: dockerSettings.liveConfigPath || '/config',
      textStyle: dockerSettings.textStyle || 'default',
      theme: effectiveTheme,
      esphomeEnabled: finalEsphomeEnabled,
      packagesEnabled: dockerSettings.packagesEnabled ?? false,
    };
    debugLog('[app-settings] Docker mode: Final response payload:', { esphomeEnabled: finalResponse.esphomeEnabled });
    debugLog('[app-settings] --- End ESPHome Flag Resolution ---');
    res.json(finalResponse);
  } catch (error) {
    console.error('[app-settings] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Save Docker app settings
app.post('/api/app-settings', async (req, res) => {
  try {
    const { liveConfigPath, backupFolderPath, textStyle, theme, esphomeEnabled, packagesEnabled } = req.body;

    const existingSettings = await loadDockerSettings();
    const settings = {
      liveConfigPath: liveConfigPath || existingSettings.liveConfigPath || '/config',
      backupFolderPath: backupFolderPath || existingSettings.backupFolderPath || '/media/backups/yaml',
      textStyle: textStyle || existingSettings.textStyle || 'default',
      theme: theme || existingSettings.theme || 'dark',
      esphomeEnabled: typeof esphomeEnabled === 'boolean' ? esphomeEnabled : existingSettings.esphomeEnabled ?? false,
      packagesEnabled: typeof packagesEnabled === 'boolean' ? packagesEnabled : existingSettings.packagesEnabled ?? false,
    };

    await saveDockerSettings(settings);
    console.log('[save-docker-settings] Saved Docker app settings:', settings);
    
    res.json({ success: true, message: 'Settings saved successfully' });
  } catch (error) {
    console.error('[save-docker-settings] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Save Docker HA credentials (fallback when env vars not set)
app.post('/api/docker-ha-credentials', async (req, res) => {
  try {
    const { homeAssistantUrl, longLivedAccessToken } = req.body;
    
    // Only allow saving credentials in Docker mode and when env vars aren't set
    if (process.env.HOME_ASSISTANT_URL || process.env.LONG_LIVED_ACCESS_TOKEN) {
      return res.status(400).json({ error: 'HA credentials are configured via environment variables' });
    }
    
    const credentials = {
      home_assistant_url: homeAssistantUrl,
      long_lived_access_token: longLivedAccessToken
    };
    
    // Ensure data directory exists
    await fs.writeFile(path.join(DATA_DIR, 'docker-ha-credentials.json'), JSON.stringify(credentials, null, 2), 'utf-8');
    console.log('[docker-ha-credentials] Saved Docker HA credentials to', path.join(DATA_DIR, 'docker-ha-credentials.json'));
    
    res.json({ success: true, message: 'HA credentials saved successfully' });
  } catch (error) {
    console.error('[docker-ha-credentials] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// App settings endpoint (expose config to frontend, excluding sensitive data)
async function loadDockerSettings() {
  const cachedSettings = (global.dockerSettings && typeof global.dockerSettings === 'object') ? global.dockerSettings : {};
  const defaultSettings = {
    liveConfigPath: '/config',
    backupFolderPath: '/media/timemachine',
    textStyle: 'default',
    theme: process.env.THEME || 'dark',
    esphomeEnabled: false,
    packagesEnabled: false,
    ...cachedSettings
  };

  try {
    const settingsPath = path.join(DATA_DIR, 'docker-app-settings.json');
    
    // Check if settings file exists
    try {
      await fs.access(settingsPath);
      const content = await fs.readFile(settingsPath, 'utf-8');
      const parsed = JSON.parse(content);

      // Merge with defaults to ensure all fields are present
      const settings = { ...defaultSettings, ...parsed };

      // Update in-memory settings
      global.dockerSettings = settings;

      
      console.log('Loaded settings from file:', settings);
      return settings;
    } catch (err) {
      if (err.code === 'ENOENT') {
        
      } else {
        console.error('Error loading settings:', err);
      }
      
      // Ensure in-memory settings are set to defaults
      global.dockerSettings = defaultSettings;
      return defaultSettings;
    }
  } catch (error) {
    console.error('Error in loadDockerSettings:', error);
    // Ensure in-memory settings are set to defaults even if there's an error
    global.dockerSettings = defaultSettings;
    return defaultSettings;
  }
}

// Save Docker settings to file
async function saveDockerSettings(settings) {
  // Ensure all required fields are present with defaults
  const settingsToSave = {
    liveConfigPath: settings.liveConfigPath || '/config',
    backupFolderPath: settings.backupFolderPath || '/media/timemachine',
    textStyle: settings.textStyle || 'default',
    theme: settings.theme || 'dark',
    esphomeEnabled: settings.esphomeEnabled ?? false,
    packagesEnabled: settings.packagesEnabled ?? false
  };
  
  // Save to file
  const settingsPath = path.join(DATA_DIR, 'docker-app-settings.json');
  try {
    await fs.mkdir(DATA_DIR, { recursive: true });
  } catch (error) {
    console.error('[saveDockerSettings] Failed to ensure data directory exists:', error);
  }
  await fs.writeFile(settingsPath, JSON.stringify(settingsToSave, null, 2), 'utf-8');
  
  console.log('Settings saved successfully to', settingsPath);
  
  // Update the in-memory settings
  global.dockerSettings = settingsToSave;
  
  return settingsToSave;
}

const SKIP_BACKUP_DIRS = new Set(['esphome', '.storage', 'packages']);

// Recursive function to find backup directories
async function getBackupDirs(dir, depth = 0) {
  let results = [];
  const indent = '  '.repeat(depth);
  
  try {
    const list = await fs.readdir(dir, { withFileTypes: true });
    
    for (const dirent of list) {
      const fullPath = path.resolve(dir, dirent.name);
      if (dirent.isDirectory()) {
        // Skip known non-backup directories
        if (SKIP_BACKUP_DIRS.has(dirent.name)) {
          continue;
        }
        const name = dirent.name;
        const dashedPattern = /^\d{4}-\d{2}-\d{2}-\d{6}$/;
        const numericPattern = /^\d{12}$/;
        let isBackupFolder = dashedPattern.test(name) || numericPattern.test(name);

        // Fallback: if folder contains common YAML backup files, treat as backup folder
        if (!isBackupFolder) {
          try {
            const inner = await fs.readdir(fullPath);
            const hasYaml = inner.some(f => f.endsWith('.yaml') || f.endsWith('.yml'));
            const hasKnownFiles = inner.includes('automations.yaml') || inner.includes('scripts.yaml');
            if (hasYaml || hasKnownFiles) {
              isBackupFolder = true;
            }
          } catch (err) {
            // Skip directories we can't read
          }
        }

        if (isBackupFolder) {
          const stats = await fs.stat(fullPath);
          results.push({ path: fullPath, folderName: name, mtime: stats.mtime });
        }

        // Continue scanning deeper regardless to support nested structures like /year/month/backup
        try {
          const nestedResults = await getBackupDirs(fullPath, depth + 1);
          results = results.concat(nestedResults);
        } catch (err) {
          // Skip directories we can't read
        }
      }
    }
  } catch (error) {
    console.error(`${indent}[scan-backups] Error reading ${dir}:`, error.message);
  }
  
  return results.filter(result => !SKIP_BACKUP_DIRS.has(path.basename(result.path)));
}

// Scan backups
app.post('/api/scan-backups', async (req, res) => {
  try {
    // Accept backupRootPath from request body or use default
    const backupRootPath = req.body?.backupRootPath || '/media/timemachine';
    console.log('[scan-backups] Scanning backup directory:', backupRootPath);
    
    // Basic security check
    if (backupRootPath.includes('..')) {
      return res.status(400).json({ error: 'Invalid path' });
    }
    
    const backups = await getBackupDirs(backupRootPath);
    
    // Sort descending to show newest first
    backups.sort((a, b) => b.folderName.localeCompare(a.folderName));
    
    console.log('[scan-backups] Found backups:', backups.length);
    res.json({ backups });
  } catch (error) {
    console.error('[scan-backups] Error:', error);
    if (error.code === 'ENOENT') {
      return res.status(404).json({ 
        error: `Directory not found: ${error.path}`, 
        code: 'DIR_NOT_FOUND' 
      });
    }
    res.status(500).json({ error: 'Failed to scan backup directory.', details: error.message });
  }
});

// Get backup automations
app.post('/api/get-backup-automations', async (req, res) => {
  try {
    const { backupPath } = req.body;
    const automationsFile = path.join(backupPath, 'automations.yaml');
    const content = await fs.readFile(automationsFile, 'utf-8');
    const automations = yaml.load(content) || [];
    res.json({ automations });
  } catch (error) {
    console.error('[get-backup-automations] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Get backup scripts  
app.post('/api/get-backup-scripts', async (req, res) => {
  try {
    const { backupPath } = req.body;
    const scriptsFile = path.join(backupPath, 'scripts.yaml');
    const content = await fs.readFile(scriptsFile, 'utf-8');
    const scriptsObject = yaml.load(content);
    
    // Scripts are stored as a dictionary/object, not an array
    // Transform: { script_id: { alias: '...', sequence: [...] } }
    // Into: [{ id: 'script_id', alias: '...', sequence: [...] }]
    let scripts = [];
    if (scriptsObject && typeof scriptsObject === 'object' && !Array.isArray(scriptsObject)) {
      scripts = Object.keys(scriptsObject).map(scriptId => ({
        id: scriptId,
        ...scriptsObject[scriptId]
      }));
    } else if (Array.isArray(scriptsObject)) {
      // Fallback for array format (shouldn't happen)
      scripts = scriptsObject;
    }
    
    res.json({ scripts });
  } catch (error) {
    console.error('[get-backup-scripts] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Get live items (automations or scripts)
app.post('/api/get-live-items', async (req, res) => {
  try {
    const { itemIdentifiers, mode, liveConfigPath } = req.body;
    const configPath = liveConfigPath || '/config';
    const fileName = mode === 'automations' ? 'automations.yaml' : 'scripts.yaml';
    const filePath = path.join(configPath, fileName);
    
    const content = await fs.readFile(filePath, 'utf-8');
    let allItems = yaml.load(content) || [];
    
    // Handle scripts dictionary format
    if (mode === 'scripts' && typeof allItems === 'object' && !Array.isArray(allItems)) {
      allItems = Object.keys(allItems).map(scriptId => ({
        id: scriptId,
        ...allItems[scriptId]
      }));
    }
    
    const liveItems = {};
    itemIdentifiers.forEach(identifier => {
      const item = allItems.find(i => (i.id === identifier || i.alias === identifier));
      if (item) {
        liveItems[identifier] = item;
      }
    });
    
    res.json({ liveItems });
  } catch (error) {
    console.error('[get-live-items] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Get live automation
app.post('/api/get-live-automation', async (req, res) => {
  try {
    const { automationIdentifier, liveConfigPath } = req.body;
    const configPath = liveConfigPath || '/config';
    const filePath = path.join(configPath, 'automations.yaml');
    const content = await fs.readFile(filePath, 'utf-8');
    const automations = yaml.load(content) || [];
    const automation = automations.find(a => a.id === automationIdentifier || a.alias === automationIdentifier);
    
    if (!automation) {
      return res.status(404).json({ error: 'Automation not found' });
    }
    
    res.json({ automation });
  } catch (error) {
    console.error('[get-live-automation] Error:', error);
    res.status(404).json({ error: error.message });
  }
});

// Get live script
app.post('/api/get-live-script', async (req, res) => {
  try {
    const { automationIdentifier, liveConfigPath } = req.body;
    const configPath = liveConfigPath || '/config';
    const filePath = path.join(configPath, 'scripts.yaml');
    const content = await fs.readFile(filePath, 'utf-8');
    const scripts = yaml.load(content) || [];
    const script = scripts.find(s => s.id === automationIdentifier || s.alias === automationIdentifier);
    
    if (!script) {
      return res.status(404).json({ error: 'Script not found' });
    }
    
    res.json({ script });
  } catch (error) {
    console.error('[get-live-script] Error:', error);
    res.status(404).json({ error: error.message });
  }
});

// Restore automation
app.post('/api/restore-automation', async (req, res) => {
  try {
    const { automationObject, timezone, liveConfigPath } = req.body;
    // Perform a backup before restoring
    await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone);

    const configPath = liveConfigPath || '/config';
    const filePath = path.join(configPath, 'automations.yaml');
    
    let automations = [];
    try {
      const content = await fs.readFile(filePath, 'utf-8');
      automations = yaml.load(content) || [];
    } catch (err) {
      // File doesn't exist, start with empty array
    }
    
    // Remove existing automation with same ID/alias
    automations = automations.filter(a => 
      a.id !== automationObject.id && a.alias !== automationObject.alias
    );
    
    // Add restored automation
    automations.push(automationObject);
    
    // Write back to file
    const newContent = yaml.dump(automations);
    await fs.writeFile(filePath, newContent, 'utf-8');
    
    res.json({ success: true, message: 'Automation restored successfully' });
  } catch (error) {
    console.error('[restore-automation] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Restore script
app.post('/api/restore-script', async (req, res) => {
  try {
    const { scriptObject, timezone, liveConfigPath } = req.body;
    // Perform a backup before restoring
    await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone);

    const configPath = liveConfigPath || '/config';
    const filePath = path.join(configPath, 'scripts.yaml');
    
    let scripts = [];
    let originalIsObject = false;
    try {
      const content = await fs.readFile(filePath, 'utf-8');
      const loadedScripts = yaml.load(content);
      if (loadedScripts && typeof loadedScripts === 'object' && !Array.isArray(loadedScripts)) {
        originalIsObject = true;
        scripts = Object.keys(loadedScripts).map(scriptId => ({
          id: scriptId,
          ...loadedScripts[scriptId]
        }));
      } else if (Array.isArray(loadedScripts)) {
        scripts = loadedScripts;
      }
    } catch (err) {
      // File doesn't exist, start with empty array
    }
    
    // Remove existing script with same ID/alias
    scripts = scripts.filter(s => 
      s.id !== scriptObject.id && s.alias !== scriptObject.alias
    );
    
    // Add restored script
    scripts.push(scriptObject);
    
    let newContent;
    if (originalIsObject) {
      // Convert back to object format if original was an object
      const scriptsObject = {};
      scripts.forEach(s => {
        const id = s.id || s.alias;
        if (id) {
          const { id: _, ...rest } = s; // Remove the 'id' property if it was added during conversion
          scriptsObject[id] = rest;
        }
      });
      newContent = yaml.dump(scriptsObject);
    } else {
      newContent = yaml.dump(scripts);
    }
    
    await fs.writeFile(filePath, newContent, 'utf-8');
    
    res.json({ success: true, message: 'Script restored successfully' });
  } catch (error) {
    console.error('[restore-script] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Reload Home Assistant
app.post('/api/reload-home-assistant', async (req, res) => {
  try {
    const { service } = req.body;
    
    if (!service) {
      return res.status(400).json({ error: 'Missing required parameter: service' });
    }

    const auth = await getHomeAssistantAuth();

    if (!auth.baseUrl || !auth.token) {
      return res.status(400).json({ error: 'Home Assistant access is not configured for this environment.' });
    }

    const serviceUrl = `${auth.baseUrl}/services/${service.replace('.', '/')}`;
    const headers = {
      'Authorization': `Bearer ${auth.token}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    };

    if (auth.source === 'supervisor') {
      headers['X-Supervisor-Token'] = auth.token;
    }
    
    // Make async call to HA (don't wait for response)
    fetch(serviceUrl, {
      method: 'POST',
      headers,
      body: JSON.stringify({})
    }).catch(err => console.error('[reload-home-assistant] Background error:', err));
    
    res.json({ message: 'Home Assistant reload initiated successfully' });
  } catch (error) {
    console.error('[reload-home-assistant] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Helper to check if directory contains backups (recursively)
async function hasBackupsRecursive(dir, depth = 0, maxDepth = 5) {
  if (depth > maxDepth) return false;
  
  try {
    const list = await fs.readdir(dir, { withFileTypes: true });
    
    // Check for YAML files in current directory
    const hasYaml = list.some(item => !item.isDirectory() && (item.name.endsWith('.yaml') || item.name.endsWith('.yml')));
    if (hasYaml) return true;
    
    // Check for backup-pattern directories
    const hasBackupPattern = list.some(item => {
      if (!item.isDirectory()) return false;
      const name = item.name;
      return /^\d{4}-\d{2}-\d{2}-\d{6}$/.test(name) || /^\d{12}$/.test(name);
    });
    if (hasBackupPattern) return true;
    
    // Recursively check subdirectories
    for (const item of list) {
      if (item.isDirectory()) {
        const fullPath = path.resolve(dir, item.name);
        const hasNested = await hasBackupsRecursive(fullPath, depth + 1, maxDepth);
        if (hasNested) return true;
      }
    }
    
    return false;
  } catch (error) {
    return false;
  }
}

// Validate backup path
app.post('/api/validate-backup-path', async (req, res) => {
  try {
    const { path: folderPath } = req.body;
    
    if (!folderPath) {
      return res.status(400).json({ isValid: false, error: 'Path is required' });
    }
    
    const stats = await fs.stat(folderPath);
    
    if (!stats.isDirectory()) {
      return res.status(400).json({ isValid: false, error: 'Provided path is not a directory' });
    }
    
    // Check recursively for backups or YAML files
    const hasBackups = await hasBackupsRecursive(folderPath);
    
    if (!hasBackups) {
      return res.status(400).json({ 
        isValid: false, 
        error: 'No backup folders or YAML files found in directory tree (searched 5 levels deep)' 
      });
    }
    
    res.json({ isValid: true });
  } catch (error) {
    if (error.code === 'ENOENT') {
      return res.status(400).json({ isValid: false, error: 'Directory does not exist' });
    }
    if (error.code === 'EACCES') {
      return res.status(400).json({ isValid: false, error: 'Permission denied - cannot access directory' });
    }
    res.status(500).json({ isValid: false, error: error.message });
  }
});

// Test Home Assistant connection
app.post('/api/test-home-assistant-connection', async (req, res) => {
  try {
    // Allow overriding with request body for testing before saving (Docker mode)
    const providedHaUrl = req.body.haUrl;
    const providedHaToken = req.body.haToken;

    const manualOverride = (providedHaUrl && providedHaToken)
      ? { haUrl: providedHaUrl, haToken: providedHaToken }
      : null;

    const auth = await getHomeAssistantAuth(null, manualOverride);

    if (!auth.baseUrl || !auth.token) {
      res.status(400).json({ success: false, message: 'Home Assistant access is not configured. For Docker deployments without ingress, supply a URL and long-lived token.' });
      return;
    }
    
    const headers = {
      'Authorization': `Bearer ${auth.token}`,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    };

    if (auth.source === 'supervisor') {
      headers['X-Supervisor-Token'] = auth.token;
    }

    const endpoint = `${auth.baseUrl}/states`;
    const fetchOptions = { headers };
    let tlsFallbackUsed = false;
    let response;

    try {
      response = await fetch(endpoint, fetchOptions);
    } catch (fetchError) {
      if (auth.baseUrl.startsWith('https://') && isTlsCertificateError(fetchError)) {
        tlsFallbackUsed = true;
        console.warn('[test-connection] TLS verification failed, retrying with relaxed validation:', {
          endpoint,
          code: fetchError.code,
          message: fetchError.message,
          causeCode: fetchError.cause?.code,
        });
        const insecureAgent = new https.Agent({ rejectUnauthorized: false });
        response = await fetch(endpoint, { ...fetchOptions, agent: insecureAgent });
      } else {
        throw fetchError;
      }
    }
    
    if (response.ok) {
      res.json({
        success: true,
        message: 'Connected to Home Assistant successfully.',
        authMode: auth.source,
        tlsFallback: tlsFallbackUsed ? 'insecure' : 'strict',
      });
    } else {
      const errorText = await response.text();
      console.error('[test-connection] HA response error', {
        status: response.status,
        authMode: auth.source,
        baseUrl: auth.baseUrl,
        errorText,
      });
      res.status(response.status).json({ 
        success: false, 
        message: `Connection failed: ${response.status} - ${errorText}`,
        tlsFallback: tlsFallbackUsed ? 'insecure' : 'strict',
      });
    }
  } catch (error) {
    console.error('[test-connection] Error:', error);
    res.status(500).json({ success: false, message: `Connection failed: ${error.message}` });
  }
});

// Schedule backup endpoints
let scheduledJobs = {};
const SCHEDULE_FILE = path.join(DATA_DIR, 'scheduled-jobs.json');

// Load scheduled jobs from file
async function loadScheduledJobs() {
  try {
    const content = await fs.readFile(SCHEDULE_FILE, 'utf-8');
    const data = JSON.parse(content);
    
    // Normalize: ensure we only have { jobs: {...} } structure
    // Remove any legacy top-level job keys
    if (!data.jobs) {
      data.jobs = {};
    }
    
    // Clean up: return only the jobs wrapper
    return { jobs: data.jobs };
  } catch (error) {
    return { jobs: {} };
  }
}

// Save scheduled jobs to file
async function saveScheduledJobs(jobs) {
  await fs.writeFile(SCHEDULE_FILE, JSON.stringify(jobs, null, 2));
}

// Get schedule
app.get('/api/schedule-backup', async (req, res) => {
  try {
    const jobs = await loadScheduledJobs();
    res.json(jobs);
  } catch (error) {
    console.error('[get-schedule] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Set schedule
app.post('/api/schedule-backup', async (req, res) => {
  try {
    const { id, cronExpression, enabled, timezone, liveConfigPath, backupFolderPath, maxBackupsEnabled, maxBackupsCount } = req.body;
    
    const jobs = await loadScheduledJobs();
    jobs.jobs = jobs.jobs || {};
    jobs.jobs[id] = { cronExpression, enabled, timezone, liveConfigPath, backupFolderPath, maxBackupsEnabled, maxBackupsCount };
    console.log('[scheduler] New schedule saved:', jobs.jobs[id]);
    
    // Clean structure: only save { jobs: {...} }
    const cleanJobs = { jobs: jobs.jobs };
    await saveScheduledJobs(cleanJobs);
    
    // Stop existing cron job if any
    if (scheduledJobs[id]) {
      scheduledJobs[id].stop();
      delete scheduledJobs[id];
    }
    
    // Start new cron job if enabled
    const jobConfig = jobs.jobs[id];
    if (enabled) {
      console.log(`[scheduler] Setting up schedule "${id}" with cron "${cronExpression}" and timezone "${timezone}"`);
      scheduledJobs[id] = cron.schedule(cronExpression, async () => {
        console.log(`[cron] Triggered backup job: ${id} at ${new Date().toISOString()}`);
        try {
          const effectiveLivePath = jobConfig.liveConfigPath || '/config';
          const effectiveBackupPath = jobConfig.backupFolderPath || '/media/timemachine';
          console.log(`[cron] Using live path "${effectiveLivePath}" and backup path "${effectiveBackupPath}".`);
          try {
            const response = await fetch(`http://localhost:${PORT}/api/backup-now`, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({
                liveConfigPath: effectiveLivePath,
                backupFolderPath: effectiveBackupPath,
                maxBackupsEnabled: jobConfig.maxBackupsEnabled,
                maxBackupsCount: jobConfig.maxBackupsCount,
                timezone: jobConfig.timezone
              })
            });
            const result = await response.json();
            if (response.ok) {
              console.log(`[cron] Backup triggered successfully: ${result.message}`);
            } else {
              console.error(`[cron] Backup trigger failed: ${result.error}`);
            }
          } catch (error) {
            console.error(`[cron] Error triggering backup:`, error);
          }
        } catch (error) {
          console.error(`[cron] Error during scheduled backup for job ${id}:`, error);
        }
      }, { timezone });
    }
    
    res.json({ success: true, message: 'Schedule updated successfully' });
  } catch (error) {
    console.error('[set-schedule] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Validate path
app.post('/api/validate-path', async (req, res) => {
  try {
    const { path: requestedPath, type } = req.body;

    if (!requestedPath) {
      return res.json({ errorCode: 'directory_not_found' });
    }

    try {
      const stats = await fs.stat(requestedPath);
      if (!stats.isDirectory()) {
        return res.json({ errorCode: 'not_directory', path: requestedPath });
      }

      if (type === 'live') {
        const automationsPath = `${requestedPath}/automations.yaml`;
        try {
          await fs.access(automationsPath);
        } catch (err) {
          if (err.code === 'ENOENT') {
            return res.json({ errorCode: 'missing_automations', path: requestedPath });
          }
          return res.json({ errorCode: 'cannot_access', path: requestedPath, details: err.message });
        }
      }

      return res.json({ success: true });
    } catch (err) {
      if (err.code === 'ENOENT') {
        return res.json({ errorCode: 'directory_not_found', path: requestedPath });
      }
      return res.json({ errorCode: 'cannot_access', path: requestedPath, details: err.message });
    }
  } catch (error) {
    console.error('[validate-path] Error:', error);
    res.status(500).json({ error: error.message, errorCode: 'unknown' });
  }
});

// Reusable backup function
async function performBackup(liveConfigPath, backupFolderPath, source = 'manual', maxBackupsEnabled = false, maxBackupsCount = 100, timezone = null) {
  const configPath = liveConfigPath || '/config';
  const backupRoot = backupFolderPath || '/media/timemachine';
  
  console.log(`[backup-${source}] Starting backup...`);
  console.log(`[backup-${source}] Config path:`, configPath);
  console.log(`[backup-${source}] Backup root:`, backupRoot);
  console.log(`[backup-${source}] Max backups enabled:`, maxBackupsEnabled, 'count:', maxBackupsCount);

  try {
    // Check if backup root exists and is writable
    await fs.access(backupRoot, fs.constants.R_OK | fs.constants.W_OK);
    console.log(`[backup-${source}] Backup root is accessible and writable`);
  } catch (err) {
    if (err.code === 'ENOENT') {
      try {
        await fs.mkdir(backupRoot, { recursive: true });
        console.log(`[backup-${source}] Backup root did not exist. Created: ${backupRoot}`);
        // Verify access after creation
        await fs.access(backupRoot, fs.constants.R_OK | fs.constants.W_OK);
      } catch (mkdirErr) {
        console.error(`[backup-${source}] Failed to create backup root:`, mkdirErr.message);
        const createError = new Error('backup_dir_create_failed');
        createError.code = 'BACKUP_DIR_CREATE_FAILED';
        createError.meta = { path: backupRoot };
        throw createError;
      }
    } else {
      console.error(`[backup-${source}] Backup root access check failed:`, err.message);
      const accessError = new Error('backup_dir_unwritable');
      accessError.code = 'BACKUP_DIR_UNWRITABLE';
      accessError.meta = { path: backupRoot };
      throw accessError;
    }
  }

  // Create backup folder with timestamp
  let now = new Date();
  let YYYY, MM, DD, HH, mm, ss;
  
  if (timezone) {
    // Use the specified timezone
    const formatter = new Intl.DateTimeFormat('en-US', {
      timeZone: timezone,
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
      hour12: false,
      hourCycle: 'h23'
    });
    
    const parts = formatter.formatToParts(now);
    YYYY = parts.find(p => p.type === 'year').value;
    MM = parts.find(p => p.type === 'month').value;
    DD = parts.find(p => p.type === 'day').value;
    HH = parts.find(p => p.type === 'hour').value;
    if (HH === '24') {
      HH = '00';
    }
    mm = parts.find(p => p.type === 'minute').value;
    ss = parts.find(p => p.type === 'second').value;
  } else {
    // Use server's local time (fallback)
    YYYY = String(now.getFullYear());
    MM = String(now.getMonth() + 1).padStart(2, '0');
    DD = String(now.getDate()).padStart(2, '0');
    HH = String(now.getHours()).padStart(2, '0');
    mm = String(now.getMinutes()).padStart(2, '0');
    ss = String(now.getSeconds()).padStart(2, '0');
  }
  
  const timestamp = `${YYYY}-${MM}-${DD}-${HH}${mm}${ss}`;
  
  const backupPath = path.join(backupRoot, YYYY, MM, timestamp);
  console.log(`[backup-${source}] Creating directory:`, backupPath);
  
  try {
    await fs.mkdir(backupPath, { recursive: true });
    console.log(`[backup-${source}] Directory created successfully`);
  } catch (err) {
    console.error(`[backup-${source}] Failed to create directory:`, err);
    const mkdirError = new Error('backup_dir_create_failed');
    mkdirError.code = 'BACKUP_DIR_CREATE_FAILED';
    mkdirError.meta = { path: backupPath, parent: backupRoot };
    throw mkdirError;
  }

  // Copy YAML files
  const files = await fs.readdir(configPath);
  const yamlFiles = files.filter(file => file.endsWith('.yaml') || file.endsWith('.yml'));
  console.log(`[backup-${source}] Found ${yamlFiles.length} YAML files to copy.`);
  
  let copiedYamlCount = 0;
  for (const file of yamlFiles) {
    const sourcePath = path.join(configPath, file);
    const destPath = path.join(backupPath, file);
    try {
      await fs.copyFile(sourcePath, destPath);
      copiedYamlCount++;
    } catch (err) {
      console.error(`[backup-${source}] Error copying ${file}:`, err.message);
    }
  }
  console.log(`[backup-${source}] Copied ${copiedYamlCount} of ${yamlFiles.length} YAML files.`);

  // Backup Lovelace files
  const storagePath = path.join(configPath, '.storage');
  const backupStoragePath = path.join(backupPath, '.storage');
  await fs.mkdir(backupStoragePath, { recursive: true });

  try {
    const storageFiles = await fs.readdir(storagePath);
    const lovelaceFiles = storageFiles.filter(file => file.startsWith('lovelace'));
    console.log(`[backup-${source}] Found ${lovelaceFiles.length} Lovelace files to copy.`);
    
    let copiedLovelaceCount = 0;
    for (const file of lovelaceFiles) {
      const sourcePath = path.join(storagePath, file);
      const destPath = path.join(backupStoragePath, file);
      try {
        await fs.copyFile(sourcePath, destPath);
        copiedLovelaceCount++;
      } catch (err) {
        if (err.code !== 'ENOENT') {
          console.error(`[backup-${source}] Error copying Lovelace file ${file}:`, err.message);
        }
      }
    }
    console.log(`[backup-${source}] Copied ${copiedLovelaceCount} of ${lovelaceFiles.length} Lovelace files.`);
  } catch (err) {
    console.error(`[backup-${source}] Error reading .storage directory:`, err.message);
  }

  const esphomeEnabled = await isEsphomeEnabled();
  const packagesEnabled = await isPackagesEnabled();

  if (esphomeEnabled) {
    // Backup ESPHome files
    const esphomePath = path.join(configPath, 'esphome');
    const backupEsphomePath = path.join(backupPath, 'esphome');

    try {
      await fs.mkdir(backupEsphomePath, { recursive: true });
      const esphomeYamlFiles = await listYamlFilesRecursive(esphomePath);
      console.log(`[backup-${source}] Found ${esphomeYamlFiles.length} ESPHome YAML files to copy.`);

      let copiedEsphomeCount = 0;
      for (const relativePath of esphomeYamlFiles) {
        const sourcePath = path.join(esphomePath, relativePath);
        const destPath = path.join(backupEsphomePath, relativePath);
        try {
          await fs.mkdir(path.dirname(destPath), { recursive: true });
          await fs.copyFile(sourcePath, destPath);
          copiedEsphomeCount++;
        } catch (err) {
          if (err.code !== 'ENOENT') {
            console.error(`[backup-${source}] Error copying ESPHome file ${relativePath}:`, err.message);
          }
        }
      }
      console.log(`[backup-${source}] Copied ${copiedEsphomeCount} of ${esphomeYamlFiles.length} ESPHome YAML files.`);
    } catch (err) {
      console.error(`[backup-${source}] Error reading esphome directory:`, err.message);
    }
  } else {
    console.log(`[backup-${source}] Skipping ESPHome backups (feature disabled).`);
  }
  
  if (packagesEnabled) {
    // Backup Packages files
    const packagesPath = path.join(configPath, 'packages');
    const backupPackagesPath = path.join(backupPath, 'packages');

    try {
      await fs.mkdir(backupPackagesPath, { recursive: true });
      const packagesYamlFiles = await listYamlFilesRecursive(packagesPath);
      console.log(`[backup-${source}] Found ${packagesYamlFiles.length} Packages YAML files to copy.`);

      let copiedPackagesCount = 0;
      for (const relativePath of packagesYamlFiles) {
        const sourcePath = path.join(packagesPath, relativePath);
        const destPath = path.join(backupPackagesPath, relativePath);
        try {
          await fs.mkdir(path.dirname(destPath), { recursive: true });
          await fs.copyFile(sourcePath, destPath);
          copiedPackagesCount++;
        } catch (err) {
          if (err.code !== 'ENOENT') {
            console.error(`[backup-${source}] Error copying Packages file ${relativePath}:`, err.message);
          }
        }
      }
      console.log(`[backup-${source}] Copied ${copiedPackagesCount} of ${packagesYamlFiles.length} Packages YAML files.`);
    } catch (err) {
      console.error(`[backup-${source}] Error reading packages directory:`, err.message);
    }
  } else {
    console.log(`[backup-${source}] Skipping Packages backups (feature disabled).`);
  }
  
  console.log(`[backup-${source}] Backup completed successfully at:`, backupPath);

  // Cleanup old backups if maxBackups is enabled
  if (maxBackupsEnabled && maxBackupsCount > 0) {
    try {
      console.log(`[backup-${source}] Cleaning up old backups, keeping max ${maxBackupsCount}...`);
      await cleanupOldBackups(backupRoot, maxBackupsCount);
    } catch (cleanupError) {
      console.error(`[backup-${source}] Error during cleanup:`, cleanupError.message);
      // Don't fail the backup if cleanup fails
    }
  }

  return backupPath;
}

// Cleanup old backups function
async function cleanupOldBackups(backupRoot, maxBackupsCount) {
  try {
    console.log(`[cleanup] Scanning backup directory: ${backupRoot}`);
    const allBackups = await getBackupDirs(backupRoot);
    
    // Sort by folderName descending (newest first)
    allBackups.sort((a, b) => b.folderName.localeCompare(a.folderName));
    
    console.log(`[cleanup] Found ${allBackups.length} total backups, keeping max ${maxBackupsCount}`);
    
    if (allBackups.length <= maxBackupsCount) {
      console.log(`[cleanup] No cleanup needed - only ${allBackups.length} backups exist`);
      return;
    }
    
    // Get backups to delete (all beyond maxBackupsCount)
    const backupsToDelete = allBackups.slice(maxBackupsCount);
    console.log(`[cleanup] Will delete ${backupsToDelete.length} old backups`);
    
    for (const backup of backupsToDelete) {
      try {
        console.log(`[cleanup] Deleting old backup: ${backup.path}`);
        await fs.rm(backup.path, { recursive: true, force: true });
        console.log(`[cleanup] Successfully deleted: ${backup.path}`);
      } catch (deleteError) {
        console.error(`[cleanup] Error deleting ${backup.path}:`, deleteError.message);
        // Continue with other deletions even if one fails
      }
    }
    
    console.log(`[cleanup] Cleanup completed. Kept ${Math.min(allBackups.length, maxBackupsCount)} backups.`);
  } catch (error) {
    console.error('[cleanup] Error during cleanup:', error.message);
    throw error;
  }
}

// Backup now
app.post('/api/backup-now', async (req, res) => {
  try {
    const { liveConfigPath, backupFolderPath, maxBackupsEnabled, maxBackupsCount, timezone } = req.body;
    const backupPath = await performBackup(liveConfigPath, backupFolderPath, 'manual', maxBackupsEnabled, maxBackupsCount, timezone);
    res.json({ success: true, path: backupPath, message: `Backup created successfully at ${backupPath}` });
  } catch (error) {
    console.error('[backup-now] Error:', error);
    res.status(500).json({
      error: error.message,
      errorCode: error.code || 'BACKUP_FAILED',
      meta: error.meta || null
    });
  }
});

// Lovelace endpoints
app.post('/api/get-backup-lovelace', async (req, res) => {
  try {
    const { backupPath } = req.body;
    const lovelaceDir = path.join(backupPath, '.storage');
    
    const files = await fs.readdir(lovelaceDir);
    const lovelaceFiles = files.filter(f => f.startsWith('lovelace'));
    
    res.json({ lovelaceFiles });
  } catch (error) {
    console.error('[get-backup-lovelace] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

app.post('/api/get-backup-lovelace-file', async (req, res) => {
  try {
    const { backupPath, fileName } = req.body;
    const filePath = path.join(backupPath, '.storage', fileName);
    
    console.log(`[get-backup-lovelace-file] Request for file: ${fileName} in backup: ${backupPath}`);
    
    res.sendFile(filePath, (err) => {
      if (err) {
        console.error('[get-backup-lovelace-file] Error sending file:', err);
        res.status(err.status || 500).json({ error: err.message });
      }
    });
  } catch (error) {
    console.error('[get-backup-lovelace-file] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

const getLiveLovelaceFile = async (req, res) => {
  try {
    const payload = req.method === 'GET' ? req.query : req.body;
    const fileName = payload?.fileName;
    const liveConfigPath = payload?.liveConfigPath;

    if (!fileName) {
      return res.status(400).json({ error: 'fileName is required' });
    }

    const configPath = liveConfigPath || '/config';
    const filePath = path.join(configPath, '.storage', fileName);
    
    console.log(`[get-live-lovelace-file] Request for file: ${fileName} in config: ${configPath}`);
    
    res.sendFile(filePath, (err) => {
      if (err) {
        console.error('[get-live-lovelace-file] Error sending file:', err);
        res.status(err.status || 404).json({ error: 'File not found' });
      }
    });
  } catch (error) {
    console.error('[get-live-lovelace-file] Error:', error);
    res.status(404).json({ error: 'File not found' });
  }
};

app.get('/api/get-live-lovelace-file', getLiveLovelaceFile);
app.post('/api/get-live-lovelace-file', getLiveLovelaceFile);

app.post('/api/restore-lovelace-file', async (req, res) => {
  try {
    const { fileName, backupPath, content, timezone, liveConfigPath } = req.body;

    if (!fileName) {
      return res.status(400).json({ error: 'fileName is required' });
    }

    if (!backupPath && typeof content === 'undefined') {
      return res.status(400).json({ error: 'backupPath or content is required' });
    }

    // Perform a backup before restoring
    await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone);

    const configPath = liveConfigPath || '/config';
    const targetFilePath = path.join(configPath, '.storage', fileName);
    await fs.mkdir(path.dirname(targetFilePath), { recursive: true });

    if (backupPath) {
      const sourceFilePath = path.join(backupPath, '.storage', fileName);
      try {
        await fs.copyFile(sourceFilePath, targetFilePath);
      } catch (copyError) {
        console.error('[restore-lovelace-file] Copy from backup failed, falling back to write:', copyError.message);
        const backupContent = await fs.readFile(sourceFilePath, 'utf-8');
        await fs.writeFile(targetFilePath, backupContent, 'utf-8');
      }
    } else {
      const contentToWrite = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
      await fs.writeFile(targetFilePath, contentToWrite, 'utf-8');
    }

    // Check if HA config is available to determine if a restart is needed
    const auth = await getHomeAssistantAuth();
    const needsRestart = !!(auth.baseUrl && auth.token);

    res.json({ success: true, message: 'Lovelace file restored successfully', needsRestart });
  } catch (error) {
    console.error('[restore-lovelace-file] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// ESPHome endpoints
app.post('/api/get-backup-esphome', async (req, res) => {
  try {
    await loadDockerSettings();
    if (!(await isEsphomeEnabled())) {
      return res.status(404).json({ error: 'ESPHome feature disabled' });
    }
    const { backupPath } = req.body;
    const esphomeDir = path.join(backupPath, 'esphome');
    const esphomeFiles = await listYamlFilesRecursive(esphomeDir);
    res.json({ esphomeFiles });
  } catch (error) {
    console.error('[get-backup-esphome] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

app.post('/api/get-backup-esphome-file', async (req, res) => {
  try {
    if (!(await isEsphomeEnabled())) {
      return res.status(404).json({ error: 'ESPHome feature disabled' });
    }
    const { backupPath, fileName } = req.body;
    const esphomeDir = path.join(backupPath, 'esphome');
    const filePath = resolveWithinDirectory(esphomeDir, fileName);
    const content = await fs.readFile(filePath, 'utf-8');
    res.json({ content });
  } catch (error) {
    if (error.code === 'INVALID_PATH') {
      return res.status(400).json({ error: 'Invalid file path' });
    }
    console.error('[get-backup-esphome-file] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

app.post('/api/get-live-esphome-file', async (req, res) => {
  try {
    if (!(await isEsphomeEnabled())) {
      return res.status(404).json({ error: 'ESPHome feature disabled' });
    }
    const { fileName, liveConfigPath } = req.body;
    const configPath = liveConfigPath || '/config';
    const esphomeDir = path.join(configPath, 'esphome');
    const filePath = resolveWithinDirectory(esphomeDir, fileName);
    const content = await fs.readFile(filePath, 'utf-8');
    res.json({ content });
  } catch (error) {
    if (error.code === 'INVALID_PATH') {
      return res.status(400).json({ error: 'Invalid file path' });
    }
    console.error('[get-live-esphome-file] Error:', error);
    res.status(404).json({ error: 'File not found' });
  }
});

app.post('/api/restore-esphome-file', async (req, res) => {
  try {
    if (!(await isEsphomeEnabled())) {
      return res.status(404).json({ error: 'ESPHome feature disabled' });
    }
    const { fileName, content, timezone, liveConfigPath } = req.body;
    // Perform a backup before restoring
    await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone);

    const configPath = liveConfigPath || '/config';
    const esphomeDir = path.join(configPath, 'esphome');
    const filePath = resolveWithinDirectory(esphomeDir, fileName);
    await fs.mkdir(path.dirname(filePath), { recursive: true });

    // Handle content being an object or a string
    const contentToWrite = typeof content === 'string' ? content : yaml.dump(content);
    await fs.writeFile(filePath, contentToWrite, 'utf-8');
    
    // Check if HA config is available to determine if a restart is needed
    const auth = await getHomeAssistantAuth();
    const needsRestart = !!(auth.baseUrl && auth.token);

    res.json({ success: true, message: 'ESPHome file restored successfully', needsRestart });
  } catch (error) {
    console.error('[restore-esphome-file] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

// Packages endpoints
app.post('/api/get-backup-packages', async (req, res) => {
  try {
    await loadDockerSettings();
    if (!(await isPackagesEnabled())) {
      return res.status(404).json({ error: 'Packages feature disabled' });
    }
    const { backupPath } = req.body;
    const packagesDir = path.join(backupPath, 'packages');
    
    try {
      // Check if packages directory exists
      await fs.access(packagesDir);
      const packageFiles = await listYamlFilesRecursive(packagesDir);
      return res.json({ packagesFiles: packageFiles });
    } catch (dirError) {
      if (dirError.code === 'ENOENT') {
        // Directory doesn't exist, return empty array
        return res.json({ packagesFiles: [] });
      }
      throw dirError; // Re-throw other errors
    }
  } catch (error) {
    console.error('[get-backup-packages] Error:', error);
    if (error.code === 'ENOENT') {
      return res.json({ packagesFiles: [] });
    }
    res.status(500).json({ error: error.message });
  }
});

app.post('/api/get-backup-packages-file', async (req, res) => {
  try {
    if (!(await isPackagesEnabled())) {
      return res.status(404).json({ error: 'Packages feature disabled' });
    }
    const { backupPath, fileName } = req.body;
    const packagesDir = path.join(backupPath, 'packages');
    const filePath = resolveWithinDirectory(packagesDir, fileName);
    const content = await fs.readFile(filePath, 'utf-8');
    res.json({ content });
  } catch (error) {
    if (error.code === 'INVALID_PATH') {
      return res.status(400).json({ error: 'Invalid file path' });
    }
    console.error('[get-backup-packages-file] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

app.post('/api/get-live-packages-file', async (req, res) => {
  try {
    if (!(await isPackagesEnabled())) {
      return res.status(404).json({ error: 'Packages feature disabled' });
    }
    const { fileName, liveConfigPath } = req.body;
    const configPath = liveConfigPath || '/config';
    const packagesDir = path.join(configPath, 'packages');
    const filePath = resolveWithinDirectory(packagesDir, fileName);
    const content = await fs.readFile(filePath, 'utf-8');
    res.json({ content });
  } catch (error) {
    if (error.code === 'INVALID_PATH') {
      return res.status(400).json({ error: 'Invalid file path' });
    }
    if (error.code === 'ENOENT') {
      return res.status(404).json({ error: 'File not found' });
    }
    console.error('[get-live-packages-file] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

app.post('/api/restore-packages-file', async (req, res) => {
  try {
    if (!(await isPackagesEnabled())) {
      return res.status(404).json({ error: 'Packages feature disabled' });
    }
    const { fileName, content, timezone, liveConfigPath } = req.body;
    // Perform a backup before restoring
    await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone);

    const configPath = liveConfigPath || '/config';
    const packagesDir = path.join(configPath, 'packages');
    const filePath = resolveWithinDirectory(packagesDir, fileName);
    await fs.mkdir(path.dirname(filePath), { recursive: true });

    // Handle content being an object or a string
    const contentToWrite = typeof content === 'string' ? content : yaml.dump(content);
    await fs.writeFile(filePath, contentToWrite, 'utf-8');
    
    // Check if HA config is available to determine if a restart is needed
    const auth = await getHomeAssistantAuth();
    const needsRestart = !!(auth.baseUrl && auth.token);

    res.json({ success: true, message: 'Package file restored successfully', needsRestart });
  } catch (error) {
    console.error('[restore-packages-file] Error:', error);
    res.status(500).json({ error: error.message });
  }
});

app.get('/api/health', async (req, res) => {
  try {
    const options = await getAddonOptions();
    res.json({
      ok: true,
      version,
      mode: options.mode,
      timestamp: Date.now()
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Start server
app.listen(PORT, HOST, () => {
  console.log('='.repeat(60));
  console.log(`Home Assistant Time Machine v${version}`);
  console.log('='.repeat(60));
  console.log(`Server running at http://${HOST}:${PORT}`);
  if (INGRESS_PATH) {
    console.log(`[ingress] Ingress path detected: ${INGRESS_PATH}`);
  }

  // Initialize scheduled jobs
  loadScheduledJobs().then(jobs => {
    console.log('[scheduler] Loaded schedules:', jobs.jobs);
    console.log('[scheduler] Initializing schedules on startup...');
    Object.entries(jobs.jobs || {}).forEach(([id, job]) => {
      if (job.enabled) {
        console.log(`[scheduler] Setting up schedule "${id}" with cron "${job.cronExpression}" and timezone "${job.timezone}"`);
        scheduledJobs[id] = cron.schedule(job.cronExpression, async () => {
          console.log(`[cron] Triggered backup job: ${id} at ${new Date().toISOString()}`);
          try {
            console.log(`[cron] Fetching addon options for job ${id}...`);
            const options = await getAddonOptions();
            const sanitizedOptions = JSON.parse(JSON.stringify(options));
            if (sanitizedOptions.long_lived_access_token) {
              sanitizedOptions.long_lived_access_token = 'REDACTED';
            }
            console.log(`[cron] Addon options for job ${id}:`, sanitizedOptions);
            try {
              const response = await fetch(`http://localhost:${PORT}/api/backup-now`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                  liveConfigPath: job.liveConfigPath || options.liveConfigPath || '/config',
                  backupFolderPath: job.backupFolderPath || options.backupFolderPath || '/media/timemachine',
                  maxBackupsEnabled: job.maxBackupsEnabled,
                  maxBackupsCount: job.maxBackupsCount
                })
              });
              const result = await response.json();
              if (response.ok) {
                console.log(`[cron] Backup triggered successfully: ${result.message}`);
              } else {
                console.error(`[cron] Backup trigger failed: ${result.error}`);
              }
            } catch (error) {
              console.error(`[cron] Error triggering backup:`, error);
            }
          } catch (error) {
            console.error(`[cron] Error during scheduled backup for job ${id}:`, error);
          }
        }, { timezone: job.timezone });
      }
    });
    console.log('[scheduler] Initialization complete.');
  });
});

```

## /homeassistant-time-machine/config.yaml

```yaml path="/homeassistant-time-machine/config.yaml" 
name: Home Assistant Time Machine
version: 2.0.2
slug: homeassistant-time-machine
description: Browse and restore Home Assistant configuration backups.
url: https://github.com/saihgupr/HomeAssistantTimeMachine
changelog: https://github.com/saihgupr/HomeAssistantTimeMachine/blob/main/homeassistant-time-machine/CHANGELOG.md
arch:
  - amd64
  - aarch64
startup: application
boot: auto
init: false
webui: http://[HOST]:[PORT:54000]
hassio_api: true
auth_api: true
homeassistant_api: true
hassio_role: default
ingress: true
ingress_port: 54000
panel_icon: mdi:history
panel_title: Time Machine
map: [config:rw, backup:rw, media:rw, share:rw, ssl:rw, addons:rw, addon_configs:rw]
ports:
  54000/tcp: null
options:
  text_style: default
  theme: dark
  esphome: true
  packages: true
schema:
  text_style: list(default|pirate|hacker|noir_detective|personal_trainer|scooby_doo)
  theme: list(dark|light)
  esphome: bool?
  packages: bool?
```

## /homeassistant-time-machine/data/docker-app-settings.json

```json path="/homeassistant-time-machine/data/docker-app-settings.json" 
{
  "liveConfigPath": "/config",
  "backupFolderPath": "/media/timemachine",
  "textStyle": "default",
  "theme": "dark"
}
```

## /homeassistant-time-machine/data/scheduled-jobs.json

```json path="/homeassistant-time-machine/data/scheduled-jobs.json" 
{
  "jobs": {
    "default-backup-job": {
      "cronExpression": "0 0 * * *",
      "enabled": false,
      "timezone": "America/New_York",
      "liveConfigPath": "/config/",
      "backupFolderPath": "/media/timemachine",
      "maxBackupsEnabled": false,
      "maxBackupsCount": 100
    }
  }
}
```

## /homeassistant-time-machine/docker-compose.yml

```yml path="/homeassistant-time-machine/docker-compose.yml" 
version: '3.8'

services:
  app:
    build: .
    ports:
      - "54000:54000"
    volumes:
      - .:/app
    environment:
      - NODE_ENV=development
      - HOST=0.0.0.0
    restart: unless-stopped

```

## /homeassistant-time-machine/icon.png

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/homeassistant-time-machine/icon.png

## /homeassistant-time-machine/package-lock.json

```json path="/homeassistant-time-machine/package-lock.json" 
{
  "name": "home-assistant-time-machine",
  "version": "2.9.376",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "home-assistant-time-machine",
      "version": "2.9.376",
      "dependencies": {
        "ejs": "^3.1.9",
        "express": "^4.18.2",
        "js-yaml": "^4.1.0",
        "node-cron": "^4.2.1",
        "node-fetch": "^2.6.12"
      }
    },
    "node_modules/accepts": {
      "version": "1.3.8",
      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
      "license": "MIT",
      "dependencies": {
        "mime-types": "~2.1.34",
        "negotiator": "0.6.3"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/argparse": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
      "license": "Python-2.0"
    },
    "node_modules/array-flatten": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
      "license": "MIT"
    },
    "node_modules/async": {
      "version": "3.2.6",
      "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
      "license": "MIT"
    },
    "node_modules/balanced-match": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
      "license": "MIT"
    },
    "node_modules/body-parser": {
      "version": "1.20.3",
      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
      "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
      "license": "MIT",
      "dependencies": {
        "bytes": "3.1.2",
        "content-type": "~1.0.5",
        "debug": "2.6.9",
        "depd": "2.0.0",
        "destroy": "1.2.0",
        "http-errors": "2.0.0",
        "iconv-lite": "0.4.24",
        "on-finished": "2.4.1",
        "qs": "6.13.0",
        "raw-body": "2.5.2",
        "type-is": "~1.6.18",
        "unpipe": "1.0.0"
      },
      "engines": {
        "node": ">= 0.8",
        "npm": "1.2.8000 || >= 1.4.16"
      }
    },
    "node_modules/brace-expansion": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
      "license": "MIT",
      "dependencies": {
        "balanced-match": "^1.0.0"
      }
    },
    "node_modules/bytes": {
      "version": "3.1.2",
      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/call-bind-apply-helpers": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/call-bound": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "get-intrinsic": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/content-disposition": {
      "version": "0.5.4",
      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
      "license": "MIT",
      "dependencies": {
        "safe-buffer": "5.2.1"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/content-type": {
      "version": "1.0.5",
      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/cookie": {
      "version": "0.7.1",
      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
      "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/cookie-signature": {
      "version": "1.0.6",
      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
      "license": "MIT"
    },
    "node_modules/debug": {
      "version": "2.6.9",
      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
      "license": "MIT",
      "dependencies": {
        "ms": "2.0.0"
      }
    },
    "node_modules/depd": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/destroy": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8",
        "npm": "1.2.8000 || >= 1.4.16"
      }
    },
    "node_modules/dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.1",
        "es-errors": "^1.3.0",
        "gopd": "^1.2.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/ee-first": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
      "license": "MIT"
    },
    "node_modules/ejs": {
      "version": "3.1.10",
      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
      "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
      "license": "Apache-2.0",
      "dependencies": {
        "jake": "^10.8.5"
      },
      "bin": {
        "ejs": "bin/cli.js"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/encodeurl": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-errors": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-object-atoms": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/escape-html": {
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
      "license": "MIT"
    },
    "node_modules/etag": {
      "version": "1.8.1",
      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/express": {
      "version": "4.21.2",
      "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
      "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
      "license": "MIT",
      "dependencies": {
        "accepts": "~1.3.8",
        "array-flatten": "1.1.1",
        "body-parser": "1.20.3",
        "content-disposition": "0.5.4",
        "content-type": "~1.0.4",
        "cookie": "0.7.1",
        "cookie-signature": "1.0.6",
        "debug": "2.6.9",
        "depd": "2.0.0",
        "encodeurl": "~2.0.0",
        "escape-html": "~1.0.3",
        "etag": "~1.8.1",
        "finalhandler": "1.3.1",
        "fresh": "0.5.2",
        "http-errors": "2.0.0",
        "merge-descriptors": "1.0.3",
        "methods": "~1.1.2",
        "on-finished": "2.4.1",
        "parseurl": "~1.3.3",
        "path-to-regexp": "0.1.12",
        "proxy-addr": "~2.0.7",
        "qs": "6.13.0",
        "range-parser": "~1.2.1",
        "safe-buffer": "5.2.1",
        "send": "0.19.0",
        "serve-static": "1.16.2",
        "setprototypeof": "1.2.0",
        "statuses": "2.0.1",
        "type-is": "~1.6.18",
        "utils-merge": "1.0.1",
        "vary": "~1.1.2"
      },
      "engines": {
        "node": ">= 0.10.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/express"
      }
    },
    "node_modules/filelist": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
      "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
      "license": "Apache-2.0",
      "dependencies": {
        "minimatch": "^5.0.1"
      }
    },
    "node_modules/finalhandler": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
      "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
      "license": "MIT",
      "dependencies": {
        "debug": "2.6.9",
        "encodeurl": "~2.0.0",
        "escape-html": "~1.0.3",
        "on-finished": "2.4.1",
        "parseurl": "~1.3.3",
        "statuses": "2.0.1",
        "unpipe": "~1.0.0"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/forwarded": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/fresh": {
      "version": "0.5.2",
      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/function-bind": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-intrinsic": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "function-bind": "^1.1.2",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "math-intrinsics": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-symbols": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/hasown": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
      "license": "MIT",
      "dependencies": {
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/http-errors": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
      "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
      "license": "MIT",
      "dependencies": {
        "depd": "2.0.0",
        "inherits": "2.0.4",
        "setprototypeof": "1.2.0",
        "statuses": "2.0.1",
        "toidentifier": "1.0.1"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/iconv-lite": {
      "version": "0.4.24",
      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
      "license": "MIT",
      "dependencies": {
        "safer-buffer": ">= 2.1.2 < 3"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/inherits": {
      "version": "2.0.4",
      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
      "license": "ISC"
    },
    "node_modules/ipaddr.js": {
      "version": "1.9.1",
      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.10"
      }
    },
    "node_modules/jake": {
      "version": "10.9.4",
      "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
      "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
      "license": "Apache-2.0",
      "dependencies": {
        "async": "^3.2.6",
        "filelist": "^1.0.4",
        "picocolors": "^1.1.1"
      },
      "bin": {
        "jake": "bin/cli.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/js-yaml": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
      "license": "MIT",
      "dependencies": {
        "argparse": "^2.0.1"
      },
      "bin": {
        "js-yaml": "bin/js-yaml.js"
      }
    },
    "node_modules/math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/media-typer": {
      "version": "0.3.0",
      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/merge-descriptors": {
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/methods": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/mime": {
      "version": "1.6.0",
      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
      "license": "MIT",
      "bin": {
        "mime": "cli.js"
      },
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/mime-db": {
      "version": "1.52.0",
      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/mime-types": {
      "version": "2.1.35",
      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
      "license": "MIT",
      "dependencies": {
        "mime-db": "1.52.0"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/minimatch": {
      "version": "5.1.6",
      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
      "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
      "license": "ISC",
      "dependencies": {
        "brace-expansion": "^2.0.1"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/ms": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
      "license": "MIT"
    },
    "node_modules/negotiator": {
      "version": "0.6.3",
      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/node-cron": {
      "version": "4.2.1",
      "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
      "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
      "license": "ISC",
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/node-fetch": {
      "version": "2.7.0",
      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
      "license": "MIT",
      "dependencies": {
        "whatwg-url": "^5.0.0"
      },
      "engines": {
        "node": "4.x || >=6.0.0"
      },
      "peerDependencies": {
        "encoding": "^0.1.0"
      },
      "peerDependenciesMeta": {
        "encoding": {
          "optional": true
        }
      }
    },
    "node_modules/object-inspect": {
      "version": "1.13.4",
      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/on-finished": {
      "version": "2.4.1",
      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
      "license": "MIT",
      "dependencies": {
        "ee-first": "1.1.1"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/parseurl": {
      "version": "1.3.3",
      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/path-to-regexp": {
      "version": "0.1.12",
      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
      "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
      "license": "MIT"
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
      "license": "ISC"
    },
    "node_modules/proxy-addr": {
      "version": "2.0.7",
      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
      "license": "MIT",
      "dependencies": {
        "forwarded": "0.2.0",
        "ipaddr.js": "1.9.1"
      },
      "engines": {
        "node": ">= 0.10"
      }
    },
    "node_modules/qs": {
      "version": "6.13.0",
      "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
      "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
      "license": "BSD-3-Clause",
      "dependencies": {
        "side-channel": "^1.0.6"
      },
      "engines": {
        "node": ">=0.6"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/range-parser": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/raw-body": {
      "version": "2.5.2",
      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
      "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
      "license": "MIT",
      "dependencies": {
        "bytes": "3.1.2",
        "http-errors": "2.0.0",
        "iconv-lite": "0.4.24",
        "unpipe": "1.0.0"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/safe-buffer": {
      "version": "5.2.1",
      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/safer-buffer": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
      "license": "MIT"
    },
    "node_modules/send": {
      "version": "0.19.0",
      "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
      "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
      "license": "MIT",
      "dependencies": {
        "debug": "2.6.9",
        "depd": "2.0.0",
        "destroy": "1.2.0",
        "encodeurl": "~1.0.2",
        "escape-html": "~1.0.3",
        "etag": "~1.8.1",
        "fresh": "0.5.2",
        "http-errors": "2.0.0",
        "mime": "1.6.0",
        "ms": "2.1.3",
        "on-finished": "2.4.1",
        "range-parser": "~1.2.1",
        "statuses": "2.0.1"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/send/node_modules/encodeurl": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
      "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/send/node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
      "license": "MIT"
    },
    "node_modules/serve-static": {
      "version": "1.16.2",
      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
      "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
      "license": "MIT",
      "dependencies": {
        "encodeurl": "~2.0.0",
        "escape-html": "~1.0.3",
        "parseurl": "~1.3.3",
        "send": "0.19.0"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/setprototypeof": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
      "license": "ISC"
    },
    "node_modules/side-channel": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "object-inspect": "^1.13.3",
        "side-channel-list": "^1.0.0",
        "side-channel-map": "^1.0.1",
        "side-channel-weakmap": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/side-channel-list": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "object-inspect": "^1.13.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/side-channel-map": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.5",
        "object-inspect": "^1.13.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/side-channel-weakmap": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.5",
        "object-inspect": "^1.13.3",
        "side-channel-map": "^1.0.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/statuses": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
      "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/toidentifier": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
      "license": "MIT",
      "engines": {
        "node": ">=0.6"
      }
    },
    "node_modules/tr46": {
      "version": "0.0.3",
      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
      "license": "MIT"
    },
    "node_modules/type-is": {
      "version": "1.6.18",
      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
      "license": "MIT",
      "dependencies": {
        "media-typer": "0.3.0",
        "mime-types": "~2.1.24"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/unpipe": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/utils-merge": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.4.0"
      }
    },
    "node_modules/vary": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
      "license": "MIT",
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/webidl-conversions": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
      "license": "BSD-2-Clause"
    },
    "node_modules/whatwg-url": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
      "license": "MIT",
      "dependencies": {
        "tr46": "~0.0.3",
        "webidl-conversions": "^3.0.0"
      }
    }
  }
}

```

## /homeassistant-time-machine/package.json

```json path="/homeassistant-time-machine/package.json" 
{
  "name": "home-assistant-time-machine",
  "version": "2.0.2",
  "description": "Browse and restore Home Assistant configuration backups",
  "private": true,
  "scripts": {
    "start": "node app.js",
    "dev": "node app.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "ejs": "^3.1.9",
    "js-yaml": "^4.1.0",
    "node-cron": "^4.2.1",
    "node-fetch": "^2.6.12"
  },
  "overrides": {
    "brace-expansion": "2.0.2"
  }
}

```

## /homeassistant-time-machine/public/css/style.css

```css path="/homeassistant-time-machine/public/css/style.css" 
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
  background-color: #171717;
  color: #ededed;
  min-height: 100vh;
  padding: 24px;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
  overflow-y: auto;
  position: relative; /* For absolute positioning of children */
}

body[data-theme='light'] {
  background-color: #f3f4f6;
  color: #1f2937;
}

html.theme-preload *,
html.theme-transition-suppress * {
  transition: none !important;
  animation: none !important;
}

.container {
  max-width: 1280px;
  margin: 0 auto;
  padding-bottom: 24px;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 32px;
  gap: 16px;
  flex-wrap: wrap;
}

.header-content {
  display: flex;
  align-items: center;
  gap: 20px;
}

.logo {
  width: 56px;
  height: 56px;
  border-radius: 12px;
  background-color: transparent;
  box-shadow: none;
  filter: drop-shadow(0 6px 16px rgba(15, 23, 42, 0.22));
  -webkit-filter: drop-shadow(0 6px 16px rgba(15, 23, 42, 0.22));
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  display: block;
}

body[data-theme='dark'] .logo {
  filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.45));
  -webkit-filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.45));
}

.header h1 {
  font-size: 26px;
  font-weight: 600;
  margin-bottom: 6px;
  color: #F9F9F9;
  letter-spacing: -0.02em;
}

body[data-theme='light'] .header h1 {
  color: #111827;
}

.version {
  font-size: 14px;
  color: #A2A2A2;
  font-weight: 400;
}

body[data-theme='light'] .version {
  color: #4b5563;
}


.hero-subtitle,
#heroSubtitle {
  color: #A2A2A2;
}

body[data-theme='light'] .hero-subtitle,
body[data-theme='light'] #heroSubtitle {
  color: #6b7280;
}

#diffTitle {
  font-size: 18px;
  font-weight: 600;
  margin-bottom: 4px;
}

#diffSubtitle {
  font-size: 14px;
  color: #60a5fa;
  font-weight: 500;
}

.tabs {
  display: flex;
  gap: 4px;
  background-color: #232323;
  padding: 4px;
  border-radius: 12px;
  border: 1px solid rgba(255, 255, 255, 0.05);
  margin-bottom: 24px;
  width: fit-content;
  max-width: 100%;
  overflow-x: auto;
  scrollbar-width: none;
  backdrop-filter: blur(12px);
}

.tabs::-webkit-scrollbar {
  display: none;
}

body[data-theme='light'] .tabs {
  background-color: rgba(248, 250, 252, 0.9);
  border: 1px solid rgba(148, 163, 184, 0.2);
  box-shadow: 0 8px 20px -12px rgba(15, 23, 42, 0.35);
}

.tab {
  padding: 12px 28px;
  border-radius: 12px;
  font-weight: 500;
  font-size: 14px;
  border: none;
  cursor: pointer;
  background-color: transparent;
  color: #A2A2A2;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  height: 44px;
  flex: 0 0 auto;
  letter-spacing: -0.01em;
}

body[data-theme='light'] .tab {
  color: #475569;
}

.tab.active {
  background: #007AFF;
  color: #FAFAFA;
}

body[data-theme='light'] .tab.active {
  color: white;
}

.content {
  display: grid;
  grid-template-columns: 1fr 2fr;
  gap: 24px;
  height: calc(100vh - 220px);
}

.panel {
  background-color: #232323;
  border-radius: 16px;
  border: 1px solid rgba(255, 255, 255, 0.05);
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

body[data-theme='light'] .panel {
  background: #ffffff;
  border: 1px solid rgba(148, 163, 184, 0.22);
  box-shadow: 0 20px 32px -24px rgba(15, 23, 42, 0.22);
}

.panel-header {
  padding: 24px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

body[data-theme='light'] .panel-header {
  border-bottom: 1px solid rgba(148, 163, 184, 0.18);
}

.panel-header-top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  margin-bottom: 16px;
  gap: 16px;
  flex-wrap: wrap;
}

.panel-header h2 {
  font-size: 19px;
  font-weight: 600;
  margin-bottom: 6px;
  color: #F9F9F9;
  letter-spacing: -0.02em;
}

body[data-theme='light'] .panel-header h2 {
  color: #0f172a;
}

.panel-header p {
  font-size: 14px;
  color: #A2A2A2;
  font-weight: 400;
}

body[data-theme='light'] .panel-header p {
  color: #6b7280;
}

#itemsSubtitle {
  color: #9ca3af;
}

body[data-theme='light'] #itemsSubtitle {
  color: #6b7280;
}

.controls {
  display: flex;
  gap: 12px;
  margin-top: 20px;
}

.search-wrapper {
  flex: 1;
  position: relative;
}

.search-input {
  width: 100%;
  padding: 14px 18px;
  padding-left: 48px;
  background-color: rgba(35, 35, 35, 0.6);
  border: 1px solid rgba(231, 229, 228, 0.1);
  border-radius: 14px;
  color: #E5E5E5;
  font-size: 14px;
  transition: all 0.3s ease;
}

body[data-theme='light'] .search-input {
  background: rgba(255, 255, 255, 0.96);
  border: 1px solid rgba(148, 163, 184, 0.12);
  color: #0f172a;
}

.search-section {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
}

.search-icon {
  position: absolute;
  left: 18px;
  width: 20px;
  height: 20px;
  color: #a2a2a2;
  pointer-events: none;
}

body[data-theme='light'] .search-icon {
  color: #94a3b8;
}

.empty-state-icon {
  width: 64px;
  height: 64px;
  border-radius: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 16px;
  background-color: rgba(255, 255, 255, 0.05);
}

.empty-state-icon__svg {
  width: 32px;
  height: 32px;
  color: #6b7280;
  margin-bottom: 12px;
}

body[data-theme='light'] .empty-state-icon {
  background-color: rgba(148, 163, 184, 0.12);
}

body[data-theme='light'] .empty-state-icon__svg {
  color: #4b5563;
}

.search-input:focus {
  outline: none;
  border-color: rgba(0,122,255, 0.3);
  box-shadow: 0 0 0 3px rgba(0,122,255, 0.1);
}

.search-input::placeholder {
  color: #717171;
}

body[data-theme='light'] .search-input::placeholder {
  color: #9ca3af;
}

.select {
  padding: 8px 40px 8px 16px;
  background-color: rgba(35, 35, 35, 0.6);
  border: 1px solid rgba(231, 229, 228, 0.1);
  border-radius: 12px;
  color: #D3D3D3;
  font-size: 14px;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
  outline: none;
  height: 45px;
  transition: all 0.3s ease;
  background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 fill=%27none%27 viewBox=%270 0 20 20%27%3e%3cpath stroke=%27%23A2A2A2%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%271.5%27 d=%27M6 8l4 4 4-4%27/%3e%3c/svg%3e');
  background-position: right 16px center;
  background-repeat: no-repeat;
  background-size: 1em;
  padding-right: 40px;
  box-shadow: none;
}

body[data-theme='light'] .select {
  background: rgba(255, 255, 255, 0.96);
  border: 1px solid rgba(148, 163, 184, 0.3);
  color: #0f172a;
  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
  -webkit-appearance: none;
  appearance: none;
  background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 fill=%27none%27 viewBox=%270 0 20 20%27%3e%3cpath stroke=%27%236b7280%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%271.5%27 d=%27M6 8l4 4 4-4%27/%3e%3c/svg%3e');
  background-position: right 16px center;
  background-repeat: no-repeat;
  background-size: 1em;
  padding-right: 40px;
  box-shadow: none;
}

.select:hover {
  border-color: rgba(231, 229, 228, 0.15);
}

body[data-theme='light'] .select:hover {
  border-color: rgba(148, 163, 184, 0.2);
}

.list {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0; /* Allow list to shrink below content width */
}

#itemsList, ul, li {
  list-style: none;
}

.list-item {
  padding: 12px 16px;
  border-radius: 12px;
  cursor: pointer;
  background-color: rgb(56, 56, 56);
  border: 1px solid rgba(255, 255, 255, 0.08);
  background: rgba(56, 56, 56, 0.8);
  color: #D3D3D3;
  margin-bottom: 10px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  min-width: 0; /* Allow item to shrink below content width */
}

body[data-theme='light'] .list-item {
  background: #ffffff;
  border: 1px solid rgba(148, 163, 184, 0.22);
  color: #475569; /* Matching the tab text color */
  box-shadow: 0 12px 26px -20px rgba(15, 23, 42, 0.22);
}

.list-item:hover {
  background: rgba(56, 56, 56, 0.95);
  border-color: rgba(231, 229, 228, 0.15);
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

body[data-theme='light'] .list-item:hover {
  background: #ffffff;
  border-color: rgba(148, 163, 184, 0.3);
  box-shadow: 0 16px 32px -22px rgba(15, 23, 42, 0.3), 0 6px 16px -12px rgba(15, 23, 42, 0.26);
}

.list-item.active {
  background: #0070e6;
  color: #FAFAFA;
  box-shadow: none;
  border-color: transparent;
}

body[data-theme='light'] .list-item.active {
  background: #0070e6;
  box-shadow: none;
  position: relative;
  z-index: 1;
}

.item-content {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 1;
  min-width: 0; /* Allow content to shrink */
  overflow: hidden; /* Hide overflow */
}

.item-content > span {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0; /* Allow text to truncate */
}

.item-badges {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-shrink: 0; /* Prevent badges from shrinking */
  margin-left: 12px;
}

/* Item badges moved up to be with item-content styles */

.dot {
  display: none;
}

.list-item.active .dot {
  background-color: #FFF9F6;
}

.badge {
  padding: 5px 14px;
  font-size: 12px;
  font-weight: 500;
  border-radius: 9999px;
  border: 1px solid;
  letter-spacing: 0.01em;
}

.badge-changed {
  background: rgba(249, 115, 22, 0.15);
  color: #f97316;
  border-color: rgba(249, 115, 22, 0.25);
}

.badge-deleted {
  background: rgba(239, 68, 68, 0.15);
  color: #ef4444;
  border-color: rgba(239, 68, 68, 0.25);
}

.badge-unchanged {
  background: rgba(34, 197, 94, 0.15);
  color: #22c55e;
  border-color: rgba(34, 197, 94, 0.25);
}

body[data-theme='light'] .badge-changed {
  background: rgba(249, 115, 22, 0.1);
  color: #c2410c;
  border-color: rgba(249, 115, 22, 0.16);
}

body[data-theme='light'] .badge-deleted {
  background: rgba(239, 68, 68, 0.1);
  color: #b91c1c;
  border-color: rgba(239, 68, 68, 0.16);
}

body[data-theme='light'] .badge-unchanged {
  background: rgba(34, 197, 94, 0.1);
  color: #15803d;
  border-color: rgba(34, 197, 94, 0.16);
}

.chevron {
  width: 20px;
  height: 20px;
  color: #717171;
  transition: all 0.3s ease;
}

.list-item:hover .chevron {
  color: #A2A2A2;
}

.list-item.active .chevron {
  color: #FAFAFA;
}

body[data-theme='light'] .chevron {
  color: #9ca3af;
}

body[data-theme='light'] .list-item:hover .chevron {
  color: #64748b;
}

body[data-theme='light'] .list-item.active .chevron {
  color: #f8fafc;
}

.loading, .empty-state, .error {
  text-align: center;
  color: #A2A2A2;
  padding: 48px 24px;
  font-weight: 400;
}

body[data-theme='light'] .loading,
body[data-theme='light'] .empty-state {
  color: #6b7280;
}

.error {
  color: #ef4444;
}

.status-message {
  margin-top: 10px;
  font-size: 13px;
  padding: 10px;
  border-radius: 8px;
}

.settings-inline-text.status-success {
  color: #22c55e;
}

.settings-inline-text.status-error {
  color: #ef4444;
}

.backup-status-error {
  background: rgba(239, 68, 68, 0.15);
  color: #ef4444;
}

.backup-status-success {
  background: rgba(34, 197, 94, 0.15);
  color: #22c55e;
}

body[data-theme='light'] .backup-status-error {
  background: rgba(239, 68, 68, 0.12);
}

body[data-theme='light'] .backup-status-success {
  background: rgba(34, 197, 94, 0.12);
}

.btn-primary, .btn-secondary {
  padding: 14px 28px;
  border-radius: 12px;
  font-weight: 500;
  font-size: 14px;
  border: none;
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  letter-spacing: -0.01em;
}

.btn-primary {
  background: #0070e6;
  color: #FAFAFA;
  box-shadow: none;
  transition: transform 0.2s ease, background-color 0.2s ease;
}

.btn-primary:hover {
  transform: translateY(-1px);
  background: #0070e6;
  box-shadow: none;
}

.btn-secondary {
  background-color: rgba(35, 35, 35, 0.6);
  color: #D3D3D3;
  border: 1px solid rgba(231, 229, 228, 0.1);
}

body[data-theme='light'] .btn-secondary {
  background-color: rgb(246, 248, 251);
  color: #0f172a;
  border: 1px solid #e5e7eb;
  box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.24);
}

body[data-theme='light'] .btn-secondary:hover {
  background-color: #ffffff;
  border-color: rgba(148, 163, 184, 0.18);
}

/* Modals */
.modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal-backdrop {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(8px);
}

body[data-theme='light'] .modal-backdrop {
  background: rgba(30, 41, 59, 0.35);
}

.modal-dialog {
  position: relative;
  background: rgba(35, 35, 35, 0.95);
  border-radius: 24px;
  border: 1px solid rgba(231, 229, 228, 0.1);
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
  width: 90%;
  max-width: 1200px;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
  backdrop-filter: blur(20px);
}

body[data-theme='light'] .modal-dialog {
  background: rgba(255, 255, 255, 0.97);
  border: 1px solid rgba(148, 163, 184, 0.28);
  box-shadow: 0 28px 60px -24px rgba(15, 23, 42, 0.35);
}

.settings-modal {
  width: 50vw;
  max-width: 50%;
}

.settings-card {
  position: relative;
  z-index: 1001;
  width: 100%;
  max-width: 400px;
  background-color: #232323;
  padding: 20px;
  border-radius: 16px;
  border: 1px solid rgba(255, 255, 255, 0.05);
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
  overflow-y: auto;
  max-height: calc(90vh - 40px);
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE and Edge */
}

/* Hide scrollbar for Chrome, Safari and Opera */
.settings-card::-webkit-scrollbar {
  display: none;
  width: 0;
  height: 0;
}

body[data-theme='light'] .settings-card {
  background-color: rgba(255, 255, 255, 0.95);
  border: 1px solid rgba(148, 163, 184, 0.25);
  box-shadow: 0 25px 55px -18px rgba(15, 23, 42, 0.28);
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE and Edge */
}

.settings-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

#settingsTitle {
  font-size: 18px;
  font-weight: 600;
  margin-bottom: 24px;
}

.settings-header h2 {
  font-size: 18px;
  font-weight: 600;
  color: white;
  margin: 0;
}

body[data-theme='light'] .settings-header h2 {
  color: #0f172a;
}

.modal-header {
  padding: 28px;
  border-bottom: 1px solid rgba(231, 229, 228, 0.08);
  display: flex;
  justify-content: space-between;
  align-items: center;
  background-color: rgba(45, 45, 45, 0.7);
  color: #E5E5E5;
  border-radius: 24px 24px 0 0;
}

body[data-theme='light'] .modal-header {
  background-color: rgba(248, 250, 252, 0.92);
  color: #0f172a;
  border-bottom: 1px solid rgba(203, 213, 225, 0.55);
}

.modal-body {
  padding: 24px;
  overflow-y: auto;
  flex: 1;
  max-height: calc(90vh - 200px);
  color: #9ca3af;
  font-size: 14px;
}

body[data-theme='light'] .modal-body {
  color: #475569;
  background-color: #fff;
}

.modal-footer {
  padding: 16px 24px;
  background-color: rgba(45, 45, 45, 0.7);
  border-top: 1px solid rgba(255, 255, 255, 0.05);
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  border-bottom-left-radius: 16px;
  border-bottom-right-radius: 16px;
}

body[data-theme='light'] .modal-footer {
  background-color: rgba(248, 250, 252, 0.92);
  border-top: 1px solid rgba(203, 213, 225, 0.55);
}

.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 28px;
  padding-top: 28px;
  border-top: 1px solid rgba(231, 229, 228, 0.08);
}

body[data-theme='light'] .modal-actions {
  border-top: 1px solid rgba(203, 213, 225, 0.55);
}

/* Diff Viewer - Claude-Inspired Design */
.diff-controls {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  padding-bottom: 20px;
  border-bottom: 1px solid rgba(231, 229, 228, 0.08);
}

body[data-theme='light'] .diff-controls {
  border-bottom: 1px solid rgba(203, 213, 225, 0.55);
}

.diff-status {
  font-size: 14px;
  font-weight: 500;
  padding: 6px 16px;
  border-radius: 9999px;
  letter-spacing: 0.01em;
}

.status-unchanged {
  background: rgba(200, 200, 200, 0.15);
  color: #C8C8C8;
  border: 1px solid rgba(200, 200, 200, 0.25);
}

.status-changed {
  background: rgba(196, 196, 196, 0.15);
  color: #C4C4C4;
  border: 1px solid rgba(196, 196, 196, 0.25);
}

.status-deleted {
  background: rgba(191, 191, 191, 0.15);
  color: #BFBFBF;
  border: 1px solid rgba(191, 191, 191, 0.25);
}

body[data-theme='light'] .status-unchanged {
  background: rgba(226, 232, 240, 0.8);
  color: #475569;
  border-color: rgba(203, 213, 225, 0.8);
}

body[data-theme='light'] .status-changed {
  background: rgba(254, 226, 226, 0.8);
  color: #b91c1c;
  border-color: rgba(254, 202, 202, 0.8);
}

body[data-theme='light'] .status-deleted {
  background: rgba(254, 242, 242, 0.85);
  color: #dc2626;
  border-color: rgba(254, 215, 215, 0.85);
}

.diff-container {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}

.diff-column {
  background: linear-gradient(135deg, rgba(25, 25, 25, 0.6) 0%, rgba(20, 18, 17, 0.4) 100%);
  border-radius: 16px;
  overflow: hidden;
  border: 1px solid rgba(231, 229, 228, 0.06);
  box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.3);
  backdrop-filter: blur(12px);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  height: 100%;
  box-sizing: border-box;
}

body[data-theme='light'] .diff-column {
  background: #ffffff;
  border: 1px solid rgba(203, 213, 225, 0.5);
  box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.1);
}

.diff-column h3 {
  font-size: 14px;
  font-weight: 500;
  padding: 16px 20px;
  background: linear-gradient(135deg, rgba(35, 35, 35, 0.8) 0%, rgba(25, 25, 25, 0.6) 100%);
  border-bottom: 1px solid rgba(231, 229, 228, 0.08);
  margin: 0;
  color: #E5E5E5;
  letter-spacing: -0.01em;
}

body[data-theme='light'] .diff-column h3 {
  background: #f8fafc;
  border-bottom: 1px solid rgba(203, 213, 225, 0.6);
  color: #0f172a;
}

.diff-content {
  background: #1e1e1e;
  color: #e0e0e0;
  font-family: 'Fira Code', 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
  font-size: 13px;
  line-height: 1.5;
  padding: 20px;
  overflow-x: auto;
  white-space: pre;
  flex: 1;
  margin: 0;
  border-radius: 0 0 16px 16px;
  height: 100%;
  box-sizing: border-box;
}

body[data-theme='light'] .diff-content {
  background: #ffffff;
  color: #1f2937;
  border-radius: 0 0 16px 16px;
}

body[data-theme='light'] .yaml-content {
  color: #1f2937;
  background: #ffffff;
}

.yaml-content {
  padding: 20px;
  margin: 0;
  font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
  font-size: 13px;
  line-height: 1.7;
  color: #D3D3D3;
  overflow-x: auto;
  white-space: pre-wrap;
  word-wrap: break-word;
}

body[data-theme='light'] .yaml-content {
  background: #ffffff;
  color: #1e293b;
}

/* Form Elements */
.form-group {
  margin-bottom: 24px;
}

.form-group--stacked {
  display: flex;
  flex-direction: column;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: 400;
  color: #d1d5db;
  letter-spacing: -0.01em;
  font-size: 14px;
}

body[data-theme='light'] .settings-card,
body[data-theme='light'] .settings-card label,
body[data-theme='light'] .settings-card .settings-inline-text,
body[data-theme='light'] .settings-card .settings-inline-btn,
body[data-theme='light'] .settings-card .settings-error-text,
body[data-theme='light'] .settings-card .settings-inline-text.status-success {
  color: #111827;
}

body[data-theme='light'] .settings-card .settings-inline-text.status-success {
  color: #16a34a;
}

body[data-theme='light'] .settings-card .settings-inline-text.status-error {
  color: #dc2626;
}

.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="time"],
.form-group input[type="number"],
.form-group select {
  width: 100%;
  height: 40px;
  padding: 8px 12px;
  background-color: #2d2d2d;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  font-size: 14px;
  color: #d1d5db;
  box-sizing: border-box;
  transition: border-color 0.2s;
}

body[data-theme='light'] .form-group input[type="text"],
body[data-theme='light'] .form-group input[type="password"],
body[data-theme='light'] .form-group input[type="time"],
body[data-theme='light'] .form-group input[type="number"],
body[data-theme='light'] .form-group select {
  background-color: rgba(255, 255, 255, 0.96);
  border: 1px solid rgba(148, 163, 184, 0.14);
  color: #4b5563;
}

#scheduleFrequency:focus {
  outline: none !important;
  box-shadow: none !important;
}

.form-group input:focus,
.form-group select:focus {
  outline: none;
  border-color: rgba(0,122,255, 0.3);
  box-shadow: 0 0 0 3px rgba(0,122,255, 0.1);
}

.form-group select {
  cursor: pointer;
  appearance: none;
  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23A8A29E' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
  background-position: right 16px center;
  background-repeat: no-repeat;
  background-size: 1em;
  padding-right: 42px;
}

body[data-theme='light'] .form-group select {
  background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%234b5563' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.8' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
  background-color: #ffffff;
  border: 1px solid rgba(148, 163, 184, 0.3);
  color: #1f2937;
}

body[data-theme='light'] .form-group select:focus {
  border-color: rgba(59, 130, 246, 0.5);
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}

.time-input {
  font-family: Arial, Helvetica, sans-serif;
}

.form-group input[type="time"]::-webkit-calendar-picker-indicator {
  display: none;
}

.form-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 12px;
}

.form-row .form-group {
  width: 100%;
}

@media (max-width: 500px) {
  .form-row {
    grid-template-columns: 1fr;
  }
}

.settings-inline-btn {
  align-self: flex-start;
  margin-top: 12px;
  padding: 14px 18px;
  min-height: 48px;
  border-radius: 12px;
  font-weight: 500;
  font-size: 14px;
  background-color: rgba(56, 56, 56, 0.8);
  color: #D3D3D3;
  border: 1px solid rgba(231, 229, 228, 0.1);
}

body[data-theme='light'] .settings-inline-btn {
  background-color: rgba(255, 255, 255, 0.95);
  color: #0f172a;
  border: 1px solid rgba(148, 163, 184, 0.12);
  box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.24);
}

.settings-inline-text {
  margin-top: 4px;
  font-size: 13px;
  color: #A2A2A2;
  min-width: 140px;
  display: inline-block;
}

body[data-theme='light'] .settings-inline-text {
  color: #475569;
}

body[data-theme='light'] .settings-inline-text.status-success {
  color: #16a34a;
}

body[data-theme='light'] .settings-inline-text.status-error {
  color: #dc2626;
}

.settings-error-text {
  color: #ef4444;
  font-size: 12px;
  min-height: 18px;
  margin-top: 8px;
  line-height: 1.4;
}

body[data-theme='light'] .settings-error-text {
  color: #b91c1c;
}

/* Hide HA connection fields when managed by supervisor */
.ha-connection-fields.managed-by-supervisor {
  display: none;
}

.settings-divider {
  border-top: 1px solid rgba(231, 229, 228, 0.08);
  margin: 20px 0 16px;
}

body[data-theme='light'] .settings-divider {
  border-top: 1px solid rgba(203, 213, 225, 0.55);
}

.settings-schedule {
  display: none;
  margin-top: 16px;
}

.settings-schedule[data-frequency="daily"] .form-row,
.settings-schedule[data-frequency="hourly"] .form-row,
.settings-schedule[data-frequency="weekly"] .form-row {
  display: grid;
  gap: 12px;
}

.settings-schedule[data-frequency="daily"] .form-row {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.settings-schedule[data-frequency="hourly"] .form-row {
  grid-template-columns: minmax(0, 1fr);
}

.settings-schedule[data-frequency="weekly"] .form-row {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.settings-schedule[data-frequency="hourly"] #scheduleFrequency {
  width: 100%;
}

#scheduleOptions .form-group:last-of-type {
  margin-bottom: 0;
}

#scheduleOptions .form-row .form-group {
  margin-bottom: 0;
}

#maxBackupsOptions {
  margin-top: 16px;
}

#maxBackupsCount::-webkit-inner-spin-button,
#maxBackupsCount::-webkit-outer-spin-button {
  display: none;
  margin: 0;
  height: 20px;
  position: relative;
  right: 4px;
}
#maxBackupsCount {
  -webkit-appearance: textfield;
  -moz-appearance: textfield;
  appearance: textfield;
}

.settings-actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  margin-top: 20px;
}

/* Toggle Switch - Claude Style */
.toggle-group {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.toggle {
  position: relative;
  display: inline-block;
  width: 50px;
  height: 28px;
}

.toggle input {
  opacity: 0;
  width: 0;
  height: 0;
}

.toggle-slider {
  position: absolute;
  cursor: pointer;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(120, 113, 108, 0.4);
  transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
  border-radius: 28px;
}

.toggle-slider:before {
  position: absolute;
  content: "";
  height: 22px;
  width: 22px;
  left: 3px;
  bottom: 3px;
  background-color: #E7E5E4;
  transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
  border-radius: 50%;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.toggle input:checked + .toggle-slider {
  background: #007AFF;
}

.toggle input:checked + .toggle-slider:before {
  transform: translateX(22px);
}

body[data-theme='light'] .toggle-slider {
  background-color: rgba(148, 163, 184, 0.35);
}

body[data-theme='light'] .toggle-slider:before {
  background-color: #ffffff;
  box-shadow: 0 2px 6px rgba(148, 163, 184, 0.35);
}

/* Notification Toast */
.notification {
  position: fixed;
  top: 24px;
  right: 24px;
  padding: 16px 28px;
  border-radius: 14px;
  color: #FAFAFA;
  font-weight: 500;
  z-index: 2000;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
  animation: slideIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
  border: 1px solid rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(12px);
}

body[data-theme='light'] .notification {
  border: 1px solid rgba(148, 163, 184, 0.25);
  box-shadow: 0 16px 34px -18px rgba(15, 23, 42, 0.35);
  color: #0f172a;
}

.notification.success {
  background: rgba(0, 200, 0, 0.9);
  color: #ffffff;
  text-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
}

body[data-theme='light'] .notification.success {
  background: #22c55e;
  color: #ffffff;
}

.notification.error {
  background: rgba(239, 68, 68, 0.95);
}

body[data-theme='light'] .notification.error {
  background: #ef4444;
  color: #ffffff;
}

.notification.info {
  background: rgba(0,122,255, 0.95);
}

body[data-theme='light'] .notification.info {
  background: #3b82f6;
  color: #ffffff;
}

.diff-state-banner {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 42px;
  padding: 0 16px;
  font-size: 14px;
  font-weight: 500;
  border-radius: 8px;
  border: 1px solid;
  margin-bottom: 16px;
}

body[data-theme='light'] .diff-state-banner {
  border: 1px solid rgba(148, 163, 184, 0.35);
}

.diff-state-banner-deleted {
  background-color: rgba(239, 68, 68, 0.15);
  color: #ef4444;
  border-color: rgba(239, 68, 68, 0.25);
}

body[data-theme='light'] .diff-state-banner-deleted {
  background-color: rgba(254, 226, 226, 0.8);
  color: #b91c1c;
  border-color: rgba(254, 202, 202, 0.8);
}

.diff-state-banner-unchanged {
  background-color: rgba(34, 197, 94, 0.15);
  color: #22c55e;
  border-color: rgba(34, 197, 94, 0.25);
}

body[data-theme='light'] .diff-state-banner-unchanged {
  background: rgba(220, 252, 231, 0.8);
  color: #15803d;
  border-color: rgba(187, 247, 208, 0.8);
}

.diff-state-banner-changed {
  background-color: rgba(59, 130, 246, 0.15);
  color: #3b82f6;
  border-color: rgba(59, 130, 246, 0.25);
}

body[data-theme='light'] .diff-state-banner-changed {
  background: rgba(219, 234, 254, 0.8);
  color: #1d4ed8;
  border-color: rgba(191, 219, 254, 0.8);
}

@keyframes slideIn {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

hr {
  border: none;
  border-top: 1px solid rgba(231, 229, 228, 0.08);
  margin: 28px 0;
}

body[data-theme='light'] hr {
  border-top: 1px solid rgba(203, 213, 225, 0.55);
}

h3 {
  font-size: 17px;
  font-weight: 600;
  margin-bottom: 20px;
  color: #F9F9F9;
  letter-spacing: -0.02em;
}

body[data-theme='light'] h3 {
  color: #0f172a;
}

/* Diff Viewer - Split Layout */
.diff-viewer-shell {
  background: linear-gradient(135deg, rgba(22, 22, 22, 0.95) 0%, rgba(14, 14, 14, 0.92) 100%);
  border-radius: 20px;
  border: 1px solid rgba(231, 229, 228, 0.08);
  box-shadow: 0 25px 60px -20px rgba(0, 0, 0, 0.8);
  overflow: hidden;
  font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Monaco', monospace;
  font-size: 13px;
  line-height: 1.45;
}

body[data-theme='light'] .diff-viewer-shell {
  background: #ffffff;
  border: 1px solid rgba(203, 213, 225, 0.6);
  box-shadow: 0 20px 40px -26px rgba(15, 23, 42, 0.18);
}

.diff-compare-banner {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 10px;
  padding: 16px 18px 14px;
  background: rgba(30, 30, 30, 0.9);
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

body[data-theme='light'] .diff-compare-banner {
  background: #f8fafc;
  border-bottom: 1px solid rgba(203, 213, 225, 0.5);
}

.diff-compare-pill {
  display: inline-flex;
  align-items: center;
  padding: 6px 12px;
  border-radius: 9999px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.01em;
  border: 1px solid rgba(148, 163, 184, 0.22);
  background: rgba(45, 45, 45, 0.9);
  color: #d1d5db;
}

body[data-theme='light'] .diff-compare-pill {
  background: rgba(241, 245, 249, 0.85);
  border-color: rgba(148, 163, 184, 0.35);
  color: #0f172a;
}

.diff-compare-pill-accent {
  background: rgba(37, 99, 235, 0.2);
  color: #60a5fa;
  border-color: rgba(37, 99, 235, 0.35);
  box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.2);
}

body[data-theme='light'] .diff-compare-pill-accent {
  background: rgba(59, 130, 246, 0.22);
  border-color: rgba(59, 130, 246, 0.35);
  color: #1d4ed8;
}

.diff-viewer-split {
  background: rgba(18, 18, 18, 0.95);
  border-radius: 0 0 20px 20px;
  overflow: hidden;
}

body[data-theme='light'] .diff-viewer-split {
  background: #ffffff;
}

.diff-hunks {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 16px 18px 20px 18px;
}

.diff-hunk {
  background: rgba(26, 26, 26, 0.9);
  border: 1px solid rgba(231, 229, 228, 0.08);
  border-radius: 12px;
  overflow: hidden;
  backdrop-filter: blur(10px);
}

body[data-theme='light'] .diff-hunk {
  background: #ffffff;
  border: 1px solid rgba(203, 213, 225, 0.5);
  box-shadow: 0 16px 30px -24px rgba(15, 23, 42, 0.18);
}

.diff-hunk-content {
  position: relative;
}

.diff-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  background: rgba(15, 15, 15, 0.32);
}

body[data-theme='light'] .diff-row {
  background: #ffffff;
}

.diff-row:nth-child(even) {
  background: rgba(20, 20, 20, 0.45);
}

body[data-theme='light'] .diff-row:nth-child(even) {
  background: #f4f6fb;
}

.diff-row.diff-row-context {
  background: rgba(12, 12, 12, 0.25);
}

body[data-theme='light'] .diff-row.diff-row-context {
  background: #e2e8f0;
}

.diff-cell {
  position: relative;
  border-top: 1px solid rgba(255, 255, 255, 0.04);
}

body[data-theme='light'] .diff-cell {
  border-top: 1px solid rgba(203, 213, 225, 0.6);
}

.diff-row:first-child .diff-cell {
  border-top: none;
}

.diff-cell-left {
  border-right: 1px solid rgba(255, 255, 255, 0.05);
}

body[data-theme='light'] .diff-cell-left {
  border-right: 1px solid rgba(203, 213, 225, 0.6);
}

.diff-hunk-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 14px;
  background: rgba(32, 32, 32, 0.8);
  border-top: 1px solid rgba(255, 255, 255, 0.04);
}

body[data-theme='light'] .diff-hunk-footer {
  background: #ffffff;
  border-top: 1px solid rgba(203, 213, 225, 0.6);
  border-bottom-left-radius: 12px;
  border-bottom-right-radius: 12px;
}

.diff-hunk-summary {
  font-size: 12px;
  color: #9ca3af;
}

body[data-theme='light'] .diff-hunk-summary {
  color: #475569;
}

.diff-context-toggle {
  background: none;
  border: none;
  color: #60a5fa;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  padding: 0;
  text-decoration: underline;
  text-decoration-color: rgba(96, 165, 250, 0.4);
  transition: color 0.2s ease;
}

.diff-context-toggle:hover {
  color: #93c5fd;
}

body[data-theme='light'] .diff-context-toggle {
  color: #2563eb;
  text-decoration-color: rgba(37, 99, 235, 0.45);
  background: white;
}

body[data-theme='light'] .diff-context-toggle:hover {
  color: #3b82f6;
}

.diff-hunk--context-collapsed .diff-row-context {
  display: none;
}


.diff-line {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 14px;
  min-height: 28px;
  transition: background-color 0.2s ease;
}

body[data-theme='light'] .diff-line {
  background: rgba(255, 255, 255, 0.95);
}

.diff-line-left {
  border-radius: 0;
}

.diff-line-right {
  border-radius: 0;
}

.diff-line-empty {
  height: 30px;
}

.diff-line-context {
  background: rgba(25, 25, 25, 0.55);
  color: rgba(148, 163, 184, 0.6);
}

body[data-theme='light'] .diff-line-context {
  background: white;
  color: rgba(71, 85, 105, 0.75);
} 

.diff-row:nth-child(even) .diff-line-added {
  background: rgba(16, 185, 129, 0.25);
}

body[data-theme='light'] .diff-row:nth-child(even) .diff-line-added {
  background: rgba(34, 197, 94, 0.22);
}

.diff-line-added {
  background: rgba(16, 185, 129, 0.16);
  box-shadow: inset 3px 0 0 rgba(16, 185, 129, 0.45);
}

body[data-theme='light'] .diff-line-added {
  background: rgba(34, 197, 94, 0.18);
  box-shadow: inset 3px 0 0 rgba(34, 197, 94, 0.45);
}

.diff-line-removed {
  background: rgba(220, 53, 69, 0.4);
  box-shadow: inset 3px 0 0 rgba(220, 53, 69, 0.7);
}

body[data-theme='light'] .diff-line-removed {
  background: rgba(220, 53, 69, 0.25);
  box-shadow: inset 3px 0 0 rgba(220, 53, 69, 0.7);
}

.diff-row:nth-child(even) .diff-line-removed {
  background: rgba(220, 53, 69, 0.5);
}

body[data-theme='light'] .diff-row:nth-child(even) .diff-line-removed {
  background: rgba(220, 53, 69, 0.35);
}

.diff-line-removed .diff-line-marker {
  color: #f87171;
}

.diff-line-marker {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  width: 16px;
  text-align: center;
  font-weight: 600;
  font-size: 13px;
  user-select: none;
  color: rgba(148, 163, 184, 0.7);
  flex-shrink: 0;
}

body[data-theme='light'] .diff-line-marker {
  color: #64748b;
}

.diff-line-added .diff-line-marker {
  color: #34d399;
}

.diff-line-removed .diff-line-marker {
  color: #f87171;
}


.diff-line-num {
  display: inline-flex;
  justify-content: flex-end;
  align-items: center;
  width: 42px;
  text-align: right;
  padding-right: 10px;
  color: rgba(168, 162, 158, 0.55);
  font-size: 11.5px;
  font-weight: 500;
  user-select: none;
  font-variant-numeric: tabular-nums;
  flex-shrink: 0;
}

body[data-theme='light'] .diff-line-num {
  color: rgba(100, 116, 139, 0.75);
}

.diff-line-text {
  margin: 0;
  padding: 0;
  flex: 1;
  color: #e5e7eb;
  white-space: pre-wrap;
  word-break: break-word;
}

body[data-theme='light'] .diff-line-text {
  color: #1f2937;
}

.diff-line-text code {
  font-family: inherit;
  background: none;
  padding: 0;
  color: inherit;
}

body[data-theme='light'] .diff-line-text code {
  color: inherit;
}

body[data-theme='light'] #sortSelect,
body[data-theme='light'] #searchBox,
body[data-theme='light'] #scheduleFrequency,
body[data-theme='light'] #scheduleTime,
body[data-theme='light'] #haUrl,
body[data-theme='light'] #haToken,
body[data-theme='light'] #liveConfigPath,
body[data-theme='light'] #backupFolderPath,
body[data-theme='light'] #scheduleDay,
body[data-theme='light'] #liveConfigPath,
body[data-theme='light'] #backupFolderPath,
body[data-theme='light'] #haUrl,
body[data-theme='light'] #haToken,
body[data-theme='light'] #maxBackupsCount {
  background-color: rgba(255, 255, 255, 0.96) !important;
  border: 1px solid rgba(148, 163, 184, 0.35) !important;
  color: #0f172a !important;
  box-shadow: none !important;
}

body[data-theme='light'] #itemsList .list-item,
body[data-theme='light'] #backupList .list-item {
  background: #ffffff;
  border: 1px solid rgba(148, 163, 184, 0.22);
}

/* Responsive adjustments - Mobile view */
@media (max-width: 500px) {
  .content {
    grid-template-columns: 1fr;
    height: auto;
  }

  .panel {
    min-height: 0;
    max-height: 50vh;
    overflow-y: auto;
  }

  .diff-container {
    grid-template-columns: 1fr;
  }

  .diff-row {
    grid-template-columns: 1fr;
  }

  .diff-cell-left {
    border-right: none;
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  }

  .diff-row .diff-cell:not(:last-child) {
    border-bottom: 1px solid rgba(255, 255, 255, 0.04);
  }

  body[data-theme='light'] .diff-row .diff-cell:not(:last-child),
  body[data-theme='light'] .diff-cell-left {
    border-bottom: 1px solid rgba(203, 213, 225, 0.6);
  }
}

@media (max-width: 500px) {
  .header {
    flex-direction: column;
    align-items: flex-start;
  }

  .header .btn-secondary {
    width: 100%;
  }

  .tabs {
    width: 100%;
  }

  .panel-header-actions {
    width: 100%;
  }

  .panel-header-actions .select {
    width: 100%;
  }

  .search-section {
    width: 100%;
  }

  .search-input {
    width: 100%;
  }

  .notification {
    left: 50%;
    right: auto;
    transform: translateX(-50%);
    width: calc(100% - 40px);
    max-width: 360px;
  }

  .diff-line-num {
    width: 40px;
    font-size: 11px;
  }

  .diff-line-marker {
    width: 16px;
    font-size: 13px;
  }
}

@media (max-width: 500px) {
  body {
    padding: 16px;
  }

  .container {
    padding-bottom: 16px;
  }

  .tabs {
    gap: 8px;
  }

  .tab {
    padding: 12px 20px;
  }

  .panel-header-top {
    flex-direction: column;
    align-items: stretch;
  }

  .header {
    margin-bottom: 28px;
  }

  .header h1 {
    font-size: 22px;
  }

  .content {
    grid-template-columns: 1fr;
    gap: 20px;
    height: auto;
  }

  .diff-line-num {
    width: 35px;
    font-size: 11px;
  }

  .diff-line-marker {
    width: 14px;
    font-size: 12px;
  }

  .diff-line-text {
    font-size: 12px;
  }

  .modal-dialog {
    width: 95%;
    max-height: 95vh;
  }

  .settings-card {
    max-width: 95vw;
    padding: 16px;
  }
}

/* Scrollbar styling - consistent across all scrollable elements */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}

::-webkit-scrollbar-track {
  background: rgba(0, 0, 0, 0.1);
}

::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.15);
  border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
  background: rgba(255, 255, 255, 0.25);
}

body[data-theme='light'] ::-webkit-scrollbar-track {
  background: rgba(0, 0, 0, 0.05);
}

body[data-theme='light'] ::-webkit-scrollbar-thumb {
  background: rgba(0, 0, 0, 0.2);
}

body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
  background: rgba(0, 0, 0, 0.3);
}


/* Selection styling */
::selection {
  background: rgba(0,122,255, 0.3);
  color: #F9F9F9;
}

body[data-theme='light'] ::selection {
  background: rgba(0,122,255, 0.2);
  color: #0f172a;
}

::-moz-selection {
  background: rgba(0,122,255, 0.3);
  color: #F9F9F9;
}

body[data-theme='light'] ::-moz-selection {
  background: rgba(0,122,255, 0.2);
  color: #0f172a;
}

/* Focus visible for accessibility */
*:focus-visible {
  outline: 2px solid rgba(0, 122, 255, 0.5);
  outline-offset: 2px;
}

/* Style diff viewer version banners to match modal header */
.diff-header {
  padding: 12px 28px;
  background-color: rgba(35, 35, 35, 0.95);
  color: #f8fafc;
  font-size: 14px;
  font-weight: 500;
  border-bottom: 1px solid rgba(231, 229, 228, 0.08);
  display: flex;
  align-items: center;
  justify-content: space-between;
}

body[data-theme='light'] .diff-header {
  background-color: rgba(248, 250, 252, 0.92);
  color: #0f172a;
  border-bottom: 1px solid rgba(203, 213, 225, 0.55);
}

.diff-header .diff-title {
  font-weight: 600;
  font-size: 15px;
  color: inherit;
}

.diff-header .diff-timestamp {
  opacity: 0.8;
  font-weight: 400;
  font-size: 13px;
  margin-left: 12px;
}

.diff-banners-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0;
  margin: 16px 18px 0 18px;
}

/* Standalone grid for the diff viewer */
.diff-banners-grid--standalone {
  margin: 0 18px 16px 18px;
  border-radius: 0 8px 8px 0; /* Match banner corners */
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
  width: calc(100% - 36px);
  box-sizing: border-box;
}

body[data-theme='light'] .diff-banners-grid--standalone {
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
}

/* Ensure the diff viewer shell has matching width and margins */
.diff-viewer-shell {
  margin: 0 18px;
  width: calc(100% - 36px);
  box-sizing: border-box;
}

.diff-state-banner--header {
  margin: 0;
  border-radius: 0;
  height: 42px;
  font-weight: 500;
  font-size: 14px;
  letter-spacing: 0.01em;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}

/* Ensure consistent height and alignment */
.diff-state-banner--header .diff-state-label {
  display: flex;
  align-items: center;
  height: 100%;
  padding: 0 16px;
}

.diff-state-banner-current {
  background-color: rgba(156, 163, 175, 0.15);
  color: #d1d5db;
  border-color: rgba(156, 163, 175, 0.25);
  border-radius: 8px 0 0 8px;
  text-shadow: none;
}

body[data-theme='light'] .diff-state-banner-current {
  background-color: rgba(209, 213, 219, 0.8);
  color: #6b7280;
  border-color: rgba(209, 213, 219, 0.8);
}

.diff-state-banner-changed {
  border-radius: 0 8px 8px 0; /* Square left corners, rounded right corners */
  position: relative; /* For z-index handling */
  z-index: 1; /* Ensure it sits above the shell */
  overflow: hidden; /* Prevent content from showing in corners */
}

.diff-state-banner-empty {
  background-color: transparent;
  border: none;
  border-radius: 0 8px 8px 0;
}

.diff-header--versions {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 20px;
  margin-top: 8px;
}

.diff-header-section {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.diff-header-section--right {
  align-items: flex-end;
  text-align: right;
}

body[data-theme='light'] .diff-header-section--right {
  color: inherit;
}

.modal-body--diff {
  padding: 12px 24px;
}

.modal-footer--diff {
  padding: 24px;
  background-color: rgba(45, 45, 45, 0.7);
  border-top: 1px solid rgba(255, 255, 255, 0.05);
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

body[data-theme='light'] .modal-footer--diff {
  background-color: #ffffff;
  border-top: 1px solid rgba(203, 213, 225, 0.55);
}

.modal-footer--diff .btn-secondary,
.modal-footer--diff .btn-primary {
  padding: 12px 24px;
  border-radius: 8px;
}

.modal-footer--diff .btn-primary {
  box-shadow: none;
}

body[data-theme='light'] .modal-footer--diff .btn-primary {
  box-shadow: none;
}

/* Responsive adjustments for backup list */
@media (max-width: 500px) {
  .list {
    padding: 8px 4px;
  }
  
  .list-item {
    padding: 10px 12px;
    border-radius: 10px;
  }

  .list-item {
    padding: 4px 4px;
  }

  .item-content {
    gap: 8px;
  }
  
  .badge {
    padding: 4px 8px;
    font-size: 12px;
  }
  
  .item-badges {
    gap: 6px;
    margin-left: 8px;
  }
  
  /* Make badges stack vertically on very small screens */
  @media (max-width: 400px) {
    .item-badges {
      flex-direction: column;
      align-items: flex-end;
      gap: 4px;
    }
  }
}

/* Code Viewer */
.code-viewer {
  padding: 16px;
  border-radius: 12px;
  text-align: left;
  font-size: 14px;
  white-space: pre-wrap;
  margin-top: 8px;
  background-color: #1e1e1e;
  color: #d1d5db;
  border: 1px solid rgba(255, 255, 255, 0.05);
  font-family: 'Fira Code', 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
  line-height: 1.5;
  overflow-x: auto;
}

body[data-theme='light'] .code-viewer {
  background-color: #ffffff !important;
  color: #1f2937 !important;
  border: 1px solid rgba(203, 213, 225, 0.6);
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
}

/* File icon styles */
.file-icon-container {
  width: 64px;
  height: 64px;
  background-color: rgba(255, 255, 255, 0.05);
  border-radius: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 16px;
}

.file-icon {
  width: 32px;
  height: 32px;
  color: #6b7280;
}

body[data-theme='light'] .file-icon-container {
  background-color: #f3f4f6; /* Light grey background for light theme */
}

body[data-theme='light'] .file-icon {
  color: #9ca3af; /* Slightly darker grey than background in light mode */
}

.empty-state-message {
  color: #9ca3af;
}

/* Additional polish */
button {
  font-family: inherit;
}

input::placeholder {
  color: #717171;
}

/* Subtle animations */
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.list-item {
  animation: fadeIn 0.3s ease-out;
}

/* Loading state */
.loading {
  position: relative;
}

.loading::after {
  display: none;
}

@keyframes pulse {
  0%, 100% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.5;
    transform: scale(1.2);
  }
}

/* Ensure 21px vertical spacing between all elements in settings card */
.settings-card > * {
  margin-bottom: 21px;
}

.settings-card > *:last-child {
  margin-bottom: 0;
}
```

## /homeassistant-time-machine/public/images/favicon.ico

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/homeassistant-time-machine/public/images/favicon.ico

## /homeassistant-time-machine/public/images/icon.png

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/homeassistant-time-machine/public/images/icon.png

## /homeassistant-time-machine/public/js/strings.js

```js path="/homeassistant-time-machine/public/js/strings.js" 
// strings.js - Styled text variants for the Home Assistant Time Machine add-on
window.STRINGS = {
  notifications: {
    haUrlNotConfigured: {
      default: "Home Assistant URL or token not configured.",
      pirate: "Ye haven't set yer Home Assistant chart or yer treasure map (token)!",
      hacker: "Access denied. Endpoint or auth token is null. No entry.",
      noir_detective: "The dame's address, or the client's dough, ain't in my book.",
      personal_trainer: "WHAT ARE YOU WAITING FOR?! YOUR HOME ASSISTANT URL AND TOKEN ARE YOUR ESSENTIAL FUEL! NO SETUP, NO GAINS! GET IT DONE, NOW!",
      scooby_doo: "Ruh-roh! Like, no Home Assistant URL or token, Scoob!"
    },
    lovelaceRestored: {
      default: "Lovelace successfully restored! Restart Home Assistant to see changes.",
      pirate: "Lovelace be shipshape once more! Haul anchor on Home Assistant to see the new tides!",
      hacker: "Payload delivered. Lovelace state pwned. Cycle the host process to see the new DOM.",
      noir_detective: "Lovelace's files are back on the table. Restart the whole operation to see the new layout.",
      personal_trainer: "LOVELACE IS BACK! STRONGER THAN EVER! NOW HIT THAT RESTART BUTTON ON HOME ASSISTANT AND WITNESS THE RAW POWER YOU'VE UNLEASHED! NO REST UNTIL YOU SEE THE RESULTS!",
      scooby_doo: "Jeepers! Lovelace is back! Restart Home Assistant to, like, see the ghost... I mean, changes!"
    },
    automationsReloaded: {
      default: "{mode}s reloaded successfully in Home Assistant!",
      pirate: "Blow me down! Yer {mode}s be reloaded true in Home Assistant!",
      hacker: "Injection successful. {mode} daemons reloaded. We're in.",
      noir_detective: "The {mode} routines are squared away in Home Assistant.",
      personal_trainer: "{mode}S ARE RE-LOADED, PUMPED, AND READY TO DOMINATE HOME ASSISTANT! FEEL THE POWER!",
      scooby_doo: "Zoinks! You did it! {mode}s reloaded in Home Assistant, man!"
    },
    errorReloadingHA: {
      default: "Error reloading Home Assistant: {error}",
      pirate: "Shiver me timbers! Error reloading Home Assistant: {error}",
      hacker: "Segfault! Reload process killed. Exploit failed. Reason: {error}",
      noir_detective: "A snag in the works. Couldn't reload Home Assistant: {error}",
      personal_trainer: "AN ERROR?! {error} IS JUST A WEAK EXCUSE! DID YOU GIVE UP?! NO! FIND THAT WEAKNESS, CRUSH IT, AND RELOAD HOME ASSISTANT AGAIN! YOU ARE STRONGER THAN THIS ERROR!",
      scooby_doo: "Ruh-roh, Shaggy! Error reloading Home Assistant: {error}"
    },
    haRestarting: {
      default: "Home Assistant is restarting...",
      pirate: "Home Assistant be settin' sail anew...",
      hacker: "Dropping to root... cycling the core process.",
      noir_detective: "Home Assistant's taking a dirt nap, then waking up.",
      personal_trainer: "HOME ASSISTANT IS RECHARGING! MAXIMUM POWER INCOMING! DON'T YOU DARE LOOK AWAY!",
      scooby_doo: "Like, Home Assistant is restarting... hopefully no monsters!"
    },
    errorRestartingHA: {
      default: "Error restarting Home Assistant: {error}",
      pirate: "Blast and confound it! Error settin' Home Assistant to sea: {error}",
      hacker: "Reboot command failed. Process hung. Kernel panic? Reason: {error}",
      noir_detective: "Couldn't get Home Assistant to wake up clean. Hit a snag: {error}",
      personal_trainer: "FAILURE IS NOT AN OPTION! {error} IS A CHALLENGE, NOT A STOP SIGN! GET UP! FIX IT! RESTART HOME ASSISTANT WITH RENEWED FURY!",
      scooby_doo: "Jinkies! Error restarting Home Assistant: {error}"
    },
    itemRestoredManual: {
      default: "{mode} restored successfully! Manual reload in Home Assistant required, or configure URL/Token in settings.",
      pirate: "{mode} be back from Davy Jones' locker! Ye'll need to haul it back by hand in Home Assistant, or set yer chart and token in the settings, savvy?",
      hacker: "{mode} payload injected from archive. Manual shell reload required, or re-auth the API.",
      noir_detective: "The {mode} file's back, but you gotta hand-deliver it to Home Assistant, or get your credentials in order.",
      personal_trainer: "RESTORE COMPLETE! THE {mode} IS BACK IN THE GAME! NOW, EITHER MANUALLY RELOAD HOME ASSISTANT OR PUMP UP THOSE URL/TOKEN SETTINGS! NO HALF-MEASURES!",
      scooby_doo: "Like, wow, {mode} is restored! But you gotta reload it yourself, or, like, check the settings for the URL and token!"
    },
    errorGeneric: {
      default: "Error: {error}",
      pirate: "Arrr! Error: {error}",
      hacker: "FATAL ERROR. Dropping core. {error}",
      noir_detective: "Looks like trouble, kid: {error}",
      personal_trainer: "ERROR! {error}! DO YOU THINK THIS IS A GAME?! FIGHT THROUGH IT! NO PAIN, NO GAIN! FIX IT!",
      scooby_doo: "Ruh-roh! Error: {error}"
    },
    restartManually: {
      default: "Restart from Home Assistant when ready.",
      pirate: "Give the Home Assistant helm a manual turn when ye be ready.",
      hacker: "Manual intervention required. Restart inside Home Assistant when convenient.",
      noir_detective: "When you're ready, flip the Home Assistant switch yourself.",
      personal_trainer: "DON'T WAIT AROUND! JOG OVER TO HOME ASSISTANT AND HIT RESTART WHEN YOU'RE READY TO DOMINATE!",
      scooby_doo: "Like, restart it yourself in Home Assistant whenever, man!"
    },
    haRestarted: {
      default: "Home Assistant restarted successfully!",
      pirate: "Home Assistant be back afloat and sailing smooth!",
      hacker: "Reboot sequence complete. Home Assistant core back online.",
      noir_detective: "Home Assistant's up and breathing easy again.",
      personal_trainer: "HOME ASSISTANT IS BACK IN THE RING AND READY TO CRUSH IT!",
      scooby_doo: "Zoinks! Home Assistant restarted perfectly!"
    },
    packagesReloaded: {
      default: "Packages file restored and Home Assistant reloaded!",
      pirate: "Packages file be restored and Home Assistant hoisted anew!",
      hacker: "Packages payload synced. Home Assistant core reloaded.",
      noir_detective: "Packages are back in the drawer and Home Assistant's been refreshed.",
      personal_trainer: "PACKAGES RESTORED! HOME ASSISTANT RELOADED! THAT'S HOW YOU DOMINATE!",
      scooby_doo: "Zoinks! Packages restored and Home Assistant reloaded, man!"
    },
    packagesManualReload: {
      default: "Packages file restored! Reload Home Assistant manually.",
      pirate: "Packages be restored! Give Home Assistant a manual shove to reload.",
      hacker: "Packages file deployed. Manual Home Assistant reload required.",
      noir_detective: "The packages are back. You gotta hit the Home Assistant switch yourself.",
      personal_trainer: "PACKAGES RESTORED! NOW GET IN THERE AND MANUALLY RELOAD HOME ASSISTANT! PUSH IT!",
      scooby_doo: "Jeepers! Packages are restored! Better reload Home Assistant yourself."
    },
    packagesRestored: {
      default: "Packages file restored!",
      pirate: "Packages file be restored!",
      hacker: "Packages payload restored.",
      noir_detective: "Packages file's back on the books.",
      personal_trainer: "PACKAGES RESTORED! KEEP THAT MOMENTUM!",
      scooby_doo: "Like, packages are restored!"
    },
    restartButton: {
      default: "Restart",
      pirate: "Restart",
      hacker: "Restart",
      noir_detective: "Restart",
      personal_trainer: "RESTART",
      scooby_doo: "Restart"
    }
  },
  ui: {
    labels: {
      backups: {
        default: "Backups",
        pirate: "Yer treasure chests",
        hacker: "Backup archives",
        noir_detective: "The evidence cabinet",
        personal_trainer: "YOUR VAULT OF GAINS!",
        scooby_doo: "Like, Snack Stash"
      },
      sortDefault: {
        default: "Default Order",
        pirate: "Proper Order",
        hacker: "Sort: default",
        noir_detective: "Case order",
        personal_trainer: "DEFAULT DOMINATION ORDER",
        scooby_doo: "Like, Normal Order"
      },
      sortAlphaAsc: {
        default: "A → Z",
        pirate: "Aft → Bow",
        hacker: "A-Z",
        noir_detective: "Alphabetical",
        personal_trainer: "ALPHA GAINZ ↑",
        scooby_doo: "A → Z, man!"
      },
      sortAlphaDesc: {
        default: "Z → A",
        pirate: "Bow → Aft",
        hacker: "Z-A",
        noir_detective: "Reverse alphabetical",
        personal_trainer: "ALPHA GAINZ ↓",
        scooby_doo: "Z → A, man!"
      },
      maxBackups: {
        default: "Max Backups",
        pirate: "Cap'n's stash limit",
        hacker: "Max archives",
        noir_detective: "Evidence limit",
        personal_trainer: "MAX BACKUP POWER",
        scooby_doo: "Like, Max Snacks"
      },
      backupsToKeep: {
        default: "Backups to keep",
        pirate: "Booty to keep",
        hacker: "Keep count",
        noir_detective: "Files to keep",
        personal_trainer: "GAINS TO KEEP",
        scooby_doo: "Snacks to Keep"
      },
      textStyle: {
        default: "Text style",
        pirate: "Voice of the crew",
        hacker: "Narration mode",
        noir_detective: "Narration style",
        personal_trainer: "HYPE LEVEL",
        scooby_doo: "Like, How We Talk"
      }
    },
    titles: {
      backups: {
        default: "Backups",
        pirate: "Yer Treasure Chests",
        hacker: "File Archives (backups.zip)",
        noir_detective: "The Case Files",
        personal_trainer: "YOUR BATTLE REPORTS! YOUR GAINZ LOG! YOUR BACKUP VAULT OF POWER!",
        scooby_doo: "The Snack Stash"
      },
      items: {
        default: "{mode}",
        pirate: "{mode}",
        hacker: "{mode} Payloads",
        noir_detective: "{mode} Dossiers",
        personal_trainer: "YOUR {mode} ARSENAL!",
        scooby_doo: "{mode}"
      }
    },
    hero: {
      subtitle: {
        default: "Browse and restore backups",
        pirate: "Chart yer course through bygone treasures",
        hacker: "Mount archives. Pwn past states.",
        noir_detective: "Flip through the case files and put things back in place",
        personal_trainer: "RANSACK YOUR HISTORY AND RESTORE THOSE GAINZ!",
        scooby_doo: "Let's, like, find some clues and restore stuff!"
      }
    },
    backupList: {
      snapshotsAvailable: {
        default: "{count} snapshots available",
        pirate: "{count} bits o' booty awaitin'",
        hacker: "{count} restore points online.",
        noir_detective: "{count} files on the books",
        personal_trainer: "{count} SNAPSHOTS OF PURE POWER READY FOR DEPLOYMENT!",
        scooby_doo: "Zoinks! {count} snapshots available!"
      },
      noBackups: {
        default: "No backups available yet.",
        pirate: "No treasure chests yet, matey.",
        hacker: "Archive directory is empty. 0 files.",
        noir_detective: "No case files in the cabinet yet, pal.",
        personal_trainer: "NO BACKUPS?! ARE YOU AFRAID OF SUCCESS?! START GENERATING THOSE GAINS NOW!",
        scooby_doo: "Ruh-roh! No snacks here!"
      },
      loading: {
        default: "Loading...",
        pirate: "Searchin' for yer sea shanties...",
        hacker: "Grepping archives...",
        noir_detective: "Sifting through the evidence...",
        personal_trainer: "LOADING! ARE YOU READY TO LIFT?! PUSH THROUGH!",
        scooby_doo: "I'm, like, looking for clues..."
      }
    },
    itemsList: {
      noBackupSelected: {
        default: "No backup selected",
        pirate: "No treasure chest chosen",
        hacker: "No target selected. Mount an archive to inspect.",
        noir_detective: "No case file on the table yet. Pick one from the stack, kid.",
        personal_trainer: "NO BACKUP SELECTED?! PICK ONE AND SHOW IT WHO’S BOSS!",
        scooby_doo: "Like, pick a snack, man!"
      },
      sortOptions: {
        default: "Default Order, A → Z, Z → A",
        pirate: "Proper Order, Aft → Bow, Bow → Aft",
        hacker: "Sort: default | A-Z | Z-A",
        noir_detective: "Sort by: case order, alphabetical, reverse alphabetical",
        personal_trainer: "SORT BY: DEFAULT ORDER | ALPHABETICAL GAINZ ↑ | REVERSE ALPHABETICAL GAINZ ↓",
        scooby_doo: "Like, sort by: Normal, A-Z, Z-A"
      },
      searchPlaceholder: {
        default: "Search {mode}...",
        pirate: "Hunt for {mode}...",
        hacker: "grep {mode}...",
        noir_detective: "Find that {mode} file...",
        personal_trainer: "SEARCH YOUR {mode} ARSENAL... FIND THAT PERFECT WEAPON!",
        scooby_doo: "Search for {mode}..."
      },
      loading: {
        default: "Loading {mode}...",
        pirate: "Haulin' in {mode}...",
        hacker: "Loading {mode} payloads...",
        noir_detective: "Digging through {mode} files...",
        personal_trainer: "LOADING YOUR {mode} ARSENAL! GET READY TO CONQUER!",
        scooby_doo: "Like, loading {mode}..."
      },
      selectBackup: {
        default: "Select a backup to view {mode}",
        pirate: "Choose a chest to see yer {mode}",
        hacker: "Mount archive to view {mode} payloads",
        noir_detective: "Pick a case file to see {mode} dossiers",
        personal_trainer: "CHOOSE YOUR BACKUP WEAPON TO VIEW YOUR {mode} ARSENAL!",
        scooby_doo: "Like, pick a snack to see the {mode}"
      },
      noItems: {
        default: "No {mode} found in this backup.",
        pirate: "No {mode} be found in this here treasure chest.",
        hacker: "No {mode} payloads in this archive.",
        noir_detective: "No {mode} dossiers in this case file.",
        personal_trainer: "NO {mode} IN THIS BACKUP?! YOUR ARSENAL NEEDS MORE WEAPONS! BUILD IT UP!",
        scooby_doo: "Ruh-roh! No {mode} in this snack."
      },
      noMatchingItems: {
        default: "No matching items",
        pirate: "No matchin' booty",
        hacker: "grep: no results found",
        noir_detective: "Nothing matches the description",
        personal_trainer: "NO MATCHING WEAPONS FOUND! ADJUST YOUR SEARCH AND CONQUER!",
        scooby_doo: "Zoinks! No matches!"
      }
    },
    badges: {
      changed: {
        default: "Changed",
        pirate: "Altered!",
        hacker: "Diff",
        noir_detective: "Modified",
        personal_trainer: "LEVEL UP!",
        scooby_doo: "Jinkies!"
      },
      deleted: {
        default: "Deleted",
        pirate: "Gone Fathoms Deep!",
        hacker: "Deleted",
        noir_detective: "Gone",
        personal_trainer: "DESTROYED!",
        scooby_doo: "Zoinks!"
      }
    },
    buttons: {
      automations: {
        default: "Automations",
        pirate: "Sea Shanties",
        hacker: "Automation Daemons",
        noir_detective: "The Routine Files",
        personal_trainer: "YOUR AUTOMATION BEAST MODE!",
        scooby_doo: "Ghostly Gadgets"
      },
      scripts: {
        default: "Scripts",
        pirate: "Swashbuckles",
        hacker: "Shell Scripts",
        noir_detective: "The Action Files",
        personal_trainer: "YOUR SCRIPT DOMINATION!",
        scooby_doo: "Trap Plans"
      },
      lovelace: {
        default: "Lovelace",
        pirate: "Lovelace's Logbook",
        hacker: "The DOM",
        noir_detective: "The Layouts",
        personal_trainer: "YOUR DASHBOARD GAINS!",
        scooby_doo: "The Mystery Machine's Dashboard"
      },
      esphome: {
        default: "ESPHome",
        pirate: "ESPHome's Logbook",
        hacker: "The Devices",
        noir_detective: "The Gadgets",
        personal_trainer: "YOUR EQUIPMENT!",
        scooby_doo: "The Gang's Gadgets"
      },
      packages: {
        default: "Packages",
        pirate: "Ship's Cargo",
        hacker: "Package Modules",
        noir_detective: "The Ledgers",
        personal_trainer: "YOUR WORKOUT PLANS!",
        scooby_doo: "The Trap Blueprints"
      },
      settings: {
        default: "Settings",
        pirate: "Ship's Log",
        hacker: "config.sys",
        noir_detective: "The Control Panel",
        personal_trainer: "YOUR COMMAND CENTER!",
        scooby_doo: "The Gang's Plans"
      },
      restartNow: {
        default: "Restart Now",
        pirate: "Set Sail Anew!",
        hacker: "Reboot",
        noir_detective: "Wake Up Call",
        personal_trainer: "POWER RESTART NOW!",
        scooby_doo: "Let's split up, gang!"
      },
      testConnection: {
        default: "Test Connection",
        pirate: "Test yer Sextant!",
        hacker: "Ping",
        noir_detective: "Test the Line",
        personal_trainer: "TEST YOUR CONNECTION STRENGTH!",
        scooby_doo: "Is the coast clear?"
      },
      backupNow: {
        default: "Backup Now",
        pirate: "Stow Yer Booty Now!",
        hacker: "tar -czf",
        noir_detective: "File the Case",
        personal_trainer: "BACKUP YOUR GAINS NOW!",
        scooby_doo: "Let's grab some snacks!"
      },
      cancel: {
        default: "Cancel",
        pirate: "Belay That!",
        hacker: "Ctrl+C",
        noir_detective: "Never Mind",
        personal_trainer: "NO MERCY! CANCEL WEAKNESS!",
        scooby_doo: "Let's get out of here!"
      },
      save: {
        default: "Save",
        pirate: "Secure The Loot!",
        hacker: "Commit",
        noir_detective: "Lock It In",
        personal_trainer: "SAVE YOUR VICTORY!",
        scooby_doo: "Let's do it, Scoob!"
      },
      restore: {
        default: "Restore This Version",
        pirate: "Haul Back This Booty!",
        hacker: "Rollback",
        noir_detective: "Back to the Drawing Board",
        personal_trainer: "RESTORE YOUR POWER LEVEL!",
        scooby_doo: "Let's, like, go back in time!"
      },
      restoreLovelace: {
        default: "Restore",
        pirate: "Haul Back!",
        hacker: "Restore",
        noir_detective: "Bring Back",
        personal_trainer: "POWER RESTORE!",
        scooby_doo: "Restore the dashboard!"
      }
    },
    settings: {
      title: {
        default: "Settings",
        pirate: "Ship's Log",
        hacker: "Root Config",
        noir_detective: "The Control Room",
        personal_trainer: "YOUR WAR ROOM!",
        scooby_doo: "The Gang's Plans"
      },
      haUrlLabel: {
        default: "Home Assistant URL",
        pirate: "Home Assistant Chart",
        hacker: "Target IP / Endpoint",
        noir_detective: "The Dame's Address",
        personal_trainer: "YOUR HOME ASSISTANT BATTLEGROUND URL!",
        scooby_doo: "Mystery Machine's Location"
      },
      haTokenLabel: {
        default: "Long-Lived Access Token",
        pirate: "Long-Lasting Treasure Map",
        hacker: "Auth Token (key)",
        noir_detective: "The Client's Dough",
        personal_trainer: "YOUR ACCESS TOKEN OF POWER!",
        scooby_doo: "The Secret Password"
      },
      liveConfigPathLabel: {
        default: "Config Folder Path",
        pirate: "Yer Config Crate Path",
        hacker: "Config Dir",
        noir_detective: "The Config Case Path",
        personal_trainer: "YOUR CONFIG GYM FLOOR PATH!",
        scooby_doo: "Clue Folder Path"
      },
      backupFolderPathLabel: {
        default: "Backup Folder Path",
        pirate: "Yer Backup Chest Path",
        hacker: "Archive Dir",
        noir_detective: "The Evidence Locker",
        personal_trainer: "YOUR BACKUP VAULT OF POWER!",
        scooby_doo: "Snack Stash Path"
      },
      enableScheduledBackup: {
        default: "Enable Scheduled Backup",
        pirate: "Set Timed Booty Stowing",
        hacker: "Enable cronjob",
        noir_detective: "Enable Case Filing",
        personal_trainer: "ENABLE YOUR AUTOMATED GAINZ MACHINE!",
        scooby_doo: "Time for a Snack Break?"
      },
      frequencyLabel: {
        default: "Frequency",
        pirate: "How Oft?",
        hacker: "cron Schedule",
        noir_detective: "Filing Frequency",
        personal_trainer: "GAINZ INTERVAL!",
        scooby_doo: "How often?"
      },
      timeLabel: {
        default: "Time",
        pirate: "The Hour",
        hacker: "Time (24h)",
        noir_detective: "The Deadline",
        personal_trainer: "CRUSH TIME!",
        scooby_doo: "What time?"
      }
    },
    connectionTest: {
      testing: {
        default: "Testing connection...",
        pirate: "Testin' the seas...",
        hacker: "Pinging target...",
        noir_detective: "Testing the line...",
        personal_trainer: "TESTING YOUR CONNECTION STRENGTH!",
        scooby_doo: "Like, is anyone there...?"
      },
      connected: {
        default: "Connected to Home Assistant successfully.",
        pirate: "We be in, arrr!",
        hacker: "Ping successful. We're in.",
        noir_detective: "We're in. The line's good.",
        personal_trainer: "CONNECTION CRUSHED! YOU'RE CONNECTED TO HOME ASSISTANT POWER!",
        scooby_doo: "Jinkies! We're in!"
      },
      failed: {
        default: "Connection failed",
        pirate: "Couldn't find the rum barrel, matey!",
        hacker: "Ping failed. Check creds or firewall.",
        noir_detective: "The line's dead. Wrong number?",
        personal_trainer: "CONNECTION FAILED! FIX IT! NO EXCUSES! TEST AGAIN!",
        scooby_doo: "Ruh-roh! It's a ghost!"
      }
    },
    settingsMessages: {
      directoryNotFound: {
        default: "We couldn't find {path}. Create it or pick the correct folder.",
        pirate: "Can't chart a course to {path}, matey. Make the berth or choose another harbor.",
        hacker: "ENOENT on {path}. Create directory or update the pointer.",
        noir_detective: "{path}? Never heard of it. Make the joint or point me somewhere real.",
        personal_trainer: "{path} DOESN'T EXIST! BUILD THAT DIRECTORY OR CHOOSE ONE THAT'S READY TO WORK!",
        scooby_doo: "Ruh-roh! We can't find {path}. Let's, like, make it or pick another."
      },
      notDirectory: {
        default: "{path} isn't a folder. Choose a directory instead.",
        pirate: "{path} be no cargo hold. Pick a true chest, savvy?",
        hacker: "{path} is not a directory. Provide a proper folder path.",
        noir_detective: "{path}? That's not a back room—it's something else. Pick the real stash.",
        personal_trainer: "{path} ISN'T EVEN A FOLDER! GIVE ME A REAL DIRECTORY TO TRAIN IN!",
        scooby_doo: "Zoinks! {path} isn't a folder. We need a folder, man."
      },
      missingAutomations: {
        default: "We couldn't find automations.yaml in {path}. Point to your Home Assistant config folder.",
        pirate: "No automations.yaml stowed in {path}. Hoist yer true Home Assistant hold.",
        hacker: "automations.yaml missing under {path}. Aim at the HA config root.",
        noir_detective: "{path} is missing automations.yaml. Give me the real HQ.",
        personal_trainer: "NO AUTOMATIONS.YAML IN {path}! POINT ME TO YOUR REAL HOME ASSISTANT TRAINING GROUND!",
        scooby_doo: "Jinkies! We can't find automations.yaml in {path}. Let's look in the right place."
      },
      cannotAccess: {
        default: "We couldn't open {path}. Check permissions and try again.",
        pirate: "Couldn't pry open {path}. Check yer locks and give it another go.",
        hacker: "Access denied on {path}. Review permissions and retry.",
        noir_detective: "{path} won't let us in. Fix the locks and we'll talk.",
        personal_trainer: "PERMISSION DENIED ON {path}! ADJUST THOSE RIGHTS AND GET BACK IN THE FIGHT!",
        scooby_doo: "Ruh-roh! We can't get into {path}. Check for ghosts... or permissions."
      },
      backupDirUnwritable: {
        default: "We can't write to {path}. Update permissions or pick another backup folder.",
        pirate: "Can't stash yer booty in {path}. Fix the permissions or choose a new chest.",
        hacker: "Write access refused on {path}. Grant perms or select another directory.",
        noir_detective: "{path} won't take the deposit. Grease the skids or find a new location.",
        personal_trainer: "CAN'T WRITE TO {path}! GIVE IT THE RIGHT PERMISSIONS OR CHOOSE A STRONGER BACKUP SPOT!",
        scooby_doo: "Zoinks! We can't write to {path}. Let's, like, fix it or pick a new spot."
      },
      backupDirCreateFailed: {
        default: "We couldn't create a backup folder inside {parent}. Check permissions or free up space.",
        pirate: "Couldn't carve out a new hold in {parent}. Loosen the rules or clear the deck.",
        hacker: "mkdir failed under {parent}. Verify permissions or disk space.",
        noir_detective: "{parent} wouldn't give us room for a new folder. Fix the setup or find another joint.",
        personal_trainer: "COULDN'T CREATE A BACKUP SPOT IN {parent}! CLEAR SPACE OR GRANT PERMISSIONS AND GO AGAIN!",
        scooby_doo: "Jinkies! We couldn't make a snack stash in {parent}. Maybe it's full?"
      },
      unknownError: {
        default: "Something went wrong. Please try again.",
        pirate: "Somethin' went sideways. Give it another go, matey.",
        hacker: "Unexpected exception. Retry the operation.",
        noir_detective: "Something's off. Let's run it back and see what shakes loose.",
        personal_trainer: "UNKNOWN ERROR?! SHAKE IT OFF AND TRY AGAIN—YOU'VE GOT THIS!",
        scooby_doo: "Ruh-roh! Something went wrong. Let's try again, Scoob!"
      }
    },
    backupNow: {
      creating: {
        default: "Creating backup...",
        pirate: "Stowin' away yer booty...",
        hacker: "Compressing archive...",
        noir_detective: "Filing the case...",
        personal_trainer: "CREATING YOUR POWER BACKUP! HOLD NOTHING BACK!",
        scooby_doo: "Like, making a snack..."
      },
      successWithPath: {
        default: "Backup stored at {path}.",
        pirate: "Booty secured at {path}.",
        hacker: "Archive written to {path}.",
        noir_detective: "Case file stamped and filed at {path}.",
        personal_trainer: "BACKUP LOCKED IN AT {path}! FEEL THAT SECURITY!",
        scooby_doo: "Snack stored at {path}!"
      }
    },
    diffViewer: {
      noChanges: {
        default: "No changes between backup and live version.",
        pirate: "No changes 'tween yer stowed booty and yer live haul, captain.",
        hacker: "Diff: no changes. Files are identical.",
        noir_detective: "No differences in the files.",
        personal_trainer: "NO CHANGES?! YOUR SYSTEM IS ALREADY AT PEAK PERFORMANCE!",
        scooby_doo: "Like, nothing's changed, man."
      },
      compareVersions: {
        default: "Compare backup with current live version.",
        pirate: "Compare yer stowed booty with yer current live haul.",
        hacker: "Running diff on archive vs. live.",
        noir_detective: "Compare the old case with the current investigation.",
        personal_trainer: "COMPARE YOUR PAST GAINS WITH YOUR CURRENT POWER LEVEL!",
        scooby_doo: "Let's, like, compare the clues."
      },
      loadingLive: {
        default: "Loading live version...",
        pirate: "Haulin' in the live haul...",
        hacker: "Reading live file system...",
        noir_detective: "Checking the current case...",
        personal_trainer: "LOADING YOUR CURRENT POWER LEVEL! GET READY TO COMPARE!",
        scooby_doo: "Like, what's happening now...?"
      },
      itemDeleted: {
        default: "This item has been deleted. Restore it from this backup version when you are ready.",
        pirate: "This item be gone to Davy Jones' Locker. Ye can haul it back from this here booty, though.",
        hacker: "File not found (44). Restore from archive?",
        noir_detective: "This file's been shredded. Restore it from the backup case.",
        personal_trainer: "THIS WEAPON HAS BEEN DESTROYED! RESTORE IT AND REBUILD YOUR ARSENAL!",
        scooby_doo: "Zoinks! It's gone! Let's, like, bring it back."
      },
      fileDeleted: {
        default: "This file has been deleted. Restore it from this backup version when you are ready.",
        pirate: "This log entry be gone to Davy Jones' Locker. Ye can haul it back from this here booty, though.",
        hacker: "File deleted. Restore from archive?",
        noir_detective: "This file's been burned. Restore it from the backup cabinet.",
        personal_trainer: "THIS FILE HAS BEEN OBLITERATED! RESTORE IT AND DOMINATE ONCE MORE!",
        scooby_doo: "Ruh-roh! The file is gone! Let's, like, restore it."
      },
      currentVersion: {
        default: "Current Version",
        pirate: "Current Haul",
        hacker: "Live System",
        noir_detective: "Live Case",
        personal_trainer: "CURRENT POWER LEVEL",
        scooby_doo: "Like, right now."
      }
    },
    lovelace: {
      title: {
        default: "Lovelace",
        pirate: "Lovelace's Logbook",
        hacker: "UI Config",
        noir_detective: "The Layout Files",
        personal_trainer: "YOUR STATS DASHBOARD!",
        scooby_doo: "The Mystery Machine's Dashboard"
      },
      searchPlaceholder: {
        default: "Search lovelace files...",
        pirate: "Scour for Lovelace's entries...",
        hacker: "grep UI files...",
        noir_detective: "Hunt for layout files...",
        personal_trainer: "SEARCH YOUR DASHBOARD GAINS... FIND YOUR PERFECT STATS!",
        scooby_doo: "Search for clues..."
      },
      loading: {
        default: "Loading Lovelace files...",
        pirate: "Haulin' in Lovelace's Logs...",
        hacker: "Loading UI modules...",
        noir_detective: "Digging through layout files...",
        personal_trainer: "LOADING YOUR STATS! PREPARE FOR PEAK PERFORMANCE!",
        scooby_doo: "Like, looking for clues..."
      },
      selectBackup: {
        default: "Select a backup to view Lovelace files",
        pirate: "Pick a chest to see Lovelace's entries",
        hacker: "Mount archive to view UI modules",
        noir_detective: "Pick a case file to see layout files",
        personal_trainer: "CHOOSE YOUR BACKUP TO VIEW YOUR DASHBOARD STATS!",
        scooby_doo: "Pick a snack to see the clues."
      },
      noFiles: {
        default: "No Lovelace files found in this backup",
        pirate: "No Lovelace entries be found in this here treasure chest",
        hacker: "No UI modules in this archive.",
        noir_detective: "No layout files found in this backup",
        personal_trainer: "NO DASHBOARD STATS IN THIS BACKUP?! GET TO WORK!",
        scooby_doo: "Ruh-roh! No clues in this snack."
      }
    },
    esphome: {
      title: {
        default: "ESPHome",
        pirate: "ESPHome's Logbook",
        hacker: "Device Configs",
        noir_detective: "The Gadget Files",
        personal_trainer: "YOUR EQUIPMENT ROSTER!",
        scooby_doo: "The Gang's Gadgets"
      },
      searchPlaceholder: {
        default: "Search ESPHome files...",
        pirate: "Scour for ESPHome's entries...",
        hacker: "grep device files...",
        noir_detective: "Hunt for gadget files...",
        personal_trainer: "SEARCH YOUR EQUIPMENT... FIND YOUR PERFECT DEVICE!",
        scooby_doo: "Search for the gang's gadgets..."
      },
      loading: {
        default: "Loading ESPHome files...",
        pirate: "Haulin' in ESPHome's Logs...",
        hacker: "Loading device modules...",
        noir_detective: "Digging through gadget files...",
        personal_trainer: "LOADING YOUR EQUIPMENT ROSTER! PREPARE FOR ACTION!",
        scooby_doo: "Like, looking for gadgets..."
      },
      selectBackup: {
        default: "Select a backup to view ESPHome files",
        pirate: "Pick a chest to see ESPHome's entries",
        hacker: "Mount archive to view ESPHome modules",
        noir_detective: "Pick a case file to see gadget files",
        personal_trainer: "CHOOSE YOUR BACKUP TO VIEW YOUR EQUIPMENT!",
        scooby_doo: "Pick a snack to see the gadgets."
      },
      noFiles: {
        default: "No ESPHome files found in this backup",
        pirate: "No ESPHome entries be found in this here treasure chest",
        hacker: "No device modules in this archive.",
        noir_detective: "No gadget files in this case file",
        personal_trainer: "NO EQUIPMENT IN THIS BACKUP?! BUILD YOUR ARSENAL NOW!",
        scooby_doo: "Ruh-roh! No gadgets in this snack."
      },
      disabled: {
        default: "ESPHome backups are disabled. Enable them in Settings to view files.",
        pirate: "ESPHome backups be sleeping. Flip the switch in Settings to see the logs.",
        hacker: "ESPHome module disabled. Toggle it on in Settings to inspect payloads.",
        noir_detective: "ESPHome's off the books. Switch it back on in Settings if you want the files.",
        personal_trainer: "ESPHOME BACKUPS ARE OFF! HIT THAT SETTINGS SWITCH AND BRING THE POWER BACK!",
        scooby_doo: "Zoinks! ESPHome backups are, like, turned off. Turn them on in the Gang's Plans."
      }
    },
    packages: {
      title: {
        default: "Packages",
        pirate: "Ship's Cargo Log",
        hacker: "Package Modules",
        noir_detective: "The Ledgers",
        personal_trainer: "YOUR CUSTOM ROUTINES!",
        scooby_doo: "The Trap Blueprints"
      },
      searchPlaceholder: {
        default: "Search Packages files...",
        pirate: "Scour for Packages entries...",
        hacker: "grep package files...",
        noir_detective: "Hunt for ledger files...",
        personal_trainer: "SEARCH YOUR ROUTINES... FIND YOUR PERFECT WORKOUT!",
        scooby_doo: "Search for trap blueprints..."
      },
      loading: {
        default: "Loading Packages files...",
        pirate: "Haulin' in Packages...",
        hacker: "Loading package modules...",
        noir_detective: "Digging through the ledgers...",
        personal_trainer: "LOADING YOUR CUSTOM ROUTINES! PREPARE FOR CONFIGURATION DOMINATION!",
        scooby_doo: "Like, looking for trap blueprints..."
      },
      selectBackup: {
        default: "Select a backup to view Packages files",
        pirate: "Pick a chest to see Packages entries",
        hacker: "Mount archive to view package modules",
        noir_detective: "Pick a case file to see the ledgers",
        personal_trainer: "CHOOSE YOUR BACKUP TO VIEW YOUR CUSTOM ROUTINES!",
        scooby_doo: "Pick a snack to see the trap blueprints."
      },
      noFiles: {
        default: "No Packages files found in this backup",
        pirate: "No Packages entries be found in this here treasure chest",
        hacker: "No package modules in this archive.",
        noir_detective: "No ledgers in this case file",
        personal_trainer: "NO ROUTINES IN THIS BACKUP?! BUILD YOUR WORKOUTS NOW!",
        scooby_doo: "Ruh-roh! No trap blueprints in this snack."
      },
      disabled: {
        default: "Packages backups are disabled. Enable them in Settings to view files.",
        pirate: "Packages backups be sleeping. Flip the switch in Settings to see the logs.",
        hacker: "Packages module disabled. Toggle it on in Settings to inspect payloads.",
        noir_detective: "Packages are off the books. Switch it back on in Settings if you want the files.",
        personal_trainer: "PACKAGES BACKUPS ARE OFF! HIT THAT SETTINGS SWITCH AND BRING THE POWER BACK!",
        scooby_doo: "Zoinks! Packages backups are, like, turned off. Turn them on in the Gang's Plans."
      }
    },
    placeholders: {
      search: {
        default: "Search {mode}...",
        pirate: "Hunt for {mode}...",
        hacker: "grep {mode}...",
        noir_detective: "Find that {mode} file...",
        personal_trainer: "SEARCH YOUR {mode} ARSENAL... FIND THAT PERFECT WEAPON!",
        scooby_doo: "Search for {mode}..."
      },
      managedByHA: {
        default: "Automatically managed by Home Assistant",
        pirate: "Managed by the Home Assistant fleet",
        hacker: "Managed by HA core",
        noir_detective: "Handled by the main office",
        personal_trainer: "HOME ASSISTANT'S GOT THIS!",
        scooby_doo: "Like, Home Assistant is on the case!"
      }
    }
  }
};
```

## /homeassistant-time-machine/run.sh

```sh path="/homeassistant-time-machine/run.sh" 
#!/bin/sh
export NODE_ENV="${NODE_ENV:-production}"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-54000}"

echo "======================================"
echo "Home Assistant Time Machine v2.0.2"
echo "======================================"
echo "Starting server..."
echo "======================================"

node app.js
```

## /images/1.png

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/1.png

## /images/2.png

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/2.png

## /images/3.png

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/3.png

## /images/4.png

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/4.png

## /images/5.png

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/5.png

## /images/history.svg

```svg path="/images/history.svg" 
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3" /></svg>
```

## /images/icon.png

Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/icon.png

## /repository.json

```json path="/repository.json" 
{
  "name": "Home Assistant Time Machine Add-on Repository",
  "url": "https://github.com/saihgupr/HomeAssistantTimeMachine",
  "maintainer": "saihgupr"
}
```


The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.
Copied!