2025-12-01
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
# 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]
|
||||
Reference in New Issue
Block a user