""" This is a separate library that overrides the extension_draw_item method from Blender extensions list display. The original code is in the bl_extension_ui.py file in the Blender source code. The override library can be placed in multiple addons, and the override should happen only once. The override is done by replacing the original method with the new one, and backing up the original method. The original method is then called from the new method, with the same arguments, but with the new code added. """ import json import os import time import logging from . import icons import bpy import bl_pkg.bl_extension_ui as exui from bpy.props import IntProperty, StringProperty from bpy.types import Operator EXTENSIONS_API_URL = "https://www.blenderkit.com/api/v1/extensions/" bk_logger = logging.getLogger(__name__) # --- New Modal Operator --- class BK_OT_buy_extension_and_watch(Operator): """Opens URL to buy extension and starts a modal timer to refresh repo periodically.""" bl_idname = "bk.buy_extension_and_watch" bl_label = "Buy Extension Online and Watch" bl_options = {"REGISTER", "UNDO"} url: StringProperty( name="URL", description="Website URL to open", ) repo_index: IntProperty( name="Repository Index", description="Index of the repository to refresh", default=-1, ) _timer = None _last_refresh_time = 0 _start_time = 0 _refresh_interval = 60 # seconds _max_duration = 300 # seconds (5 minutes timeout) def execute(self, context): if not self.url: self.report({"ERROR"}, "No URL specified.") return {"CANCELLED"} if self.repo_index == -1: self.report({"ERROR"}, "No repository index specified.") return {"CANCELLED"} # Open the URL try: bpy.ops.wm.url_open(url=self.url) bk_logger.info("Opening buy URL: %s.", self.url) except Exception as e: self.report({"ERROR"}, f"Could not open URL: {e}") # Don't cancel, maybe the user still wants the refresh? # Decide if you want modal to continue even if URL fails # Add modal handler and timer wm = context.window_manager self._timer = wm.event_timer_add( 1.0, window=context.window ) # Check every second wm.modal_handler_add(self) self._start_time = time.time() self._last_refresh_time = ( self._start_time ) # Initialize to avoid immediate refresh bk_logger.info( "Started watching repository index %s for updates.", self.repo_index ) if context and context.area: context.area.tag_redraw() # Update UI to show operator is running if needed return {"RUNNING_MODAL"} def modal(self, context, event): current_time = time.time() # --- Exit Conditions --- # 1. User closed Preferences or changed area if context.area is None or context.area.type != "PREFERENCES": bk_logger.info("Preferences window closed or changed, stopping watcher.") self.cancel(context) return {"CANCELLED"} # 2. Timeout if current_time - self._start_time > self._max_duration: bk_logger.info("Watcher timed out, stopping.") self.cancel(context) return {"CANCELLED"} # 3. User cancellation if event.type in {"RIGHTMOUSE", "ESC"}: bk_logger.info("Watcher cancelled by user.") self.cancel(context) return {"CANCELLED"} # --- Timer Logic --- if event.type == "TIMER": # Check if refresh interval has passed if current_time - self._last_refresh_time >= self._refresh_interval: bk_logger.info( "Refresh interval reached, attempting sync for repo index %s...", self.repo_index, ) try: # Check if repo still exists at that index if self.repo_index < len(context.preferences.extensions.repos): bpy.ops.extensions.repo_sync(repo_index=self.repo_index) bk_logger.info( "repo_sync called for index %s.", self.repo_index ) else: bk_logger.info( "Repository index %s no longer valid.", self.repo_index ) # Optionally cancel here if repo is gone except: # This might fail if another operation is in progress bk_logger.exception("extensions.repo_sync failed.") finally: self._last_refresh_time = ( current_time # Reset timer regardless of success ) return {"PASS_THROUGH"} # Pass other events through def cancel(self, context): if self._timer: wm = context.window_manager wm.event_timer_remove(self._timer) self._timer = None bk_logger.info("Watcher timer removed.") if context and context.area: context.area.tag_redraw() # Update UI # --- End New Modal Operator --- def redraw_preferences_once(): """Tag the redraw on the Blender preferences. Meant to be registered as a timer, runs just once. """ for window in bpy.context.window_manager.windows: screen = window.screen if not screen: continue for area in screen.areas: if area.type != "PREFERENCES": continue for region in area.regions: if region.type in {"UI", "WINDOW"}: region.tag_redraw() return None def extension_draw_item_blenderkit( layout, *, pkg_id, # `str` item_local, # `PkgManifest_Normalized | None` item_remote, # `PkgManifest_Normalized | None` is_enabled, # `bool` is_outdated, # `bool` show, # `bool`. mark, # `bool | None`. # General vars. repo_index, # `int` repo_item, # `RepoItem` operation_in_progress, # `bool` extensions_warnings, # `dict[str, list[str]]` show_developer_ui, # `bool` ): ### BlenderKit cache code # Ensure cache is up-to-date before drawing cache_reloaded = ensure_repo_cache() if cache_reloaded: # If cache was just reloaded, tag UI for redraw # as UILayout doesn't have tag_redraw we call a custom function if bpy.app.timers.is_registered(redraw_preferences_once): bpy.app.timers.unregister(redraw_preferences_once) bpy.app.timers.register(redraw_preferences_once, first_interval=0.01) bk_logger.info("Cache reloaded, tagging preferences for redraw.") # check if the cache is already in the window manager if "blenderkit_extensions_repo_cache" not in bpy.context.window_manager: # Log if cache is missing after trying to ensure it bk_logger.info( "Extension cache not available in window_manager after ensure_repo_cache call." ) # Optionally draw a minimal representation or return early to avoid errors # For now, just return to avoid potential errors accessing bk_ext_cache return bk_ext_cache = bpy.context.window_manager["blenderkit_extensions_repo_cache"] bk_cache_pkg = bk_ext_cache.get(pkg_id[:32], None) ### end of BlenderKit cache code item = item_local or item_remote is_installed = item_local is not None has_remote = repo_item.remote_url != "" if item_remote is not None: pkg_block = item_remote.block else: pkg_block = None if is_enabled: item_warnings = extensions_warnings.get( exui.pkg_repo_module_prefix(repo_item) + pkg_id, [] ) else: item_warnings = [] # Left align so the operator text isn't centered. colsub = layout.column() row = colsub.row(align=True) if show: props = row.operator( "extensions.package_show_clear", text="", icon="DOWNARROW_HLT", emboss=False ) else: props = row.operator( "extensions.package_show_set", text="", icon="RIGHTARROW", emboss=False ) props.pkg_id = pkg_id props.repo_index = repo_index if mark is not None: if mark: props = row.operator( "extensions.package_mark_clear", text="", icon="RADIOBUT_ON", emboss=False, ) else: props = row.operator( "extensions.package_mark_set", text="", icon="RADIOBUT_OFF", emboss=False, ) props.pkg_id = pkg_id props.repo_index = repo_index sub = row.row() sub.active = is_enabled # Without checking `is_enabled` here, there is no way for the user to know if an extension # is enabled or not, which is useful to show - when they may be considering removing/updating # extensions based on them being used or not. if pkg_block or item_warnings: sub.label(text=item.name, icon="ERROR", translate=False) else: sub.label(text=item.name, translate=False) # Add a top-level row so `row_right` can have a grayed out button/label # without graying out the menu item since# that is functional. row_right_toplevel = row.row(align=True) if operation_in_progress: row_right_toplevel.enabled = False row_right_toplevel.alignment = "RIGHT" row_right = row_right_toplevel.row() row_right.alignment = "RIGHT" if has_remote and (item_remote is not None): if pkg_block is not None: row_right.label(text="Blocked ") elif is_installed: if is_outdated: props = row_right.operator("extensions.package_install", text="Update") props.repo_index = repo_index props.pkg_id = pkg_id props.enable_on_install = is_enabled else: ### BlenderKit specific code # blenderkit logo icon pcoll = icons.icon_collections["main"] icon_value = pcoll["logo"].icon_id # row.label(text="", icon_value=icon_value) # only enable install for those for whom it's available if bk_cache_pkg is not None: # Free , purchased and subscribed add-ons, probably also private add-ons if bk_cache_pkg.get("can_download") is True: # if the addon is also for sale, it means the user purchased it and we write "install purchased" if bk_cache_pkg.get("is_for_sale") is True: props = row_right.operator( "extensions.package_install", text="Install purchased", icon_value=icon_value, ) else: props = row_right.operator( "extensions.package_install", text="Install", icon_value=icon_value, ) props.repo_index = repo_index props.pkg_id = pkg_id # Full plan addons elif not bk_cache_pkg.get("is_free") and not bk_cache_pkg.get( "is_for_sale" ): # open website to subscribe props = row_right.operator( "wm.url_open", text="Subscribe to Full Plan", icon_value=icon_value, ) props.url = "https://www.blenderkit.com/plans/pricing/" # Paid addons get a buy button and lead to their website link else: # Use the new modal operator props = row_right.operator( BK_OT_buy_extension_and_watch.bl_idname, # Use bl_idname text=f"Buy online ${bk_cache_pkg.get('base_price')}", icon_value=icon_value, ) props.url = bk_cache_pkg.get("website", "") # Pass URL props.repo_index = repo_index # Pass repo index ### end of BlenderKit specific code else: # Right space for alignment with the button. if has_remote and (item_remote is None): # There is a local item with no remote row_right.label(text="Orphan ") row_right.active = False row_right = row_right_toplevel.row(align=True) row_right.alignment = "RIGHT" row_right.separator() # NOTE: Keep space between any buttons and this menu to prevent stray clicks accidentally running install. # The separator is around together with the align to give some space while keeping the button and the menu # still close-by. Used `extension_path` so the menu can access "this" extension. row_right.context_string_set( "extension_path", "{:s}.{:s}".format(repo_item.module, pkg_id) ) row_right.menu("USERPREF_MT_extensions_item", text="", icon="DOWNARROW_HLT") if show: import os from bpy.app.translations import pgettext_iface as iface_ col = layout.column() row = col.row() row.active = is_enabled # The full tagline may be multiple lines (not yet supported by Blender's UI). row.label(text=" {:s}.".format(item.tagline), translate=False) col.separator(type="LINE") col_info = layout.column() col_info.active = is_enabled split = col_info.split(factor=0.15) col_a = split.column() col_b = split.column() col_a.alignment = "RIGHT" if pkg_block is not None: col_a.label(text="Blocked") col_b.label(text=pkg_block.reason, translate=False) if item_warnings: col_a.label(text="Warning") col_b.label(text=item_warnings[0]) if len(item_warnings) > 1: for value in item_warnings[1:]: col_a.label(text="") col_b.label(text=value) # pylint: disable-next=undefined-loop-variable if value := (item_remote or item_local).website: col_a.label(text="Website") col_b.split(factor=0.5).operator( "wm.url_open", text=exui.domain_extract_from_url(value), icon="URL", ).url = value del value if item.type == "add-on": col_a.label(text="Permissions") # WARNING: while this is documented to be a dict, old packages may contain a list of strings. # As it happens dictionary keys & list values both iterate over string, # however we will want to show the dictionary values eventually. if value := item.permissions: col_b.label( text=", ".join([iface_(x).title() for x in value]), translate=False ) else: col_b.label(text="No permissions specified") del value col_a.label(text="Maintainer") col_b.label(text=item.maintainer, translate=False) col_a.label(text="Version") if is_outdated: col_b.label( text=iface_("{:s} ({:s} available)").format( item.version, item_remote.version ), translate=False, ) else: col_b.label(text=item.version, translate=False) if has_remote and (item_remote is not None): col_a.label(text="Size") col_b.label( text=exui.size_as_fmt_string(item_remote.archive_size), translate=False ) col_a.label(text="License") col_b.label(text=item.license, translate=False) col_a.label(text="Repository") col_b.label(text=repo_item.name, translate=False) if is_installed: col_a.label(text="Path") col_b.label(text=os.path.join(repo_item.directory, pkg_id), translate=False) def extension_draw_item_override( layout, *, pkg_id, # `str` item_local, # `PkgManifest_Normalized | None` item_remote, # `PkgManifest_Normalized | None` is_enabled, # `bool` is_outdated, # `bool` show, # `bool`. mark, # `bool | None`. # General vars. repo_index, # `int` repo_item, # `RepoItem` operation_in_progress, # `bool` extensions_warnings, # `dict[str, list[str]]` show_developer_ui=False, # `bool` ): # filter by verification state, only for blenderkit repository if repo_item.remote_url == EXTENSIONS_API_URL: extension_draw_item_blenderkit( layout, pkg_id=pkg_id, item_local=item_local, item_remote=item_remote, is_enabled=is_enabled, is_outdated=is_outdated, show=show, mark=mark, repo_index=repo_index, repo_item=repo_item, operation_in_progress=operation_in_progress, extensions_warnings=extensions_warnings, show_developer_ui=show_developer_ui, ) return True # show developer ui only needs to be passed since blender 4.4 if bpy.app.version >= (4, 4): exui.extension_draw_item_original( layout, pkg_id=pkg_id, item_local=item_local, item_remote=item_remote, is_enabled=is_enabled, is_outdated=is_outdated, show=show, mark=mark, repo_index=repo_index, repo_item=repo_item, operation_in_progress=operation_in_progress, extensions_warnings=extensions_warnings, show_developer_ui=show_developer_ui, ) else: exui.extension_draw_item_original( layout, pkg_id=pkg_id, item_local=item_local, item_remote=item_remote, is_enabled=is_enabled, is_outdated=is_outdated, show=show, mark=mark, repo_index=repo_index, repo_item=repo_item, operation_in_progress=operation_in_progress, extensions_warnings=extensions_warnings, ) return True def override_draw_function(): if hasattr(exui, "extension_draw_item_original"): return False exui.extension_draw_item_original = exui.extension_draw_item exui.extension_draw_item = extension_draw_item_override return True def get_repository_by_url(url: str): """Get the repository by its remote URL, from registered blenderkit Extension repositories.""" for r in bpy.context.preferences.extensions.repos: if r.remote_url == url: return r return None def clear_repo_cache(): """Clear the repository cache.""" wm = bpy.context.window_manager cache_key = "blenderkit_extensions_repo_cache" if cache_key in wm: del wm[cache_key] def ensure_repo_cache(): r""" Reads the .json file blender stores in \extensions\www_blenderkit_com\.blender_ext and parses it to a dict from json, we can use it then for drawing purposes and have the extra data BlenderKit api provides. Checks the modification time of the cache file and reloads it if necessary. """ reloaded_flag = False # Track if we actually reloaded wm = bpy.context.window_manager cache_key = "blenderkit_extensions_repo_cache" mtime_key = "blenderkit_extensions_repo_cache_mtime" blenderkit_repository = get_repository_by_url(EXTENSIONS_API_URL) if blenderkit_repository is None: # If repo doesn't exist, clear cache if it exists in window manager if cache_key in wm: del wm[cache_key] bk_logger.info("Cleared stale extension cache for missing repository.") if mtime_key in wm: del wm[mtime_key] bk_logger.debug("Repository not found, exiting check.") return False # No repo, nothing loaded # get the path to the cache file which is in repository directory under /.blender_ext/index.json cache_file = os.path.join( blenderkit_repository.directory, ".blender_ext", "index.json" ) current_mtime = None try: if os.path.exists(cache_file): current_mtime = os.path.getmtime(cache_file) except OSError as e: # Handle potential race condition or permission issue bk_logger.exception("Could not get modification time for %s.", cache_file) # Clear cache if we can't verify its freshness? Safer approach. if cache_key in wm: del wm[cache_key] bk_logger.info("Cleared extension cache due to mtime access error.") if mtime_key in wm: del wm[mtime_key] return False # Error, nothing loaded stored_mtime = wm.get(mtime_key, None) # --- Determine if reload is needed --- should_reload = False if cache_key not in wm: if current_mtime is not None: # Only load if file actually exists should_reload = True # Cache doesn't exist, need initial load. else: # Cache doesn't exist and file doesn't exist/accessible. Fall through to check if we need to clear. pass elif current_mtime is None: # Cache exists in wm, but file is gone/inaccessible. Clear stale cache. del wm[cache_key] if mtime_key in wm: del wm[mtime_key] return False # Cleared stale cache, did not load new data elif cache_key not in wm and current_mtime is None: # Cache doesn't exist, and file doesn't exist. Nothing to do or load. return False elif ( cache_key in wm and (stored_mtime is None or stored_mtime != current_mtime) ) or ( cache_key not in wm and current_mtime is not None ): # Reload if cache exists and is outdated, OR if cache doesn't exist but file does should_reload = True # Cache exists but is outdated or missing mtime. if not should_reload: # Cache exists and is up-to-date return False # Nothing reloaded # --- (Re)Load cache --- try: with open(cache_file, "r", encoding="utf-8") as f: # Specify encoding data_str = f.read() data = json.loads(data_str) # store the data as a dict with keys being the package names (first 32 chars) new_cache = {} for pkg in data.get( "data", [] ): # Handle case where 'data' key might be missing if ( isinstance(pkg, dict) and "id" in pkg ): # Ensure pkg is a dict and 'id' key exists new_cache[pkg["id"][:32]] = pkg else: bk_logger.info("Skipping invalid package entry in cache: %s.", pkg) wm[cache_key] = new_cache wm[mtime_key] = current_mtime # Update mtime only on successful load reloaded_flag = True # Mark that we reloaded successfully except json.JSONDecodeError: bk_logger.warning( "Error decoding JSON from %s. Cache not loaded/updated.", cache_file ) # Clear potentially corrupt cache? Or leave old one? Clearing is safer. if cache_key in wm: del wm[cache_key] bk_logger.info("Cleared cache due to JSON error.") if mtime_key in wm: del wm[mtime_key] except Exception: bk_logger.exception("Error reading or processing cache file %s.", cache_file) # Clear potentially corrupt cache? if cache_key in wm: del wm[cache_key] bk_logger.info("Cleared cache due to file processing error.") if mtime_key in wm: del wm[mtime_key] return reloaded_flag # Return whether cache was actually reloaded def ensure_repo_order(): """Ensure order of repositories in Blender's preferences.""" # get the blenderkit repository blenderkit_repository = get_repository_by_url(EXTENSIONS_API_URL) if blenderkit_repository is None: return # get all repositories all_repos = bpy.context.preferences.extensions.repos # get all online repositories except blenderkit online_repos = [] # need to convert repos to dicts remove_online_repos = [] for r in all_repos: if r.remote_url != EXTENSIONS_API_URL and r.remote_url != "": repo_dict = { "name": r.name, "module": r.module, "use_remote_url": r.use_remote_url, "remote_url": r.remote_url, "use_sync_on_startup": r.use_sync_on_startup, "use_cache": r.use_cache, "use_access_token": r.use_access_token, "access_token": r.access_token, "use_custom_directory": r.use_custom_directory, "custom_directory": r.custom_directory, "enabled": r.enabled, } online_repos.append(repo_dict) remove_online_repos.append(r) # remove all online repositories except blenderkit for r in remove_online_repos: all_repos.remove(r) # add all other repositories back for r in online_repos: # complete list of properties of a repository: #'access_token', 'custom_directory', 'directory', 'enabled', 'module', 'name', 'remote_url', 'rna_type', 'source', 'use_access_token', 'use_cache', 'use_custom_directory', 'use_remote_url', 'use_sync_on_startup' new_repo = all_repos.new() new_repo.name = r["name"] new_repo.module = r["module"] new_repo.use_remote_url = r["use_remote_url"] new_repo.remote_url = r["remote_url"] new_repo.use_sync_on_startup = r["use_sync_on_startup"] new_repo.use_cache = r["use_cache"] new_repo.use_access_token = r["use_access_token"] new_repo.access_token = r["access_token"] new_repo.use_custom_directory = r["use_custom_directory"] new_repo.custom_directory = r["custom_directory"] new_repo.enabled = r["enabled"] def ensure_repository(api_key: str = ""): """Ensure that the blenderkit extensions repository is correctly added in Blender's preferences. If the repository is not present, it is added. If the repository is present, but the API key is not set, it is set. """ blenderkit_repository = get_repository_by_url(EXTENSIONS_API_URL) if blenderkit_repository is None: blenderkit_repository = bpy.context.preferences.extensions.repos.new() blenderkit_repository.name = "www.blenderkit.com" blenderkit_repository.module = "www_blenderkit_com" blenderkit_repository.use_remote_url = True blenderkit_repository.remote_url = EXTENSIONS_API_URL blenderkit_repository.use_sync_on_startup = True if api_key != "": blenderkit_repository.use_access_token = True blenderkit_repository.access_token = api_key else: # let's try to import blenderkit preferences and get the api key # try: user_preferences = bpy.context.preferences.addons[__package__].preferences api_key = user_preferences.api_key if api_key != "": blenderkit_repository.use_access_token = True blenderkit_repository.access_token = api_key else: # clear after logout blenderkit_repository.use_access_token = False blenderkit_repository.access_token = "" # pass # ensure_repo_order() ensure_repo_cache() def register(): ensure_repository() override_draw_function() bpy.utils.register_class(BK_OT_buy_extension_and_watch) # Register new operator def unregister(): exui.extension_draw_item = exui.extension_draw_item_original del exui.extension_draw_item_original bpy.utils.unregister_class(BK_OT_buy_extension_and_watch) # Unregister new operator