# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later from typing import Optional,Tuple from pathlib import Path import bpy from bpy.types import PropertyGroup from bpy.props import ( StringProperty, BoolProperty, CollectionProperty, IntProperty, EnumProperty, FloatProperty, ) from .threaded import svn_log from .threaded.background_process import Processes from .operators.svn_commit import SVN_commit_line from .svn_info import get_svn_info from .util import get_addon_prefs from . import constants class SVN_file(PropertyGroup): """Property Group that can represent a version of a File in an SVN repository.""" name: StringProperty( name="File Name", options=set() ) @property def svn_path(self): return self.name @svn_path.setter def svn_path(self, value): self.name = value @property def file_name(self): return Path(self.svn_path).name absolute_path: StringProperty( name="Absolute Path", description="Absolute filepath", options=set() ) status: EnumProperty( name="Status", description="SVN Status of the file in the local repository (aka working copy)", items=constants.ENUM_SVN_STATUS, default="normal", options=set() ) repos_status: EnumProperty( name="Remote's Status", description="SVN Status of the file in the remote repository (periodically updated)", items=constants.ENUM_SVN_STATUS, default="none", options=set() ) @property def will_conflict(self): return self.status != 'normal' and self.repos_status != 'none' status_prediction_type: EnumProperty( name="Status Predicted By Process", items=[ ("NONE", "None", "File status is not predicted, but actual."), ("SVN_UP", "Update", "File status is predicted by `svn up`. Status is protected until process is finished."), ("SVN_COMMIT", "Commit", "File status is predicted by `svn commit`. Status is protected until process is finished."), ("SKIP_ONCE", "Skip Once", "File status is predicted by a working-copy svn file operation, like Revert. Next status update should be ignored, and this enum should be set to SKIPPED_ONCE."), ("SKIPPED_ONCE", "Skipped Once", "File status update was skipped. Next status update can be considered accurate, and this flag can be reset to NONE. Until then, operations on this file should remain disabled."), ], description="Internal flag that notes what process set a predicted status on this file. Should be empty string when the status is not predicted but confirmed. When svn commit/update predicts a status, that status should not be overwritten until the process is finished. With instantaneous processes, a single status update should be ignored since it may be outdated", options=set() ) include_in_commit: BoolProperty( name="Commit", description="Whether this file should be included in the commit or not", default=False, options=set() ) @property def is_outdated(self): return self.repos_status == 'modified' and self.status == 'normal' @property def is_dir(self): if self.exists: return Path(self.absolute_path).is_dir() else: # This file may not exist locally yet, but it could still exist on the SVN, # and in this case we still want to provide a guess as to whether it's a folder or not. return "." not in Path(self.absolute_path).name revision: IntProperty( name="Revision", description="Revision number", options=set() ) @property def exists(self) -> bool: return Path(self.absolute_path).exists() @property def status_icon(self) -> str: return constants.SVN_STATUS_DATA[self.status][0] @property def status_name(self) -> str: if self.status == 'none': return 'Outdated' return self.status.title() @property def file_icon(self) -> str: if self.is_dir: return 'FILE_FOLDER' extension = Path(self.svn_path).suffix if extension in ['.abc']: return 'FILE_CACHE' elif 'blend' in extension: return 'FILE_BLEND' elif extension in [ '.tga', '.bmp', '.tif', '.tiff', '.tga', '.png', '.dds', '.jpg', '.exr', '.hdr', ]: return 'TEXTURE' elif extension in ['.psd', '.kra']: return 'IMAGE_DATA' elif extension in ['.mp4', '.mov']: return 'SEQUENCE' elif extension in ['.mp3', '.ogg', '.wav']: return 'SPEAKER' return 'QUESTION' @property def has_default_status(self): return self.status == 'normal' and self.repos_status == 'none' and self.status_prediction_type == 'NONE' show_in_filelist: BoolProperty( name="Show In File List", description="Flag indicating whether this file should be drawn in the file list. This flag is updated for every file whenever the file search string is modified. If we did this filtering during drawing time, it is painfully slow", default=False ) def get_file_size(self): num = self.file_size_KiB for unit in ("KiB", "MiB", "GiB", "TiB", "PiB", "EiB"): if num < 1024: return f"{num:3.1f} {unit}" num /= 1024.0 return f"{num:.1f} YiB" def update_file_size(self, _context): self.file_size = self.get_file_size() file_size_KiB: FloatProperty(description="One KibiByte (KiB) is 1024 bytes", update=update_file_size) file_size: StringProperty(description="File size for displaying in the UI") class SVN_log(PropertyGroup): """Property Group that can represent an SVN log entry.""" revision_number: IntProperty( name="Revision Number", description="Revision number of the current .blend file", ) revision_date: StringProperty( name="Revision Date", description="Date when the current revision was committed", ) revision_date_simple: StringProperty( name="Revision Date", description="Date when the current revision was committed", ) revision_author: StringProperty( name="Revision Author", description="SVN username of the revision author", ) commit_message: StringProperty( name="Commit Message", description="Commit message written by the commit author to describe the changes in this revision", ) changed_files: CollectionProperty( type=SVN_file, name="Changed Files", description="List of file entries that were affected by this revision. Note that these are NOT pointers to the actual file entries stored in repository.external_files. These are copies, merely serving to store a file path", ) def changes_file(self, file: SVN_file) -> bool: """Return whether the given file is among this log entry's changed files list.""" for affected_file in self.changed_files: if affected_file.svn_path == "/"+file.svn_path: return True return False text_to_search: StringProperty( name="Text to Search", description="Text to be used by the search filter. This should be set by calling set_search_string() when the log entry is created, and then never touched again", ) def set_search_string(self): rev = "r"+str(self.revision_number) auth = self.revision_author files = " ".join([f.svn_path for f in self.changed_files]) msg = self.commit_message date = self.revision_date_simple self.text_to_search = " ".join([rev, auth, files, msg, date]).lower() # Cached variables; Things that update when active file or search filter changes, # so that they don't have to be re-calculated on each re-draw of the log UI. matches_filter: BoolProperty( name="Matches Filter", description="Whether the log entry matches the currently typed in search filter. This is cached, and should be re-calculated ONLY whenever the search filter changes", default=True, ) affects_active_file: BoolProperty( name="Affects Active File", description="Flag set whenever the active file index updates. Used to accelerate drawing performance by moving filtering logic from the drawing code to update callbacks and flags", default=False ) class SVN_repository(PropertyGroup): ### Basic SVN Info. ### @property def name(self): return self.directory def update_repo_info_file(self, context): get_addon_prefs(context).save_repo_info_to_file() display_name: StringProperty( name="Display Name", description="Display name of this SVN repository", update=update_repo_info_file ) url: StringProperty( name="URL", description="URL of the remote repository", ) def update_directory(self, context): self.name = self.directory root_dir, base_url = get_svn_info(self.directory) if root_dir and base_url: self.initialize(root_dir, base_url) directory: StringProperty( name="Root Directory", default="", subtype="DIR_PATH", description="Absolute directory path of the SVN repository's root in the file system", update=update_directory ) @property def dir_exists(self): dir_path = Path(self.directory) return dir_path.exists() and dir_path.is_dir() @property def is_valid_svn(self): dir_path = Path(self.directory) # TODO: This property is checked pretty often, so we run `svn info` pretty often. Might not be a big deal, but maybe it's a bit overkill? root_dir, base_url = get_svn_info(self.directory) return ( dir_path.exists() and dir_path.is_dir() and root_dir and base_url and root_dir == self.directory and base_url == self.url ) def initialize(self, directory: str, url: str, display_name="", username="", password=""): self.url = url if username: self.username = username if password: self.password = password if self.directory != directory: # Don't set this if it's already set, to avoid infinite recursion # via the update callback. self.directory = directory if display_name: self.display_name = display_name else: self.display_name = Path(directory).name return self ### Credentials. ### def update_cred(self, context): if not (self.username and self.password): # Only try to authenticate if BOTH username AND pw are entered. self.authenticated = False return if get_addon_prefs(context).loading: return self.authenticate() self.update_repo_info_file(context) def authenticate(self): self.auth_failed = False if self.is_valid_svn and self.is_cred_entered: Processes.start('Authenticate') # Trigger the file list filtering. self.file_search_filter = self.file_search_filter username: StringProperty( name="Username", description="User name used for authentication with this SVN repository", update=update_cred ) password: StringProperty( name="Password", description="Password used for authentication with this SVN repository. This password is stored in your Blender user preferences as plain text. Somebody with access to your user preferences will be able to read your password", subtype='PASSWORD', update=update_cred ) @property def is_cred_entered(self) -> bool: """Check if there's a username and password entered at all.""" return bool(self.username and self.password) authenticated: BoolProperty( name="Authenticated", description="Internal flag to mark whether the last entered credentials were confirmed by the repo as correct credentials", default=False ) auth_failed: BoolProperty( name="Authentication Failed", description="Internal flag to mark whether the last entered credentials were rejected by the repo", default=False ) ### SVN Commit Message. ### commit_lines: CollectionProperty(type=SVN_commit_line) @property def commit_message(self): return "\n".join([l.line for l in self.commit_lines]).strip() @commit_message.setter def commit_message(self, msg: str): self.commit_lines.clear() for line in msg.split("\n"): line_entry = self.commit_lines.add() line_entry.line = line while len(self.commit_lines) < 3: self.commit_lines.add() ### SVN Log / Revision History. ### log: CollectionProperty(type=SVN_log) log_active_index: IntProperty( name="SVN Log", options=set() ) reload_svn_log = svn_log.reload_svn_log @property def log_file_path(self) -> Path: return Path(self.directory+"/.svn/svn.log") @property def active_log(self): try: return self.log[self.log_active_index] except IndexError: return None def get_log_by_revision(self, revision: int) -> Tuple[int, SVN_log]: for i, log in enumerate(self.log): if log.revision_number == revision: return i, log def get_latest_revision_of_file(self, svn_path: str) -> int: """Return the revision number of the last log entry that affects the given file.""" svn_path = str(svn_path) for log in reversed(self.log): for changed_file in log.changed_files: if changed_file.svn_path == "/"+str(svn_path): return log.revision_number return 0 def is_file_outdated(self, file: SVN_file) -> bool: """A file may have the 'modified' state while also being outdated. In this case SVN is of no use, we need to detect and handle this case by ourselves. """ latest = self.get_latest_revision_of_file(file.svn_path) current = file.revision return latest > current def get_file_abspath(self, file: SVN_file) -> Path: """Return the absolute path of an SVN file if it were in this repo.""" return Path(self.directory) / Path(file.svn_path) ### SVN File List. ### external_files: CollectionProperty(type=SVN_file) def remove_file_entry(self, file_entry: SVN_file): """Remove a file entry from the file list, based on its filepath.""" for i, f in enumerate(self.external_files): if f == file_entry: self.external_files.remove(i) if i <= self.external_files_active_index: self.external_files_active_index -= 1 return def absolute_to_svn_path(self, absolute_path: Path) -> Path: if type(absolute_path) == str: absolute_path = Path(absolute_path) svn_dir = Path(self.directory) try: return absolute_path.relative_to(svn_dir) except ValueError: return None def svn_to_absolute_path(self, svn_path: Path) -> Path: if type(svn_path) == str: svn_path = Path(svn_path) svn_dir = Path(self.directory) return svn_dir / svn_path def get_file_by_absolute_path(self, abs_path: str or Path) -> Optional[SVN_file]: rel_path = str(self.absolute_to_svn_path(abs_path)) if rel_path: return self.external_files.get(rel_path) def get_index_of_file(self, file_entry) -> Optional[int]: for i, file in enumerate(self.external_files): if file == file_entry: return i def force_update_ui_caches(self, context): """Update UI caches even if the active file index hasn't changed. This is used when loading a file. """ self.prev_external_files_active_index = -1 self.update_ui_caches(context) def update_ui_caches(self, context): """When user clicks on a different file, the latest log entry of that file should become the active log entry. NOTE: Try to only trigger this on explicit user actions! """ if self.external_files_active_index == self.prev_external_files_active_index: return self.prev_external_files_active_index = self.external_files_active_index if not self.active_file: return latest_rev = self.get_latest_revision_of_file( self.active_file.svn_path) # SVN Revisions are not 0-indexed, so we need to subtract 1. self.log_active_index = latest_rev-1 space = context.space_data if space and space.type == 'FILE_BROWSER': space.params.directory = Path(self.active_file.absolute_path).parent.as_posix().encode() space.params.filename = self.active_file.file_name.encode() space.deselect_all() # Set the active file in the file browser to whatever was selected # in the SVN Files panel. space.activate_file_by_relative_path( # This doesn't actually work, due to what I assume is a bug. relative_path=self.active_file.file_name) Processes.start('Activate File') # This is my work-around. # Set the filter flag of the log entries based on whether they affect the active file or not. self.log.foreach_set( 'affects_active_file', [log_entry.changes_file(self.active_file) for log_entry in self.log] ) prev_external_files_active_index: IntProperty( name="Previous Active Index", description="Internal value to avoid triggering the update callback unnecessarily", options=set() ) external_files_active_index: IntProperty( name="File List", description="Files tracked by SVN", update=update_ui_caches, options=set(), ) @property def active_file(self) -> SVN_file: if len(self.external_files) == 0: return return self.external_files[self.external_files_active_index] def is_filebrowser_directory_in_repo(self, context) -> bool: assert context.space_data.type == 'FILE_BROWSER', "This function needs a File Browser context." params = context.space_data.params abs_path = Path(params.directory.decode()) if not abs_path.exists(): return False return Path(self.directory) in [abs_path] + list(abs_path.parents) def get_filebrowser_active_file(self, context) -> SVN_file: assert context.space_data.type == 'FILE_BROWSER', "This function needs a File Browser context." params = context.space_data.params abs_path = Path(params.directory.decode()) / Path(params.filename) if not abs_path.exists(): return if Path(self.directory) not in abs_path.parents: return False svn_path = self.absolute_to_svn_path(abs_path) svn_file = self.external_files.get(svn_path) return svn_file @property def current_blend_file(self) -> SVN_file: return self.get_file_by_absolute_path(bpy.data.filepath) ### File List UIList filter properties ### def refresh_ui_lists(self, context): """Refresh the file UI list based on filter settings. Also triggers a refresh of the SVN UIList, through the update callback of external_files_active_index.""" UI_LIST = bpy.types.UI_UL_list if self.file_search_filter: filter_list = UI_LIST.filter_items_by_name( self.file_search_filter, 1, self.external_files, "name", reverse=False ) filter_list = [bool(val) for val in filter_list] self.external_files.foreach_set('show_in_filelist', filter_list) else: for file in self.external_files: if file == self.current_blend_file: file.show_in_filelist = True continue file.show_in_filelist = not file.has_default_status if len(self.external_files) == 0: return # Make sure the active file isn't now being filtered out. # If it is, change the active file to the first visible one. if self.active_file.show_in_filelist: return for i, file in enumerate(self.external_files): if file.show_in_filelist: self.external_files_active_index = i return file_search_filter: StringProperty( name="Search Filter", description="Only show entries that contain this string", update=refresh_ui_lists ) registry = [ SVN_file, SVN_log, SVN_repository, ]