When localizing an ASP.NET app (MVC or webforms, does't matter), how do you handle HTML strings in your resource file? In particular, how do you handle something like a paragraph with an embedded dynamic link? My strategy so far has been to use some sort of placeholder for the href attribute value and replace it at runtime with the actual URL, but this seems hokey at best.
As an example, suppose my copy is:
Thank you for registering. Click
here
to update your preferences.
To login and begin using the app, click
here.
Using MVC (Razor), what could be a simple:
<p>#Resources.Strings.ThankYouMessage</p>
now turns into
<p>#Resources.Strings.ThankYouMessage
.Replace("{prefs_url}", Url.Action("Preferences", "User"))
.Replace("{login_url}", Url.Action("Login", "User"))</p>
It's not horrible, but I guess I'm just wondering if there's a better way?
There isn't really a better way, beyond some syntax and performance tweaks. For example, you might add a cache layer so that you aren't doing these string operations for every request. Something like this:
<p>#Resources.LocalizedStrings.ThankYouMessage</p>
which calls a function perhaps like this:
Localize("ThankYouMessage", Resources.Strings.ThankYouMessage)
which does a hashtable lookup by resource + culture:
//use Hashtable instead of Dictionary<> because DictionaryBase is not thread safe.
private static System.Collections.Hashtable _cache =
System.Collections.Hashtable.Synchronized(new Hashtable());
public static string Localize(string resourceName, string resourceContent) {
string cultureName = System.Threading.Thread.CurrentThread.CurrentCulture.Name;
if (string.IsNullOrEmpty(resourceName))
throw new ArgumentException("'resourceName' is null or empty.");
string cacheKey = resourceName + "/" + cultureName;
object o = _cache[cacheKey];
if (null == o) { //first generation; add it to the cache.
_cache[cacheKey] = o = ReplaceTokensWithValues(resourceContent);
}
return o as string;
}
Notice the call to ReplaceTokensWithValues(). That is the function that contains all the "not horrible" string-replacement fiffery:
public static string ReplaceTokensWithValues(string s) {
return s.Replace("{prefs_url}", Url.Action("Preferences", "User"))
.Replace("{login_url}", Url.Action("Login", "User")
.Replace("{any_other_stuff}", "random stuff");
}
By using a caching approach as above, ReplaceTokensWithValues() is only called once per culture, per resource for the lifetime of the application--instead of once per resource call. The difference may be on the order of 100 vs. 1,000,000.
Related
I try to accomplish a similar behaviour with MS Docs.
For example, if you visit https://learn.microsoft.com/, you will be redirected to your culture, in my case I'm being redirected automatically to https://learn.microsoft.com/en-gb/.
Same goes for inner pages if you access them without the culture in the URL.
For instance, by accessing:
https://learn.microsoft.com/aspnet/core/razor-pages/?view=aspnetcore-3.1&tabs=visual-studio
it will be automatically redirect you to:
https://learn.microsoft.com/en-gb/aspnet/core/razor-pages/?view=aspnetcore-3.1&tabs=visual-studio
I have a small demo app where I conduct my localisation experiment for .NET Core 3.1 and Razor Pages here.
I have set options.Conventions here, and I have created CustomCultureRouteRouteModelConvention class here, but I'm fairly novice with .NET Core and I'm kind of stuck on how to implement the above-described functionality.
Thank you all in advance!
You should use existing Rewriting Middleware to do redirects: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/url-rewriting?view=aspnetcore-3.1
In the simplest form, you can tell rewrite middleware to redirect if it does not see a locale pattern at the beginning of the URL path, maybe
new RewriteOptions() .AddRedirect("^([a-z]{2}-[a-z]{2})", "en-US/$1")
(regex not tested) or do full redirect class with more detailed rules when and to what locale you want to redirect. Example in that aspnet document references RedirectImageRequest which you can use to get an understanding of how custom redirect rules works. Adapting to your case as a proof of concept, I reused most of the logic in your existing RedirectUnsupportedCulture:
public class RedirectUnsupportedCultures : IRule
{
private readonly string _extension;
private readonly PathString _newPath;
private IList<CultureInfo> _cultureItems;
private string _cultureRouteKey;
public RedirectUnsupportedCultures(IOptions<RequestLocalizationOptions> options)
{
RouteDataRequestCultureProvider provider = options.Value.RequestCultureProviders
.OfType<RouteDataRequestCultureProvider>()
.First();
_cultureItems = options.Value.SupportedUICultures;
_cultureRouteKey = provider.RouteDataStringKey;
}
public void ApplyRule(RewriteContext rewriteContext)
{
// do not redirect static assets and do not redirect from a controller that is meant to set the locale
// similar to how you would not restrict a guest user from login form on public site.
if (rewriteContext.HttpContext.Request.Path.Value.EndsWith(".ico") ||
rewriteContext.HttpContext.Request.Path.Value.Contains("change-culture"))
{
return;
}
IRequestCultureFeature cultureFeature = rewriteContext.HttpContext.Features.Get<IRequestCultureFeature>();
string actualCulture = cultureFeature?.RequestCulture.Culture.Name;
string requestedCulture = rewriteContext.HttpContext.GetRouteValue(_cultureRouteKey)?.ToString();
// Here you can add more rules to redirect based on maybe cookie setting, or even language options saved in database user profile
if(string.IsNullOrEmpty(requestedCulture) || _cultureItems.All(x => x.Name != requestedCulture)
&& !string.Equals(requestedCulture, actualCulture, StringComparison.OrdinalIgnoreCase))
{
string localizedPath = $"/{actualCulture}{rewriteContext.HttpContext.Request.Path.Value}";
HttpResponse response = rewriteContext.HttpContext.Response;
response.StatusCode = StatusCodes.Status301MovedPermanently;
rewriteContext.Result = RuleResult.EndResponse;
// preserve query part parameters of the URL (?parameters) if there were any
response.Headers[HeaderNames.Location] =
localizedPath + rewriteContext.HttpContext.Request.QueryString;
}
}
and registered it in Startup.cs with
// Attempt to make auto-redirect to culture if it is not exist in the url
RewriteOptions rewriter = new RewriteOptions();
rewriter.Add(new RedirectUnsupportedCultures(app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>()));
app.UseRewriter(rewriter);
Improvement:
After using the above code I bumped on a bug that in case the culture is not supported by the application, the redirection will end up with infinite culture paths. For example, if I support the cultures en (default) and gr, if instead of either /en/foobar or /gr/foobar I would write /fr/foobar, I would end up getting /en/fr/foobar then /en/en/fr/foobar and etc.
I added private readonly LinkGenerator _linkGenerator; to the class, which I initialise it in the constructor. I removed that line string localizedPath = $"/{actualCulture}{rewriteContext.HttpContext.Request.Path.Value}"; and the code after that line looks like this:
rewriteContext.HttpContext.GetRouteData().Values[_cultureRouteKey] = actualCulture;
HttpResponse response = rewriteContext.HttpContext.Response;
response.StatusCode = StatusCodes.Status301MovedPermanently;
rewriteContext.Result = RuleResult.EndResponse;
// preserve query part parameters of the URL (?parameters) if there were any
response.Headers[HeaderNames.Location] =
_linkGenerator.GetPathByAction(
rewriteContext.HttpContext,
values: rewriteContext.HttpContext.GetRouteData().Values
)
+ rewriteContext.HttpContext.Request.QueryString;
As decribed in Microsoft docs localization middleware; each the localization request initializes a list of RequestCultureProvider and is enumerated by the below order :
QueryStringRequestCultureProvider : e.g. http://localhost:1234/Index?culture=en
CookieRequestCultureProvider : Looks for the culture cookie, and it will be null if you haven't set it manually.
AcceptLanguageHeaderRequestCultureProvider : This one depends on the browsers cultures adn this is what you need to look for.
To make sure how it works, delete the culture cookie and change the browser language preferences by moving the desired language to the top, you will see that the language is selected according to the browser preferences.
I made an API that looks like this
[HttpPost]
[Route("/products")]
public async Task<IActionResult> Add([FromBody]ProductDTO productDTO) {
Product newProduct = await _productsService.Add(productDTO);
return Ok(Mapper.Map<ProductDTO>(newProduct));
}
The ProductDTO has a Price property and has a decimal type.
I want to make sure that if a move my application to another server with a different locale, it will still accept a "." as separator. For instance, if I move it to a server that has a locale in Italy, I want to make sure that the separator won't change to ",".
Does setting the culture info in the Startup.Configure solve my problem?
var cultureInfo = new CultureInfo("en-US");
cultureInfo.NumberFormat.CurrencyDecimalSeparator = ".";
CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
I tried to change the locale of the computer that I'm currently using for development, and it works. But I want to know if someone had problems when he/she deployed the application to another server.
I have an ASP.NET page which takes a number of parameters in the query string:
search.aspx?q=123&source=WebSearch
This would display the first page of search results. Now within the rendering of that page, I want to display a set of links that allow the user to jump to different pages within the search results. I can do this simply by append &page=1 or &page=2 etc.
Where it gets complicated is that I want to preserve the input query string from the original page for every parameter except the one that I'm trying to change. There may be other parameters in the url used by other components and the value I'm trying to replace may or may not already be defined:
search.aspx?q=123&source=WebSearch&page=1&Theme=Blue
In this case to generate a link to the next page of results, I want to change page=1 to page=2 while leaving the rest of the query string unchanged.
Is there a builtin way to do this, or do I need to do all of the string parsing/recombining manually?
You can't modify the QueryString directly as it is readonly. You will need to get the values, modify them, then put them back together. Try this:
var nameValues = HttpUtility.ParseQueryString(Request.QueryString.ToString());
nameValues.Set("page", "2");
string url = Request.Url.AbsolutePath;
string updatedQueryString = "?" + nameValues.ToString();
Response.Redirect(url + updatedQueryString);
The ParseQueryString method returns a NameValueCollection (actually it really returns a HttpValueCollection which encodes the results, as I mention in an answer to another question). You can then use the Set method to update a value. You can also use the Add method to add a new one, or Remove to remove a value. Finally, calling ToString() on the name NameValueCollection returns the name value pairs in a name1=value1&name2=value2 querystring ready format. Once you have that append it to the URL and redirect.
Alternately, you can add a new key, or modify an existing one, using the indexer:
nameValues["temp"] = "hello!"; // add "temp" if it didn't exist
nameValues["temp"] = "hello, world!"; // overwrite "temp"
nameValues.Remove("temp"); // can't remove via indexer
You may need to add a using System.Collections.Specialized; to make use of the NameValueCollection class.
You can do this without all the overhead of redirection (which is not inconsiderable). My personal preference is to work with a NameValueCollection which a querystring really is, but using reflection:
// reflect to readonly property
PropertyInfo isReadOnly = typeof(System.Collections.Specialized.NameValueCollection).GetProperty("IsReadOnly", BindingFlags.Instance | BindingFlags.NonPublic);
// make collection editable
isReadOnly.SetValue(this.Request.QueryString, false, null);
// remove
this.Request.QueryString.Remove("foo");
// modify
this.Request.QueryString.Set("bar", "123");
// make collection readonly again
isReadOnly.SetValue(this.Request.QueryString, true, null);
Using this QueryStringBuilder helper class, you can grab the current QueryString and call the Add method to change an existing key/value pair...
//before: "?id=123&page=1&sessionId=ABC"
string newQueryString = QueryString.Current.Add("page", "2");
//after: "?id=123&page=2&sessionId=ABC"
Use the URIBuilder Specifically the link textQuery property
I believe that does what you need.
This is pretty arbitrary, in .NET Core at least. And it all boils down to asp-all-route-data
Consider the following trivial example (taken from the "paginator" view model I use in virtually every project):
public class SomeViewModel
{
public Dictionary<string, string> NextPageLink(IQueryCollection query)
{
/*
* NOTE: how you derive the "2" is fully up to you
*/
return ParseQueryCollection(query, "page", "2");
}
Dictionary<string, string> ParseQueryCollection(IQueryCollection query, string replacementKey, string replacementValue)
{
var dict = new Dictionary<string, string>()
{
{ replacementKey, replacementValue }
};
foreach (var q in query)
{
if (!string.Equals(q.Key, replacementKey, StringComparison.OrdinalIgnoreCase))
{
dict.Add(q.Key, q.Value);
}
}
return dict;
}
}
Then to use in your view, simply pass the method the current request query collection from Context.Request:
<a asp-all-route-data="#Model.NextPageLink(Context.Request.Query)">Next</a>
I want to dynamically populate a link with the URI of the current request, but set one specific query string parameter. All other querystring paramaters (if there are any) should be left untouched. And I don't know in advance what they might be.
Eg, imagine I want to build a link back to the current page, but with the querystring parameter "valueOfInterest" always set to be "wibble" (I'm doing this from the code-behind of an aspx page, .Net 3.5 in C# FWIW).
Eg, a request for either of these two:
/somepage.aspx
/somepage.aspx?valueOfInterest=sausages
would become:
/somepage.aspx?valueOfInterest=wibble
And most importantly (perhaps) a request for:
/somepage.aspx?boring=something
/somepage.aspx?boring=something&valueOfInterest=sausages
would preserve the boring params to become:
/somepage.aspx?boring=something&valueOfInterest=wibble
Caveats: I'd like to avoid string manipulation if there's something more elegant in asp.net that is more robust. However if there isn't something more elegant, so be it.
I've done (a little) homework:
I found a blog post which suggested copying the request into a local HttpRequest object, but that still has a read-only collection for the querystring params. I've also had a look at using a URI object, but that doesn't seem to have a querystring
This will work as long as [1] you have a valid URL to begin with (which seems reasonable) [2] you make sure that your new value ('sausages') is properly escaped. There's no parsing, the only string manipulation is to concatenate the parameters.
Edit
Here's the C#:
UriBuilder u = new UriBuilder(Request.Url);
NameValueCollection nv = new NameValueCollection(Request.QueryString);
/* A NameValueColllection automatically makes room if this is a new
name. You don't have to check for NULL.
*/
nv["valueOfInterest"] = "sausages";
/* Appending to u.Query doesn't quite work, it
overloaded to add an extra '?' each time. Have to
use StringBuilder instead.
*/
StringBuilder newQuery = new StringBuilder();
foreach (string k in nv.Keys)
newQuery.AppendFormat("&{0}={1}", k, nv[k]);
u.Query = newQuery.ToString();
Response.Redirect(u.Uri.ToString());
UriBuilder u = new UriBuilder(Request.Url);
NameValueCollection nv = new NameValueCollection(Request.QueryString);
nv["valueofinterest"] = "wibble";
string newQuery = "";
foreach (string k in nv.Keys)
{
newQuery += k + "=" + nv[k] + "&";
}
u.Query = newQuery.Substring(0,newQuery.Length-1);
Response.Redirect(u.ToString());
that should do it
If you can't find something that exists to do it, then build a bullet-proof function to do it that is thoroughly tested and can be relied upon. If this uses string manipulation, but is efficient and fully tested, then in reality it will be little different to what you may find any way.
I'm having some difficulty with using NVelocity in an ASP.NET MVC application. I'm using it as a way of generating emails.
As far as I can make out the details I'm passing are all correct, but it fails to load the template.
Here is the code:
private const string defaultTemplatePath = "Views\\EmailTemplates\\";
...
velocityEngine = new VelocityEngine();
basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, defaultTemplatePath);
ExtendedProperties properties = new ExtendedProperties();
properties.Add(RuntimeConstants.RESOURCE_LOADER, "file");
properties.Add(RuntimeConstants.FILE_RESOURCE_LOADER_PATH, basePath);
velocityEngine.Init(properties);
The basePath is the correct directory, I've pasted the value into explorer to ensure it is correct.
if (!velocityEngine.TemplateExists(name))
throw new InvalidOperationException(string.Format("Could not find a template named '{0}'", name));
Template result = velocityEngine.GetTemplate(name);
'name' above is a valid filename in the folder defined as basePath above. However, TemplateExists returns false. If I comment that conditional out and let it fail on the GetTemplate method call the stack trace looks like this:
at NVelocity.Runtime.Resource.ResourceManagerImpl.LoadResource(String resourceName, ResourceType resourceType, String encoding)
at NVelocity.Runtime.Resource.ResourceManagerImpl.GetResource(String resourceName, ResourceType resourceType, String encoding)
at NVelocity.Runtime.RuntimeInstance.GetTemplate(String name, String encoding)
at NVelocity.Runtime.RuntimeInstance.GetTemplate(String name)
at NVelocity.App.VelocityEngine.GetTemplate(String name)
...
I'm now at a bit of an impasse. I feel that the answer is blindingly obvious, but I just can't seem to see it at the moment.
Have you considered using Castle's NVelocityTemplateEngine?
Download from the "TemplateEngine Component 1.1 - September 29th, 2009" section and reference the following assemblies:
using Castle.Components.Common.TemplateEngine.NVelocityTemplateEngine;
using Castle.Components.Common.TemplateEngine;
Then you can simply call:
using (var writer = new StringWriter())
{
_templateEngine.Process(data, string.Empty, writer, _templateContents);
return writer.ToString();
}
Where:
_templateEngine is your NVelocityTemplateEngine
data is your Dictionary of information (I'm using a Dictionary to enable me to access objects by a key ($objectKeyName) in my template.
_templateContents is the actual template string itself.
I hope this is of help to you!
Just to add, you'll want to put that into a static method returning a string of course!
Had this issue recently - NVelocity needs to be initialised with the location of the template files. In this case mergeValues is an anonymous type so in my template I can just refer to $Values.SomeItem:
private string Merge(Object mergeValues)
{
var velocity = new VelocityEngine();
var props = new ExtendedProperties();
props.AddProperty("file.resource.loader.path", #"D:\Path\To\Templates");
velocity.Init(props);
var template = velocity.GetTemplate("MailTemplate.vm");
var context = new VelocityContext();
context.Put("Values", mergeValues);
using (var writer = new StringWriter())
{
template.Merge(context, writer);
return writer.ToString();
}
}
Try setting the file.resource.loader.path
http://weblogs.asp.net/george_v_reilly/archive/2007/03/06/img-srchttpwwwcodegenerationnetlogosnveloc.aspx
Okay - So I'm managed to get something working but it is a bit of a hack and isn't anywhere near a solution that I want, but it got something working.
Basically, I manually load in the template into a string then pass that string to the velocityEngine.Evaluate() method which writes the result into the the given StringWriter. The side effect of this is that the #parse instructions in the template don't work because it still cannot find the files.
using (StringWriter writer = new StringWriter())
{
velocityEngine.Evaluate(context, writer, templateName, template);
return writer.ToString();
}
In the code above templateName is irrelevant as it isn't used. template is the string that contains the entire template that has been pre-loaded from disk.
I'd still appreciate any better solutions as I really don't like this.
The tests are the ultimate authority:
http://fisheye2.atlassian.com/browse/castleproject/NVelocity/trunk/src/NVelocity.Tests/Test/ParserTest.cs?r=6005#l122
Or you could use the TemplateEngine component which is a thin wrapper around NVelocity that makes things easier.