Files
blender-portable-repo/extensions/user_default/blenderkit/override_extension_draw.py
T
2026-03-17 14:58:51 -06:00

759 lines
28 KiB
Python

"""
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