1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENSE BLOCK #####
19 from blenderkit
import paths
, utils
, categories
, ui
, colors
, bkit_oauth
, version_checker
, tasks_queue
, rerequests
, \
20 resolutions
, image_utils
, ratings_utils
23 from bpy
.app
.handlers
import persistent
25 from bpy
.props
import ( # TODO only keep the ones actually used when cleaning
34 from bpy
.types
import (
42 import requests
, os
, random
55 bk_logger
= logging
.getLogger('blenderkit')
61 def check_errors(rdata
):
62 if rdata
.get('statusCode') and int(rdata
.get('statusCode')) > 299:
64 if rdata
.get('detail') == 'Invalid token.':
65 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
66 if user_preferences
.api_key
!= '':
67 if user_preferences
.enable_oauth
:
68 bkit_oauth
.refresh_token_thread()
69 return False, rdata
.get('detail')
70 return False, 'Use login panel to connect your profile.'
72 return False, rdata
.get('detail')
73 if rdata
.get('statusCode') is None and rdata
.get('results') is None:
74 return False, 'Connection error'
79 thumb_workers_sml
= []
80 thumb_workers_full
= []
81 thumb_sml_download_threads
= queue
.Queue()
82 thumb_full_download_threads
= queue
.Queue()
83 reports_queue
= queue
.Queue()
84 all_thumbs_loaded
= True
86 rtips
= ['Click or drag model or material in scene to link/append ',
87 "Please rate responsively and plentifully. This helps us distribute rewards to the authors.",
88 "Click on brushes to link them into scene.",
89 "All materials are free.",
90 "Storage for public assets is unlimited.",
91 "Locked models are available if you subscribe to Full plan.",
92 "Login to upload your own models, materials or brushes.",
93 "Use 'A' key over asset bar to search assets by same author.",
94 "Use 'W' key over asset bar to open Authors webpage.", ]
97 def refresh_token_timer():
98 ''' this timer gets run every time the token needs refresh. It refreshes tokens and also categories.'''
99 utils
.p('refresh timer')
100 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
102 categories
.load_categories()
104 return max(3600, user_preferences
.api_key_life
- 3600)
108 if not ad
.get('assetBaseId'):
110 ad
['assetBaseId'] = ad
['asset_base_id'] # this should stay ONLY for compatibility with older scenes
111 ad
['assetType'] = ad
['asset_type'] # this should stay ONLY for compatibility with older scenes
112 ad
['verificationStatus'] = ad
[
113 'verification_status'] # this should stay ONLY for compatibility with older scenes
115 ad
['author']['id'] = ad
['author_id'] # this should stay ONLY for compatibility with older scenes
116 ad
['canDownload'] = ad
['can_download'] # this should stay ONLY for compatibility with older scenes
117 except Exception as e
:
118 bk_logger
.error('BlenderKit failed to update older asset data')
122 def update_assets_data(): # updates assets data on scene load.
123 '''updates some properties that were changed on scenes with older assets.
124 The properties were mainly changed from snake_case to CamelCase to fit the data that is coming from the server.
133 for dtype
in datablocks
:
135 if block
.get('asset_data') != None:
136 update_ad(block
['asset_data'])
140 # 'assets rated',# assets rated stores only true/false, not asset data.
142 for s
in bpy
.data
.scenes
:
149 for asset_id
in d
.keys():
150 update_ad(d
[asset_id
])
151 # bpy.context.scene['assets used'][ad] = ad
154 def purge_search_results():
155 ''' clean up search results on save/load.'''
157 s
= bpy
.context
.scene
161 'search results orig',
163 asset_types
= ['model', 'material', 'scene', 'hdr', 'brush']
164 for at
in asset_types
:
165 sr_props
.append('bkit {at} search')
166 sr_props
.append('bkit {at} search orig')
167 for sr_prop
in sr_props
:
173 def scene_load(context
):
175 Loads categories , checks timers registration, and updates scene asset data.
176 Should (probably)also update asset data from server (after user consent)
178 wm
= bpy
.context
.window_manager
179 purge_search_results()
181 categories
.load_categories()
182 if not bpy
.app
.timers
.is_registered(refresh_token_timer
):
183 bpy
.app
.timers
.register(refresh_token_timer
, persistent
=True, first_interval
=36000)
187 def fetch_server_data():
188 ''' download categories , profile, and refresh token if needed.'''
189 if not bpy
.app
.background
:
190 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
191 api_key
= user_preferences
.api_key
192 # Only refresh new type of tokens(by length), and only one hour before the token timeouts.
193 if user_preferences
.enable_oauth
and \
194 len(user_preferences
.api_key
) < 38 and len(user_preferences
.api_key
) > 0 and \
195 user_preferences
.api_key_timeout
< time
.time() + 3600:
196 bkit_oauth
.refresh_token_thread()
197 if api_key
!= '' and bpy
.context
.window_manager
.get('bkit profile') == None:
199 if bpy
.context
.window_manager
.get('bkit_categories') is None:
200 categories
.fetch_categories_thread(api_key
, force
=False)
207 def check_clipboard():
209 Checks clipboard for an exact string containing asset ID.
210 The string is generated on www.blenderkit.com as for example here:
211 https://www.blenderkit.com/get-blenderkit/54ff5c85-2c73-49e9-ba80-aec18616a408/
214 # clipboard monitoring to search assets from web
215 if platform
.system() != 'Linux':
216 global last_clipboard
217 if bpy
.context
.window_manager
.clipboard
!= last_clipboard
:
218 last_clipboard
= bpy
.context
.window_manager
.clipboard
219 instr
= 'asset_base_id:'
220 # first check if contains asset id, then asset type
221 if last_clipboard
[:len(instr
)] == instr
:
222 atstr
= 'asset_type:'
223 ati
= last_clipboard
.find(atstr
)
224 # this only checks if the asset_type keyword is there but let's the keywords update function do the parsing.
226 search_props
= utils
.get_search_props()
227 search_props
.search_keywords
= last_clipboard
228 # don't run search after this - assigning to keywords runs the search_update function.
233 needed to generate some extra data in the result(by now)
236 r - search result, also called asset_data
238 scene
= bpy
.context
.scene
240 # TODO remove this fix when filesSize is fixed.
241 # this is a temporary fix for too big numbers from the server.
243 # r['filesSize'] = int(r['filesSize'] / 1024)
245 # utils.p('asset with no files-size')
246 asset_type
= r
['assetType']
247 if len(r
['files']) > 0: # TODO remove this condition so all assets are parsed.
250 r
['available_resolutions'] = []
252 durl
, tname
, small_tname
= '', '', ''
254 if r
['assetType'] == 'hdr':
255 tname
= paths
.extract_filename_from_url(r
['thumbnailLargeUrlNonsquared'])
257 tname
= paths
.extract_filename_from_url(r
['thumbnailMiddleUrl'])
258 small_tname
= paths
.extract_filename_from_url(r
['thumbnailSmallUrl'])
259 allthumbs
.append(tname
) # TODO just first thumb is used now.
260 # if r['fileType'] == 'thumbnail':
261 # tname = paths.extract_filename_from_url(f['fileThumbnailLarge'])
262 # small_tname = paths.extract_filename_from_url(f['fileThumbnail'])
263 # allthumbs.append(tname) # TODO just first thumb is used now.
266 # if f['fileType'] == 'thumbnail':
267 # tname = paths.extract_filename_from_url(f['fileThumbnailLarge'])
268 # small_tname = paths.extract_filename_from_url(f['fileThumbnail'])
269 # allthumbs.append(tname) # TODO just first thumb is used now.
271 if f
['fileType'] == 'blend':
272 durl
= f
['downloadUrl'].split('?')[0]
273 # fname = paths.extract_filename_from_url(f['filePath'])
275 if f
['fileType'].find('resolution') > -1:
276 r
['available_resolutions'].append(resolutions
.resolutions
[f
['fileType']])
278 # code for more thumbnails
280 # for i, t in enumerate(allthumbs):
281 # tdict['thumbnail_%i'] = t
283 r
['max_resolution'] = 0
284 if r
['available_resolutions']: # should check only for non-empty sequences
285 r
['max_resolution'] = max(r
['available_resolutions'])
287 # tooltip = generate_tooltip(r)
288 # for some reason, the id was still int on some occurances. investigate this.
289 r
['author']['id'] = str(r
['author']['id'])
291 # some helper props, but generally shouldn't be renaming/duplifiying original properties,
292 # so blender's data is same as on server.
293 asset_data
= {'thumbnail': tname
,
294 'thumbnail_small': small_tname
,
295 # 'tooltip': tooltip,
298 asset_data
['downloaded'] = 0
300 # parse extra params needed for blender here
301 params
= r
['dictParameters'] # utils.params_to_dict(r['parameters'])
303 if asset_type
== 'model':
304 if params
.get('boundBoxMinX') != None:
307 float(params
['boundBoxMinX']),
308 float(params
['boundBoxMinY']),
309 float(params
['boundBoxMinZ'])),
311 float(params
['boundBoxMaxX']),
312 float(params
['boundBoxMaxY']),
313 float(params
['boundBoxMaxZ']))
318 'bbox_min': (-.5, -.5, 0),
319 'bbox_max': (.5, .5, 1)
321 asset_data
.update(bbox
)
322 if asset_type
== 'material':
323 asset_data
['texture_size_meters'] = params
.get('textureSizeMeters', 1.0)
325 # asset_data.update(tdict)
327 au
= scene
.get('assets used', {})
329 scene
['assets used'] = au
330 if r
['assetBaseId'] in au
.keys():
331 asset_data
['downloaded'] = 100
332 # transcribe all urls already fetched from the server
333 r_previous
= au
[r
['assetBaseId']]
334 if r_previous
.get('files'):
335 for f
in r_previous
['files']:
337 for f1
in r
['files']:
338 if f1
['fileType'] == f
['fileType']:
341 # attempt to switch to use original data gradually, since the parsing as itself should become obsolete.
346 # @bpy.app.handlers.persistent
348 # this makes a first search after opening blender. showing latest assets.
349 # utils.p('timer search')
350 # utils.p('start search timer')
352 preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
353 if first_time
and not bpy
.app
.background
: # first time
356 if preferences
.show_on_start
:
357 # TODO here it should check if there are some results, and only open assetbar if this is the case, not search.
358 # if bpy.context.window_manager.get('search results') is None:
360 # preferences.first_run = False
361 if preferences
.tips_on_start
:
362 utils
.get_largest_area()
363 ui
.update_ui_size(ui
.active_area_pointer
, ui
.active_region_pointer
)
364 ui
.add_report(text
='BlenderKit Tip: ' + random
.choice(rtips
), timeout
=12, color
=colors
.GREEN
)
365 # utils.p('end search timer')
369 # if preferences.first_run:
371 # preferences.first_run = False
375 # finish loading thumbs from queues
376 global all_thumbs_loaded
377 if not all_thumbs_loaded
:
378 ui_props
= bpy
.context
.scene
.blenderkitUI
379 search_name
= f
'bkit {ui_props.asset_type.lower()} search'
380 wm
= bpy
.context
.window_manager
381 if wm
.get(search_name
) is not None:
383 for ri
, r
in enumerate(wm
[search_name
]):
384 if not r
.get('thumb_small_loaded'):
385 all_loaded
= all_loaded
and load_preview(r
, ri
)
386 all_thumbs_loaded
= all_loaded
388 global search_threads
389 if len(search_threads
) == 0:
390 # utils.p('end search timer')
391 props
= utils
.get_search_props()
392 props
.is_searching
= False
394 # don't do anything while dragging - this could switch asset during drag, and make results list length different,
395 # causing a lot of throuble.
396 if bpy
.context
.scene
.blenderkitUI
.dragging
:
397 # utils.p('end search timer')
401 for thread
in search_threads
:
402 # TODO this doesn't check all processes when one gets removed,
403 # but most of the time only one is running anyway
404 if not thread
[0].is_alive():
406 search_threads
.remove(thread
) #
407 icons_dir
= thread
[1]
408 scene
= bpy
.context
.scene
409 # these 2 lines should update the previews enum and set the first result as active.
410 wm
= bpy
.context
.window_manager
411 asset_type
= thread
[2]
413 props
= utils
.get_search_props()
414 search_name
= f
'bkit {asset_type} search'
416 if not thread
[0].params
.get('get_next'):
417 # wm[search_name] = []
421 for r
in wm
[search_name
]:
422 result_field
.append(r
.to_dict())
426 while not reports_queue
.empty():
427 props
.report
= str(reports_queue
.get())
428 # utils.p('end search timer')
432 rdata
= thread
[0].result
434 ok
, error
= check_errors(rdata
)
436 ui_props
= bpy
.context
.scene
.blenderkitUI
437 orig_len
= len(result_field
)
439 for ri
, r
in enumerate(rdata
['results']):
440 asset_data
= parse_result(r
)
441 if asset_data
!= None:
442 result_field
.append(asset_data
)
443 all_thumbs_loaded
= all_thumbs_loaded
and load_preview(asset_data
, ri
+ orig_len
)
445 # Get ratings from BlenderKit server
446 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
447 api_key
= user_preferences
.api_key
448 headers
= utils
.get_headers(api_key
)
449 if utils
.profile_is_validator():
450 for r
in rdata
['results']:
451 if ratings_utils
.get_rating_local(r
['id']) is None:
452 rating_thread
= threading
.Thread(target
=ratings_utils
.get_rating
, args
=([r
['id'], headers
]),
454 rating_thread
.start()
456 wm
[search_name
] = result_field
457 wm
['search results'] = result_field
459 # rdata=['results']=[]
460 wm
[search_name
+ ' orig'] = rdata
461 wm
['search results orig'] = rdata
463 if len(result_field
) < ui_props
.scrolloffset
or not (thread
[0].params
.get('get_next')):
465 ui_props
.scrolloffset
= 0
466 props
.search_error
= False
467 props
.report
= 'Found %i results. ' % (wm
['search results orig']['count'])
468 if len(wm
['search results']) == 0:
469 tasks_queue
.add_task((ui
.add_report
, ('No matching results found.',)))
471 # bpy.ops.wm.undo_push_context(message='Get BlenderKit search')
472 # show asset bar automatically, but only on first page - others are loaded also when asset bar is hidden.
473 if not ui_props
.assetbar_on
and not thread
[0].params
.get('get_next'):
474 bpy
.ops
.object.run_assetbar_fix_context()
477 bk_logger
.error(error
)
479 props
.search_error
= True
481 props
.is_searching
= False
482 # print('finished search thread')
483 mt('preview loading finished')
484 # utils.p('end search timer')
485 if not all_thumbs_loaded
:
490 def load_preview(asset
, index
):
491 scene
= bpy
.context
.scene
493 props
= scene
.blenderkitUI
494 directory
= paths
.get_temp_dir('%s_search' % props
.asset_type
.lower())
495 s
= bpy
.context
.scene
496 results
= bpy
.context
.window_manager
.get('search results')
499 tpath
= os
.path
.join(directory
, asset
['thumbnail_small'])
500 if not asset
['thumbnail_small'] or asset
['thumbnail_small'] == '' or not os
.path
.exists(tpath
):
501 # tpath = paths.get_addon_thumbnail_path('thumbnail_notready.jpg')
502 asset
['thumb_small_loaded'] = False
504 iname
= utils
.previmg_name(index
)
506 # if os.path.exists(tpath): # sometimes we are unlucky...
507 img
= bpy
.data
.images
.get(iname
)
510 if not os
.path
.exists(tpath
):
512 # wrap into try statement since sometimes
514 img
= bpy
.data
.images
.load(tpath
)
518 elif img
.filepath
!= tpath
:
519 if not os
.path
.exists(tpath
):
520 # unload loaded previews from previous results
521 bpy
.data
.images
.remove(img
)
523 # had to add this check for autopacking files...
524 if bpy
.data
.use_autopack
and img
.packed_file
is not None:
525 img
.unpack(method
='USE_ORIGINAL')
532 if asset
['assetType'] == 'hdr':
533 # to display hdr thumbnails correctly, we use non-color, otherwise looks shifted
534 image_utils
.set_colorspace(img
, 'Non-Color')
536 image_utils
.set_colorspace(img
, 'sRGB')
537 asset
['thumb_small_loaded'] = True
542 scene
= bpy
.context
.scene
544 props
= scene
.blenderkitUI
545 directory
= paths
.get_temp_dir('%s_search' % props
.asset_type
.lower())
546 s
= bpy
.context
.scene
547 results
= bpy
.context
.window_manager
.get('search results')
549 if results
is not None:
556 # line splitting for longer texts...
557 def split_subs(text
, threshold
=40):
560 # temporarily disable this, to be able to do this in drawing code
563 text
= text
.replace('\r\n', '\n')
567 while len(text
) > threshold
:
568 # first handle if there's an \n line ending
569 i_rn
= text
.find('\n')
570 if 1 < i_rn
< threshold
:
572 text
= text
.replace('\n', '', 1)
574 i
= text
.rfind(' ', 0, threshold
)
575 i1
= text
.rfind(',', 0, threshold
)
576 i2
= text
.rfind('.', 0, threshold
)
580 lines
.append(text
[:i
])
586 def list_to_str(input):
588 for i
, text
in enumerate(input):
590 if i
< len(input) - 1:
595 def writeblock(t
, input, width
=40): # for longer texts
596 dlines
= split_subs(input, threshold
=width
)
597 for i
, l
in enumerate(dlines
):
602 def writeblockm(tooltip
, mdata
, key
='', pretext
=None, width
=40): # for longer texts
603 if mdata
.get(key
) == None:
607 if type(intext
) == list:
608 intext
= list_to_str(intext
)
609 if type(intext
) == float:
610 intext
= round(intext
, 3)
612 if intext
.rstrip() == '':
617 pretext
= pretext
+ ': '
618 text
= pretext
+ intext
619 dlines
= split_subs(text
, threshold
=width
)
620 for i
, l
in enumerate(dlines
):
621 tooltip
+= '%s\n' % l
626 def has(mdata
, prop
):
627 if mdata
.get(prop
) is not None and mdata
[prop
] is not None and mdata
[prop
] is not False:
633 def generate_tooltip(mdata
):
635 if type(mdata
['parameters']) == list:
636 mparams
= utils
.params_to_dict(mdata
['parameters'])
638 mparams
= mdata
['parameters']
640 t
= writeblock(t
, mdata
['displayName'], width
=int(col_w
* .6))
643 # t = writeblockm(t, mdata, key='description', pretext='', width=col_w)
647 def get_random_tip():
649 tip
= 'Tip: ' + random
.choice(rtips
)
650 t
= writeblock(t
, tip
)
654 def generate_author_textblock(adata
):
657 if adata
not in (None, ''):
659 if len(adata
['firstName'] + adata
['lastName']) > 0:
660 t
= '%s %s\n' % (adata
['firstName'], adata
['lastName'])
662 if adata
.get('aboutMe') is not None:
663 t
= writeblockm(t
, adata
, key
='aboutMe', pretext
='', width
=col_w
)
667 def download_image(session
, url
, filepath
):
670 r
= session
.get(url
, stream
=False)
671 except Exception as e
:
672 bk_logger
.error('Thumbnail download failed')
673 bk_logger
.error(str(e
))
674 if r
and r
.status_code
== 200:
675 with
open(filepath
, 'wb') as f
:
679 def thumb_download_worker(queue_sml
, queue_full
):
680 # print('thumb downloader', self.url)
681 # utils.p('start thumbdownloader thread')
684 # start a session only for single search usually, if users starts scrolling, the session might last longer if
686 if not queue_sml
.empty() or not queue_full
.empty():
688 session
= requests
.Session()
689 while not queue_sml
.empty():
690 # first empty the small thumbs queue
691 url
, filepath
= queue_sml
.get()
692 download_image(session
, url
, filepath
)
694 # download full resolution image, but only if no small thumbs are waiting. If there are small
695 while not queue_full
.empty() and queue_sml
.empty():
696 url
, filepath
= queue_full
.get()
697 download_image(session
, url
, filepath
)
699 if queue_sml
.empty() and queue_full
.empty():
700 if session
is not None:
706 def write_gravatar(a_id
, gravatar_path
):
708 Write down gravatar path, as a result of thread-based gravatar image download.
709 This should happen on timer in queue.
711 # print('write author', a_id, type(a_id))
712 authors
= bpy
.context
.window_manager
['bkit authors']
713 if authors
.get(a_id
) is not None:
714 adata
= authors
.get(a_id
)
715 adata
['gravatarImg'] = gravatar_path
718 def fetch_gravatar(adata
):
720 Gets avatars from blenderkit server
723 adata - author data from elastic search result
726 # utils.p('fetch gravatar')
728 # fetch new avatars if available already
729 if adata
.get('avatar128') is not None:
730 avatar_path
= paths
.get_temp_dir(subdir
='bkit_g/') + adata
['id'] + '.jpg'
731 if os
.path
.exists(avatar_path
):
732 tasks_queue
.add_task((write_gravatar
, (adata
['id'], avatar_path
)))
735 url
= paths
.get_bkit_url() + adata
['avatar128']
736 r
= rerequests
.get(url
, stream
=False)
738 if r
.status_code
== 200:
740 # print(r.headers['content-disposition'])
741 with
open(avatar_path
, 'wb') as f
:
743 tasks_queue
.add_task((write_gravatar
, (adata
['id'], avatar_path
)))
744 elif r
.status_code
== '404':
745 adata
['avatar128'] = None
746 utils
.p('avatar for author not available.')
749 # older gravatar code
750 if adata
.get('gravatarHash') is not None:
751 gravatar_path
= paths
.get_temp_dir(subdir
='bkit_g/') + adata
['gravatarHash'] + '.jpg'
753 if os
.path
.exists(gravatar_path
):
754 tasks_queue
.add_task((write_gravatar
, (adata
['id'], gravatar_path
)))
757 url
= "https://www.gravatar.com/avatar/" + adata
['gravatarHash'] + '?d=404'
758 r
= rerequests
.get(url
, stream
=False)
759 if r
.status_code
== 200:
760 with
open(gravatar_path
, 'wb') as f
:
762 tasks_queue
.add_task((write_gravatar
, (adata
['id'], gravatar_path
)))
763 elif r
.status_code
== '404':
764 adata
['gravatarHash'] = None
765 utils
.p('gravatar for author not available.')
768 fetching_gravatars
= {}
772 ''' Writes author info (now from search results) and fetches gravatar if needed.'''
773 global fetching_gravatars
775 a_id
= str(r
['author']['id'])
776 preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
777 authors
= bpy
.context
.window_manager
.get('bkit authors', {})
779 bpy
.context
.window_manager
['bkit authors'] = authors
780 a
= authors
.get(a_id
)
781 if a
is None: # or a is '' or (a.get('gravatarHash') is not None and a.get('gravatarImg') is None):
784 a
['tooltip'] = generate_author_textblock(a
)
787 if fetching_gravatars
.get(a
['id']) is None:
788 fetching_gravatars
[a
['id']] = True
790 thread
= threading
.Thread(target
=fetch_gravatar
, args
=(a
.copy(),), daemon
=True)
795 def write_profile(adata
):
796 utils
.p('writing profile information')
798 # we have to convert to MiB here, numbers too big for python int type
799 if user
.get('sumAssetFilesSize') is not None:
800 user
['sumAssetFilesSize'] /= (1024 * 1024)
801 if user
.get('sumPrivateAssetFilesSize') is not None:
802 user
['sumPrivateAssetFilesSize'] /= (1024 * 1024)
803 if user
.get('remainingPrivateQuota') is not None:
804 user
['remainingPrivateQuota'] /= (1024 * 1024)
806 if adata
.get('canEditAllAssets') is True:
807 user
['exmenu'] = True
809 user
['exmenu'] = False
811 bpy
.context
.window_manager
['bkit profile'] = adata
814 def request_profile(api_key
):
815 a_url
= paths
.get_api_url() + 'me/'
816 headers
= utils
.get_headers(api_key
)
817 r
= rerequests
.get(a_url
, headers
=headers
)
819 if adata
.get('user') is None:
821 utils
.p('getting profile failed')
826 def fetch_profile(api_key
):
827 utils
.p('fetch profile')
829 adata
= request_profile(api_key
)
830 if adata
is not None:
831 tasks_queue
.add_task((write_profile
, (adata
,)))
832 except Exception as e
:
837 preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
838 a
= bpy
.context
.window_manager
.get('bkit profile')
839 thread
= threading
.Thread(target
=fetch_profile
, args
=(preferences
.api_key
,), daemon
=True)
844 def query_to_url(query
={}, params
={}):
845 # build a new request
846 url
= paths
.get_api_url() + 'search/'
848 # build request manually
849 # TODO use real queries
850 requeststring
= '?query='
852 if query
.get('query') not in ('', None):
853 requeststring
+= query
['query'].lower()
854 for i
, q
in enumerate(query
):
857 requeststring
+= q
+ ':' + str(query
[q
]).lower()
859 # add dict_parameters to make results smaller
860 # result ordering: _score - relevance, score - BlenderKit score
862 if params
['free_first']:
863 order
= ['-is_free', ]
864 if query
.get('query') is None and query
.get('category_subtree') == None:
865 # assumes no keywords and no category, thus an empty search that is triggered on start.
866 # orders by last core file upload
867 if query
.get('verification_status') == 'uploaded':
868 # for validators, sort uploaded from oldest
869 order
.append('created')
871 order
.append('-last_upload')
872 elif query
.get('author_id') is not None and utils
.profile_is_validator():
874 order
.append('-created')
876 if query
.get('category_subtree') is not None:
877 order
.append('-score,_score')
879 order
.append('_score')
880 if requeststring
.find('+order:') == -1:
881 requeststring
+= '+order:' + ','.join(order
)
882 requeststring
+= '&dict_parameters=1'
884 requeststring
+= '&page_size=' + str(params
['page_size'])
885 requeststring
+= '&addon_version=%s' % params
['addon_version']
886 if params
.get('scene_uuid') is not None:
887 requeststring
+= '&scene_uuid=%s' % params
['scene_uuid']
888 # print('params', params)
889 urlquery
= url
+ requeststring
893 def parse_html_formated_error(text
):
894 report
= text
[text
.find('<title>') + 7: text
.find('</title>')]
899 class Searcher(threading
.Thread
):
902 def __init__(self
, query
, params
, tempdir
='', headers
=None, urlquery
=''):
903 super(Searcher
, self
).__init
__()
906 self
._stop
_event
= threading
.Event()
908 self
.tempdir
= tempdir
909 self
.headers
= headers
910 self
.urlquery
= urlquery
913 self
._stop
_event
.set()
916 return self
._stop
_event
.is_set()
919 global reports_queue
, thumb_sml_download_threads
, thumb_full_download_threads
926 # utils.p('start search thread')
928 mt('search thread started')
929 # tempdir = paths.get_temp_dir('%s_search' % query['asset_type'])
930 # json_filepath = os.path.join(tempdir, '%s_searchresult.json' % query['asset_type'])
933 rdata
['results'] = []
936 utils
.p(self
.urlquery
)
937 r
= rerequests
.get(self
.urlquery
, headers
=self
.headers
) # , params = rparameters)
938 except requests
.exceptions
.RequestException
as e
:
940 reports_queue
.put(str(e
))
941 # utils.p('end search thread')
945 mt('search response is back ')
948 except Exception as e
:
949 if hasattr(r
, 'text'):
950 error_description
= parse_html_formated_error(r
.text
)
951 reports_queue
.put(error_description
)
952 tasks_queue
.add_task((ui
.add_report
, (error_description
, 10, colors
.RED
)))
957 if not rdata
.get('results'):
959 # if the result was converted to json and didn't return results,
960 # it means it's a server error that has a clear message.
961 # That's why it gets processed in the update timer, where it can be passed in messages to user.
963 # utils.p('end search thread')
966 # print('number of results: ', len(rdata.get('results', [])))
968 utils
.p('stopping search : ' + str(query
))
969 # utils.p('end search thread')
973 mt('search finished')
976 thumb_small_urls
= []
977 thumb_small_filepaths
= []
979 thumb_full_filepaths
= []
981 for d
in rdata
.get('results', []):
982 thumb_small_urls
.append(d
["thumbnailSmallUrl"])
983 imgname
= paths
.extract_filename_from_url(d
['thumbnailSmallUrl'])
984 imgpath
= os
.path
.join(self
.tempdir
, imgname
)
985 thumb_small_filepaths
.append(imgpath
)
987 if d
["assetType"] == 'hdr':
988 larege_thumb_url
= d
['thumbnailLargeUrlNonsquared']
991 larege_thumb_url
= d
['thumbnailMiddleUrl']
993 thumb_full_urls
.append(larege_thumb_url
)
994 imgname
= paths
.extract_filename_from_url(larege_thumb_url
)
995 imgpath
= os
.path
.join(self
.tempdir
, imgname
)
996 thumb_full_filepaths
.append(imgpath
)
998 # for f in d['files']:
999 # # TODO move validation of published assets to server, too manmy checks here.
1000 # if f['fileType'] == 'thumbnail' and f['fileThumbnail'] != None and f['fileThumbnailLarge'] != None:
1001 # if f['fileThumbnail'] == None:
1002 # f['fileThumbnail'] = 'NONE'
1003 # if f['fileThumbnailLarge'] == None:
1004 # f['fileThumbnailLarge'] = 'NONE'
1006 # thumb_small_urls.append(f['fileThumbnail'])
1007 # thumb_full_urls.append(f['fileThumbnailLarge'])
1009 # imgname = paths.extract_filename_from_url(f['fileThumbnail'])
1010 # imgpath = os.path.join(self.tempdir, imgname)
1011 # thumb_small_filepaths.append(imgpath)
1013 # imgname = paths.extract_filename_from_url(f['fileThumbnailLarge'])
1014 # imgpath = os.path.join(self.tempdir, imgname)
1015 # thumb_full_filepaths.append(imgpath)
1017 sml_thbs
= zip(thumb_small_filepaths
, thumb_small_urls
)
1018 full_thbs
= zip(thumb_full_filepaths
, thumb_full_urls
)
1020 # we save here because a missing thumbnail check is in the previous loop
1021 # we can also prepend previous results. These have downloaded thumbnails already...
1026 utils
.p('stopping search : ' + str(query
))
1027 # utils.p('end search thread')
1030 # this loop handles downloading of small thumbnails
1031 for imgpath
, url
in sml_thbs
:
1032 if not os
.path
.exists(imgpath
):
1033 thumb_sml_download_threads
.put((url
, imgpath
))
1036 utils
.p('stopping search : ' + str(query
))
1037 # utils.p('end search thread')
1041 # utils.p('end search thread')
1043 utils
.p('stopping search : ' + str(query
))
1046 # start downloading full thumbs in the end
1047 tsession
= requests
.Session()
1049 for imgpath
, url
in full_thbs
:
1050 if not os
.path
.exists(imgpath
):
1051 thumb_full_download_threads
.put((url
, imgpath
))
1052 # utils.p('end search thread')
1053 mt('thumbnails finished')
1056 def build_query_common(query
, props
):
1057 '''add shared parameters to query'''
1059 if props
.search_keywords
!= '':
1060 # keywords = urllib.parse.urlencode(props.search_keywords)
1061 keywords
= props
.search_keywords
.replace('&', '%26')
1062 query_common
["query"] = keywords
1064 if props
.search_verification_status
!= 'ALL' and utils
.profile_is_validator():
1065 query_common
['verification_status'] = props
.search_verification_status
.lower()
1067 if props
.unrated_only
and utils
.profile_is_validator():
1068 query
["quality_count"] = 0
1070 if props
.search_file_size
:
1071 query_common
["files_size_gte"] = props
.search_file_size_min
* 1024 * 1024
1072 query_common
["files_size_lte"] = props
.search_file_size_max
* 1024 * 1024
1074 if props
.quality_limit
> 0:
1075 query
["quality_gte"] = props
.quality_limit
1077 query
.update(query_common
)
1080 def build_query_model():
1081 '''use all search input to request results from server'''
1083 props
= bpy
.context
.window_manager
.blenderkit_models
1085 "asset_type": 'model',
1086 # "engine": props.search_engine,
1087 # "adult": props.search_adult,
1089 if props
.search_style
!= 'ANY':
1090 if props
.search_style
!= 'OTHER':
1091 query
["model_style"] = props
.search_style
1093 query
["model_style"] = props
.search_style_other
1095 # the 'free_only' parametr gets moved to the search command and is used for ordering the assets as free first
1096 # if props.free_only:
1097 # query["is_free"] = True
1099 if props
.search_condition
!= 'UNSPECIFIED':
1100 query
["condition"] = props
.search_condition
1102 if props
.search_design_year
:
1103 query
["designYear_gte"] = props
.search_design_year_min
1104 query
["designYear_lte"] = props
.search_design_year_max
1105 if props
.search_polycount
:
1106 query
["faceCount_gte"] = props
.search_polycount_min
1107 query
["faceCount_lte"] = props
.search_polycount_max
1108 if props
.search_texture_resolution
:
1109 query
["textureResolutionMax_gte"] = props
.search_texture_resolution_min
1110 query
["textureResolutionMax_lte"] = props
.search_texture_resolution_max
1112 build_query_common(query
, props
)
1117 def build_query_scene():
1118 '''use all search input to request results from server'''
1120 props
= bpy
.context
.window_manager
.blenderkit_scene
1122 "asset_type": 'scene',
1123 # "engine": props.search_engine,
1124 # "adult": props.search_adult,
1126 build_query_common(query
, props
)
1130 def build_query_HDR():
1131 '''use all search input to request results from server'''
1133 props
= bpy
.context
.window_manager
.blenderkit_HDR
1135 "asset_type": 'hdr',
1137 # "engine": props.search_engine,
1138 # "adult": props.search_adult,
1141 query
["trueHDR"] = props
.true_hdr
1142 build_query_common(query
, props
)
1146 def build_query_material():
1147 props
= bpy
.context
.window_manager
.blenderkit_mat
1149 "asset_type": 'material',
1152 # if props.search_engine == 'NONE':
1153 # query["engine"] = ''
1154 # if props.search_engine != 'OTHER':
1155 # query["engine"] = props.search_engine
1157 # query["engine"] = props.search_engine_other
1158 if props
.search_style
!= 'ANY':
1159 if props
.search_style
!= 'OTHER':
1160 query
["style"] = props
.search_style
1162 query
["style"] = props
.search_style_other
1163 if props
.search_procedural
== 'TEXTURE_BASED':
1164 # todo this procedural hack should be replaced with the parameter
1165 query
["textureResolutionMax_gte"] = 0
1166 # query["procedural"] = False
1167 if props
.search_texture_resolution
:
1168 query
["textureResolutionMax_gte"] = props
.search_texture_resolution_min
1169 query
["textureResolutionMax_lte"] = props
.search_texture_resolution_max
1173 elif props
.search_procedural
== "PROCEDURAL":
1174 # todo this procedural hack should be replaced with the parameter
1175 query
["files_size_lte"] = 1024 * 1024
1176 # query["procedural"] = True
1178 build_query_common(query
, props
)
1183 def build_query_texture():
1184 props
= bpy
.context
.scene
.blenderkit_tex
1186 "asset_type": 'texture',
1190 if props
.search_style
!= 'ANY':
1191 if props
.search_style
!= 'OTHER':
1192 query
["search_style"] = props
.search_style
1194 query
["search_style"] = props
.search_style_other
1196 build_query_common(query
, props
)
1201 def build_query_brush():
1202 props
= bpy
.context
.window_manager
.blenderkit_brush
1205 if bpy
.context
.sculpt_object
is not None:
1206 brush_type
= 'sculpt'
1208 elif bpy
.context
.image_paint_object
: # could be just else, but for future p
1209 brush_type
= 'texture_paint'
1212 "asset_type": 'brush',
1217 build_query_common(query
, props
)
1223 global search_start_time
, prev_time
1224 alltime
= time
.time() - search_start_time
1225 since_last
= time
.time() - prev_time
1226 prev_time
= time
.time()
1227 utils
.p(text
, alltime
, since_last
)
1230 def add_search_process(query
, params
):
1231 global search_threads
, thumb_workers_sml
, thumb_workers_full
, all_thumbs_loaded
1233 while (len(search_threads
) > 0):
1234 old_thread
= search_threads
.pop(0)
1235 old_thread
[0].stop()
1236 # TODO CARE HERE FOR ALSO KILLING THE Thumbnail THREADS.?
1237 # AT LEAST NOW SEARCH DONE FIRST WON'T REWRITE AN NEWER ONE
1238 tempdir
= paths
.get_temp_dir('%s_search' % query
['asset_type'])
1239 headers
= utils
.get_headers(params
['api_key'])
1241 if params
.get('get_next'):
1242 urlquery
= params
['next']
1244 urlquery
= query_to_url(query
, params
)
1246 if thumb_workers_sml
== []:
1247 for a
in range(0, 8):
1248 thread
= threading
.Thread(target
=thumb_download_worker
,
1249 args
=(thumb_sml_download_threads
, thumb_full_download_threads
),
1252 thumb_workers_sml
.append(thread
)
1254 all_thumbs_loaded
= False
1256 thread
= Searcher(query
, params
, tempdir
=tempdir
, headers
=headers
, urlquery
=urlquery
)
1259 search_threads
.append([thread
, tempdir
, query
['asset_type'], {}]) # 4th field is for results
1261 mt('search thread started')
1264 def get_search_simple(parameters
, filepath
=None, page_size
=100, max_results
=100000000, api_key
=''):
1266 Searches and returns the
1271 parameters - dict of blenderkit elastic parameters
1272 filepath - a file to save the results. If None, results are returned
1273 page_size - page size for retrieved results
1274 max_results - max results of the search
1275 api_key - BlenderKit api key
1279 Returns search results as a list, and optionally saves to filepath
1282 headers
= utils
.get_headers(api_key
)
1283 url
= paths
.get_api_url() + 'search/'
1284 requeststring
= url
+ '?query='
1285 for p
in parameters
.keys():
1286 requeststring
+= f
'+{p}:{parameters[p]}'
1288 requeststring
+= '&page_size=' + str(page_size
)
1289 requeststring
+= '&dict_parameters=1'
1291 bk_logger
.debug(requeststring
)
1292 response
= rerequests
.get(requeststring
, headers
=headers
) # , params = rparameters)
1293 # print(response.json())
1294 search_results
= response
.json()
1297 results
.extend(search_results
['results'])
1299 page_count
= math
.ceil(search_results
['count'] / page_size
)
1300 while search_results
.get('next') and len(results
) < max_results
:
1301 bk_logger
.info(f
'getting page {page_index} , total pages {page_count}')
1302 response
= rerequests
.get(search_results
['next'], headers
=headers
) # , params = rparameters)
1303 search_results
= response
.json()
1304 # print(search_results)
1305 results
.extend(search_results
['results'])
1311 with
open(filepath
, 'w', encoding
='utf-8') as s
:
1312 json
.dump(results
, s
, ensure_ascii
=False, indent
=4)
1313 bk_logger
.info(f
'retrieved {len(results)} assets from elastic search')
1317 def search(category
='', get_next
=False, author_id
=''):
1318 ''' initialize searching'''
1319 global search_start_time
1320 # print(category,get_next,author_id)
1321 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
1322 search_start_time
= time
.time()
1324 scene
= bpy
.context
.scene
1325 wm
= bpy
.context
.window_manager
1326 ui_props
= scene
.blenderkitUI
1328 props
= utils
.get_search_props()
1329 if ui_props
.asset_type
== 'MODEL':
1330 if not hasattr(wm
, 'blenderkit_models'):
1332 query
= build_query_model()
1334 if ui_props
.asset_type
== 'SCENE':
1335 if not hasattr(wm
, 'blenderkit_scene'):
1337 query
= build_query_scene()
1339 if ui_props
.asset_type
== 'HDR':
1340 if not hasattr(wm
, 'blenderkit_HDR'):
1342 query
= build_query_HDR()
1344 if ui_props
.asset_type
== 'MATERIAL':
1345 if not hasattr(wm
, 'blenderkit_mat'):
1348 query
= build_query_material()
1350 if ui_props
.asset_type
== 'TEXTURE':
1351 if not hasattr(wm
, 'blenderkit_tex'):
1353 # props = scene.blenderkit_tex
1354 # query = build_query_texture()
1356 if ui_props
.asset_type
== 'BRUSH':
1357 if not hasattr(wm
, 'blenderkit_brush'):
1359 query
= build_query_brush()
1361 # crop long searches
1362 if query
.get('query'):
1363 if len(query
['query']) > 50:
1364 query
['query'] = strip_accents(query
['query'])
1366 if len(query
['query']) > 150:
1367 idx
= query
['query'].find(' ', 142)
1368 query
['query'] = query
['query'][:idx
]
1370 # it's possible get_next was requested more than once.
1371 # print(category,props.is_searching, get_next)
1373 if props
.is_searching
and get_next
== True:
1374 # print('return because of get next and searching is happening')
1378 if utils
.profile_is_validator() and user_preferences
.categories_fix
:
1379 query
['category'] = category
1381 query
['category_subtree'] = category
1384 query
['author_id'] = author_id
1386 elif props
.own_only
:
1387 # if user searches for [another] author, 'only my assets' is invalid. that's why in elif.
1388 profile
= bpy
.context
.window_manager
.get('bkit profile')
1389 if profile
is not None:
1390 query
['author_id'] = str(profile
['user']['id'])
1392 # utils.p('searching')
1393 props
.is_searching
= True
1395 page_size
= min(30, ui_props
.wcount
* user_preferences
.max_assetbar_rows
)
1398 'scene_uuid': bpy
.context
.scene
.get('uuid', None),
1399 'addon_version': version_checker
.get_addon_version(),
1400 'api_key': user_preferences
.api_key
,
1401 'get_next': get_next
,
1402 'free_first': props
.free_only
,
1403 'page_size': page_size
,
1406 orig_results
= bpy
.context
.window_manager
.get(f
'bkit {ui_props.asset_type.lower()} search orig')
1407 if orig_results
is not None and get_next
:
1408 params
['next'] = orig_results
['next']
1409 add_search_process(query
, params
)
1410 tasks_queue
.add_task((ui
.add_report
, ('BlenderKit searching....', 2)))
1412 props
.report
= 'BlenderKit searching....'
1415 def update_filters():
1416 sprops
= utils
.get_search_props()
1417 ui_props
= bpy
.context
.scene
.blenderkitUI
1418 fcommon
= sprops
.own_only
or \
1419 sprops
.search_texture_resolution
or \
1420 sprops
.search_file_size
or \
1421 sprops
.search_procedural
!= 'BOTH' or \
1422 sprops
.free_only
or \
1423 sprops
.quality_limit
> 0
1425 if ui_props
.asset_type
== 'MODEL':
1426 sprops
.use_filters
= fcommon
or \
1427 sprops
.search_style
!= 'ANY' or \
1428 sprops
.search_condition
!= 'UNSPECIFIED' or \
1429 sprops
.search_design_year
or \
1430 sprops
.search_polycount
1431 elif ui_props
.asset_type
== 'MATERIAL':
1432 sprops
.use_filters
= fcommon
1433 elif ui_props
.asset_type
== 'HDR':
1434 sprops
.use_filters
= sprops
.true_hdr
1437 def search_update(self
, context
):
1438 utils
.p('search updater')
1439 # if self.search_keywords != '':
1441 ui_props
= bpy
.context
.scene
.blenderkitUI
1442 if ui_props
.down_up
!= 'SEARCH':
1443 ui_props
.down_up
= 'SEARCH'
1445 # here we tweak the input if it comes form the clipboard. we need to get rid of asset type and set it in UI
1446 sprops
= utils
.get_search_props()
1447 instr
= 'asset_base_id:'
1448 atstr
= 'asset_type:'
1449 kwds
= sprops
.search_keywords
1450 idi
= kwds
.find(instr
)
1451 ati
= kwds
.find(atstr
)
1452 # if the asset type already isn't there it means this update function
1453 # was triggered by it's last iteration and needs to cancel
1455 at
= kwds
[ati
:].lower()
1456 # uncertain length of the remaining string - find as better method to check the presence of asset type
1457 if at
.find('model') > -1:
1458 ui_props
.asset_type
= 'MODEL'
1459 elif at
.find('material') > -1:
1460 ui_props
.asset_type
= 'MATERIAL'
1461 elif at
.find('brush') > -1:
1462 ui_props
.asset_type
= 'BRUSH'
1463 elif at
.find('scene') > -1:
1464 ui_props
.asset_type
= 'SCENE'
1465 elif at
.find('hdr') > -1:
1466 ui_props
.asset_type
= 'HDR'
1467 # now we trim the input copypaste by anything extra that is there,
1468 # this is also a way for this function to recognize that it already has parsed the clipboard
1469 # the search props can have changed and this needs to transfer the data to the other field
1470 # this complex behaviour is here for the case where the user needs to paste manually into blender?
1471 sprops
= utils
.get_search_props()
1472 sprops
.search_keywords
= kwds
[:ati
].rstrip()
1473 # return here since writing into search keywords triggers this update function once more.
1476 # print('search update search')
1480 # accented_string is of type 'unicode'
1481 def strip_accents(s
):
1482 return ''.join(c
for c
in unicodedata
.normalize('NFD', s
)
1483 if unicodedata
.category(c
) != 'Mn')
1486 class SearchOperator(Operator
):
1488 bl_idname
= "view3d.blenderkit_search"
1489 bl_label
= "BlenderKit asset search"
1490 bl_description
= "Search online for assets"
1491 bl_options
= {'REGISTER', 'UNDO', 'INTERNAL'}
1493 esc
: BoolProperty(name
="Escape window",
1494 description
="Escape window right after start",
1496 options
={'SKIP_SAVE'}
1499 own
: BoolProperty(name
="own assets only",
1500 description
="Find all own assets",
1502 options
={'SKIP_SAVE'})
1504 category
: StringProperty(
1506 description
="search only subtree of this category",
1508 options
={'SKIP_SAVE'}
1511 author_id
: StringProperty(
1513 description
="Author ID - search only assets by this author",
1515 options
={'SKIP_SAVE'}
1518 get_next
: BoolProperty(name
="next page",
1519 description
="get next page from previous search",
1521 options
={'SKIP_SAVE'}
1524 keywords
: StringProperty(
1526 description
="Keywords",
1528 options
={'SKIP_SAVE'}
1531 # close_window: BoolProperty(name='Close window',
1532 # description='Try to close the window below mouse before download',
1535 tooltip
: bpy
.props
.StringProperty(default
='Runs search and displays the asset bar at the same time')
1538 def description(cls
, context
, properties
):
1539 return properties
.tooltip
1542 def poll(cls
, context
):
1545 def execute(self
, context
):
1546 # TODO ; this should all get transferred to properties of the search operator, so sprops don't have to be fetched here at all.
1548 bpy
.ops
.view3d
.close_popup_button('INVOKE_DEFAULT')
1549 sprops
= utils
.get_search_props()
1550 if self
.author_id
!= '':
1551 sprops
.search_keywords
= ''
1552 if self
.keywords
!= '':
1553 sprops
.search_keywords
= self
.keywords
1555 search(category
=self
.category
, get_next
=self
.get_next
, author_id
=self
.author_id
)
1556 # bpy.ops.view3d.blenderkit_asset_bar()
1560 # def invoke(self, context, event):
1561 # if self.close_window:
1562 # context.window.cursor_warp(event.mouse_x, event.mouse_y - 100);
1563 # context.area.tag_redraw()
1565 # context.window.cursor_warp(event.mouse_x, event.mouse_y);
1566 # return self. execute(context)
1569 class UrlOperator(Operator
):
1571 bl_idname
= "wm.blenderkit_url"
1573 bl_description
= "Search online for assets"
1574 bl_options
= {'REGISTER', 'UNDO', 'INTERNAL'}
1576 tooltip
: bpy
.props
.StringProperty(default
='Open a web page')
1577 url
: bpy
.props
.StringProperty(default
='Runs search and displays the asset bar at the same time')
1580 def description(cls
, context
, properties
):
1581 return properties
.tooltip
1583 def execute(self
, context
):
1584 bpy
.ops
.wm
.url_open(url
=self
.url
)
1588 class TooltipLabelOperator(Operator
):
1590 bl_idname
= "wm.blenderkit_tooltip"
1592 bl_description
= "Empty operator to be able to create tooltips on labels in UI"
1593 bl_options
= {'REGISTER', 'UNDO', 'INTERNAL'}
1595 tooltip
: bpy
.props
.StringProperty(default
='Open a web page')
1598 def description(cls
, context
, properties
):
1599 return properties
.tooltip
1601 def execute(self
, context
):
1608 TooltipLabelOperator
1612 def register_search():
1613 bpy
.app
.handlers
.load_post
.append(scene_load
)
1616 bpy
.utils
.register_class(c
)
1618 user_preferences
= bpy
.context
.preferences
.addons
['blenderkit'].preferences
1619 if user_preferences
.use_timers
:
1620 bpy
.app
.timers
.register(search_timer
)
1622 categories
.load_categories()
1625 def unregister_search():
1626 bpy
.app
.handlers
.load_post
.remove(scene_load
)
1629 bpy
.utils
.unregister_class(c
)
1631 if bpy
.app
.timers
.is_registered(search_timer
):
1632 bpy
.app
.timers
.unregister(search_timer
)