diff --git a/.gitignore b/.gitignore index f7be36c7..d3f4d447 100644 --- a/.gitignore +++ b/.gitignore @@ -486,4 +486,8 @@ $RECYCLE.BIN/ # Nuget package Umbraco.Cms.StaticAssets will copy them in during dotnet build **/wwwroot/umbraco/ -**/SeoToolkit.Umbraco.Site/App_Plugins/ \ No newline at end of file +**/SeoToolkit.Umbraco.Site/App_Plugins/ +**/uSync/ +**/umbraco/Data/Umbraco.sqlite.db +**/umbraco/Data/Umbraco.sqlite.db-shm +**/umbraco/Data/Umbraco.sqlite.db-wal diff --git a/src/SeoToolkit.Umbraco.MetaFields.Core/Interfaces/Services/IMetaFieldsValueService.cs b/src/SeoToolkit.Umbraco.MetaFields.Core/Interfaces/Services/IMetaFieldsValueService.cs index 43450e8e..3a1ec0f7 100644 --- a/src/SeoToolkit.Umbraco.MetaFields.Core/Interfaces/Services/IMetaFieldsValueService.cs +++ b/src/SeoToolkit.Umbraco.MetaFields.Core/Interfaces/Services/IMetaFieldsValueService.cs @@ -6,8 +6,8 @@ namespace SeoToolkit.Umbraco.MetaFields.Core.Interfaces.Services { public interface IMetaFieldsValueService { - Dictionary GetUserValues(int nodeId); - void AddValues(int nodeId, Dictionary values); + Dictionary GetUserValues(int nodeId, string culture = null); + void AddValues(int nodeId, Dictionary values, string culture = null); void Delete(int nodeId, string fieldAlias); } } diff --git a/src/SeoToolkit.Umbraco.MetaFields.Core/Services/MetaFieldsValueService/MetaFieldsValueService.cs b/src/SeoToolkit.Umbraco.MetaFields.Core/Services/MetaFieldsValueService/MetaFieldsValueService.cs index 5e33e0a4..c4199cb7 100644 --- a/src/SeoToolkit.Umbraco.MetaFields.Core/Services/MetaFieldsValueService/MetaFieldsValueService.cs +++ b/src/SeoToolkit.Umbraco.MetaFields.Core/Services/MetaFieldsValueService/MetaFieldsValueService.cs @@ -23,26 +23,29 @@ public MetaFieldsValueService(IMetaFieldsValueRepository repository, IVariationC _cache = appCaches.RuntimeCache; } - public Dictionary GetUserValues(int nodeId) + public Dictionary GetUserValues(int nodeId, string culture = null) { - var culture = GetCulture(); - return _cache.GetCacheItem($"{BaseCacheKey}{nodeId}_{culture}", () => _repository.GetAllValues(nodeId, culture), TimeSpan.FromMinutes(30)); + var iso = culture ?? GetCulture(); + return _cache.GetCacheItem($"{BaseCacheKey}{nodeId}_{iso}", () => _repository.GetAllValues(nodeId, iso), TimeSpan.FromMinutes(30)); } - public void AddValues(int nodeId, Dictionary values) + public void AddValues(int nodeId, Dictionary values, string culture = null) { + var iso = culture ?? GetCulture(); foreach (var (key, value) in values) { - if (_repository.Exists(nodeId, key, GetCulture())) - _repository.Update(nodeId, key, GetCulture(), value); + if (_repository.Exists(nodeId, key, iso)) + _repository.Update(nodeId, key, iso, value); else { - _repository.Add(nodeId, key, GetCulture(), value); + _repository.Add(nodeId, key, iso, value); } } ClearCache(nodeId); } + + public void Delete(int nodeId, string fieldAlias) { _repository.Delete(nodeId, fieldAlias, GetCulture()); @@ -50,7 +53,7 @@ public void Delete(int nodeId, string fieldAlias) private string GetCulture() { - return _variationContextAccessor.VariationContext.Culture; + return _variationContextAccessor.VariationContext?.Culture ?? string.Empty; } private void ClearCache(int nodeId) diff --git a/src/SeoToolkit.Umbraco.Site/SeoToolkit.Umbraco.Site.csproj b/src/SeoToolkit.Umbraco.Site/SeoToolkit.Umbraco.Site.csproj index ad95bbf1..f6813ef1 100644 --- a/src/SeoToolkit.Umbraco.Site/SeoToolkit.Umbraco.Site.csproj +++ b/src/SeoToolkit.Umbraco.Site/SeoToolkit.Umbraco.Site.csproj @@ -45,6 +45,7 @@ + diff --git a/src/SeoToolkit.Umbraco.sln b/src/SeoToolkit.Umbraco.sln index 86d06fb2..68fbd0fb 100644 --- a/src/SeoToolkit.Umbraco.sln +++ b/src/SeoToolkit.Umbraco.sln @@ -49,6 +49,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeoToolkit.Umbraco.Redirect EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeoToolkit.Umbraco.Redirects.Core", "SeoToolkit.Umbraco.Redirects.Core\SeoToolkit.Umbraco.Redirects.Core.csproj", "{566D1DBF-EC21-405C-BB92-424C4A1C1B88}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "uSync", "uSync", "{0080FA37-4D4E-44E0-8235-740C3D557D77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeoToolkit.Umbraco.uSync.Core", "SeoToolkit.Umbraco.uSync.Core\SeoToolkit.Umbraco.uSync.Core.csproj", "{A509609B-306B-41ED-9115-C3319ED779D9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -123,6 +127,10 @@ Global {566D1DBF-EC21-405C-BB92-424C4A1C1B88}.Debug|Any CPU.Build.0 = Debug|Any CPU {566D1DBF-EC21-405C-BB92-424C4A1C1B88}.Release|Any CPU.ActiveCfg = Release|Any CPU {566D1DBF-EC21-405C-BB92-424C4A1C1B88}.Release|Any CPU.Build.0 = Release|Any CPU + {A509609B-306B-41ED-9115-C3319ED779D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A509609B-306B-41ED-9115-C3319ED779D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A509609B-306B-41ED-9115-C3319ED779D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A509609B-306B-41ED-9115-C3319ED779D9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -140,6 +148,7 @@ Global {5B460335-8786-4FA9-911B-AEC5A752B88C} = {5641B436-FCCB-4D1A-9D25-6A39B95031CB} {7C9ABE0D-106C-48C0-B0CF-DBBCD8AF334E} = {B0B49325-8248-4DB8-889D-211D9408D7A5} {566D1DBF-EC21-405C-BB92-424C4A1C1B88} = {B0B49325-8248-4DB8-889D-211D9408D7A5} + {A509609B-306B-41ED-9115-C3319ED779D9} = {0080FA37-4D4E-44E0-8235-740C3D557D77} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {551ADBA5-3DFB-4300-929C-2BE0DB346636} diff --git a/src/SeoToolkit.Umbraco.uSync.Core/Composers/USyncComposer.cs b/src/SeoToolkit.Umbraco.uSync.Core/Composers/USyncComposer.cs new file mode 100644 index 00000000..ebd6512b --- /dev/null +++ b/src/SeoToolkit.Umbraco.uSync.Core/Composers/USyncComposer.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using SeoToolkit.Umbraco.uSync.Core.Serializers; +using SeoToolkit.Umbraco.uSync.Core.XmlTrackers; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models; +using uSync.Core.Tracking; + +namespace SeoToolkit.Umbraco.uSync.Core.Composers; + +public class USyncComposer : IComposer +{ + public void Compose(IUmbracoBuilder builder) + { + builder.Services.AddTransient(); + builder.Services.AddTransient(); + } +} \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.uSync.Core/Constants/Serialization.cs b/src/SeoToolkit.Umbraco.uSync.Core/Constants/Serialization.cs new file mode 100644 index 00000000..4b584702 --- /dev/null +++ b/src/SeoToolkit.Umbraco.uSync.Core/Constants/Serialization.cs @@ -0,0 +1,9 @@ +namespace SeoToolkit.Umbraco.uSync.Core.Constants; + +public static class Serialization +{ + public const string DocumentTypeSettingsDto = "DocumentTypeSettingsDto"; + public const string MetaFieldValues = "MetaFieldValues"; + + public const string RootName = "Content"; +} \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.uSync.Core/Handlers/MetaFieldValuesHandler.cs b/src/SeoToolkit.Umbraco.uSync.Core/Handlers/MetaFieldValuesHandler.cs new file mode 100644 index 00000000..b8e0616b --- /dev/null +++ b/src/SeoToolkit.Umbraco.uSync.Core/Handlers/MetaFieldValuesHandler.cs @@ -0,0 +1,110 @@ +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using SeoToolkit.Umbraco.MetaFields.Core.Interfaces.Services; +using SeoToolkit.Umbraco.uSync.Core.Serializers; +using SeoToolkit.Umbraco.uSync.Core.XmlTrackers; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; +using uSync.BackOffice.Configuration; +using uSync.BackOffice.Services; +using uSync.Core; +using uSync.Core.Serialization; + +namespace uSync.BackOffice.SyncHandlers.Handlers; + +[SyncHandler("seoToolkitMetaFieldValuesHandler", "SeoToolkit Meta Fields", "SeoToolkitMetaFields", uSyncConstants.Priorites.Content + , Icon = "icon-list", IsTwoPass = true, EntityType = Constants.UdiEntityType.Document)] +public class MetaFieldValuesHandler : ContentHandlerBase, ISyncHandler +{ + private readonly IMetaFieldsValueService _metaFieldsValueService; + private readonly MetaFieldValuesSerializer _metaFieldValuesSerializer; + private readonly IContentService _contentService; + + /// + /// the default group for which events matter (content group) + /// + public override string Group => uSyncConstants.Groups.Content; + + public MetaFieldValuesHandler(MetaFieldValuesSerializer metaFieldValuesSerializer,IContentService contentService, ILogger logger, + IEntityService entityService, AppCaches appCaches, IShortStringHelper shortStringHelper, + SyncFileService syncFileService, uSyncEventService mutexService, uSyncConfigService uSyncConfigService, + ISyncItemFactory syncItemFactory) : base(logger, entityService, appCaches, shortStringHelper, syncFileService, + mutexService, uSyncConfigService, syncItemFactory) + { + + _metaFieldValuesSerializer = metaFieldValuesSerializer; + _contentService = contentService; + + this.serializer = _metaFieldValuesSerializer as ISyncSerializer; + } + + public override IEnumerable Export(IContent? item, string folder, HandlerSettings config) + { + if (item == null) + return uSyncAction.Fail(nameof(item), typeof(IContent).ToString(), ChangeType.Fail, "Item not set", + new ArgumentNullException(nameof(item))).AsEnumerableOfOne(); + + var filename = GetPath(folder, item, config.GuidNames, config.UseFlatStructure) + .ToAppSafeFileName(); + + var attempt = _metaFieldValuesSerializer.Serialize(item, new SyncSerializerOptions(config.Settings)); + if (!attempt.Success) + return uSyncActionHelper.SetAction(attempt, filename, GetItemKey(item), this.Alias) + .AsEnumerableOfOne(); + + if (ShouldExport(attempt.Item, config)) + { + // only write the file to disk if it should be exported. + syncFileService.SaveXElement(attempt.Item, filename); + } + else + { + return uSyncAction.SetAction(true, filename, type: typeof(IContent).ToString(), + change: ChangeType.NoChange, message: "Not Exported (Based on config)", filename: filename) + .AsEnumerableOfOne(); + } + + return uSyncActionHelper.SetAction(attempt, filename, GetItemKey(item), this.Alias) + .AsEnumerableOfOne(); + } + + + public override IEnumerable Import(XElement node, string filename, HandlerSettings config, SerializerFlags flags) + { + if (ShouldImport(node, new HandlerSettings())) + { + var attempt = _metaFieldValuesSerializer.Deserialize(node, new SyncSerializerOptions(config.Settings)); + return uSyncActionHelper.SetAction(attempt, filename,node.GetKey(), this.Alias) + .AsEnumerableOfOne(); + } + + return uSyncAction.SetAction(true, filename, type: typeof(IContent).ToString(), + change: ChangeType.NoChange, message: "Not Imported (Based on config)", filename: filename) + .AsEnumerableOfOne(); + } + + + public override IEnumerable ReportFolder(string folder, HandlerSettings config, SyncUpdateCallback callback) + { + var reportActions= base.ReportFolder(folder, config, callback).ToList(); + reportActions = reportActions.Select(action => + { + if (!action.Details.Any()) + { + return action; + } + + var details = action.Details.ToList(); + //First item will always be an error since the default XmlTracker will have a xml schema mismatch error. + details.RemoveAt(0); + action.Details = details; + return action; + }).ToList(); + + return reportActions; + } +} \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.uSync.Core/SeoToolkit.Umbraco.uSync.Core.csproj b/src/SeoToolkit.Umbraco.uSync.Core/SeoToolkit.Umbraco.uSync.Core.csproj new file mode 100644 index 00000000..892171d1 --- /dev/null +++ b/src/SeoToolkit.Umbraco.uSync.Core/SeoToolkit.Umbraco.uSync.Core.csproj @@ -0,0 +1,17 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + diff --git a/src/SeoToolkit.Umbraco.uSync.Core/Serializers/MetaFieldValuesSerializer.cs b/src/SeoToolkit.Umbraco.uSync.Core/Serializers/MetaFieldValuesSerializer.cs new file mode 100644 index 00000000..f948ab80 --- /dev/null +++ b/src/SeoToolkit.Umbraco.uSync.Core/Serializers/MetaFieldValuesSerializer.cs @@ -0,0 +1,226 @@ +using System.Drawing; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using SeoToolkit.Umbraco.MetaFields.Core.Interfaces.Services; +using SeoToolkit.Umbraco.MetaFields.Core.Repositories.SeoValueRepository; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; +using uSync.Core; +using uSync.Core.Mapping; +using uSync.Core.Models; +using uSync.Core.Serialization; +using uSync.Core.Serialization.Serializers; + +namespace SeoToolkit.Umbraco.uSync.Core.Serializers; + +[SyncSerializer("cfeaa2d0-8af5-4ef4-9dd6-3b696df18189", "SeoToolkit.MetaFieldSerializer", + Constants.Serialization.MetaFieldValues)] +public class MetaFieldValuesSerializer : ContentSerializerBase, ISyncSerializer +{ + private readonly ILocalizationService _localizationService; + private readonly IMetaFieldsValueService _metaFieldsValueService; + private readonly IContentService _contentService; + + public MetaFieldValuesSerializer(IEntityService entityService, ILocalizationService localizationService, + IRelationService relationService, IShortStringHelper shortStringHelper, + ILogger logger, + SyncValueMapperCollection syncMappers, IMetaFieldsValueService metaFieldsValueService, + IContentService contentService) : base(entityService, localizationService, relationService, shortStringHelper, + logger, UmbracoObjectTypes.Document, syncMappers) + { + _localizationService = localizationService; + _metaFieldsValueService = metaFieldsValueService; + _contentService = contentService; + } + + private XElement InitializeNode(IContent item) + { + return new XElement(Constants.Serialization.RootName, new XAttribute("Key", ItemKey(item)), + new XAttribute("Alias", ItemAlias(item))); + } + + public new SyncAttempt Deserialize(XElement node, SyncSerializerOptions options) + { + if (node.IsEmptyItem()) + return ProcessAction(node, options); + if (!IsValid(node)) + throw new FormatException("XML Not valid for type " + this.ItemType); + if (!options.Force && this.IsCurrent(node, options) <= ChangeType.NoChange) + return SyncAttempt.Succeed(node.GetAlias(), ChangeType.NoChange); + var syncAttempt1 = this.CanDeserialize(node, options); + if (!syncAttempt1.Success) + return syncAttempt1; + logger.LogDebug($"Base: Deserializing {0}", (object)this.ItemType); + var syncAttempt2 = DeserializeCore(node, options); + if (syncAttempt2.Success) + { + logger.LogDebug("Base: Deserialize Core Success {0}", (object)this.ItemType); + if (!syncAttempt2.Saved && !options.Flags.HasFlag((Enum)SerializerFlags.DoNotSave)) + { + logger.LogDebug("Base: Serializer Saving (No DoNotSaveFlag) {0}", + (object)this.ItemAlias(syncAttempt2.Item)); + SaveItem(syncAttempt2.Item); + } + + if (options.OnePass) + { + logger.LogDebug("Base: Processing item in one pass {0}", (object)this.ItemAlias(syncAttempt2.Item)); + var syncAttempt3 = this.DeserializeSecondPass(syncAttempt2.Item, node, options); + logger.LogDebug("Base: Second Pass Result {0} {1}", (object)this.ItemAlias(syncAttempt2.Item), + (object)syncAttempt3.Success); + return syncAttempt3; + } + } + + return syncAttempt2; + } + + private const string Value = "Value"; + private const string Culture = "Culture"; + + protected override SyncAttempt SerializeCore(IContent item, SyncSerializerOptions options) + { + var node = InitializeNode(item); + + var languageVariants = _localizationService.GetAllLanguages(); + + foreach (var languageVariant in languageVariants) + { + var fields = _metaFieldsValueService.GetUserValues(item.Id, languageVariant.IsoCode); + + if (!fields.Any()) + { + return SyncAttempt.Fail(item.Name, ChangeType.NoChange, "No Meta Fields"); + } + + foreach (var field in fields) + { + var el = node.Element(field.Key); + if (el != null) + { + el.Elements(Value).FirstOrDefault(e => + e.HasAttributes && e.Attribute(Culture)?.Value == languageVariant.IsoCode)?.Remove(); + + + el.Add(new XElement(Value, field.Value, new XAttribute(Culture, languageVariant.IsoCode))); + } + else + { + node.Add(fields.Select(field => + { + var element = new XElement(field.Key, + new XElement(Value, field.Value, new XAttribute(Culture, languageVariant.IsoCode))); + return element; + })); + } + } + } + + + return SyncAttempt.Succeed(item.Name, node, typeof(IContent), ChangeType.Export); + } + + protected override SyncAttempt DeserializeCore(XElement node, SyncSerializerOptions options) + { + var languageVariants = _localizationService.GetAllLanguages(); + + var attempt = FindOrCreate(node); + + if (!attempt.Success || attempt.Result == null) + return SyncAttempt.Fail(node.GetAlias(), attempt.Result!, ChangeType.Fail, + "Failed to find or create content", attempt.Exception); + + foreach (var languageVariant in languageVariants) + { + var item = GetDictionaryValues(node, languageVariant.IsoCode); + + Save(attempt.Result.Id, item!, languageVariant.IsoCode); + } + + return SyncAttempt.Succeed(node.GetAlias(), attempt.Result, + typeof(IContent), ChangeType.Import); + } + + public override bool IsValid(XElement node) + { + return node != null! && node.GetAlias() != null && node.Name == Constants.Serialization.RootName; + } + + + public override IContent FindItem(int id) + { + var item = _contentService.GetById(id); + if (item != null) + { + AddToNameCache(id, item.Key, item.Name); + return item; + } + + return null!; + } + + + public override IContent FindItem(Guid key) + => _contentService.GetById(key)!; + + public override void SaveItem(IContent item) + { + //We do not need to save the contentItem itself, but we do need to override this method + } + + public override void DeleteItem(IContent item) + { + //We do not need to delete the contentItem itself, but we do need to override this method + //Maybe we should delete meta-field values for a content item when it is deleted? + } + + + private void Save(int nodeId, Dictionary item, string culture) + { + _metaFieldsValueService.AddValues(nodeId, item, culture); + } + + public Dictionary GetDictionaryValues(XElement node, string culture) + { + return node.Elements() + .ToDictionary( + descendantNode => descendantNode.Name.ToString(), + descendantNode => + { + return descendantNode.Elements(Value) + .FirstOrDefault(e => e.HasAttributes && e.Attribute(Culture)?.Value == culture)?.Value!; + }); + } + + + protected override Attempt CreateItem(string alias, ITreeEntity parent, string itemType) + { + var parentId = parent != null! ? parent.Id : -1; + var item = _contentService.Create(alias, parentId, itemType); + if (item == null!) + return Attempt.Fail(item, new ArgumentException($"Unable to create content item of type {itemType}"))!; + + return Attempt.Succeed(item)!; + } + + protected override uSyncChange HandleTrashedState(IContent item, bool trashed) + { + // We do not need to handle trashed state, the default handler does this for us. + return null!; + } + + protected override IContent FindAtRoot(string alias) + { + var rootNodes = _contentService.GetRootContent().ToList(); + if (rootNodes.Any()) + { + return rootNodes.FirstOrDefault(x => x.Name!.ToSafeAlias(shortStringHelper).InvariantEquals(alias))!; + } + + return null!; + } +} \ No newline at end of file diff --git a/src/SeoToolkit.Umbraco.uSync.Core/XmlTrackers/SeoToolkitXmlTracker.cs b/src/SeoToolkit.Umbraco.uSync.Core/XmlTrackers/SeoToolkitXmlTracker.cs new file mode 100644 index 00000000..781aed89 --- /dev/null +++ b/src/SeoToolkit.Umbraco.uSync.Core/XmlTrackers/SeoToolkitXmlTracker.cs @@ -0,0 +1,29 @@ +using System.Xml.Linq; +using SeoToolkit.Umbraco.uSync.Core.Serializers; +using Umbraco.Cms.Core.Models; +using uSync.Core; +using uSync.Core.Models; +using uSync.Core.Serialization; +using uSync.Core.Tracking; +using uSync.Core.Tracking.Impliment; + +namespace SeoToolkit.Umbraco.uSync.Core.XmlTrackers; + +public class SeoToolkitXmlTracker : + SyncXmlTracker, + ISyncTracker, + ISyncTrackerBase +{ + public override List TrackingItems => new List() + { + TrackingItem.Many("Property - *", "/*/Value", "@Culture"), + + }; + + public SeoToolkitXmlTracker(SyncSerializerCollection serializers, MetaFieldValuesSerializer serializer) : base(serializers) + { + this.serializer = serializer; + } + + +} \ No newline at end of file