# SPDX-FileCopyrightText: 2022 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later from ..svn_info import get_svn_info from ..util import get_addon_prefs from .. import constants from .execute_subprocess import execute_svn_command, check_svn_installed from .background_process import BackgroundProcess, Processes from bpy.types import Operator from bpy.props import StringProperty import bpy import xmltodict import time from pathlib import Path from typing import List, Dict, Union, Any, Set, Optional, Tuple from .. import wheels # This will load the xmltodict wheel file. wheels.preload_dependencies() class SVN_OT_explain_status(Operator): bl_idname = "svn.explain_status" bl_label = "" # Don't want the first line of the tooltip on mouse hover. bl_description = "Show an explanation of this status, using a dynamic tooltip" bl_options = {'INTERNAL'} status: StringProperty( description="Identifier of the status to show an explanation for" ) file_rel_path: StringProperty( description="Path of the file to select in the list when clicking this explanation, to act as if it was click-through-able" ) @staticmethod def get_explanation(status: str): return constants.SVN_STATUS_DATA[status][1] @classmethod def description(cls, context, properties): return cls.get_explanation(properties.status) def draw(self, context): self.layout.label(text=self.get_explanation(self.status)) def execute(self, context): """Set the index on click, to act as if this operator button was click-through in the UIList.""" if not self.file_rel_path: return {'FINISHED'} repo = context.scene.svn.get_repo(context) file_entry = repo.external_files.get(self.file_rel_path) file_entry_idx = repo.get_index_of_file(file_entry) if file_entry_idx: repo.external_files_active_index = file_entry_idx return {'FINISHED'} @bpy.app.handlers.persistent def ensure_svn_of_current_file(_scene=None): """When opening or saving a .blend file, it's possible that the new .blend is part of an SVN repository. If this is the case, do the following: - Check if this file's repository is already in our database - If not, create it - Switch to that repo """ context = bpy.context prefs = get_addon_prefs(context) prefs.is_svn_installed = check_svn_installed() if not prefs.is_svn_installed: return scene_svn = context.scene.svn old_active_repo = prefs.active_repo prefs.init_repo_list() # If the file is unsaved, nothing more to do. if not bpy.data.filepath: scene_svn.svn_url = "" return # If file is not in a repo, nothing more to do. is_in_repo = set_scene_svn_info(context) if not is_in_repo: return # If file is in an existing repo, we should switch over to that repo. for i, existing_repo in enumerate(prefs.repositories): if ( existing_repo.url == scene_svn.svn_url and existing_repo.directory == scene_svn.svn_directory and existing_repo != old_active_repo ): prefs.active_repo_idx = i else: # If file is in a non-existing repo, initialize that repo. prefs.init_repo(context, scene_svn.svn_directory) context.scene.svn.get_repo(context).force_update_ui_caches(context) def set_scene_svn_info(context) -> bool: """Check if the current .blend file is in an SVN repository. If it is, use `svn info` to grab the SVN URL and directory and store them in the Scene. The rest of the add-on will use this stored URL & Dir to find the corresponding SVN repository data stored in the user preferences. Returns whether initialization was successful or not. """ scene_svn = context.scene.svn scene_svn.svn_directory = "" scene_svn.svn_url = "" blend_path = Path(bpy.data.filepath).parent root_dir, base_url = get_svn_info(blend_path) if not root_dir: return False scene_svn.svn_directory = root_dir scene_svn.svn_url = base_url return True ################################################################################ ############## AUTOMATICALLY KEEPING FILE STATUSES UP TO DATE ################## ################################################################################ class BGP_SVN_Status(BackgroundProcess): name = "Status" needs_authentication = True timeout = 10 repeat_delay = 15 debug = False def __init__(self): self.timestamp_last_update = 0 self.list_command_output = "" super().__init__() def acquire_output(self, context, prefs): self.output = execute_svn_command( context, ["svn", "status", "--show-updates", "--verbose", "--xml"], use_cred=True, ) # The list command includes file size info and also files of directories # which have their Depth set to Empty, which is used for a partial check-out, # which we also use for updating files and folders one-by-one instead of # all-at-once, so we can provide more live feedback in the UI. # NOTE: This one-by-one updating functionality conflicts with a potential # future support for partial check-outs, so that would require storing user-intended # partially checked out folders separately somewhere. self.list_command_output = execute_svn_command( context, ["svn", "list", "--recursive", "--xml"], use_cred=True, ) def process_output(self, context, prefs): repo = context.scene.svn.get_repo(context) update_file_list_svn_status(context, svn_status_xml_to_dict(self.output)) update_file_list_svn_list(context, self.list_command_output) repo.refresh_ui_lists(context) self.timestamp_last_update = time.time() def get_ui_message(self, context): time_since_last_update = time.time() - self.timestamp_last_update time_delta = self.repeat_delay - time_since_last_update if self.repeat_delay > time_delta > 0: return f"Status update in {int(time_delta)}s." return f"Updating repo status..." class BGP_SVN_Authenticate(BGP_SVN_Status): name = "Authenticate" needs_authentication = False timeout = 10 repeat_delay = 0 debug = False def get_ui_message(self, context): return "Authenticating..." def acquire_output(self, context, prefs): repo = context.scene.svn.get_repo(context) if ( not repo or not repo.is_valid_svn or not repo.is_cred_entered or repo.authenticated ): return super().acquire_output(context, prefs) def handle_error(self, context, error): super().handle_error(context, error) repo = context.scene.svn.get_repo(context) repo.authenticated = False repo.auth_failed = True def process_output(self, context, prefs): repo = context.scene.svn.get_repo(context) if not repo or not repo.is_cred_entered or repo.authenticated: return assert self.output super().process_output(context, prefs) repo.authenticated = True repo.auth_failed = False Processes.start('Status') Processes.start('Log') def update_file_list_svn_status(context, file_statuses: Dict[str, Tuple[str, str, int]]): """Update the file list based on data from get_svn_file_statuses(). (See timer_update_svn_status)""" repo = context.scene.svn.get_repo(context) svn_paths = [] new_files_on_repo = set() for filepath_str, status_info in file_statuses.items(): svn_path = Path(filepath_str) svn_path_str = str(filepath_str) suffix = svn_path.suffix if ( (suffix.startswith(".r") and suffix[2:].isdecimal()) or (suffix.startswith(".blend") and suffix[6:].isdecimal()) or suffix.endswith("blend@") ): # Do not add certain file extensions, ever: # .r### files are from SVN conflicts waiting to be resolved. # .blend@ is the Blender filesave temp file. # .blend### are Blender backup files. continue svn_paths.append(svn_path_str) wc_status, repos_status, revision = status_info file_entry = repo.external_files.get(svn_path_str) entry_existed = True if not file_entry: entry_existed = False file_entry = repo.external_files.add() file_entry.svn_path = svn_path_str if not file_entry.exists: new_files_on_repo.add((file_entry.svn_path, repos_status)) if entry_existed and ( file_entry.repos_status == 'none' and repos_status != 'none' ): new_files_on_repo.add((file_entry.svn_path, repos_status)) file_entry.revision = revision file_entry.status = wc_status file_entry.repos_status = repos_status file_entry.status_prediction_type = 'NONE' file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path)) if new_files_on_repo: # File entry status has changed between local and repo. file_strings = [] for svn_path, repos_status in new_files_on_repo: status_char = constants.SVN_STATUS_NAME_TO_CHAR.get(repos_status, " ") file_strings.append(f"{status_char} {svn_path}") print( "SVN: Detected file changes on remote:\n", "\n".join(file_strings), "\nUpdating log...\n", ) Processes.start('Log') # Remove file entries who no longer seem to have an SVN status. # This can happen if an unversioned file was removed from the filesystem, # Or sub-folders whose parent was Un-Added to the SVN. for file_entry in repo.external_files[:]: if file_entry.svn_path not in svn_paths: repo.remove_file_entry(file_entry) def svn_status_xml_to_dict(svn_status_str: str) -> Dict[str, Tuple[str, str, int]]: svn_status_xml = xmltodict.parse(svn_status_str) file_infos = svn_status_xml['status']['target']['entry'] # print(json.dumps(file_infos, indent=4)) file_statuses = {} for file_info in file_infos: filepath = file_info.get('@path') assert filepath, f"Filepath was not found in an SVN status entry:\n{file_info}" # Remote Repository status. repos_status = "none" if 'repos-status' in file_info: repos_status_block = file_info.get('repos-status') if repos_status_block: repos_status = repos_status_block.get('@item', "none") # _repo_props = repos_status_block.get('@props') # Working Copy status. wc_status_block = file_info.get('wc-status') wc_status = wc_status_block.get('@item', 'normal') # _revision = int(wc_status_block.get('@revision', 0)) # _props = wc_status_block['@props'] commit_revision = 0 if 'commit' in wc_status_block: commit_block = wc_status_block['commit'] if commit_block: commit_revision = int(commit_block.get('@revision', 0)) # _commit_author = commit_block.get('author') # _commit_date = commit_block.get('date') file_statuses[filepath] = (wc_status, repos_status, commit_revision) return file_statuses def update_file_list_svn_list(context, svn_list_str: str) -> Dict: repo = context.scene.svn.get_repo(context) try: svn_list_xml = xmltodict.parse(svn_list_str) except: # This seems to fail with an "ExpatError" on Windows...? return file_infos = svn_list_xml['lists']['list']['entry'] for file_info in file_infos: svn_path = file_info['name'] kind = file_info['@kind'] file_entry = repo.external_files.get(svn_path) if not file_entry: file_entry = repo.external_files.add() file_entry.svn_path = svn_path file_entry.absolute_path = str(repo.svn_to_absolute_path(svn_path)) if not file_entry.exists: file_entry.status = 'none' file_entry.repos_status = 'added' file_entry.status_prediction_type = 'NONE' if kind == 'file': file_entry.file_size_KiB = float(file_info['size']) / 1024.0 @bpy.app.handlers.persistent def mark_current_file_as_modified(_dummy1=None, _dummy2=None): context = bpy.context scene_svn = context.scene.svn if not scene_svn.svn_directory: return repo = scene_svn.get_repo(context) if not repo: return current_blend = repo.current_blend_file if current_blend: current_blend.status = 'modified' current_blend.status_prediction_type = 'SKIP_ONCE' ################################################################################ ############################# REGISTER ######################################### ################################################################################ def delayed_init_svn(delay=1): bpy.app.timers.register(ensure_svn_of_current_file, first_interval=delay) def register(): bpy.app.handlers.load_post.append(ensure_svn_of_current_file) bpy.app.handlers.save_post.append(ensure_svn_of_current_file) bpy.app.handlers.save_post.append(mark_current_file_as_modified) delayed_init_svn() def unregister(): bpy.app.handlers.load_post.remove(ensure_svn_of_current_file) bpy.app.handlers.save_post.remove(ensure_svn_of_current_file) bpy.app.handlers.save_post.remove(mark_current_file_as_modified) Processes.kill('Status') registry = [SVN_OT_explain_status]