Top Banner
1 1
27

Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

May 17, 2015

Download

Technology

Atlassian

Building Atlassian Plugins with Groovy

Paul King, ASERT
Welcome message from author
This document is posted to help you gain knowledge. Please leave a comment to let me know what you think about it! Share it to your friends and learn new things together.
Transcript
Page 1: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

11

Page 2: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Groovy PluginsWhy you should be developingAtlassian plugins using Groovy

Dr Paul King, Director, ASERT

22

Page 3: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

What is Groovy?

3

“Groovy is like a super version of Java. It can leverage Java's enterprise capabilities but also has cool productivity features like closures, DSL support, builders and dynamic typing.”

Groovy  =  Java  –  boiler  plate  code                            +  optional  dynamic  typing                            +  closures                            +  domain  specific  languages                            +  builders                            +  meta-­‐programming

3

Page 4: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

What is Groovy?

4

Now free

4

Page 5: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

What is Groovy?

5

What alternative JVM language are you using or intending to use

http://www.leonardoborges.com/writings

http://it-republik.de/jaxenter/quickvote/results/1/poll/44(translated using http://babelfish.yahoo.com)

Source: http://www.micropoll.com/akira/mpresult/501697-116746

http://www.java.net

http://www.jroller.com/scolebourne/entry/devoxx_2008_whiteboard_votes

Source: http://www.grailspodcast.com/

5

Page 6: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Reason: Language Features• Closures

• Runtime metaprogramming

• Compile-time metaprogramming

• Grape modules

• Builders

• DSL friendly

• Productivity

• Clarity

•Maintainability

•Quality

• Fun

66

Page 7: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Reason: Testing• Support for Testing DSLs and

BDD style tests

• Built-in assert, power asserts

• Built-in testing

• Built-in mocks

• Metaprogramming eases testing pain points

7

• Productivity

• Clarity

•Maintainability

•Quality

• Fun

• Shareability

7

Page 8: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Myth: Dynamic typing == No IDE support• Completion through inference

• Code analysis

• Seamless debugging

• Seamless refactoring

• DSL completion

88

Page 9: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Myth: Scripting == Non-professional• Analysis tools

• Coverage tools

• Testing support

99

Page 10: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Java

10

import  java.util.List;import  java.util.ArrayList;

class  Erase  {        private  List  removeLongerThan(List  strings,  int  length)  {                List  result  =  new  ArrayList();                for  (int  i  =  0;  i  <  strings.size();  i++)  {                        String  s  =  (String)  strings.get(i);                        if  (s.length()  <=  length)  {                                result.add(s);                        }                }                return  result;        }        public  static  void  main(String[]  args)  {                List  names  =  new  ArrayList();                names.add("Ted");  names.add("Fred");                names.add("Jed");  names.add("Ned");                System.out.println(names);                Erase  e  =  new  Erase();                List  shortNames  =  e.removeLongerThan(names,  3);                System.out.println(shortNames.size());                for  (int  i  =  0;  i  <  shortNames.size();  i++)  {                        String  s  =  (String)  shortNames.get(i);                        System.out.println(s);                }        }}

names  =  ["Ted",  "Fred",  "Jed",  "Ned"]println  namesshortNames  =  names.findAll{  it.size()  <=  3  }println  shortNames.size()shortNames.each{  println  it  }

Groovy

10

Page 11: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Java

11

Groovyimport  org.w3c.dom.Document;import  org.w3c.dom.NodeList;import  org.w3c.dom.Node;import  org.xml.sax.SAXException;

import  javax.xml.parsers.DocumentBuilderFactory;import  javax.xml.parsers.DocumentBuilder;import  javax.xml.parsers.ParserConfigurationException;import  java.io.File;import  java.io.IOException;

public  class  FindYearsJava  {        public  static  void  main(String[]  args)  {                DocumentBuilderFactory  builderFactory  =  DocumentBuilderFactory.newInstance();                try  {                        DocumentBuilder  builder  =  builderFactory.newDocumentBuilder();                        Document  document  =  builder.parse(new  File("records.xml"));                        NodeList  list  =  document.getElementsByTagName("car");                        for  (int  i  =  0;  i  <  list.getLength();  i++)  {                                Node  n  =  list.item(i);                                Node  year  =  n.getAttributes().getNamedItem("year");                                System.out.println("year  =  "  +  year.getTextContent());                        }                }  catch  (ParserConfigurationException  e)  {                        e.printStackTrace();                }  catch  (SAXException  e)  {                        e.printStackTrace();                }  catch  (IOException  e)  {                        e.printStackTrace();                }        }}

def  p  =  new  XmlParser()def  records  =  p.parse("records.xml")records.car.each  {        println  "year  =  ${it.@year}"}

11

Page 12: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Java

12

Groovy

@Immutable  class  Punter  {        String  first,  last}

public  final  class  Punter  {        private  final  String  first;        private  final  String  last;

       public  String  getFirst()  {                return  first;        }

       public  String  getLast()  {                return  last;        }

       @Override        public  int  hashCode()  {                final  int  prime  =  31;                int  result  =  1;                result  =  prime  *  result  +  ((first  ==  null)                        ?  0  :  first.hashCode());                result  =  prime  *  result  +  ((last  ==  null)                        ?  0  :  last.hashCode());                return  result;        }

       public  Punter(String  first,  String  last)  {                this.first  =  first;                this.last  =  last;        }        //  ...

       //  ...        @Override        public  boolean  equals(Object  obj)  {                if  (this  ==  obj)                        return  true;                if  (obj  ==  null)                        return  false;                if  (getClass()  !=  obj.getClass())                        return  false;                Punter  other  =  (Punter)  obj;                if  (first  ==  null)  {                        if  (other.first  !=  null)                                return  false;                }  else  if  (!first.equals(other.first))                        return  false;                if  (last  ==  null)  {                        if  (other.last  !=  null)                                return  false;                }  else  if  (!last.equals(other.last))                        return  false;                return  true;        }

       @Override        public  String  toString()  {                return  "Punter(first:"  +  first                        +  ",  last:"  +  last  +  ")";        }

}

12

Page 13: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Java

13

Groovy

@InheritConstructorsclass CustomExceptionextends RuntimeException { }

public class CustomException extends RuntimeException { public CustomException() { super(); }

public CustomException(String message) { super(message); }

public CustomException(String message, Throwable cause) { super(message, cause); }

public CustomException(Throwable cause) { super(cause); }}

13

Page 14: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

14

@Grab('org.gcontracts:gcontracts:1.0.2')import  org.gcontracts.annotations.*

@Invariant({  first  !=  null  &&  last  !=  null  })class  Person  {      String  first,  last

     @Requires({  delimiter  in  ['.',  ',',  '  ']  })      @Ensures({  result  ==  first+delimiter+last  })      String  getName(String  delimiter)  {            first  +  delimiter  +  last      }}

new  Person(first:  'John',  last:  'Smith').getName('.')

Groovy

@Grab('org.codehaus.gpars:gpars:0.10')import  groovyx.gpars.agent.Agent

withPool(5)  {        def  nums  =  1..100000        println  nums.parallel.                map{  it  **  2  }.                filter{  it  %  7  ==  it  %  5  }.                filter{  it  %  3  ==  0  }.                reduce{  a,  b  -­‐>  a  +  b  }}

@Grab('com.google.collections:google-­‐collections:1.0')import  com.google.common.collect.HashBiMap

HashBiMap  fruit  =    [grape:'purple',  lemon:'yellow',  lime:'green']

assert  fruit.lemon  ==  'yellow'assert  fruit.inverse().yellow  ==  'lemon'

Groovy and Gpars both OSGi compliant

Groovy 1.8+

14

Page 15: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

Plugin Tutorial: World of WarCraft...• http://confluence.atlassian.com/display/CONFDEV/

WoW+Macro+explanation

1515

Page 16: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

• Normal instructions for gmaven:http://gmaven.codehaus.org/

16

...Plugin Tutorial: World of WarCraft...

   ...    <plugin>          <groupId>org.codehaus.gmaven</groupId>          <artifactId>gmaven-­‐plugin</artifactId>          <version>1.2</version>          <configuration>...</configuration>          <executions>...</executions>          <dependencies>...</dependencies>    </plugin>    ...

16

Page 17: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

17

...Plugin Tutorial: World of WarCraft...package  com.atlassian.confluence.plugins.wowplugin;

import  java.io.Serializable;import  java.util.Arrays;import  java.util.List;

/***  Simple  data  holder  for  basic  toon  information*/public  final  class  Toon  implements  Comparable,  Serializable{        private  static  final  String[]  CLASSES  =  {                        "Warrior",                        "Paladin",                        "Hunter",                        "Rogue",                        "Priest",                        "Death  Knight",                        "Shaman",                        "Mage",                        "Warlock",                        "Unknown",  //  There  is  no  class  with  ID  10.  Weird.                        "Druid"        };

       private  final  String  name;        private  final  String  spec;        private  final  int  gearScore;        private  final  List  recommendedRaids;        private  final  String  className;

       public  Toon(String  name,  int  classId,  String  spec,  int  gearScore,  String...  recommendedRaids)        {                this.className  =  toClassName(classId  -­‐  1);                this.name  =  name;                this.spec  =  spec;                this.gearScore  =  gearScore;                this.recommendedRaids  =  Arrays.asList(recommendedRaids);        }...

...        public  String  getName()  {                return  name;        }

       public  String  getSpec()  {                return  spec;        }

       public  int  getGearScore()  {                return  gearScore;        }

       public  List  getRecommendedRaids()  {                return  recommendedRaids;        }

       public  String  getClassName()  {                return  className;        }

       public  int  compareTo(Object  o)        {                Toon  otherToon  =  (Toon)  o;

               if  (otherToon.gearScore  -­‐  gearScore  !=  0)                        return  otherToon.gearScore  -­‐  gearScore;

               return  name.compareTo(otherToon.name);        }

       private  String  toClassName(int  classIndex)        {                if  (classIndex  <  0  ||  classIndex  >=  CLASSES.length)                        return  "Unknown:  "  +  classIndex  +  1;                else                        return  CLASSES[classIndex];        }}

17

Page 18: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

18

...Plugin Tutorial: World of WarCraft...package  com.atlassian.confluence.plugins.gwowplugin

class  Toon  implements  Serializable  {        private  static  final  String[]  CLASSES  =  [                "Warrior",  "Paladin",  "Hunter",  "Rogue",  "Priest",                "Death  Knight",  "Shaman",  "Mage",  "Warlock",  "Unknown",  "Druid"]

       String  name        int  classId        String  spec        int  gearScore        def  recommendedRaids

       String  getClassName()  {                classId  in  0..<CLASSES.length  ?  CLASSES[classId  -­‐  1]  :  "Unknown:  "  +  classId        }}

83 -> 17

18

Page 19: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

19

...Plugin Tutorial: World of WarCraft...package com.atlassian.confluence.plugins.wowplugin;

import com.atlassian.cache.Cache;import com.atlassian.cache.CacheManager;import com.atlassian.confluence.util.http.HttpResponse;import com.atlassian.confluence.util.http.HttpRetrievalService;import com.atlassian.renderer.RenderContext;import com.atlassian.renderer.v2.RenderMode;import com.atlassian.renderer.v2.SubRenderer;import com.atlassian.renderer.v2.macro.BaseMacro;import com.atlassian.renderer.v2.macro.MacroException;import org.dom4j.Document;import org.dom4j.DocumentException;import org.dom4j.Element;import org.dom4j.io.SAXReader;

import java.io.IOException;import java.io.InputStream;import java.io.UnsupportedEncodingException;import java.net.URLEncoder;import java.util.*;

/** * Inserts a table of a guild's roster of 80s ranked by gear level, with recommended raid instances. The data for * the macro is grabbed from http://wow-heroes.com. Results are cached for $DEFAULT_CACHE_LIFETIME to reduce * load on the server. * <p/> * Usage: {guild-gear|realm=Nagrand|guild=A New Beginning|zone=us} * <p/> * Problems: * <p/> * * wow-heroes reports your main spec, but whatever gear you logged out in. So if you logged out in off-spec gear * your number will be wrong * * gear score != ability. l2play nub. */public class GuildGearMacro extends BaseMacro { private HttpRetrievalService httpRetrievalService; private SubRenderer subRenderer; private CacheManager cacheManager;

private static final String[] RAIDS = { "Heroics", "Naxxramas 10", // and OS10 "Naxxramas 25", // and OS25/EoE10 "Ulduar 10", // and EoE25 "Onyxia 10", "Ulduar 25", // and ToTCr10 "Onyxia 25", "Trial of the Crusader 25", "Icecrown Citadel 10" };

private static final String[] SHORT_RAIDS = { "H", "Naxx10/OS10", "Naxx25/OS25/EoE10", "Uld10/EoE25", "Ony10", "Uld25/TotCr10", "Ony25", "TotCr25", "IC" }; ...

... public boolean isInline() { return false; }

public boolean hasBody() { return false; }

public RenderMode getBodyRenderMode() { return RenderMode.NO_RENDER; }

public String execute(Map map, String s, RenderContext renderContext) throws MacroException { String guildName = (String) map.get("guild"); String realmName = (String) map.get("realm"); String zone = (String) map.get("zone"); if (zone == null) zone = "us";

StringBuilder out = new StringBuilder("||Name||Class||Gear Score"); for (int i = 0; i < SHORT_RAIDS.length; i++) { out.append("||").append(SHORT_RAIDS[i].replace('/', '\n')); } out.append("||\n");

List<Toon> toons = retrieveToons(guildName, realmName, zone);

for (Toon toon : toons) {

out.append("| "); try { String url = String.format("http://xml.wow-heroes.com/index.php?zone=%s&server=%s&name=%s", URLEncoder.encode(zone, "UTF-8"), URLEncoder.encode(realmName, "UTF-8"), URLEncoder.encode(toon.getName(), "UTF-8")); out.append("["); out.append(toon.getName()); out.append("|"); out.append(url); out.append("]"); } catch (UnsupportedEncodingException e) { out.append(toon.getName()); }

out.append(" | "); out.append(toon.getClassName()); out.append(" ("); out.append(toon.getSpec()); out.append(")"); out.append("|"); out.append(toon.getGearScore()); boolean found = false;

for (String raid : RAIDS) { if (toon.getRecommendedRaids().contains(raid)) { out.append("|(!)"); found = true; } else { out.append("|").append(found ? "(x)" : "(/)"); } } out.append("|\n"); }

return subRenderer.render(out.toString(), renderContext); }

private List<Toon> retrieveToons(String guildName, String realmName, String zone) throws MacroException { String url = null;...

... try { url = String.format("http://xml.wow-heroes.com/xml-guild.php?z=%s&r=%s&g=%s", URLEncoder.encode(zone, "UTF-8"), URLEncoder.encode(realmName, "UTF-8"), URLEncoder.encode(guildName, "UTF-8")); } catch (UnsupportedEncodingException e) { throw new MacroException(e.getMessage(), e); }

Cache cache = cacheManager.getCache(this.getClass().getName() + ".toons");

if (cache.get(url) != null) return (List<Toon>) cache.get(url);

try { List<Toon> toons = retrieveAndParseFromWowArmory(url); cache.put(url, toons); return toons; } catch (IOException e) { throw new MacroException("Unable to retrieve information for guild: " + guildName + ", " + e.toString()); } catch (DocumentException e) { throw new MacroException("Unable to parse information for guild: " + guildName + ", " + e.toString()); } }

private List<Toon> retrieveAndParseFromWowArmory(String url) throws IOException, DocumentException { List<Toon> toons = new ArrayList<Toon>(); HttpResponse response = httpRetrievalService.get(url);

InputStream responseStream = response.getResponse(); try { SAXReader reader = new SAXReader(); Document doc = reader.read(responseStream); List toonsXml = doc.selectNodes("//character"); for (Object o : toonsXml) { Element element = (Element) o; toons.add(new Toon(element.attributeValue("name"), Integer.parseInt(element.attributeValue("classId")), element.attributeValue("specName"), Integer.parseInt(element.attributeValue("score")), element.attributeValue("suggest").split(";"))); }

Collections.sort(toons); } finally { responseStream.close(); } return toons; }

public void setHttpRetrievalService(HttpRetrievalService httpRetrievalService) { this.httpRetrievalService = httpRetrievalService; }

public void setSubRenderer(SubRenderer subRenderer) { this.subRenderer = subRenderer; }

public void setCacheManager(CacheManager cacheManager) { this.cacheManager = cacheManager; }}

19

Page 20: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

20

...Plugin Tutorial: World of WarCraft...package  com.atlassian.confluence.plugins.gwowplugin

import  com.atlassian.cache.CacheManagerimport  com.atlassian.confluence.util.http.HttpRetrievalServiceimport  com.atlassian.renderer.RenderContextimport  com.atlassian.renderer.v2.RenderModeimport  com.atlassian.renderer.v2.SubRendererimport  com.atlassian.renderer.v2.macro.BaseMacroimport  com.atlassian.renderer.v2.macro.MacroException

/**  *  Inserts  a  table  of  a  guild's  roster  of  80s  ranked  by  gear  level,  with  recommended  raid  *  instances.  The  data  for  the  macro  is  grabbed  from  http://wow-­‐heroes.com.  Results  are  *  cached  for  $DEFAULT_CACHE_LIFETIME  to  reduce  load  on  the  server.  *  <p/>  *  Usage:  {guild-­‐gear:realm=Nagrand|guild=A  New  Beginning|zone=us}  */class  GuildGearMacro  extends  BaseMacro  {    HttpRetrievalService  httpRetrievalService    SubRenderer  subRenderer    CacheManager  cacheManager

   private  static  final  String[]  RAIDS  =  [                "Heroics",  "Naxxramas  10",  "Naxxramas  25",  "Ulduar  10",  "Onyxia  10",                "Ulduar  25",  "Onyxia  25",  "Trial  of  the  Crusader  25",  "Icecrown  Citadel  10"]    private  static  final  String[]  SHORT_RAIDS  =  [                "H",  "Naxx10/OS10",  "Naxx25/OS25/EoE10",  "Uld10/EoE25",  "Ony10",                "Uld25/TotCr10",  "Ony25",  "TotCr25",  "IC"]

   boolean  isInline()  {  false  }    boolean  hasBody()  {  false  }    RenderMode  getBodyRenderMode()  {  RenderMode.NO_RENDER  }

   String  execute(Map  map,  String  s,  RenderContext  renderContext)  throws  MacroException  {        def  zone  =  map.zone  ?:  "us"        def  out  =  new  StringBuilder("||Name||Class||Gear  Score")        SHORT_RAIDS.each  {  out.append("||").append(it.replace('/',  '\n'))  }        out.append("||\n")

       def  toons  =  retrieveToons(map.guild,  map.realm,  zone)...

...        toons.each  {  toon  -­‐>            def  url  =  "http://xml.wow-­‐heroes.com/index.php?zone=${enc  zone}&server=${enc  map.realm}&name=${enc  toon.name}"            out.append("|  [${toon.name}|${url}]  |  $toon.className  ($toon.spec)|  $toon.gearScore")            boolean  found  =  false            RAIDS.each  {  raid  -­‐>                if  (raid  in  toon.recommendedRaids)  {                    out.append("|(!)")                    found  =  true                }  else  {                    out.append("|").append(found  ?  "(x)"  :  "(/)")                }            }            out.append("|\n")        }        subRenderer.render(out.toString(),  renderContext)    }

   private  retrieveToons(String  guildName,  String  realmName,  String  zone)  throws  MacroException  {        def  url  =  "http://xml.wow-­‐heroes.com/xml-­‐guild.php?z=${enc  zone}&r=${enc  realmName}&g=${enc  guildName}"        def  cache  =  cacheManager.getCache(this.class.name  +  ".toons")        if  (!cache.get(url))  cache.put(url,  retrieveAndParseFromWowArmory(url))        return  cache.get(url)    }

   private  retrieveAndParseFromWowArmory(String  url)  {        def  toons        httpRetrievalService.get(url).response.withReader  {  reader  -­‐>            toons  =  new  XmlSlurper().parse(reader).guild.character.collect  {                new  Toon(                    name:                          it.@name,                    classId:                    [email protected](),                    spec:                          it.@specName,                    gearScore:                [email protected](),                    recommendedRaids:  [email protected]().split(";"))            }        }        toons.sort{  a,  b  -­‐>  a.gearScore  ==  b.gearScore  ?  a.name  <=>  b.name  :  a.gearScore  <=>  b.gearScore  }    }

   def  enc(s)  {  URLEncoder.encode(s,  'UTF-­‐8')  }}

200 -> 90

20

Page 21: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

21

...Plugin Tutorial: World of WarCraft...{groovy-wow-item:1624} {groovy-guild-gear:realm=Kirin Tor|guild=Faceroll Syndicate|zone=us}

21

Page 22: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

22

...Plugin Tutorial: World of WarCraft...> atlas-mvn clover2:setup test clover2:aggregate clover2:clover

22

Page 23: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

• Testing with Spock • Or Cucumber, EasyB, JBehave,

23

...Plugin Tutorial: World of WarCraftpackage  com.atlassian.confluence.plugins.gwowplugin

class  ToonSpec  extends  spock.lang.Specification  {        def  "successful  name  of  Toon  given  classId"()  {

               given:                def  t  =  new  Toon(classId:  thisClassId)

               expect:                t.className  ==  name

               where:                name              |    thisClassId                "Hunter"      |    3                "Rogue"        |    4                "Priest"      |    5

       }}

narrative  'segment  flown',  {        as_a  'frequent  flyer'        i_want  'to  accrue  rewards  points  for  every  segment  I  fly'        so_that  'I  can  receive  free  flights  for  my  dedication  to  the  airline'}

scenario  'segment  flown',  {        given  'a  frequent  flyer  with  a  rewards  balance  of  1500  points'        when  'that  flyer  completes  a  segment  worth  500  points'        then  'that  flyer  has  a  new  rewards  balance  of  2000  points'}

scenario  'segment  flown',  {          given  'a  frequent  flyer  with  a  rewards  balance  of  1500  points',  {                  flyer  =  new  FrequentFlyer(1500)          }          when  'that  flyer  completes  a  segment  worth  500  points',  {                  flyer.fly(new  Segment(500))          }          then  'that  flyer  has  a  new  rewards  balance  of  2000  points',  {                  flyer.pointsBalance.shouldBe  2000          }  }

23

Page 24: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

24

Scripting on the fly...

24

Page 25: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

25

...Scripting on the fly...

25

Page 26: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

26

...Scripting on the fly

26

Page 27: Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks

2727