Skip to content

Commit 2e59c35

Browse files
feat: implement monolithic template support and deprecate legacy commands
- Introduced support for a new monolithic template format, allowing for more streamlined channel and category management. - Updated the `!createchannel` and `!createcategory` commands to check for the new template format and provide deprecation warnings for legacy usage. - Enhanced the template application process with detailed logging and error handling for better user feedback. - Added functions to parse and validate the new template structure, ensuring robust configuration management.
1 parent c411702 commit 2e59c35

File tree

4 files changed

+322
-2
lines changed

4 files changed

+322
-2
lines changed

docs/src/user-guide/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Create one channel from a YAML file.
2929

3030
**Permissions:** Manage Channels
3131

32-
**Note:** Uses a fixed file path, This will be changed with the release of 1.X: `/home/user/Projects/gitcord-template/community/off-topic.yaml`
32+
**Note:** Now uses dynamic template paths. For servers using the new monolithic template format, this command shows a deprecation warning and suggests using `!git pull` instead.
3333

3434
### `!createcategory` / `/createcategory`
3535
Create a category with multiple channels.

src/gitcord/cogs/admin.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,13 @@ def _download_and_extract_github(
484484
async def _apply_template_from_dir(
485485
self, guild, template_dir, ctx=None, interaction=None
486486
):
487+
"""Apply template from directory - now looks for monolithic template.yaml first, falls back to legacy format."""
488+
# Try monolithic template format first
489+
template_path = os.path.join(template_dir, "template.yaml")
490+
if os.path.exists(template_path):
491+
return await self._apply_monolithic_template(guild, template_path, ctx, interaction)
492+
493+
# Fall back to legacy directory-based format
487494
result_msgs = []
488495
template_category_names = set()
489496
template_channel_names = set()
@@ -671,6 +678,175 @@ async def _apply_template_from_dir(
671678
result_msgs.append(msg)
672679
return result_msgs
673680

681+
async def _apply_monolithic_template(self, guild, template_path, ctx=None, interaction=None):
682+
"""Apply a monolithic template.yaml file to the guild."""
683+
from ..utils.helpers import parse_monolithic_template
684+
685+
result_msgs = []
686+
template_category_names = set()
687+
688+
try:
689+
template_config = parse_monolithic_template(template_path)
690+
except Exception as e:
691+
msg = f"❌ Failed to parse template: {e}"
692+
self.logger.error(f"[apply_monolithic_template] {msg}")
693+
result_msgs.append(msg)
694+
return result_msgs
695+
696+
# Log template info if available
697+
if "server" in template_config:
698+
server_info = template_config["server"]
699+
if "name" in server_info:
700+
msg = f"📋 Applying template: {server_info['name']}"
701+
if "version" in server_info:
702+
msg += f" v{server_info['version']}"
703+
self.logger.info(f"[apply_monolithic_template] {msg}")
704+
result_msgs.append(msg)
705+
706+
# Process each category
707+
for category_config in template_config["categories"]:
708+
category_name = category_config["name"]
709+
template_category_names.add(category_name)
710+
711+
# Create or update the category
712+
existing_category = discord.utils.get(guild.categories, name=category_name)
713+
if existing_category:
714+
category = existing_category
715+
msg = f"ℹ️ Category '{category_name}' already exists. Will update channels."
716+
else:
717+
category = await guild.create_category(
718+
name=category_name, position=category_config.get("position", 0)
719+
)
720+
msg = f"✅ Created category: {category_name}"
721+
722+
self.logger.info(f"[apply_monolithic_template] {msg}")
723+
result_msgs.append(msg)
724+
725+
# Process channels in this category
726+
created, updated, skipped = 0, 0, 0
727+
template_channel_names = set()
728+
729+
channels = category_config.get("channels", [])
730+
for channel_config in channels:
731+
channel_name = channel_config["name"]
732+
template_channel_names.add(channel_name)
733+
734+
# Check if channel exists
735+
existing_channel = discord.utils.get(category.channels, name=channel_name)
736+
channel_type = channel_config["type"].lower()
737+
738+
if existing_channel:
739+
# Update topic/nsfw/position if needed
740+
update_kwargs = {}
741+
if (
742+
channel_type == "text"
743+
and hasattr(existing_channel, "topic")
744+
and existing_channel.topic != channel_config.get("topic", "")
745+
):
746+
update_kwargs["topic"] = channel_config.get("topic", "")
747+
if (
748+
channel_type in ("text", "voice")
749+
and hasattr(existing_channel, "nsfw")
750+
and existing_channel.nsfw != channel_config.get("nsfw", False)
751+
):
752+
update_kwargs["nsfw"] = channel_config.get("nsfw", False)
753+
if (
754+
"position" in channel_config
755+
and hasattr(existing_channel, "position")
756+
and existing_channel.position != channel_config["position"]
757+
):
758+
update_kwargs["position"] = channel_config["position"]
759+
760+
if update_kwargs:
761+
await existing_channel.edit(**update_kwargs)
762+
updated += 1
763+
msg = f"🔄 Updated channel: {existing_channel.name} in {category_name}"
764+
else:
765+
skipped += 1
766+
msg = f"⏭️ Skipped channel (no changes): {existing_channel.name} in {category_name}"
767+
768+
self.logger.info(f"[apply_monolithic_template] {msg}")
769+
result_msgs.append(msg)
770+
else:
771+
# Create new channel
772+
channel_kwargs = {
773+
"name": channel_config["name"],
774+
"category": category,
775+
}
776+
if "position" in channel_config:
777+
channel_kwargs["position"] = channel_config["position"]
778+
779+
if channel_type == "text":
780+
if "topic" in channel_config:
781+
channel_kwargs["topic"] = channel_config["topic"]
782+
if "nsfw" in channel_config:
783+
channel_kwargs["nsfw"] = channel_config["nsfw"]
784+
await guild.create_text_channel(**channel_kwargs)
785+
elif channel_type == "voice":
786+
await guild.create_voice_channel(**channel_kwargs)
787+
else:
788+
msg = f"❌ Unknown channel type: {channel_type} for {channel_config['name']}"
789+
self.logger.error(f"[apply_monolithic_template] {msg}")
790+
result_msgs.append(msg)
791+
skipped += 1
792+
continue
793+
794+
created += 1
795+
msg = f"✅ Created channel: {channel_config['name']} in {category_name}"
796+
self.logger.info(f"[apply_monolithic_template] {msg}")
797+
result_msgs.append(msg)
798+
799+
# Check for extra channels in this category
800+
extra_channels = [
801+
ch for ch in category.channels if ch.name not in template_channel_names
802+
]
803+
if extra_channels:
804+
msg = f"⚠️ Extra channels not in template for category '{category_name}': {', '.join(ch.name for ch in extra_channels)}"
805+
self.logger.warning(f"[apply_monolithic_template] {msg}")
806+
result_msgs.append(msg)
807+
view = DeleteExtraObjectsView(extra_channels, object_type_label="channel")
808+
if interaction:
809+
await interaction.followup.send(msg, view=view)
810+
elif ctx:
811+
await ctx.send(msg, view=view)
812+
813+
summary = f"**{category_name}**: {created} created, {updated} updated, {skipped} skipped"
814+
result_msgs.append(summary)
815+
816+
# Check for extra categories
817+
extra_categories = [
818+
cat for cat in guild.categories if cat.name not in template_category_names
819+
]
820+
if extra_categories:
821+
msg = f"⚠️ Extra categories not in template: {', '.join(cat.name for cat in extra_categories)}"
822+
self.logger.warning(f"[apply_monolithic_template] {msg}")
823+
result_msgs.append(msg)
824+
view = DeleteExtraObjectsView(extra_categories, object_type_label="category")
825+
if interaction:
826+
await interaction.followup.send(msg, view=view)
827+
elif ctx:
828+
await ctx.send(msg, view=view)
829+
830+
# Check for orphan channels
831+
orphan_channels = []
832+
for ch in guild.channels:
833+
if getattr(ch, "category", None) is None and isinstance(
834+
ch, (discord.TextChannel, discord.VoiceChannel)
835+
):
836+
orphan_channels.append(ch)
837+
838+
if orphan_channels:
839+
msg = f"⚠️ Uncategorized channels not in template: {', '.join(ch.name for ch in orphan_channels)}"
840+
self.logger.warning(f"[apply_monolithic_template] {msg}")
841+
result_msgs.append(msg)
842+
view = DeleteExtraObjectsView(orphan_channels, object_type_label="channel")
843+
if interaction:
844+
await interaction.followup.send(msg, view=view)
845+
elif ctx:
846+
await ctx.send(msg, view=view)
847+
848+
return result_msgs
849+
674850

675851
async def setup(bot: commands.Bot) -> None:
676852
"""Set up the Admin cog."""

src/gitcord/cogs/channels.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
create_channel_kwargs,
2121
create_channel_by_type,
2222
check_channel_exists,
23+
get_template_path,
2324
)
2425
from ..utils import template_metadata
2526
from ..constants.paths import get_template_repo_dir
@@ -47,7 +48,14 @@ def __init__(self, bot: commands.Bot):
4748
self.logger.info("Channels cog loaded")
4849

4950
def _get_template_path(self, guild_id: int, file_name: str = None, folder: str = None) -> Optional[str]:
50-
"""Get the template path for a guild, falling back to None if no template repo exists."""
51+
"""Get the template path for a guild, now looking for monolithic template.yaml first."""
52+
53+
# Try the new monolithic template format first
54+
template_path = get_template_path(guild_id)
55+
if template_path:
56+
return template_path
57+
58+
# Fall back to legacy format if requested
5159
meta = template_metadata.load_metadata(guild_id)
5260
if not meta or not os.path.exists(meta.get("local_path", "")):
5361
return None
@@ -446,6 +454,14 @@ def _create_category_result_embed(self, result: CategoryResult) -> discord.Embed
446454
@commands.has_permissions(manage_channels=True)
447455
async def createchannel(self, ctx: commands.Context) -> None:
448456
"""Create a channel based on properties defined in a YAML file."""
457+
# Check if we have a monolithic template first
458+
template_path = self._get_template_path(ctx.guild.id)
459+
if template_path and template_path.endswith("template.yaml"):
460+
await self.send_error(ctx, "⚠️ Command Deprecated",
461+
"This server uses the new monolithic template format. Please use `!git pull` to apply the entire template instead of creating individual channels.")
462+
return
463+
464+
# Fall back to legacy format
449465
yaml_path = self._get_template_path(ctx.guild.id, "off-topic.yaml")
450466
if not yaml_path:
451467
await self.send_error(ctx, "❌ No Template Repository",
@@ -462,6 +478,14 @@ async def createcategory(self, ctx: commands.Context) -> None:
462478
await self.send_error(ctx, "❌ Error", "Guild not found")
463479
return
464480

481+
# Check if we have a monolithic template first
482+
template_path = self._get_template_path(guild.id)
483+
if template_path and template_path.endswith("template.yaml"):
484+
await self.send_error(ctx, "⚠️ Command Deprecated",
485+
"This server uses the new monolithic template format. Please use `!git pull` to apply the entire template instead of creating individual categories.")
486+
return
487+
488+
# Fall back to legacy format
465489
yaml_path = self._get_template_path(guild.id, "category.yaml")
466490
if not yaml_path:
467491
await self.send_error(ctx, "❌ No Template Repository",
@@ -539,6 +563,18 @@ async def createcategory_slash(
539563

540564
# Use default path if none provided
541565
if yaml_path is None:
566+
# Check if we have a monolithic template first
567+
template_path = self._get_template_path(guild.id)
568+
if template_path and template_path.endswith("template.yaml"):
569+
embed = create_embed(
570+
title="⚠️ Command Deprecated",
571+
description="This server uses the new monolithic template format. Please use `!git pull` to apply the entire template instead of creating individual categories.",
572+
color=discord.Color.orange(),
573+
)
574+
await interaction.followup.send(embed=embed)
575+
return
576+
577+
# Fall back to legacy format
542578
yaml_path = self._get_template_path(guild.id, "category.yaml")
543579
if not yaml_path:
544580
embed = create_embed(

src/gitcord/utils/helpers.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,114 @@ def format_time_delta(seconds: float) -> str:
100100
return f"{hours:.1f}h"
101101

102102

103+
def parse_monolithic_template(yaml_path: str) -> dict:
104+
"""Parse and validate the monolithic YAML template file."""
105+
if not os.path.exists(yaml_path):
106+
raise FileNotFoundError(f"Template file not found at: {yaml_path}")
107+
108+
try:
109+
with open(yaml_path, "r", encoding="utf-8") as file:
110+
template_config = yaml.safe_load(file)
111+
except yaml.YAMLError as e:
112+
raise ValueError(f"Invalid YAML format: {e}") from e
113+
114+
if template_config is None:
115+
raise ValueError("Template file is empty or invalid.")
116+
117+
# Validate required fields
118+
if "categories" not in template_config:
119+
raise ValueError("Missing required field: categories")
120+
121+
if not isinstance(template_config["categories"], list):
122+
raise ValueError("categories must be a list")
123+
124+
# Validate each category
125+
for i, category in enumerate(template_config["categories"]):
126+
if not isinstance(category, dict):
127+
raise ValueError(f"Category {i} must be a dictionary")
128+
129+
required_category_fields = ["name", "type"]
130+
for field in required_category_fields:
131+
if field not in category:
132+
raise ValueError(f"Category {i} missing required field: {field}")
133+
134+
if "channels" in category:
135+
if not isinstance(category["channels"], list):
136+
raise ValueError(f"Category {i} channels must be a list")
137+
138+
# Validate each channel in the category
139+
for j, channel in enumerate(category["channels"]):
140+
if not isinstance(channel, dict):
141+
raise ValueError(f"Category {i}, channel {j} must be a dictionary")
142+
143+
required_channel_fields = ["name", "type"]
144+
for field in required_channel_fields:
145+
if field not in channel:
146+
raise ValueError(f"Category {i}, channel {j} missing required field: {field}")
147+
148+
return template_config
149+
150+
151+
def parse_monolithic_template_from_str(yaml_str: str) -> dict:
152+
"""Parse and validate monolithic YAML template from a string."""
153+
try:
154+
template_config = yaml.safe_load(yaml_str)
155+
except yaml.YAMLError as e:
156+
raise ValueError(f"Invalid YAML format: {e}") from e
157+
158+
if template_config is None:
159+
raise ValueError("Template YAML is empty or invalid.")
160+
161+
# Validate required fields
162+
if "categories" not in template_config:
163+
raise ValueError("Missing required field: categories")
164+
165+
if not isinstance(template_config["categories"], list):
166+
raise ValueError("categories must be a list")
167+
168+
# Validate each category
169+
for i, category in enumerate(template_config["categories"]):
170+
if not isinstance(category, dict):
171+
raise ValueError(f"Category {i} must be a dictionary")
172+
173+
required_category_fields = ["name", "type"]
174+
for field in required_category_fields:
175+
if field not in category:
176+
raise ValueError(f"Category {i} missing required field: {field}")
177+
178+
if "channels" in category:
179+
if not isinstance(category["channels"], list):
180+
raise ValueError(f"Category {i} channels must be a list")
181+
182+
# Validate each channel in the category
183+
for j, channel in enumerate(category["channels"]):
184+
if not isinstance(channel, dict):
185+
raise ValueError(f"Category {i}, channel {j} must be a dictionary")
186+
187+
required_channel_fields = ["name", "type"]
188+
for field in required_channel_fields:
189+
if field not in channel:
190+
raise ValueError(f"Category {i}, channel {j} missing required field: {field}")
191+
192+
return template_config
193+
194+
195+
def get_template_path(guild_id: int) -> Optional[str]:
196+
"""Get the template path for a guild, looking for template.yaml in the root of the template repo."""
197+
from .template_metadata import load_metadata
198+
199+
meta = load_metadata(guild_id)
200+
if not meta or not os.path.exists(meta.get("local_path", "")):
201+
return None
202+
203+
template_path = os.path.join(meta["local_path"], "template.yaml")
204+
if os.path.exists(template_path):
205+
return template_path
206+
207+
return None
208+
209+
210+
# Legacy functions - kept for backward compatibility during transition
103211
def parse_channel_config(yaml_path: str) -> dict:
104212
"""Parse and validate the YAML configuration file."""
105213
if not os.path.exists(yaml_path):

0 commit comments

Comments
 (0)