Thursday, October 25, 2012

Promisable Windows Runtime Component

The async methods in Windows Runtime component can be consumed directly by C# apps, but not by JavaScript. In order to expose async method to JavaScript world, simply wrap it using AsAsyncOperation() method:
using System;
using System.Threading.Tasks;
using Windows.Foundation;

namespace WinRTComponent
{
    public sealed class Test
    {
        public IAsyncOperation<double> TestAsync(int a, int b)
        {
            return DivideAsync(a, b).AsAsyncOperation();
        }

        private static async Task<double> DivideAsync(int a, int b)
        {
            if ( b == 0 )
                throw new InvalidOperationException("Dividing by zero!");
            await Task.Delay(1000);
            return 1.0 * a / b;
        }
    }
}
Inside WinJS project you just add reference to the runtime component, and the async method will be availale (intelliSense works as well):
    WinJS.UI.Pages.define("/pages/home/home.html", {
        ready: function (element, options) {

            var tester = new WinRTComponent.Test();
            tester.testAsync(123, 2).then(function (result) {
                console.log(result);        // result = 61.5
            });
            tester.testAsync(123, 0).then(
                function (result) {
                    console.log(result);    // not reachable 
                },
                function (error) {
                    console.log(error);     // InvalidOperationException 
                });
        }
    }
Note that the async method name automatically becomes camel case (TestAsync => testAsync). Also the exception thrown by runtime component will fall to error function handler in WinJS promise then function. The exception type is visible but the original excetion string is invisible from JavaScript side:

Saturday, October 13, 2012

Android Activity Life Cycle

Android apps with UI interaction are composed of one or more Activity. It's crucial to understand Activity life cycle just as ASP.NET developers have to know how the Page object is constructed. Following image from Android developer guide shows the overall flow:

A few notes on events for different user actions:

  1. Hit home/power button: onPause() => onStop(). Stop state means in Activity in background, and it's no longer visible on the screen. From documentation Android OS could kill the activity process (onDestroy() will be called) when memory reaches a low point, but I can't effectively reproduce that in a physical device.
  2. From 1 back to activity: onRestart() => onStart() => onResume(). This is quite straightforward. If the low-memory-kill-background-process happened, a new Activity instance will be recreated from scratch.
  3. Hit back button: onPause() => onStop() => onDestroy(). The previous Activity will show up and current activity is destroyed and gone. The app will close if no previous activity exists in navigation history.
  4. Rotate the screen from landscape to portrait or vise verse: onPause() => onStop() => onDestroy() => onCreate() => onStart() => onResume(). The whole activity is destroyed and recreated. That's very different from Windows 8/Windows phone 8 implementation where that only triggers an event and the instance of the page or class remains. Android considers screen rotation, language change, storage plug/unplug, etc. as "runtime configuration change". The Activity will be reloaded by default when configuration is changed, so a new layout xml file as well as other related resources can be reconstructed properly. You can skip this behavior by overriding onConfigurationChanged method to handle the situation by your own, but such approach is not recommended from Android developer guide.

Another interesting thing is the state management, two scenarios in particular: 1. background Activity is destroyed due to low memory and then is back to active Activty; 2. foreground Activity is destroyed and rebuilt because of the screen rotation. Android provides onSaveInstanceState method for you to save your data, which is executed after the onPause(0 and before the onStop() callback handlers, then you can restore the data in onCreate() method:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
 
        // check if saved state data exists 
        if (savedInstanceState != null) {
            int prevSelectedNumber = savedInstanceState.getInt("SELECTEDNUMBER");
            // do UI logic based on prevSelectedNumber
        }
    }
 
    @Override
    public void onSaveInstanceState(Bundle savedInstanceState) {
        super.onSaveInstanceState(savedInstanceState);
 
        // save instance state data
        savedInstanceState.putInt("SELECTEDNUMBER", 123);
    }

Note that onSaveInstanceState() method is only called when the Activity is destroyed by Android OS internally like the orientation change discussed above, but it will not be called if the Activity is killed by the user action such as turning off the device or pressing the back button to go to previous Activity. In some cases, for example the address/contact input form, it would be great to keep unsaved and uncompleted data to avoid user retyping when the user is going back and forth to different screens, then the data should be saved during onPause() callback.

On top of Activities there're a set of rules defining how an Activity instance is created and behaves in one or multiple applications, e.g. a browser Activity declared as singleTask launch mode will result in only one instance running inside one Task cross the whole Android environment. The details are well described in this Android Developer Guide.

Friday, October 12, 2012

Eclipse Tips

I mainly use Visual Studio for development in the past 8 years. Recently I switch to Eclipse to do some Android work. Not like old days I had to spend a lot of time on memorizing a list of commands to run the vi editor, modern IDEs are quite easy to work with, but the right settings and handy short-cuts can still speed up your work. Below is some of my findings on Eclipse that helps me to do my work more efficiently.

Preference Settings:

1. Show code line number:

I am not sure why both Eclipse and Visual Studio are not showing line number by default. The line number is quite useful for me to pin point the source code, so I turn it on the first place.

2. Save before build but not automatically build.

Unlike Visual Studio Eclipse will not save your changes when you click build or run the application. On the other hand, Eclipse automatically build(compile) the source files in the background every time you make a change in source file. I don't like both default options and here is where you can change the behavior:

3. Enable auto insert:

This is a nice feature in Eclipse which is not available in Visual Studio or I am not aware of. Basically you can type ";" or "{" anywhere during coding and Eclipse will automatically put it to the right spot, the ending of the code line.

4. Enable step filters:

With “Step Filters” you will skip stepping into those types during debugging, so you could just focus on your own code.

Handy short-cuts

Ctrl+Space content assist just like intellisense in Visual Studio
Ctrl+Shift+F format code
Ctrl+/ comment/uncomment code with //
Ctrl+Shift+/ comment code with /**/
Ctrl+D delete line
F3 go to declaration
Ctrl+Shift+O auto import (when this available in Visual Studio?)
Ctrl+T show type hierarchy
Ctrl+Shift+T search for type
Ctrl+Shift+R search for resource
Ctrl+Shift+G find all references
Ctrl+H open advanced search window
Alt+Left/Right navigate source back and forward like Ctrl-/+ in Visual Studio

Saturday, October 06, 2012

WinJS Promise Any and Join

WinJS.Promise.any function will return another Promise object when any of the Promise objects defined in an array completes. The object passed to next Promise function from Promise.Any function has key and value properties. The key property is the array index (zero-based) of the first completed Promise, and the value property is another Promise object which wraps the return of first completed Promise, as demoed in following code snippet:
    function addAsync(number1, number2, delayInSeconds) {
        return new WinJS.Promise(function (c, m, p) {
            setTimeout(function () {
                c(number1 + number2);
            }, delayInSeconds * 1000); 
        });
    }

    function test() {
        var promise1 = addAsync(1, 1, 3);
        var promise2 = addAsync(2, 2, 2);
        var promise3 = addAsync(3, 3, 1);

        WinJS.Promise.any([promise1, promise2, promise3]).then(function (data) {
            var key = data.key;            // key = 2
            var value = data.value;        // value is a Promise
            var realValue = value._value;  // realValue = 6
        });
    }
On the other hand, Promise.join function will wait for all Promises in an array to complete before the then function starts. The same size of array holding each Promise's return will be passed to the then function:
    function addAsync(number1, number2, delayInSeconds) {
        return new WinJS.Promise(function (c, m, p) {
            setTimeout(function () {
                c(number1 + number2);
            }, delayInSeconds * 1000); 
        });
    }

    function test() {
        var promise1 = addAsync(1, 1, 3);
        var promise2 = addAsync(2, 2, 2);
        var promise3 = addAsync(3, 3, 1);

        WinJS.Promise.join([promise1, promise2, promise3]).then(function (data) {
            var promise1Return = data[0];    // promise1Return = 2
            var promise2Return = data[1];    // promise2Return = 4
            var promise3Return = data[2];    // promise3Return = 6
        });
    }
What happens if exception occurs inside the Promise? Let's change a little of the test code:
    function test() {
        var promise1 = addAsync(1, 1, 3);
        var promise2 = addAsync(2, 2, 2).then(function () { throw "promise2 error" });
        var promise3 = addAsync(3, 3, 1);

        WinJS.Promise.any([promise1, promise2, promise3]).done(function () {
            console.log("Promise.any completed");
        }, function (err) {
            console.log("Promise.any err: " + err);
        });

        WinJS.Promise.join([promise1, promise2, promise3]).done(function () {
            console.log("Promise.join completed");
        }, function (err) {
            console.log("Promise.join err: " + err);
        });
    }
The result is:
Promise.any completed
Promise.join err: ,promise2 error
We can see Promise.any function would complete successfully if any of the Promise object is completed without error. But Promise.join would fail and jump to error handler if any Promise object throws exception. There's a comma "," in error message because like the return data in okay case, the errors are wrapped to an array and mapped to the Promise index, in above case: err[0] is undefined and err[1] is "promise2 error" message.

Reference: the source of Promise.any and Promise.join is defined in base.js from WinJS library:
  any: function Promise_any(values) {
        /// <signature helpKeyword="WinJS.Promise.any">
        /// <summary locid="WinJS.Promise.any">
        /// Returns a promise that is fulfilled when one of the input promises
        /// has been fulfilled.
        /// </summary>
        /// <param name="values" type="Array" locid="WinJS.Promise.any_p:values">
        /// An array that contains promise objects or objects whose property
        /// values include promise objects.
        /// </param>
        /// <returns type="WinJS.Promise" locid="WinJS.Promise.any_returnValue">
        /// A promise that on fulfillment yields the value of the input (complete or error).
        /// </returns>
        /// </signature>
        return new Promise(
            function (complete, error, progress) {
                var keys = Object.keys(values);
                var errors = Array.isArray(values) ? [] : {};
                if (keys.length === 0) {
                    complete();
                }
                var canceled = 0;
                keys.forEach(function (key) {
                    Promise.as(values[key]).then(
                        function () { complete({ key: key, value: values[key] }); },
                        function (e) {
                            if (e instanceof Error && e.name === canceledName) {
                                if ((++canceled) === keys.length) {
                                    complete(WinJS.Promise.cancel);
                                }
                                return;
                            }
                            error({ key: key, value: values[key] });
                        }
                    );
                });
            },
            function () {
                var keys = Object.keys(values);
                keys.forEach(function (key) {
                    var promise = Promise.as(values[key]);
                    if (typeof promise.cancel === "function") {
                        promise.cancel();
                    }
                });
            }
        );
    },
  join: function Promise_join(values) {
        /// <signature helpKeyword="WinJS.Promise.join">
        /// <summary locid="WinJS.Promise.join">
        /// Creates a promise that is fulfilled when all the values are fulfilled.
        /// </summary>
        /// <param name="values" type="Object" locid="WinJS.Promise.join_p:values">
        /// An object whose fields contain values, some of which may be promises.
        /// </param>
        /// <returns type="WinJS.Promise" locid="WinJS.Promise.join_returnValue">
        /// A promise whose value is an object with the same field names as those of the object in the values parameter, where
        /// each field value is the fulfilled value of a promise.
        /// </returns>
        /// </signature>
        return new Promise(
            function (complete, error, progress) {
                var keys = Object.keys(values);
                var errors = Array.isArray(values) ? [] : {};
                var results = Array.isArray(values) ? [] : {};
                var undefineds = 0;
                var pending = keys.length;
                var argDone = function (key) {
                    if ((--pending) === 0) {
                        var errorCount = Object.keys(errors).length;
                        if (errorCount === 0) {
                            complete(results);
                        } else {
                            var canceledCount = 0;
                            keys.forEach(function (key) {
                                var e = errors[key];
                                if (e instanceof Error && e.name === canceledName) {
                                    canceledCount++;
                                }
                            });
                            if (canceledCount === errorCount) {
                                complete(WinJS.Promise.cancel);
                            } else {
                                error(errors);
                            }
                        }
                    } else {
                        progress({ Key: key, Done: true });
                    }
                };
                keys.forEach(function (key) {
                    var value = values[key];
                    if (value === undefined) {
                        undefineds++;
                    } else {
                        Promise.then(value,
                            function (value) { results[key] = value; argDone(key); },
                            function (value) { errors[key] = value; argDone(key); }
                        );
                    }
                });
                pending -= undefineds;
                if (pending === 0) {
                    complete(results);
                    return;
                }
            },
            function () {
                Object.keys(values).forEach(function (key) {
                    var promise = Promise.as(values[key]);
                    if (typeof promise.cancel === "function") {
                        promise.cancel();
                    }
                });
            }
        );
    }

Monday, September 24, 2012

Android Experience

Nowadays electronic gadgets are so pervasive. I have iPhone, iPad, multiple laptops and desktops at home. Recently I got a Nexus 7 tablet, my first Android device. What really impressed me is its easiness of development process.

Apple products are cool, no doubt about it. But you have to have a physical Mac machine to work on iOS/Mac apps. In addition you need to register the developer license which cost $99/year. Apple defines quite a bit of restriction and rules to mark sure they have full control of the Apple nation. Apple's main focus is user experience, not much for developers. Many people claim Object C and XCode the worst development environment comparing with other popular languages and IDE in the market. However, due to Apple's huge crowd of so called high-end and loyal customers, more and more developers are joining this not so friendly platform.

On the other hand, Windows phone as a late-catch-up follower, surprisingly, still requires the same $99/year fee too. With registration done, you need to install the commercial Visual Studio IDE, Windows phone SDK and that notorious Zune package to develop a simple app to run on a Windows phone handset. Should they have made all free on the first day when realizing they were way behind Apple and Google? I love C# and .NET, but I feel sad about those dump decisions made by Microsoft.

In the Android world things are very different. You only need to pay one-time $25 registration fee to publish your app to the public. It's totally free for you to do any coding experience on your own Android devices. Unlike proprietary object C and C#, you will use Java and eclipse to program the Android apps. Java is the most used object-oriented programming language (source from Tiobe) in the past few years, and open-source eclipse is a popular IDE for many programming languages for years. All Java developers would easily switch to Android development.

Yesterday I downloaded the free Android SDK, unzipped it (no need to install anything), and I was able to open eclipse IDE to create a new Android project. After installed the USB driver for Nexus 7 I could connect the tablet to my development machine using USB cable, within 30 minutes, my first "Hello World" Android app was running in the Nexus 7. The only problem I had was the Android emulator which is super slow and basically useless. But running and debugging the code via a physical device is easy and fast.

Android devices become so popular in just a few years. Will Android rules the mobile world as Microsoft dominates PC world in the past 30 years? No one knows, but people like Android for reasons. From a developer point of view, it's free, open and easy to work with. All that is a nice invitation to developers to join the Android world.

Monday, September 17, 2012

SharePoint 2010 Email Notification With Workflow

Here's a common scenario in SharePoint environment: you have a SharePoint custom list to store and trace some tasks or jobs, you want the system automatically send out email notification based on some conditions. Let's say there's a SharePoint List with following two columns:

1. Tickler Date: a DateTime field that triggers the workflow to send out the email notification
2. Tickler Contacts: a People field (multiple section) where the notification email will be sent to

The requirement is simple:

1. Email notification sent to "Tickler Contacts" whenever the "Tickler Date" is reached, if both fields are defined.
2. Notification should be aware of the change on those two fields, and updated "Tickler Date" and "Tickler Contacts" should be applied.

There are a few options to accomplish such task. We can set a Windows Scheduled Task to run a console app which scans the whole list daily and send the notification, or use SharePoint Timer Jobs to do similar things, or implement SharePoint Workflow. SharePoint Workflow is very powerful and capable of handling many SharePoint tasks easily. With SharePoint Designer, you can even visually work on workflow's conditions, steps and actions, and apply the Workflow directly to the server without any code deployment (arguably good and bad on this). Workflow approach will be discussed in this post.

SharePoint Designer Workflow has a built-in "Send an email" action. We use that as the only one action step in our first Workflow for notification. Because the recipients come from a multi-user People field, we need to set Person field returned as delimited email addresses:


By default the BCC is not available on the email form. However, when you can highlight the send email action and click the "Advanced Properties" button on the top menu you will see the BCC field. You could even fine tune the formatting of the mail content since the original body HTML can be seen and updated there:


The next step is to figure out how to start the Workflow. A list-associated Workflow can be set to start manually, or automatically start when a list item is created or updated. For the simple Workflow we defined above is not going to start automatically because we haven't defined the condition yet.

If the trigger condition is based on the date time field, then we can also utilize retention policy to start a Workflow. Go to List's settings page and click "Information management policy settings" you will see following settings (the top "List Based Retention Schedule" won't be available if "Library and Folder Based Retention" Site Collection Feature is not activated):


We are interested in the item properties, so click the Item and go to Item policy editing page, check "Enable Retention", and then add a retention stage:


The condition here is that when "Tickler Date" is passed by 0 day, or "Tickler Date" is today, then workflow will start and send the email notification. All look good except that requirement #2 can not be satisfied. The "Tickler Date" is fixed, and Retention policy won't recalculate the condition when the "Tickler Date" is changed. There's no workaround on that as far as I know. So Retention policy is not an option in our case.

The solution is let Workflow deals with the conditions by itself, and make it start automatically when a List item is created or updated:


The Workflow quite explains itself. First it checks if "Tickler Contacts" and "Tickler Date" are both valid. It would simply "quit" (WF completes) if any of those two empty or "Tickler Date" has passed. Otherwise it continues to further logic. A Workflow variable is set to the "Tickler Date" so later it has a reference to compare if that has been changed. Then two parallel branches are split to run simultaneously. The first branch is waiting until the "Tickler Date" reaches so email notification will be sent out, and the workflow completes its work; the second branch is pending and looking for any change on the "Tickler Date" field, when that occurs the workflow stops and a new workflow instance will be created by SharePoint using the updated "Tickler Date", because the workflow had set to auto-start when list item is updated.

You may ponder what happens if the fields other than "Tickler Date" have been changed, would a new Workflow instance will be created? The answer is no. SharePoint only allows one instance of Workflow for each version to run. That's why we cancel the old Workflow instance when the "Tickler Date" is changed, so a new Workflow instance can be created and start.

You may also wonder why not just simply using the general "Modified" field instead of "Tickler Date": cancel the old Workflow instance whenever the List item is changed, then there’s room for a new WF instance with updated data. You will see that won't work if you try it out. The reason is that SharePoint does modify the List item silently for some background process such as updating List item's Workflow status. That causes time change on List item's "Modified" field which stops old Workflow, but SharePoint won't start a Workflow for such internal processing.

A final note: don't use system account to test your Workflow. It won't work! Creating or updating a list item using system account won't trigger associated Workflow even although the Workflow is configured to auto-start. Under the hood List Workflow was implemented using List Item Event Receivers, and system account is just ignored there.
Reference: Create a Detailed Custom Task Notification with a SharePoint Designer Workflow: http://sharepoint-videos.com/sp10creating-a-workflow-on-a-list-using-sharepoint-designer-2010

Tuesday, September 04, 2012

An "Unsocial" Bug in Socail NewsGator

NewsGator is a Social Networking Solution for SharePoint environment. Recently our company utilizes NewsGator to make our SharePoint 2010 portal more social and Facebookish. I found an issue related to NewsGator Community API (Social Sites 2010 Suite V2.6.615) today, and missing communication between different dev teams inside NewsGator may be the culprit.

Newsgator heavily uses Feeds and Communities concepts to connect people together and encourages employees participation. Overall those NewsGator features are great. The problem is that, as most third-party components, it's very hard to do some customization on top of NewsGator Components. The NewsGator API and its documentation are so limited that I have to use reflector to figure out some issues to do my work.

Here is the secnario: for security and a few other considerations we don't allow end users to create NewsGator communities directly. Instead, a custom WebPart is built for creating a new Community programmatically so you have full control of that. One of the business requirement is that you can set the NewsGator Community as Public or Private in the custom WebPart.

It sounds straightforward, just setting Community's properties right? But I just couldn't find out the proper way to do that (the formal NewsGator API documentation has less than 20-page with just a few simple code snippets showing how to create Feeds). Thanks to reflector I found there's a SocialContextClient class often used to get/set SocialGroup (Community) inside backend web service calls. So I tried the following code:
    SocialSites.ApplicationServices.SocialContextClient contexClient = 
      new SocialSites.ApplicationServices.SocialContextClient();
    NewsGator.Social.Data.SocialGroup community = contexClient.GetCommunityByName("CommunityName");
    community.PrivacyLevel = NewsGator.Social.Data.SocialGroupPrivacyLevel.Private;
    community.Discoverable = false;
    contexClient.UpdateSocialGroup(community);
The PrivacyLevel and Discoverable properties are responsible for handling the public-visibility, and their values are stored in a SocialGroup table in backend database (NewsGator doesn't use SharePoint lists and has its own database). I could see that the code has successfully updated their values in SocialGroup table, however the created Community is still public. It's very confusing. I checked all possibilities including permission, caching, synchronization, etc. but no luck.

It turns out there’s another method to get the SocialGroup (Community) using SharePointConextManagerBase:
    using (SPSite site = new SPSite(SiteUrl))
    {
        using (SPWeb web = site.OpenWeb())
        {
            SocialGroup community = 
          new NewsGator.Social.Library.Client.SharePointContextManagerBase().GetCommunity(web);
            SocialGroupPrivacyLevel privacyLevel = community.PrivacyLevel;
            bool discoverable = community.Discoverable;
        }
    }
I found that the community’s settings in above method are not the same as what we get from the first method using SocialContextClient. By reflector I traced out that SharePointContextManagerBase actually checks web.AllProperties[NewsGator.Social.Library.SocialGroups.WebKeys.PrivacyLevel] and web.AllProperties[NewsGator.Social.Library.SocialGroups.WebKeys.Discoverable] values to populate the privacy and discoverable values, and it’s not related to any NewsGator backend database.

Apparently different teams from NewsGator had built these two methods with their own purpose. Method SharePointContextManagerBase is used by UI WebParts so updating the backend database by SocialContextClient class won’t have any effect. To workaround this problem simply assign the web’s properties which is checked by SharePointContextManagerBase mthod, then everything works as expected:
    private void UpdateNewsGatorCommunity(SPWeb web, bool isPrivateCommunity)
    {
        if (isPrivateCommunity) // Set NewsGator Community as private
        {
            try
            {
                // Direct value assignment to avoid referecing NewsGator dlls
                web.AllProperties["ng-community-privacy"] = "2";
                web.AllProperties["ng-community-discoverable"] = "False";
                web.Update();
            }
            catch (Exception ex)
            {
                LoggingService.LogError("UpdateNewsGatorCommunity error: " + ex.Message, ex.StackTrace);
            }
        }
    }
It's hacking not an elegant solution and we hope NewsGator could fix this issue in the future.

This is a perfect example of a problem causing by one piece of information defined/stored in multiple places, and reason for that is most likely missing communication between people and teams in a company.

Wednesday, August 29, 2012

ASP.NET 4.5 Web Optimization Feature

Just read Rick's Bundling and Minificaiton new features in .NET 4.5. Waiting for this for so long, finally!

Sunday, July 22, 2012

Including Product Tags in nopCommerce Import/Export

Product tags are not included in Excel import/export by default in nopCommerce solution. Product tags are many-to-many relation to products. Following code illustrates how to import/export product tags in nopCommerce (added to Nop.Services.ExportImport in service layer):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Nop.Core.Domain.Catalog;
using Nop.Services.Catalog;
using OfficeOpenXml;

namespace Nop.Services.ExportImport
{
    /// <summary>
    /// Import/Export helper class
    /// </summary>
    public class ImportExportHelper
    {
        public static string[] AddCustomColumns(string[] properties)
        {
            string[] newProperties = new string[] 
            {       
                "ProductTags",      
            };
            string[] returnProperties = properties.Union(newProperties).ToArray();
            return returnProperties;
        }

        public static string GetProductTagsString(Product product)
        {
            var result = new StringBuilder();
            for (int i = 0; i < product.ProductTags.Count; i++)
            {
                var pt = product.ProductTags.ToList()[i];
                result.Append(pt.Name);
                if (i != product.ProductTags.Count - 1)
                    result.Append(", ");
            }
            return result.ToString();
        }

        public static string[] ParseProductTags(string productTags)
        {
            var result = new List<string>();
            if (!String.IsNullOrWhiteSpace(productTags))
            {
                string[] values = productTags.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
                foreach (string val1 in values)
                    if (!String.IsNullOrEmpty(val1.Trim()))
                        result.Add(val1.Trim());
            }
            return result.ToArray();
        }

        public static void SaveProductTags(IProductService _productService, 
            IProductTagService _productTagService, Product product, string[] productTags)
        {
            if (product == null)
                throw new ArgumentNullException("product");

            //product tags
            var existingProductTags = product.ProductTags.OrderByDescending(pt => pt.ProductCount).ToList();
            var productTagsToDelete = new List<ProductTag>();
            foreach (var existingProductTag in existingProductTags)
            {
                bool found = false;
                foreach (string newProductTag in productTags)
                {
                    if (existingProductTag.Name.Equals(newProductTag, StringComparison.InvariantCultureIgnoreCase))
                    {
                        found = true;
                        break;
                    }
                }
                if (!found)
                {
                    productTagsToDelete.Add(existingProductTag);
                }
            }
            foreach (var productTag in productTagsToDelete)
            {
                product.ProductTags.Remove(productTag);
                //ensure product is saved before updating totals
                _productService.UpdateProduct(product);
                _productTagService.UpdateProductTagTotals(productTag);
            }
            foreach (string productTagName in productTags)
            {
                ProductTag productTag = null;
                var productTag2 = _productTagService.GetProductTagByName(productTagName);
                if (productTag2 == null)
                {
                    //add new product tag
                    productTag = new ProductTag()
                    {
                        Name = productTagName,
                        ProductCount = 0
                    };
                    _productTagService.InsertProductTag(productTag);
                }
                else
                {
                    productTag = productTag2;
                }
                if (!product.ProductTagExists(productTag.Id))
                {
                    product.ProductTags.Add(productTag);
                    //ensure product is saved before updating totals
                    _productService.UpdateProduct(product);
                }
                //update product tag totals 
                _productTagService.UpdateProductTagTotals(productTag);
            }
        }

    }

    /// <summary>
    /// Partial class of ExportManager
    /// </summary>
    public partial class ExportManager : IExportManager
    {
        public void UpdateCustomProductProperties(ExcelWorksheet worksheet, int row, ref int column, Product product)
        {
            string productTags = ImportExportHelper.GetProductTagsString(product);
            worksheet.Cells[row, column].Value = productTags;
            column++;
        }

    }

    /// <summary>
    /// Partial class of ImportManager
    /// </summary>
    public partial class ImportManager : IImportManager
    {
        public void UpdateCustomProductProperties(ExcelWorksheet worksheet, string[] properties, int row, Product product)
        {
            string productTags = Convert.ToString(worksheet.Cells[row, GetColumnIndex(properties, "ProductTags")].Value);
            ImportExportHelper.SaveProductTags(_productService, _productTagService, product, ImportExportHelper.ParseProductTags(productTags));
        }
    }
}
In order to use above code, the original ExportManager and ImportManager need to be modified a bit. Go to Nop.Services.ExportImport/ExportManger.cs, inside the ExportProductsToXlsx method:

1. Update property definition and add following code after line 532:
properties = ImportExportHelper.AddCustomColumns(properties);
2. Export product tags using custom function and add following code after line 797:
UpdateCustomProductProperties(worksheet, row, ref col, p);

Update Nop.Services.ExportImport/ExportManger.cs constructor and inject IProductTagService:
 public partial class ImportManager : IImportManager
    {
        private readonly IProductService _productService;
        private readonly ICategoryService _categoryService;
        private readonly IManufacturerService _manufacturerService;
        private readonly IPictureService _pictureService;
        private readonly IProductTagService _productTagService;

        public ImportManager(IProductService productService, ICategoryService categoryService,
            IManufacturerService manufacturerService, IPictureService pictureService, 
            IProductTagService productTagService)
        {
            this._productService = productService;
            this._categoryService = categoryService;
            this._manufacturerService = manufacturerService;
            this._pictureService = pictureService;
            this._productTagService = productTagService;
        }
Then update ImportProductsFromXlsx method inside ExportManger.cs:

1. Update property definition; add following code after line 142:
properties = ImportExportHelper.AddCustomColumns(properties);
2. Import product tags using custom function add following code after line 318 AND line 402:
UpdateCustomProductProperties(worksheet, properties, iRow, product);

Rebuild the solution and test out.

Create Custom Scheduled Task in nopCommerce

nopCommerce is a popular open source eCommerce solution built by ASP.NET MVC framework. We can create a custom scheduled task to scan the products periodically and do some business logic there, e.g. sending out email notification if some condition reached for certain products. In order to do that we need to define a record in the ScheduleTask table. Following script inserts a scheduled task record into database that would make nopCommerce invoke 'Nop.Services.Messages.ProductScanService' every 10 minutes:
INSERT INTO ScheduleTask(Name, Seconds, [Type], [Enabled], StopOnError)
 VALUES('ProductScanService schedule', 600, 'Nop.Services.Messages.ProductScanService', 1, 0) 
The ProductScanService class implements ITask interface, and is added to nop.Services.Messages in the service layer (tested in the latest version of 2.60):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Nop.Services.Tasks;
using Nop.Services.Catalog;
using Nop.Services.Logging;

namespace Nop.Services.Messages
{
    class ProductScanService : ITask
    {
        private readonly IProductService _productService;
        private readonly ILogger _logger;

        /// <summary>
        /// Dependency injection here
        /// </summary>
        public ProductScanService(IProductService productService, ILogger logger)
        {
            this._productService = productService;
            this._logger = logger;
        }

        /// <summary>
        /// Executes the task
        /// </summary>
        public void Execute()
        {
            _logger.Information(string.Format("ProductScanService starts at {0} (UTC {1})", 
                DateTime.Now, DateTime.UtcNow));
            ScanAllProducts();
            _logger.Information(string.Format("ProductScanService ends at {0} (UTC {1})",
                DateTime.Now, DateTime.UtcNow));
        }

        /// <summary>
        /// Do business logic here
        /// </summary>
        private void ScanAllProducts()
        {
            // Do stuff with _productService
        }
    }
}