// Copyright (c) Umbraco. // See LICENSE for more details. using System; using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors { /// /// Represents an image cropper property editor. /// [DataEditor( Constants.PropertyEditors.Aliases.ImageCropper, "Image Cropper", "imagecropper", ValueType = ValueTypes.Json, HideLabel = false, Group = Constants.PropertyEditors.Groups.Media, Icon = "icon-crop")] public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator, INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler { private readonly MediaFileManager _mediaFileManager; private readonly ContentSettings _contentSettings; private readonly IDataTypeService _dataTypeService; private readonly IIOHelper _ioHelper; private readonly UploadAutoFillProperties _autoFillProperties; private readonly ILogger _logger; private readonly IContentService _contentService; /// /// Initializes a new instance of the class. /// public ImageCropperPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, ILoggerFactory loggerFactory, MediaFileManager mediaFileManager, IOptions contentSettings, IDataTypeService dataTypeService, IIOHelper ioHelper, UploadAutoFillProperties uploadAutoFillProperties, IContentService contentService) : base(dataValueEditorFactory) { _mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager)); _contentSettings = contentSettings.Value ?? throw new ArgumentNullException(nameof(contentSettings)); _dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); _ioHelper = ioHelper ?? throw new ArgumentNullException(nameof(ioHelper)); _autoFillProperties = uploadAutoFillProperties ?? throw new ArgumentNullException(nameof(uploadAutoFillProperties)); _contentService = contentService; _logger = loggerFactory.CreateLogger(); } public bool TryGetMediaPath(string propertyEditorAlias, object value, out string mediaPath) { if (propertyEditorAlias == Alias && GetFileSrcFromPropertyValue(value, out _, false) is var mediaPathValue && !string.IsNullOrWhiteSpace(mediaPathValue)) { mediaPath = mediaPathValue; return true; } mediaPath = null; return false; } /// /// Creates the corresponding property value editor. /// /// The corresponding property value editor. protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute); /// /// Creates the corresponding preValue editor. /// /// The corresponding preValue editor. protected override IConfigurationEditor CreateConfigurationEditor() => new ImageCropperConfigurationEditor(_ioHelper); /// /// Gets a value indicating whether a property is an image cropper field. /// /// The property. /// /// true if the specified property is an image cropper field; otherwise, false. /// private static bool IsCropperField(IProperty property) => property.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.ImageCropper; /// /// Parses the property value into a json object. /// /// The property value. /// A value indicating whether to log the error. /// The json object corresponding to the property value. /// In case of an error, optionally logs the error and returns null. private JObject GetJObject(string value, bool writeLog) { if (string.IsNullOrWhiteSpace(value)) return null; try { return JsonConvert.DeserializeObject(value); } catch (Exception ex) { if (writeLog) _logger.LogError(ex, "Could not parse image cropper value '{Json}'", value); return null; } } /// /// The paths to all image cropper property files contained within a collection of content entities /// /// private IEnumerable ContainedFilePaths(IEnumerable entities) => entities .SelectMany(x => x.Properties) .Where(IsCropperField) .SelectMany(GetFilePathsFromPropertyValues) .Distinct(); /// /// Look through all property values stored against the property and resolve any file paths stored /// /// /// private IEnumerable GetFilePathsFromPropertyValues(IProperty prop) { //parses out the src from a json string foreach (var propertyValue in prop.Values) { //check if the published value contains data and return it var src = GetFileSrcFromPropertyValue(propertyValue.PublishedValue, out var _); if (src != null) yield return _mediaFileManager.FileSystem.GetRelativePath(src); //check if the edited value contains data and return it src = GetFileSrcFromPropertyValue(propertyValue.EditedValue, out var _); if (src != null) yield return _mediaFileManager.FileSystem.GetRelativePath(src); } } /// /// Returns the "src" property from the json structure if the value is formatted correctly /// /// /// The deserialized value /// Should the path returned be the application relative path /// private string GetFileSrcFromPropertyValue(object propVal, out JObject deserializedValue, bool relative = true) { deserializedValue = null; if (propVal == null || !(propVal is string str)) return null; if (!str.DetectIsJson()) { // Assume the value is a plain string with the file path deserializedValue = new JObject() { { "src", str } }; } else { deserializedValue = GetJObject(str, true); } if (deserializedValue?["src"] == null) return null; var src = deserializedValue["src"].Value(); return relative ? _mediaFileManager.FileSystem.GetRelativePath(src) : src; } /// /// After a content has been copied, also copy uploaded files. /// public void Handle(ContentCopiedNotification notification) { // get the image cropper field properties var properties = notification.Original.Properties.Where(IsCropperField); // copy files var isUpdated = false; foreach (var property in properties) { //copy each of the property values (variants, segments) to the destination by using the edited value foreach (var propertyValue in property.Values) { var propVal = property.GetValue(propertyValue.Culture, propertyValue.Segment); var src = GetFileSrcFromPropertyValue(propVal, out var jo); if (src == null) { continue; } var sourcePath = _mediaFileManager.FileSystem.GetRelativePath(src); var copyPath = _mediaFileManager.CopyFile(notification.Copy, property.PropertyType, sourcePath); jo["src"] = _mediaFileManager.FileSystem.GetUrl(copyPath); notification.Copy.SetValue(property.Alias, jo.ToString(), propertyValue.Culture, propertyValue.Segment); isUpdated = true; } } // if updated, re-save the copy with the updated value if (isUpdated) { _contentService.Save(notification.Copy); } } public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); private void DeleteContainedFiles(IEnumerable deletedEntities) { var filePathsToDelete = ContainedFilePaths(deletedEntities); _mediaFileManager.DeleteMediaFiles(filePathsToDelete); } public void Handle(MediaSavingNotification notification) { foreach (var entity in notification.SavedEntities) { AutoFillProperties(entity); } } /// /// Auto-fill properties (or clear). /// private void AutoFillProperties(IContentBase model) { var properties = model.Properties.Where(IsCropperField); foreach (var property in properties) { var autoFillConfig = _contentSettings.GetConfig(property.Alias); if (autoFillConfig == null) continue; foreach (var pvalue in property.Values) { var svalue = property.GetValue(pvalue.Culture, pvalue.Segment) as string; if (string.IsNullOrWhiteSpace(svalue)) { _autoFillProperties.Reset(model, autoFillConfig, pvalue.Culture, pvalue.Segment); } else { var jo = GetJObject(svalue, false); string src; if (jo == null) { // so we have a non-empty string value that cannot be parsed into a json object // see http://issues.umbraco.org/issue/U4-4756 // it can happen when an image is uploaded via the folder browser, in which case // the property value will be the file source eg '/media/23454/hello.jpg' and we // are fixing that anomaly here - does not make any sense at all but... bah... var dt = _dataTypeService.GetDataType(property.PropertyType.DataTypeId); var config = dt?.ConfigurationAs(); src = svalue; var json = new { src = svalue, crops = config == null ? Array.Empty() : config.Crops }; property.SetValue(JsonConvert.SerializeObject(json), pvalue.Culture, pvalue.Segment); } else { src = jo["src"]?.Value(); } if (src == null) _autoFillProperties.Reset(model, autoFillConfig, pvalue.Culture, pvalue.Segment); else _autoFillProperties.Populate(model, autoFillConfig, _mediaFileManager.FileSystem.GetRelativePath(src), pvalue.Culture, pvalue.Segment); } } } } } }