Thursday, January 12, 2012

Writing Liferay Apps with Web Content Templates

One of the often overlooked features of Liferay's WCM system is the ability to write non-trivial apps using it.  There have been a few blog posts about this, notably Ray Augé's Advanced Web Content Example With AJAX .  In the community, it's great for me because I can quickly create interesting visualizations of community data and share it with you immediately.  There are some pros and cons to this approach:

Benefits:

  • No compilation needed - WCM relies on the use of Templates , written in interpreted (i.e. scripted) languages such as Velocity Templates .  This means you can quickly make a change and see your results quickly.
  • No deployment needed - since Web Content isn't a java portlet, you don't need to re-deploy.   More importantly it means you don't have to wait for a website administrator to deploy it if you cannot deploy yourself!
  • You can combine presentation (e.g. HTML/JS/CSS) and logic (e.g. Velocity) into the same template, keeping related code together.

Drawbacks:

  • Velocity is first and foremost a templating/presentation language.  It is not a general purpose computing language, so die hard MVC types will probably dismiss the use of Velocity in this way and call me a heretic/lunatic.  It's great for prototyping though!  
  • Currently, Structures and Templates aren't versioned, and they do not participate in Liferay's Workflow system.  So you can't revert to older (working) versions of templates if you make a mistake.
  • No compilation needed - so it's not as fast as the native bytecode that would result from the equivalent java source code.  But it's still quite fast.
  • Velocity and other scripting languages have weird quirks that often cannot be caught except through trial and error, and limitations (e.g. no use of generics) that compiled/strongly typed languages have.
  • You can combine presentation (e.g. HTML) and logic (e.g. Velocity) into the same template :)

In my opinion, Liferay WCM is a very good solution for app prototyping or for non-trivial apps that don't have tons of logic or page flows in them.  You have already seen an example of this in the Community Activity Map , and the example I use below forms the basis for the Hot Topics app that you can now see on liferay.org .

Basic Template Template

To get started creating an app of this nature, you need to start with simple Web Content Template that is itself a template:

#if ($request.lifecycle == "RENDER_PHASE")
 ## This phase will handle the presentation code (i.e. HTML/CSS/JS). Any calls ## to the ${request.resource-url} will retrieve the result of evaluating the below ## RESOURCE_PHASE below. 
#elseif ($request.lifecycle == "RESOURCE_PHASE")
 ## This phase will handle the AJAX request like a portlet's serveResource() method 
#end

So decide what needs to be executed on the server side, and put it in the RESOURCE_PHASE .  This is typically where most if not all of the business (i.e. non-presentation) logic goes.  Put the presentation logic in the RENDER_PHASE .

Hot Topics Example

For this app, I want to show which threads have the most posts in the last week.  So, I needed to query Liferay's Message Boards.  Since there is no getMostActiveThreadsInTheLastWeek() method (I know.. what's up with that??), I needed a custom query.  This means using Liferay's DynamicQuery feature.  But from Velocity?  Turns out it's not that bad.  Here's the full RESOURCE_PHASE code to create and execute a Dynamic Query, and generate a JSON object as a result which contains the most active threads in the last week:

#set ($portletNamespace = $request.portlet-namespace) #set ($scopeGroupId = $getterUtil.getLong($request.theme-display.scope-group-id)) #if ($request.lifecycle == "RENDER_PHASE") ## bunch of display logic to show the JSON result nicely #elseif ($request.lifecycle == "RESOURCE_PHASE") #set ($logFactory = $portal.getClass().forName('com.liferay.portal.kernel.log.LogFactoryUtil')) #set ($log = $logFactory.getLog('mylog')) #set ($portalBeanLocator = $portal.getClass().forName("com.liferay.portal.kernel.bean.PortalBeanLocatorUtil")) #set ($jsonFactory = $portalBeanLocator.locate("com.liferay.portal.kernel.json.JSONFactoryUtil")) #set ($mbMessageLocalService = $portalBeanLocator.locate("com.liferay.portlet.messageboards.service.MBMessageLocalService.velocity")) #set ($mbThreadLocalService = $portalBeanLocator.locate("com.liferay.portlet.messageboards.service.MBThreadLocalService.velocity")) #set ($calClass = $portal.getClass().forName("java.util.GregorianCalendar")) #set ($mbMessageClass = $portal.getClass().forName("com.liferay.portlet.messageboards.model.MBMessage")) #set ($mbThreadClass = $portal.getClass().forName("com.liferay.portlet.messageboards.model.MBThread")) #set ($dqfu = $portal.getClass().forName("com.liferay.portal.kernel.dao.orm.DynamicQueryFactoryUtil")) #set ($pfu = $portal.getClass().forName("com.liferay.portal.kernel.dao.orm.ProjectionFactoryUtil")) #set ($ofu = $portal.getClass().forName("com.liferay.portal.kernel.dao.orm.OrderFactoryUtil")) #set ($now = $calClass.getInstance()) #set ($weeksago = $calClass.getInstance()) #set ($prevweeks = 0 - $getterUtil.getInteger($period.data)) #set ($V = $weeksago.add(3, $prevweeks)) #set ($q = $dqfu.forClass($mbThreadClass)) #set ($rfu = $portal.getClass().forName("com.liferay.portal.kernel.dao.orm.RestrictionsFactoryUtil")) #set ($groupIdCriteria = $rfu.ne("categoryId", $getterUtil.getLong("-1"))) #set ($V = $q.add($groupIdCriteria)) #set ($groupIdCriteria = $rfu.eq("groupId", $getterUtil.getLong($scopeGroupId))) #set ($V = $q.add($groupIdCriteria)) #set ($companyIdCriteria = $rfu.eq("companyId", $getterUtil.getLong($companyId))) #set ($V = $q.add($companyIdCriteria)) #set ($statusCriteria = $rfu.eq("status", 0)) #set ($V = $q.add($statusCriteria)) #set ($lastPostDateCriteria = $rfu.between("lastPostDate", $weeksago.getTime(), $now.getTime())) #set ($V = $q.add($lastPostDateCriteria)) #set ($V = $q.setProjection($pfu.property("threadId"))) #set ($res1 = $mbMessageLocalService.dynamicQuery($q)) #set ($q2 = $dqfu.forClass($mbMessageClass)) #set ($inCriteria = $rfu.in("threadId", $res1)) #set ($V = $q2.add($inCriteria)) #set ($createDateCriteria = $rfu.between("createDate", $weeksago.getTime(), $now.getTime())) #set ($V = $q2.add($createDateCriteria)) #set ($V = $q2.setProjection($pfu.projectionList().add($pfu.groupProperty("rootMessageId")).add($pfu.alias($pfu.rowCount(), "msgCount")))) #set ($V = $q2.addOrder($ofu.desc("msgCount"))) #set ($V = $q2.setLimit(0, 7)) #set ($res2 = $mbMessageLocalService.dynamicQuery($q2)) #set ($jsonArray = $jsonFactory.createJSONArray()) #foreach ($msgSum in $res2) #set ($rootMsgId = $msgSum.get(0)) #set ($msgCount = $msgSum.get(1)) #set ($subject = $mbMessageLocalService.getMessage($rootMsgId).getSubject()) #set ($jsonObject = $jsonFactory.createJSONObject()) #set ($V = $jsonObject.put("subject", $stringUtil.shorten($htmlUtil.escape($subject), 55))) #set ($V = $jsonObject.put("msgid", $rootMsgId)) #set ($V = $jsonObject.put("msgCount", $msgCount)) #set ($V = $jsonArray.put($jsonObject)) #end { "jsonArray": $jsonArray } #end 

Details

There are many things going on here:

Velocity Debugging/Logging

 #set ($logFactory = $portal.getClass().forName('com.liferay.portal.kernel.log.LogFactoryUtil')) #set ($log = $logFactory.getLog('mylog')) 
This gives me a way to debug the code by looking at the server log (if you are using this kind of app so that you can bypass your website admin, chances are you won't have access to the server logs, so this won't help you).  To emit debug info, I can do things like $log.error($msgCount) or $log.error("Hi There").
 

Creating references for arbitrary JVM classes

 #set ($calClass = $portal.getClass().forName("java.util.GregorianCalendar")) 
This allows me to create references to any class known in the JVM for doing things like calling static methods, etc.  Many of these are needed for constructing Dynamic Queries.
 

Calculating Now and a Week Ago

 #set ($now = $calClass.getInstance()) #set ($weeksago = $calClass.getInstance()) #set ($prevweeks = 0 - $getterUtil.getInteger($period.data)) #set ($V = $weeksago.add(3, $prevweeks)) 
This creates Calendar objects representing the current time, and a week ago.  Note that the number of weeks is specified in a web content structure using the period structure element.
 
The rest of the code constructs two dynamic queries:
  • The first one ( $q ) queries for MBThread entities that have a categoryId of -1 (MBThreads that do not have a categoryId of -1 are not threads from the message boards portlet, instead they are threads for things like comments on document library entries, etc).  The query also includes other criteria, like groupId/companyId must match the "current" groupId/companyId of the site in which the web content is placed, the status must be 0 (indicating it is an approved (i.e. not draft or deleted) entry), and most importantly the lastPostDate must be between my desired time period start and end.  Finally, I am not interested in all of the MBThread entity - I just need the threadId .  So my query includes a Projection that only returns the threadId .
  • The second query ( $q2 ) queries for all MBMessage entities that match my new critieria: they must have a threadId of one of the threads identified in the first query (hence the in criteria), and the message's createDate must also be between my start/end dates.  This is to avoid counting messages in the thread that occured before the cutoff dates.  Finally, this gem:
 #set ($V = $q2.setProjection($pfu.projectionList().add($pfu.groupProperty("rootMessageId")).add($pfu.alias($pfu.rowCount(), "msgCount")))) #set ($V = $q2.addOrder($ofu.desc("msgCount"))) #set ($V = $q2.setLimit(0, 7)) 
This creates a projection that is grouped by the rootMessageId , since each message in the same thread will have the same rootMessageId (which I eventually use to construct the URL to the message), and includes a count of the messages that match (with an alias defined so I can refer to the row count when specifying the order of the results via addOrder() ).  I also limit the results to 7 because I don't want to show any more than that (this is a simple app).  This second query returns a result table that looks like:
 

So after the Dynamic Queries executes, it's just a matter of constructing a JSONObject (and sanitizing/sizing the actual text of the subject of the thread) and returning it.

Liferay WCM and Velocity Gotchas

Velocity is first and foremost a templating/presentation language.  It is not a general purpose computing language, so die hard MVC types will probably dismiss the use of Velocity in this way and call me a heretic/lunatic.  But it also means that some things are hard (or impossible, for example did anyone catch that I hard-coded Calendar.MONTH to be 3 ?  You can't reference static member variables of a class unless it is already part of the Velocity context in which a template is evaluated).  There are many other perils awaiting the adventurous Velocity coder.  I learned many things through trial and error (and the help of my IRC friends on the #liferay channel!).  Here are some more:
 
  • Don't forget to use $var instead of var .  If you forget the $, you won't get syntax errors, just silent errors and half of your code will next execute.
  • If you can use an intelligent IDE (like IntelliJ IDEA or Eclipse ) and its Velocity syntax checking, do it!  I saved tons of time by using IntelliJ and declaring variable types, which allowed for autocompletion and type checking.  For example, I had tons of these:
#* @vtlvariable name="request" type="java.util.Map" *# #* @vtlvariable name="httpUtil" type="com.liferay.portal.kernel.util.HttpUtil" *# #* @vtlvariable name="htmlUtil" type="com.liferay.portal.kernel.util.HtmlUtil" *# #* @vtlvariable name="obc" type="com.liferay.portal.util.comparator.UserLastNameComparator" *# #* @vtlvariable name="serviceLocator" type="com.liferay.portal.velocity.ServiceLocator" *# #* @vtlvariable name="teamLocalService" type="com.liferay.portal.service.TeamLocalServiceUtil" *# #* @vtlvariable name="mbMessageLocalService" type="com.liferay.portlet.messageboards.service.MBMessageLocalServiceUtil" *# #* @vtlvariable name="mbThreadLocalService" type="com.liferay.portlet.messageboards.service.MBThreadLocalServiceUtil" *#
  • Any time you access things from the ${request.theme-display} , or access one of your WCM structure fields that represent a number (but are of type "Text" in the template), they are probably not of the the type that you want.  You need to use generous amounts of $getterUtil.getXXX calls to make sure.  For example, 
#set ($scopeGroupId = $getterUtil.getLong($request.theme-display.scope-group-id)) 
will work (and makes  $scopeGroupId a Long), whereas
#set ($scopeGroupId = $request.theme-display.scope-group-id) 
Will results in a $scopeGroupId that is not a Long, and so if you pass it in to a method that is expecting a Long, it won't work, and will probably silently fail and you'll be befuddled.
  • If you want to create a new instance of a class (e.g. a HashMap) you can'y say new HashMap() .  Velocity does not know what " new " is - after all, you're using a presentation/templating language, not Java!   But we're using it for more than display.  So as a workaround you can do things like $portal.getClass().forName("java.util.HashMap").newInstance() .
  • Accessing elements of an array cannot be done using $array[0] .  You have to use  $array.get(0) .
 

The Full Source to Hot Topics

Here's the full source, including my display code, and my IntelliJ variable declarations (which may have some unnecessary declarations, but I use this block for other stuff too).  If you spot errors or poor coding technique or whatever else, please let me know so I can learn!
#* @vtlvariable name="portletNamespace" type="java.lang.String" *# #* @vtlvariable name="portal" type="com.liferay.portal.util.Portal" *# #* @vtlvariable name="getterUtil" type="com.liferay.portal.kernel.util.GetterUtil" *# #* @vtlvariable name="stringUtil" type="com.liferay.portal.kernel.util.StringUtil" *# #* @vtlvariable name="max-members" type="com.liferay.portlet.journal.util.TemplateNode" *# #* @vtlvariable name="team-name" type="com.liferay.portlet.journal.util.TemplateNode" *# #* @vtlvariable name="section-members" type="com.liferay.portlet.journal.util.TemplateNode" *# #* @vtlvariable name="groupId" type="java.lang.String" *# #* @vtlvariable name="sectionMembers" type="java.lang.String" *# #* @vtlvariable name="locale" type="java.util.Locale" *# #* @vtlvariable name="companyId" type="java.lang.String" *# #* @vtlvariable name="scopeGroupId" type="java.lang.String" *# #* @vtlvariable name="sectionName" type="java.lang.String" *# #* @vtlvariable name="section-name" type="com.liferay.portlet.journal.util.TemplateNode" *# #* @vtlvariable name="params" type="java.util.LinkedHashMap" *# #* @vtlvariable name="users" type="java.util.List" *# #* @vtlvariable name="user" type="com.liferay.portal.model.User" *# #* @vtlvariable name="themeDisplay" type="com.liferay.portal.theme.ThemeDisplay" *# #* @vtlvariable name="languageUtil" type="com.liferay.portal.kernel.language.LanguageUtil" *# #* @vtlvariable name="request" type="java.util.Map" *# #* @vtlvariable name="httpUtil" type="com.liferay.portal.kernel.util.HttpUtil" *# #* @vtlvariable name="htmlUtil" type="com.liferay.portal.kernel.util.HtmlUtil" *# #* @vtlvariable name="obc" type="com.liferay.portal.util.comparator.UserLastNameComparator" *# #* @vtlvariable name="serviceLocator" type="com.liferay.portal.velocity.ServiceLocator" *# #* @vtlvariable name="teamLocalService" type="com.liferay.portal.service.TeamLocalServiceUtil" *# #* @vtlvariable name="mbMessageLocalService" type="com.liferay.portlet.messageboards.service.MBMessageLocalServiceUtil" *# #* @vtlvariable name="mbThreadLocalService" type="com.liferay.portlet.messageboards.service.MBThreadLocalServiceUtil" *# #* @vtlvariable name="imageToken" type="com.liferay.portal.kernel.servlet.ImageServletToken" *# #* @vtlvariable name="userLocalService" type="com.liferay.portal.service.UserLocalServiceUtil" *# #* @vtlvariable name="groupIdCriteria" type="com.liferay.portal.kernel.dao.orm.Criterion" *# #* @vtlvariable name="groupIdProp" type="com.liferay.portal.kernel.dao.orm.Property" *# #* @vtlvariable name="threadMap" type="java.util.Map<java.lang.Long, java.lang.Integer>" *# #* @vtlvariable name="q" type="com.liferay.portal.kernel.dao.orm.DynamicQuery" *# #* @vtlvariable name="q2" type="com.liferay.portal.kernel.dao.orm.DynamicQuery" *# #* @vtlvariable name="rfu" type="com.liferay.portal.kernel.dao.orm.RestrictionsFactoryUtil" *# #* @vtlvariable name="pfu" type="com.liferay.portal.kernel.dao.orm.ProjectionFactoryUtil" *# #* @vtlvariable name="ofu" type="com.liferay.portal.kernel.dao.orm.OrderFactoryUtil" *# #* @vtlvariable name="msgs" type="java.util.List<com.liferay.portlet.messageboards.model.MBMessage>" *# #set ($portletNamespace = $request.portlet-namespace) #set ($scopeGroupId = $getterUtil.getLong($request.theme-display.scope-group-id)) #if ($request.lifecycle == "RENDER_PHASE") <body onload="${portletNamespace}getTable();"> <article> <h1 class="section-heading section-heading-b"> <div>$title.data</div> <div class="section-heading-hr"></div> </h1> <div id='${portletNamespace}tablediv' style='width: 85%;'><!-- --></div> </article> </body> <script type="text/javascript"> var ${portletNamespace}table = new Object(); var ${portletNamespace}ICON = '<img class="icon" \ src="http://my-liferay-site-cdn.com/osb-theme/images/spacer.png" \ alt="Message Boards" title="Message Boards" \ style=" background-image: url(\'/html/icons/_sprite.png\');\ background-position: 50% -736px; \ background-repeat: no-repeat; height: 16px; width: 16px;">'; function ${portletNamespace}drawChart() { var html = '<div> \ <table style="margin-bottom:0em;"> \ <tbody>'; for (i = 0; i < ${portletNamespace}table.length; i++) { html += '<tr> \ <td class="portlet-icon" style="padding-right:6px;"> \ <table style="margin-top:-4px; margin-bottom:0px;">\ <tr>\ <td>\ <span>' + ${portletNamespace}ICON + '</span> \ </td>\ </tr>\ <tr>\ <td>\ <span style="color:#908E91; font-size:9px;">'+ ${portletNamespace}table[i].msgCount + '</span>\ </td>\ </tr>\ </table>\ </td> \ <td>\ <div>\ <h3 class="txt-n fs-11 m-0 o-h">\ <span class="display-b m-tn3 m-b6">\ <a href="/community/forums/-/message_boards/message/' + ${portletNamespace}table[i].msgid +'">'+ ${portletNamespace}table[i].subject + '</a>\ </span>\ </h3>\ </div>\ </td>\ </tr>'; } html += '</tbody>\ </table>\ </div>'; document.getElementById('${portletNamespace}tablediv').innerHTML = html; } function ${portletNamespace}getTable() { AUI().use( "aui-base", "aui-io-plugin", "aui-io-request", function(A) { A.io.request( '${request.resource-url}', { data: { }, dataType: "json", on: { success: function(event, id, obj) { var responseData = this.get("responseData"); ${portletNamespace}table = responseData.jsonArray || []; ${portletNamespace}drawChart(); }, failure: function(event, id, obj) { } } } ); } ); } </script> #elseif ($request.lifecycle == "RESOURCE_PHASE") #set ($logFactory = $portal.getClass().forName('com.liferay.portal.kernel.log.LogFactoryUtil')) #set ($log = $logFactory.getLog('mylog')) #set ($portalBeanLocator = $portal.getClass().forName("com.liferay.portal.kernel.bean.PortalBeanLocatorUtil")) #set ($jsonFactory = $portalBeanLocator.locate("com.liferay.portal.kernel.json.JSONFactoryUtil")) #set ($mbMessageLocalService = $portalBeanLocator.locate("com.liferay.portlet.messageboards.service.MBMessageLocalService.velocity")) #set ($mbThreadLocalService = $portalBeanLocator.locate("com.liferay.portlet.messageboards.service.MBThreadLocalService.velocity")) #set ($calClass = $portal.getClass().forName("java.util.GregorianCalendar")) #set ($mbMessageClass = $portal.getClass().forName("com.liferay.portlet.messageboards.model.MBMessage")) #set ($mbThreadClass = $portal.getClass().forName("com.liferay.portlet.messageboards.model.MBThread")) #set ($dqfu = $portal.getClass().forName("com.liferay.portal.kernel.dao.orm.DynamicQueryFactoryUtil")) #set ($pfu = $portal.getClass().forName("com.liferay.portal.kernel.dao.orm.ProjectionFactoryUtil")) #set ($ofu = $portal.getClass().forName("com.liferay.portal.kernel.dao.orm.OrderFactoryUtil")) #set ($now = $calClass.getInstance()) #set ($weeksago = $calClass.getInstance()) #set ($prevweeks = 0 - $getterUtil.getInteger($period.data)) #set ($V = $weeksago.add(3, $prevweeks)) #set ($q = $dqfu.forClass($mbThreadClass)) #set ($rfu = $portal.getClass().forName("com.liferay.portal.kernel.dao.orm.RestrictionsFactoryUtil")) #set ($groupIdCriteria = $rfu.ne("categoryId", $getterUtil.getLong("-1"))) #set ($V = $q.add($groupIdCriteria)) #set ($groupIdCriteria = $rfu.eq("groupId", $getterUtil.getLong($scopeGroupId))) #set ($V = $q.add($groupIdCriteria)) #set ($companyIdCriteria = $rfu.eq("companyId", $getterUtil.getLong($companyId))) #set ($V = $q.add($companyIdCriteria)) #set ($statusCriteria = $rfu.eq("status", 0)) #set ($V = $q.add($statusCriteria)) #set ($lastPostDateCriteria = $rfu.between("lastPostDate", $weeksago.getTime(), $now.getTime())) #set ($V = $q.add($lastPostDateCriteria)) #set ($V = $q.setProjection($pfu.property("threadId"))) #set ($res1 = $mbMessageLocalService.dynamicQuery($q)) #set ($q2 = $dqfu.forClass($mbMessageClass)) #set ($inCriteria = $rfu.in("threadId", $res1)) #set ($V = $q2.add($inCriteria)) #set ($createDateCriteria = $rfu.between("createDate", $weeksago.getTime(), $now.getTime())) #set ($V = $q2.add($createDateCriteria)) #set ($V = $q2.setProjection($pfu.projectionList().add($pfu.groupProperty("rootMessageId")).add($pfu.alias($pfu.rowCount(), "msgCount")))) #set ($V = $q2.addOrder($ofu.desc("msgCount"))) #set ($V = $q2.setLimit(0, 7)) #set ($res2 = $mbMessageLocalService.dynamicQuery($q2)) #set ($jsonArray = $jsonFactory.createJSONArray()) #foreach ($msgSum in $res2) #set ($rootMsgId = $msgSum.get(0)) #set ($msgCount = $msgSum.get(1)) #set ($subject = $mbMessageLocalService.getMessage($rootMsgId).getSubject()) #set ($jsonObject = $jsonFactory.createJSONObject()) #set ($V = $jsonObject.put("subject", $stringUtil.shorten($htmlUtil.escape($subject), 55))) #set ($V = $jsonObject.put("msgid", $rootMsgId)) #set ($V = $jsonObject.put("msgCount", $msgCount)) #set ($V = $jsonArray.put($jsonObject)) #end { "jsonArray": $jsonArray } #end 

 

source

7 comments:


  1. Great Article I love to read your articles because your writing style is too good, its is very very helpful for all of us and I never get bored while reading your article because it becomes more and more interesting from the starting lines until the end. So Thank you for sharing a COOL Meaningful stuff with us Keep it up..!


    Datawarehousing Training in Chennai

    ReplyDelete
  2. Thanks for the post, I am techno savvy. I believe you hit the nail right on the head. I am highly impressed with your blog.
    It is very nicely explained. Your article adds best knowledge to our Java Online Training from India.
    or learn thru Java Online Training from India Students.

    ReplyDelete
  3. Some us know all relating to the compelling medium you present powerful steps on this blog and therefore strongly encourage
    contribution from other ones on this subject while our own child is truly discovering a great deal.
    Have fun with the remaining portion of the year.
    Selenium training in bangalore
    Selenium training in Chennai
    Selenium training in Bangalore
    Selenium training in Pune
    Selenium Online training

    ReplyDelete
  4. https://k2incenseonlineheadshop.com/
    info@k2incenseonlineheadshop.com
    k2incenseonlineheadshop Buy liquid incense cheap Buy liquid incense cheap For Sale At The Best Incense Online Shop

    ReplyDelete
  5. chemicalmedshop.com Buy liquid incense cheap ,Incense for sale online offers ,Liquid Spice ,k2 chemical spray for sale , where to buy k2 near me ,K2 E-LIQUID. liquid k2 , k2 spice spray ,herbal incense for sale , Liquid Herbal Incense, cheap herbal incense , strong herbal incense for sale , Liquid k2 on paper , Liquid K2 , Legal High Incense , liquid herbal incense for sale, Order Strong Liquid Incense , Herbal Incense For Sale , Buy Vape Cartridges , Cheap K2 Spice , Legal Potpourri , Buy Herbal Incense Discrete , Legit Herbal Incense Website , Liquid Incense Overnight Delivery , Buy Potpourri With Credit Cards , buy herbal incense with debit card, , Where To Order Liquid Incense Online , Buy Liquid Incense With Bitcoin , Buy K2 Liquid Incense On Paper In USA , Strongest Incense In USA

    ReplyDelete
  6. kingpure ss water tanksAugust 1, 2022 at 3:28 AM

    Thank you for very useful information.
    Luxury Series Stainless Steel Water Tanks in India

    ReplyDelete