Files
blender-portable-repo/scripts/addons/blender_svn/repository.py
T
2026-03-17 14:58:51 -06:00

607 lines
21 KiB
Python

# 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,
]