diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c773d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,348 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..3bfe10f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +# The MIT License + +Copyright (c) 2019 Christopher Robinson + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2011711 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Virtual Nodes for Umbraco 8 + +Basically a rewrite of [Umbraco-VirtualNodes](https://github.com/sotirisf/Umbraco-VirtualNodes/) from [Sotiris Filippidis](https://github.com/sotirisf/) to make it compatible with Umbraco 8.1+. + +This plugin lets you define document types that will be excluded from generated URLs., thus making them "invisible". + +## Usage +After you include this plugin you must have to add a single `appSettings` entry to your `web.config` file, e.g. + +```xml + +``` +Where docTypeName is the document type alias to be treated as a "virtual" node. + +You can define multiple "rules" by separating them with commas, e.g. + +```xml + +``` + +You can also use wildcards at the start and/or the end of the document type alias, like this: + +```xml + +``` +This means that all document type aliases ending with "dog", starting with "cat" or containing "mouse" will be treated as virtual nodes. + +## Advanced: Auto numbering of nodes + +Consider the following example: + +``` +articles + groupingNode1 + article1 + article2 + groupingNode2 +``` + +Supposing that groupingNode1 and groupingNode2 are virtual nodes, the path for article1 will be /articles/article1. Okay, but what if we add a new article named "article1" under groupingNode2? + +The plugin checks nodes on save and changes their names accordingly to protect you from this. More specifically, it checks if the parent node of the node being saved is a virtual node and, if so, it checks all its sibling nodes to see if there is already another node using the same name. It then adjusts numbering accordingly. + +So, if you saved a new node named "article1" under "groupingNode2" it would become: + +``` +articles + groupingNode1 + article1 + article2 + groupingNode2 + article1 (1) +``` + +And then if you saved another node named "article1" again under "groupingNode1" it would become "article1 (2)" like this: + +``` +articles + groupingNode1 + article1 + article2 + article1 (2) + groupingNode2 + article1 (1) +``` + +## Known issues + +To keep things simple the auto numbering of nodes only go one level up - if you have multiple virtual nodes under each other and multiple nodes with the same name in different levels then you will run into problems. diff --git a/VirtualNodes.sln b/VirtualNodes.sln new file mode 100644 index 0000000..2ce1502 --- /dev/null +++ b/VirtualNodes.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.28307.757 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualNodes", "VirtualNodes\VirtualNodes.csproj", "{3CEEA23B-3ECE-4CFF-B44A-551EDF7D2270}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3CEEA23B-3ECE-4CFF-B44A-551EDF7D2270}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CEEA23B-3ECE-4CFF-B44A-551EDF7D2270}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CEEA23B-3ECE-4CFF-B44A-551EDF7D2270}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CEEA23B-3ECE-4CFF-B44A-551EDF7D2270}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D3BC2B59-5838-45CA-872D-1ACE0A6F3FCE} + EndGlobalSection +EndGlobal diff --git a/VirtualNodes/App.config b/VirtualNodes/App.config new file mode 100644 index 0000000..f8646d6 --- /dev/null +++ b/VirtualNodes/App.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/VirtualNodes/VirtualNodes.csproj b/VirtualNodes/VirtualNodes.csproj new file mode 100644 index 0000000..f0ef649 --- /dev/null +++ b/VirtualNodes/VirtualNodes.csproj @@ -0,0 +1,58 @@ + + + + + Debug + AnyCPU + {3CEEA23B-3ECE-4CFF-B44A-551EDF7D2270} + Library + VirtualNodes + VirtualNodes + v4.7.2 + 512 + true + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + 8.1.0 + + + 8.1.0 + + + + + + + + + + + + diff --git a/VirtualNodes/VirtualNodesComposer.cs b/VirtualNodes/VirtualNodesComposer.cs new file mode 100644 index 0000000..25901e3 --- /dev/null +++ b/VirtualNodes/VirtualNodesComposer.cs @@ -0,0 +1,18 @@ +using Umbraco.Core; +using Umbraco.Core.Composing; +using Umbraco.Web; + +namespace VirtualNodes +{ + public class VirtualNodesComposer : IUserComposer + { + public void Compose(Composition composition) + { + composition.Components().Append(); + + composition.ContentFinders().Insert(); + + composition.UrlProviders().Insert(); + } + } +} diff --git a/VirtualNodes/VirtualNodesContentFinder.cs b/VirtualNodes/VirtualNodesContentFinder.cs new file mode 100644 index 0000000..44bb643 --- /dev/null +++ b/VirtualNodes/VirtualNodesContentFinder.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using System.Web.Caching; +using Umbraco.Core.Cache; +using Umbraco.Core.Composing; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web; +using Umbraco.Web.Routing; + +namespace VirtualNodes +{ + public class VirtualNodesContentFinder : IContentFinder + { + public bool TryFindContent(PublishedRequest contentRequest) + { + var _runtimeCache = Current.AppCaches.RuntimeCache; + var _umbracoContext = contentRequest.UmbracoContext; + var cachedVirtualNodeUrls = _runtimeCache.GetCacheItem>("CachedVirtualNodes"); + var path = contentRequest.Uri.AbsolutePath; + + // If found in the cached dictionary + if ((cachedVirtualNodeUrls != null) && cachedVirtualNodeUrls.ContainsKey(path)) + { + var nodeId = cachedVirtualNodeUrls[path]; + + contentRequest.PublishedContent = _umbracoContext.Content.GetById(nodeId); + + return true; + } + + // If not found in the cached dictionary, traverse nodes and find the node that corresponds to the URL + var rootNodes = _umbracoContext.Content.GetAtRoot(); + var item = rootNodes.DescendantsOrSelf().Where(x => (x.Url == (path + "/") || (x.Url == path))).FirstOrDefault(); + + // If item is found, return it after adding it to the cache so we don't have to go through the same process again. + if (cachedVirtualNodeUrls == null) + { + cachedVirtualNodeUrls = new Dictionary(); + } + + // If we have found a node that corresponds to the URL given + if (item != null) + { + // Update cache + _runtimeCache.InsertCacheItem("CachedVirtualNodes", () => cachedVirtualNodeUrls, null, false, CacheItemPriority.High); + + // That's all folks + contentRequest.PublishedContent = item; + + return true; + } + + return false; + } + } +} diff --git a/VirtualNodes/VirtualNodesEventHandler.cs b/VirtualNodes/VirtualNodesEventHandler.cs new file mode 100644 index 0000000..554ee29 --- /dev/null +++ b/VirtualNodes/VirtualNodesEventHandler.cs @@ -0,0 +1,130 @@ +using System; +using Umbraco.Core; +using Umbraco.Core.Composing; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.Services; +using Umbraco.Core.Services.Implement; +using Umbraco.Web; + +namespace VirtualNodes +{ + public class VirtualNodesComponent : IComponent + { + private readonly IContentService _contentService; + private readonly IUmbracoContextFactory _context; + + public VirtualNodesComponent(IContentService contentService, IUmbracoContextFactory context) + { + _contentService = contentService; + _context = context; + } + + public void Initialize() + { + ContentService.Saving += (contentService, e) => + { + using (var cref = _context.EnsureUmbracoContext()) + { + var cache = cref.UmbracoContext.Content; + + // Go through nodes being published + foreach (IContent node in e.SavedEntities) + { + // Name of node hasn't changed, so don't do anything + if (node.HasIdentity && !node.IsPropertyDirty("Name")) + { + continue; + } + + IPublishedContent parent; + + try + { + // If there is no parent, exit + if ((node.ParentId == 0) || (!node.HasIdentity && (node.Level == 1)) || (node.HasIdentity && (node.Level == 0))) + { + continue; + } + + // Switch to IPublishedContent to go faster + parent = cache.GetById(node.ParentId); + + // If parent is home (redundant) and parent is not a virtual node, exit current iteration + if ((parent == null) || (parent.Level < 2) || !parent.IsVirtualNode()) + { + continue; + } + } + catch (Exception ex) + { + continue; + } + + // Start the counter. This will count the nodes with the same name (taking numbering under consideration) + // that will be found under all the node's parent siblings that are virtual nodes. + + int nodesFound = 0; + int maxNumber = 0; + + foreach (IPublishedContent farSibling in parent.Siblings()) + { + // Don't take other nodes under considerations - only virtual nodes + // I know the name "farSibling" is not that pretty, couldn't think of anything else though. + if (!farSibling.IsVirtualNode()) + { + continue; + } + + // For each sibling of the node's parent, get all children and check names + foreach (IPublishedContent potentialDuplicate in farSibling.Children()) + { + + string p = potentialDuplicate.Name.ToLower(); + string y = node.Name.ToLower(); + + // Don't take the node itself under consideration - only other nodes. + if (potentialDuplicate.Id == node.Id) + { + continue; + } + + // If we find a node that already has the same name, increase counter by 1. + if (p.Equals(y)) + { + nodesFound++; + } + // If we find a node with the same name and numbering immediately after, increase counter by 1. + // Maxnumber will be the max number we found in node numbering, even if there are deleted node numbers in between. + // For example, if we have "aaa (1)" and "aaa(5)" only, maxNumber will be 5. + else if (VirtualNodesHelpers.MatchDuplicateName(p, y)) + { + nodesFound++; + maxNumber = VirtualNodesHelpers.GetMaxNodeNameNumbering(p, y, maxNumber); + } + } + } + + //Change the node's name to the appropriate number if duplicates were found. + //The number of nodes found will be the actual node number since we'll already have a node with + //no numbering. Meaning that if there is "aaa", "aaa (1)" and "aaa (2)" then + //our new node (initially named "aaa") will be renamed to "aaa (3)" - that is 3 nodes found. + if (nodesFound > 0) + { + node.Name += " (" + (maxNumber + 1).ToString() + ")"; + } + } + } + }; + + ContentService.Published += (contentService, e) => + { + Current.AppCaches.RuntimeCache.ClearByKey("CachedVirtualNodes"); + }; + } + + public void Terminate() + { + } + } +} diff --git a/VirtualNodes/VirtualNodesHelpers.cs b/VirtualNodes/VirtualNodesHelpers.cs new file mode 100644 index 0000000..d81bf62 --- /dev/null +++ b/VirtualNodes/VirtualNodesHelpers.cs @@ -0,0 +1,86 @@ +using System.Text.RegularExpressions; +using Umbraco.Core.Models.PublishedContent; + +namespace VirtualNodes +{ + public static class VirtualNodesHelpers + { + /// + /// Checks a given node's name against a potential duplicate name. If the name is the same, followed by a space, a parenthesis and a number, then this is a duplicate name. + /// + /// The name to check against + /// The given node's name + /// True if the potential duplicate name is same with the current node's name followed by a parenthesis with a number + public static bool MatchDuplicateName(string potentialDuplicateName, string currNodeName) + { + var rgName = new Regex(@"^(.+)( \(\d+\))$"); + + return (rgName.IsMatch(potentialDuplicateName) && rgName.Replace(potentialDuplicateName, "$1").Equals(currNodeName)); + } + + /// + /// Gets the largest same-name node number being used + /// + /// The name to check for duplicates + /// The current node's name + /// The current maximum number + /// The new maximum number, if applicable, or the same maximum number if nothing has changed + public static int GetMaxNodeNameNumbering(string potentialDuplicateName, string currNodeName, int maxNumber) + { + var rgName = new Regex(@"^.+ \((\d+)\)$"); + + if (rgName.IsMatch(potentialDuplicateName)) + { + var newNumber = int.Parse(rgName.Replace(potentialDuplicateName, "$1")); + + maxNumber = ((maxNumber < newNumber) ? newNumber : maxNumber); + } + + return maxNumber; + } + + /// + /// Checks if a node is a virtual node + /// + /// The node to check + /// True if it is a virtual node + public static bool IsVirtualNode(this IPublishedContent item) + { + foreach (string rule in VirtualNodesRuleManager.Instance.Rules) + { + if (MatchContentTypeAlias(item.ContentType.Alias, rule)) + { + return true; + } + } + + return false; + } + + /// + /// Checks rules from settings agains a given document type alias to see if it matches the rule + /// + /// The given document type alias + /// The rule from settings + /// True if it is a match + private static bool MatchContentTypeAlias(string nodeContentTypeAlias, string contentTypeAliasFromSettings) + { + if (contentTypeAliasFromSettings.EndsWith("*") && contentTypeAliasFromSettings.StartsWith("*")) + { + return nodeContentTypeAlias.ToLower().Contains(contentTypeAliasFromSettings.ToLower().Replace("*", "")); + } + else if (contentTypeAliasFromSettings.EndsWith("*")) + { + return nodeContentTypeAlias.ToLower().StartsWith(contentTypeAliasFromSettings.ToLower().Replace("*", "")); + } + else if (contentTypeAliasFromSettings.StartsWith("*")) + { + return nodeContentTypeAlias.ToLower().EndsWith(contentTypeAliasFromSettings.ToLower().Replace("*", "")); + } + else + { + return nodeContentTypeAlias.ToLower().Equals(contentTypeAliasFromSettings.ToLower()); + } + } + } +} diff --git a/VirtualNodes/VirtualNodesRuleManager.cs b/VirtualNodes/VirtualNodesRuleManager.cs new file mode 100644 index 0000000..47c03b4 --- /dev/null +++ b/VirtualNodes/VirtualNodesRuleManager.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Configuration; + +namespace VirtualNodes +{ + /// + /// Loads rules for VirtualNodesUrlProvider + /// + public sealed class VirtualNodesRuleManager + { + #region Private Members + + /// + /// Lazy singleton instance member + /// + private static readonly Lazy _instance = new Lazy(() => new VirtualNodesRuleManager()); + + #endregion + + #region Public Members + + /// + /// Gets the list of rules + /// + public List Rules { get; } + + #endregion + + #region Constructors + + /// + /// Returns a (singleton) VirtualNodesRuleManager instance + /// + public static VirtualNodesRuleManager Instance { get { return _instance.Value; } } + + /// + /// Private constructor for Singleton + /// + private VirtualNodesRuleManager() + { + Rules = new List(); + + //Get all entries with keys starting with specified prefix + var rules = ConfigurationManager.AppSettings.Get("VirtualNodes"); + + if (String.IsNullOrEmpty(rules)) + { + return; + } + + //Register a rule for each item + foreach (string rule in rules.Split(',')) + { + Rules.Add(rule.Trim()); + } + } + + #endregion + } +} diff --git a/VirtualNodes/VirtualNodesUrlProvider.cs b/VirtualNodes/VirtualNodesUrlProvider.cs new file mode 100644 index 0000000..9be98d8 --- /dev/null +++ b/VirtualNodes/VirtualNodesUrlProvider.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Linq; +using Umbraco.Core.Configuration; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Logging; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.Services; +using Umbraco.Web; +using Umbraco.Web.Routing; + +namespace VirtualNodes +{ + public class VirtualNodesUrlProvider : DefaultUrlProvider + { + private readonly IRequestHandlerSection _requestSettings; + + public VirtualNodesUrlProvider(IRequestHandlerSection requestSettings, ILogger logger, IGlobalSettings globalSettings, ISiteDomainHelper siteDomainHelper) + : base(requestSettings, logger, globalSettings, siteDomainHelper) + { + _requestSettings = requestSettings; + } + + public override IEnumerable GetOtherUrls(UmbracoContext umbracoContext, int id, Uri current) + { + return base.GetOtherUrls(umbracoContext, id, current); + } + + public override UrlInfo GetUrl(UmbracoContext umbracoContext, IPublishedContent content, UrlMode mode, string culture, Uri current) + { + // If this is a virtual node itself, no need to handle it - should return normal URL + var hasVirtualNodeInPath = false; + + foreach (var item in content.Ancestors()) + { + if (item.IsVirtualNode()) + { + hasVirtualNodeInPath = true; + + break; + } + } + + return (hasVirtualNodeInPath ? ConstructUrl(umbracoContext, content, mode, culture, current) : base.GetUrl(umbracoContext, content, mode, culture, current)); + } + + + private UrlInfo ConstructUrl(UmbracoContext umbracoContext, IPublishedContent content, UrlMode mode, string culture, Uri current) + { + string path = content.Path; + + // Keep path items in par with path segments in url + // If we are hiding the top node from path, then we'll have to skip one path item (the root). + // If we are not, then we'll have to skip two path items (root and home) + var hideTopNode = ConfigurationManager.AppSettings.Get("Umbraco.Core.HideTopLevelNodeFromPath"); + + if (String.IsNullOrEmpty(hideTopNode)) + { + hideTopNode = "false"; + } + + var pathItemsToSkip = ((hideTopNode == "true") ? 2 : 1); + + // Get the path ids but skip what's needed in order to have the same number of elements in url and path ids + var pathIds = path.Split(',').Skip(pathItemsToSkip).Reverse().ToArray(); + + // Get the default url + // DO NOT USE THIS - RECURSES: string url = content.Url; + // https://our.umbraco.org/forum/developers/extending-umbraco/73533-custom-url-provider-stackoverflowerror + // https://our.umbraco.org/forum/developers/extending-umbraco/66741-iurlprovider-cannot-evaluate-expression-because-the-current-thread-is-in-a-stack-overflow-state + var url = base.GetUrl(umbracoContext, content, mode, culture, current); + var urlText = url.Text; + + // If we come from an absolute URL, strip the host part and keep it so that we can append + // it again when returing the URL. + var hostPart = ""; + + if (urlText.StartsWith("http")) + { + var uri = new Uri(url.Text); + + urlText = urlText.Replace(uri.GetLeftPart(UriPartial.Authority), ""); + hostPart = uri.GetLeftPart(UriPartial.Authority); + } + + // Strip leading and trailing slashes + if (urlText.EndsWith("/")) + { + urlText = urlText.Substring(0, urlText.Length - 1); + } + + if (urlText.StartsWith("/")) + { + urlText = urlText.Substring(1, urlText.Length - 1); + } + + // Now split the url. We should have as many elements as those in pathIds. + string[] urlParts = urlText.Split('/').Reverse().ToArray(); + + // Iterate the url parts. Check the corresponding path id and if the document that corresponds there + // is of a type that must be excluded from the path, just make that url part an empty string. + var i = 0; + + foreach (var urlPart in urlParts) + { + var currentItem = umbracoContext.Content.GetById(int.Parse(pathIds[i])); + + // Omit any virtual node unless it's leaf level (we still need this otherwise it will be pointing to parent's URL) + if (currentItem.IsVirtualNode() && i > 0) + { + urlParts[i] = ""; + } + + i++; + } + + // Reconstruct the url, leaving out all parts that we emptied above. This + // will be our final url, without the parts that correspond to excluded nodes. + string finalUrl = String.Join("/", urlParts.Reverse().Where(x => x != "").ToArray()); + + // Just in case - check if there are trailing and leading slashes and add them if not + if (!finalUrl.EndsWith("/") && _requestSettings.AddTrailingSlash) + { + finalUrl += "/"; + } + + if (!finalUrl.StartsWith("/")) + { + finalUrl = "/" + finalUrl; + } + + finalUrl = String.Concat(hostPart, finalUrl); + + // Voila + return new UrlInfo(finalUrl, true, culture); + } + } +}