Replacing strings upon publish, save or rendering?

I’m working upon a hobby project where I want to replace strings in the output.
My idea is to do this 1 time each 24 hours

What would be the best way to do this ?

Let’s say that in all Rich Text Editors I would like to replace “Umbraco” with

<a href="http://umbraco.com" target="_blank">Umbraco</a>

Interesting… so just to make sure, by “the output” you mean your template basically, right?

And can you say more about wanting to do this every 24 hours instead of instantly?

I’m reminded of PVCs:

But you’d need some kind of way to change the replace text every 24 hours, which is going to be a bit tricky. What you could do is have a … file, database table, something… with dates in them: on March 1st, replace with x, on March 2nd, replace with y, etc.

In your PVC you can read the value for “today” and use that. Would something like that work for you?

Well, that is the question :slight_smile:

My initial thought was to do it in the template, but then I thought about the performance. With time there could be a lot of string to replace, and therefor I thought about doing it once every 24 hours and save it to the database.

The entire idea is to have a website with a load of articles. Then there could be some brands with a login where they can define strings and what each string should link to.

These strings would then be replace with the link in the output, or in the database upon save/publish.

Hi @dammark

Maybe you could do some of it with this plugin Umbraco Marketplace | Umbraco integrations and packages

Then instead of replacing links you change the string in the richtext editor to match tokenreplacement structure and then just update the dictionary item?

Possibly something like a search and replace approach would work for what you want to accomplish? Or maybe not! This is from a v12 site, but gives you the idea…and apologies for the kinda nasty code, this was a one-off:

https://gist.github.com/paulsterling/96801e3ca70b6ddb174c6435191e5f36

using Umbraco.Cms.Web.BackOffice.Controllers;
using Umbraco.Cms.Core.Services;
using Microsoft.AspNetCore.Mvc;

namespace FindAndReplace.Core.Controllers
{
    public class FindAndReplaceApiController : UmbracoAuthorizedApiController
    {
        private readonly IContentTypeService _contentTypeService;
        private readonly IContentService _contentService;


        public FindAndReplaceApiController(IContentTypeService contentTypeService,
                                            IContentService contentService)
        {
            _contentTypeService = contentTypeService;
            _contentService = contentService;
        }

        /// <summary>
        /// Url: /umbraco/backoffice/api/FindAndReplaceApi/PostBulkUpdate
        /// </summary>
        public ActionResult PostBulkUpdate()
        {
            // ContentType by alias
            var contentType = _contentTypeService.Get("article");

            var contentOfType = _contentService.GetPagedOfType(contentType.Id, 0, int.MaxValue, out long totalArchive, null);

            foreach (var content in contentOfType)
            {
                // the node name can be used as the title
                if(content.Name.Contains("youtube", StringComparison.InvariantCultureIgnoreCase))
                {
                    content.Name = content.Name.Replace("youtube", "MyTube", StringComparison.InvariantCultureIgnoreCase);
                }

                // alternately, we could iterate all properties from the document
                if(content.HasProperty("title"))
                {
                    foreach(var value in content.Properties["title"].Values)
                    {
                        // if this is a JSON object (eg, BlockList) we may want to add some safety to check the Json is still valid
                        if(value.EditedValue.ToString().Contains("youtube", StringComparison.InvariantCultureIgnoreCase))
                        {
                            var newValue = value.EditedValue.ToString().Replace("youtube", "MyTube", StringComparison.InvariantCultureIgnoreCase);
                            content.Properties["title"].SetValue(newValue);
                        }
                    }
                }

                if(content.IsDirty())
                {
                    _contentService.SaveAndPublish(content);
                }
            }

            return new OkResult();
        }
    }
}

For scheduling you can use either a Recurring Background Job (Scheduling | Umbraco CMS) or use something like Hangfire (Umbraco Marketplace | Umbraco integrations and packages).

The benefit of hangfire is that you can schedule your “jobs” using a cronjob, which basically means you can set a job to run every day at a specific time.

With recurring background jobs, you have less control. You can still make them run only once per day, but you can’t control the time, as that is determined by when the site was started/restarted. A cheeky workaround could be to run the job every minute, but then return early if the time is not a specific time.

Eg.

using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Infrastructure.BackgroundJobs;

namespace Umbraco.Docs.Samples.Web.RecurringBackgroundJob;

public class CleanUpYourRoom : IRecurringBackgroundJob
{
    public TimeSpan Period { get => TimeSpan.FromMinutes(1); }

    // Runs on all servers
    public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }

    // No-op event as the period never changes on this job
    public event EventHandler PeriodChanged { add { } remove { } }

    public async Task RunJobAsync()
    {
        // If the time is not 7:38, return early and don't do anything
        if (DateTime.Now.Hour != 7 && DateTime.Now.Minute != 38) {
            return Task.FromResult(true);
        }
        // YOUR CODE GOES HERE
    }
}

But upon thinking about your project, I would handle the replacement when rendering instead of having to update and republish all content. Its going to create a mess, and make the editors ability to rollback a lot harder, if the site potentially creates a new version of every node every day.

Maybe I’m misunderstanding, but if it’s a bunch of nodes in which many strings need to change, why not use Umbraco… as a CMS where they can update the content as needed? :sweat_smile:

With Documents blueprints, they can create the default pre-filles content item(s) and fill in the changes where needed.

Keeps it simple. :man_shrugging:

Another alternative would to simply use middleware to render the changes to the output.

Another way to accomplish this may be to use the Rich Text Block Editor functionality - for example, I’ve implemented a “Placeholder” block in the Rich Text Editor where I can have the content automatically updated in that block based on some variables.

Whether the updated content is pulled from elsewhere in the site, or based on a service call, is up to you. The advantage is that you limit the replacement to a block View rather than having to parse the entire Rich Text everytime.

2 Likes

I hope I’ve understood the requirement correctly. Two ideas come to mind, both based around leaving the article content unmodified and applying string replacement.

  1. Apply the string replacement in the view or controller when rendering the template, but cache the page output. This way the majority of requests come from the cache and over all it should be performant.
  2. Output the content unmodified in the view and have JavaScript call an API to get the list of replacements. Then the JavaScript/browser can process the list and modify the DOM. You’ll always get a fast initial page load and then, with a short delay, will shift the processing burden from the server to the client. This could potentially be the most performant with the best page load speeds. The API response could also be cached if it would help performance.

The entire idea is that those that are writing the articles and those creating the links, are not the same people. Those writing articles are not tech savvy at all, so it should be as easy as possible.

As for now I’ve created a helper function that replaces words/sentences in the output in the template.

So you want them to select a link ‘item’ from a list of link items, so they don’t have to know the link?

How do they now select the link to use? Or do the editor just need to know what the link keys are?

I would create a block for that, where the editor can select a link from a list. I would make the links a list of nodes instead of properties on a single node. You can render a block inline in the RTE. The code for the block replaced the selected link item with the actual link. This is a win win:

  • For the editors, it’s very convinient because they cannot select a wrong key
  • Separation of concerns and reusability: you could use the block on other places on the site as well.

No, the editor shouldn’t deal with links at all.

In my example the editor included the following words Dammark, Greystate and Steinmeier in the text. These are then replaced with my helper-function when the output is rendered.

As for now it works, but I’m not sure about the performance in the long run :slight_smile:

Ok I get the use case :slight_smile: If possible I really think it would be nice for the editing experience ánd because it’s less error prone to use blocks for this.

from a usability perspective, Blocks in the RTE are a bit of a learning curve at first for the non-technical, but once they get used to them, blocks can be really useful. What I don’t like about them is that if you have a block that’s set as in-line and has minimal output (e.g.) text rendered in it (we use the BlockPreview plugin) then it can be a bit unwieldy editing or deleting the block.

I’ve had similar requirements pop up a few times over the years, and have implemented all of the various options mentioned above, to varying degrees of success.

tl;dr; Don’t make this a content problem. Use a PropertyValueConverter - it has caching built in.

There’s only one safe time/place in the CMS to modify user content - on save. Doing it in the background messes with the history, and means editors don’t see the actual content that they’re publishing.

That would normally be my recommendation, but two things mentioned above don’t sit well with that:

Doing it on save will mean that editors will need to deal with links when they come back and edit. If you don’t want them to have to worry about links at all then you probably don’t want this in content either.

Brand rules change. I used to do a bit of work with LEGO® (Not Lego, lego, or LEGO, but “LEGO®”!) and we had some interesting requirements come in that required us to do things exactly like @dammark’s requirements. What we learnt pretty quickly is that these rules change. You might need to undo any change you make, which isn’t so easy when you’re applying changes to content directly.

Instead, making these requirements a display/viewmodel concern, makes a lot more sense. You don’t need to worry about the content itself and the rules becomes business logic that you can apply to Umbraco’s output rather than mess around with the CMS itself.

Doing this in your own controller and viewmodel is an option, but it takes a fair amount of code to make it performant. Umbraco already has a built in way of transforming content for display - the ConvertIntermediateToObject method inside of PropertyValueConverter.

So, I recommend creating custom PropertyValueConverters for this. It’s an easy place to transform content for display and comes with caching built in. It also happens after the block grid/list has been constructed from JSON so you only need to worry about actual property values.

You’ll still want to optimise for performance, but really that’s just a case of using native string operations instead of regex.

Overriding the built-in converters requires you to unregister your own (they get typescanned) and pop them back in the right order, like this:

builder.PropertyValueConverters()
    .Remove<CustomTextStringValueConverter>()
    .Remove<CustomMultipleTextboxValueConverter>()
    .Remove<CustomRteValueConverter>()
    .InsertBefore<TextStringValueConverter, CustomTextStringValueConverter>()
    .InsertBefore<MultipleTextStringValueConverter, CustomMultipleTextboxValueConverter>()
    .InsertBefore<RteMacroRenderingValueConverter, CustomRteValueConverter>();
// etc...
4 Likes