Skip to content

Commit 691ca28

Browse files
Migrate old locallinks formats (#17307)
* Initial working localLinks migration * Cleanup * Refactor for extendability part 1 * Refactor part 2 * Fixed circular dependency * Made sure all the extendable logic for the migration is marked as obsolete for v18 * Refactor to use Interface and non circular references instead * Use Npco for SQLserver compatibility and include media properties too --------- Co-authored-by: nikolajlauridsen <[email protected]>
1 parent 6417948 commit 691ca28

File tree

10 files changed

+458
-3
lines changed

10 files changed

+458
-3
lines changed

src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ private IEnumerable<LocalLinkTag> FindLocalLinkIds(string text)
118118
}
119119
}
120120

121-
// todo remove at some point?
122-
private IEnumerable<LocalLinkTag> FindLegacyLocalLinkIds(string text)
121+
[Obsolete("This is a temporary method to support legacy formats until we are sure all data has been migration. Scheduled for removal in v17")]
122+
public IEnumerable<LocalLinkTag> FindLegacyLocalLinkIds(string text)
123123
{
124124
// Parse internal links
125125
MatchCollection tags = LocalLinkPattern.Matches(text);
@@ -148,7 +148,8 @@ private IEnumerable<LocalLinkTag> FindLegacyLocalLinkIds(string text)
148148
}
149149
}
150150

151-
private class LocalLinkTag
151+
[Obsolete("This is a temporary method to support legacy formats until we are sure all data has been migration. Scheduled for removal in v17")]
152+
public class LocalLinkTag
152153
{
153154
public LocalLinkTag(int? intId, GuidUdi? udi, string tagHref)
154155
{

src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,6 @@ protected virtual void DefinePlan()
103103
To<V_15_0_0.ConvertBlockListEditorProperties>("{6C04B137-0097-4938-8C6A-276DF1A0ECA8}");
104104
To<V_15_0_0.ConvertBlockGridEditorProperties>("{9D3CE7D4-4884-41D4-98E8-302EB6CB0CF6}");
105105
To<V_15_0_0.ConvertRichTextEditorProperties>("{37875E80-5CDD-42FF-A21A-7D4E3E23E0ED}");
106+
To<V_15_0_0.ConvertLocalLinks>("{42E44F9E-7262-4269-922D-7310CB48E724}");
106107
}
107108
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using Microsoft.Extensions.Logging;
2+
using NPoco;
3+
using Umbraco.Cms.Core;
4+
using Umbraco.Cms.Core.Models;
5+
using Umbraco.Cms.Core.Models.Editors;
6+
using Umbraco.Cms.Core.Serialization;
7+
using Umbraco.Cms.Core.Services;
8+
using Umbraco.Cms.Core.Web;
9+
using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
10+
using Umbraco.Cms.Infrastructure.Persistence;
11+
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
12+
using Umbraco.Extensions;
13+
14+
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0;
15+
16+
public class ConvertLocalLinks : MigrationBase
17+
{
18+
private readonly IUmbracoContextFactory _umbracoContextFactory;
19+
private readonly IContentTypeService _contentTypeService;
20+
private readonly ILogger<ConvertLocalLinks> _logger;
21+
private readonly IDataTypeService _dataTypeService;
22+
private readonly ILanguageService _languageService;
23+
private readonly IJsonSerializer _jsonSerializer;
24+
private readonly LocalLinkProcessor _localLinkProcessor;
25+
private readonly IMediaTypeService _mediaTypeService;
26+
27+
public ConvertLocalLinks(
28+
IMigrationContext context,
29+
IUmbracoContextFactory umbracoContextFactory,
30+
IContentTypeService contentTypeService,
31+
ILogger<ConvertLocalLinks> logger,
32+
IDataTypeService dataTypeService,
33+
ILanguageService languageService,
34+
IJsonSerializer jsonSerializer,
35+
LocalLinkProcessor localLinkProcessor,
36+
IMediaTypeService mediaTypeService)
37+
: base(context)
38+
{
39+
_umbracoContextFactory = umbracoContextFactory;
40+
_contentTypeService = contentTypeService;
41+
_logger = logger;
42+
_dataTypeService = dataTypeService;
43+
_languageService = languageService;
44+
_jsonSerializer = jsonSerializer;
45+
_localLinkProcessor = localLinkProcessor;
46+
_mediaTypeService = mediaTypeService;
47+
}
48+
49+
protected override void Migrate()
50+
{
51+
IEnumerable<string> propertyEditorAliases = _localLinkProcessor.GetSupportedPropertyEditorAliases();
52+
53+
using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext();
54+
var languagesById = _languageService.GetAllAsync().GetAwaiter().GetResult()
55+
.ToDictionary(language => language.Id);
56+
57+
IEnumerable<IContentType> allContentTypes = _contentTypeService.GetAll();
58+
IEnumerable<IPropertyType> contentPropertyTypes = allContentTypes
59+
.SelectMany(ct => ct.PropertyTypes);
60+
61+
IMediaType[] allMediaTypes = _mediaTypeService.GetAll().ToArray();
62+
IEnumerable<IPropertyType> mediaPropertyTypes = allMediaTypes
63+
.SelectMany(ct => ct.PropertyTypes);
64+
65+
var relevantPropertyEditors =
66+
contentPropertyTypes.Concat(mediaPropertyTypes).DistinctBy(pt => pt.Id)
67+
.Where(pt => propertyEditorAliases.Contains(pt.PropertyEditorAlias))
68+
.GroupBy(pt => pt.PropertyEditorAlias)
69+
.ToDictionary(group => group.Key, group => group.ToArray());
70+
71+
72+
foreach (var propertyEditorAlias in propertyEditorAliases)
73+
{
74+
if (relevantPropertyEditors.TryGetValue(propertyEditorAlias, out IPropertyType[]? propertyTypes) is false)
75+
{
76+
continue;
77+
}
78+
79+
_logger.LogInformation(
80+
"Migration starting for all properties of type: {propertyEditorAlias}",
81+
propertyEditorAlias);
82+
if (ProcessPropertyTypes(propertyTypes, languagesById))
83+
{
84+
_logger.LogInformation(
85+
"Migration succeeded for all properties of type: {propertyEditorAlias}",
86+
propertyEditorAlias);
87+
}
88+
else
89+
{
90+
_logger.LogError(
91+
"Migration failed for one or more properties of type: {propertyEditorAlias}",
92+
propertyEditorAlias);
93+
}
94+
}
95+
}
96+
97+
private bool ProcessPropertyTypes(IPropertyType[] propertyTypes, IDictionary<int, ILanguage> languagesById)
98+
{
99+
foreach (IPropertyType propertyType in propertyTypes)
100+
{
101+
IDataType dataType = _dataTypeService.GetAsync(propertyType.DataTypeKey).GetAwaiter().GetResult()
102+
?? throw new InvalidOperationException("The data type could not be fetched.");
103+
104+
IDataValueEditor valueEditor = dataType.Editor?.GetValueEditor()
105+
?? throw new InvalidOperationException(
106+
"The data type value editor could not be fetched.");
107+
108+
Sql<ISqlContext> sql = Sql()
109+
.Select<PropertyDataDto>()
110+
.From<PropertyDataDto>()
111+
.InnerJoin<ContentVersionDto>()
112+
.On<PropertyDataDto, ContentVersionDto>((propertyData, contentVersion) =>
113+
propertyData.VersionId == contentVersion.Id)
114+
.LeftJoin<DocumentVersionDto>()
115+
.On<ContentVersionDto, DocumentVersionDto>((contentVersion, documentVersion) =>
116+
contentVersion.Id == documentVersion.Id)
117+
.Where<PropertyDataDto, ContentVersionDto, DocumentVersionDto>(
118+
(propertyData, contentVersion, documentVersion) =>
119+
(contentVersion.Current == true || documentVersion.Published == true)
120+
&& propertyData.PropertyTypeId == propertyType.Id);
121+
122+
List<PropertyDataDto> propertyDataDtos = Database.Fetch<PropertyDataDto>(sql);
123+
if (propertyDataDtos.Any() is false)
124+
{
125+
continue;
126+
}
127+
128+
foreach (PropertyDataDto propertyDataDto in propertyDataDtos)
129+
{
130+
if (ProcessPropertyDataDto(propertyDataDto, propertyType, languagesById, valueEditor))
131+
{
132+
Database.Update(propertyDataDto);
133+
}
134+
}
135+
}
136+
137+
return true;
138+
}
139+
140+
private bool ProcessPropertyDataDto(PropertyDataDto propertyDataDto, IPropertyType propertyType,
141+
IDictionary<int, ILanguage> languagesById, IDataValueEditor valueEditor)
142+
{
143+
// NOTE: some old property data DTOs can have variance defined, even if the property type no longer varies
144+
var culture = propertyType.VariesByCulture()
145+
&& propertyDataDto.LanguageId.HasValue
146+
&& languagesById.TryGetValue(propertyDataDto.LanguageId.Value, out ILanguage? language)
147+
? language.IsoCode
148+
: null;
149+
150+
if (culture is null && propertyType.VariesByCulture())
151+
{
152+
// if we end up here, the property DTO is bound to a language that no longer exists. this is an error scenario,
153+
// and we can't really handle it in any other way than logging; in all likelihood this is an old property version,
154+
// and it won't cause any runtime issues
155+
_logger.LogWarning(
156+
" - property data with id: {propertyDataId} references a language that does not exist - language id: {languageId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})",
157+
propertyDataDto.Id,
158+
propertyDataDto.LanguageId,
159+
propertyType.Name,
160+
propertyType.Id,
161+
propertyType.Alias);
162+
return false;
163+
}
164+
165+
var segment = propertyType.VariesBySegment() ? propertyDataDto.Segment : null;
166+
var property = new Property(propertyType);
167+
property.SetValue(propertyDataDto.Value, culture, segment);
168+
var toEditorValue = valueEditor.ToEditor(property, culture, segment);
169+
170+
_localLinkProcessor.ProcessToEditorValue(toEditorValue);
171+
172+
var editorValue = _jsonSerializer.Serialize(toEditorValue);
173+
var dbValue = valueEditor.FromEditor(new ContentPropertyData(editorValue, null), null);
174+
if (dbValue is not string stringValue || stringValue.DetectIsJson() is false)
175+
{
176+
_logger.LogError(
177+
" - value editor did not yield a valid JSON string as FromEditor value property data with id: {propertyDataId} (property type: {propertyTypeName}, id: {propertyTypeId}, alias: {propertyTypeAlias})",
178+
propertyDataDto.Id,
179+
propertyType.Name,
180+
propertyType.Id,
181+
propertyType.Alias);
182+
return false;
183+
}
184+
185+
propertyDataDto.TextValue = stringValue;
186+
return true;
187+
}
188+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Umbraco.Cms.Core.Composing;
3+
using Umbraco.Cms.Core.DependencyInjection;
4+
5+
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
6+
7+
[Obsolete("Will be removed in V18")]
8+
public class ConvertLocalLinkComposer : IComposer
9+
{
10+
public void Compose(IUmbracoBuilder builder)
11+
{
12+
builder.Services.AddSingleton<ITypedLocalLinkProcessor, LocalLinkBlockListProcessor>();
13+
builder.Services.AddSingleton<ITypedLocalLinkProcessor, LocalLinkBlockGridProcessor>();
14+
builder.Services.AddSingleton<ITypedLocalLinkProcessor, LocalLinkRteProcessor>();
15+
builder.Services.AddSingleton<LocalLinkProcessor>();
16+
}
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
2+
3+
[Obsolete("Will be removed in V18")]
4+
public interface ITypedLocalLinkProcessor
5+
{
6+
public Type PropertyEditorValueType { get; }
7+
8+
public IEnumerable<string> PropertyEditorAliases { get; }
9+
10+
public Func<object?, Func<object?, bool>, Func<string, string>, bool> Process { get; }
11+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Umbraco.Cms.Core;
2+
using Umbraco.Cms.Core.Models.Blocks;
3+
4+
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
5+
6+
[Obsolete("Will be removed in V18")]
7+
public abstract class LocalLinkBlocksProcessor
8+
{
9+
public bool ProcessBlocks(
10+
object? value,
11+
Func<object?, bool> processNested,
12+
Func<string, string> processStringValue)
13+
{
14+
if (value is not BlockValue blockValue)
15+
{
16+
return false;
17+
}
18+
19+
bool hasChanged = false;
20+
21+
foreach (BlockItemData blockItemData in blockValue.ContentData)
22+
{
23+
foreach (BlockPropertyValue blockPropertyValue in blockItemData.Values)
24+
{
25+
if (processNested.Invoke(blockPropertyValue.Value))
26+
{
27+
hasChanged = true;
28+
}
29+
}
30+
}
31+
32+
return hasChanged;
33+
}
34+
}
35+
36+
[Obsolete("Will be removed in V18")]
37+
public class LocalLinkBlockListProcessor : LocalLinkBlocksProcessor, ITypedLocalLinkProcessor
38+
{
39+
public Type PropertyEditorValueType => typeof(BlockListValue);
40+
41+
public IEnumerable<string> PropertyEditorAliases => [Constants.PropertyEditors.Aliases.BlockList];
42+
43+
public Func<object?, Func<object?, bool>, Func<string, string>, bool> Process => ProcessBlocks;
44+
}
45+
46+
[Obsolete("Will be removed in V18")]
47+
public class LocalLinkBlockGridProcessor : LocalLinkBlocksProcessor, ITypedLocalLinkProcessor
48+
{
49+
public Type PropertyEditorValueType => typeof(BlockGridValue);
50+
51+
public IEnumerable<string> PropertyEditorAliases => [Constants.PropertyEditors.Aliases.BlockGrid];
52+
53+
public Func<object?, Func<object?, bool>, Func<string, string>, bool> Process => ProcessBlocks;
54+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using Umbraco.Cms.Core;
2+
using Umbraco.Cms.Core.Models;
3+
using Umbraco.Cms.Core.Services;
4+
using Umbraco.Cms.Core.Templates;
5+
6+
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
7+
8+
[Obsolete("Will be removed in V18")]
9+
public class LocalLinkProcessor
10+
{
11+
private readonly HtmlLocalLinkParser _localLinkParser;
12+
private readonly IIdKeyMap _idKeyMap;
13+
private readonly IEnumerable<ITypedLocalLinkProcessor> _localLinkProcessors;
14+
15+
public LocalLinkProcessor(
16+
HtmlLocalLinkParser localLinkParser,
17+
IIdKeyMap idKeyMap,
18+
IEnumerable<ITypedLocalLinkProcessor> localLinkProcessors)
19+
{
20+
_localLinkParser = localLinkParser;
21+
_idKeyMap = idKeyMap;
22+
_localLinkProcessors = localLinkProcessors;
23+
}
24+
25+
public IEnumerable<string> GetSupportedPropertyEditorAliases() =>
26+
_localLinkProcessors.SelectMany(p => p.PropertyEditorAliases);
27+
28+
public bool ProcessToEditorValue(object? editorValue)
29+
{
30+
ITypedLocalLinkProcessor? processor =
31+
_localLinkProcessors.FirstOrDefault(p => p.PropertyEditorValueType == editorValue?.GetType());
32+
33+
return processor is not null && processor.Process.Invoke(editorValue, ProcessToEditorValue, ProcessStringValue);
34+
}
35+
36+
public string ProcessStringValue(string input)
37+
{
38+
// find all legacy tags
39+
var tags = _localLinkParser.FindLegacyLocalLinkIds(input).ToList();
40+
41+
foreach (HtmlLocalLinkParser.LocalLinkTag tag in tags)
42+
{
43+
string newTagHref;
44+
if (tag.Udi is not null)
45+
{
46+
newTagHref = $" type=\"{tag.Udi.EntityType}\" "
47+
+ tag.TagHref.Replace(tag.Udi.ToString(), tag.Udi.Guid.ToString());
48+
}
49+
else if (tag.IntId is not null)
50+
{
51+
// try to get the key and type from the int, else do nothing
52+
(Guid Key, string EntityType)? conversionResult = CreateIntBasedKeyType(tag.IntId.Value);
53+
if (conversionResult is null)
54+
{
55+
continue;
56+
}
57+
58+
newTagHref = $" type=\"{conversionResult.Value.EntityType}\" "
59+
+ tag.TagHref.Replace(tag.IntId.Value.ToString(), conversionResult.Value.Key.ToString());
60+
}
61+
else
62+
{
63+
// tag does not contain enough information to convert
64+
continue;
65+
}
66+
67+
input = input.Replace(tag.TagHref, newTagHref);
68+
}
69+
70+
return input;
71+
}
72+
73+
private (Guid Key, string EntityType)? CreateIntBasedKeyType(int id)
74+
{
75+
// very old data, best effort replacement
76+
Attempt<Guid> documentAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document);
77+
if (documentAttempt.Success)
78+
{
79+
return (Key: documentAttempt.Result, EntityType: UmbracoObjectTypes.Document.ToString());
80+
}
81+
82+
Attempt<Guid> mediaAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
83+
if (mediaAttempt.Success)
84+
{
85+
return (Key: mediaAttempt.Result, EntityType: UmbracoObjectTypes.Media.ToString());
86+
}
87+
88+
return null;
89+
}
90+
}

0 commit comments

Comments
 (0)