mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2026-01-03 11:34:54 +01:00
Compare commits
7 Commits
main
...
copilot/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc6b4519c | ||
|
|
6f77eae638 | ||
|
|
6926aabd62 | ||
|
|
b735ad12fd | ||
|
|
f3e817e139 | ||
|
|
153ecd1094 | ||
|
|
0be54da9cf |
349
.github/scripts/update_language_names.py
vendored
Executable file
349
.github/scripts/update_language_names.py
vendored
Executable file
@@ -0,0 +1,349 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script to automatically update language names in the English translation file.
|
||||||
|
Queries Weblate for translation completion and language names.
|
||||||
|
Only adds languages with >=80% completion to en.json.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from babel import Locale, UnknownLocaleError
|
||||||
|
|
||||||
|
LOCALES_DIR = Path('frontend/locales')
|
||||||
|
EN_JSON_PATH = LOCALES_DIR / 'en.json'
|
||||||
|
WEBLATE_API_URL = 'https://translate.sysadminsmedia.com/api'
|
||||||
|
WEBLATE_PROJECT = 'homebox'
|
||||||
|
WEBLATE_COMPONENT = 'frontend'
|
||||||
|
COMPLETION_THRESHOLD = 80.0 # Minimum completion percentage to include language
|
||||||
|
TIMEOUT = 10 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s: %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_locale_files() -> List[str]:
|
||||||
|
"""Get all locale codes from JSON files in the locales directory."""
|
||||||
|
if not LOCALES_DIR.exists():
|
||||||
|
logging.error("Locales directory not found: %s", LOCALES_DIR)
|
||||||
|
return []
|
||||||
|
|
||||||
|
locale_codes = []
|
||||||
|
for file in sorted(LOCALES_DIR.glob('*.json')):
|
||||||
|
# Extract locale code from filename (e.g., "en.json" -> "en")
|
||||||
|
locale_code = file.stem
|
||||||
|
# Validate locale code format - should not contain dots
|
||||||
|
if '.' not in locale_code:
|
||||||
|
locale_codes.append(locale_code)
|
||||||
|
else:
|
||||||
|
logging.warning("Skipping invalid locale code: %s", locale_code)
|
||||||
|
|
||||||
|
logging.info("Found %d locale files", len(locale_codes))
|
||||||
|
return sorted(locale_codes)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_weblate_translations() -> Optional[Dict[str, Dict]]:
|
||||||
|
"""
|
||||||
|
Fetch translation statistics from Weblate API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping locale code to translation data (percent, name, native_name)
|
||||||
|
or None if API is unavailable
|
||||||
|
"""
|
||||||
|
url = f"{WEBLATE_API_URL}/components/{WEBLATE_PROJECT}/{WEBLATE_COMPONENT}/translations/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Weblate API may require pagination
|
||||||
|
translations = {}
|
||||||
|
page_url = url
|
||||||
|
|
||||||
|
while page_url:
|
||||||
|
logging.info("Fetching translations from Weblate: %s", page_url)
|
||||||
|
resp = requests.get(page_url, timeout=TIMEOUT)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logging.warning("Weblate API returned status %d", resp.status_code)
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
for trans in data.get('results', []):
|
||||||
|
# Weblate uses underscores, we use hyphens
|
||||||
|
locale_code = trans.get('language_code', '').replace('_', '-')
|
||||||
|
percent = trans.get('translated_percent', 0.0)
|
||||||
|
|
||||||
|
lang_info = trans.get('language', {})
|
||||||
|
english_name = lang_info.get('name', '')
|
||||||
|
native_name = lang_info.get('native', '')
|
||||||
|
|
||||||
|
translations[locale_code] = {
|
||||||
|
'percent': percent,
|
||||||
|
'english_name': english_name,
|
||||||
|
'native_name': native_name
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for next page
|
||||||
|
page_url = data.get('next')
|
||||||
|
|
||||||
|
logging.info("Fetched %d translations from Weblate", len(translations))
|
||||||
|
return translations
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.warning("Failed to fetch from Weblate API: %s", e)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Unexpected error fetching Weblate data: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_name_from_babel(locale_code: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the language name using Babel in format "English (Native)".
|
||||||
|
Special handling for variants that need disambiguation (Portuguese, Chinese).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
locale_code: Language/locale code (e.g., 'en', 'pt-BR', 'zh-CN')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Language name in format "English (Native)" or None if cannot parse
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Special handling for ar-AA (non-standard code, use standard 'ar')
|
||||||
|
if locale_code == 'ar-AA':
|
||||||
|
locale = Locale.parse('ar')
|
||||||
|
else:
|
||||||
|
# Parse locale code using Babel
|
||||||
|
locale = Locale.parse(locale_code.replace('-', '_'))
|
||||||
|
|
||||||
|
# Get English display name
|
||||||
|
english_name = locale.get_display_name('en')
|
||||||
|
|
||||||
|
# Get native display name
|
||||||
|
native_name = locale.get_display_name(locale)
|
||||||
|
|
||||||
|
if not english_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Special handling for Portuguese variants (distinguish Brazil vs Portugal)
|
||||||
|
if locale_code == 'pt-BR':
|
||||||
|
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||||
|
return f"Portuguese — Brazil ({native_base})"
|
||||||
|
elif locale_code == 'pt-PT':
|
||||||
|
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||||
|
return f"Portuguese — Portugal ({native_base})"
|
||||||
|
|
||||||
|
# Special handling for Chinese variants (distinguish Simplified/Traditional and regions)
|
||||||
|
if locale_code == 'zh-CN':
|
||||||
|
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||||
|
return f"Chinese — Simplified ({native_base})"
|
||||||
|
elif locale_code == 'zh-TW':
|
||||||
|
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||||
|
return f"Chinese — Traditional ({native_base})"
|
||||||
|
elif locale_code == 'zh-HK':
|
||||||
|
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||||
|
return f"Chinese — Hong Kong ({native_base})"
|
||||||
|
elif locale_code == 'zh-MO':
|
||||||
|
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||||
|
return f"Chinese — Macau ({native_base})"
|
||||||
|
|
||||||
|
# Format: "English (Native)" if native name differs and is available
|
||||||
|
if native_name and native_name != english_name:
|
||||||
|
# Clean up nested parentheses for complex locales
|
||||||
|
if '(' in english_name and '(' in native_name:
|
||||||
|
# For cases like "Japanese (Japan) (日本語 (日本))"
|
||||||
|
# Simplify to "Japanese (日本語)"
|
||||||
|
english_base = english_name.split('(')[0].strip()
|
||||||
|
native_base = native_name.split('(')[0].strip()
|
||||||
|
return f"{english_base} ({native_base})"
|
||||||
|
else:
|
||||||
|
return f"{english_name} ({native_name})"
|
||||||
|
else:
|
||||||
|
return english_name
|
||||||
|
|
||||||
|
except (UnknownLocaleError, ValueError) as e:
|
||||||
|
logging.debug("Could not parse locale '%s' with Babel: %s", locale_code, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_name(locale_code: str, weblate_data: Optional[Dict] = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the display name for a locale code.
|
||||||
|
Priority: Weblate API > Babel > None
|
||||||
|
|
||||||
|
Args:
|
||||||
|
locale_code: Language/locale code (e.g., 'en', 'pt-BR', 'zh-CN')
|
||||||
|
weblate_data: Translation data from Weblate (if available)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Language name in format "English (Native)" or None if invalid
|
||||||
|
"""
|
||||||
|
# Validate locale code format
|
||||||
|
if '.' in locale_code or locale_code.startswith('languages.'):
|
||||||
|
logging.error("Invalid locale code format: %s", locale_code)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try Weblate first
|
||||||
|
if weblate_data and locale_code in weblate_data:
|
||||||
|
english_name = weblate_data[locale_code].get('english_name', '')
|
||||||
|
native_name = weblate_data[locale_code].get('native_name', '')
|
||||||
|
|
||||||
|
if english_name:
|
||||||
|
# Format: "English (Native)" if both names available and different
|
||||||
|
if native_name and native_name != english_name:
|
||||||
|
return f"{english_name} ({native_name})"
|
||||||
|
else:
|
||||||
|
return english_name
|
||||||
|
|
||||||
|
# Fallback to Babel
|
||||||
|
babel_name = get_language_name_from_babel(locale_code)
|
||||||
|
if babel_name:
|
||||||
|
return babel_name
|
||||||
|
|
||||||
|
# If all else fails, return None (don't guess)
|
||||||
|
logging.warning("Could not determine language name for: %s", locale_code)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_en_json() -> dict:
|
||||||
|
"""Load the English translation JSON file."""
|
||||||
|
if not EN_JSON_PATH.exists():
|
||||||
|
logging.error("English translation file not found: %s", EN_JSON_PATH)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with EN_JSON_PATH.open('r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (IOError, json.JSONDecodeError) as e:
|
||||||
|
logging.error("Failed to load %s: %s", EN_JSON_PATH, e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_en_json(data: dict):
|
||||||
|
"""Save the English translation JSON file."""
|
||||||
|
try:
|
||||||
|
with EN_JSON_PATH.open('w', encoding='utf-8') as f:
|
||||||
|
# Use 4-space indentation to match existing file format
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||||
|
# Add newline at end of file
|
||||||
|
f.write('\n')
|
||||||
|
logging.info("Saved updated en.json")
|
||||||
|
except IOError as e:
|
||||||
|
logging.error("Failed to save %s: %s", EN_JSON_PATH, e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def update_language_names(en_data: dict, locale_codes: List[str], weblate_data: Optional[Dict] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Update the languages section in en.json.
|
||||||
|
- Add new languages with >=80% completion (from Weblate) or that exist as locale files
|
||||||
|
- Never remove existing entries (even if completion drops below 80%)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
en_data: The parsed en.json data
|
||||||
|
locale_codes: List of all locale codes from files
|
||||||
|
weblate_data: Translation data from Weblate (if available)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if changes were made, False otherwise
|
||||||
|
"""
|
||||||
|
# Ensure languages section exists
|
||||||
|
if 'languages' not in en_data:
|
||||||
|
en_data['languages'] = {}
|
||||||
|
logging.info("Created 'languages' section in en.json")
|
||||||
|
|
||||||
|
languages = en_data['languages']
|
||||||
|
original_languages = languages.copy()
|
||||||
|
|
||||||
|
# Process each locale file
|
||||||
|
added_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for locale_code in locale_codes:
|
||||||
|
# Skip if already in languages (never remove existing entries)
|
||||||
|
if locale_code in languages:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check Weblate completion threshold if data available
|
||||||
|
if weblate_data and locale_code in weblate_data:
|
||||||
|
percent = weblate_data[locale_code].get('percent', 0.0)
|
||||||
|
|
||||||
|
if percent < COMPLETION_THRESHOLD:
|
||||||
|
logging.info("Skipping %s: %.1f%% completion (threshold: %.1f%%)",
|
||||||
|
locale_code, percent, COMPLETION_THRESHOLD)
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logging.info("Including %s: %.1f%% completion", locale_code, percent)
|
||||||
|
else:
|
||||||
|
# If Weblate data not available, include locale file but log warning
|
||||||
|
logging.info("Including %s: Weblate data not available, locale file exists", locale_code)
|
||||||
|
|
||||||
|
# Get language name
|
||||||
|
language_name = get_language_name(locale_code, weblate_data)
|
||||||
|
|
||||||
|
if language_name:
|
||||||
|
languages[locale_code] = language_name
|
||||||
|
logging.info("Added language: %s = %s", locale_code, language_name)
|
||||||
|
added_count += 1
|
||||||
|
else:
|
||||||
|
logging.warning("Skipping %s: could not determine language name", locale_code)
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
# Sort languages alphabetically by key
|
||||||
|
en_data['languages'] = dict(sorted(languages.items()))
|
||||||
|
|
||||||
|
# Check if anything changed
|
||||||
|
changed = (original_languages != en_data['languages'])
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
logging.info("Updated %d language names, skipped %d", added_count, skipped_count)
|
||||||
|
else:
|
||||||
|
logging.info("All languages already present, no changes needed")
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
setup_logging()
|
||||||
|
logging.info("🔄 Starting language names update")
|
||||||
|
|
||||||
|
# Get all locale files
|
||||||
|
locale_codes = get_locale_files()
|
||||||
|
if not locale_codes:
|
||||||
|
logging.error("No locale files found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load English translation file
|
||||||
|
en_data = load_en_json()
|
||||||
|
if not en_data:
|
||||||
|
logging.error("Failed to load English translation file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Fetch Weblate translation statistics
|
||||||
|
weblate_data = fetch_weblate_translations()
|
||||||
|
if weblate_data:
|
||||||
|
logging.info("Successfully fetched Weblate data for %d languages", len(weblate_data))
|
||||||
|
else:
|
||||||
|
logging.warning("Weblate data not available, proceeding with locale files only")
|
||||||
|
|
||||||
|
# Update language names
|
||||||
|
changed = update_language_names(en_data, locale_codes, weblate_data)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
save_en_json(en_data)
|
||||||
|
logging.info("✅ Language names updated successfully")
|
||||||
|
else:
|
||||||
|
logging.info("✅ No updates needed, en.json is already up-to-date")
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -78,15 +78,6 @@ jobs:
|
|||||||
images: |
|
images: |
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
||||||
name=${{ env.GHCR_REPO }}
|
name=${{ env.GHCR_REPO }}
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=schedule,pattern=nightly
|
|
||||||
flavor: |
|
|
||||||
suffix=-hardened,onlatest=true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||||
|
|||||||
@@ -81,15 +81,6 @@ jobs:
|
|||||||
images: |
|
images: |
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
||||||
name=${{ env.GHCR_REPO }}
|
name=${{ env.GHCR_REPO }}
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=schedule,pattern=nightly
|
|
||||||
flavor: |
|
|
||||||
suffix=-rootless,onlatest=true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||||
|
|||||||
7
.github/workflows/docker-publish.yaml
vendored
7
.github/workflows/docker-publish.yaml
vendored
@@ -76,13 +76,6 @@ jobs:
|
|||||||
images: |
|
images: |
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
||||||
name=${{ env.GHCR_REPO }}
|
name=${{ env.GHCR_REPO }}
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=schedule,pattern=nightly
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||||
|
|||||||
70
.github/workflows/update-language-names.yml
vendored
Normal file
70
.github/workflows/update-language-names.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Update Language Names
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- 'frontend/locales/*.json'
|
||||||
|
- '.github/scripts/update_language_names.py'
|
||||||
|
- '.github/workflows/update-language-names.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-language-names:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
|
||||||
|
with:
|
||||||
|
python-version: '3.8'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: .github/workflows/update-languages/requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r .github/workflows/update-languages/requirements.txt
|
||||||
|
|
||||||
|
- name: Run language names update script
|
||||||
|
run: python .github/scripts/update_language_names.py
|
||||||
|
|
||||||
|
- name: Check for en.json changes
|
||||||
|
run: |
|
||||||
|
if git diff --quiet -- frontend/locales/en.json; then
|
||||||
|
echo "changed=false" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "changed=true" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
if: env.changed == 'true'
|
||||||
|
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
branch: automation/update-language-file
|
||||||
|
base: main
|
||||||
|
title: "Update language names in en.json"
|
||||||
|
commit-message: "chore: update language names in en.json"
|
||||||
|
body: |
|
||||||
|
This PR automatically updates the language names in `frontend/locales/en.json` based on the available locale files.
|
||||||
|
|
||||||
|
New languages have been added to ensure all locale files have corresponding language names in the English translation file.
|
||||||
|
|
||||||
|
🤖 This PR was automatically created by the update-language-names workflow.
|
||||||
|
path: .
|
||||||
|
add-paths: |
|
||||||
|
frontend/locales/en.json
|
||||||
|
|
||||||
|
- name: No updates needed
|
||||||
|
if: env.changed == 'false'
|
||||||
|
run: echo "✅ en.json language names are already up-to-date"
|
||||||
2
.github/workflows/update-languages/requirements.txt
vendored
Normal file
2
.github/workflows/update-languages/requirements.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
babel
|
||||||
|
requests
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -67,3 +67,5 @@ frontend/test-results/
|
|||||||
frontend/playwright-report/
|
frontend/playwright-report/
|
||||||
frontend/blob-report/
|
frontend/blob-report/
|
||||||
frontend/playwright/.cache/
|
frontend/playwright/.cache/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -21,9 +21,6 @@
|
|||||||
<img src="https://img.shields.io/mastodon/follow/110749314839831923?domain=infosec.exchange"/>
|
<img src="https://img.shields.io/mastodon/follow/110749314839831923?domain=infosec.exchange"/>
|
||||||
<img src="https://img.shields.io/lemmy/homebox%40lemmy.world?label=lemmy"/>
|
<img src="https://img.shields.io/lemmy/homebox%40lemmy.world?label=lemmy"/>
|
||||||
</p>
|
</p>
|
||||||
<p align="center" style="width: 100%;">
|
|
||||||
<a href="https://www.pikapods.com/pods?run=homebox"><img src="https://www.pikapods.com/static/run-button.svg"/></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## What is HomeBox
|
## What is HomeBox
|
||||||
|
|
||||||
|
|||||||
@@ -521,42 +521,45 @@
|
|||||||
"update_label": "Update Label"
|
"update_label": "Update Label"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"bs-BA": "Bosnian (Bosnia and Herzegovina)",
|
"ar-AA": "Arabic (العربية)",
|
||||||
"ca": "Catalan",
|
"bs-BA": "Bosnian (bosanski)",
|
||||||
"cs-CZ": "Czech",
|
"ca": "Catalan (català)",
|
||||||
"da-DK": "Danish",
|
"cs-CZ": "Czech (čeština)",
|
||||||
"de": "German",
|
"da-DK": "Danish (dansk)",
|
||||||
|
"de": "German (Deutsch)",
|
||||||
|
"el-GR": "Greek (Ελληνικά)",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
"es": "Spanish",
|
"es": "Spanish (español)",
|
||||||
"fi-FI": "Finnish",
|
"fi-FI": "Finnish (suomi)",
|
||||||
"fr": "French",
|
"fr": "French (français)",
|
||||||
"hu": "Hungarian",
|
"hu": "Hungarian (magyar)",
|
||||||
"id-ID": "Indonesian",
|
"id-ID": "Indonesian (Indonesia)",
|
||||||
"it": "Italian",
|
"it": "Italian (italiano)",
|
||||||
"ja-JP": "Japanese",
|
"ja-JP": "Japanese (日本語)",
|
||||||
"ko-KR": "Korean",
|
"ko-KR": "Korean (한국어)",
|
||||||
"lb-LU": "Luxembourgish (Luxembourg)",
|
"lb-LU": "Luxembourgish (Lëtzebuergesch)",
|
||||||
"lt-LT": "Lithuanian (Lithuania)",
|
"lt-LT": "Lithuanian (lietuvių)",
|
||||||
"nb-NO": "Norwegian Bokmål",
|
"nb-NO": "Norwegian Bokmål (norsk bokmål)",
|
||||||
"nl": "Dutch",
|
"nl": "Dutch (Nederlands)",
|
||||||
"pl": "Polish",
|
"pl": "Polish (polski)",
|
||||||
"pt-BR": "Portuguese (Brazil)",
|
"pt-BR": "Portuguese - Brazil (português)",
|
||||||
"pt-PT": "Portuguese (Portugal)",
|
"pt-PT": "Portuguese - Portugal (português)",
|
||||||
"ro-RO": "Romanian",
|
"ro-RO": "Romanian (română)",
|
||||||
"ru": "Russian",
|
"ru": "Russian (русский)",
|
||||||
"sk-SK": "Slovak",
|
"sk-SK": "Slovak (slovenčina)",
|
||||||
"sl": "Slovenian",
|
"sl": "Slovenian (slovenščina)",
|
||||||
"sq-AL": "Albanian",
|
"sq-AL": "Albanian (shqip)",
|
||||||
"sv": "Swedish",
|
"sv": "Swedish (svenska)",
|
||||||
"ta-IN": "Tamil",
|
"ta-IN": "Tamil (தமிழ்)",
|
||||||
"th-TH": "Thai",
|
"te-IN": "Telugu (తెలుగు)",
|
||||||
"tr": "Turkish",
|
"th-TH": "Thai (ไทย)",
|
||||||
"uk-UA": "Ukrainian",
|
"tr": "Turkish (Türkçe)",
|
||||||
"vi-VN": "Vietnamese",
|
"uk-UA": "Ukrainian (українська)",
|
||||||
"zh-CN": "Chinese (Simplified)",
|
"vi-VN": "Vietnamese (Tiếng Việt)",
|
||||||
"zh-HK": "Chinese (Hong Kong)",
|
"zh-CN": "Chinese - Simplified (中文)",
|
||||||
"zh-MO": "Chinese (Macau)",
|
"zh-HK": "Chinese - Hong Kong (中文)",
|
||||||
"zh-TW": "Chinese (Traditional)"
|
"zh-MO": "Chinese - Macau (中文)",
|
||||||
|
"zh-TW": "Chinese - Traditional (中文)"
|
||||||
},
|
},
|
||||||
"locations": {
|
"locations": {
|
||||||
"child_locations": "Child Locations",
|
"child_locations": "Child Locations",
|
||||||
|
|||||||
9
frontend/pnpm-lock.yaml
generated
9
frontend/pnpm-lock.yaml
generated
@@ -5235,8 +5235,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
qs@6.14.1:
|
qs@6.14.0:
|
||||||
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
|
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
quansync@0.2.11:
|
quansync@0.2.11:
|
||||||
@@ -6289,7 +6289,6 @@ packages:
|
|||||||
vue-i18n@11.2.7:
|
vue-i18n@11.2.7:
|
||||||
resolution: {integrity: sha512-LPv8bAY5OA0UvFEXl4vBQOBqJzRrlExy92tWgRuwW7tbykHf7CH71G2Y4TM2OwGcIS4+hyqKHS2EVBqaYwPY9Q==}
|
resolution: {integrity: sha512-LPv8bAY5OA0UvFEXl4vBQOBqJzRrlExy92tWgRuwW7tbykHf7CH71G2Y4TM2OwGcIS4+hyqKHS2EVBqaYwPY9Q==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
deprecated: This version is NOT deprecated. Previous deprecation was a mistake.
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.0.0
|
vue: ^3.0.0
|
||||||
|
|
||||||
@@ -11389,7 +11388,7 @@ snapshots:
|
|||||||
micro-api-client: 3.3.0
|
micro-api-client: 3.3.0
|
||||||
node-fetch: 3.3.2
|
node-fetch: 3.3.2
|
||||||
p-wait-for: 5.0.2
|
p-wait-for: 5.0.2
|
||||||
qs: 6.14.1
|
qs: 6.14.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
nitropack@2.12.9(@netlify/blobs@9.1.2):
|
nitropack@2.12.9(@netlify/blobs@9.1.2):
|
||||||
@@ -12199,7 +12198,7 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
qs@6.14.1:
|
qs@6.14.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|||||||
@@ -1,182 +1,129 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
const STATUS_ROUTE = "**/api/v1/status";
|
test.describe("Wipe Inventory E2E Test", () => {
|
||||||
const WIPE_ROUTE = "**/api/v1/actions/wipe-inventory";
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Login as demo user (owner with permissions)
|
||||||
const buildStatusResponse = (demo: boolean) => ({
|
await page.goto("/");
|
||||||
allowRegistration: true,
|
await page.fill("input[type='text']", "demo@example.com");
|
||||||
build: { buildTime: new Date().toISOString(), commit: "test", version: "v0.0.0" },
|
await page.fill("input[type='password']", "demo");
|
||||||
demo,
|
await page.click("button[type='submit']");
|
||||||
health: true,
|
await expect(page).toHaveURL("/home");
|
||||||
labelPrinting: false,
|
|
||||||
latest: { date: new Date().toISOString(), version: "v0.0.0" },
|
|
||||||
message: "",
|
|
||||||
oidc: { allowLocal: true, autoRedirect: false, buttonText: "", enabled: false },
|
|
||||||
title: "Homebox",
|
|
||||||
versions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
async function mockStatus(page: Page, demo: boolean) {
|
|
||||||
await page.route(STATUS_ROUTE, route => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(buildStatusResponse(demo)),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(page: Page, email = "demo@example.com", password = "demo") {
|
|
||||||
await page.goto("/home");
|
|
||||||
await expect(page).toHaveURL("/");
|
|
||||||
await page.fill("input[type='text']", email);
|
|
||||||
await page.fill("input[type='password']", password);
|
|
||||||
await page.click("button[type='submit']");
|
|
||||||
await expect(page).toHaveURL("/home");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openWipeInventory(page: Page) {
|
|
||||||
await page.goto("/tools");
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
|
|
||||||
const wipeButton = page.getByRole("button", { name: "Wipe Inventory" }).last();
|
|
||||||
await expect(wipeButton).toBeVisible();
|
|
||||||
await wipeButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Wipe Inventory", () => {
|
|
||||||
test("shows demo mode warning without wipe options", async ({ page }) => {
|
|
||||||
await mockStatus(page, true);
|
|
||||||
await login(page);
|
|
||||||
await openWipeInventory(page);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(
|
|
||||||
"Inventory, labels, locations and maintenance records cannot be wiped whilst Homebox is in demo mode.",
|
|
||||||
{ exact: false }
|
|
||||||
)
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.locator("input#wipe-labels-checkbox")).toHaveCount(0);
|
|
||||||
await expect(page.locator("input#wipe-locations-checkbox")).toHaveCount(0);
|
|
||||||
await expect(page.locator("input#wipe-maintenance-checkbox")).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("production mode", () => {
|
test("should open wipe inventory dialog with all options", async ({ page }) => {
|
||||||
test.beforeEach(async ({ page }) => {
|
// Navigate to Tools page
|
||||||
await mockStatus(page, false);
|
await page.goto("/tools");
|
||||||
await login(page);
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Scroll to the bottom where wipe inventory is located
|
||||||
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Find and click the Wipe Inventory button
|
||||||
|
const wipeButton = page.locator("button", { hasText: "Wipe Inventory" }).last();
|
||||||
|
await expect(wipeButton).toBeVisible();
|
||||||
|
await wipeButton.click();
|
||||||
|
|
||||||
|
// Wait for dialog to appear
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify dialog title is visible
|
||||||
|
await expect(page.locator("text=Wipe Inventory").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Verify all checkboxes are present
|
||||||
|
await expect(page.locator("input#wipe-labels-checkbox")).toBeVisible();
|
||||||
|
await expect(page.locator("input#wipe-locations-checkbox")).toBeVisible();
|
||||||
|
await expect(page.locator("input#wipe-maintenance-checkbox")).toBeVisible();
|
||||||
|
|
||||||
|
// Verify labels for checkboxes
|
||||||
|
await expect(page.locator("label[for='wipe-labels-checkbox']")).toBeVisible();
|
||||||
|
await expect(page.locator("label[for='wipe-locations-checkbox']")).toBeVisible();
|
||||||
|
await expect(page.locator("label[for='wipe-maintenance-checkbox']")).toBeVisible();
|
||||||
|
|
||||||
|
// Verify both Cancel and Confirm buttons are present
|
||||||
|
await expect(page.locator("button", { hasText: "Cancel" })).toBeVisible();
|
||||||
|
const confirmButton = page.locator("button", { hasText: "Confirm" });
|
||||||
|
await expect(confirmButton).toBeVisible();
|
||||||
|
|
||||||
|
// Take screenshot of the modal
|
||||||
|
await page.screenshot({
|
||||||
|
path: "/tmp/playwright-logs/wipe-inventory-modal-initial.png",
|
||||||
});
|
});
|
||||||
|
console.log("✅ Screenshot saved: wipe-inventory-modal-initial.png");
|
||||||
|
|
||||||
test("renders wipe options and submits all flags", async ({ page }) => {
|
// Check all three options
|
||||||
await page.route(WIPE_ROUTE, route => {
|
await page.check("input#wipe-labels-checkbox");
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ completed: 0 }) });
|
await page.check("input#wipe-locations-checkbox");
|
||||||
});
|
await page.check("input#wipe-maintenance-checkbox");
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
await openWipeInventory(page);
|
// Verify checkboxes are checked
|
||||||
await expect(page.getByText("Wipe Inventory").first()).toBeVisible();
|
await expect(page.locator("input#wipe-labels-checkbox")).toBeChecked();
|
||||||
|
await expect(page.locator("input#wipe-locations-checkbox")).toBeChecked();
|
||||||
|
await expect(page.locator("input#wipe-maintenance-checkbox")).toBeChecked();
|
||||||
|
|
||||||
const labels = page.locator("input#wipe-labels-checkbox");
|
// Take screenshot with all options checked
|
||||||
const locations = page.locator("input#wipe-locations-checkbox");
|
await page.screenshot({
|
||||||
const maintenance = page.locator("input#wipe-maintenance-checkbox");
|
path: "/tmp/playwright-logs/wipe-inventory-modal-options-checked.png",
|
||||||
|
|
||||||
await expect(labels).toBeVisible();
|
|
||||||
await expect(locations).toBeVisible();
|
|
||||||
await expect(maintenance).toBeVisible();
|
|
||||||
|
|
||||||
await labels.check();
|
|
||||||
await locations.check();
|
|
||||||
await maintenance.check();
|
|
||||||
|
|
||||||
const requestPromise = page.waitForRequest(WIPE_ROUTE);
|
|
||||||
await page.getByRole("button", { name: "Confirm" }).last().click();
|
|
||||||
const request = await requestPromise;
|
|
||||||
|
|
||||||
expect(request.postDataJSON()).toEqual({
|
|
||||||
wipeLabels: true,
|
|
||||||
wipeLocations: true,
|
|
||||||
wipeMaintenance: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page.locator("[role='status']").first()).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
console.log("✅ Screenshot saved: wipe-inventory-modal-options-checked.png");
|
||||||
|
|
||||||
test("blocks wipe attempts from non-owners", async ({ page }) => {
|
// Click Confirm button
|
||||||
await page.route(WIPE_ROUTE, route => {
|
await confirmButton.click();
|
||||||
route.fulfill({
|
await page.waitForTimeout(2000);
|
||||||
status: 403,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ message: "forbidden" }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await openWipeInventory(page);
|
// Wait for the dialog to close (verify button is no longer visible)
|
||||||
|
await expect(confirmButton).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
const requestPromise = page.waitForRequest(WIPE_ROUTE);
|
// Check for success toast notification
|
||||||
await page.getByRole("button", { name: "Confirm" }).last().click();
|
// The toast should contain text about items being deleted
|
||||||
await requestPromise;
|
const toastLocator = page.locator("[role='status'], [class*='toast'], [class*='sonner']");
|
||||||
|
await expect(toastLocator.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await expect(page.getByText("Failed to wipe inventory.")).toBeVisible();
|
// Take screenshot of the page after confirmation
|
||||||
|
await page.screenshot({
|
||||||
|
path: "/tmp/playwright-logs/after-wipe-confirmation.png",
|
||||||
|
fullPage: true,
|
||||||
});
|
});
|
||||||
|
console.log("✅ Screenshot saved: after-wipe-confirmation.png");
|
||||||
|
|
||||||
const checkboxCases = [
|
console.log("✅ Test completed successfully!");
|
||||||
{
|
console.log("✅ Wipe Inventory dialog opened correctly");
|
||||||
name: "labels only",
|
console.log("✅ All three options (labels, locations, maintenance) are available");
|
||||||
selection: { labels: true, locations: false, maintenance: false },
|
console.log("✅ Confirm button triggers the action");
|
||||||
},
|
console.log("✅ Dialog closes after confirmation");
|
||||||
{
|
});
|
||||||
name: "locations only",
|
|
||||||
selection: { labels: false, locations: true, maintenance: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "maintenance only",
|
|
||||||
selection: { labels: false, locations: false, maintenance: true },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const scenario of checkboxCases) {
|
test("should cancel wipe inventory operation", async ({ page }) => {
|
||||||
test(`submits correct flags when ${scenario.name} is selected`, async ({ page }) => {
|
// Navigate to Tools page
|
||||||
await page.route(WIPE_ROUTE, route => {
|
await page.goto("/tools");
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ completed: 0 }) });
|
await page.waitForLoadState("networkidle");
|
||||||
});
|
|
||||||
|
|
||||||
await openWipeInventory(page);
|
// Scroll to wipe inventory section
|
||||||
await expect(page.getByText("Wipe Inventory").first()).toBeVisible();
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
const labels = page.locator("input#wipe-labels-checkbox");
|
// Click Wipe Inventory button
|
||||||
const locations = page.locator("input#wipe-locations-checkbox");
|
const wipeButton = page.locator("button", { hasText: "Wipe Inventory" }).last();
|
||||||
const maintenance = page.locator("input#wipe-maintenance-checkbox");
|
await wipeButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
if (scenario.selection.labels) {
|
// Verify dialog is open
|
||||||
await labels.check();
|
await expect(page.locator("text=Wipe Inventory").first()).toBeVisible();
|
||||||
} else {
|
|
||||||
await labels.uncheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scenario.selection.locations) {
|
// Click Cancel button
|
||||||
await locations.check();
|
const cancelButton = page.locator("button", { hasText: "Cancel" });
|
||||||
} else {
|
await cancelButton.click();
|
||||||
await locations.uncheck();
|
await page.waitForTimeout(1000);
|
||||||
}
|
|
||||||
|
|
||||||
if (scenario.selection.maintenance) {
|
// Verify dialog is closed
|
||||||
await maintenance.check();
|
await expect(page.locator("text=Wipe Inventory").first()).not.toBeVisible({ timeout: 5000 });
|
||||||
} else {
|
|
||||||
await maintenance.uncheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestPromise = page.waitForRequest(WIPE_ROUTE);
|
// Take screenshot after cancel
|
||||||
await page.getByRole("button", { name: "Confirm" }).last().click();
|
await page.screenshot({
|
||||||
const request = await requestPromise;
|
path: "/tmp/playwright-logs/after-cancel.png",
|
||||||
|
});
|
||||||
expect(request.postDataJSON()).toEqual({
|
console.log("✅ Screenshot saved: after-cancel.png");
|
||||||
wipeLabels: scenario.selection.labels,
|
console.log("✅ Cancel button works correctly");
|
||||||
wipeLocations: scenario.selection.locations,
|
|
||||||
wipeMaintenance: scenario.selection.maintenance,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -3211,8 +3211,8 @@ packages:
|
|||||||
protocols@2.0.2:
|
protocols@2.0.2:
|
||||||
resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==}
|
resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==}
|
||||||
|
|
||||||
qs@6.14.1:
|
qs@6.14.0:
|
||||||
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
|
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
quansync@0.2.11:
|
quansync@0.2.11:
|
||||||
@@ -6860,7 +6860,7 @@ snapshots:
|
|||||||
micro-api-client: 3.3.0
|
micro-api-client: 3.3.0
|
||||||
node-fetch: 3.3.2
|
node-fetch: 3.3.2
|
||||||
p-wait-for: 5.0.2
|
p-wait-for: 5.0.2
|
||||||
qs: 6.14.1
|
qs: 6.14.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
nitropack@2.12.9(@netlify/blobs@9.1.2):
|
nitropack@2.12.9(@netlify/blobs@9.1.2):
|
||||||
@@ -7502,7 +7502,7 @@ snapshots:
|
|||||||
|
|
||||||
protocols@2.0.2: {}
|
protocols@2.0.2: {}
|
||||||
|
|
||||||
qs@6.14.1:
|
qs@6.14.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|||||||
Reference in New Issue
Block a user