Skip to content

Instantly share code, notes, and snippets.

@tdalon
Last active July 3, 2026 15:09
Show Gist options
  • Select an option

  • Save tdalon/ec4803c7443c01742fe7fcf4122a6979 to your computer and use it in GitHub Desktop.

Select an option

Save tdalon/ec4803c7443c01742fe7fcf4122a6979 to your computer and use it in GitHub Desktop.
Confluence Recent Updates Table
## Macro title: Recent Updates Table
## Description: Displays recently updated content in a table with optional filters and pageId-based scope handling.
## Body processing: No macro body
## Output format: HTML
## @param scope:title=Scope|type=string|required=false|default=@currentspace|desc=Comma-separated: @currentpage, @currentspace, @all, SPACEKEY, or PAGEID:123456789
## @param labels:title=Filter Labels|type=string|required=false|default=|desc=Optional comma-separated labels; content must contain at least one
## @param authors:title=Authors|type=string|required=false|default=|desc=Optional comma-separated creators; supports @current
## @param lastModifiers:title=Last Modifiers|type=string|required=false|default=|desc=Optional comma-separated last modifiers; supports @current
## @param types:title=Types|type=string|required=false|default=page|desc=Comma-separated list: page, blog, blogpost, post, @all
## @param limit:title=Max Results|type=int|required=false|default=20|desc=Number of content items to show
## @param columns:title=Columns|type=string|required=false|default=title,type,space,labels,creationDate,createdBy,lastModified,modifiedBy|desc=Comma-separated list: title,type,space,labels,creationDate,createdBy,lastModified,modifiedBy
## @param dateFormat:title=Date Format|type=string|required=false|default=yyyy-MM-dd HH:mm|desc=Java date pattern, e.g. yyyy-MM-dd, dd.MM.yyyy HH:mm
## @param sortBy:title=Sort By|type=string|required=false|default=lastModified|desc=Allowed values: lastModified, creationDate, title, type
## @param sortOrder:title=Sort Order|type=string|required=false|default=desc|desc=Allowed values: asc, desc
## ---------- defaults ----------
#set($scopeParam = $paramscope)
#if(!$scopeParam || $scopeParam.trim() == "")
#set($scopeParam = "@currentspace")
#end
#set($dateFmt = $paramdateFormat)
#if(!$dateFmt || $dateFmt.trim() == "")
#set($dateFmt = "yyyy-MM-dd HH:mm")
#end
#set($maxResults = $paramlimit)
#if(!$maxResults || $maxResults < 1)
#set($maxResults = 20)
#end
#set($labelsParam = $paramlabels)
#set($authorsParam = $paramauthors)
#set($lastModifiersParam = $paramlastModifiers)
#set($typesParam = $paramtypes)
#if(!$typesParam || $typesParam.trim() == "")
#set($typesParam = "page")
#end
#set($columnsParam = $paramcolumns)
#if(!$columnsParam || $columnsParam.trim() == "")
#set($columnsParam = "title,type,space,labels,creationDate,createdBy,lastModified,modifiedBy")
#end
#set($sortBy = $paramsortBy)
#if(!$sortBy || $sortBy.trim() == "")
#set($sortBy = "lastModified")
#end
#set($sortOrder = $paramsortOrder)
#if(!$sortOrder || $sortOrder.trim() == "")
#set($sortOrder = "desc")
#end
## ---------- current context ----------
#set($currentUserName = "")
#if($action && $action.remoteUser)
#set($currentUserName = $action.remoteUser.name)
#end
#set($currentSpaceKey = "")
#if($space && $space.key)
#set($currentSpaceKey = $space.key)
#end
#set($currentPage = $content)
## ---------- helper collections ----------
#set($allCandidatePages = [])
#set($seenIds = [])
## ---------- parse filter labels ----------
#set($labelFilters = [])
#if($labelsParam && $labelsParam.trim() != "")
#foreach($token in $labelsParam.split(","))
#set($t = $token.trim())
#if($t != "")
#set($void = $labelFilters.add($t.toLowerCase()))
#end
#end
#end
## ---------- parse authors ----------
#set($authorFilters = [])
#if($authorsParam && $authorsParam.trim() != "")
#foreach($token in $authorsParam.split(","))
#set($t = $token.trim())
#if($t.equalsIgnoreCase("@current"))
#if($currentUserName != "")
#set($void = $authorFilters.add($currentUserName))
#end
#elseif($t != "")
#set($void = $authorFilters.add($t))
#end
#end
#end
## ---------- parse last modifiers ----------
#set($modifierFilters = [])
#if($lastModifiersParam && $lastModifiersParam.trim() != "")
#foreach($token in $lastModifiersParam.split(","))
#set($t = $token.trim())
#if($t.equalsIgnoreCase("@current"))
#if($currentUserName != "")
#set($void = $modifierFilters.add($currentUserName))
#end
#elseif($t != "")
#set($void = $modifierFilters.add($t))
#end
#end
#end
## ---------- parse types ----------
#set($typeFilters = [])
#foreach($token in $typesParam.split(","))
#set($t = $token.trim().toLowerCase())
#if($t != "")
#if($t.equals("@all"))
#set($void = $typeFilters.clear())
#set($void = $typeFilters.add("@all"))
#break
#elseif($t.equals("blogpost") || $t.equals("blog") || $t.equals("post"))
#set($void = $typeFilters.add("blogpost"))
#elseif($t.equals("page"))
#set($void = $typeFilters.add("page"))
#end
#end
#end
#if($typeFilters.isEmpty())
#set($void = $typeFilters.add("page"))
#end
## ---------- requested columns ----------
#set($showTitle = false)
#set($showType = false)
#set($showSpace = false)
#set($showLabels = false)
#set($showCreationDate = false)
#set($showCreatedBy = false)
#set($showLastModified = false)
#set($showModifiedBy = false)
#foreach($col in $columnsParam.split(","))
#set($c = $col.trim())
#if($c.equalsIgnoreCase("title"))
#set($showTitle = true)
#elseif($c.equalsIgnoreCase("type"))
#set($showType = true)
#elseif($c.equalsIgnoreCase("space"))
#set($showSpace = true)
#elseif($c.equalsIgnoreCase("labels"))
#set($showLabels = true)
#elseif($c.equalsIgnoreCase("creationDate"))
#set($showCreationDate = true)
#elseif($c.equalsIgnoreCase("createdBy"))
#set($showCreatedBy = true)
#elseif($c.equalsIgnoreCase("lastModified"))
#set($showLastModified = true)
#elseif($c.equalsIgnoreCase("modifiedBy"))
#set($showModifiedBy = true)
#end
#end
#if(!$showTitle && !$showType && !$showSpace && !$showLabels && !$showCreationDate && !$showCreatedBy && !$showLastModified && !$showModifiedBy)
#set($showTitle = true)
#set($showType = true)
#set($showSpace = true)
#set($showLabels = true)
#set($showCreationDate = true)
#set($showCreatedBy = true)
#set($showLastModified = true)
#set($showModifiedBy = true)
#end
## ---------- collect by scope ----------
#foreach($rawToken in $scopeParam.split(","))
#set($token = $rawToken.trim())
#if($token != "")
## @all = all spaces
#if($token.equalsIgnoreCase("@all"))
#foreach($sp in $spaceManager.getAllSpaces())
#if($typeFilters.contains("@all") || $typeFilters.contains("page"))
#foreach($p in $pageManager.getPages($sp.key))
#if(!$p.isDeleted() && !$seenIds.contains($p.id))
#set($void = $allCandidatePages.add($p))
#set($void = $seenIds.add($p.id))
#end
#end
#end
#if($typeFilters.contains("@all") || $typeFilters.contains("blogpost"))
#foreach($b in $pageManager.getBlogPosts($sp.key, true))
#if(!$b.isDeleted() && !$seenIds.contains($b.id))
#set($void = $allCandidatePages.add($b))
#set($void = $seenIds.add($b.id))
#end
#end
#end
#end
## @currentspace = all content in current space
#elseif($token.equalsIgnoreCase("@currentspace"))
#if($currentSpaceKey != "")
#if($typeFilters.contains("@all") || $typeFilters.contains("page"))
#foreach($p in $pageManager.getPages($currentSpaceKey))
#if(!$p.isDeleted() && !$seenIds.contains($p.id))
#set($void = $allCandidatePages.add($p))
#set($void = $seenIds.add($p.id))
#end
#end
#end
#if($typeFilters.contains("@all") || $typeFilters.contains("blogpost"))
#foreach($b in $pageManager.getBlogPosts($currentSpaceKey, true))
#if(!$b.isDeleted() && !$seenIds.contains($b.id))
#set($void = $allCandidatePages.add($b))
#set($void = $seenIds.add($b.id))
#end
#end
#end
#end
## @currentpage = current page + descendants
#elseif($token.equalsIgnoreCase("@currentpage"))
#if($currentPage)
#if(!$seenIds.contains($currentPage.id))
#set($void = $allCandidatePages.add($currentPage))
#set($void = $seenIds.add($currentPage.id))
#end
#foreach($child in $currentPage.getDescendants())
#if(!$child.isDeleted() && !$seenIds.contains($child.id))
#set($void = $allCandidatePages.add($child))
#set($void = $seenIds.add($child.id))
#end
#end
#end
## PAGEID:123456789 = root page + descendants
#elseif($token.toUpperCase().startsWith("PAGEID:"))
#set($idText = $token.substring(7).trim())
#if($idText != "")
#set($pageIdNum = $generalUtil.convertToInteger($idText))
#set($rootPage = $pageManager.getPage($pageIdNum))
#if($rootPage)
#if(!$seenIds.contains($rootPage.id))
#set($void = $allCandidatePages.add($rootPage))
#set($void = $seenIds.add($rootPage.id))
#end
#foreach($child in $rootPage.getDescendants())
#if(!$child.isDeleted() && !$seenIds.contains($child.id))
#set($void = $allCandidatePages.add($child))
#set($void = $seenIds.add($child.id))
#end
#end
#end
#end
## plain numeric token = pageId, otherwise treat as space key
#else
#set($isNumeric = true)
#foreach($ch in [0..$token.length()-1])
#set($c = $token.substring($ch, $ch + 1))
#if("0123456789".indexOf($c) == -1)
#set($isNumeric = false)
#break
#end
#end
#if($isNumeric)
#set($pageIdNum = $generalUtil.convertToInteger($token))
#set($rootPage = $pageManager.getPage($pageIdNum))
#if($rootPage)
#if(!$seenIds.contains($rootPage.id))
#set($void = $allCandidatePages.add($rootPage))
#set($void = $seenIds.add($rootPage.id))
#end
#foreach($child in $rootPage.getDescendants())
#if(!$child.isDeleted() && !$seenIds.contains($child.id))
#set($void = $allCandidatePages.add($child))
#set($void = $seenIds.add($child.id))
#end
#end
#end
#else
#set($scopeSpaceKey = $token)
#if($typeFilters.contains("@all") || $typeFilters.contains("page"))
#foreach($p in $pageManager.getPages($scopeSpaceKey))
#if(!$p.isDeleted() && !$seenIds.contains($p.id))
#set($void = $allCandidatePages.add($p))
#set($void = $seenIds.add($p.id))
#end
#end
#end
#if($typeFilters.contains("@all") || $typeFilters.contains("blogpost"))
#foreach($b in $pageManager.getBlogPosts($scopeSpaceKey, true))
#if(!$b.isDeleted() && !$seenIds.contains($b.id))
#set($void = $allCandidatePages.add($b))
#set($void = $seenIds.add($b.id))
#end
#end
#end
#end
#end
#end
#end
## ---------- filter content ----------
#set($filteredPages = [])
#foreach($page in $allCandidatePages)
#set($include = true)
## type filter for mixed scopes
#if($include && !$typeFilters.contains("@all"))
#set($currentType = "page")
#if($page.class.name.contains("BlogPost"))
#set($currentType = "blogpost")
#end
#if(!$typeFilters.contains($currentType))
#set($include = false)
#end
#end
## creator filter
#if($include && !$authorFilters.isEmpty())
#set($include = false)
#foreach($u in $authorFilters)
#if($page.getCreatorName() == $u)
#set($include = true)
#break
#end
#end
#end
## modifier filter
#if($include && !$modifierFilters.isEmpty())
#set($include = false)
#foreach($u in $modifierFilters)
#if($page.getLastModifierName() == $u)
#set($include = true)
#break
#end
#end
#end
## label filter (match any)
#if($include && !$labelFilters.isEmpty())
#set($include = false)
#foreach($lbl in $page.getLabels())
#set($pageLabelName = $lbl.name.toLowerCase())
#foreach($wanted in $labelFilters)
#if($pageLabelName == $wanted)
#set($include = true)
#break
#end
#end
#if($include)
#break
#end
#end
#end
#if($include)
#set($void = $filteredPages.add($page))
#end
#end
## ---------- sort ----------
#set($sortField = "lastModificationDate")
#if($sortBy.equalsIgnoreCase("title"))
#set($sortField = "title")
#elseif($sortBy.equalsIgnoreCase("creationDate"))
#set($sortField = "creationDate")
#elseif($sortBy.equalsIgnoreCase("type"))
#set($sortField = "contentType")
#elseif($sortBy.equalsIgnoreCase("lastModified"))
#set($sortField = "lastModificationDate")
#end
#set($sortedPages = $filteredPages.sort($sorter.sortBy($sortField)))
#if($sortOrder.equalsIgnoreCase("desc"))
#set($sortedPages = $sortedPages.reverse())
#end
## ---------- render ----------
<table class="confluenceTable">
<tbody>
<tr>
#if($showTitle)<th class="confluenceTh">Title</th>#end
#if($showType)<th class="confluenceTh">Type</th>#end
#if($showSpace)<th class="confluenceTh">Space</th>#end
#if($showLabels)<th class="confluenceTh">Labels</th>#end
#if($showCreationDate)<th class="confluenceTh">Creation date</th>#end
#if($showCreatedBy)<th class="confluenceTh">Created by</th>#end
#if($showLastModified)<th class="confluenceTh">Last modified</th>#end
#if($showModifiedBy)<th class="confluenceTh">Modified by</th>#end
</tr>
#set($count = 0)
#foreach($page in $sortedPages)
#if($count < $maxResults)
#set($typeText = "page")
#if($page.class.name.contains("BlogPost"))
#set($typeText = "blog")
#end
#set($labelNames = [])
#foreach($lbl in $page.getLabels())
#set($void = $labelNames.add($lbl.name))
#end
<tr>
#if($showTitle)
<td class="confluenceTd">
<a href="$req.contextPath$page.urlPath">$htmlUtil.htmlEncode($page.title)</a>
</td>
#end
#if($showType)
<td class="confluenceTd">$typeText</td>
#end
#if($showSpace)
<td class="confluenceTd">$htmlUtil.htmlEncode($page.space.name)</td>
#end
#if($showLabels)
<td class="confluenceTd">
#if($labelNames.isEmpty())
&nbsp;
#else
$htmlUtil.htmlEncode($labelNames.toString().replace("[","").replace("]",""))
#end
</td>
#end
#if($showCreationDate)
<td class="confluenceTd">$action.dateFormatter.formatGivenString($dateFmt, $page.getCreationDate())</td>
#end
#if($showCreatedBy)
<td class="confluenceTd">$htmlUtil.htmlEncode($page.getCreatorName())</td>
#end
#if($showLastModified)
<td class="confluenceTd">$action.dateFormatter.formatGivenString($dateFmt, $page.getLastModificationDate())</td>
#end
#if($showModifiedBy)
<td class="confluenceTd">$htmlUtil.htmlEncode($page.getLastModifierName())</td>
#end
</tr>
#set($count = $count + 1)
#end
#end
</tbody>
</table>
@tdalon

tdalon commented Jul 3, 2026

Copy link
Copy Markdown
Author

Example scope values
You can now use:
@currentpage
@currentspace
@ALL
PAGEID:123456789
DOC,PAGEID:123456789,@currentpage

Important notes
On modern Confluence Data Center, user macros may not have access to keys like action, req, content, spaceManager, pageManager, and sorter unless admins add them to macro.required.velocity.context.keys. Atlassian also notes that complex page retrieval logic is better handled in Java action/plugin code than directly in Velocity templates, so this macro may need adjustment on stricter 8.x/9.x environments.

One caution
This version uses generalUtil.convertToInteger(...) for page IDs, which matches common user-macro parameter conversion patterns, but if your page IDs exceed integer size on your instance, a plugin or ScriptRunner version would be safer than pure Velocity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment