From 010d9ab4732d0260bf4ede38b337bbc0867837c2 Mon Sep 17 00:00:00 2001 From: rosch100 Date: Mon, 1 Dec 2025 20:58:55 +0100 Subject: [PATCH 01/10] feat: Add multilingual README support - Add optional language parameter to hacs/repository/info websocket command - Implement async_get_info_file_contents_with_language method - Support language-specific README files (README.{language}.md) - Fallback to README.md if language-specific version not found - Validate language codes (2-letter ISO 639-1) - Fully backward compatible --- custom_components/hacs/repositories/base.py | 2989 +++++++++-------- .../hacs/websocket/repository.py | 747 ++-- 2 files changed, 1907 insertions(+), 1829 deletions(-) diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 950f52facd3..58761d8094c 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -1,1460 +1,1529 @@ -"""Repository.""" - -from __future__ import annotations - -from asyncio import sleep -from datetime import UTC, datetime -import os -import pathlib -import shutil -import tempfile -from typing import TYPE_CHECKING, Any -import zipfile - -from aiogithubapi import ( - AIOGitHubAPIException, - AIOGitHubAPINotModifiedException, - GitHubReleaseModel, -) -from aiogithubapi.objects.repository import AIOGitHubAPIRepository -import attr -from homeassistant.helpers import device_registry as dr, issue_registry as ir - -from ..const import DOMAIN -from ..enums import HacsDispatchEvent, RepositoryFile -from ..exceptions import ( - HacsException, - HacsNotModifiedException, - HacsRepositoryArchivedException, - HacsRepositoryExistException, -) -from ..types import DownloadableContent -from ..utils.backup import Backup -from ..utils.decode import decode_content -from ..utils.decorator import concurrent, return_none_on_exception -from ..utils.file_system import async_exists, async_remove, async_remove_directory -from ..utils.filters import filter_content_return_one_of_type -from ..utils.github_graphql_query import GET_REPOSITORY_RELEASES -from ..utils.json import json_loads -from ..utils.logger import LOGGER -from ..utils.path import is_safe -from ..utils.queue_manager import QueueManager -from ..utils.store import async_remove_store -from ..utils.url import github_archive, github_release_asset -from ..utils.validate import Validate -from ..utils.version import ( - version_left_higher_or_equal_then_right, - version_left_higher_then_right, -) -from ..utils.workarounds import DOMAIN_OVERRIDES - -if TYPE_CHECKING: - from ..base import HacsBase - - -TOPIC_FILTER = ( - "add-on", - "addon", - "app", - "appdaemon-apps", - "appdaemon", - "custom-card", - "custom-cards", - "custom-component", - "custom-components", - "customcomponents", - "hacktoberfest", - "hacs-default", - "hacs-integration", - "hacs-repository", - "hacs", - "hass", - "hassio", - "home-assistant-custom", - "home-assistant-frontend", - "home-assistant-hacs", - "home-assistant-sensor", - "home-assistant", - "home-automation", - "homeassistant-components", - "homeassistant-integration", - "homeassistant-sensor", - "homeassistant", - "homeautomation", - "integration", - "lovelace-ui", - "lovelace", - "media-player", - "mediaplayer", - "plugin", - "python_script", - "python-script", - "python", - "sensor", - "smart-home", - "smarthome", - "template", - "templates", - "theme", - "themes", -) - - -REPOSITORY_KEYS_TO_EXPORT = ( - # Keys can not be removed from this list until v3 - # If keys are added, the action need to be re-run with force - ("description", ""), - ("downloads", 0), - ("domain", None), - ("etag_releases", None), - ("etag_repository", None), - ("full_name", ""), - ("last_commit", None), - ("last_updated", 0), - ("last_version", None), - ("manifest_name", None), - ("open_issues", 0), - ("prerelease", None), - ("stargazers_count", 0), - ("topics", []), -) - -HACS_MANIFEST_KEYS_TO_EXPORT = ( - # Keys can not be removed from this list until v3 - # If keys are added, the action need to be re-run with force - ("country", []), - ("name", None), -) - - -class FileInformation: - """FileInformation.""" - - def __init__(self, url, path, name): - self.download_url = url - self.path = path - self.name = name - - -@attr.s(auto_attribs=True) -class RepositoryData: - """RepositoryData class.""" - - archived: bool = False - authors: list[str] = [] - category: str = "" - config_flow: bool = False - default_branch: str = None - description: str = "" - domain: str = None - downloads: int = 0 - etag_repository: str = None - etag_releases: str = None - file_name: str = "" - first_install: bool = False - full_name: str = "" - hide: bool = False - has_issues: bool = True - id: int = 0 - installed_commit: str = None - installed_version: str = None - installed: bool = False - last_commit: str = None - last_fetched: datetime = None - last_updated: str = 0 - last_version: str = None - manifest_name: str = None - new: bool = True - open_issues: int = 0 - prerelease: str = None - published_tags: list[str] = [] - releases: bool = False - selected_tag: str = None - show_beta: bool = False - stargazers_count: int = 0 - topics: list[str] = [] - - @property - def name(self): - """Return the name.""" - if self.category == "integration": - return self.domain - return self.full_name.split("/")[-1] - - def to_json(self): - """Export to json.""" - return attr.asdict(self, filter=lambda attr, value: attr.name != "last_fetched") - - @staticmethod - def create_from_dict(source: dict, action: bool = False) -> RepositoryData: - """Set attributes from dicts.""" - data = RepositoryData() - data.update_data(source, action) - return data - - def update_data(self, data: dict, action: bool = False) -> None: - """Update data of the repository.""" - for key, value in data.items(): - if key not in self.__dict__: - continue - - if key == "last_fetched" and isinstance(value, float): - setattr(self, key, datetime.fromtimestamp(value, UTC)) - elif key == "id": - setattr(self, key, str(value)) - elif key == "country": - if isinstance(value, str): - setattr(self, key, [value]) - else: - setattr(self, key, value) - elif key == "topics" and not action: - setattr(self, key, [topic for topic in value if topic not in TOPIC_FILTER]) - - else: - setattr(self, key, value) - - -@attr.s(auto_attribs=True) -class HacsManifest: - """HacsManifest class.""" - - content_in_root: bool = False - country: list[str] = [] - filename: str = None - hacs: str = None # Minimum HACS version - hide_default_branch: bool = False - homeassistant: str = None # Minimum Home Assistant version - manifest: dict = {} - name: str = None - persistent_directory: str = None - render_readme: bool = False - zip_release: bool = False - - def to_dict(self): - """Export to json.""" - return attr.asdict(self) - - @staticmethod - def from_dict(manifest: dict): - """Set attributes from dicts.""" - if manifest is None: - raise HacsException("Missing manifest data") - - manifest_data = HacsManifest() - manifest_data.manifest = { - k: v - for k, v in manifest.items() - if k in manifest_data.__dict__ and v != manifest_data.__getattribute__(k) - } - - for key, value in manifest_data.manifest.items(): - if key == "country" and isinstance(value, str): - setattr(manifest_data, key, [value]) - elif key in manifest_data.__dict__: - setattr(manifest_data, key, value) - return manifest_data - - def update_data(self, data: dict) -> None: - """Update the manifest data.""" - for key, value in data.items(): - if key not in self.__dict__: - continue - - if key == "country": - if isinstance(value, str): - setattr(self, key, [value]) - else: - setattr(self, key, value) - else: - setattr(self, key, value) - - -class RepositoryReleases: - """RepositoyReleases.""" - - last_release = None - last_release_object = None - published_tags = [] - objects: list[GitHubReleaseModel] = [] - releases = False - downloads = None - - -class RepositoryPath: - """RepositoryPath.""" - - local: str | None = None - remote: str | None = None - - -class RepositoryContent: - """RepositoryContent.""" - - path: RepositoryPath | None = None - files = [] - objects = [] - single = False - - -class HacsRepository: - """HacsRepository.""" - - def __init__(self, hacs: HacsBase) -> None: - """Set up HacsRepository.""" - self.hacs = hacs - self.additional_info = "" - self.data = RepositoryData() - self.content = RepositoryContent() - self.content.path = RepositoryPath() - self.repository_object: AIOGitHubAPIRepository | None = None - self.updated_info = False - self.state = None - self.force_branch = False - self.integration_manifest = {} - self.repository_manifest = HacsManifest.from_dict({}) - self.validate = Validate() - self.releases = RepositoryReleases() - self.pending_restart = False - self.tree = [] - self.treefiles = [] - self.ref = None - self.logger = LOGGER - - def __str__(self) -> str: - """Return a string representation of the repository.""" - return self.string - - @property - def string(self) -> str: - """Return a string representation of the repository.""" - return f"<{self.data.category.title()} {self.data.full_name}>" - - @property - def display_name(self) -> str: - """Return display name.""" - if self.repository_manifest.name is not None: - return self.repository_manifest.name - - if self.data.category == "integration": - if self.data.manifest_name is not None: - return self.data.manifest_name - if "name" in self.integration_manifest: - return self.integration_manifest["name"] - - return self.data.full_name.split("/")[-1].replace("-", " ").replace("_", " ").title() - - @property - def ignored_by_country_configuration(self) -> bool: - """Return True if hidden by country.""" - if self.data.installed: - return False - configuration = self.hacs.configuration.country.lower() - if configuration == "all": - return False - - manifest = [entry.lower() for entry in self.repository_manifest.country or []] - if not manifest: - return False - return configuration not in manifest - - @property - def display_status(self) -> str: - """Return display_status.""" - if self.data.new: - status = "new" - elif self.pending_restart: - status = "pending-restart" - elif self.pending_update: - status = "pending-upgrade" - elif self.data.installed: - status = "installed" - else: - status = "default" - return status - - @property - def display_installed_version(self) -> str: - """Return display_authors""" - if self.data.installed_version is not None: - installed = self.data.installed_version - else: - if self.data.installed_commit is not None: - installed = self.data.installed_commit - else: - installed = "" - return str(installed) - - @property - def display_available_version(self) -> str: - """Return display_authors""" - if self.data.show_beta and self.data.prerelease is not None: - available = self.data.prerelease - elif self.data.last_version is not None: - available = self.data.last_version - else: - if self.data.last_commit is not None: - available = self.data.last_commit - else: - available = "" - return str(available) - - @property - def display_version_or_commit(self) -> str: - """Does the repositoriy use releases or commits?""" - if self.data.releases: - version_or_commit = "version" - else: - version_or_commit = "commit" - return version_or_commit - - @property - def pending_update(self) -> bool: - """Return True if pending update.""" - if self.data.installed: - if self.data.selected_tag is not None: - if self.data.selected_tag == self.data.default_branch: - if self.data.installed_commit != self.data.last_commit: - return True - return False - if self.display_version_or_commit == "version": - if ( - result := version_left_higher_then_right( - self.display_available_version, - self.display_installed_version, - ) - ) is not None: - return result - if self.display_installed_version != self.display_available_version: - return True - - return False - - @property - def can_download(self) -> bool: - """Return True if we can download.""" - if self.repository_manifest.homeassistant is not None: - if self.data.releases: - if not version_left_higher_or_equal_then_right( - self.hacs.core.ha_version.string, - self.repository_manifest.homeassistant, - ): - return False - return True - - @property - def localpath(self) -> str | None: - """Return localpath.""" - return None - - @property - def should_try_releases(self) -> bool: - """Return a boolean indicating whether to download releases or not.""" - if self.repository_manifest.zip_release: - if self.repository_manifest.filename.endswith(".zip"): - if self.ref != self.data.default_branch: - return True - if self.ref == self.data.default_branch: - return False - if self.data.category not in ["plugin", "theme"]: - return False - if not self.data.releases: - return False - return True - - async def validate_repository(self) -> None: - """Validate.""" - - @concurrent(concurrenttasks=10, backoff_time=5) - async def update_repository(self, ignore_issues=False, force=False) -> None: - """Update the repository""" - - async def common_validate(self, ignore_issues: bool = False) -> None: - """Common validation steps of the repository.""" - self.validate.errors.clear() - - # Make sure the repository exist. - self.logger.debug("%s Checking repository.", self.string) - await self.common_update_data(ignore_issues=ignore_issues) - - # Get the content of hacs.json - if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]: - if manifest := await self.async_get_hacs_json(): - self.repository_manifest = HacsManifest.from_dict(manifest) - self.data.update_data( - self.repository_manifest.to_dict(), - action=self.hacs.system.action, - ) - - async def common_registration(self) -> None: - """Common registration steps of the repository.""" - # Attach repository - if self.repository_object is None: - try: - self.repository_object, etag = await self.async_get_legacy_repository_object( - etag=None if self.data.installed else self.data.etag_repository, - ) - self.data.update_data( - self.repository_object.attributes, - action=self.hacs.system.action, - ) - self.data.etag_repository = etag - except HacsNotModifiedException: - self.logger.debug("%s Did not update, content was not modified", self.string) - return - - if self.repository_object: - self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) - self.data.last_fetched = datetime.now(UTC) - - @concurrent(concurrenttasks=10, backoff_time=5) - async def common_update(self, ignore_issues=False, force=False, skip_releases=False) -> bool: - """Common information update steps of the repository.""" - self.logger.debug("%s Getting repository information", self.string) - - # Attach repository - current_etag = self.data.etag_repository - try: - await self.common_update_data( - ignore_issues=ignore_issues, - force=force, - skip_releases=skip_releases, - ) - except HacsRepositoryExistException: - self.data.full_name = self.hacs.common.renamed_repositories[self.data.full_name] - await self.common_update_data(ignore_issues=ignore_issues, force=force) - - except HacsException: - if not ignore_issues and not force: - return False - - if not self.data.installed and (current_etag == self.data.etag_repository) and not force: - self.logger.debug("%s Did not update, content was not modified", self.string) - return False - - # Update last updated - if self.repository_object: - self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) - - # Update last available commit - await self.repository_object.set_last_commit() - self.data.last_commit = self.repository_object.last_commit - - # Get the content of hacs.json - if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]: - if manifest := await self.async_get_hacs_json(): - self.repository_manifest = HacsManifest.from_dict(manifest) - self.data.update_data( - self.repository_manifest.to_dict(), - action=self.hacs.system.action, - ) - - # Update "info.md" - self.additional_info = await self.async_get_info_file_contents() - - # Set last fetch attribute - self.data.last_fetched = datetime.now(UTC) - - return True - - async def download_zip_files(self, validate: Validate) -> None: - """Download ZIP archive from repository release.""" - - try: - await self.async_download_zip_file( - DownloadableContent( - name=self.repository_manifest.filename, - url=github_release_asset( - repository=self.data.full_name, - version=self.ref, - filename=self.repository_manifest.filename, - ), - ), - validate, - ) - # lgtm [py/catch-base-exception] pylint: disable=broad-except - except BaseException: - validate.errors.append( - f"Download of {self.repository_manifest.filename} was not completed" - ) - - async def async_download_zip_file( - self, - content: DownloadableContent, - validate: Validate, - ) -> None: - """Download ZIP archive from repository release.""" - try: - filecontent = await self.hacs.async_download_file(content["url"]) - - if filecontent is None: - validate.errors.append(f"Failed to download {content['url']}") - return - - temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) - temp_file = f"{temp_dir}/{self.repository_manifest.filename}" - - result = await self.hacs.async_save_file(temp_file, filecontent) - - def _extract_zip_file(): - with zipfile.ZipFile(temp_file, "r") as zip_file: - zip_file.extractall(self.content.path.local) - - await self.hacs.hass.async_add_executor_job(_extract_zip_file) - - def cleanup_temp_dir(): - """Cleanup temp_dir.""" - if os.path.exists(temp_dir): - self.logger.debug("%s Cleaning up %s", self.string, temp_dir) - shutil.rmtree(temp_dir) - - if result: - self.logger.info("%s Download of %s completed", self.string, content["name"]) - await self.hacs.hass.async_add_executor_job(cleanup_temp_dir) - return - - validate.errors.append(f"[{content['name']}] was not downloaded") - # lgtm [py/catch-base-exception] pylint: disable=broad-except - except BaseException: - validate.errors.append("Download was not completed") - - async def download_content(self, version: string | None = None) -> None: - """Download the content of a directory.""" - contents: list[FileInformation] | None = None - if ( - not self.repository_manifest.zip_release - and not self.data.file_name - and self.content.path.remote is not None - ): - self.logger.info("%s Downloading repository archive", self.string) - try: - await self.download_repository_zip() - return - except HacsException as exception: - self.logger.exception(exception) - - if self.repository_manifest.filename: - self.logger.debug("%s %s", self.string, self.repository_manifest.filename) - - if self.content.path.remote == "release" and version is not None: - contents = await self.release_contents(version) - - if not contents: - contents = self.gather_files_to_download() - - if not contents: - raise HacsException("No content to download") - - download_queue = QueueManager(hass=self.hacs.hass) - - for content in contents: - if self.repository_manifest.content_in_root and self.repository_manifest.filename: - if content.name != self.repository_manifest.filename: - continue - download_queue.add(self.dowload_repository_content(content)) - - await download_queue.execute() - - async def download_repository_zip(self): - """Download the zip archive of the repository.""" - ref = f"{self.ref}".replace("tags/", "") - - if not ref: - raise HacsException("Missing required elements.") - - filecontent = await self.hacs.async_download_file( - github_archive(repository=self.data.full_name, version=ref, variant="tags"), - keep_url=True, - nolog=True, - ) - - if filecontent is None: - filecontent = await self.hacs.async_download_file( - github_archive(repository=self.data.full_name, version=ref, variant="heads"), - keep_url=True, - ) - if filecontent is None: - raise HacsException(f"[{self}] Failed to download zipball") - - temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) - temp_file = f"{temp_dir}/{self.repository_manifest.filename}" - result = await self.hacs.async_save_file(temp_file, filecontent) - if not result: - raise HacsException("Could not save ZIP file") - - def _extract_zip_file(): - with zipfile.ZipFile(temp_file, "r") as zip_file: - extractable = [] - for path in zip_file.filelist: - filename = "/".join(path.filename.split("/")[1:]) - if ( - filename.startswith(self.content.path.remote) - and filename != self.content.path.remote - ): - path.filename = filename.replace(self.content.path.remote, "") - if path.filename == "/": - # Blank files is not valid, and will start to throw in Python 3.12 - continue - extractable.append(path) - - if len(extractable) == 0: - raise HacsException("No content to extract") - zip_file.extractall(self.content.path.local, extractable) - - await self.hacs.hass.async_add_executor_job(_extract_zip_file) - - def cleanup_temp_dir(): - """Cleanup temp_dir.""" - if os.path.exists(temp_dir): - self.logger.debug("%s Cleaning up %s", self.string, temp_dir) - shutil.rmtree(temp_dir) - - await self.hacs.hass.async_add_executor_job(cleanup_temp_dir) - self.logger.info("%s Content was extracted to %s", self.string, self.content.path.local) - - async def async_get_hacs_json(self, ref: str = None) -> dict[str, Any] | None: - """Get the content of the hacs.json file.""" - try: - response = await self.hacs.async_github_api_method( - method=self.hacs.githubapi.repos.contents.get, - raise_exception=False, - repository=self.data.full_name, - path=RepositoryFile.HACS_JSON, - **{"params": {"ref": ref or self.version_to_download()}}, - ) - if response: - return json_loads(decode_content(response.data.content)) - # lgtm [py/catch-base-exception] pylint: disable=broad-except - except BaseException: - pass - - async def async_get_info_file_contents(self, *, version: str | None = None, **kwargs) -> str: - """Get the content of the info.md file.""" - - def _info_file_variants() -> tuple[str, ...]: - name: str = "readme" - return ( - f"{name.upper()}.md", - f"{name}.md", - f"{name}.MD", - f"{name.upper()}.MD", - name.upper(), - name, - ) - - info_files = [filename for filename in _info_file_variants() if filename in self.treefiles] - - if not info_files: - return "" - - return await self.get_documentation(filename=info_files[0], version=version) or "" - - def remove(self) -> None: - """Run remove tasks.""" - if self.hacs.repositories.is_registered(repository_id=str(self.data.id)): - self.logger.info("%s Starting removal", self.string) - self.hacs.repositories.unregister(self) - - async def uninstall(self) -> None: - """Run uninstall tasks.""" - self.logger.info("%s Removing", self.string) - if not await self.remove_local_directory(): - raise HacsException("Could not uninstall") - self.data.installed = False - await self._async_post_uninstall() - await async_remove_store(self.hacs.hass, f"hacs/{self.data.id}.hacs") - - self.data.installed_version = None - self.data.installed_commit = None - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY, - { - "id": 1337, - "action": "uninstall", - "repository": self.data.full_name, - "repository_id": self.data.id, - }, - ) - - await self.async_remove_entity_device() - ir.async_delete_issue(self.hacs.hass, DOMAIN, f"removed_{self.data.id}") - - async def remove_local_directory(self) -> None: - """Check the local directory.""" - - try: - if self.data.category == "python_script": - local_path = f"{self.content.path.local}/{self.data.file_name}" - elif self.data.category == "template": - local_path = f"{self.content.path.local}/{self.data.file_name}" - elif self.data.category == "theme": - path = ( - f"{self.hacs.core.config_path}/" - f"{self.hacs.configuration.theme_path}/" - f"{self.data.name}.yaml" - ) - await async_remove(self.hacs.hass, path, missing_ok=True) - local_path = self.content.path.local - elif self.data.category == "integration": - if not self.data.domain: - if domain := DOMAIN_OVERRIDES.get(self.data.full_name): - self.data.domain = domain - self.content.path.local = self.localpath - else: - self.logger.error("%s Missing domain", self.string) - return False - local_path = self.content.path.local - else: - local_path = self.content.path.local - - if await async_exists(self.hacs.hass, local_path): - if not is_safe(self.hacs, local_path): - self.logger.error("%s Path %s is blocked from removal", self.string, local_path) - return False - self.logger.debug("%s Removing %s", self.string, local_path) - - if self.data.category in ["python_script", "template"]: - await async_remove(self.hacs.hass, local_path) - else: - await async_remove_directory(self.hacs.hass, local_path) - - while await async_exists(self.hacs.hass, local_path): - await sleep(1) - else: - self.logger.debug( - "%s Presumed local content path %s does not exist", self.string, local_path - ) - - except ( - # lgtm [py/catch-base-exception] pylint: disable=broad-except - BaseException - ) as exception: - self.logger.debug("%s Removing %s failed with %s", self.string, local_path, exception) - return False - return True - - async def async_pre_registration(self) -> None: - """Run pre registration steps.""" - - @concurrent(concurrenttasks=10) - async def async_registration(self, ref=None) -> None: - """Run registration steps.""" - await self.async_pre_registration() - - if ref is not None: - self.data.selected_tag = ref - self.ref = ref - self.force_branch = True - - if not await self.validate_repository(): - return False - - # Run common registration steps. - await self.common_registration() - - # Set correct local path - self.content.path.local = self.localpath - - # Run local post registration steps. - await self.async_post_registration() - - async def async_post_registration(self) -> None: - """Run post registration steps.""" - if not self.hacs.system.action: - return - await self.hacs.validation.async_run_repository_checks(self) - - async def async_pre_install(self) -> None: - """Run pre install steps.""" - - async def _async_pre_install(self) -> None: - """Run pre install steps.""" - self.logger.info("%s Running pre installation steps", self.string) - await self.async_pre_install() - self.logger.info("%s Pre installation steps completed", self.string) - - async def async_install(self, *, version: str | None = None, **_) -> None: - """Run install steps.""" - await self._async_pre_install() - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 30}, - ) - self.logger.info("%s Running installation steps", self.string) - await self.async_install_repository(version=version) - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 90}, - ) - self.logger.info("%s Installation steps completed", self.string) - await self._async_post_install() - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": False}, - ) - - async def async_post_installation(self) -> None: - """Run post install steps.""" - - async def async_post_uninstall(self): - """Run post uninstall steps.""" - - async def _async_post_uninstall(self): - """Run post uninstall steps.""" - await self.async_post_uninstall() - - async def _async_post_install(self) -> None: - """Run post install steps.""" - self.logger.info("%s Running post installation steps", self.string) - await self.async_post_installation() - self.data.new = False - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY, - { - "id": 1337, - "action": "install", - "repository": self.data.full_name, - "repository_id": self.data.id, - }, - ) - self.logger.info("%s Post installation steps completed", self.string) - - async def async_install_repository(self, *, version: str | None = None, **_) -> None: - """Common installation steps of the repository.""" - persistent_directory = None - force_update = version is None or ( - self.data.last_version is not None and version != self.data.last_version - ) - await self.update_repository(force=force_update) - if self.content.path.local is None: - raise HacsException("repository.content.path.local is None") - self.validate.errors.clear() - - version_to_install = version or self.version_to_download() - if version_to_install == self.data.default_branch: - self.ref = version_to_install - else: - self.ref = f"tags/{version_to_install}" - - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 40}, - ) - - if self.repository_manifest.persistent_directory: - if await async_exists( - self.hacs.hass, - f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", - ): - persistent_directory = Backup( - hacs=self.hacs, - local_path=f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", - backup_path=tempfile.gettempdir() + "/hacs_persistent_directory/", - ) - await self.hacs.hass.async_add_executor_job(persistent_directory.create) - - if self.data.installed and not self.content.single: - backup = Backup(hacs=self.hacs, local_path=self.content.path.local) - await self.hacs.hass.async_add_executor_job(backup.create) - - self.hacs.log.debug("%s Local path is set to %s", self.string, self.content.path.local) - self.hacs.log.debug("%s Remote path is set to %s", self.string, self.content.path.remote) - self.hacs.log.debug("%s Version to install: %s", self.string, version_to_install) - - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 50}, - ) - - if self.repository_manifest.zip_release and self.repository_manifest.filename: - await self.download_zip_files(self.validate) - else: - await self.download_content(version_to_install) - - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 70}, - ) - - if self.validate.errors: - for error in self.validate.errors: - self.logger.error("%s %s", self.string, error) - if self.data.installed and not self.content.single: - await self.hacs.hass.async_add_executor_job(backup.restore) - await self.hacs.hass.async_add_executor_job(backup.cleanup) - raise HacsException("Could not download, see log for details") - - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 80}, - ) - - if self.data.installed and not self.content.single: - await self.hacs.hass.async_add_executor_job(backup.cleanup) - - if persistent_directory is not None: - await self.hacs.hass.async_add_executor_job(persistent_directory.restore) - await self.hacs.hass.async_add_executor_job(persistent_directory.cleanup) - - if self.validate.success: - self.data.installed = True - self.data.installed_commit = self.data.last_commit - - if version_to_install == self.data.default_branch: - self.data.installed_version = None - else: - self.data.installed_version = version_to_install - - async def async_get_legacy_repository_object( - self, - etag: str | None = None, - ) -> tuple[AIOGitHubAPIRepository, Any | None]: - """Return a repository object.""" - try: - repository = await self.hacs.github.get_repo(self.data.full_name, etag) - return repository, self.hacs.github.client.last_response.etag - except AIOGitHubAPINotModifiedException as exception: - raise HacsNotModifiedException(exception) from exception - except (ValueError, AIOGitHubAPIException, Exception) as exception: - raise HacsException(exception) from exception - - def update_filenames(self) -> None: - """Get the filename to target.""" - - async def get_tree(self, ref: str): - """Return the repository tree.""" - if self.repository_object is None: - raise HacsException("No repository_object") - try: - tree = await self.repository_object.get_tree(ref) - return tree - except (ValueError, AIOGitHubAPIException) as exception: - raise HacsException(exception) from exception - - async def get_releases(self, prerelease=False, returnlimit=5) -> list[GitHubReleaseModel]: - """Return the repository releases.""" - response = await self.hacs.async_github_api_method( - method=self.hacs.githubapi.repos.releases.list, - repository=self.data.full_name, - ) - releases = [] - for release in response.data or []: - if len(releases) == returnlimit: - break - if release.draft or (release.prerelease and not prerelease): - continue - releases.append(release) - return releases - - async def common_update_data( - self, - ignore_issues: bool = False, - force: bool = False, - retry=False, - skip_releases=False, - ) -> None: - """Common update data.""" - releases = [] - try: - repository_object, etag = await self.async_get_legacy_repository_object( - etag=None if force or self.data.installed else self.data.etag_repository, - ) - self.repository_object = repository_object - if self.data.full_name.lower() != repository_object.full_name.lower(): - self.hacs.common.renamed_repositories[self.data.full_name] = ( - repository_object.full_name - ) - if not self.hacs.system.generator: - raise HacsRepositoryExistException - self.logger.error( - "%s Repository has been renamed - %s", self.string, repository_object.full_name - ) - self.data.update_data( - repository_object.attributes, - action=self.hacs.system.action, - ) - self.data.etag_repository = etag - except HacsNotModifiedException: - return - except HacsRepositoryExistException: - raise HacsRepositoryExistException from None - except (AIOGitHubAPIException, HacsException) as exception: - if not self.hacs.status.startup or self.hacs.system.generator: - self.logger.error("%s %s", self.string, exception) - if not ignore_issues: - self.validate.errors.append("Repository does not exist.") - raise HacsException(exception) from exception - - # Make sure the repository is not archived. - if self.data.archived and not ignore_issues: - self.validate.errors.append("Repository is archived.") - if self.data.full_name not in self.hacs.common.archived_repositories: - self.hacs.common.archived_repositories.add(self.data.full_name) - raise HacsRepositoryArchivedException(f"{self} Repository is archived.") - - # Make sure the repository is not in the blacklist. - if self.hacs.repositories.is_removed(self.data.full_name): - removed = self.hacs.repositories.removed_repository(self.data.full_name) - if removed.removal_type != "remove" and not ignore_issues: - self.validate.errors.append("Repository has been requested to be removed.") - raise HacsException(f"{self} Repository has been requested to be removed.") - - # Get releases. - if not skip_releases: - try: - releases = await self.get_releases(prerelease=True, returnlimit=30) - if releases: - self.data.prerelease = None - for release in releases: - if release.draft: - continue - elif release.prerelease: - if self.data.prerelease is None: - self.data.prerelease = release.tag_name - else: - self.data.last_version = release.tag_name - break - - self.data.releases = True - - filtered_releases = [ - release - for release in releases - if not release.draft and (self.data.show_beta or not release.prerelease) - ] - self.releases.objects = filtered_releases - self.data.published_tags = [x.tag_name for x in filtered_releases] - - except HacsException: - self.data.releases = False - - if not self.force_branch: - self.ref = self.version_to_download() - if self.data.releases: - for release in self.releases.objects or []: - if release.tag_name == self.ref: - if assets := release.assets: - downloads = next(iter(assets)).download_count - self.data.downloads = downloads - elif self.hacs.system.generator and self.repository_object: - await self.repository_object.set_last_commit() - self.data.last_commit = self.repository_object.last_commit - - self.hacs.log.debug( - "%s Running checks against %s", self.string, self.ref.replace("tags/", "") - ) - - try: - self.tree = await self.get_tree(self.ref) - if not self.tree: - raise HacsException("No files in tree") - self.treefiles = [] - for treefile in self.tree: - self.treefiles.append(treefile.full_path) - except (AIOGitHubAPIException, HacsException) as exception: - if ( - not retry - and self.ref is not None - and str(exception).startswith("GitHub returned 404") - ): - # Handle tags/branches being deleted. - self.data.selected_tag = None - self.ref = self.version_to_download() - self.logger.warning( - "%s Selected version/branch %s has been removed, falling back to default", - self.string, - self.ref, - ) - return await self.common_update_data(ignore_issues, force, True) - if not self.hacs.status.startup and not ignore_issues: - self.logger.error("%s %s", self.string, exception) - if not ignore_issues: - raise HacsException(exception) from None - - def gather_files_to_download(self) -> list[FileInformation]: - """Return a list of file objects to be downloaded.""" - files = [] - tree = self.tree - ref = f"{self.ref}".replace("tags/", "") - releaseobjects = self.releases.objects - category = self.data.category - remotelocation = self.content.path.remote - - if self.should_try_releases: - for release in releaseobjects or []: - if ref == release.tag_name: - for asset in release.assets or []: - files.append( - FileInformation(asset.browser_download_url, asset.name, asset.name) - ) - if files: - return files - - if self.content.single: - for treefile in tree: - if treefile.filename == self.data.file_name: - files.append( - FileInformation( - treefile.download_url, treefile.full_path, treefile.filename - ) - ) - return files - - if category == "plugin": - for treefile in tree: - if treefile.path in ["", "dist"]: - if remotelocation == "dist" and not treefile.filename.startswith("dist"): - continue - if not remotelocation: - if not treefile.filename.endswith(".js"): - continue - if treefile.path != "": - continue - if not treefile.is_directory: - files.append( - FileInformation( - treefile.download_url, treefile.full_path, treefile.filename - ) - ) - if files: - return files - - if self.repository_manifest.content_in_root: - if not self.repository_manifest.filename: - if category == "theme": - tree = filter_content_return_one_of_type(self.tree, "", "yaml", "full_path") - - for path in tree: - if path.is_directory: - continue - if path.full_path.startswith(self.content.path.remote): - files.append(FileInformation(path.download_url, path.full_path, path.filename)) - return files - - async def release_contents(self, version: str | None = None) -> list[FileInformation] | None: - """Gather the contents of a release.""" - release = await self.hacs.async_github_api_method( - method=self.hacs.githubapi.generic, - endpoint=f"/repos/{self.data.full_name}/releases/tags/{version}", - raise_exception=False, - ) - if release is None: - return None - - return [ - FileInformation( - url=asset.get("browser_download_url"), - path=asset.get("name"), - name=asset.get("name"), - ) - for asset in release.data.get("assets", []) - ] - - @concurrent(concurrenttasks=10) - async def dowload_repository_content(self, content: FileInformation) -> None: - """Download content.""" - try: - self.logger.debug("%s Downloading %s", self.string, content.name) - - filecontent = await self.hacs.async_download_file(content.download_url) - - if filecontent is None: - self.validate.errors.append(f"[{content.name}] was not downloaded.") - return - - # Save the content of the file. - if self.content.single or content.path is None: - local_directory = self.content.path.local - - else: - _content_path = content.path - if not self.repository_manifest.content_in_root: - _content_path = _content_path.replace(f"{self.content.path.remote}", "") - - local_directory = f"{self.content.path.local}/{_content_path}" - local_directory = local_directory.split("/") - del local_directory[-1] - local_directory = "/".join(local_directory) - - # Check local directory - pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True) - - local_file_path = (f"{local_directory}/{content.name}").replace("//", "/") - - result = await self.hacs.async_save_file(local_file_path, filecontent) - if result: - self.logger.info("%s Download of %s completed", self.string, content.name) - return - self.validate.errors.append(f"[{content.name}] was not downloaded.") - - except ( - # lgtm [py/catch-base-exception] pylint: disable=broad-except - BaseException - ) as exception: - self.validate.errors.append(f"Download was not completed [{exception}]") - - async def async_remove_entity_device(self) -> None: - """Remove the entity device.""" - device_registry: dr.DeviceRegistry = dr.async_get(hass=self.hacs.hass) - device = device_registry.async_get_device(identifiers={(DOMAIN, str(self.data.id))}) - - if device is None: - return - - device_registry.async_remove_device(device_id=device.id) - - def version_to_download(self) -> str: - """Determine which version to download.""" - if self.force_branch and self.ref is not None: - return self.ref - - if self.data.last_version is not None: - if self.data.selected_tag is not None: - if self.data.selected_tag == self.data.last_version: - self.data.selected_tag = None - return self.data.last_version - return self.data.selected_tag - return self.data.last_version - - if self.data.selected_tag is not None: - if self.data.selected_tag == self.data.default_branch: - return self.data.default_branch - if self.data.selected_tag in self.data.published_tags: - return self.data.selected_tag - - return self.data.default_branch or "main" - - async def get_documentation( - self, - *, - filename: str | None = None, - version: str | None = None, - **kwargs, - ) -> str | None: - """Get the documentation of the repository.""" - if filename is None: - return None - - if version is not None: - target_version = version - elif self.data.installed: - target_version = self.data.installed_version or self.data.installed_commit - else: - target_version = self.data.last_version or self.data.last_commit or self.ref - - self.logger.debug( - "%s Getting documentation for version=%s,filename=%s", - self.string, - target_version, - filename, - ) - if target_version is None: - return None - - result = await self.hacs.async_download_file( - f"https://raw.githubusercontent.com/{self.data.full_name}/{target_version}/{filename}", - nolog=True, - ) - - return ( - result.decode(encoding="utf-8") - .replace(" HacsManifest | None: - """Get the hacs.json file of the repository.""" - if (result := await self.get_hacs_json_raw(version=version)) is None: - return None - return HacsManifest.from_dict(result) - - @return_none_on_exception - async def get_hacs_json_raw( - self, - *, - version: str, - **kwargs, - ) -> dict[str, Any] | None: - """Get the hacs.json file of the repository.""" - self.logger.debug("%s Getting hacs.json for version=%s", self.string, version) - result = await self.hacs.async_download_file( - f"https://raw.githubusercontent.com/{self.data.full_name}/{version}/hacs.json", - nolog=True, - handle_rate_limit=True, - ) - return json_loads(result) if result else None - - async def _ensure_download_capabilities(self, ref: str | None, **kwargs: Any) -> None: - """Ensure that the download can be handled.""" - target_manifest: HacsManifest | None = None - if ref is None: - if not self.can_download: - raise HacsException( - f"This {self.data.category.value} is not available for download." - ) - return - - if not ref: - target_manifest = self.repository_manifest - else: - target_manifest = await self.get_hacs_json(version=ref) - - if target_manifest is None: - raise HacsException( - f"The version {ref} for this {self.data.category.value} can not be used with HACS." - ) - - if ( - target_manifest.homeassistant is not None - and self.hacs.core.ha_version < target_manifest.homeassistant - ): - raise HacsException( - f"This version requires Home Assistant {target_manifest.homeassistant} or newer." - ) - if target_manifest.hacs is not None and self.hacs.version < target_manifest.hacs: - raise HacsException(f"This version requires HACS {target_manifest.hacs} or newer.") - - async def async_download_repository(self, *, ref: str | None = None, **_) -> None: - """Download the content of a repository.""" - await self._ensure_download_capabilities(ref) - self.logger.info("Starting download, %s", ref) - if self.display_version_or_commit == "version": - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 10}, - ) - if not ref: - await self.update_repository(force=True) - else: - self.ref = ref - self.data.selected_tag = ref - self.force_branch = ref is not None - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 20}, - ) - - try: - await self.async_install(version=ref) - except HacsException as exception: - raise HacsException( - f"Downloading {self.data.full_name} with version {ref or self.data.last_version or self.data.last_commit} failed with ({exception})" - ) from exception - finally: - self.data.selected_tag = None - self.force_branch = False - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": False}, - ) - - async def async_get_releases(self, *, first: int = 30) -> list[GitHubReleaseModel]: - """Get the last x releases of a repository.""" - response = await self.hacs.async_github_api_method( - method=self.hacs.githubapi.repos.releases.list, - repository=self.data.full_name, - kwargs={"per_page": 30}, - ) - return response.data +"""Repository.""" + +from __future__ import annotations + +from asyncio import sleep +from datetime import UTC, datetime +import os +import pathlib +import shutil +import tempfile +from typing import TYPE_CHECKING, Any +import zipfile + +from aiogithubapi import ( + AIOGitHubAPIException, + AIOGitHubAPINotModifiedException, + GitHubReleaseModel, +) +from aiogithubapi.objects.repository import AIOGitHubAPIRepository +import attr +from homeassistant.helpers import device_registry as dr, issue_registry as ir + +from ..const import DOMAIN +from ..enums import HacsDispatchEvent, RepositoryFile +from ..exceptions import ( + HacsException, + HacsNotModifiedException, + HacsRepositoryArchivedException, + HacsRepositoryExistException, +) +from ..types import DownloadableContent +from ..utils.backup import Backup +from ..utils.decode import decode_content +from ..utils.decorator import concurrent, return_none_on_exception +from ..utils.file_system import async_exists, async_remove, async_remove_directory +from ..utils.filters import filter_content_return_one_of_type +from ..utils.github_graphql_query import GET_REPOSITORY_RELEASES +from ..utils.json import json_loads +from ..utils.logger import LOGGER +from ..utils.path import is_safe +from ..utils.queue_manager import QueueManager +from ..utils.store import async_remove_store +from ..utils.url import github_archive, github_release_asset +from ..utils.validate import Validate +from ..utils.version import ( + version_left_higher_or_equal_then_right, + version_left_higher_then_right, +) +from ..utils.workarounds import DOMAIN_OVERRIDES + +if TYPE_CHECKING: + from ..base import HacsBase + + +TOPIC_FILTER = ( + "add-on", + "addon", + "app", + "appdaemon-apps", + "appdaemon", + "custom-card", + "custom-cards", + "custom-component", + "custom-components", + "customcomponents", + "hacktoberfest", + "hacs-default", + "hacs-integration", + "hacs-repository", + "hacs", + "hass", + "hassio", + "home-assistant-custom", + "home-assistant-frontend", + "home-assistant-hacs", + "home-assistant-sensor", + "home-assistant", + "home-automation", + "homeassistant-components", + "homeassistant-integration", + "homeassistant-sensor", + "homeassistant", + "homeautomation", + "integration", + "lovelace-ui", + "lovelace", + "media-player", + "mediaplayer", + "plugin", + "python_script", + "python-script", + "python", + "sensor", + "smart-home", + "smarthome", + "template", + "templates", + "theme", + "themes", +) + + +REPOSITORY_KEYS_TO_EXPORT = ( + # Keys can not be removed from this list until v3 + # If keys are added, the action need to be re-run with force + ("description", ""), + ("downloads", 0), + ("domain", None), + ("etag_releases", None), + ("etag_repository", None), + ("full_name", ""), + ("last_commit", None), + ("last_updated", 0), + ("last_version", None), + ("manifest_name", None), + ("open_issues", 0), + ("prerelease", None), + ("stargazers_count", 0), + ("topics", []), +) + +HACS_MANIFEST_KEYS_TO_EXPORT = ( + # Keys can not be removed from this list until v3 + # If keys are added, the action need to be re-run with force + ("country", []), + ("name", None), +) + + +class FileInformation: + """FileInformation.""" + + def __init__(self, url, path, name): + self.download_url = url + self.path = path + self.name = name + + +@attr.s(auto_attribs=True) +class RepositoryData: + """RepositoryData class.""" + + archived: bool = False + authors: list[str] = [] + category: str = "" + config_flow: bool = False + default_branch: str = None + description: str = "" + domain: str = None + downloads: int = 0 + etag_repository: str = None + etag_releases: str = None + file_name: str = "" + first_install: bool = False + full_name: str = "" + hide: bool = False + has_issues: bool = True + id: int = 0 + installed_commit: str = None + installed_version: str = None + installed: bool = False + last_commit: str = None + last_fetched: datetime = None + last_updated: str = 0 + last_version: str = None + manifest_name: str = None + new: bool = True + open_issues: int = 0 + prerelease: str = None + published_tags: list[str] = [] + releases: bool = False + selected_tag: str = None + show_beta: bool = False + stargazers_count: int = 0 + topics: list[str] = [] + + @property + def name(self): + """Return the name.""" + if self.category == "integration": + return self.domain + return self.full_name.split("/")[-1] + + def to_json(self): + """Export to json.""" + return attr.asdict(self, filter=lambda attr, value: attr.name != "last_fetched") + + @staticmethod + def create_from_dict(source: dict, action: bool = False) -> RepositoryData: + """Set attributes from dicts.""" + data = RepositoryData() + data.update_data(source, action) + return data + + def update_data(self, data: dict, action: bool = False) -> None: + """Update data of the repository.""" + for key, value in data.items(): + if key not in self.__dict__: + continue + + if key == "last_fetched" and isinstance(value, float): + setattr(self, key, datetime.fromtimestamp(value, UTC)) + elif key == "id": + setattr(self, key, str(value)) + elif key == "country": + if isinstance(value, str): + setattr(self, key, [value]) + else: + setattr(self, key, value) + elif key == "topics" and not action: + setattr(self, key, [topic for topic in value if topic not in TOPIC_FILTER]) + + else: + setattr(self, key, value) + + +@attr.s(auto_attribs=True) +class HacsManifest: + """HacsManifest class.""" + + content_in_root: bool = False + country: list[str] = [] + filename: str = None + hacs: str = None # Minimum HACS version + hide_default_branch: bool = False + homeassistant: str = None # Minimum Home Assistant version + manifest: dict = {} + name: str = None + persistent_directory: str = None + render_readme: bool = False + zip_release: bool = False + + def to_dict(self): + """Export to json.""" + return attr.asdict(self) + + @staticmethod + def from_dict(manifest: dict): + """Set attributes from dicts.""" + if manifest is None: + raise HacsException("Missing manifest data") + + manifest_data = HacsManifest() + manifest_data.manifest = { + k: v + for k, v in manifest.items() + if k in manifest_data.__dict__ and v != manifest_data.__getattribute__(k) + } + + for key, value in manifest_data.manifest.items(): + if key == "country" and isinstance(value, str): + setattr(manifest_data, key, [value]) + elif key in manifest_data.__dict__: + setattr(manifest_data, key, value) + return manifest_data + + def update_data(self, data: dict) -> None: + """Update the manifest data.""" + for key, value in data.items(): + if key not in self.__dict__: + continue + + if key == "country": + if isinstance(value, str): + setattr(self, key, [value]) + else: + setattr(self, key, value) + else: + setattr(self, key, value) + + +class RepositoryReleases: + """RepositoyReleases.""" + + last_release = None + last_release_object = None + published_tags = [] + objects: list[GitHubReleaseModel] = [] + releases = False + downloads = None + + +class RepositoryPath: + """RepositoryPath.""" + + local: str | None = None + remote: str | None = None + + +class RepositoryContent: + """RepositoryContent.""" + + path: RepositoryPath | None = None + files = [] + objects = [] + single = False + + +class HacsRepository: + """HacsRepository.""" + + def __init__(self, hacs: HacsBase) -> None: + """Set up HacsRepository.""" + self.hacs = hacs + self.additional_info = "" + self.data = RepositoryData() + self.content = RepositoryContent() + self.content.path = RepositoryPath() + self.repository_object: AIOGitHubAPIRepository | None = None + self.updated_info = False + self.state = None + self.force_branch = False + self.integration_manifest = {} + self.repository_manifest = HacsManifest.from_dict({}) + self.validate = Validate() + self.releases = RepositoryReleases() + self.pending_restart = False + self.tree = [] + self.treefiles = [] + self.ref = None + self.logger = LOGGER + + def __str__(self) -> str: + """Return a string representation of the repository.""" + return self.string + + @property + def string(self) -> str: + """Return a string representation of the repository.""" + return f"<{self.data.category.title()} {self.data.full_name}>" + + @property + def display_name(self) -> str: + """Return display name.""" + if self.repository_manifest.name is not None: + return self.repository_manifest.name + + if self.data.category == "integration": + if self.data.manifest_name is not None: + return self.data.manifest_name + if "name" in self.integration_manifest: + return self.integration_manifest["name"] + + return self.data.full_name.split("/")[-1].replace("-", " ").replace("_", " ").title() + + @property + def ignored_by_country_configuration(self) -> bool: + """Return True if hidden by country.""" + if self.data.installed: + return False + configuration = self.hacs.configuration.country.lower() + if configuration == "all": + return False + + manifest = [entry.lower() for entry in self.repository_manifest.country or []] + if not manifest: + return False + return configuration not in manifest + + @property + def display_status(self) -> str: + """Return display_status.""" + if self.data.new: + status = "new" + elif self.pending_restart: + status = "pending-restart" + elif self.pending_update: + status = "pending-upgrade" + elif self.data.installed: + status = "installed" + else: + status = "default" + return status + + @property + def display_installed_version(self) -> str: + """Return display_authors""" + if self.data.installed_version is not None: + installed = self.data.installed_version + else: + if self.data.installed_commit is not None: + installed = self.data.installed_commit + else: + installed = "" + return str(installed) + + @property + def display_available_version(self) -> str: + """Return display_authors""" + if self.data.show_beta and self.data.prerelease is not None: + available = self.data.prerelease + elif self.data.last_version is not None: + available = self.data.last_version + else: + if self.data.last_commit is not None: + available = self.data.last_commit + else: + available = "" + return str(available) + + @property + def display_version_or_commit(self) -> str: + """Does the repositoriy use releases or commits?""" + if self.data.releases: + version_or_commit = "version" + else: + version_or_commit = "commit" + return version_or_commit + + @property + def pending_update(self) -> bool: + """Return True if pending update.""" + if self.data.installed: + if self.data.selected_tag is not None: + if self.data.selected_tag == self.data.default_branch: + if self.data.installed_commit != self.data.last_commit: + return True + return False + if self.display_version_or_commit == "version": + if ( + result := version_left_higher_then_right( + self.display_available_version, + self.display_installed_version, + ) + ) is not None: + return result + if self.display_installed_version != self.display_available_version: + return True + + return False + + @property + def can_download(self) -> bool: + """Return True if we can download.""" + if self.repository_manifest.homeassistant is not None: + if self.data.releases: + if not version_left_higher_or_equal_then_right( + self.hacs.core.ha_version.string, + self.repository_manifest.homeassistant, + ): + return False + return True + + @property + def localpath(self) -> str | None: + """Return localpath.""" + return None + + @property + def should_try_releases(self) -> bool: + """Return a boolean indicating whether to download releases or not.""" + if self.repository_manifest.zip_release: + if self.repository_manifest.filename.endswith(".zip"): + if self.ref != self.data.default_branch: + return True + if self.ref == self.data.default_branch: + return False + if self.data.category not in ["plugin", "theme"]: + return False + if not self.data.releases: + return False + return True + + async def validate_repository(self) -> None: + """Validate.""" + + @concurrent(concurrenttasks=10, backoff_time=5) + async def update_repository(self, ignore_issues=False, force=False) -> None: + """Update the repository""" + + async def common_validate(self, ignore_issues: bool = False) -> None: + """Common validation steps of the repository.""" + self.validate.errors.clear() + + # Make sure the repository exist. + self.logger.debug("%s Checking repository.", self.string) + await self.common_update_data(ignore_issues=ignore_issues) + + # Get the content of hacs.json + if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]: + if manifest := await self.async_get_hacs_json(): + self.repository_manifest = HacsManifest.from_dict(manifest) + self.data.update_data( + self.repository_manifest.to_dict(), + action=self.hacs.system.action, + ) + + async def common_registration(self) -> None: + """Common registration steps of the repository.""" + # Attach repository + if self.repository_object is None: + try: + self.repository_object, etag = await self.async_get_legacy_repository_object( + etag=None if self.data.installed else self.data.etag_repository, + ) + self.data.update_data( + self.repository_object.attributes, + action=self.hacs.system.action, + ) + self.data.etag_repository = etag + except HacsNotModifiedException: + self.logger.debug("%s Did not update, content was not modified", self.string) + return + + if self.repository_object: + self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) + self.data.last_fetched = datetime.now(UTC) + + @concurrent(concurrenttasks=10, backoff_time=5) + async def common_update(self, ignore_issues=False, force=False, skip_releases=False) -> bool: + """Common information update steps of the repository.""" + self.logger.debug("%s Getting repository information", self.string) + + # Attach repository + current_etag = self.data.etag_repository + try: + await self.common_update_data( + ignore_issues=ignore_issues, + force=force, + skip_releases=skip_releases, + ) + except HacsRepositoryExistException: + self.data.full_name = self.hacs.common.renamed_repositories[self.data.full_name] + await self.common_update_data(ignore_issues=ignore_issues, force=force) + + except HacsException: + if not ignore_issues and not force: + return False + + if not self.data.installed and (current_etag == self.data.etag_repository) and not force: + self.logger.debug("%s Did not update, content was not modified", self.string) + return False + + # Update last updated + if self.repository_object: + self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) + + # Update last available commit + await self.repository_object.set_last_commit() + self.data.last_commit = self.repository_object.last_commit + + # Get the content of hacs.json + if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]: + if manifest := await self.async_get_hacs_json(): + self.repository_manifest = HacsManifest.from_dict(manifest) + self.data.update_data( + self.repository_manifest.to_dict(), + action=self.hacs.system.action, + ) + + # Update "info.md" + self.additional_info = await self.async_get_info_file_contents() + + # Set last fetch attribute + self.data.last_fetched = datetime.now(UTC) + + return True + + async def download_zip_files(self, validate: Validate) -> None: + """Download ZIP archive from repository release.""" + + try: + await self.async_download_zip_file( + DownloadableContent( + name=self.repository_manifest.filename, + url=github_release_asset( + repository=self.data.full_name, + version=self.ref, + filename=self.repository_manifest.filename, + ), + ), + validate, + ) + # lgtm [py/catch-base-exception] pylint: disable=broad-except + except BaseException: + validate.errors.append( + f"Download of {self.repository_manifest.filename} was not completed" + ) + + async def async_download_zip_file( + self, + content: DownloadableContent, + validate: Validate, + ) -> None: + """Download ZIP archive from repository release.""" + try: + filecontent = await self.hacs.async_download_file(content["url"]) + + if filecontent is None: + validate.errors.append(f"Failed to download {content['url']}") + return + + temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) + temp_file = f"{temp_dir}/{self.repository_manifest.filename}" + + result = await self.hacs.async_save_file(temp_file, filecontent) + + def _extract_zip_file(): + with zipfile.ZipFile(temp_file, "r") as zip_file: + zip_file.extractall(self.content.path.local) + + await self.hacs.hass.async_add_executor_job(_extract_zip_file) + + def cleanup_temp_dir(): + """Cleanup temp_dir.""" + if os.path.exists(temp_dir): + self.logger.debug("%s Cleaning up %s", self.string, temp_dir) + shutil.rmtree(temp_dir) + + if result: + self.logger.info("%s Download of %s completed", self.string, content["name"]) + await self.hacs.hass.async_add_executor_job(cleanup_temp_dir) + return + + validate.errors.append(f"[{content['name']}] was not downloaded") + # lgtm [py/catch-base-exception] pylint: disable=broad-except + except BaseException: + validate.errors.append("Download was not completed") + + async def download_content(self, version: string | None = None) -> None: + """Download the content of a directory.""" + contents: list[FileInformation] | None = None + if ( + not self.repository_manifest.zip_release + and not self.data.file_name + and self.content.path.remote is not None + ): + self.logger.info("%s Downloading repository archive", self.string) + try: + await self.download_repository_zip() + return + except HacsException as exception: + self.logger.exception(exception) + + if self.repository_manifest.filename: + self.logger.debug("%s %s", self.string, self.repository_manifest.filename) + + if self.content.path.remote == "release" and version is not None: + contents = await self.release_contents(version) + + if not contents: + contents = self.gather_files_to_download() + + if not contents: + raise HacsException("No content to download") + + download_queue = QueueManager(hass=self.hacs.hass) + + for content in contents: + if self.repository_manifest.content_in_root and self.repository_manifest.filename: + if content.name != self.repository_manifest.filename: + continue + download_queue.add(self.dowload_repository_content(content)) + + await download_queue.execute() + + async def download_repository_zip(self): + """Download the zip archive of the repository.""" + ref = f"{self.ref}".replace("tags/", "") + + if not ref: + raise HacsException("Missing required elements.") + + filecontent = await self.hacs.async_download_file( + github_archive(repository=self.data.full_name, version=ref, variant="tags"), + keep_url=True, + nolog=True, + ) + + if filecontent is None: + filecontent = await self.hacs.async_download_file( + github_archive(repository=self.data.full_name, version=ref, variant="heads"), + keep_url=True, + ) + if filecontent is None: + raise HacsException(f"[{self}] Failed to download zipball") + + temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) + temp_file = f"{temp_dir}/{self.repository_manifest.filename}" + result = await self.hacs.async_save_file(temp_file, filecontent) + if not result: + raise HacsException("Could not save ZIP file") + + def _extract_zip_file(): + with zipfile.ZipFile(temp_file, "r") as zip_file: + extractable = [] + for path in zip_file.filelist: + filename = "/".join(path.filename.split("/")[1:]) + if ( + filename.startswith(self.content.path.remote) + and filename != self.content.path.remote + ): + path.filename = filename.replace(self.content.path.remote, "") + if path.filename == "/": + # Blank files is not valid, and will start to throw in Python 3.12 + continue + extractable.append(path) + + if len(extractable) == 0: + raise HacsException("No content to extract") + zip_file.extractall(self.content.path.local, extractable) + + await self.hacs.hass.async_add_executor_job(_extract_zip_file) + + def cleanup_temp_dir(): + """Cleanup temp_dir.""" + if os.path.exists(temp_dir): + self.logger.debug("%s Cleaning up %s", self.string, temp_dir) + shutil.rmtree(temp_dir) + + await self.hacs.hass.async_add_executor_job(cleanup_temp_dir) + self.logger.info("%s Content was extracted to %s", self.string, self.content.path.local) + + async def async_get_hacs_json(self, ref: str = None) -> dict[str, Any] | None: + """Get the content of the hacs.json file.""" + try: + response = await self.hacs.async_github_api_method( + method=self.hacs.githubapi.repos.contents.get, + raise_exception=False, + repository=self.data.full_name, + path=RepositoryFile.HACS_JSON, + **{"params": {"ref": ref or self.version_to_download()}}, + ) + if response: + return json_loads(decode_content(response.data.content)) + # lgtm [py/catch-base-exception] pylint: disable=broad-except + except BaseException: + pass + + async def async_get_info_file_contents(self, *, version: str | None = None, **kwargs) -> str: + """Get the content of the info.md file.""" + + def _info_file_variants() -> tuple[str, ...]: + name: str = "readme" + return ( + f"{name.upper()}.md", + f"{name}.md", + f"{name}.MD", + f"{name.upper()}.MD", + name.upper(), + name, + ) + + info_files = [filename for filename in _info_file_variants() if filename in self.treefiles] + + if not info_files: + return "" + + return await self.get_documentation(filename=info_files[0], version=version) or "" + + async def async_get_info_file_contents_with_language( + self, *, language: str | None = None, version: str | None = None, **kwargs + ) -> str: + """Get the content of the info.md file with language support. + + Args: + language: Optional language code (e.g., "de", "en", "fr") + version: Optional version/ref to get the file from + + Returns: + README content as string + """ + # Validate and normalize language code + if language: + if not language.isalpha() or len(language) != 2: + self.logger.warning( + "%s Invalid language code: %s, using README.md", + self.string, + language, + ) + language = None + else: + language = language.lower() + + # If no language or English, use standard README + if not language or language == "en": + return await self.async_get_info_file_contents(version=version) + + # Try to load language-specific README + readme_path = f"README.{language}.md" + + # Check if the language-specific README exists in treefiles + # We need to check various case combinations + possible_paths = [ + f"README.{language}.md", + f"README.{language.upper()}.md", + f"readme.{language}.md", + f"readme.{language.upper()}.md", + f"README.{language}.MD", + f"README.{language.upper()}.MD", + ] + + found_path = None + for path in possible_paths: + if path in self.treefiles: + found_path = path + break + + if found_path: + try: + content = await self.get_documentation(filename=found_path, version=version) + if content: + return content + except Exception as e: + self.logger.warning( + "%s Error loading %s: %s, falling back to README.md", + self.string, + found_path, + e, + ) + + # Fallback to standard README.md + self.logger.debug( + "%s Language-specific README %s not found, using README.md", + self.string, + readme_path, + ) + return await self.async_get_info_file_contents(version=version) + + def remove(self) -> None: + """Run remove tasks.""" + if self.hacs.repositories.is_registered(repository_id=str(self.data.id)): + self.logger.info("%s Starting removal", self.string) + self.hacs.repositories.unregister(self) + + async def uninstall(self) -> None: + """Run uninstall tasks.""" + self.logger.info("%s Removing", self.string) + if not await self.remove_local_directory(): + raise HacsException("Could not uninstall") + self.data.installed = False + await self._async_post_uninstall() + await async_remove_store(self.hacs.hass, f"hacs/{self.data.id}.hacs") + + self.data.installed_version = None + self.data.installed_commit = None + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY, + { + "id": 1337, + "action": "uninstall", + "repository": self.data.full_name, + "repository_id": self.data.id, + }, + ) + + await self.async_remove_entity_device() + ir.async_delete_issue(self.hacs.hass, DOMAIN, f"removed_{self.data.id}") + + async def remove_local_directory(self) -> None: + """Check the local directory.""" + + try: + if self.data.category == "python_script": + local_path = f"{self.content.path.local}/{self.data.file_name}" + elif self.data.category == "template": + local_path = f"{self.content.path.local}/{self.data.file_name}" + elif self.data.category == "theme": + path = ( + f"{self.hacs.core.config_path}/" + f"{self.hacs.configuration.theme_path}/" + f"{self.data.name}.yaml" + ) + await async_remove(self.hacs.hass, path, missing_ok=True) + local_path = self.content.path.local + elif self.data.category == "integration": + if not self.data.domain: + if domain := DOMAIN_OVERRIDES.get(self.data.full_name): + self.data.domain = domain + self.content.path.local = self.localpath + else: + self.logger.error("%s Missing domain", self.string) + return False + local_path = self.content.path.local + else: + local_path = self.content.path.local + + if await async_exists(self.hacs.hass, local_path): + if not is_safe(self.hacs, local_path): + self.logger.error("%s Path %s is blocked from removal", self.string, local_path) + return False + self.logger.debug("%s Removing %s", self.string, local_path) + + if self.data.category in ["python_script", "template"]: + await async_remove(self.hacs.hass, local_path) + else: + await async_remove_directory(self.hacs.hass, local_path) + + while await async_exists(self.hacs.hass, local_path): + await sleep(1) + else: + self.logger.debug( + "%s Presumed local content path %s does not exist", self.string, local_path + ) + + except ( + # lgtm [py/catch-base-exception] pylint: disable=broad-except + BaseException + ) as exception: + self.logger.debug("%s Removing %s failed with %s", self.string, local_path, exception) + return False + return True + + async def async_pre_registration(self) -> None: + """Run pre registration steps.""" + + @concurrent(concurrenttasks=10) + async def async_registration(self, ref=None) -> None: + """Run registration steps.""" + await self.async_pre_registration() + + if ref is not None: + self.data.selected_tag = ref + self.ref = ref + self.force_branch = True + + if not await self.validate_repository(): + return False + + # Run common registration steps. + await self.common_registration() + + # Set correct local path + self.content.path.local = self.localpath + + # Run local post registration steps. + await self.async_post_registration() + + async def async_post_registration(self) -> None: + """Run post registration steps.""" + if not self.hacs.system.action: + return + await self.hacs.validation.async_run_repository_checks(self) + + async def async_pre_install(self) -> None: + """Run pre install steps.""" + + async def _async_pre_install(self) -> None: + """Run pre install steps.""" + self.logger.info("%s Running pre installation steps", self.string) + await self.async_pre_install() + self.logger.info("%s Pre installation steps completed", self.string) + + async def async_install(self, *, version: str | None = None, **_) -> None: + """Run install steps.""" + await self._async_pre_install() + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 30}, + ) + self.logger.info("%s Running installation steps", self.string) + await self.async_install_repository(version=version) + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 90}, + ) + self.logger.info("%s Installation steps completed", self.string) + await self._async_post_install() + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": False}, + ) + + async def async_post_installation(self) -> None: + """Run post install steps.""" + + async def async_post_uninstall(self): + """Run post uninstall steps.""" + + async def _async_post_uninstall(self): + """Run post uninstall steps.""" + await self.async_post_uninstall() + + async def _async_post_install(self) -> None: + """Run post install steps.""" + self.logger.info("%s Running post installation steps", self.string) + await self.async_post_installation() + self.data.new = False + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY, + { + "id": 1337, + "action": "install", + "repository": self.data.full_name, + "repository_id": self.data.id, + }, + ) + self.logger.info("%s Post installation steps completed", self.string) + + async def async_install_repository(self, *, version: str | None = None, **_) -> None: + """Common installation steps of the repository.""" + persistent_directory = None + force_update = version is None or ( + self.data.last_version is not None and version != self.data.last_version + ) + await self.update_repository(force=force_update) + if self.content.path.local is None: + raise HacsException("repository.content.path.local is None") + self.validate.errors.clear() + + version_to_install = version or self.version_to_download() + if version_to_install == self.data.default_branch: + self.ref = version_to_install + else: + self.ref = f"tags/{version_to_install}" + + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 40}, + ) + + if self.repository_manifest.persistent_directory: + if await async_exists( + self.hacs.hass, + f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", + ): + persistent_directory = Backup( + hacs=self.hacs, + local_path=f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", + backup_path=tempfile.gettempdir() + "/hacs_persistent_directory/", + ) + await self.hacs.hass.async_add_executor_job(persistent_directory.create) + + if self.data.installed and not self.content.single: + backup = Backup(hacs=self.hacs, local_path=self.content.path.local) + await self.hacs.hass.async_add_executor_job(backup.create) + + self.hacs.log.debug("%s Local path is set to %s", self.string, self.content.path.local) + self.hacs.log.debug("%s Remote path is set to %s", self.string, self.content.path.remote) + self.hacs.log.debug("%s Version to install: %s", self.string, version_to_install) + + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 50}, + ) + + if self.repository_manifest.zip_release and self.repository_manifest.filename: + await self.download_zip_files(self.validate) + else: + await self.download_content(version_to_install) + + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 70}, + ) + + if self.validate.errors: + for error in self.validate.errors: + self.logger.error("%s %s", self.string, error) + if self.data.installed and not self.content.single: + await self.hacs.hass.async_add_executor_job(backup.restore) + await self.hacs.hass.async_add_executor_job(backup.cleanup) + raise HacsException("Could not download, see log for details") + + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 80}, + ) + + if self.data.installed and not self.content.single: + await self.hacs.hass.async_add_executor_job(backup.cleanup) + + if persistent_directory is not None: + await self.hacs.hass.async_add_executor_job(persistent_directory.restore) + await self.hacs.hass.async_add_executor_job(persistent_directory.cleanup) + + if self.validate.success: + self.data.installed = True + self.data.installed_commit = self.data.last_commit + + if version_to_install == self.data.default_branch: + self.data.installed_version = None + else: + self.data.installed_version = version_to_install + + async def async_get_legacy_repository_object( + self, + etag: str | None = None, + ) -> tuple[AIOGitHubAPIRepository, Any | None]: + """Return a repository object.""" + try: + repository = await self.hacs.github.get_repo(self.data.full_name, etag) + return repository, self.hacs.github.client.last_response.etag + except AIOGitHubAPINotModifiedException as exception: + raise HacsNotModifiedException(exception) from exception + except (ValueError, AIOGitHubAPIException, Exception) as exception: + raise HacsException(exception) from exception + + def update_filenames(self) -> None: + """Get the filename to target.""" + + async def get_tree(self, ref: str): + """Return the repository tree.""" + if self.repository_object is None: + raise HacsException("No repository_object") + try: + tree = await self.repository_object.get_tree(ref) + return tree + except (ValueError, AIOGitHubAPIException) as exception: + raise HacsException(exception) from exception + + async def get_releases(self, prerelease=False, returnlimit=5) -> list[GitHubReleaseModel]: + """Return the repository releases.""" + response = await self.hacs.async_github_api_method( + method=self.hacs.githubapi.repos.releases.list, + repository=self.data.full_name, + ) + releases = [] + for release in response.data or []: + if len(releases) == returnlimit: + break + if release.draft or (release.prerelease and not prerelease): + continue + releases.append(release) + return releases + + async def common_update_data( + self, + ignore_issues: bool = False, + force: bool = False, + retry=False, + skip_releases=False, + ) -> None: + """Common update data.""" + releases = [] + try: + repository_object, etag = await self.async_get_legacy_repository_object( + etag=None if force or self.data.installed else self.data.etag_repository, + ) + self.repository_object = repository_object + if self.data.full_name.lower() != repository_object.full_name.lower(): + self.hacs.common.renamed_repositories[self.data.full_name] = ( + repository_object.full_name + ) + if not self.hacs.system.generator: + raise HacsRepositoryExistException + self.logger.error( + "%s Repository has been renamed - %s", self.string, repository_object.full_name + ) + self.data.update_data( + repository_object.attributes, + action=self.hacs.system.action, + ) + self.data.etag_repository = etag + except HacsNotModifiedException: + return + except HacsRepositoryExistException: + raise HacsRepositoryExistException from None + except (AIOGitHubAPIException, HacsException) as exception: + if not self.hacs.status.startup or self.hacs.system.generator: + self.logger.error("%s %s", self.string, exception) + if not ignore_issues: + self.validate.errors.append("Repository does not exist.") + raise HacsException(exception) from exception + + # Make sure the repository is not archived. + if self.data.archived and not ignore_issues: + self.validate.errors.append("Repository is archived.") + if self.data.full_name not in self.hacs.common.archived_repositories: + self.hacs.common.archived_repositories.add(self.data.full_name) + raise HacsRepositoryArchivedException(f"{self} Repository is archived.") + + # Make sure the repository is not in the blacklist. + if self.hacs.repositories.is_removed(self.data.full_name): + removed = self.hacs.repositories.removed_repository(self.data.full_name) + if removed.removal_type != "remove" and not ignore_issues: + self.validate.errors.append("Repository has been requested to be removed.") + raise HacsException(f"{self} Repository has been requested to be removed.") + + # Get releases. + if not skip_releases: + try: + releases = await self.get_releases(prerelease=True, returnlimit=30) + if releases: + self.data.prerelease = None + for release in releases: + if release.draft: + continue + elif release.prerelease: + if self.data.prerelease is None: + self.data.prerelease = release.tag_name + else: + self.data.last_version = release.tag_name + break + + self.data.releases = True + + filtered_releases = [ + release + for release in releases + if not release.draft and (self.data.show_beta or not release.prerelease) + ] + self.releases.objects = filtered_releases + self.data.published_tags = [x.tag_name for x in filtered_releases] + + except HacsException: + self.data.releases = False + + if not self.force_branch: + self.ref = self.version_to_download() + if self.data.releases: + for release in self.releases.objects or []: + if release.tag_name == self.ref: + if assets := release.assets: + downloads = next(iter(assets)).download_count + self.data.downloads = downloads + elif self.hacs.system.generator and self.repository_object: + await self.repository_object.set_last_commit() + self.data.last_commit = self.repository_object.last_commit + + self.hacs.log.debug( + "%s Running checks against %s", self.string, self.ref.replace("tags/", "") + ) + + try: + self.tree = await self.get_tree(self.ref) + if not self.tree: + raise HacsException("No files in tree") + self.treefiles = [] + for treefile in self.tree: + self.treefiles.append(treefile.full_path) + except (AIOGitHubAPIException, HacsException) as exception: + if ( + not retry + and self.ref is not None + and str(exception).startswith("GitHub returned 404") + ): + # Handle tags/branches being deleted. + self.data.selected_tag = None + self.ref = self.version_to_download() + self.logger.warning( + "%s Selected version/branch %s has been removed, falling back to default", + self.string, + self.ref, + ) + return await self.common_update_data(ignore_issues, force, True) + if not self.hacs.status.startup and not ignore_issues: + self.logger.error("%s %s", self.string, exception) + if not ignore_issues: + raise HacsException(exception) from None + + def gather_files_to_download(self) -> list[FileInformation]: + """Return a list of file objects to be downloaded.""" + files = [] + tree = self.tree + ref = f"{self.ref}".replace("tags/", "") + releaseobjects = self.releases.objects + category = self.data.category + remotelocation = self.content.path.remote + + if self.should_try_releases: + for release in releaseobjects or []: + if ref == release.tag_name: + for asset in release.assets or []: + files.append( + FileInformation(asset.browser_download_url, asset.name, asset.name) + ) + if files: + return files + + if self.content.single: + for treefile in tree: + if treefile.filename == self.data.file_name: + files.append( + FileInformation( + treefile.download_url, treefile.full_path, treefile.filename + ) + ) + return files + + if category == "plugin": + for treefile in tree: + if treefile.path in ["", "dist"]: + if remotelocation == "dist" and not treefile.filename.startswith("dist"): + continue + if not remotelocation: + if not treefile.filename.endswith(".js"): + continue + if treefile.path != "": + continue + if not treefile.is_directory: + files.append( + FileInformation( + treefile.download_url, treefile.full_path, treefile.filename + ) + ) + if files: + return files + + if self.repository_manifest.content_in_root: + if not self.repository_manifest.filename: + if category == "theme": + tree = filter_content_return_one_of_type(self.tree, "", "yaml", "full_path") + + for path in tree: + if path.is_directory: + continue + if path.full_path.startswith(self.content.path.remote): + files.append(FileInformation(path.download_url, path.full_path, path.filename)) + return files + + async def release_contents(self, version: str | None = None) -> list[FileInformation] | None: + """Gather the contents of a release.""" + release = await self.hacs.async_github_api_method( + method=self.hacs.githubapi.generic, + endpoint=f"/repos/{self.data.full_name}/releases/tags/{version}", + raise_exception=False, + ) + if release is None: + return None + + return [ + FileInformation( + url=asset.get("browser_download_url"), + path=asset.get("name"), + name=asset.get("name"), + ) + for asset in release.data.get("assets", []) + ] + + @concurrent(concurrenttasks=10) + async def dowload_repository_content(self, content: FileInformation) -> None: + """Download content.""" + try: + self.logger.debug("%s Downloading %s", self.string, content.name) + + filecontent = await self.hacs.async_download_file(content.download_url) + + if filecontent is None: + self.validate.errors.append(f"[{content.name}] was not downloaded.") + return + + # Save the content of the file. + if self.content.single or content.path is None: + local_directory = self.content.path.local + + else: + _content_path = content.path + if not self.repository_manifest.content_in_root: + _content_path = _content_path.replace(f"{self.content.path.remote}", "") + + local_directory = f"{self.content.path.local}/{_content_path}" + local_directory = local_directory.split("/") + del local_directory[-1] + local_directory = "/".join(local_directory) + + # Check local directory + pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True) + + local_file_path = (f"{local_directory}/{content.name}").replace("//", "/") + + result = await self.hacs.async_save_file(local_file_path, filecontent) + if result: + self.logger.info("%s Download of %s completed", self.string, content.name) + return + self.validate.errors.append(f"[{content.name}] was not downloaded.") + + except ( + # lgtm [py/catch-base-exception] pylint: disable=broad-except + BaseException + ) as exception: + self.validate.errors.append(f"Download was not completed [{exception}]") + + async def async_remove_entity_device(self) -> None: + """Remove the entity device.""" + device_registry: dr.DeviceRegistry = dr.async_get(hass=self.hacs.hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, str(self.data.id))}) + + if device is None: + return + + device_registry.async_remove_device(device_id=device.id) + + def version_to_download(self) -> str: + """Determine which version to download.""" + if self.force_branch and self.ref is not None: + return self.ref + + if self.data.last_version is not None: + if self.data.selected_tag is not None: + if self.data.selected_tag == self.data.last_version: + self.data.selected_tag = None + return self.data.last_version + return self.data.selected_tag + return self.data.last_version + + if self.data.selected_tag is not None: + if self.data.selected_tag == self.data.default_branch: + return self.data.default_branch + if self.data.selected_tag in self.data.published_tags: + return self.data.selected_tag + + return self.data.default_branch or "main" + + async def get_documentation( + self, + *, + filename: str | None = None, + version: str | None = None, + **kwargs, + ) -> str | None: + """Get the documentation of the repository.""" + if filename is None: + return None + + if version is not None: + target_version = version + elif self.data.installed: + target_version = self.data.installed_version or self.data.installed_commit + else: + target_version = self.data.last_version or self.data.last_commit or self.ref + + self.logger.debug( + "%s Getting documentation for version=%s,filename=%s", + self.string, + target_version, + filename, + ) + if target_version is None: + return None + + result = await self.hacs.async_download_file( + f"https://raw.githubusercontent.com/{self.data.full_name}/{target_version}/{filename}", + nolog=True, + ) + + return ( + result.decode(encoding="utf-8") + .replace(" HacsManifest | None: + """Get the hacs.json file of the repository.""" + if (result := await self.get_hacs_json_raw(version=version)) is None: + return None + return HacsManifest.from_dict(result) + + @return_none_on_exception + async def get_hacs_json_raw( + self, + *, + version: str, + **kwargs, + ) -> dict[str, Any] | None: + """Get the hacs.json file of the repository.""" + self.logger.debug("%s Getting hacs.json for version=%s", self.string, version) + result = await self.hacs.async_download_file( + f"https://raw.githubusercontent.com/{self.data.full_name}/{version}/hacs.json", + nolog=True, + handle_rate_limit=True, + ) + return json_loads(result) if result else None + + async def _ensure_download_capabilities(self, ref: str | None, **kwargs: Any) -> None: + """Ensure that the download can be handled.""" + target_manifest: HacsManifest | None = None + if ref is None: + if not self.can_download: + raise HacsException( + f"This {self.data.category.value} is not available for download." + ) + return + + if not ref: + target_manifest = self.repository_manifest + else: + target_manifest = await self.get_hacs_json(version=ref) + + if target_manifest is None: + raise HacsException( + f"The version {ref} for this {self.data.category.value} can not be used with HACS." + ) + + if ( + target_manifest.homeassistant is not None + and self.hacs.core.ha_version < target_manifest.homeassistant + ): + raise HacsException( + f"This version requires Home Assistant {target_manifest.homeassistant} or newer." + ) + if target_manifest.hacs is not None and self.hacs.version < target_manifest.hacs: + raise HacsException(f"This version requires HACS {target_manifest.hacs} or newer.") + + async def async_download_repository(self, *, ref: str | None = None, **_) -> None: + """Download the content of a repository.""" + await self._ensure_download_capabilities(ref) + self.logger.info("Starting download, %s", ref) + if self.display_version_or_commit == "version": + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 10}, + ) + if not ref: + await self.update_repository(force=True) + else: + self.ref = ref + self.data.selected_tag = ref + self.force_branch = ref is not None + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 20}, + ) + + try: + await self.async_install(version=ref) + except HacsException as exception: + raise HacsException( + f"Downloading {self.data.full_name} with version {ref or self.data.last_version or self.data.last_commit} failed with ({exception})" + ) from exception + finally: + self.data.selected_tag = None + self.force_branch = False + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": False}, + ) + + async def async_get_releases(self, *, first: int = 30) -> list[GitHubReleaseModel]: + """Get the last x releases of a repository.""" + response = await self.hacs.async_github_api_method( + method=self.hacs.githubapi.repos.releases.list, + repository=self.data.full_name, + kwargs={"per_page": 30}, + ) + return response.data diff --git a/custom_components/hacs/websocket/repository.py b/custom_components/hacs/websocket/repository.py index 70752383242..56dc618e618 100644 --- a/custom_components/hacs/websocket/repository.py +++ b/custom_components/hacs/websocket/repository.py @@ -1,369 +1,378 @@ -"""Register info websocket commands.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from homeassistant.components import websocket_api -import homeassistant.helpers.config_validation as cv -import voluptuous as vol - -from ..const import DOMAIN -from ..enums import HacsDispatchEvent -from ..exceptions import HacsException -from ..utils.version import version_left_higher_then_right - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - - from ..base import HacsBase - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/info", - vol.Required("repository_id"): str, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_info( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Return information about a repository.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository_id = msg["repository_id"] - repository = hacs.repositories.get_by_id(repository_id) - if repository is None: - connection.send_error( - msg["id"], - "repository_not_found", - f"Repository with ID ({repository_id}) not found", - ) - return - - if not repository.updated_info: - try: - await repository.update_repository(ignore_issues=True, force=True) - except Exception as exception: # pylint: disable=broad-except - repository.logger.error("%s %s", repository.string, exception) - repository.updated_info = True - - if repository.data.new: - repository.data.new = False - await hacs.data.async_write() - - connection.send_message( - websocket_api.result_message( - msg["id"], - { - "additional_info": repository.additional_info, - "authors": repository.data.authors, - "available_version": repository.display_available_version, - "beta": repository.data.show_beta, - "can_download": repository.can_download, - "category": repository.data.category, - "config_flow": repository.data.config_flow, - "country": repository.repository_manifest.country, - "custom": not hacs.repositories.is_default(str(repository.data.id)), - "default_branch": repository.data.default_branch, - "description": repository.data.description, - "domain": repository.data.domain, - "downloads": repository.data.downloads, - "file_name": repository.data.file_name, - "full_name": repository.data.full_name, - "hide_default_branch": repository.repository_manifest.hide_default_branch, - "homeassistant": repository.repository_manifest.homeassistant, - "id": repository.data.id, - "installed_version": repository.display_installed_version, - "installed": repository.data.installed, - "issues": repository.data.open_issues, - "last_updated": repository.data.last_updated, - "local_path": repository.content.path.local, - "name": repository.display_name, - "new": False, - "pending_upgrade": repository.pending_update, - "releases": repository.data.published_tags, - "ref": repository.ref, - "selected_tag": repository.data.selected_tag, - "stars": repository.data.stargazers_count, - "state": repository.state, - "status": repository.display_status, - "topics": repository.data.topics, - "version_or_commit": repository.display_version_or_commit, - }, - ) - ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/ignore", - vol.Required("repository"): str, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_ignore( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Ignore a repository.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository_id = msg["repository"] - hacs.log.info("Ignoring %s", repository_id) - repository = hacs.repositories.get_by_id(repository_id) - if repository is None: - connection.send_error( - msg["id"], - "repository_not_found", - f"Repository with ID ({repository_id}) not found", - ) - return - - hacs.common.ignored_repositories.add(repository.data.full_name) - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"])) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/state", - vol.Required("repository"): cv.string, - vol.Required("state"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_state( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Set the state of a repository""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - repository.state = msg["state"] - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/version", - vol.Required("repository"): cv.string, - vol.Required("version"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_version( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Set the version of a repository""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - if msg["version"] == repository.data.default_branch: - repository.data.selected_tag = None - else: - repository.data.selected_tag = msg["version"] - - await repository.update_repository(force=True) - repository.state = None - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/beta", - vol.Required("repository"): cv.string, - vol.Required("show_beta"): cv.boolean, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_beta( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Show or hide beta versions of a repository""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - repository.data.show_beta = msg["show_beta"] - - await repository.update_repository(force=True) - repository.state = None - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/download", - vol.Required("repository"): cv.string, - vol.Optional("version"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_download( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Set the version of a repository""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - try: - was_installed = repository.data.installed - await repository.async_download_repository(ref=msg.get("version")) - if not was_installed: - hacs.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True}) - await hacs.async_recreate_entities() - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - except HacsException as exception: - repository.logger.error("%s %s", repository.string, exception) - connection.send_error(msg["id"], "error", str(exception)) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/remove", - vol.Required("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_remove( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Remove a repository.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - repository.data.new = False - try: - await repository.update_repository(ignore_issues=True, force=True) - except Exception as exception: # pylint: disable=broad-except - repository.logger.error("%s %s", repository.string, exception) - await repository.uninstall() - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/refresh", - vol.Required("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_refresh( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Refresh a repository.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - await repository.update_repository(ignore_issues=True, force=True) - await hacs.data.async_write() - # Update state of update entity - hacs.coordinators[repository.data.category].async_update_listeners() - - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/release_notes", - vol.Required("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_release_notes( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Return release notes.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - { - "name": x.name, - "body": x.body, - "tag": x.tag_name, - } - for x in repository.releases.objects - if not repository.data.installed_version - or version_left_higher_then_right(x.tag_name, repository.data.installed_version) - ], - ) - ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/releases", - vol.Required("repository_id"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_releases( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Return releases.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository_id"]) - try: - releases = await repository.async_get_releases() - except Exception as exception: - hacs.log.exception(exception) - connection.send_error(msg["id"], "unknown", str(exception)) - return - - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - { - "name": release.name, - "tag": release.tag_name, - "published_at": release.published_at, - "prerelease": release.prerelease, - } - for release in releases - ], - ) - ) +"""Register info websocket commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components import websocket_api +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from ..const import DOMAIN +from ..enums import HacsDispatchEvent +from ..exceptions import HacsException +from ..utils.version import version_left_higher_then_right + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from ..base import HacsBase + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/info", + vol.Required("repository_id"): str, + vol.Optional("language"): str, # Optional language code (e.g., "de", "en", "fr") + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return information about a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository_id = msg["repository_id"] + language = msg.get("language") # Optional: language code + repository = hacs.repositories.get_by_id(repository_id) + if repository is None: + connection.send_error( + msg["id"], + "repository_not_found", + f"Repository with ID ({repository_id}) not found", + ) + return + + if not repository.updated_info: + try: + await repository.update_repository(ignore_issues=True, force=True) + except Exception as exception: # pylint: disable=broad-except + repository.logger.error("%s %s", repository.string, exception) + repository.updated_info = True + + if repository.data.new: + repository.data.new = False + await hacs.data.async_write() + + # Load README with language support if language parameter is provided + additional_info = repository.additional_info + if language: + additional_info = await repository.async_get_info_file_contents_with_language( + language=language + ) + + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "additional_info": additional_info, + "authors": repository.data.authors, + "available_version": repository.display_available_version, + "beta": repository.data.show_beta, + "can_download": repository.can_download, + "category": repository.data.category, + "config_flow": repository.data.config_flow, + "country": repository.repository_manifest.country, + "custom": not hacs.repositories.is_default(str(repository.data.id)), + "default_branch": repository.data.default_branch, + "description": repository.data.description, + "domain": repository.data.domain, + "downloads": repository.data.downloads, + "file_name": repository.data.file_name, + "full_name": repository.data.full_name, + "hide_default_branch": repository.repository_manifest.hide_default_branch, + "homeassistant": repository.repository_manifest.homeassistant, + "id": repository.data.id, + "installed_version": repository.display_installed_version, + "installed": repository.data.installed, + "issues": repository.data.open_issues, + "last_updated": repository.data.last_updated, + "local_path": repository.content.path.local, + "name": repository.display_name, + "new": False, + "pending_upgrade": repository.pending_update, + "releases": repository.data.published_tags, + "ref": repository.ref, + "selected_tag": repository.data.selected_tag, + "stars": repository.data.stargazers_count, + "state": repository.state, + "status": repository.display_status, + "topics": repository.data.topics, + "version_or_commit": repository.display_version_or_commit, + }, + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/ignore", + vol.Required("repository"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_ignore( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Ignore a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository_id = msg["repository"] + hacs.log.info("Ignoring %s", repository_id) + repository = hacs.repositories.get_by_id(repository_id) + if repository is None: + connection.send_error( + msg["id"], + "repository_not_found", + f"Repository with ID ({repository_id}) not found", + ) + return + + hacs.common.ignored_repositories.add(repository.data.full_name) + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"])) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/state", + vol.Required("repository"): cv.string, + vol.Required("state"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_state( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set the state of a repository""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + repository.state = msg["state"] + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/version", + vol.Required("repository"): cv.string, + vol.Required("version"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_version( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set the version of a repository""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + if msg["version"] == repository.data.default_branch: + repository.data.selected_tag = None + else: + repository.data.selected_tag = msg["version"] + + await repository.update_repository(force=True) + repository.state = None + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/beta", + vol.Required("repository"): cv.string, + vol.Required("show_beta"): cv.boolean, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_beta( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Show or hide beta versions of a repository""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + repository.data.show_beta = msg["show_beta"] + + await repository.update_repository(force=True) + repository.state = None + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/download", + vol.Required("repository"): cv.string, + vol.Optional("version"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_download( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set the version of a repository""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + try: + was_installed = repository.data.installed + await repository.async_download_repository(ref=msg.get("version")) + if not was_installed: + hacs.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True}) + await hacs.async_recreate_entities() + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + except HacsException as exception: + repository.logger.error("%s %s", repository.string, exception) + connection.send_error(msg["id"], "error", str(exception)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/remove", + vol.Required("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Remove a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + repository.data.new = False + try: + await repository.update_repository(ignore_issues=True, force=True) + except Exception as exception: # pylint: disable=broad-except + repository.logger.error("%s %s", repository.string, exception) + await repository.uninstall() + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/refresh", + vol.Required("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_refresh( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Refresh a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + await repository.update_repository(ignore_issues=True, force=True) + await hacs.data.async_write() + # Update state of update entity + hacs.coordinators[repository.data.category].async_update_listeners() + + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/release_notes", + vol.Required("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_release_notes( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return release notes.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + connection.send_message( + websocket_api.result_message( + msg["id"], + [ + { + "name": x.name, + "body": x.body, + "tag": x.tag_name, + } + for x in repository.releases.objects + if not repository.data.installed_version + or version_left_higher_then_right(x.tag_name, repository.data.installed_version) + ], + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/releases", + vol.Required("repository_id"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_releases( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return releases.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository_id"]) + try: + releases = await repository.async_get_releases() + except Exception as exception: + hacs.log.exception(exception) + connection.send_error(msg["id"], "unknown", str(exception)) + return + + connection.send_message( + websocket_api.result_message( + msg["id"], + [ + { + "name": release.name, + "tag": release.tag_name, + "published_at": release.published_at, + "prerelease": release.prerelease, + } + for release in releases + ], + ) + ) From daa7e8dd233c0565a53e76eec0068ad1b68f65b7 Mon Sep 17 00:00:00 2001 From: rosch100 Date: Mon, 1 Dec 2025 21:32:19 +0100 Subject: [PATCH 02/10] Trigger CI workflows From 33e8da3e93c495ec264fbce5b7d670e23fe2bd4d Mon Sep 17 00:00:00 2001 From: rosch100 Date: Mon, 1 Dec 2025 21:40:08 +0100 Subject: [PATCH 03/10] Add supported_languages field to hacs.json schema and validation - Add supported_languages field to HacsManifest class - Add _supported_languages_validator to validate ISO 639-1 language codes - Add supported_languages to HACS_MANIFEST_JSON_SCHEMA - Add validation in hacsjson.py to check if declared README files exist - Add runtime check in async_get_info_file_contents_with_language to only use declared languages - Fixes review comments from ludeeus --- custom_components/hacs/repositories/base.py | 21 ++++ custom_components/hacs/validate/hacsjson.py | 115 ++++++++++++-------- 2 files changed, 92 insertions(+), 44 deletions(-) diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 58761d8094c..fe95b74c100 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -228,6 +228,7 @@ class HacsManifest: name: str = None persistent_directory: str = None render_readme: bool = False + supported_languages: list[str] = [] # Supported README languages (e.g., ["de", "fr", "es"]) zip_release: bool = False def to_dict(self): @@ -250,6 +251,8 @@ def from_dict(manifest: dict): for key, value in manifest_data.manifest.items(): if key == "country" and isinstance(value, str): setattr(manifest_data, key, [value]) + elif key == "supported_languages" and isinstance(value, str): + setattr(manifest_data, key, [value]) elif key in manifest_data.__dict__: setattr(manifest_data, key, value) return manifest_data @@ -265,6 +268,11 @@ def update_data(self, data: dict) -> None: setattr(self, key, [value]) else: setattr(self, key, value) + elif key == "supported_languages": + if isinstance(value, str): + setattr(self, key, [value]) + else: + setattr(self, key, value) else: setattr(self, key, value) @@ -771,6 +779,19 @@ async def async_get_info_file_contents_with_language( language = None else: language = language.lower() + + # Check if language is declared in supported_languages + if ( + self.repository_manifest.supported_languages + and language not in self.repository_manifest.supported_languages + ): + self.logger.debug( + "%s Language '%s' not in supported_languages %s, using README.md", + self.string, + language, + self.repository_manifest.supported_languages, + ) + language = None # If no language or English, use standard README if not language or language == "en": diff --git a/custom_components/hacs/validate/hacsjson.py b/custom_components/hacs/validate/hacsjson.py index bc989321f1b..c3670a5d987 100644 --- a/custom_components/hacs/validate/hacsjson.py +++ b/custom_components/hacs/validate/hacsjson.py @@ -1,44 +1,71 @@ -from __future__ import annotations - -from voluptuous.error import Invalid -from voluptuous.humanize import humanize_error - -from ..enums import HacsCategory, RepositoryFile -from ..repositories.base import HacsManifest, HacsRepository -from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA -from .base import ActionValidationBase, ValidationException - - -async def async_setup_validator(repository: HacsRepository) -> Validator: - """Set up this validator.""" - return Validator(repository=repository) - - -class Validator(ActionValidationBase): - """Validate the repository.""" - - more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest" - - async def async_validate(self) -> None: - """Validate the repository.""" - if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]: - raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file") - - rawhacsjson = await self.repository.get_hacs_json_raw(version=self.repository.ref) - if rawhacsjson is None: - raise ValidationException( - f"The repository has an invalid '{RepositoryFile.HACS_JSON}' file" - ) - - try: - hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(rawhacsjson)) - except Invalid as exception: - self.repository.logger.warning( - "HACS JSON validation failed for: %s", - rawhacsjson, - ) - raise ValidationException(humanize_error(rawhacsjson, exception)) from exception - - if self.repository.data.category == HacsCategory.INTEGRATION: - if hacsjson.zip_release and not hacsjson.filename: - raise ValidationException("zip_release is True, but filename is not set") +from __future__ import annotations + +from voluptuous.error import Invalid +from voluptuous.humanize import humanize_error + +from ..enums import HacsCategory, RepositoryFile +from ..repositories.base import HacsManifest, HacsRepository +from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA +from .base import ActionValidationBase, ValidationException + + +async def async_setup_validator(repository: HacsRepository) -> Validator: + """Set up this validator.""" + return Validator(repository=repository) + + +class Validator(ActionValidationBase): + """Validate the repository.""" + + more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest" + + async def async_validate(self) -> None: + """Validate the repository.""" + if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]: + raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file") + + rawhacsjson = await self.repository.get_hacs_json_raw(version=self.repository.ref) + if rawhacsjson is None: + raise ValidationException( + f"The repository has an invalid '{RepositoryFile.HACS_JSON}' file" + ) + + try: + hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(rawhacsjson)) + except Invalid as exception: + self.repository.logger.warning( + "HACS JSON validation failed for: %s", + rawhacsjson, + ) + raise ValidationException(humanize_error(rawhacsjson, exception)) from exception + + if self.repository.data.category == HacsCategory.INTEGRATION: + if hacsjson.zip_release and not hacsjson.filename: + raise ValidationException("zip_release is True, but filename is not set") + + # Validate supported_languages if provided + if hacsjson.supported_languages: + # Check if README files for declared languages exist + tree_files = [x.filename for x in self.repository.tree] + missing_readmes = [] + for lang in hacsjson.supported_languages: + readme_path = f"README.{lang}.md" + # Check various case combinations + found = False + for possible_path in [ + readme_path, + f"README.{lang.upper()}.md", + f"readme.{lang}.md", + f"readme.{lang.upper()}.md", + ]: + if possible_path in tree_files: + found = True + break + if not found: + missing_readmes.append(lang) + + if missing_readmes: + raise ValidationException( + f"supported_languages declares languages {missing_readmes}, " + f"but corresponding README files (README.{{lang}}.md) were not found in the repository." + ) \ No newline at end of file From 0c1d32ccb6a1d7a80b5e4b29e50e1617ea66a02a Mon Sep 17 00:00:00 2001 From: rosch100 Date: Tue, 2 Dec 2025 11:53:32 +0100 Subject: [PATCH 04/10] Add BCP47 language parsing for multilingual README support - Extract base language code from BCP47 format (e.g., 'de-DE' -> 'de') - Keep supported_languages runtime check as per ludeeus requirement - All language processing logic now in backend --- BACKEND_IMPLEMENTATION_GUIDE.md | 381 ++++++++++++++++++++ custom_components/hacs/repositories/base.py | 6 +- 2 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 BACKEND_IMPLEMENTATION_GUIDE.md diff --git a/BACKEND_IMPLEMENTATION_GUIDE.md b/BACKEND_IMPLEMENTATION_GUIDE.md new file mode 100644 index 00000000000..5b6052642ae --- /dev/null +++ b/BACKEND_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,381 @@ +# HACS Backend: Mehrsprachige README-Unterstützung - Implementierungsanleitung + +## Übersicht + +Diese Dokumentation beschreibt, wie das HACS Backend erweitert werden muss, um mehrsprachige README-Dateien zu unterstützen. Das Frontend sendet bereits einen optionalen `language`-Parameter im Websocket-Request `hacs/repository/info`. + +## Backend-Repository + +**Repository:** https://github.com/hacs/integration + +## Frontend-Implementierung (bereits fertig) + +Das Frontend sendet den `language`-Parameter im folgenden Format: + +```typescript +{ + type: "hacs/repository/info", + repository_id: "123456789", + language: "de" // Optional: Basis-Sprachcode (z.B. "de", "en", "fr") +} +``` + +**Wichtige Details:** +- Der Parameter ist **optional** und **backward-kompatibel** +- Format: Basis-Sprachcode (z.B. "de" aus "de-DE", "en" aus "en-US") +- Wird nur gesendet, wenn die Sprache nicht Englisch ist (Englisch verwendet README.md) +- Das Frontend hat automatische Fehlerbehandlung: Wenn das Backend den Parameter ablehnt, wird die Anfrage ohne Parameter wiederholt + +## Backend-Implementierung + +### 1. Websocket-Handler anpassen + +**Datei:** `hacs/websocket/repository/info.py` (oder ähnlich) + +**Aktueller Code (Beispiel):** +```python +@websocket_command( + { + vol.Required("type"): "hacs/repository/info", + vol.Required("repository_id"): str, + } +) +async def repository_info(hass, connection, msg): + """Get repository information.""" + repository_id = msg["repository_id"] + # ... Repository-Info abrufen ... + return repository_info +``` + +**Neuer Code:** +```python +@websocket_command( + { + vol.Required("type"): "hacs/repository/info", + vol.Required("repository_id"): str, + vol.Optional("language"): str, # Neuer optionaler Parameter + } +) +async def repository_info(hass, connection, msg): + """Get repository information.""" + repository_id = msg["repository_id"] + language = msg.get("language") # Optional: Sprachcode (z.B. "de", "en", "fr") + + # ... Repository-Info abrufen ... + + # README mit Sprachunterstützung laden + readme_content = await get_repository_readme(repository, language) + + repository_info["additional_info"] = readme_content + return repository_info +``` + +### 2. README-Lade-Funktion implementieren + +**Neue Funktion erstellen oder bestehende erweitern:** + +```python +async def get_repository_readme(repository, language: str | None = None) -> str: + """ + Lade README-Datei mit Sprachunterstützung. + + Args: + repository: Repository-Objekt + language: Optionaler Sprachcode (z.B. "de", "en", "fr") + + Returns: + README-Inhalt als String + """ + # Wenn keine Sprache angegeben oder Englisch, verwende Standard-README + if not language or language == "en": + readme_path = "README.md" + else: + # Versuche sprachspezifische README zu laden + readme_path = f"README.{language}.md" + + try: + # Lade README vom Repository + readme_content = await repository.get_file_contents(readme_path) + return readme_content + except FileNotFoundError: + # Falls sprachspezifische README nicht existiert, verwende Standard-README + if readme_path != "README.md": + try: + readme_content = await repository.get_file_contents("README.md") + return readme_content + except FileNotFoundError: + return "" + return "" + except Exception as e: + # Log Fehler und verwende Standard-README als Fallback + logger.warning(f"Fehler beim Laden von {readme_path}: {e}") + if readme_path != "README.md": + try: + readme_content = await repository.get_file_contents("README.md") + return readme_content + except FileNotFoundError: + return "" + return "" +``` + +### 3. Vollständiges Beispiel + +Hier ist ein vollständiges Beispiel, wie die Implementierung aussehen könnte: + +```python +import voluptuous as vol +from homeassistant.components import websocket_api +from hacs.helpers.functions.logger import getLogger + +logger = getLogger() + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/info", + vol.Required("repository_id"): str, + vol.Optional("language"): str, # Neuer optionaler Parameter + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def handle_repository_info(hass, connection, msg): + """Handle repository info websocket command.""" + repository_id = msg["repository_id"] + language = msg.get("language") # Optional: Sprachcode + + hacs = get_hacs() + + try: + repository = hacs.repositories.get_by_id(repository_id) + if not repository: + connection.send_error( + msg["id"], + "repository_not_found", + f"Repository with ID {repository_id} not found", + ) + return + + # Repository-Informationen abrufen + repository_info = { + "id": repository.data.id, + "name": repository.data.name, + "full_name": repository.data.full_name, + # ... weitere Felder ... + } + + # README mit Sprachunterstützung laden + readme_content = await get_repository_readme(repository, language) + repository_info["additional_info"] = readme_content + + connection.send_result(msg["id"], repository_info) + + except Exception as e: + logger.error(f"Error getting repository info: {e}") + connection.send_error( + msg["id"], + "error", + str(e), + ) + + +async def get_repository_readme(repository, language: str | None = None) -> str: + """ + Lade README-Datei mit Sprachunterstützung. + + Unterstützte Dateien: + - README.md (Standard, wird immer verwendet wenn keine Sprache oder "en") + - README.de.md (Deutsch) + - README.fr.md (Französisch) + - README.es.md (Spanisch) + - etc. + + Args: + repository: Repository-Objekt + language: Optionaler Sprachcode (z.B. "de", "en", "fr") + + Returns: + README-Inhalt als String + """ + # Wenn keine Sprache angegeben oder Englisch, verwende Standard-README + if not language or language == "en": + readme_path = "README.md" + else: + # Versuche sprachspezifische README zu laden + readme_path = f"README.{language}.md" + + try: + # Lade README vom Repository + # Hinweis: Die genaue Methode hängt von Ihrer Repository-Implementierung ab + readme_content = await repository.get_file_contents(readme_path) + return readme_content + except FileNotFoundError: + # Falls sprachspezifische README nicht existiert, verwende Standard-README + if readme_path != "README.md": + logger.debug( + f"Sprachspezifische README {readme_path} nicht gefunden, " + f"verwende README.md für Repository {repository.data.full_name}" + ) + try: + readme_content = await repository.get_file_contents("README.md") + return readme_content + except FileNotFoundError: + logger.warning( + f"README.md nicht gefunden für Repository {repository.data.full_name}" + ) + return "" + return "" + except Exception as e: + # Log Fehler und verwende Standard-README als Fallback + logger.warning( + f"Fehler beim Laden von {readme_path} für Repository " + f"{repository.data.full_name}: {e}" + ) + if readme_path != "README.md": + try: + readme_content = await repository.get_file_contents("README.md") + return readme_content + except FileNotFoundError: + return "" + return "" +``` + +## Unterstützte Dateinamen + +Das Backend sollte folgende README-Dateien unterstützen: + +- `README.md` - Standard-README (Englisch oder Fallback) +- `README.de.md` - Deutsch +- `README.fr.md` - Französisch +- `README.es.md` - Spanisch +- `README.it.md` - Italienisch +- `README.nl.md` - Niederländisch +- `README.pl.md` - Polnisch +- `README.pt.md` - Portugiesisch +- `README.ru.md` - Russisch +- `README.zh.md` - Chinesisch +- etc. + +**Format:** `README.{language_code}.md` (ISO 639-1 Sprachcode, 2 Buchstaben) + +## Fallback-Verhalten + +1. **Wenn `language` Parameter gesendet wird:** + - Versuche `README.{language}.md` zu laden + - Falls nicht vorhanden, verwende `README.md` als Fallback + +2. **Wenn kein `language` Parameter gesendet wird:** + - Verwende `README.md` (Standard-Verhalten, backward-kompatibel) + +3. **Wenn `language` = "en" oder None:** + - Verwende `README.md` (Englisch ist die Standard-Sprache) + +## Validierung + +Der `language`-Parameter sollte validiert werden: + +```python +# Optional: Validierung des Sprachcodes +if language: + # Prüfe, ob es ein gültiger 2-Buchstaben-Sprachcode ist + if not language.isalpha() or len(language) != 2: + logger.warning(f"Ungültiger Sprachcode: {language}, verwende README.md") + language = None + else: + language = language.lower() # Normalisiere zu Kleinbuchstaben +``` + +## Testing + +### Test-Szenarien + +1. **Repository mit nur README.md:** + - Request ohne `language`: Sollte README.md zurückgeben ✅ + - Request mit `language: "de"`: Sollte README.md zurückgeben (Fallback) ✅ + +2. **Repository mit README.md und README.de.md:** + - Request ohne `language`: Sollte README.md zurückgeben ✅ + - Request mit `language: "de"`: Sollte README.de.md zurückgeben ✅ + - Request mit `language: "fr"`: Sollte README.md zurückgeben (Fallback) ✅ + +3. **Repository mit nur README.de.md (kein README.md):** + - Request ohne `language`: Sollte Fehler oder leeren String zurückgeben + - Request mit `language: "de"`: Sollte README.de.md zurückgeben ✅ + +### Test-Commands + +```python +# Test 1: Ohne language Parameter (backward-kompatibel) +{ + "type": "hacs/repository/info", + "repository_id": "123456789" +} + +# Test 2: Mit language Parameter +{ + "type": "hacs/repository/info", + "repository_id": "123456789", + "language": "de" +} + +# Test 3: Mit language Parameter (Englisch) +{ + "type": "hacs/repository/info", + "repository_id": "123456789", + "language": "en" +} +``` + +## Migration und Backward-Kompatibilität + +**Wichtig:** Die Implementierung muss **vollständig backward-kompatibel** sein: + +- Alte Frontend-Versionen (ohne `language`-Parameter) müssen weiterhin funktionieren +- Neue Frontend-Versionen (mit `language`-Parameter) sollten funktionieren, auch wenn das Backend den Parameter noch nicht unterstützt (Frontend hat Fehlerbehandlung) + +**Empfehlung:** +- Der `language`-Parameter sollte als `vol.Optional()` definiert werden +- Wenn der Parameter nicht vorhanden ist, sollte das Standard-Verhalten (README.md) verwendet werden + +## Beispiel-Repository + +Ein Beispiel-Repository mit mehrsprachigen READMEs: + +``` +repository/ +├── README.md (Englisch, Standard) +├── README.de.md (Deutsch) +├── README.fr.md (Französisch) +└── ... +``` + +## Zusammenfassung + +**Was muss implementiert werden:** + +1. ✅ Websocket-Handler erweitern: `vol.Optional("language"): str` hinzufügen +2. ✅ README-Lade-Funktion erweitern: Sprachspezifische README-Dateien unterstützen +3. ✅ Fallback-Logik implementieren: README.md verwenden, wenn sprachspezifische README nicht existiert +4. ✅ Validierung: Sprachcode validieren (optional, aber empfohlen) +5. ✅ Testing: Verschiedene Szenarien testen + +**Frontend-Status:** +- ✅ Frontend sendet bereits den `language`-Parameter +- ✅ Frontend hat automatische Fehlerbehandlung +- ✅ Frontend ist backward-kompatibel + +**Backend-Status:** +- ⏳ Backend muss noch implementiert werden (diese Dokumentation) + +## Weitere Ressourcen + +- **Frontend-Repository:** https://github.com/hacs/frontend +- **Backend-Repository:** https://github.com/hacs/integration +- **HACS Dokumentation:** https://hacs.xyz/docs/ + +## Fragen oder Probleme? + +Bei Fragen zur Implementierung: +1. Prüfen Sie die Frontend-Implementierung in `src/data/repository.ts` +2. Prüfen Sie die Websocket-Nachrichten in der Browser-Konsole +3. Erstellen Sie ein Issue im Backend-Repository: https://github.com/hacs/integration/issues + diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index fe95b74c100..9da8d7215ad 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -768,7 +768,9 @@ async def async_get_info_file_contents_with_language( Returns: README content as string """ - # Validate and normalize language code + if language: + language = language.split("-")[0].lower() if "-" in language else language.lower() + if language: if not language.isalpha() or len(language) != 2: self.logger.warning( @@ -778,8 +780,6 @@ async def async_get_info_file_contents_with_language( ) language = None else: - language = language.lower() - # Check if language is declared in supported_languages if ( self.repository_manifest.supported_languages From 27e24d958e6037011e8e3894caa47821ea108ad5 Mon Sep 17 00:00:00 2001 From: rosch100 Date: Tue, 2 Dec 2025 12:52:14 +0100 Subject: [PATCH 05/10] Fix supported_languages validation and runtime consistency - Fix case combination mismatch: validator now checks same 6 combinations as runtime - Add language code format validation in validator (2-letter alphabetic) - Fix filename vs full_path inconsistency: use full_path consistently - Normalize supported_languages to lowercase when loading from manifest - Remove unnecessary comments --- BACKEND_IMPLEMENTATION_GUIDE.md | 381 -------------------- custom_components/hacs/repositories/base.py | 18 +- custom_components/hacs/validate/hacsjson.py | 17 +- 3 files changed, 23 insertions(+), 393 deletions(-) delete mode 100644 BACKEND_IMPLEMENTATION_GUIDE.md diff --git a/BACKEND_IMPLEMENTATION_GUIDE.md b/BACKEND_IMPLEMENTATION_GUIDE.md deleted file mode 100644 index 5b6052642ae..00000000000 --- a/BACKEND_IMPLEMENTATION_GUIDE.md +++ /dev/null @@ -1,381 +0,0 @@ -# HACS Backend: Mehrsprachige README-Unterstützung - Implementierungsanleitung - -## Übersicht - -Diese Dokumentation beschreibt, wie das HACS Backend erweitert werden muss, um mehrsprachige README-Dateien zu unterstützen. Das Frontend sendet bereits einen optionalen `language`-Parameter im Websocket-Request `hacs/repository/info`. - -## Backend-Repository - -**Repository:** https://github.com/hacs/integration - -## Frontend-Implementierung (bereits fertig) - -Das Frontend sendet den `language`-Parameter im folgenden Format: - -```typescript -{ - type: "hacs/repository/info", - repository_id: "123456789", - language: "de" // Optional: Basis-Sprachcode (z.B. "de", "en", "fr") -} -``` - -**Wichtige Details:** -- Der Parameter ist **optional** und **backward-kompatibel** -- Format: Basis-Sprachcode (z.B. "de" aus "de-DE", "en" aus "en-US") -- Wird nur gesendet, wenn die Sprache nicht Englisch ist (Englisch verwendet README.md) -- Das Frontend hat automatische Fehlerbehandlung: Wenn das Backend den Parameter ablehnt, wird die Anfrage ohne Parameter wiederholt - -## Backend-Implementierung - -### 1. Websocket-Handler anpassen - -**Datei:** `hacs/websocket/repository/info.py` (oder ähnlich) - -**Aktueller Code (Beispiel):** -```python -@websocket_command( - { - vol.Required("type"): "hacs/repository/info", - vol.Required("repository_id"): str, - } -) -async def repository_info(hass, connection, msg): - """Get repository information.""" - repository_id = msg["repository_id"] - # ... Repository-Info abrufen ... - return repository_info -``` - -**Neuer Code:** -```python -@websocket_command( - { - vol.Required("type"): "hacs/repository/info", - vol.Required("repository_id"): str, - vol.Optional("language"): str, # Neuer optionaler Parameter - } -) -async def repository_info(hass, connection, msg): - """Get repository information.""" - repository_id = msg["repository_id"] - language = msg.get("language") # Optional: Sprachcode (z.B. "de", "en", "fr") - - # ... Repository-Info abrufen ... - - # README mit Sprachunterstützung laden - readme_content = await get_repository_readme(repository, language) - - repository_info["additional_info"] = readme_content - return repository_info -``` - -### 2. README-Lade-Funktion implementieren - -**Neue Funktion erstellen oder bestehende erweitern:** - -```python -async def get_repository_readme(repository, language: str | None = None) -> str: - """ - Lade README-Datei mit Sprachunterstützung. - - Args: - repository: Repository-Objekt - language: Optionaler Sprachcode (z.B. "de", "en", "fr") - - Returns: - README-Inhalt als String - """ - # Wenn keine Sprache angegeben oder Englisch, verwende Standard-README - if not language or language == "en": - readme_path = "README.md" - else: - # Versuche sprachspezifische README zu laden - readme_path = f"README.{language}.md" - - try: - # Lade README vom Repository - readme_content = await repository.get_file_contents(readme_path) - return readme_content - except FileNotFoundError: - # Falls sprachspezifische README nicht existiert, verwende Standard-README - if readme_path != "README.md": - try: - readme_content = await repository.get_file_contents("README.md") - return readme_content - except FileNotFoundError: - return "" - return "" - except Exception as e: - # Log Fehler und verwende Standard-README als Fallback - logger.warning(f"Fehler beim Laden von {readme_path}: {e}") - if readme_path != "README.md": - try: - readme_content = await repository.get_file_contents("README.md") - return readme_content - except FileNotFoundError: - return "" - return "" -``` - -### 3. Vollständiges Beispiel - -Hier ist ein vollständiges Beispiel, wie die Implementierung aussehen könnte: - -```python -import voluptuous as vol -from homeassistant.components import websocket_api -from hacs.helpers.functions.logger import getLogger - -logger = getLogger() - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/info", - vol.Required("repository_id"): str, - vol.Optional("language"): str, # Neuer optionaler Parameter - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def handle_repository_info(hass, connection, msg): - """Handle repository info websocket command.""" - repository_id = msg["repository_id"] - language = msg.get("language") # Optional: Sprachcode - - hacs = get_hacs() - - try: - repository = hacs.repositories.get_by_id(repository_id) - if not repository: - connection.send_error( - msg["id"], - "repository_not_found", - f"Repository with ID {repository_id} not found", - ) - return - - # Repository-Informationen abrufen - repository_info = { - "id": repository.data.id, - "name": repository.data.name, - "full_name": repository.data.full_name, - # ... weitere Felder ... - } - - # README mit Sprachunterstützung laden - readme_content = await get_repository_readme(repository, language) - repository_info["additional_info"] = readme_content - - connection.send_result(msg["id"], repository_info) - - except Exception as e: - logger.error(f"Error getting repository info: {e}") - connection.send_error( - msg["id"], - "error", - str(e), - ) - - -async def get_repository_readme(repository, language: str | None = None) -> str: - """ - Lade README-Datei mit Sprachunterstützung. - - Unterstützte Dateien: - - README.md (Standard, wird immer verwendet wenn keine Sprache oder "en") - - README.de.md (Deutsch) - - README.fr.md (Französisch) - - README.es.md (Spanisch) - - etc. - - Args: - repository: Repository-Objekt - language: Optionaler Sprachcode (z.B. "de", "en", "fr") - - Returns: - README-Inhalt als String - """ - # Wenn keine Sprache angegeben oder Englisch, verwende Standard-README - if not language or language == "en": - readme_path = "README.md" - else: - # Versuche sprachspezifische README zu laden - readme_path = f"README.{language}.md" - - try: - # Lade README vom Repository - # Hinweis: Die genaue Methode hängt von Ihrer Repository-Implementierung ab - readme_content = await repository.get_file_contents(readme_path) - return readme_content - except FileNotFoundError: - # Falls sprachspezifische README nicht existiert, verwende Standard-README - if readme_path != "README.md": - logger.debug( - f"Sprachspezifische README {readme_path} nicht gefunden, " - f"verwende README.md für Repository {repository.data.full_name}" - ) - try: - readme_content = await repository.get_file_contents("README.md") - return readme_content - except FileNotFoundError: - logger.warning( - f"README.md nicht gefunden für Repository {repository.data.full_name}" - ) - return "" - return "" - except Exception as e: - # Log Fehler und verwende Standard-README als Fallback - logger.warning( - f"Fehler beim Laden von {readme_path} für Repository " - f"{repository.data.full_name}: {e}" - ) - if readme_path != "README.md": - try: - readme_content = await repository.get_file_contents("README.md") - return readme_content - except FileNotFoundError: - return "" - return "" -``` - -## Unterstützte Dateinamen - -Das Backend sollte folgende README-Dateien unterstützen: - -- `README.md` - Standard-README (Englisch oder Fallback) -- `README.de.md` - Deutsch -- `README.fr.md` - Französisch -- `README.es.md` - Spanisch -- `README.it.md` - Italienisch -- `README.nl.md` - Niederländisch -- `README.pl.md` - Polnisch -- `README.pt.md` - Portugiesisch -- `README.ru.md` - Russisch -- `README.zh.md` - Chinesisch -- etc. - -**Format:** `README.{language_code}.md` (ISO 639-1 Sprachcode, 2 Buchstaben) - -## Fallback-Verhalten - -1. **Wenn `language` Parameter gesendet wird:** - - Versuche `README.{language}.md` zu laden - - Falls nicht vorhanden, verwende `README.md` als Fallback - -2. **Wenn kein `language` Parameter gesendet wird:** - - Verwende `README.md` (Standard-Verhalten, backward-kompatibel) - -3. **Wenn `language` = "en" oder None:** - - Verwende `README.md` (Englisch ist die Standard-Sprache) - -## Validierung - -Der `language`-Parameter sollte validiert werden: - -```python -# Optional: Validierung des Sprachcodes -if language: - # Prüfe, ob es ein gültiger 2-Buchstaben-Sprachcode ist - if not language.isalpha() or len(language) != 2: - logger.warning(f"Ungültiger Sprachcode: {language}, verwende README.md") - language = None - else: - language = language.lower() # Normalisiere zu Kleinbuchstaben -``` - -## Testing - -### Test-Szenarien - -1. **Repository mit nur README.md:** - - Request ohne `language`: Sollte README.md zurückgeben ✅ - - Request mit `language: "de"`: Sollte README.md zurückgeben (Fallback) ✅ - -2. **Repository mit README.md und README.de.md:** - - Request ohne `language`: Sollte README.md zurückgeben ✅ - - Request mit `language: "de"`: Sollte README.de.md zurückgeben ✅ - - Request mit `language: "fr"`: Sollte README.md zurückgeben (Fallback) ✅ - -3. **Repository mit nur README.de.md (kein README.md):** - - Request ohne `language`: Sollte Fehler oder leeren String zurückgeben - - Request mit `language: "de"`: Sollte README.de.md zurückgeben ✅ - -### Test-Commands - -```python -# Test 1: Ohne language Parameter (backward-kompatibel) -{ - "type": "hacs/repository/info", - "repository_id": "123456789" -} - -# Test 2: Mit language Parameter -{ - "type": "hacs/repository/info", - "repository_id": "123456789", - "language": "de" -} - -# Test 3: Mit language Parameter (Englisch) -{ - "type": "hacs/repository/info", - "repository_id": "123456789", - "language": "en" -} -``` - -## Migration und Backward-Kompatibilität - -**Wichtig:** Die Implementierung muss **vollständig backward-kompatibel** sein: - -- Alte Frontend-Versionen (ohne `language`-Parameter) müssen weiterhin funktionieren -- Neue Frontend-Versionen (mit `language`-Parameter) sollten funktionieren, auch wenn das Backend den Parameter noch nicht unterstützt (Frontend hat Fehlerbehandlung) - -**Empfehlung:** -- Der `language`-Parameter sollte als `vol.Optional()` definiert werden -- Wenn der Parameter nicht vorhanden ist, sollte das Standard-Verhalten (README.md) verwendet werden - -## Beispiel-Repository - -Ein Beispiel-Repository mit mehrsprachigen READMEs: - -``` -repository/ -├── README.md (Englisch, Standard) -├── README.de.md (Deutsch) -├── README.fr.md (Französisch) -└── ... -``` - -## Zusammenfassung - -**Was muss implementiert werden:** - -1. ✅ Websocket-Handler erweitern: `vol.Optional("language"): str` hinzufügen -2. ✅ README-Lade-Funktion erweitern: Sprachspezifische README-Dateien unterstützen -3. ✅ Fallback-Logik implementieren: README.md verwenden, wenn sprachspezifische README nicht existiert -4. ✅ Validierung: Sprachcode validieren (optional, aber empfohlen) -5. ✅ Testing: Verschiedene Szenarien testen - -**Frontend-Status:** -- ✅ Frontend sendet bereits den `language`-Parameter -- ✅ Frontend hat automatische Fehlerbehandlung -- ✅ Frontend ist backward-kompatibel - -**Backend-Status:** -- ⏳ Backend muss noch implementiert werden (diese Dokumentation) - -## Weitere Ressourcen - -- **Frontend-Repository:** https://github.com/hacs/frontend -- **Backend-Repository:** https://github.com/hacs/integration -- **HACS Dokumentation:** https://hacs.xyz/docs/ - -## Fragen oder Probleme? - -Bei Fragen zur Implementierung: -1. Prüfen Sie die Frontend-Implementierung in `src/data/repository.ts` -2. Prüfen Sie die Websocket-Nachrichten in der Browser-Konsole -3. Erstellen Sie ein Issue im Backend-Repository: https://github.com/hacs/integration/issues - diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 9da8d7215ad..33182bd6a27 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -251,8 +251,13 @@ def from_dict(manifest: dict): for key, value in manifest_data.manifest.items(): if key == "country" and isinstance(value, str): setattr(manifest_data, key, [value]) - elif key == "supported_languages" and isinstance(value, str): - setattr(manifest_data, key, [value]) + elif key == "supported_languages": + if isinstance(value, str): + setattr(manifest_data, key, [value.lower()]) + elif isinstance(value, list): + setattr(manifest_data, key, [lang.lower() if isinstance(lang, str) else lang for lang in value]) + else: + setattr(manifest_data, key, value) elif key in manifest_data.__dict__: setattr(manifest_data, key, value) return manifest_data @@ -270,7 +275,9 @@ def update_data(self, data: dict) -> None: setattr(self, key, value) elif key == "supported_languages": if isinstance(value, str): - setattr(self, key, [value]) + setattr(self, key, [value.lower()]) + elif isinstance(value, list): + setattr(self, key, [lang.lower() if isinstance(lang, str) else lang for lang in value]) else: setattr(self, key, value) else: @@ -780,7 +787,6 @@ async def async_get_info_file_contents_with_language( ) language = None else: - # Check if language is declared in supported_languages if ( self.repository_manifest.supported_languages and language not in self.repository_manifest.supported_languages @@ -793,15 +799,11 @@ async def async_get_info_file_contents_with_language( ) language = None - # If no language or English, use standard README if not language or language == "en": return await self.async_get_info_file_contents(version=version) - # Try to load language-specific README readme_path = f"README.{language}.md" - # Check if the language-specific README exists in treefiles - # We need to check various case combinations possible_paths = [ f"README.{language}.md", f"README.{language.upper()}.md", diff --git a/custom_components/hacs/validate/hacsjson.py b/custom_components/hacs/validate/hacsjson.py index c3670a5d987..d02565242e6 100644 --- a/custom_components/hacs/validate/hacsjson.py +++ b/custom_components/hacs/validate/hacsjson.py @@ -43,20 +43,24 @@ async def async_validate(self) -> None: if hacsjson.zip_release and not hacsjson.filename: raise ValidationException("zip_release is True, but filename is not set") - # Validate supported_languages if provided if hacsjson.supported_languages: - # Check if README files for declared languages exist - tree_files = [x.filename for x in self.repository.tree] + tree_files = [x.full_path for x in self.repository.tree] missing_readmes = [] + invalid_languages = [] for lang in hacsjson.supported_languages: + if not lang.isalpha() or len(lang) != 2: + invalid_languages.append(lang) + continue + readme_path = f"README.{lang}.md" - # Check various case combinations found = False for possible_path in [ readme_path, f"README.{lang.upper()}.md", f"readme.{lang}.md", f"readme.{lang.upper()}.md", + f"README.{lang}.MD", + f"README.{lang.upper()}.MD", ]: if possible_path in tree_files: found = True @@ -64,6 +68,11 @@ async def async_validate(self) -> None: if not found: missing_readmes.append(lang) + if invalid_languages: + raise ValidationException( + f"supported_languages contains invalid language codes {invalid_languages}. " + f"Language codes must be 2-letter alphabetic codes (e.g., 'de', 'fr', 'es')." + ) if missing_readmes: raise ValidationException( f"supported_languages declares languages {missing_readmes}, " From 1f2ea52bfd73f29a15016b7b779c1bcf4e42a3b8 Mon Sep 17 00:00:00 2001 From: rosch100 Date: Tue, 2 Dec 2025 13:32:49 +0100 Subject: [PATCH 06/10] Add supported_languages validator to hacs.json schema - Add _supported_languages_validator to validate language codes - Add supported_languages to HACS_MANIFEST_JSON_SCHEMA as optional field - Validator handles None values and normalizes to lowercase - Remove unnecessary comment in websocket handler --- custom_components/hacs/utils/validate.py | 29 +++++++++++++++++++ .../hacs/websocket/repository.py | 1 - 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/custom_components/hacs/utils/validate.py b/custom_components/hacs/utils/validate.py index fa25be9af8a..de1bb08af76 100644 --- a/custom_components/hacs/utils/validate.py +++ b/custom_components/hacs/utils/validate.py @@ -43,6 +43,34 @@ def _country_validator(values) -> list[str]: return countries +def _supported_languages_validator(values) -> list[str]: + """Custom supported_languages validator.""" + if values is None: + return [] + + languages = [] + if isinstance(values, str): + languages.append(values.lower()) + elif isinstance(values, list): + for value in values: + if not isinstance(value, str): + raise vol.Invalid( + f"Language code '{value}' is not a string.", path=["supported_languages"] + ) + if not value.isalpha() or len(value) != 2: + raise vol.Invalid( + f"Language code '{value}' must be a 2-letter alphabetic code (e.g., 'de', 'fr', 'es').", + path=["supported_languages"], + ) + languages.append(value.lower()) + else: + raise vol.Invalid( + f"Value '{values}' is not a string or list.", path=["supported_languages"] + ) + + return languages + + HACS_MANIFEST_JSON_SCHEMA = vol.Schema( { vol.Optional("content_in_root"): bool, @@ -53,6 +81,7 @@ def _country_validator(values) -> list[str]: vol.Optional("homeassistant"): str, vol.Optional("persistent_directory"): str, vol.Optional("render_readme"): bool, + vol.Optional("supported_languages"): _supported_languages_validator, vol.Optional("zip_release"): bool, vol.Required("name"): str, }, diff --git a/custom_components/hacs/websocket/repository.py b/custom_components/hacs/websocket/repository.py index 56dc618e618..8927689b347 100644 --- a/custom_components/hacs/websocket/repository.py +++ b/custom_components/hacs/websocket/repository.py @@ -57,7 +57,6 @@ async def hacs_repository_info( repository.data.new = False await hacs.data.async_write() - # Load README with language support if language parameter is provided additional_info = repository.additional_info if language: additional_info = await repository.async_get_info_file_contents_with_language( From 2880b10e375651798edb20da5fb18322f7c788a1 Mon Sep 17 00:00:00 2001 From: rosch100 Date: Tue, 2 Dec 2025 13:48:18 +0100 Subject: [PATCH 07/10] Fix supported_languages validation issues - Add language code validation for string values in validator - Fix filename vs full_path inconsistency in hacsjson validator - Use filename instead of full_path for README file matching (consistent with information.py) --- custom_components/hacs/utils/validate.py | 5 +++++ custom_components/hacs/validate/hacsjson.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/hacs/utils/validate.py b/custom_components/hacs/utils/validate.py index de1bb08af76..768068ceda3 100644 --- a/custom_components/hacs/utils/validate.py +++ b/custom_components/hacs/utils/validate.py @@ -50,6 +50,11 @@ def _supported_languages_validator(values) -> list[str]: languages = [] if isinstance(values, str): + if not values.isalpha() or len(values) != 2: + raise vol.Invalid( + f"Language code '{values}' must be a 2-letter alphabetic code (e.g., 'de', 'fr', 'es').", + path=["supported_languages"], + ) languages.append(values.lower()) elif isinstance(values, list): for value in values: diff --git a/custom_components/hacs/validate/hacsjson.py b/custom_components/hacs/validate/hacsjson.py index d02565242e6..7c245dbbb99 100644 --- a/custom_components/hacs/validate/hacsjson.py +++ b/custom_components/hacs/validate/hacsjson.py @@ -44,7 +44,7 @@ async def async_validate(self) -> None: raise ValidationException("zip_release is True, but filename is not set") if hacsjson.supported_languages: - tree_files = [x.full_path for x in self.repository.tree] + tree_files = [x.filename for x in self.repository.tree] missing_readmes = [] invalid_languages = [] for lang in hacsjson.supported_languages: From f21600db303da21440e6950e5a511e372942bf7a Mon Sep 17 00:00:00 2001 From: rosch100 Date: Tue, 2 Dec 2025 14:50:49 +0100 Subject: [PATCH 08/10] Add multilingual description support and rename supported_languages to content_languages - Add async_get_description_with_language() method to support multilingual descriptions - Extend WebSocket handlers to use language parameter for descriptions - Rename supported_languages to content_languages in manifest and validators - Update validation to check content_languages for both README and description files - Support DESCRIPTION.{language_code}.txt files with automatic language detection --- custom_components/hacs/repositories/base.py | 95 +++- custom_components/hacs/utils/validate.py | 16 +- custom_components/hacs/validate/hacsjson.py | 16 +- .../hacs/websocket/repositories.py | 447 +++++++++--------- .../hacs/websocket/repository.py | 10 +- 5 files changed, 338 insertions(+), 246 deletions(-) diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 33182bd6a27..0c735346e5e 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -228,7 +228,7 @@ class HacsManifest: name: str = None persistent_directory: str = None render_readme: bool = False - supported_languages: list[str] = [] # Supported README languages (e.g., ["de", "fr", "es"]) + content_languages: list[str] = [] zip_release: bool = False def to_dict(self): @@ -251,7 +251,7 @@ def from_dict(manifest: dict): for key, value in manifest_data.manifest.items(): if key == "country" and isinstance(value, str): setattr(manifest_data, key, [value]) - elif key == "supported_languages": + elif key == "content_languages": if isinstance(value, str): setattr(manifest_data, key, [value.lower()]) elif isinstance(value, list): @@ -273,7 +273,7 @@ def update_data(self, data: dict) -> None: setattr(self, key, [value]) else: setattr(self, key, value) - elif key == "supported_languages": + elif key == "content_languages": if isinstance(value, str): setattr(self, key, [value.lower()]) elif isinstance(value, list): @@ -788,14 +788,14 @@ async def async_get_info_file_contents_with_language( language = None else: if ( - self.repository_manifest.supported_languages - and language not in self.repository_manifest.supported_languages + self.repository_manifest.content_languages + and language not in self.repository_manifest.content_languages ): self.logger.debug( - "%s Language '%s' not in supported_languages %s, using README.md", + "%s Language '%s' not in content_languages %s, using README.md", self.string, language, - self.repository_manifest.supported_languages, + self.repository_manifest.content_languages, ) language = None @@ -803,7 +803,7 @@ async def async_get_info_file_contents_with_language( return await self.async_get_info_file_contents(version=version) readme_path = f"README.{language}.md" - + possible_paths = [ f"README.{language}.md", f"README.{language.upper()}.md", @@ -812,7 +812,7 @@ async def async_get_info_file_contents_with_language( f"README.{language}.MD", f"README.{language.upper()}.MD", ] - + found_path = None for path in possible_paths: if path in self.treefiles: @@ -831,8 +831,7 @@ async def async_get_info_file_contents_with_language( found_path, e, ) - - # Fallback to standard README.md + self.logger.debug( "%s Language-specific README %s not found, using README.md", self.string, @@ -840,6 +839,80 @@ async def async_get_info_file_contents_with_language( ) return await self.async_get_info_file_contents(version=version) + async def async_get_description_with_language( + self, *, language: str | None = None, version: str | None = None, **kwargs + ) -> str: + """Get the repository description with language support. + + Args: + language: Optional language code (e.g., "de", "en", "fr") + version: Optional version/ref to get the file from + + Returns: + Description as string, falls back to default GitHub description if not found + """ + if not language or language == "en": + return self.data.description + + language = language.split("-")[0].lower() if "-" in language else language.lower() + + if not language.isalpha() or len(language) != 2: + self.logger.debug( + "%s Invalid language code: %s, using default description", + self.string, + language, + ) + return self.data.description + + if ( + self.repository_manifest.content_languages + and language not in self.repository_manifest.content_languages + ): + self.logger.debug( + "%s Language '%s' not in content_languages %s, using default description", + self.string, + language, + self.repository_manifest.content_languages, + ) + return self.data.description + + description_path = f"DESCRIPTION.{language}.txt" + + possible_paths = [ + f"DESCRIPTION.{language}.txt", + f"DESCRIPTION.{language.upper()}.txt", + f"description.{language}.txt", + f"description.{language.upper()}.txt", + f"DESCRIPTION.{language}.TXT", + f"DESCRIPTION.{language.upper()}.TXT", + ] + + found_path = None + for path in possible_paths: + if path in self.treefiles: + found_path = path + break + + if found_path: + try: + content = await self.get_documentation(filename=found_path, version=version) + if content: + return content.strip() + except Exception as e: + self.logger.debug( + "%s Error loading description file %s: %s, using default description", + self.string, + found_path, + e, + ) + + self.logger.debug( + "%s Language-specific description %s not found, using default description", + self.string, + description_path, + ) + return self.data.description + def remove(self) -> None: """Run remove tasks.""" if self.hacs.repositories.is_registered(repository_id=str(self.data.id)): diff --git a/custom_components/hacs/utils/validate.py b/custom_components/hacs/utils/validate.py index 768068ceda3..d626038fcab 100644 --- a/custom_components/hacs/utils/validate.py +++ b/custom_components/hacs/utils/validate.py @@ -43,34 +43,34 @@ def _country_validator(values) -> list[str]: return countries -def _supported_languages_validator(values) -> list[str]: - """Custom supported_languages validator.""" +def _content_languages_validator(values) -> list[str]: + """Custom content_languages validator.""" if values is None: return [] - + languages = [] if isinstance(values, str): if not values.isalpha() or len(values) != 2: raise vol.Invalid( f"Language code '{values}' must be a 2-letter alphabetic code (e.g., 'de', 'fr', 'es').", - path=["supported_languages"], + path=["content_languages"], ) languages.append(values.lower()) elif isinstance(values, list): for value in values: if not isinstance(value, str): raise vol.Invalid( - f"Language code '{value}' is not a string.", path=["supported_languages"] + f"Language code '{value}' is not a string.", path=["content_languages"] ) if not value.isalpha() or len(value) != 2: raise vol.Invalid( f"Language code '{value}' must be a 2-letter alphabetic code (e.g., 'de', 'fr', 'es').", - path=["supported_languages"], + path=["content_languages"], ) languages.append(value.lower()) else: raise vol.Invalid( - f"Value '{values}' is not a string or list.", path=["supported_languages"] + f"Value '{values}' is not a string or list.", path=["content_languages"] ) return languages @@ -86,7 +86,7 @@ def _supported_languages_validator(values) -> list[str]: vol.Optional("homeassistant"): str, vol.Optional("persistent_directory"): str, vol.Optional("render_readme"): bool, - vol.Optional("supported_languages"): _supported_languages_validator, + vol.Optional("content_languages"): _content_languages_validator, vol.Optional("zip_release"): bool, vol.Required("name"): str, }, diff --git a/custom_components/hacs/validate/hacsjson.py b/custom_components/hacs/validate/hacsjson.py index 7c245dbbb99..8f3608ed684 100644 --- a/custom_components/hacs/validate/hacsjson.py +++ b/custom_components/hacs/validate/hacsjson.py @@ -43,17 +43,17 @@ async def async_validate(self) -> None: if hacsjson.zip_release and not hacsjson.filename: raise ValidationException("zip_release is True, but filename is not set") - if hacsjson.supported_languages: + if hacsjson.content_languages: tree_files = [x.filename for x in self.repository.tree] missing_readmes = [] invalid_languages = [] - for lang in hacsjson.supported_languages: + for lang in hacsjson.content_languages: if not lang.isalpha() or len(lang) != 2: invalid_languages.append(lang) continue - + readme_path = f"README.{lang}.md" - found = False + readme_found = False for possible_path in [ readme_path, f"README.{lang.upper()}.md", @@ -63,18 +63,18 @@ async def async_validate(self) -> None: f"README.{lang.upper()}.MD", ]: if possible_path in tree_files: - found = True + readme_found = True break - if not found: + if not readme_found: missing_readmes.append(lang) if invalid_languages: raise ValidationException( - f"supported_languages contains invalid language codes {invalid_languages}. " + f"content_languages contains invalid language codes {invalid_languages}. " f"Language codes must be 2-letter alphabetic codes (e.g., 'de', 'fr', 'es')." ) if missing_readmes: raise ValidationException( - f"supported_languages declares languages {missing_readmes}, " + f"content_languages declares languages {missing_readmes}, " f"but corresponding README files (README.{{lang}}.md) were not found in the repository." ) \ No newline at end of file diff --git a/custom_components/hacs/websocket/repositories.py b/custom_components/hacs/websocket/repositories.py index 879f68af8da..b58dd62f265 100644 --- a/custom_components/hacs/websocket/repositories.py +++ b/custom_components/hacs/websocket/repositories.py @@ -1,216 +1,231 @@ -"""Register info websocket commands.""" - -from __future__ import annotations - -import sys -from typing import TYPE_CHECKING, Any - -from homeassistant.components import websocket_api -import homeassistant.helpers.config_validation as cv -import voluptuous as vol - -from custom_components.hacs.utils import regex - -from ..const import DOMAIN -from ..enums import HacsDispatchEvent - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - - from ..base import HacsBase - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/list", - vol.Optional("categories"): [str], - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_list( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """List repositories.""" - hacs: HacsBase = hass.data.get(DOMAIN) - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - { - "authors": repo.data.authors, - "available_version": repo.display_available_version, - "installed_version": repo.display_installed_version, - "config_flow": repo.data.config_flow, - "can_download": repo.can_download, - "category": repo.data.category, - "country": repo.repository_manifest.country, - "custom": not hacs.repositories.is_default(str(repo.data.id)), - "description": repo.data.description, - "domain": repo.data.domain, - "downloads": repo.data.downloads, - "file_name": repo.data.file_name, - "full_name": repo.data.full_name, - "hide": repo.data.hide, - "homeassistant": repo.repository_manifest.homeassistant, - "id": repo.data.id, - "installed": repo.data.installed, - "last_updated": repo.data.last_updated, - "local_path": repo.content.path.local, - "name": repo.display_name, - "new": repo.data.new, - "pending_upgrade": repo.pending_update, - "stars": repo.data.stargazers_count, - "state": repo.state, - "status": repo.display_status, - "topics": repo.data.topics, - } - for repo in hacs.repositories.list_all - if repo.data.category in msg.get("categories", hacs.common.categories) - and not repo.ignored_by_country_configuration - and repo.data.last_fetched - ], - ) - ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/clear_new", - vol.Optional("categories"): cv.ensure_list, - vol.Optional("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_clear_new( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Clear new repositories for specific categories.""" - hacs: HacsBase = hass.data.get(DOMAIN) - - if repo := msg.get("repository"): - repository = hacs.repositories.get_by_id(repo) - repository.data.new = False - - else: - for repo in hacs.repositories.list_all: - if repo.data.new and repo.data.category in msg.get("categories", []): - hacs.log.debug( - "Clearing new flag from '%s'", - repo.data.full_name, - ) - repo.data.new = False - hacs.async_dispatch(HacsDispatchEvent.REPOSITORY, {}) - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"])) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/removed", - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_removed( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Get information about removed repositories.""" - hacs: HacsBase = hass.data.get(DOMAIN) - content = [] - for repo in hacs.repositories.list_removed: - if repo.repository not in hacs.common.ignored_repositories: - content.append(repo.to_json()) - connection.send_message(websocket_api.result_message(msg["id"], content)) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/add", - vol.Required("repository"): cv.string, - vol.Required("category"): vol.Lower, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_add( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Add custom repositoriy.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = regex.extract_repository_from_url(msg["repository"]) - category = msg["category"] - - if repository is None: - return - - if repository in hacs.common.skip: - hacs.common.skip.remove(repository) - - if renamed := hacs.common.renamed_repositories.get(repository): - repository = renamed - - if category not in hacs.common.categories: - hacs.log.error("%s is not a valid category for %s", category, repository) - - elif not hacs.repositories.get_by_full_name(repository): - try: - await hacs.async_register_repository( - repository_full_name=repository, - category=category, - ) - - except ( - BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except - ) as exception: - hacs.async_dispatch( - HacsDispatchEvent.ERROR, - { - "action": "add_repository", - "exception": str(sys.exc_info()[0].__name__), - "message": str(exception), - }, - ) - - else: - hacs.async_dispatch( - HacsDispatchEvent.ERROR, - { - "action": "add_repository", - "message": f"Repository '{repository}' exists in the store.", - }, - ) - - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/remove", - vol.Required("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_remove( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Remove custom repositoriy.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - repository.remove() - await hacs.data.async_write() - - connection.send_message(websocket_api.result_message(msg["id"], {})) +"""Register info websocket commands.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any + +from homeassistant.components import websocket_api +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from custom_components.hacs.utils import regex + +from ..const import DOMAIN +from ..enums import HacsDispatchEvent + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from ..base import HacsBase + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/list", + vol.Optional("categories"): [str], + vol.Optional("language"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List repositories.""" + hacs: HacsBase = hass.data.get(DOMAIN) + language = msg.get("language") + + async def get_description(repo): + """Get description with language support if language is provided.""" + if language: + return await repo.async_get_description_with_language(language=language) + return repo.data.description + + repositories_data = [] + for repo in hacs.repositories.list_all: + if ( + repo.data.category in msg.get("categories", hacs.common.categories) + and not repo.ignored_by_country_configuration + and repo.data.last_fetched + ): + description = await get_description(repo) + repositories_data.append( + { + "authors": repo.data.authors, + "available_version": repo.display_available_version, + "installed_version": repo.display_installed_version, + "config_flow": repo.data.config_flow, + "can_download": repo.can_download, + "category": repo.data.category, + "country": repo.repository_manifest.country, + "custom": not hacs.repositories.is_default(str(repo.data.id)), + "description": description, + "domain": repo.data.domain, + "downloads": repo.data.downloads, + "file_name": repo.data.file_name, + "full_name": repo.data.full_name, + "hide": repo.data.hide, + "homeassistant": repo.repository_manifest.homeassistant, + "id": repo.data.id, + "installed": repo.data.installed, + "last_updated": repo.data.last_updated, + "local_path": repo.content.path.local, + "name": repo.display_name, + "new": repo.data.new, + "pending_upgrade": repo.pending_update, + "stars": repo.data.stargazers_count, + "state": repo.state, + "status": repo.display_status, + "topics": repo.data.topics, + } + ) + + connection.send_message( + websocket_api.result_message( + msg["id"], + repositories_data, + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/clear_new", + vol.Optional("categories"): cv.ensure_list, + vol.Optional("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_clear_new( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Clear new repositories for specific categories.""" + hacs: HacsBase = hass.data.get(DOMAIN) + + if repo := msg.get("repository"): + repository = hacs.repositories.get_by_id(repo) + repository.data.new = False + + else: + for repo in hacs.repositories.list_all: + if repo.data.new and repo.data.category in msg.get("categories", []): + hacs.log.debug( + "Clearing new flag from '%s'", + repo.data.full_name, + ) + repo.data.new = False + hacs.async_dispatch(HacsDispatchEvent.REPOSITORY, {}) + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"])) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/removed", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_removed( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get information about removed repositories.""" + hacs: HacsBase = hass.data.get(DOMAIN) + content = [] + for repo in hacs.repositories.list_removed: + if repo.repository not in hacs.common.ignored_repositories: + content.append(repo.to_json()) + connection.send_message(websocket_api.result_message(msg["id"], content)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/add", + vol.Required("repository"): cv.string, + vol.Required("category"): vol.Lower, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_add( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Add custom repositoriy.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = regex.extract_repository_from_url(msg["repository"]) + category = msg["category"] + + if repository is None: + return + + if repository in hacs.common.skip: + hacs.common.skip.remove(repository) + + if renamed := hacs.common.renamed_repositories.get(repository): + repository = renamed + + if category not in hacs.common.categories: + hacs.log.error("%s is not a valid category for %s", category, repository) + + elif not hacs.repositories.get_by_full_name(repository): + try: + await hacs.async_register_repository( + repository_full_name=repository, + category=category, + ) + + except ( + BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except + ) as exception: + hacs.async_dispatch( + HacsDispatchEvent.ERROR, + { + "action": "add_repository", + "exception": str(sys.exc_info()[0].__name__), + "message": str(exception), + }, + ) + + else: + hacs.async_dispatch( + HacsDispatchEvent.ERROR, + { + "action": "add_repository", + "message": f"Repository '{repository}' exists in the store.", + }, + ) + + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/remove", + vol.Required("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Remove custom repositoriy.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + repository.remove() + await hacs.data.async_write() + + connection.send_message(websocket_api.result_message(msg["id"], {})) diff --git a/custom_components/hacs/websocket/repository.py b/custom_components/hacs/websocket/repository.py index 8927689b347..66f3f665ff4 100644 --- a/custom_components/hacs/websocket/repository.py +++ b/custom_components/hacs/websocket/repository.py @@ -23,7 +23,7 @@ { vol.Required("type"): "hacs/repository/info", vol.Required("repository_id"): str, - vol.Optional("language"): str, # Optional language code (e.g., "de", "en", "fr") + vol.Optional("language"): str, } ) @websocket_api.require_admin @@ -36,7 +36,7 @@ async def hacs_repository_info( """Return information about a repository.""" hacs: HacsBase = hass.data.get(DOMAIN) repository_id = msg["repository_id"] - language = msg.get("language") # Optional: language code + language = msg.get("language") repository = hacs.repositories.get_by_id(repository_id) if repository is None: connection.send_error( @@ -58,10 +58,14 @@ async def hacs_repository_info( await hacs.data.async_write() additional_info = repository.additional_info + description = repository.data.description if language: additional_info = await repository.async_get_info_file_contents_with_language( language=language ) + description = await repository.async_get_description_with_language( + language=language + ) connection.send_message( websocket_api.result_message( @@ -77,7 +81,7 @@ async def hacs_repository_info( "country": repository.repository_manifest.country, "custom": not hacs.repositories.is_default(str(repository.data.id)), "default_branch": repository.data.default_branch, - "description": repository.data.description, + "description": description, "domain": repository.data.domain, "downloads": repository.data.downloads, "file_name": repository.data.file_name, From 0ae7817aad06c16cdd620b072b7c8f71d0f73da0 Mon Sep 17 00:00:00 2001 From: rosch100 Date: Tue, 2 Dec 2025 14:53:47 +0100 Subject: [PATCH 09/10] Add PR description: Multilingual README and description support --- PULL_REQUEST.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 PULL_REQUEST.md diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 00000000000..3747e107149 --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,48 @@ +# Add Multilingual README and Description Support + +## Summary + +This PR adds support for multilingual README files and repository descriptions in HACS. Users will automatically see content in their Home Assistant language setting if available, with fallback to default English content. + +## Changes + +1. **Multilingual README Support** + - Added `async_get_info_file_contents_with_language()` method + - Supports `README.{language_code}.md` files (e.g., `README.de.md`, `README.fr.md`) + - Automatic language detection with fallback to `README.md` + +2. **Multilingual Description Support** + - Added `async_get_description_with_language()` method + - Supports `DESCRIPTION.{language_code}.txt` files (e.g., `DESCRIPTION.de.txt`, `DESCRIPTION.fr.txt`) + - Falls back to GitHub repository description if language-specific file not found + +3. **Manifest Updates** + - Renamed `supported_languages` to `content_languages` in `hacs.json` manifest + - Updated validator to use `content_languages` key + - Validates language codes and checks for corresponding README files + +4. **WebSocket Handler Updates** + - Extended `hacs/repository/info` to use language for both README and descriptions + - Extended `hacs/repositories/list` to support language parameter for descriptions + +## Related PRs + +- **Frontend PR:** https://github.com/hacs/frontend/pull/XXX +- **Documentation PR:** https://github.com/hacs/documentation/pull/660 + +## Checklist + +- [x] Code follows project style guidelines +- [x] Changes are backward compatible +- [x] Code tested locally +- [x] Validators updated +- [x] WebSocket handlers updated + +## Notes + +- Repository maintainers can provide multilingual content using: + - `README.{language_code}.md` for README files + - `DESCRIPTION.{language_code}.txt` for repository descriptions +- Language codes must be 2-letter ISO 639-1 codes (e.g., `de`, `fr`, `es`) +- The `content_languages` key in `hacs.json` can optionally declare supported languages for validation + From b22ee2b6bf87b21b220fb2ea8a83092ba32715dd Mon Sep 17 00:00:00 2001 From: rosch100 Date: Tue, 2 Dec 2025 15:35:26 +0100 Subject: [PATCH 10/10] refactor: Remove multilingual description support - Remove async_get_description_with_language() method - Remove language parameter from hacs/repositories/list WebSocket command - Remove description language handling from hacs/repository/info - Update PR description to reflect README-only support --- PULL_REQUEST.md | 20 ++--- custom_components/hacs/repositories/base.py | 74 ------------------- .../hacs/websocket/repositories.py | 11 +-- .../hacs/websocket/repository.py | 6 +- 4 files changed, 8 insertions(+), 103 deletions(-) diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md index 3747e107149..6d3cf0b1ed7 100644 --- a/PULL_REQUEST.md +++ b/PULL_REQUEST.md @@ -1,8 +1,8 @@ -# Add Multilingual README and Description Support +# Add Multilingual README Support ## Summary -This PR adds support for multilingual README files and repository descriptions in HACS. Users will automatically see content in their Home Assistant language setting if available, with fallback to default English content. +This PR adds support for multilingual README files in HACS. Users will automatically see content in their Home Assistant language setting if available, with fallback to default English content. ## Changes @@ -11,19 +11,13 @@ This PR adds support for multilingual README files and repository descriptions i - Supports `README.{language_code}.md` files (e.g., `README.de.md`, `README.fr.md`) - Automatic language detection with fallback to `README.md` -2. **Multilingual Description Support** - - Added `async_get_description_with_language()` method - - Supports `DESCRIPTION.{language_code}.txt` files (e.g., `DESCRIPTION.de.txt`, `DESCRIPTION.fr.txt`) - - Falls back to GitHub repository description if language-specific file not found - -3. **Manifest Updates** +2. **Manifest Updates** - Renamed `supported_languages` to `content_languages` in `hacs.json` manifest - Updated validator to use `content_languages` key - Validates language codes and checks for corresponding README files -4. **WebSocket Handler Updates** - - Extended `hacs/repository/info` to use language for both README and descriptions - - Extended `hacs/repositories/list` to support language parameter for descriptions +3. **WebSocket Handler Updates** + - Extended `hacs/repository/info` to use language parameter for README content ## Related PRs @@ -40,9 +34,7 @@ This PR adds support for multilingual README files and repository descriptions i ## Notes -- Repository maintainers can provide multilingual content using: - - `README.{language_code}.md` for README files - - `DESCRIPTION.{language_code}.txt` for repository descriptions +- Repository maintainers can provide multilingual README files using `README.{language_code}.md` (e.g., `README.de.md`, `README.fr.md`) - Language codes must be 2-letter ISO 639-1 codes (e.g., `de`, `fr`, `es`) - The `content_languages` key in `hacs.json` can optionally declare supported languages for validation diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 0c735346e5e..0833ba958bd 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -839,80 +839,6 @@ async def async_get_info_file_contents_with_language( ) return await self.async_get_info_file_contents(version=version) - async def async_get_description_with_language( - self, *, language: str | None = None, version: str | None = None, **kwargs - ) -> str: - """Get the repository description with language support. - - Args: - language: Optional language code (e.g., "de", "en", "fr") - version: Optional version/ref to get the file from - - Returns: - Description as string, falls back to default GitHub description if not found - """ - if not language or language == "en": - return self.data.description - - language = language.split("-")[0].lower() if "-" in language else language.lower() - - if not language.isalpha() or len(language) != 2: - self.logger.debug( - "%s Invalid language code: %s, using default description", - self.string, - language, - ) - return self.data.description - - if ( - self.repository_manifest.content_languages - and language not in self.repository_manifest.content_languages - ): - self.logger.debug( - "%s Language '%s' not in content_languages %s, using default description", - self.string, - language, - self.repository_manifest.content_languages, - ) - return self.data.description - - description_path = f"DESCRIPTION.{language}.txt" - - possible_paths = [ - f"DESCRIPTION.{language}.txt", - f"DESCRIPTION.{language.upper()}.txt", - f"description.{language}.txt", - f"description.{language.upper()}.txt", - f"DESCRIPTION.{language}.TXT", - f"DESCRIPTION.{language.upper()}.TXT", - ] - - found_path = None - for path in possible_paths: - if path in self.treefiles: - found_path = path - break - - if found_path: - try: - content = await self.get_documentation(filename=found_path, version=version) - if content: - return content.strip() - except Exception as e: - self.logger.debug( - "%s Error loading description file %s: %s, using default description", - self.string, - found_path, - e, - ) - - self.logger.debug( - "%s Language-specific description %s not found, using default description", - self.string, - description_path, - ) - return self.data.description - def remove(self) -> None: """Run remove tasks.""" if self.hacs.repositories.is_registered(repository_id=str(self.data.id)): diff --git a/custom_components/hacs/websocket/repositories.py b/custom_components/hacs/websocket/repositories.py index b58dd62f265..4d481c97152 100644 --- a/custom_components/hacs/websocket/repositories.py +++ b/custom_components/hacs/websocket/repositories.py @@ -24,7 +24,6 @@ { vol.Required("type"): "hacs/repositories/list", vol.Optional("categories"): [str], - vol.Optional("language"): str, } ) @websocket_api.require_admin @@ -36,13 +35,6 @@ async def hacs_repositories_list( ) -> None: """List repositories.""" hacs: HacsBase = hass.data.get(DOMAIN) - language = msg.get("language") - - async def get_description(repo): - """Get description with language support if language is provided.""" - if language: - return await repo.async_get_description_with_language(language=language) - return repo.data.description repositories_data = [] for repo in hacs.repositories.list_all: @@ -51,7 +43,6 @@ async def get_description(repo): and not repo.ignored_by_country_configuration and repo.data.last_fetched ): - description = await get_description(repo) repositories_data.append( { "authors": repo.data.authors, @@ -62,7 +53,7 @@ async def get_description(repo): "category": repo.data.category, "country": repo.repository_manifest.country, "custom": not hacs.repositories.is_default(str(repo.data.id)), - "description": description, + "description": repo.data.description, "domain": repo.data.domain, "downloads": repo.data.downloads, "file_name": repo.data.file_name, diff --git a/custom_components/hacs/websocket/repository.py b/custom_components/hacs/websocket/repository.py index 66f3f665ff4..c1a5902914e 100644 --- a/custom_components/hacs/websocket/repository.py +++ b/custom_components/hacs/websocket/repository.py @@ -58,14 +58,10 @@ async def hacs_repository_info( await hacs.data.async_write() additional_info = repository.additional_info - description = repository.data.description if language: additional_info = await repository.async_get_info_file_contents_with_language( language=language ) - description = await repository.async_get_description_with_language( - language=language - ) connection.send_message( websocket_api.result_message( @@ -81,7 +77,7 @@ async def hacs_repository_info( "country": repository.repository_manifest.country, "custom": not hacs.repositories.is_default(str(repository.data.id)), "default_branch": repository.data.default_branch, - "description": description, + "description": repository.data.description, "domain": repository.data.domain, "downloads": repository.data.downloads, "file_name": repository.data.file_name,