GPencil Tools: Fix T90976 Timeline-scrub toggle
[blender-addons.git] / blenderkit / search.py
blobc386f5883420e6652983ee7c6436dd12b1cc3af2
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
22 import blenderkit
23 from bpy.app.handlers import persistent
25 from bpy.props import ( # TODO only keep the ones actually used when cleaning
26 IntProperty,
27 FloatProperty,
28 FloatVectorProperty,
29 StringProperty,
30 EnumProperty,
31 BoolProperty,
32 PointerProperty,
34 from bpy.types import (
35 Operator,
36 Panel,
37 AddonPreferences,
38 PropertyGroup,
39 UIList
42 import requests, os, random
43 import time
44 import threading
45 import platform
46 import bpy
47 import copy
48 import json
49 import math
50 import unicodedata
51 import urllib
52 import queue
53 import logging
55 bk_logger = logging.getLogger('blenderkit')
57 search_start_time = 0
58 prev_time = 0
61 def check_errors(rdata):
62 if rdata.get('statusCode') and int(rdata.get('statusCode')) > 299:
63 utils.p(rdata)
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.'
71 else:
72 return False, rdata.get('detail')
73 if rdata.get('statusCode') is None and rdata.get('results') is None:
74 return False, 'Connection error'
75 return True, ''
78 search_threads = []
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
101 fetch_server_data()
102 categories.load_categories()
104 return max(3600, user_preferences.api_key_life - 3600)
107 def update_ad(ad):
108 if not ad.get('assetBaseId'):
109 try:
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
114 ad['author'] = {}
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')
119 return ad
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.
126 data = bpy.data
128 datablocks = [
129 bpy.data.objects,
130 bpy.data.materials,
131 bpy.data.brushes,
133 for dtype in datablocks:
134 for block in dtype:
135 if block.get('asset_data') != None:
136 update_ad(block['asset_data'])
138 dicts = [
139 'assets used',
140 # 'assets rated',# assets rated stores only true/false, not asset data.
142 for s in bpy.data.scenes:
143 for bkdict in dicts:
145 d = s.get(bkdict)
146 if not d:
147 continue;
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
159 sr_props = [
160 'search results',
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:
168 if s.get(sr_prop):
169 del (s[sr_prop])
172 @persistent
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()
180 fetch_server_data()
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)
184 update_assets_data()
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:
198 get_profile()
199 if bpy.context.window_manager.get('bkit_categories') is None:
200 categories.fetch_categories_thread(api_key, force=False)
203 first_time = True
204 last_clipboard = ''
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.
225 if ati > -1:
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.
231 def parse_result(r):
233 needed to generate some extra data in the result(by now)
234 Parameters
235 ----------
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.
242 # try:
243 # r['filesSize'] = int(r['filesSize'] / 1024)
244 # except:
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.
248 get_author(r)
250 r['available_resolutions'] = []
251 allthumbs = []
252 durl, tname, small_tname = '', '', ''
254 if r['assetType'] == 'hdr':
255 tname = paths.extract_filename_from_url(r['thumbnailLargeUrlNonsquared'])
256 else:
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.
265 for f in r['files']:
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
279 # tdict = {}
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:
305 bbox = {
306 'bbox_min': (
307 float(params['boundBoxMinX']),
308 float(params['boundBoxMinY']),
309 float(params['boundBoxMinZ'])),
310 'bbox_max': (
311 float(params['boundBoxMaxX']),
312 float(params['boundBoxMaxY']),
313 float(params['boundBoxMaxZ']))
316 else:
317 bbox = {
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', {})
328 if au == {}:
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']:
336 if f.get('url'):
337 for f1 in r['files']:
338 if f1['fileType'] == f['fileType']:
339 f1['url'] = f['url']
341 # attempt to switch to use original data gradually, since the parsing as itself should become obsolete.
342 asset_data.update(r)
343 return asset_data
346 # @bpy.app.handlers.persistent
347 def search_timer():
348 # this makes a first search after opening blender. showing latest assets.
349 # utils.p('timer search')
350 # utils.p('start search timer')
351 global first_time
352 preferences = bpy.context.preferences.addons['blenderkit'].preferences
353 if first_time and not bpy.app.background: # first time
355 first_time = False
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:
359 search()
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')
367 return 3.0
369 # if preferences.first_run:
370 # search()
371 # preferences.first_run = False
373 # check_clipboard()
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:
382 all_loaded = True
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
393 return 1.0
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')
399 return 0.5
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] = []
418 result_field = []
419 else:
420 result_field = []
421 for r in wm[search_name]:
422 result_field.append(r.to_dict())
424 global reports_queue
426 while not reports_queue.empty():
427 props.report = str(reports_queue.get())
428 # utils.p('end search timer')
430 return .2
432 rdata = thread[0].result
434 ok, error = check_errors(rdata)
435 if ok:
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]),
453 daemon=True)
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')):
464 # jump back
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.',)))
470 # undo push
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()
476 else:
477 bk_logger.error(error)
478 props.report = 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:
486 return .1
487 return .3
490 def load_preview(asset, index):
491 scene = bpy.context.scene
492 # FIRST START SEARCH
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')
497 loaded = True
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)
509 if img is None:
510 if not os.path.exists(tpath):
511 return False
512 # wrap into try statement since sometimes
513 try:
514 img = bpy.data.images.load(tpath)
515 img.name = iname
516 except:
517 return False
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)
522 return False
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')
526 img.filepath = tpath
527 try:
528 img.reload()
529 except:
530 return False
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')
535 else:
536 image_utils.set_colorspace(img, 'sRGB')
537 asset['thumb_small_loaded'] = True
538 return True
541 def load_previews():
542 scene = bpy.context.scene
543 # FIRST START SEARCH
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:
550 i = 0
551 for r in results:
552 load_preview(r, i)
553 i += 1
556 # line splitting for longer texts...
557 def split_subs(text, threshold=40):
558 if text == '':
559 return []
560 # temporarily disable this, to be able to do this in drawing code
562 text = text.rstrip()
563 text = text.replace('\r\n', '\n')
565 lines = []
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:
571 i = i_rn
572 text = text.replace('\n', '', 1)
573 else:
574 i = text.rfind(' ', 0, threshold)
575 i1 = text.rfind(',', 0, threshold)
576 i2 = text.rfind('.', 0, threshold)
577 i = max(i, i1, i2)
578 if i <= 0:
579 i = threshold
580 lines.append(text[:i])
581 text = text[i:]
582 lines.append(text)
583 return lines
586 def list_to_str(input):
587 output = ''
588 for i, text in enumerate(input):
589 output += text
590 if i < len(input) - 1:
591 output += ', '
592 return output
595 def writeblock(t, input, width=40): # for longer texts
596 dlines = split_subs(input, threshold=width)
597 for i, l in enumerate(dlines):
598 t += '%s\n' % l
599 return t
602 def writeblockm(tooltip, mdata, key='', pretext=None, width=40): # for longer texts
603 if mdata.get(key) == None:
604 return tooltip
605 else:
606 intext = mdata[key]
607 if type(intext) == list:
608 intext = list_to_str(intext)
609 if type(intext) == float:
610 intext = round(intext, 3)
611 intext = str(intext)
612 if intext.rstrip() == '':
613 return tooltip
614 if pretext == None:
615 pretext = key
616 if pretext != '':
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
623 return tooltip
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:
628 return True
629 else:
630 return False
633 def generate_tooltip(mdata):
634 col_w = 40
635 if type(mdata['parameters']) == list:
636 mparams = utils.params_to_dict(mdata['parameters'])
637 else:
638 mparams = mdata['parameters']
639 t = ''
640 t = writeblock(t, mdata['displayName'], width=int(col_w * .6))
641 # t += '\n'
643 # t = writeblockm(t, mdata, key='description', pretext='', width=col_w)
644 return t
647 def get_random_tip():
648 t = ''
649 tip = 'Tip: ' + random.choice(rtips)
650 t = writeblock(t, tip)
651 return t
654 def generate_author_textblock(adata):
655 t = ''
657 if adata not in (None, ''):
658 col_w = 2000
659 if len(adata['firstName'] + adata['lastName']) > 0:
660 t = '%s %s\n' % (adata['firstName'], adata['lastName'])
661 t += '\n'
662 if adata.get('aboutMe') is not None:
663 t = writeblockm(t, adata, key='aboutMe', pretext='', width=col_w)
664 return t
667 def download_image(session, url, filepath):
668 r = None
669 try:
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:
676 f.write(r.content)
679 def thumb_download_worker(queue_sml, queue_full):
680 # print('thumb downloader', self.url)
681 # utils.p('start thumbdownloader thread')
682 while 1:
683 session = None
684 # start a session only for single search usually, if users starts scrolling, the session might last longer if
685 # queue gets filled.
686 if not queue_sml.empty() or not queue_full.empty():
687 if session is None:
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)
693 exit_full = False
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:
701 session.close()
702 session = None
703 time.sleep(.5)
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
721 Parameters
722 ----------
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)))
733 return;
735 url = paths.get_bkit_url() + adata['avatar128']
736 r = rerequests.get(url, stream=False)
737 # print(r.body)
738 if r.status_code == 200:
739 # print(url)
740 # print(r.headers['content-disposition'])
741 with open(avatar_path, 'wb') as f:
742 f.write(r.content)
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.')
747 return
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)))
755 return;
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:
761 f.write(r.content)
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 = {}
771 def get_author(r):
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', {})
778 if 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):
782 a = r['author']
783 a['id'] = a_id
784 a['tooltip'] = generate_author_textblock(a)
786 authors[a_id] = 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)
791 thread.start()
792 return a
795 def write_profile(adata):
796 utils.p('writing profile information')
797 user = adata['user']
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
808 else:
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)
818 adata = r.json()
819 if adata.get('user') is None:
820 utils.p(adata)
821 utils.p('getting profile failed')
822 return None
823 return adata
826 def fetch_profile(api_key):
827 utils.p('fetch profile')
828 try:
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:
833 bk_logger.error(e)
836 def get_profile():
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)
840 thread.start()
841 return a
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):
855 if q != 'query':
856 requeststring += '+'
857 requeststring += q + ':' + str(query[q]).lower()
859 # add dict_parameters to make results smaller
860 # result ordering: _score - relevance, score - BlenderKit score
861 order = []
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')
870 else:
871 order.append('-last_upload')
872 elif query.get('author_id') is not None and utils.profile_is_validator():
874 order.append('-created')
875 else:
876 if query.get('category_subtree') is not None:
877 order.append('-score,_score')
878 else:
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
890 return urlquery
893 def parse_html_formated_error(text):
894 report = text[text.find('<title>') + 7: text.find('</title>')]
896 return report
899 class Searcher(threading.Thread):
900 query = None
902 def __init__(self, query, params, tempdir='', headers=None, urlquery=''):
903 super(Searcher, self).__init__()
904 self.query = query
905 self.params = params
906 self._stop_event = threading.Event()
907 self.result = {}
908 self.tempdir = tempdir
909 self.headers = headers
910 self.urlquery = urlquery
912 def stop(self):
913 self._stop_event.set()
915 def stopped(self):
916 return self._stop_event.is_set()
918 def run(self):
919 global reports_queue, thumb_sml_download_threads, thumb_full_download_threads
921 maxthreads = 50
922 query = self.query
923 params = self.params
925 t = time.time()
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'])
932 rdata = {}
933 rdata['results'] = []
935 try:
936 utils.p(self.urlquery)
937 r = rerequests.get(self.urlquery, headers=self.headers) # , params = rparameters)
938 except requests.exceptions.RequestException as e:
939 bk_logger.error(e)
940 reports_queue.put(str(e))
941 # utils.p('end search thread')
943 return
945 mt('search response is back ')
946 try:
947 rdata = r.json()
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)))
954 bk_logger.error(e)
955 return
956 mt('data parsed ')
957 if not rdata.get('results'):
958 utils.pprint(rdata)
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.
962 self.result = rdata
963 # utils.p('end search thread')
965 return
966 # print('number of results: ', len(rdata.get('results', [])))
967 if self.stopped():
968 utils.p('stopping search : ' + str(query))
969 # utils.p('end search thread')
971 return
973 mt('search finished')
974 i = 0
976 thumb_small_urls = []
977 thumb_small_filepaths = []
978 thumb_full_urls = []
979 thumb_full_filepaths = []
980 # END OF PARSING
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']
990 else:
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...
1023 self.result = rdata
1025 if self.stopped():
1026 utils.p('stopping search : ' + str(query))
1027 # utils.p('end search thread')
1028 return
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))
1035 if self.stopped():
1036 utils.p('stopping search : ' + str(query))
1037 # utils.p('end search thread')
1038 return
1040 if self.stopped():
1041 # utils.p('end search thread')
1043 utils.p('stopping search : ' + str(query))
1044 return
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'''
1058 query_common = {}
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
1084 query = {
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
1092 else:
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)
1114 return query
1117 def build_query_scene():
1118 '''use all search input to request results from server'''
1120 props = bpy.context.window_manager.blenderkit_scene
1121 query = {
1122 "asset_type": 'scene',
1123 # "engine": props.search_engine,
1124 # "adult": props.search_adult,
1126 build_query_common(query, props)
1127 return query
1130 def build_query_HDR():
1131 '''use all search input to request results from server'''
1133 props = bpy.context.window_manager.blenderkit_HDR
1134 query = {
1135 "asset_type": 'hdr',
1137 # "engine": props.search_engine,
1138 # "adult": props.search_adult,
1140 if props.true_hdr:
1141 query["trueHDR"] = props.true_hdr
1142 build_query_common(query, props)
1143 return query
1146 def build_query_material():
1147 props = bpy.context.window_manager.blenderkit_mat
1148 query = {
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
1156 # else:
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
1161 else:
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)
1180 return query
1183 def build_query_texture():
1184 props = bpy.context.scene.blenderkit_tex
1185 query = {
1186 "asset_type": 'texture',
1190 if props.search_style != 'ANY':
1191 if props.search_style != 'OTHER':
1192 query["search_style"] = props.search_style
1193 else:
1194 query["search_style"] = props.search_style_other
1196 build_query_common(query, props)
1198 return query
1201 def build_query_brush():
1202 props = bpy.context.window_manager.blenderkit_brush
1204 brush_type = ''
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'
1211 query = {
1212 "asset_type": 'brush',
1214 "mode": brush_type
1217 build_query_common(query, props)
1219 return query
1222 def mt(text):
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']
1243 else:
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),
1250 daemon=True)
1251 thread.start()
1252 thumb_workers_sml.append(thread)
1254 all_thumbs_loaded = False
1256 thread = Searcher(query, params, tempdir=tempdir, headers=headers, urlquery=urlquery)
1257 thread.start()
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
1269 Parameters
1270 ----------
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
1277 Returns
1278 -------
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()
1296 results = []
1297 results.extend(search_results['results'])
1298 page_index = 2
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'])
1306 page_index += 1
1308 if not filepath:
1309 return 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')
1314 return results
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()
1323 # mt('start')
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'):
1331 return;
1332 query = build_query_model()
1334 if ui_props.asset_type == 'SCENE':
1335 if not hasattr(wm, 'blenderkit_scene'):
1336 return;
1337 query = build_query_scene()
1339 if ui_props.asset_type == 'HDR':
1340 if not hasattr(wm, 'blenderkit_HDR'):
1341 return;
1342 query = build_query_HDR()
1344 if ui_props.asset_type == 'MATERIAL':
1345 if not hasattr(wm, 'blenderkit_mat'):
1346 return;
1348 query = build_query_material()
1350 if ui_props.asset_type == 'TEXTURE':
1351 if not hasattr(wm, 'blenderkit_tex'):
1352 return;
1353 # props = scene.blenderkit_tex
1354 # query = build_query_texture()
1356 if ui_props.asset_type == 'BRUSH':
1357 if not hasattr(wm, 'blenderkit_brush'):
1358 return;
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)
1372 # print(query)
1373 if props.is_searching and get_next == True:
1374 # print('return because of get next and searching is happening')
1375 return;
1377 if category != '':
1378 if utils.profile_is_validator() and user_preferences.categories_fix:
1379 query['category'] = category
1380 else:
1381 query['category_subtree'] = category
1383 if author_id != '':
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)
1397 params = {
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 != '':
1440 update_filters()
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
1454 if ati > -1:
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.
1474 return
1476 # print('search update search')
1477 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):
1487 """Tooltip"""
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",
1495 default=False,
1496 options={'SKIP_SAVE'}
1499 own: BoolProperty(name="own assets only",
1500 description="Find all own assets",
1501 default=False,
1502 options={'SKIP_SAVE'})
1504 category: StringProperty(
1505 name="category",
1506 description="search only subtree of this category",
1507 default="",
1508 options={'SKIP_SAVE'}
1511 author_id: StringProperty(
1512 name="Author ID",
1513 description="Author ID - search only assets by this author",
1514 default="",
1515 options={'SKIP_SAVE'}
1518 get_next: BoolProperty(name="next page",
1519 description="get next page from previous search",
1520 default=False,
1521 options={'SKIP_SAVE'}
1524 keywords: StringProperty(
1525 name="Keywords",
1526 description="Keywords",
1527 default="",
1528 options={'SKIP_SAVE'}
1531 # close_window: BoolProperty(name='Close window',
1532 # description='Try to close the window below mouse before download',
1533 # default=False)
1535 tooltip: bpy.props.StringProperty(default='Runs search and displays the asset bar at the same time')
1537 @classmethod
1538 def description(cls, context, properties):
1539 return properties.tooltip
1541 @classmethod
1542 def poll(cls, context):
1543 return True
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.
1547 if self.esc:
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()
1558 return {'FINISHED'}
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):
1570 """"""
1571 bl_idname = "wm.blenderkit_url"
1572 bl_label = ""
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')
1579 @classmethod
1580 def description(cls, context, properties):
1581 return properties.tooltip
1583 def execute(self, context):
1584 bpy.ops.wm.url_open(url=self.url)
1585 return {'FINISHED'}
1588 class TooltipLabelOperator(Operator):
1589 """"""
1590 bl_idname = "wm.blenderkit_tooltip"
1591 bl_label = ""
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')
1597 @classmethod
1598 def description(cls, context, properties):
1599 return properties.tooltip
1601 def execute(self, context):
1602 return {'FINISHED'}
1605 classes = [
1606 SearchOperator,
1607 UrlOperator,
1608 TooltipLabelOperator
1612 def register_search():
1613 bpy.app.handlers.load_post.append(scene_load)
1615 for c in classes:
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)
1628 for c in classes:
1629 bpy.utils.unregister_class(c)
1631 if bpy.app.timers.is_registered(search_timer):
1632 bpy.app.timers.unregister(search_timer)