0
1
1
Groovy Plugins
Why you should be developing
Atlassian plugins using Groovy

Dr Paul King, Director, ASERT




            ...
What is Groovy?
 “Groovy is like a super version of Java. It
  can leverage Java's enterprise capabilities
  but also has ...
What is Groovy?




 Now free




                  4
                  4
What is Groovy?
                                         What alternative JVM language are you using or intending to use

...
Reason: Language Features
• Closures
                                 • Productivity
• Runtime metaprogramming
           ...
Reason: Testing
• Support for Testing DSLs and     • Productivity
  BDD style tests
                                   • C...
Myth: Dynamic typing == No IDE support
• Completion through inference
• Code analysis
• Seamless debugging
• Seamless refa...
Myth: Scripting == Non-professional
• Analysis tools
• Coverage tools
• Testing support




                              ...
Java                                                                                                       Groovy
import	
...
Java                                                                                                                      ...
Java                                                                                                                      ...
Java                                                            Groovy
public class CustomException extends RuntimeExcepti...
Groovy
@Grab('com.google.collections:google-­‐collections:1.0')
import	
  com.google.common.collect.HashBiMap             ...
Plugin Tutorial: World of WarCraft...
• http://confluence.atlassian.com/display/CONFDEV/
  WoW+Macro+explanation




      ...
...Plugin Tutorial: World of WarCraft...
            • Normal instructions for gmaven:
              http://gmaven.codehau...
...Plugin Tutorial: World of WarCraft...
package	
  com.atlassian.confluence.plugins.wowplugin;                           ...
...Plugin Tutorial: World of WarCraft...
package	
  com.atlassian.confluence.plugins.gwowplugin

class	
  Toon	
  implemen...
...Plugin Tutorial: World of WarCraft...
package com.atlassian.confluence.plugins.wowplugin;                   ...        ...
...Plugin Tutorial: World of WarCraft...
package	
  com.atlassian.confluence.plugins.gwowplugin                           ...
...Plugin Tutorial: World of WarCraft...
  {groovy-wow-item:1624}   {groovy-guild-gear:realm=Kirin Tor|guild=Faceroll Synd...
...Plugin Tutorial: World of WarCraft...
> atlas-mvn clover2:setup test clover2:aggregate clover2:clover




             ...
...Plugin Tutorial: World of WarCraft
                                                                                    ...
Scripting on the fly...




                         24
                          24
...Scripting on the fly...




                            25
                             25
...Scripting on the fly




                         26
                          26
27
 27
Upcoming SlideShare
Loading in...5
×

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

1,365

Published on

Building Atlassian Plugins with Groovy

Paul King, ASERT

Published in: Technology
0 Comments
2 Likes
Statistics
Notes
  • Be the first to comment

No Downloads
Views
Total Views
1,365
On Slideshare
0
From Embeds
0
Number of Embeds
5
Actions
Shares
0
Downloads
17
Comments
0
Likes
2
Embeds 0
No embeds

No notes for slide

Transcript of "Building Atlassian Plugins with Groovy - Atlassian Summit 2010 - Lightning Talks"

  1. 1. 1 1
  2. 2. Groovy Plugins Why you should be developing Atlassian plugins using Groovy Dr Paul King, Director, ASERT 2 2
  3. 3. What is Groovy? “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 3
  4. 4. What is Groovy? Now free 4 4
  5. 5. What is Groovy? What alternative JVM language are you using or intending to use http://www.jroller.com/scolebourne/entry/devoxx_2008_whiteboard_votes 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 Source: http://www.grailspodcast.com/ 5 5
  6. 6. Reason: Language Features • Closures • Productivity • Runtime metaprogramming • Clarity • Compile-time metaprogramming • Maintainability • Grape modules • Quality • Builders • Fun • DSL friendly 6 6
  7. 7. Reason: Testing • Support for Testing DSLs and • Productivity BDD style tests • Clarity • Built-in assert, power asserts • Maintainability • Built-in testing • Quality • Built-in mocks • Fun • Metaprogramming eases testing pain points • Shareability 7 7
  8. 8. Myth: Dynamic typing == No IDE support • Completion through inference • Code analysis • Seamless debugging • Seamless refactoring • DSL completion 8 8
  9. 9. Myth: Scripting == Non-professional • Analysis tools • Coverage tools • Testing support 9 9
  10. 10. Java Groovy 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); names  =  ["Ted",  "Fred",  "Jed",  "Ned"]                        } println  names                }                return  result; shortNames  =  names.findAll{  it.size()  <=  3  }        }        public  static  void  main(String[]  args)  { println  shortNames.size()                List  names  =  new  ArrayList();                names.add("Ted");  names.add("Fred"); shortNames.each{  println  it  }                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);                }        } } 10 10
  11. 11. Java Groovy import  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; def  p  =  new  XmlParser() public  class  FindYearsJava  { def  records  =  p.parse("records.xml")        public  static  void  main(String[]  args)  {                DocumentBuilderFactory  builderFactory  =  DocumentBuilderFactory.newInstance(); records.car.each  {                try  {                        DocumentBuilder  builder  =  builderFactory.newDocumentBuilder();        println  "year  =  ${it.@year}"                        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();                }        } } 11 11
  12. 12. Java Groovy public  final  class  Punter  {        //  ...        private  final  String  first;        @Override        private  final  String  last;        public  boolean  equals(Object  obj)  {                if  (this  ==  obj)        public  String  getFirst()  {                        return  true;                return  first;                if  (obj  ==  null)        }                        return  false;                if  (getClass()  !=  obj.getClass()) @Immutable  class  Punter  {        public  String  getLast()  {                        return  false;                return  last;                Punter  other  =  (Punter)  obj;        String  first,  last        }                if  (first  ==  null)  {                        if  (other.first  !=  null) }        @Override                                return  false;        public  int  hashCode()  {                }  else  if  (!first.equals(other.first))                final  int  prime  =  31;                        return  false;                int  result  =  1;                if  (last  ==  null)  {                result  =  prime  *  result  +  ((first  ==  null)                        if  (other.last  !=  null)                        ?  0  :  first.hashCode());                                return  false;                result  =  prime  *  result  +  ((last  ==  null)                }  else  if  (!last.equals(other.last))                        ?  0  :  last.hashCode());                        return  false;                return  result;                return  true;        }        }        public  Punter(String  first,  String  last)  {        @Override                this.first  =  first;        public  String  toString()  {                this.last  =  last;                return  "Punter(first:"  +  first        }                        +  ",  last:"  +  last  +  ")";        //  ...        } } 12 12
  13. 13. Java Groovy public class CustomException extends RuntimeException { public CustomException() { super(); @InheritConstructors } class CustomException public CustomException(String message) { extends RuntimeException { } super(message); } public CustomException(String message, Throwable cause) { super(message, cause); } public CustomException(Throwable cause) { super(cause); } } 13 13
  14. 14. Groovy @Grab('com.google.collections:google-­‐collections:1.0') import  com.google.common.collect.HashBiMap @Grab('org.gcontracts:gcontracts:1.0.2') Groovy 1.8+ import  org.gcontracts.annotations.* HashBiMap  fruit  =    [grape:'purple',  lemon:'yellow',  lime:'green'] @Invariant({  first  !=  null  &&  last  !=  null  }) assert  fruit.lemon  ==  'yellow' class  Person  { assert  fruit.inverse().yellow  ==  'lemon'      String  first,  last      @Requires({  delimiter  in  ['.',  ',',  '  ']  }) @Grab('org.codehaus.gpars:gpars:0.10')      @Ensures({  result  ==  first+delimiter+last  }) import  groovyx.gpars.agent.Agent      String  getName(String  delimiter)  {            first  +  delimiter  +  last withPool(5)  {      }        def  nums  =  1..100000 }        println  nums.parallel.                map{  it  **  2  }. new  Person(first:  'John',  last:  'Smith').getName('.')                filter{  it  %  7  ==  it  %  5  }.                filter{  it  %  3  ==  0  }.                reduce{  a,  b  -­‐>  a  +  b  } } Groovy and Gpars both OSGi compliant 14 14
  15. 15. Plugin Tutorial: World of WarCraft... • http://confluence.atlassian.com/display/CONFDEV/ WoW+Macro+explanation 15 15
  16. 16. ...Plugin Tutorial: World of WarCraft... • Normal instructions for gmaven: http://gmaven.codehaus.org/    ...    <plugin>          <groupId>org.codehaus.gmaven</groupId>          <artifactId>gmaven-­‐plugin</artifactId>          <version>1.2</version>          <configuration>...</configuration>          <executions>...</executions>          <dependencies>...</dependencies>    </plugin>    ... 16 16
  17. 17. ...Plugin Tutorial: World of WarCraft... package  com.atlassian.confluence.plugins.wowplugin; ...        public  String  getName()  { import  java.io.Serializable;                return  name; import  java.util.Arrays;        } import  java.util.List;        public  String  getSpec()  { /**                return  spec; *  Simple  data  holder  for  basic  toon  information        } */ public  final  class  Toon  implements  Comparable,  Serializable        public  int  getGearScore()  { {                return  gearScore;        private  static  final  String[]  CLASSES  =  {        }                        "Warrior",                        "Paladin",        public  List  getRecommendedRaids()  {                        "Hunter",                return  recommendedRaids;                        "Rogue",        }                        "Priest",                        "Death  Knight",        public  String  getClassName()  {                        "Shaman",                return  className;                        "Mage",        }                        "Warlock",                        "Unknown",  //  There  is  no  class  with  ID  10.  Weird.        public  int  compareTo(Object  o)                        "Druid"        {        };                Toon  otherToon  =  (Toon)  o;        private  final  String  name;                if  (otherToon.gearScore  -­‐  gearScore  !=  0)        private  final  String  spec;                        return  otherToon.gearScore  -­‐  gearScore;        private  final  int  gearScore;        private  final  List  recommendedRaids;                return  name.compareTo(otherToon.name);        private  final  String  className;        }        public  Toon(String  name,  int  classId,  String  spec,  int  gearScore,  String...  recommendedRaids)        private  String  toClassName(int  classIndex)        {        {                this.className  =  toClassName(classId  -­‐  1);                if  (classIndex  <  0  ||  classIndex  >=  CLASSES.length)                this.name  =  name;                        return  "Unknown:  "  +  classIndex  +  1;                this.spec  =  spec;                else                this.gearScore  =  gearScore;                        return  CLASSES[classIndex];                this.recommendedRaids  =  Arrays.asList(recommendedRaids);        }        } } ... 17 17
  18. 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"] 83 -> 17        String  name        int  classId        String  spec        int  gearScore        def  recommendedRaids        String  getClassName()  {                classId  in  0..<CLASSES.length  ?  CLASSES[classId  -­‐  1]  :  "Unknown:  "  +  classId        } } 18 18
  19. 19. ...Plugin Tutorial: World of WarCraft... package com.atlassian.confluence.plugins.wowplugin; ... ... public boolean isInline() { return false; } try { import com.atlassian.cache.Cache; url = String.format("http://xml.wow-heroes.com/xml-guild.php?z=%s&r=%s&g=%s", import com.atlassian.cache.CacheManager; public boolean hasBody() { return false; } URLEncoder.encode(zone, "UTF-8"), import com.atlassian.confluence.util.http.HttpResponse; URLEncoder.encode(realmName, "UTF-8"), import com.atlassian.confluence.util.http.HttpRetrievalService; public RenderMode getBodyRenderMode() { URLEncoder.encode(guildName, "UTF-8")); import com.atlassian.renderer.RenderContext; return RenderMode.NO_RENDER; } catch (UnsupportedEncodingException e) { import com.atlassian.renderer.v2.RenderMode; } throw new MacroException(e.getMessage(), e); import com.atlassian.renderer.v2.SubRenderer; } import com.atlassian.renderer.v2.macro.BaseMacro; public String execute(Map map, String s, RenderContext renderContext) throws MacroException { import com.atlassian.renderer.v2.macro.MacroException; String guildName = (String) map.get("guild"); Cache cache = cacheManager.getCache(this.getClass().getName() + ".toons"); import org.dom4j.Document; String realmName = (String) map.get("realm"); import org.dom4j.DocumentException; String zone = (String) map.get("zone"); if (cache.get(url) != null) import org.dom4j.Element; if (zone == null) zone = "us"; return (List<Toon>) cache.get(url); import org.dom4j.io.SAXReader; StringBuilder out = new StringBuilder("||Name||Class||Gear Score"); try { for (int i = 0; i < SHORT_RAIDS.length; i++) { import java.io.IOException; List<Toon> toons = retrieveAndParseFromWowArmory(url); out.append("||").append(SHORT_RAIDS[i].replace('/', 'n')); import java.io.InputStream; cache.put(url, toons); } import java.io.UnsupportedEncodingException; return toons; out.append("||n"); import java.net.URLEncoder; } import java.util.*; catch (IOException e) { List<Toon> toons = retrieveToons(guildName, realmName, zone); throw new MacroException("Unable to retrieve information for guild: " + guildName + ", " + e.toString()); /** for (Toon toon : toons) { } * Inserts a table of a guild's roster of 80s ranked by gear level, with recommended raid instances. The data for catch (DocumentException e) { * the macro is grabbed from http://wow-heroes.com. Results are cached for $DEFAULT_CACHE_LIFETIME to reduce out.append("| "); throw new MacroException("Unable to parse information for guild: " + guildName + ", " + e.toString()); * load on the server. try { } * <p/> } String url = String.format("http://xml.wow-heroes.com/index.php?zone=%s&server=%s&name=%s", * Usage: {guild-gear|realm=Nagrand|guild=A New Beginning|zone=us} URLEncoder.encode(zone, "UTF-8"), * <p/> URLEncoder.encode(realmName, "UTF-8"), private List<Toon> retrieveAndParseFromWowArmory(String url) throws IOException, DocumentException { * Problems: URLEncoder.encode(toon.getName(), "UTF-8")); List<Toon> toons = new ArrayList<Toon>(); * <p/> out.append("["); out.append(toon.getName()); HttpResponse response = httpRetrievalService.get(url); * * wow-heroes reports your main spec, but whatever gear you logged out in. So if you out.append("|"); out.append(url); out.append("]"); logged out in off-spec gear * your number will be wrong } InputStream responseStream = response.getResponse(); * * gear score != ability. l2play nub. catch (UnsupportedEncodingException e) { try { */ out.append(toon.getName()); SAXReader reader = new SAXReader(); public class GuildGearMacro extends BaseMacro { } Document doc = reader.read(responseStream); private HttpRetrievalService httpRetrievalService; List toonsXml = doc.selectNodes("//character"); private SubRenderer subRenderer; out.append(" | "); for (Object o : toonsXml) { private CacheManager cacheManager; out.append(toon.getClassName()); Element element = (Element) o; out.append(" ("); toons.add(new Toon(element.attributeValue("name"), Integer.parseInt(element.attributeValue("classId")), private static final String[] RAIDS = { out.append(toon.getSpec()); element.attributeValue("specName"), "Heroics", out.append(")"); Integer.parseInt(element.attributeValue("score")), element.attributeValue("suggest").split(";"))); "Naxxramas 10", // and OS10 out.append("|"); } "Naxxramas 25", // and OS25/EoE10 out.append(toon.getGearScore()); "Ulduar 10", // and EoE25 boolean found = false; Collections.sort(toons); "Onyxia 10", } "Ulduar 25", // and ToTCr10 for (String raid : RAIDS) { finally { "Onyxia 25", if (toon.getRecommendedRaids().contains(raid)) { responseStream.close(); "Trial of the Crusader 25", out.append("|(!)"); } "Icecrown Citadel 10" found = true; return toons; }; } else { } out.append("|").append(found ? "(x)" : "(/)"); private static final String[] SHORT_RAIDS = { } public void setHttpRetrievalService(HttpRetrievalService httpRetrievalService) { "H", } this.httpRetrievalService = httpRetrievalService; "Naxx10/OS10", out.append("|n"); } "Naxx25/OS25/EoE10", } "Uld10/EoE25", public void setSubRenderer(SubRenderer subRenderer) { "Ony10", return subRenderer.render(out.toString(), renderContext); this.subRenderer = subRenderer; "Uld25/TotCr10", } } "Ony25", "TotCr25", private List<Toon> retrieveToons(String guildName, String realmName, String zone) public void setCacheManager(CacheManager cacheManager) { "IC" throws MacroException { this.cacheManager = cacheManager; }; String url = null; } ... ... } 19 19
  20. 20. ...Plugin Tutorial: World of WarCraft... package  com.atlassian.confluence.plugins.gwowplugin ...        toons.each  {  toon  -­‐> import  com.atlassian.cache.CacheManager            def  url  =  "http://xml.wow-­‐heroes.com/index.php?zone=${enc  zone}&server=${enc  map.realm}&name=${enc  toon.name}" import  com.atlassian.confluence.util.http.HttpRetrievalService            out.append("|  [${toon.name}|${url}]  |  $toon.className  ($toon.spec)|  $toon.gearScore") import  com.atlassian.renderer.RenderContext            boolean  found  =  false import  com.atlassian.renderer.v2.RenderMode            RAIDS.each  {  raid  -­‐> import  com.atlassian.renderer.v2.SubRenderer                if  (raid  in  toon.recommendedRaids)  { import  com.atlassian.renderer.v2.macro.BaseMacro                    out.append("|(!)") import  com.atlassian.renderer.v2.macro.MacroException                    found  =  true                }  else  { /**                    out.append("|").append(found  ?  "(x)"  :  "(/)")  *  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            } 200 -> 90  *  cached  for  $DEFAULT_CACHE_LIFETIME  to  reduce  load  on  the  server.            out.append("|n")  *  <p/>        }  *  Usage:  {guild-­‐gear:realm=Nagrand|guild=A  New  Beginning|zone=us}        subRenderer.render(out.toString(),  renderContext)  */    } class  GuildGearMacro  extends  BaseMacro  {    HttpRetrievalService  httpRetrievalService    private  retrieveToons(String  guildName,  String  realmName,  String  zone)  throws  MacroException  {    SubRenderer  subRenderer        def  url  =  "http://xml.wow-­‐heroes.com/xml-­‐guild.php?z=${enc  zone}&r=${enc  realmName}&g=${enc  guildName}"    CacheManager  cacheManager        def  cache  =  cacheManager.getCache(this.class.name  +  ".toons")        if  (!cache.get(url))  cache.put(url,  retrieveAndParseFromWowArmory(url))    private  static  final  String[]  RAIDS  =  [        return  cache.get(url)                "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  =  [    private  retrieveAndParseFromWowArmory(String  url)  {                "H",  "Naxx10/OS10",  "Naxx25/OS25/EoE10",  "Uld10/EoE25",  "Ony10",        def  toons                "Uld25/TotCr10",  "Ony25",  "TotCr25",  "IC"]        httpRetrievalService.get(url).response.withReader  {  reader  -­‐>            toons  =  new  XmlSlurper().parse(reader).guild.character.collect  {    boolean  isInline()  {  false  }                new  Toon(    boolean  hasBody()  {  false  }                    name:                          it.@name,    RenderMode  getBodyRenderMode()  {  RenderMode.NO_RENDER  }                    classId:                    it.@classId.toInteger(),                    spec:                          it.@specName,    String  execute(Map  map,  String  s,  RenderContext  renderContext)  throws  MacroException  {                    gearScore:                it.@score.toInteger(),        def  zone  =  map.zone  ?:  "us"                    recommendedRaids:  it.@suggest.toString().split(";"))        def  out  =  new  StringBuilder("||Name||Class||Gear  Score")            }        SHORT_RAIDS.each  {  out.append("||").append(it.replace('/',  'n'))  }        }        out.append("||n")        toons.sort{  a,  b  -­‐>  a.gearScore  ==  b.gearScore  ?  a.name  <=>  b.name  :  a.gearScore  <=>  b.gearScore  }    }        def  toons  =  retrieveToons(map.guild,  map.realm,  zone) ...    def  enc(s)  {  URLEncoder.encode(s,  'UTF-­‐8')  } } 20 20
  21. 21. ...Plugin Tutorial: World of WarCraft... {groovy-wow-item:1624} {groovy-guild-gear:realm=Kirin Tor|guild=Faceroll Syndicate|zone=us} 21 21
  22. 22. ...Plugin Tutorial: World of WarCraft... > atlas-mvn clover2:setup test clover2:aggregate clover2:clover 22 22
  23. 23. ...Plugin Tutorial: World of WarCraft narrative  'segment  flown',  { package  com.atlassian.confluence.plugins.gwowplugin        as_a  'frequent  flyer'        i_want  'to  accrue  rewards  points  for  every  segment  I  fly' class  ToonSpec  extends  spock.lang.Specification  {        so_that  'I  can  receive  free  flights  for  my  dedication  to  the  airline'        def  "successful  name  of  Toon  given  classId"()  { } scenario  'segment  flown',  {                given:        given  'a  frequent  flyer  with  a  rewards  balance  of  1500  points'                def  t  =  new  Toon(classId:  thisClassId)        when  'that  flyer  completes  a  segment  worth  500  points'        then  'that  flyer  has  a  new  rewards  balance  of  2000  points'                expect: }                t.className  ==  name scenario  'segment  flown',  {          given  'a  frequent  flyer  with  a  rewards  balance  of  1500  points',  {                where:                  flyer  =  new  FrequentFlyer(1500)                name              |    thisClassId          }                "Hunter"      |    3          when  'that  flyer  completes  a  segment  worth  500  points',  {                "Rogue"        |    4                  flyer.fly(new  Segment(500))          }                "Priest"      |    5          then  'that  flyer  has  a  new  rewards  balance  of  2000  points',  {                  flyer.pointsBalance.shouldBe  2000        }          } }•  } • Testing with Spock • Or Cucumber, EasyB, JBehave, 23 23
  24. 24. Scripting on the fly... 24 24
  25. 25. ...Scripting on the fly... 25 25
  26. 26. ...Scripting on the fly 26 26
  27. 27. 27 27
  1. A particular slide catching your eye?

    Clipping is a handy way to collect important slides you want to go back to later.

×