diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..20f59eb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.{cs,sln,csproj,config,xml}] +indent_size = 4 +indent_style = space diff --git a/src/Sitecore.Support.221558.sln b/src/Sitecore.Support.221558.sln new file mode 100644 index 0000000..bb92867 --- /dev/null +++ b/src/Sitecore.Support.221558.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.25123.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{992C233D-4B19-4F75-91C3-217E5F548378}") = "Sitecore.Support.221558", "Sitecore.Support.221558\Sitecore.Support.221558.csproj", "{6D157773-4CA9-42F3-9AB2-98239839002A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6D157773-4CA9-42F3-9AB2-98239839002A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D157773-4CA9-42F3-9AB2-98239839002A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D157773-4CA9-42F3-9AB2-98239839002A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D157773-4CA9-42F3-9AB2-98239839002A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/Sitecore.Support.221558/Properties/AssemblyInfo.cs b/src/Sitecore.Support.221558/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..47b59e2 --- /dev/null +++ b/src/Sitecore.Support.221558/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Sitecore.Support.221558")] +[assembly: AssemblyProduct("Sitecore.Support.221558")] +[assembly: ComVisible(false)] diff --git a/src/Sitecore.Support.221558/Shell/Applications/Dialogs/BreakingLinks/BreakingLinksForm.cs b/src/Sitecore.Support.221558/Shell/Applications/Dialogs/BreakingLinks/BreakingLinksForm.cs new file mode 100644 index 0000000..9aa6ee5 --- /dev/null +++ b/src/Sitecore.Support.221558/Shell/Applications/Dialogs/BreakingLinks/BreakingLinksForm.cs @@ -0,0 +1,773 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Web.UI; +using Sitecore.Data; +using Sitecore.Data.Fields; +using Sitecore.Data.Items; +using Sitecore.Diagnostics; +using Sitecore.Globalization; +using Sitecore.Jobs; +using Sitecore.Links; +using Sitecore.Resources; +using Sitecore.SecurityModel; +using Sitecore.Text; +using Sitecore.Web; +using Sitecore.Web.UI.HtmlControls; +using Sitecore.Web.UI.Pages; +using Sitecore.Web.UI.Sheer; +using Sitecore.Web.UI.WebControls; + +namespace Sitecore.Support.Shell.Applications.Dialogs.BreakingLinks +{ + public class BreakingLinksForm : DialogForm + { + /// + /// Represents a RemoveLinks. + /// + public class RemoveLinks + { + #region Fields + + /// + /// The list of item IDs to be processed. + /// + private readonly string list; + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class. + /// + /// + /// The list of item IDs to be processed. + /// + public RemoveLinks([NotNull] string list) + { + Assert.ArgumentNotNullOrEmpty(list, "list"); + + this.list = list; + } + + #endregion + + #region Protected methods + + /// + /// Fixes this instance. + /// + protected void Remove() + { + Job job = Context.Job; + Assert.IsNotNull(job, "job"); + + try + { + var ids = new ListString(this.list); + + LinkDatabase linkDatabase = Globals.LinkDatabase; + + foreach (string id in ids) + { + this.RemoveItemLinks(job, linkDatabase, id); + } + } + catch (Exception ex) + { + job.Status.Failed = true; + job.Status.Messages.Add(ex.ToString()); + } + + job.Status.State = JobState.Finished; + } + + #endregion + + #region Private methods + + /// + /// Removes the link. + /// + /// + /// The version. + /// + /// + /// The item link. + /// + private static void RemoveLink([NotNull] Item version, [NotNull] ItemLink itemLink) + { + Assert.ArgumentNotNull(version, "version"); + Assert.ArgumentNotNull(itemLink, "itemLink"); + + Field sourceField = version.Fields[itemLink.SourceFieldID]; + CustomField customField = FieldTypeManager.GetField(sourceField); + if (customField == null) + { + return; + } + + using (new SecurityDisabler()) + { + version.Editing.BeginEdit(); + customField.RemoveLink(itemLink); + + #region modified code #221558: make sure the unversioned statistics-related fields won't be updated. + version.Editing.EndEdit(false, false); + #endregion + } + } + + /// + /// Removes the item links. + /// + /// + /// The job object. + /// + /// + /// The link database. + /// + /// + /// The item id. + /// + private void RemoveItemLinks([NotNull] Job job, [NotNull] LinkDatabase linkDatabase, [NotNull] string id) + { + Assert.ArgumentNotNull(job, "job"); + Assert.ArgumentNotNull(linkDatabase, "linkDatabase"); + Assert.ArgumentNotNullOrEmpty(id, "id"); + + var data = job.Options.CustomData as Dictionary; + + Database contentDatabase = data == null ? Context.ContentDatabase : (Database)data["content_database"]; + Item targetItem = contentDatabase.GetItem(id); + if (targetItem == null) + { + return; + } + + job.Status.Processed++; + + bool removeCloneLinks = true; + if (data != null && data.ContainsKey("ignoreclones")) + { + removeCloneLinks = data["ignoreclones"] as string != "1"; + } + + this.RemoveItemLinks(linkDatabase, targetItem, removeCloneLinks); + } + + /// + /// Removes the item links. + /// + /// The link database. + /// The target item. + /// If set to True links from clone items will be removed. + protected void RemoveItemLinks([NotNull] LinkDatabase linkDatabase, [NotNull] Item targetItem, bool removeCloneLinks) + { + Assert.ArgumentNotNull(linkDatabase, "linkDatabase"); + Assert.ArgumentNotNull(targetItem, "targetItem"); + + foreach (Item child in targetItem.Children) + { + this.RemoveItemLinks(linkDatabase, child, removeCloneLinks); + } + + ItemLink[] links = linkDatabase.GetReferrers(targetItem); + + foreach (ItemLink itemLink in links) + { + if (!removeCloneLinks && (itemLink.SourceFieldID == FieldIDs.Source || itemLink.SourceFieldID == FieldIDs.SourceItem)) + { + continue; + } + + Item sourceItem = itemLink.GetSourceItem(); + if (sourceItem == null || ID.IsNullOrEmpty(itemLink.SourceFieldID)) + { + continue; + } + + RemoveLink(sourceItem, itemLink); + } + + Log.Audit(this, "Remove link: {0}", AuditFormatter.FormatItem(targetItem)); + } + + #endregion + } + + /// + /// Represents a RemoveLinks. + /// + public class Relink + { + #region Fields + + /// + /// The item to link to. + /// + private Item item; + + /// + /// The list of item IDs to process. + /// + private string list; + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class. + /// + /// The list of item IDs to process. + /// The item to link to. + public Relink([NotNull] string list, [NotNull] Item item) + { + Assert.ArgumentNotNullOrEmpty(list, "list"); + Assert.ArgumentNotNull(item, "item"); + + this.list = list; + this.item = item; + } + + #endregion + + #region Protected methods + + /// + /// Fixes this instance. + /// + protected void RelinkItems() + { + Job job = Context.Job; + Assert.IsNotNull(job, "job"); + + try + { + var ids = new ListString(this.list); + + LinkDatabase linkDatabase = Globals.LinkDatabase; + + foreach (string id in ids) + { + this.RelinkItemLinks(job, linkDatabase, id); + } + } + catch (Exception ex) + { + job.Status.Failed = true; + job.Status.Messages.Add(ex.ToString()); + } + + job.Status.State = JobState.Finished; + } + + #endregion + + #region Private methods + + /// + /// Removes the item links. + /// + /// The job object. + /// The link database. + /// The item id to process. + private void RelinkItemLinks([NotNull] Job job, [NotNull] LinkDatabase linkDatabase, [NotNull] string id) + { + Assert.ArgumentNotNull(job, "job"); + Assert.ArgumentNotNull(linkDatabase, "linkDatabase"); + Assert.ArgumentNotNullOrEmpty(id, "id"); + + Assert.IsNotNull(Context.ContentDatabase, "content database"); + Item targetItem = Context.ContentDatabase.GetItem(id); + if (targetItem == null) + { + return; + } + + job.Status.Processed++; + + bool relinkCloneLinks = true; + if ((job.Options != null) && (job.Options.CustomData != null) && (job.Options.CustomData is Dictionary)) + { + var data = job.Options.CustomData as Dictionary; + if ((data != null) && data.ContainsKey("ignoreclones")) + { + relinkCloneLinks = data["ignoreclones"] as string != "1"; + } + } + + this.RelinkItemLinks(linkDatabase, targetItem, relinkCloneLinks); + } + + /// + /// Relinks the item links. + /// + /// The link database. + /// The item to process. + /// If set to true links from clones will be processed. + protected void RelinkItemLinks([NotNull] LinkDatabase linkDatabase, [NotNull] Item targetItem, bool relinkCloneLinks) + { + Assert.ArgumentNotNull(linkDatabase, "linkDatabase"); + Assert.ArgumentNotNull(targetItem, "targetItem"); + + foreach (Item child in targetItem.Children) + { + this.RelinkItemLinks(linkDatabase, child, relinkCloneLinks); + } + + ItemLink[] links = linkDatabase.GetReferrers(targetItem); + + foreach (ItemLink itemLink in links) + { + if (!relinkCloneLinks && (itemLink.SourceFieldID == FieldIDs.Source || itemLink.SourceFieldID == FieldIDs.SourceItem)) + { + continue; + } + + Item sourceItem = itemLink.GetSourceItem(); + if (sourceItem == null || ID.IsNullOrEmpty(itemLink.SourceFieldID)) + { + continue; + } + + this.RelinkLink(sourceItem, itemLink); + } + } + + /// + /// Removes the link. + /// + /// The source item. + /// The item link. + private void RelinkLink([NotNull] Item sourceItem, [NotNull] ItemLink itemLink) + { + Assert.ArgumentNotNull(sourceItem, "sourceItem"); + Assert.ArgumentNotNull(itemLink, "itemLink"); + + Field sourceField = sourceItem.Fields[itemLink.SourceFieldID]; + + using (new SecurityDisabler()) + { + sourceItem.Editing.BeginEdit(); + + CustomField customField = FieldTypeManager.GetField(sourceField); + + if (customField != null) + { + customField.Relink(itemLink, this.item); + Log.Audit(this, "Relink: {0}, ReferrerItem: {1}", AuditFormatter.FormatItem(this.item), AuditFormatter.FormatItem(sourceItem)); + } + + #region modified code #221558: make sure the unversioned statistics-related fields won't be updated. + sourceItem.Editing.EndEdit(false, false); + #endregion + } + } + + #endregion + } + + /// + /// Back Button + /// + protected Button BackButton; + + /// + /// The error text. + /// + protected Memo ErrorText; + + /// + /// The executing page. + /// + protected Border ExecutingPage; + + /// + /// The failed page. + /// + protected Border FailedPage; + + /// + /// The link. + /// + protected TreeviewEx Link; + + /// + /// The relink button. + /// + protected Radiobutton RelinkButton; + + /// + /// The remove button. + /// + protected Radiobutton RemoveButton; + + /// + /// The select action page. + /// + protected Border SelectActionPage; + + /// + /// The select item page. + /// + protected Border SelectItemPage; + + /// + /// The broken or removed links count page + /// + protected Border LinksBrokenOrRemovedPage; + + /// + /// The broken or removed links count literal + /// + protected Literal LinksBrokenOrRemovedCount; + + /// + /// The lead-in text for items to be deleted. + /// + protected Border DeletingItems; + + /// + /// Checks the status. + /// + protected void CheckStatus() + { + string expr_19 = Context.ClientPage.ServerProperties["handle"] as string; + Assert.IsNotNullOrEmpty(expr_19, "raw handle"); + Handle handle = Handle.Parse(expr_19); + if (!handle.IsLocal) + { + Context.ClientPage.ClientResponse.Timer("CheckStatus", 500); + return; + } + Job job = JobManager.GetJob(handle); + if (job.Status.Failed) + { + this.ErrorText.Value = StringUtil.StringCollectionToString(job.Status.Messages); + this.ShowPage("Failed"); + return; + } + string value; + if (job.Status.State == JobState.Running) + { + value = Translate.Text("Processed {0} items. ", new object[] + { + job.Status.Processed, + job.Status.Total + }); + } + else + { + value = Translate.Text("Queued."); + } + if (job.IsDone) + { + SheerResponse.SetDialogValue("yes"); + SheerResponse.CloseWindow(); + UrlHandle.DisposeHandle(UrlHandle.Get()); + return; + } + SheerResponse.SetInnerHtml("Status", value); + SheerResponse.Timer("CheckStatus", 500); + } + + /// + /// Edits the links. + /// + protected void EditLinks() + { + UrlString urlString = ResourceUri.Parse("control:EditLinks").ToUrlString(); + if (WebUtil.GetQueryString("ignoreclones") == "1") + { + urlString.Add("ignoreclones", "1"); + } + UrlHandle expr_3C = new UrlHandle(); + expr_3C["list"] = UrlHandle.Get()["list"]; + expr_3C.Add(urlString); + SheerResponse.ShowModalDialog(urlString.ToString()); + } + + /// + /// Handles a click on the Cancel button. + /// + /// The event sender object. + /// The event args. + /// + /// When the user clicksCancel, the dialog is closed by calling + /// the CloseWindow method. + /// + protected override void OnCancel(object sender, EventArgs args) + { + Assert.ArgumentNotNull(sender, "sender"); + Assert.ArgumentNotNull(args, "args"); + if (!string.IsNullOrEmpty(Context.ClientPage.ServerProperties["handle"] as string)) + { + Log.Audit(this, "The RemoveLinks job was cancelled by the user. The target item will therefore not be deleted. Some or all of the referring links have already been removed or updated.", new string[0]); + } + SheerResponse.SetDialogValue("no"); + UrlHandle.DisposeHandle(UrlHandle.Get()); + base.OnCancel(sender, args); + } + + /// + /// Handles a click on the Cancel button. + /// + /// The event sender object. + /// The event args. + /// + /// When the user clicksCancel, the dialog is closed by calling + /// the CloseWindow method. + /// + protected void OnBackButton(object sender, EventArgs args) + { + Assert.ArgumentNotNull(sender, "sender"); + Assert.ArgumentNotNull(args, "args"); + this.ShowPage("Action"); + } + + /// + /// Raises the load event. + /// + /// + /// The instance containing the event data. + /// + /// + /// This method notifies the server control that it should perform actions common to each HTTP + /// request for the page it is associated with, such as setting up a database query. At this + /// stage in the page lifecycle, server controls in the hierarchy are created and initialized, + /// view state is restored, and form controls reflect client-side data. Use the IsPostBack + /// property to determine whether the page is being loaded in response to a client postback, + /// or if it is being loaded and accessed for the first time. + /// + protected override void OnLoad(EventArgs e) + { + Assert.ArgumentNotNull(e, "e"); + base.OnLoad(e); + if (this.BackButton != null) + { + this.BackButton.OnClick += new EventHandler(this.OnBackButton); + } + if (!Context.ClientPage.IsEvent) + { + this.BuildItemsToBeDeleted(); + } + } + + /// + /// Handles a click on the OK button. + /// + /// The event sender object. + /// The event args. + /// + /// When the user clicks OK, the dialog is closed by calling + /// the CloseWindow method. + /// + /// Unknown action + protected override void OnOK(object sender, EventArgs args) + { + Assert.ArgumentNotNull(sender, "sender"); + Assert.ArgumentNotNull(args, "args"); + string formValue = WebUtil.GetFormValue("Action"); + if (formValue == "Remove") + { + if (this.SelectActionPage.Visible) + { + this.ShowLinksBrokenOrRemovedCount(); + return; + } + this.StartRemove(); + return; + } + else if (formValue == "Relink") + { + if (this.SelectActionPage.Visible) + { + this.SelectItem(); + return; + } + this.StartRelink(); + return; + } + else + { + if (!(formValue == "Break")) + { + throw new InvalidOperationException(string.Format("Unknown action: '{0}'", formValue)); + } + if (this.SelectActionPage.Visible) + { + this.ShowLinksBrokenOrRemovedCount(); + return; + } + SheerResponse.SetDialogValue("yes"); + base.OnOK(sender, args); + UrlHandle.DisposeHandle(UrlHandle.Get()); + return; + } + } + + /// + /// Starts the remove. + /// + private void SelectItem() + { + this.ShowPage("Item"); + } + + /// + /// Shows the page. + /// + /// + /// The page ID. + /// + private void ShowPage(string pageID) + { + Assert.ArgumentNotNullOrEmpty(pageID, "pageID"); + this.SelectActionPage.Visible = (pageID == "Action"); + this.SelectItemPage.Visible = (pageID == "Item"); + this.ExecutingPage.Visible = (pageID == "Executing"); + this.FailedPage.Visible = (pageID == "Failed"); + this.LinksBrokenOrRemovedPage.Visible = (pageID == "LinksBrokenOrRemoved"); + this.BackButton.Visible = (pageID != "Action" && pageID != "Executing"); + this.OK.Visible = (pageID != "Executing"); + } + + /// + /// Returns number of referrers to item (and all of its children) + /// + /// The item + private int CountReferrers(Item item) + { + int num = Globals.LinkDatabase.GetReferrerCount(item); + foreach (Item item2 in item.Children) + { + num += this.CountReferrers(item2); + } + return num; + } + + /// + /// Starts the remove. + /// + /// Unknown action + private void ShowLinksBrokenOrRemovedCount() + { + Assert.IsNotNull(Context.ContentDatabase, "content database"); + int num = 0; + foreach (string current in new ListString(UrlHandle.Get()["list"])) + { + Item item = Context.ContentDatabase.GetItem(current); + Assert.IsNotNull(item, "item"); + num += this.CountReferrers(item); + } + string formValue = WebUtil.GetFormValue("Action"); + if (formValue == "Remove") + { + this.LinksBrokenOrRemovedCount.Text = string.Format(Translate.Text("If you delete this item, you will permanently remove every link to it. Number of links to this item: {0}"), num); + } + else + { + if (!(formValue == "Break")) + { + throw new InvalidOperationException(string.Format("Invalid action: '{0}'", formValue)); + } + this.LinksBrokenOrRemovedCount.Text = string.Format(Translate.Text("If you delete this item, you will leave broken links. Number of links to this item: {0}"), num); + } + this.ShowPage("LinksBrokenOrRemoved"); + } + + /// + /// Starts the remove. + /// + private void StartRelink() + { + string list = UrlHandle.Get()["list"]; + Item selectionItem = this.Link.GetSelectionItem(); + if (selectionItem == null) + { + SheerResponse.Alert("Select an item.", new string[0]); + return; + } + this.ShowPage("Executing"); + Dictionary dictionary = new Dictionary(); + if (WebUtil.GetQueryString("ignoreclones") == "1") + { + dictionary.Add("ignoreclones", "1"); + } + Job job = JobManager.Start(new JobOptions("Relink", "Relink", Client.Site.Name, new BreakingLinksForm.Relink(list, selectionItem), "RelinkItems") + { + AfterLife = TimeSpan.FromMinutes(1.0), + ContextUser = Context.User, + CustomData = dictionary + }); + Context.ClientPage.ServerProperties["handle"] = job.Handle.ToString(); + Context.ClientPage.ClientResponse.Timer("CheckStatus", 500); + } + + /// + /// Starts the remove. + /// + private void StartRemove() + { + string list = UrlHandle.Get()["list"]; + this.ShowPage("Executing"); + Dictionary dictionary = new Dictionary(); + if (WebUtil.GetQueryString("ignoreclones") == "1") + { + dictionary.Add("ignoreclones", "1"); + } + dictionary["content_database"] = Context.ContentDatabase; + Job job = JobManager.Start(new JobOptions("RemoveLinks", "RemoveLinks", Client.Site.Name, new BreakingLinksForm.RemoveLinks(list), "Remove") + { + AfterLife = TimeSpan.FromMinutes(1.0), + ContextUser = Context.User, + CustomData = dictionary + }); + Context.ClientPage.ServerProperties["handle"] = job.Handle.ToString(); + Context.ClientPage.ClientResponse.Timer("CheckStatus", 500); + } + + /// + /// Show the list of items to be deleted + /// + private void BuildItemsToBeDeleted() + { + Assert.IsNotNull(Context.ContentDatabase, "content database"); + HtmlTextWriter htmlTextWriter = new HtmlTextWriter(new StringWriter()); + foreach (string current in new ListString(UrlHandle.Get()["list"])) + { + Item item = Context.ContentDatabase.GetItem(current); + if (item != null) + { + htmlTextWriter.Write("
"); + htmlTextWriter.Write(""); + htmlTextWriter.Write(""); + htmlTextWriter.Write(""); + htmlTextWriter.Write("
"); + ImageBuilder imageBuilder = new ImageBuilder + { + Src = item.Appearance.Icon, + Width = 32, + Height = 32, + Class = "scLinkIcon" + }; + htmlTextWriter.Write(imageBuilder.ToString()); + htmlTextWriter.Write(""); + htmlTextWriter.Write("
"); + htmlTextWriter.Write(item.GetUIDisplayName()); + htmlTextWriter.Write("
"); + htmlTextWriter.Write("
"); + htmlTextWriter.Write(item.Paths.ContentPath); + htmlTextWriter.Write("
"); + htmlTextWriter.Write("
"); + htmlTextWriter.Write("
"); + } + } + this.DeletingItems.InnerHtml = htmlTextWriter.InnerWriter.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Sitecore.Support.221558/Sitecore.Support.221558.csproj b/src/Sitecore.Support.221558/Sitecore.Support.221558.csproj new file mode 100644 index 0000000..5edeb80 --- /dev/null +++ b/src/Sitecore.Support.221558/Sitecore.Support.221558.csproj @@ -0,0 +1,95 @@ + + + + + Debug + AnyCPU + + + 2.0 + {6D157773-4CA9-42F3-9AB2-98239839002A} + {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} + Library + Properties + Sitecore.Support + Sitecore.Support.221558 + v4.6.2 + true + + + + + + + 6 + + + + true + full + false + bin\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\ + TRACE + prompt + 4 + + + + ..\packages\SC.Sitecore.Client.9.0.2\lib\Sitecore.Client.dll + False + + + ..\packages\SC.Sitecore.Kernel.9.0.2\lib\Sitecore.Kernel.dll + False + + + + + + + + + + + + + + + + + + + + 10.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + + + + + True + True + 0 + / + http://localhost:49196/ + False + False + + + False + + + + + \ No newline at end of file diff --git a/src/Sitecore.Support.221558/packages.config b/src/Sitecore.Support.221558/packages.config new file mode 100644 index 0000000..0819bd6 --- /dev/null +++ b/src/Sitecore.Support.221558/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Sitecore.Support.221558/sitecore/shell/Override/BreakingLinks.xml b/src/Sitecore.Support.221558/sitecore/shell/Override/BreakingLinks.xml new file mode 100644 index 0000000..aa4b9b8 --- /dev/null +++ b/src/Sitecore.Support.221558/sitecore/shell/Override/BreakingLinks.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +