mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 06:21:40 +00:00
initial commit
This commit is contained in:
committed by
Dion Moult
parent
2bcfe93dd2
commit
bf945e36bd
@@ -0,0 +1,59 @@
|
|||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software Foundation,
|
||||||
|
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||||
|
#
|
||||||
|
# 2023 Bruno Postle <bruno@postle.net>, Bruno Perdigão <brunoperdigao@tutanota.com>,
|
||||||
|
# Massimo Fabbro <maxfb87@yahoo.it>
|
||||||
|
|
||||||
|
# import os
|
||||||
|
# import sys
|
||||||
|
import bpy
|
||||||
|
from . import ui, prop, operator
|
||||||
|
|
||||||
|
# sys.path.insert(0, "/home/bruno/src/ifc-git")
|
||||||
|
# sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# bl_info = {
|
||||||
|
# "name": "IFC Git",
|
||||||
|
# "author": "Bruno Postle",
|
||||||
|
# "location": "Scene > IFC Git",
|
||||||
|
# "description": "Manage IFC files in Git repositories",
|
||||||
|
# "blender": (2, 80, 0),
|
||||||
|
# "category": "Import-Export",
|
||||||
|
# }
|
||||||
|
|
||||||
|
classes = (
|
||||||
|
operators.AddFileToRepo,
|
||||||
|
operators.CommitChanges,
|
||||||
|
operators.CreateRepo,
|
||||||
|
operators.DiscardUncommitted,
|
||||||
|
operators.DisplayRevision,
|
||||||
|
operators.DisplayUncommitted,
|
||||||
|
operators.Merge,
|
||||||
|
operators.RefreshGit,
|
||||||
|
operators.SwitchRevision,
|
||||||
|
prop.IfcGitListItem,
|
||||||
|
prop.IfcGitProperties,
|
||||||
|
ui.IFCGIT_PT_panel,
|
||||||
|
ui.COMMIT_UL_List,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
bpy.types.Scene.IfcGitProperties = bpy.props.PointerProperty(type=prop.IfcGitProperties)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
del bpy.types.Scene.IfcGitProperties
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import bpy
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tool
|
||||||
|
import blenderbim.tool as btool
|
||||||
|
|
||||||
|
|
||||||
|
def refresh():
|
||||||
|
IfcGitData.is_loaded = False
|
||||||
|
|
||||||
|
|
||||||
|
class IfcGitData:
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
is_loaded = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls):
|
||||||
|
cls.data = {
|
||||||
|
"repo": cls.repo(),
|
||||||
|
"branch_names": cls.branch_names(),
|
||||||
|
"path_ifc": cls.path_ifc(),
|
||||||
|
"branches_by_hexsha": cls.branches_by_hexsha(),
|
||||||
|
"tags_by_hexsha": cls.tags_by_hexsha(),
|
||||||
|
"name_ifc": cls.name_ifc(),
|
||||||
|
"dir_name": cls.dir_name(),
|
||||||
|
"base_name": cls.base_name(),
|
||||||
|
"is_dirty": cls.is_dirty(),
|
||||||
|
"commit": cls.commit(),
|
||||||
|
"current_revision": cls.current_revision(),
|
||||||
|
"git_exe": cls.git_exe(),
|
||||||
|
"ifcmerge_exe": cls.ifcmerge_exe(),
|
||||||
|
}
|
||||||
|
cls.is_loaded = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def repo(cls):
|
||||||
|
if bool(btool.Ifc.get()):
|
||||||
|
path_ifc = btool.Ifc.get_path()
|
||||||
|
if not os.path.isfile(path_ifc):
|
||||||
|
return None
|
||||||
|
return tool.IfcGit.repo_from_path(path_ifc)
|
||||||
|
else:
|
||||||
|
return tool.IfcGitRepo.repo
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def branch_names(cls):
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def path_ifc(cls):
|
||||||
|
return btool.Ifc.get_path()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def branches_by_hexsha(cls):
|
||||||
|
try:
|
||||||
|
if tool.IfcGitRepo.repo.branches:
|
||||||
|
return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo)
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tags_by_hexsha(cls):
|
||||||
|
if tool.IfcGitRepo.repo:
|
||||||
|
return tool.IfcGit.tags_by_hexsha(tool.IfcGitRepo.repo)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def name_ifc(cls):
|
||||||
|
if bool(btool.Ifc.get()):
|
||||||
|
path_ifc = btool.Ifc.get_path()
|
||||||
|
if tool.IfcGitRepo.repo:
|
||||||
|
working_dir = tool.IfcGitRepo.repo.working_dir
|
||||||
|
return os.path.relpath(path_ifc, working_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def dir_name(cls):
|
||||||
|
if bool(btool.Ifc.get()):
|
||||||
|
path_ifc = btool.Ifc.get_path()
|
||||||
|
if not os.path.isfile(path_ifc):
|
||||||
|
return None
|
||||||
|
return os.path.dirname(path_ifc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def base_name(cls):
|
||||||
|
if bool(btool.Ifc.get()):
|
||||||
|
path_ifc = btool.Ifc.get_path()
|
||||||
|
return os.path.basename(path_ifc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_dirty(cls):
|
||||||
|
if tool.IfcGitRepo.repo and cls.git_exe():
|
||||||
|
path_ifc = btool.Ifc.get_path()
|
||||||
|
return tool.IfcGitRepo.repo.is_dirty(path=path_ifc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def commit(cls):
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
if len(props.ifcgit_commits) > 0:
|
||||||
|
item = props.ifcgit_commits[props.commit_index]
|
||||||
|
return tool.IfcGitRepo.repo.commit(rev=item.hexsha)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def current_revision(cls):
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
if len(props.ifcgit_commits) > 0:
|
||||||
|
return tool.IfcGitRepo.repo.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def git_exe(cls):
|
||||||
|
return shutil.which("git")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ifcmerge_exe(cls):
|
||||||
|
return shutil.which("ifcmerge")
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
import core
|
||||||
|
from tool import IfcGit as tool
|
||||||
|
from data import IfcGitData
|
||||||
|
|
||||||
|
# TODO Remove all handler elements once in the correct folder structure
|
||||||
|
import handler
|
||||||
|
|
||||||
|
import blenderbim.tool as btool
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRepo(bpy.types.Operator):
|
||||||
|
"""Initialise a Git repository"""
|
||||||
|
|
||||||
|
bl_label = "Create Git repository"
|
||||||
|
bl_idname = "ifcgit.createrepo"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
path_ifc = IfcGitData.data["path_ifc"]
|
||||||
|
if not os.path.isfile(path_ifc):
|
||||||
|
return False
|
||||||
|
if IfcGitData.data["repo"]:
|
||||||
|
# repo already exists
|
||||||
|
return False
|
||||||
|
if re.match("^/home/[^/]+/?$", os.path.dirname(path_ifc)):
|
||||||
|
# don't make ${HOME} a repo
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
core.create_repo(tool, btool.Ifc)
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class AddFileToRepo(bpy.types.Operator):
|
||||||
|
"""Add a file to a repository"""
|
||||||
|
|
||||||
|
bl_label = "Add file to repository"
|
||||||
|
bl_idname = "ifcgit.addfile"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
path_ifc = IfcGitData.data["path_ifc"]
|
||||||
|
if not os.path.isfile(path_ifc):
|
||||||
|
return False
|
||||||
|
if not IfcGitData.data["repo"]:
|
||||||
|
# repo doesn't exist
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
core.add_file(tool, btool.Ifc)
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class DiscardUncommitted(bpy.types.Operator):
|
||||||
|
"""Discard saved changes and update to HEAD"""
|
||||||
|
|
||||||
|
bl_label = "Discard uncommitted changes"
|
||||||
|
bl_idname = "ifcgit.discard"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
core.discard_uncomitted(tool, btool.Ifc)
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class CommitChanges(bpy.types.Operator):
|
||||||
|
"""Commit current saved changes"""
|
||||||
|
|
||||||
|
bl_label = "Commit changes"
|
||||||
|
bl_idname = "ifcgit.commit_changes"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
props = context.scene.IfcGitProperties
|
||||||
|
repo = IfcGitData.data["repo"]
|
||||||
|
if props.commit_message == "":
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
repo
|
||||||
|
and repo.head.is_detached
|
||||||
|
and (
|
||||||
|
not tool.is_valid_ref_format(props.new_branch_name)
|
||||||
|
or props.new_branch_name in [branch.name for branch in repo.branches]
|
||||||
|
)
|
||||||
|
):
|
||||||
|
cls.poll_message_set(
|
||||||
|
"The new branch name is invalid, please insert a valid branch name (eg. with no spaces, ...)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
repo = IfcGitData.data["repo"]
|
||||||
|
core.commit_changes(tool, btool.Ifc, repo, context)
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshGit(bpy.types.Operator):
|
||||||
|
"""Refresh revision list"""
|
||||||
|
|
||||||
|
bl_label = ""
|
||||||
|
bl_idname = "ifcgit.refresh"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
repo = IfcGitData.data["repo"]
|
||||||
|
if repo != None and repo.heads:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
repo = IfcGitData.data["repo"]
|
||||||
|
core.refresh_revision_list(tool, repo, btool.Ifc)
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayRevision(bpy.types.Operator):
|
||||||
|
"""Colourise objects by selected revision"""
|
||||||
|
|
||||||
|
bl_label = ""
|
||||||
|
bl_idname = "ifcgit.display_revision"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
core.colourise_revision(tool, context)
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayUncommitted(bpy.types.Operator):
|
||||||
|
"""Colourise uncommitted objects"""
|
||||||
|
|
||||||
|
bl_label = "Show uncommitted changes"
|
||||||
|
bl_idname = "ifcgit.display_uncommitted"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
repo = IfcGitData.data["repo"]
|
||||||
|
core.colourise_uncommitted(tool, btool.Ifc, repo)
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchRevision(bpy.types.Operator):
|
||||||
|
"""Switches the repository to the selected revision and reloads the IFC file"""
|
||||||
|
|
||||||
|
bl_label = ""
|
||||||
|
bl_idname = "ifcgit.switch_revision"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
core.switch_revision(tool, btool.Ifc)
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class Merge(bpy.types.Operator):
|
||||||
|
"""Merges the selected branch into working branch"""
|
||||||
|
|
||||||
|
bl_label = "Merge this branch"
|
||||||
|
bl_idname = "ifcgit.merge"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
if IfcGitData.data["ifcmerge_exe"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
|
||||||
|
if core.merge_branch(tool, btool.Ifc, self):
|
||||||
|
handler.refresh_ui_data()
|
||||||
|
return {"FINISHED"}
|
||||||
|
else:
|
||||||
|
return {"CANCELLED"}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import bpy
|
||||||
|
from bpy.types import PropertyGroup
|
||||||
|
|
||||||
|
from bpy.props import (
|
||||||
|
StringProperty,
|
||||||
|
BoolProperty,
|
||||||
|
CollectionProperty,
|
||||||
|
IntProperty,
|
||||||
|
EnumProperty,
|
||||||
|
)
|
||||||
|
|
||||||
|
from data import IfcGitData
|
||||||
|
|
||||||
|
|
||||||
|
def git_branches(self, context):
|
||||||
|
"""branches enum"""
|
||||||
|
|
||||||
|
# NOTE "Python must keep a reference to the strings returned by
|
||||||
|
# the callback or Blender will misbehave or even crash"
|
||||||
|
IfcGitData.data["branch_names"] = sorted(
|
||||||
|
[branch.name for branch in IfcGitData.data["repo"].heads]
|
||||||
|
)
|
||||||
|
|
||||||
|
if "main" in IfcGitData.data["branch_names"]:
|
||||||
|
IfcGitData.data["branch_names"].remove("main")
|
||||||
|
IfcGitData.data["branch_names"] = ["main"] + IfcGitData.data["branch_names"]
|
||||||
|
|
||||||
|
return [(myname, myname, myname) for myname in IfcGitData.data["branch_names"]]
|
||||||
|
|
||||||
|
|
||||||
|
def update_revlist(self, context):
|
||||||
|
"""wrapper to trigger update of the revision list"""
|
||||||
|
|
||||||
|
bpy.ops.ifcgit.refresh()
|
||||||
|
props = context.scene.IfcGitProperties
|
||||||
|
props.commit_index = 0
|
||||||
|
|
||||||
|
|
||||||
|
class IfcGitListItem(PropertyGroup):
|
||||||
|
"""Group of properties representing an item in the list."""
|
||||||
|
|
||||||
|
hexsha: StringProperty(
|
||||||
|
name="Git hash",
|
||||||
|
description="checksum for this commit",
|
||||||
|
default="Uncommitted data!",
|
||||||
|
)
|
||||||
|
relevant: BoolProperty(
|
||||||
|
name="Is relevant",
|
||||||
|
description="does this commit reference our ifc file",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IfcGitProperties(PropertyGroup):
|
||||||
|
|
||||||
|
ifcgit_commits: CollectionProperty(type=IfcGitListItem, name="List of git items")
|
||||||
|
commit_index: IntProperty(name="Index for my_list", default=0)
|
||||||
|
commit_message: StringProperty(
|
||||||
|
name="Commit message",
|
||||||
|
description="A human readable description of these changes",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
new_branch_name: StringProperty(
|
||||||
|
name="New branch name",
|
||||||
|
description="A short name used to refer to this branch",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
display_branch: EnumProperty(items=git_branches, update=update_revlist)
|
||||||
|
ifcgit_filter: EnumProperty(
|
||||||
|
items=[
|
||||||
|
("all", "All", "All revisions"),
|
||||||
|
("tagged", "Tagged", "Tagged revisions"),
|
||||||
|
("relevant", "Relevant", "Revisions for this project"),
|
||||||
|
],
|
||||||
|
update=update_revlist,
|
||||||
|
)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
|
||||||
|
import bpy
|
||||||
|
import time
|
||||||
|
from data import IfcGitData
|
||||||
|
|
||||||
|
|
||||||
|
class IFCGIT_PT_panel(bpy.types.Panel):
|
||||||
|
"""Scene Properties panel to interact with IFC repository data"""
|
||||||
|
|
||||||
|
bl_label = "IFC Git"
|
||||||
|
bl_space_type = "PROPERTIES"
|
||||||
|
bl_region_type = "WINDOW"
|
||||||
|
bl_context = "scene"
|
||||||
|
bl_options = {"DEFAULT_CLOSED"}
|
||||||
|
bl_parent_id = "BIM_PT_project_info"
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
|
||||||
|
if not IfcGitData.is_loaded:
|
||||||
|
IfcGitData.load()
|
||||||
|
|
||||||
|
layout = self.layout
|
||||||
|
path_ifc = IfcGitData.data["path_ifc"]
|
||||||
|
|
||||||
|
if not IfcGitData.data["git_exe"]:
|
||||||
|
row = layout.row()
|
||||||
|
row.label(text="Git is not installed", icon="ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
props = context.scene.IfcGitProperties
|
||||||
|
|
||||||
|
# TODO if file isn't saved, offer to save to disk
|
||||||
|
|
||||||
|
row = layout.row()
|
||||||
|
if path_ifc:
|
||||||
|
if IfcGitData.data["repo"]:
|
||||||
|
name_ifc = IfcGitData.data["name_ifc"]
|
||||||
|
row.label(text=IfcGitData.data["repo"].working_dir, icon="SYSTEM")
|
||||||
|
if name_ifc in IfcGitData.data["repo"].untracked_files:
|
||||||
|
row.operator(
|
||||||
|
"ifcgit.addfile",
|
||||||
|
text="Add '" + name_ifc + "' to repository",
|
||||||
|
icon="FILE",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.label(text=name_ifc, icon="FILE")
|
||||||
|
else:
|
||||||
|
row.operator(
|
||||||
|
"ifcgit.createrepo",
|
||||||
|
text="Create '" + IfcGitData.data["dir_name"] + "' repository",
|
||||||
|
icon="SYSTEM",
|
||||||
|
)
|
||||||
|
row.label(text=IfcGitData.data["base_name"], icon="FILE")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
row.label(text="No Git repository found", icon="SYSTEM")
|
||||||
|
row.label(text="No IFC project saved", icon="FILE")
|
||||||
|
return
|
||||||
|
|
||||||
|
is_dirty = IfcGitData.data["is_dirty"]
|
||||||
|
|
||||||
|
if is_dirty:
|
||||||
|
row = layout.row()
|
||||||
|
row.label(text="Saved changes have not been committed", icon="ERROR")
|
||||||
|
|
||||||
|
row = layout.row()
|
||||||
|
row.operator("ifcgit.display_uncommitted", icon="SELECT_DIFFERENCE")
|
||||||
|
row.operator("ifcgit.discard", icon="TRASH")
|
||||||
|
|
||||||
|
row = layout.row()
|
||||||
|
row.prop(props, "commit_message")
|
||||||
|
|
||||||
|
if IfcGitData.data["repo"].head.is_detached:
|
||||||
|
row = layout.row()
|
||||||
|
row.label(
|
||||||
|
text="HEAD is detached, commit will create a branch", icon="ERROR"
|
||||||
|
)
|
||||||
|
row.prop(props, "new_branch_name")
|
||||||
|
|
||||||
|
row = layout.row()
|
||||||
|
row.operator("ifcgit.commit_changes", icon="GREASEPENCIL")
|
||||||
|
|
||||||
|
row = layout.row()
|
||||||
|
if IfcGitData.data["repo"].head.is_detached:
|
||||||
|
row.label(text="Working branch: Detached HEAD")
|
||||||
|
else:
|
||||||
|
row.label(
|
||||||
|
text="Working branch: " + IfcGitData.data["repo"].active_branch.name
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped = layout.row()
|
||||||
|
column = grouped.column()
|
||||||
|
row = column.row()
|
||||||
|
row.prop(props, "display_branch", text="Browse branch")
|
||||||
|
row.prop(props, "ifcgit_filter", text="Filter revisions")
|
||||||
|
|
||||||
|
row = column.row()
|
||||||
|
row.template_list(
|
||||||
|
"COMMIT_UL_List",
|
||||||
|
"The_List",
|
||||||
|
props,
|
||||||
|
"ifcgit_commits",
|
||||||
|
props,
|
||||||
|
"commit_index",
|
||||||
|
)
|
||||||
|
column = grouped.column()
|
||||||
|
row = column.row()
|
||||||
|
row.operator("ifcgit.refresh", icon="FILE_REFRESH")
|
||||||
|
|
||||||
|
if not is_dirty:
|
||||||
|
|
||||||
|
row = column.row()
|
||||||
|
row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE")
|
||||||
|
|
||||||
|
row = column.row()
|
||||||
|
row.operator("ifcgit.switch_revision", icon="CURRENT_FILE")
|
||||||
|
|
||||||
|
# TODO operator to tag selected
|
||||||
|
|
||||||
|
row = column.row()
|
||||||
|
row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="")
|
||||||
|
|
||||||
|
if not props.ifcgit_commits:
|
||||||
|
return
|
||||||
|
|
||||||
|
item = props.ifcgit_commits[props.commit_index]
|
||||||
|
commit = IfcGitData.data["commit"]
|
||||||
|
if not item.relevant:
|
||||||
|
row = layout.row()
|
||||||
|
row.label(text="Revision unrelated to current IFC project", icon="ERROR")
|
||||||
|
|
||||||
|
box = layout.box()
|
||||||
|
column = box.column(align=True)
|
||||||
|
row = column.row()
|
||||||
|
row.label(text=commit.hexsha)
|
||||||
|
row = column.row()
|
||||||
|
row.label(text=commit.author.name + " <" + commit.author.email + ">")
|
||||||
|
row = column.row()
|
||||||
|
row.label(text=commit.message)
|
||||||
|
|
||||||
|
|
||||||
|
class COMMIT_UL_List(bpy.types.UIList):
|
||||||
|
"""List of Git commits"""
|
||||||
|
|
||||||
|
def draw_item(
|
||||||
|
self, context, layout, data, item, icon, active_data, active_propname, index
|
||||||
|
):
|
||||||
|
|
||||||
|
props = context.scene.IfcGitProperties
|
||||||
|
|
||||||
|
current_revision = IfcGitData.data["current_revision"]
|
||||||
|
|
||||||
|
# TODO Figure how this "item" can be acesse in "data.py"
|
||||||
|
# so it's possible to move the ".commit"
|
||||||
|
commit = IfcGitData.data["repo"].commit(rev=item.hexsha)
|
||||||
|
|
||||||
|
lookup = IfcGitData.data["branches_by_hexsha"]
|
||||||
|
refs = ""
|
||||||
|
if item.hexsha in lookup:
|
||||||
|
for branch in lookup[item.hexsha]:
|
||||||
|
if branch.name == props.display_branch:
|
||||||
|
refs = "[" + branch.name + "] "
|
||||||
|
|
||||||
|
lookup = IfcGitData.data["tags_by_hexsha"]
|
||||||
|
if item.hexsha in lookup:
|
||||||
|
for tag in lookup[item.hexsha]:
|
||||||
|
refs += "{" + tag.name + "} "
|
||||||
|
|
||||||
|
if commit == current_revision:
|
||||||
|
layout.label(
|
||||||
|
text="[HEAD] " + refs + commit.message, icon="DECORATE_KEYFRAME"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
layout.label(text=refs + commit.message, icon="DECORATE_ANIMATE")
|
||||||
|
layout.label(text=time.strftime("%c", time.localtime(commit.committed_date)))
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
def create_repo(ifcgit, ifc):
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
path_dir = ifcgit.get_path_dir(path_ifc)
|
||||||
|
ifcgit.init_repo(path_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def add_file(ifcgit, ifc):
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
repo = ifcgit.repo_from_path(path_ifc)
|
||||||
|
ifcgit.add_file_to_repo(repo, path_ifc)
|
||||||
|
|
||||||
|
|
||||||
|
def discard_uncomitted(ifcgit, ifc):
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
# NOTE this is calling the git binary in a subprocess
|
||||||
|
ifcgit.git_checkout(path_ifc)
|
||||||
|
ifcgit.load_project(path_ifc)
|
||||||
|
|
||||||
|
|
||||||
|
def commit_changes(ifcgit, ifc, repo, context):
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
ifcgit.git_commit(path_ifc)
|
||||||
|
|
||||||
|
if repo.head.is_detached:
|
||||||
|
ifcgit.create_new_branch()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_revision_list(ifcgit, repo, ifc):
|
||||||
|
ifcgit.clear_commits_list()
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
lookup = ifcgit.tags_by_hexsha(repo)
|
||||||
|
ifcgit.get_commits_list(path_ifc, lookup)
|
||||||
|
|
||||||
|
|
||||||
|
def colourise_revision(ifcgit, context):
|
||||||
|
|
||||||
|
step_ids = ifcgit.get_revisions_step_ids()
|
||||||
|
if not step_ids:
|
||||||
|
return
|
||||||
|
modified_shape_object_step_ids = ifcgit.get_modified_shape_object_step_ids(step_ids)
|
||||||
|
final_step_ids = ifcgit.update_step_ids(step_ids, modified_shape_object_step_ids)
|
||||||
|
ifcgit.colourise(final_step_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def colourise_uncommitted(ifcgit, ifc, repo):
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
step_ids = ifcgit.ifc_diff_ids(repo, None, "HEAD", path_ifc)
|
||||||
|
ifcgit.colourise(step_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def switch_revision(ifcgit, ifc):
|
||||||
|
# FIXME bad things happen when switching to a revision that predates current project
|
||||||
|
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
ifcgit.switch_to_revision_item()
|
||||||
|
ifcgit.load_project(path_ifc)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_branch(ifcgit, ifc, operator):
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
ifcgit.config_ifcmerge()
|
||||||
|
ifcgit.execute_merge(path_ifc, operator)
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
# allows git import even if git executable isn't found
|
||||||
|
os.environ["GIT_PYTHON_REFRESH"] = "quiet"
|
||||||
|
import git
|
||||||
|
import bpy
|
||||||
|
from blenderbim.bim.ifc import IfcStore
|
||||||
|
import blenderbim.tool as tool
|
||||||
|
|
||||||
|
|
||||||
|
class IfcGit:
|
||||||
|
@classmethod
|
||||||
|
def init_repo(cls, path_dir):
|
||||||
|
IfcGitRepo.repo = git.Repo.init(path_dir)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_path_dir(cls, path_ifc):
|
||||||
|
return os.path.abspath(os.path.dirname(path_ifc))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def repo_from_path(cls, path):
|
||||||
|
"""Returns a Git repository object or None"""
|
||||||
|
|
||||||
|
if os.path.isdir(path):
|
||||||
|
path_dir = os.path.abspath(path)
|
||||||
|
elif os.path.isfile(path):
|
||||||
|
path_dir = os.path.abspath(os.path.dirname(path))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if IfcGitRepo.repo != None and IfcGitRepo.repo.working_dir == path_dir:
|
||||||
|
return IfcGitRepo.repo
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo = git.Repo(path_dir)
|
||||||
|
except:
|
||||||
|
parentdir_path = os.path.abspath(os.path.join(path_dir, os.pardir))
|
||||||
|
if parentdir_path == path_dir:
|
||||||
|
# root folder
|
||||||
|
return None
|
||||||
|
return IfcGit.repo_from_path(parentdir_path)
|
||||||
|
if repo:
|
||||||
|
IfcGitRepo.repo = repo
|
||||||
|
return repo
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_file_to_repo(cls, repo, path_file):
|
||||||
|
repo.index.add(path_file)
|
||||||
|
repo.index.commit(
|
||||||
|
message="Added " + os.path.relpath(path_file, repo.working_dir)
|
||||||
|
)
|
||||||
|
bpy.ops.ifcgit.refresh()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def git_checkout(cls, path_file):
|
||||||
|
IfcGitRepo.repo.git.checkout(path_file)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def git_commit(cls, path_file):
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
repo = IfcGitRepo.repo
|
||||||
|
repo.index.add(path_file)
|
||||||
|
repo.index.commit(message=props.commit_message)
|
||||||
|
props.commit_message = ""
|
||||||
|
return repo
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_new_branch(cls):
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
repo = IfcGitRepo.repo
|
||||||
|
new_branch = repo.create_head(props.new_branch_name)
|
||||||
|
new_branch.checkout()
|
||||||
|
props.display_branch = props.new_branch_name
|
||||||
|
props.new_branch_name = ""
|
||||||
|
|
||||||
|
bpy.ops.ifcgit.refresh()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_commits_list(cls):
|
||||||
|
area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D")
|
||||||
|
area.spaces[0].shading.color_type = "MATERIAL"
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
|
||||||
|
# ifcgit_commits is registered list widget
|
||||||
|
props.ifcgit_commits.clear()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_commits_list(cls, path_ifc, lookup):
|
||||||
|
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
commits = list(
|
||||||
|
git.objects.commit.Commit.iter_items(
|
||||||
|
repo=IfcGitRepo.repo,
|
||||||
|
rev=[props.display_branch],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
commits_relevant = list(
|
||||||
|
git.objects.commit.Commit.iter_items(
|
||||||
|
repo=IfcGitRepo.repo,
|
||||||
|
rev=[props.display_branch],
|
||||||
|
paths=[path_ifc],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for commit in commits:
|
||||||
|
|
||||||
|
if props.ifcgit_filter == "tagged" and not commit.hexsha in lookup:
|
||||||
|
continue
|
||||||
|
elif props.ifcgit_filter == "relevant" and not commit in commits_relevant:
|
||||||
|
continue
|
||||||
|
|
||||||
|
props.ifcgit_commits.add()
|
||||||
|
props.ifcgit_commits[-1].hexsha = commit.hexsha
|
||||||
|
if commit in commits_relevant:
|
||||||
|
props.ifcgit_commits[-1].relevant = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_valid_ref_format(cls, string):
|
||||||
|
"""Check a bare branch or tag name is valid"""
|
||||||
|
|
||||||
|
return re.match(
|
||||||
|
"^(?!\.| |-|/)((?!\.\.)(?!.*/\.)(/\*|/\*/)*(?!@\{)[^\~\:\^\\\ \?*\[])+(?<!\.|/)(?<!\.lock)$",
|
||||||
|
string,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_project(cls, path_ifc):
|
||||||
|
"""Clear and load an ifc project"""
|
||||||
|
|
||||||
|
IfcStore.purge()
|
||||||
|
# delete any IfcProject/* collections
|
||||||
|
for collection in bpy.data.collections:
|
||||||
|
if re.match("^IfcProject/", collection.name):
|
||||||
|
IfcGit.delete_collection(collection)
|
||||||
|
# delete any Ifc* objects not in IfcProject/ heirarchy
|
||||||
|
for obj in bpy.data.objects:
|
||||||
|
if re.match("^Ifc", obj.name):
|
||||||
|
bpy.data.objects.remove(obj, do_unlink=True)
|
||||||
|
|
||||||
|
bpy.data.orphans_purge(do_recursive=True)
|
||||||
|
|
||||||
|
bpy.ops.bim.load_project(filepath=path_ifc)
|
||||||
|
bpy.ops.ifcgit.refresh()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def branches_by_hexsha(cls, repo):
|
||||||
|
"""reverse lookup for branches"""
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for branch in repo.branches:
|
||||||
|
if branch.commit.hexsha in result:
|
||||||
|
result[branch.commit.hexsha].append(branch)
|
||||||
|
else:
|
||||||
|
result[branch.commit.hexsha] = [branch]
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tags_by_hexsha(cls, repo):
|
||||||
|
"""reverse lookup for tags"""
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for tag in repo.tags:
|
||||||
|
if tag.commit.hexsha in result:
|
||||||
|
result[tag.commit.hexsha].append(tag)
|
||||||
|
else:
|
||||||
|
result[tag.commit.hexsha] = [tag]
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ifc_diff_ids(cls, repo, hash_a, hash_b, path_ifc):
|
||||||
|
"""Given two revision hashes and a filename, retrieve"""
|
||||||
|
"""step-ids of modified, added and removed entities"""
|
||||||
|
|
||||||
|
# NOTE this is calling the git binary in a subprocess
|
||||||
|
if not hash_a:
|
||||||
|
diff_lines = repo.git.diff(hash_b, path_ifc).split("\n")
|
||||||
|
else:
|
||||||
|
diff_lines = repo.git.diff(hash_a, hash_b, path_ifc).split("\n")
|
||||||
|
|
||||||
|
inserted = set()
|
||||||
|
deleted = set()
|
||||||
|
for line in diff_lines:
|
||||||
|
re_search = re.search(r"^\+#([0-9]+)=", line)
|
||||||
|
if re_search:
|
||||||
|
inserted.add(int(re_search.group(1)))
|
||||||
|
continue
|
||||||
|
re_search = re.search(r"^-#([0-9]+)=", line)
|
||||||
|
if re_search:
|
||||||
|
deleted.add(int(re_search.group(1)))
|
||||||
|
|
||||||
|
modified = inserted.intersection(deleted)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"modified": modified,
|
||||||
|
"added": inserted.difference(modified),
|
||||||
|
"removed": deleted.difference(modified),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_revisions_step_ids(cls):
|
||||||
|
|
||||||
|
path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
repo = IfcGitRepo.repo
|
||||||
|
item = props.ifcgit_commits[props.commit_index]
|
||||||
|
|
||||||
|
selected_revision = repo.commit(rev=item.hexsha)
|
||||||
|
current_revision = repo.commit()
|
||||||
|
|
||||||
|
if selected_revision == current_revision:
|
||||||
|
area = next(
|
||||||
|
area for area in bpy.context.screen.areas if area.type == "VIEW_3D"
|
||||||
|
)
|
||||||
|
area.spaces[0].shading.color_type = "MATERIAL"
|
||||||
|
return
|
||||||
|
|
||||||
|
if current_revision.committed_date > selected_revision.committed_date:
|
||||||
|
step_ids = IfcGit.ifc_diff_ids(
|
||||||
|
repo,
|
||||||
|
selected_revision.hexsha,
|
||||||
|
current_revision.hexsha,
|
||||||
|
path_ifc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
step_ids = IfcGit.ifc_diff_ids(
|
||||||
|
repo,
|
||||||
|
current_revision.hexsha,
|
||||||
|
selected_revision.hexsha,
|
||||||
|
path_ifc,
|
||||||
|
)
|
||||||
|
return step_ids
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_modified_shape_object_step_ids(cls, step_ids):
|
||||||
|
model = tool.Ifc.get()
|
||||||
|
modified_shape_object_step_ids = {"modified": []}
|
||||||
|
|
||||||
|
for step_id in step_ids["modified"]:
|
||||||
|
if model.by_id(step_id).is_a() == "IfcProductDefinitionShape":
|
||||||
|
product = model.by_id(step_id).ShapeOfProduct[0]
|
||||||
|
modified_shape_object_step_ids["modified"].append(product.id())
|
||||||
|
|
||||||
|
return modified_shape_object_step_ids
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_step_ids(cls, step_ids, modified_shape_object_step_ids):
|
||||||
|
|
||||||
|
final_step_ids = {}
|
||||||
|
final_step_ids["added"] = step_ids["added"]
|
||||||
|
final_step_ids["removed"] = step_ids["removed"]
|
||||||
|
final_step_ids["modified"] = step_ids["modified"].union(
|
||||||
|
modified_shape_object_step_ids["modified"]
|
||||||
|
)
|
||||||
|
return final_step_ids
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def colourise(cls, step_ids):
|
||||||
|
area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D")
|
||||||
|
area.spaces[0].shading.color_type = "OBJECT"
|
||||||
|
|
||||||
|
for obj in bpy.context.visible_objects:
|
||||||
|
if not obj.BIMObjectProperties.ifc_definition_id:
|
||||||
|
continue
|
||||||
|
step_id = obj.BIMObjectProperties.ifc_definition_id
|
||||||
|
if step_id in step_ids["modified"]:
|
||||||
|
obj.color = (0.3, 0.3, 1.0, 1)
|
||||||
|
elif step_id in step_ids["added"]:
|
||||||
|
obj.color = (0.2, 0.8, 0.2, 1)
|
||||||
|
elif step_id in step_ids["removed"]:
|
||||||
|
obj.color = (1.0, 0.2, 0.2, 1)
|
||||||
|
else:
|
||||||
|
obj.color = (1.0, 1.0, 1.0, 0.5)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def switch_to_revision_item(cls):
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
repo = IfcGitRepo.repo
|
||||||
|
item = props.ifcgit_commits[props.commit_index]
|
||||||
|
|
||||||
|
lookup = IfcGit.branches_by_hexsha(repo)
|
||||||
|
if item.hexsha in lookup:
|
||||||
|
for branch in lookup[item.hexsha]:
|
||||||
|
if branch.name == props.display_branch:
|
||||||
|
branch.checkout()
|
||||||
|
else:
|
||||||
|
# NOTE this is calling the git binary in a subprocess
|
||||||
|
repo.git.checkout(item.hexsha)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_collection(cls, blender_collection):
|
||||||
|
for obj in blender_collection.objects:
|
||||||
|
bpy.data.objects.remove(obj, do_unlink=True)
|
||||||
|
bpy.data.collections.remove(blender_collection)
|
||||||
|
for collection in bpy.data.collections:
|
||||||
|
if not collection.users:
|
||||||
|
bpy.data.collections.remove(collection)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_valid_branch_name(cls, new_branch_name):
|
||||||
|
"""Check if a branch name is valid and doesn't conflict with existing branches"""
|
||||||
|
if not IfcGit.is_valid_ref_format(new_branch_name):
|
||||||
|
return False
|
||||||
|
if new_branch_name in [branch.name for branch in IfcGitRepo.repo.branches]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def config_ifcmerge(cls):
|
||||||
|
config_reader = IfcGitRepo.repo.config_reader()
|
||||||
|
section = 'mergetool "ifcmerge"'
|
||||||
|
if not config_reader.has_section(section):
|
||||||
|
config_writer = IfcGitRepo.repo.config_writer()
|
||||||
|
config_writer.set_value(
|
||||||
|
section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED"
|
||||||
|
)
|
||||||
|
config_writer.set_value(section, "trustExitCode", True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute_merge(cls, path_ifc, operator):
|
||||||
|
props = bpy.context.scene.IfcGitProperties
|
||||||
|
repo = IfcGitRepo.repo
|
||||||
|
item = props.ifcgit_commits[props.commit_index]
|
||||||
|
lookup = IfcGit.branches_by_hexsha(repo)
|
||||||
|
if item.hexsha in lookup:
|
||||||
|
for branch in lookup[item.hexsha]:
|
||||||
|
if branch.name == props.display_branch:
|
||||||
|
# this is a branch!
|
||||||
|
try:
|
||||||
|
# NOTE this is calling the git binary in a subprocess
|
||||||
|
repo.git.merge(branch)
|
||||||
|
except git.exc.GitCommandError:
|
||||||
|
# merge is expected to fail, run ifcmerge
|
||||||
|
try:
|
||||||
|
repo.git.mergetool(tool="ifcmerge")
|
||||||
|
except:
|
||||||
|
# ifcmerge failed, rollback
|
||||||
|
repo.git.merge(abort=True)
|
||||||
|
# FIXME need to report errors somehow
|
||||||
|
|
||||||
|
operator.report({"ERROR"}, "IFC Merge failed")
|
||||||
|
return False
|
||||||
|
except:
|
||||||
|
|
||||||
|
operator.report({"ERROR"}, "Unknown IFC Merge failure")
|
||||||
|
return False
|
||||||
|
|
||||||
|
repo.index.add(path_ifc)
|
||||||
|
props.commit_message = "Merged branch: " + props.display_branch
|
||||||
|
props.display_branch = repo.active_branch.name
|
||||||
|
|
||||||
|
IfcGit.load_project(path_ifc)
|
||||||
|
|
||||||
|
|
||||||
|
class IfcGitRepo:
|
||||||
|
repo = None
|
||||||
Reference in New Issue
Block a user