Wiki-Quellcode von RecentlyUpdatedService
Version 3.1 von Daniel Herrmann am 2025/06/23 19:53
Zeige letzte Bearbeiter
| author | version | line-number | content |
|---|---|---|---|
| 1 | {{velocity output="false"}} | ||
| 2 | |||
| 3 | ## ----------------------------------------------------------------- | ||
| 4 | ## ----------------------------------------------------------------- | ||
| 5 | ## ----------------------------------------------------------------- | ||
| 6 | ## Constants & Globals | ||
| 7 | ## ----------------------------------------------------------------- | ||
| 8 | |||
| 9 | #set ($TYPES = ['page', 'blogpost', 'attachment', 'comment']) | ||
| 10 | |||
| 11 | #set ($ICON_BY_TYPE = { | ||
| 12 | "page": 'page', | ||
| 13 | "blogpost": 'page', | ||
| 14 | "attachment": 'attach', | ||
| 15 | "comment": 'comment' | ||
| 16 | }) | ||
| 17 | #set ($AUTHORING_TEXT_BY_TYPE = { | ||
| 18 | "page": 'contributed', | ||
| 19 | "blogpost": 'contributed', | ||
| 20 | "attachment": 'attached', | ||
| 21 | "comment": 'commented' | ||
| 22 | }) | ||
| 23 | |||
| 24 | ## See https://solr.apache.org/guide/solr/latest/query-guide/standard-query-parser.html#escaping-special-characters | ||
| 25 | ## And https://jira.xwiki.org/browse/XCOMMONS-2926 | ||
| 26 | #set ($solrSpecialChars = ['+', '-', '&&', '||', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '/', '\', ' ']) | ||
| 27 | #set ($escapedSolrSpecialChars = ['\+', '\-', '\&&', '\||', '\!', '\(', '\)', '\{', '\}', '\[', '\]', '\^', '\"', '\~', '\*', '\?', '\:', '\/', '\\', '\ ']) | ||
| 28 | #macro (escapeSolr $v) | ||
| 29 | $stringtool.replaceEach($v, $solrSpecialChars, $escapedSolrSpecialChars)## | ||
| 30 | #end | ||
| 31 | |||
| 32 | #macro (addSolrParameterListWithSign $listString $fq $fieldName $onlyComma, $forAuthors) | ||
| 33 | #if ("$!listString" != '') | ||
| 34 | #set ($solrList = " ") | ||
| 35 | #set ($sep = "[\s]*,[\s]*|[\s]+") | ||
| 36 | #if ("$onlyComma" == "true") | ||
| 37 | #set ($sep = ",") | ||
| 38 | #end | ||
| 39 | #foreach ($signedV in $listString.split($sep)) | ||
| 40 | #set ($signedV = $signedV.trim()) | ||
| 41 | #if ($signedV.startsWith("-") || $signedV.startsWith("+")) | ||
| 42 | #set ($sign = $signedV.charAt(0)) | ||
| 43 | #set ($v = $signedV.substring(1)) | ||
| 44 | #else | ||
| 45 | #set ($sign = "") | ||
| 46 | #set ($v = $signedV) | ||
| 47 | #end | ||
| 48 | #set ($escaped = "#escapeSolr($v)") | ||
| 49 | #if ($forAuthors && !$v.contains(":")) | ||
| 50 | #set ($solrList = "$solrList $sign*\:$escaped") | ||
| 51 | #end | ||
| 52 | #set ($solrList = "$solrList $sign$escaped") | ||
| 53 | #end | ||
| 54 | #set($solrList = $solrList.trim()) | ||
| 55 | #if (!$solrList.isEmpty()) | ||
| 56 | #set ($discard = $fq.add("$fieldName:($solrList)")) | ||
| 57 | #end | ||
| 58 | #end | ||
| 59 | #end | ||
| 60 | |||
| 61 | #macro (queryElements $type $options $return) | ||
| 62 | #set ($fq = []) | ||
| 63 | #if ($type == "attachment") | ||
| 64 | #set($typeField = "ATTACHMENT") | ||
| 65 | #set($authorField = "attauthor") | ||
| 66 | #set($versionField = "attdate") | ||
| 67 | #elseif ($type == "comment") | ||
| 68 | #set($typeField = "OBJECT_PROPERTY") | ||
| 69 | #set($authorField = "propertyvalue_") | ||
| 70 | #set($versionField = "_version_") | ||
| 71 | #else | ||
| 72 | #set($typeField = "DOCUMENT") | ||
| 73 | #set($authorField = "author") | ||
| 74 | #set($versionField = "date") | ||
| 75 | #end | ||
| 76 | #if (!$services.user.getProperties().displayHiddenDocuments()) | ||
| 77 | #set($discard = $fq.add("hidden:false")) | ||
| 78 | #end | ||
| 79 | #set ($query = $services.query.createQuery('name:*', 'solr')) | ||
| 80 | #set ($discard = $fq.add("type:$typeField")) | ||
| 81 | #if ($type == "comment") | ||
| 82 | #set ($discard = $fq.add('class:XWiki.XWikiComments')) | ||
| 83 | #set ($discard = $fq.add('propertyname:(author)')) | ||
| 84 | #end | ||
| 85 | #if ("$!options.wiki" != '') | ||
| 86 | #set ($discard = $fq.add("wiki:(#escapeSolr($options.wiki))")) | ||
| 87 | #end | ||
| 88 | #addSolrParameterListWithSign($options.spaces, $fq, "space_prefix", true) | ||
| 89 | #addSolrParameterListWithSign($options.authors, $fq, $authorField, false, true) | ||
| 90 | #if ($type == 'page') | ||
| 91 | #if ("$!options.tags" == '') | ||
| 92 | #set ($discard = $fq.add('class:(-Blog.BlogPostClass)')) | ||
| 93 | #else | ||
| 94 | #set ($discard = $fq.add('class:(-Blog.BlogPostClass AND XWiki.TagClass)')) | ||
| 95 | #addSolrParameterListWithSign($options.tags, $fq, "property.XWiki.TagClass.tags_string") | ||
| 96 | #end | ||
| 97 | #elseif ($type == 'blogpost') | ||
| 98 | #if ("$!options.tags" == '') | ||
| 99 | #set ($discard = $fq.add('class:(Blog.BlogPostClass)')) | ||
| 100 | #else | ||
| 101 | #set ($discard = $fq.add('class:(Blog.BlogPostClass AND XWiki.TagClass)')) | ||
| 102 | #addSolrParameterListWithSign($options.tags, $fq, "property.XWiki.TagClass.tags_string") | ||
| 103 | #end | ||
| 104 | #end | ||
| 105 | ## Query params | ||
| 106 | #set ($discard = $query.bindValue('fq', $fq)) | ||
| 107 | #set ($discard = $query.bindValue('sort', "$versionField desc")) | ||
| 108 | #set ($discard = $query.setOffset($options.offset)) | ||
| 109 | #set ($discard = $query.setLimit($options.limit)) | ||
| 110 | #set ($results = $query.execute()) | ||
| 111 | #if ($results.isEmpty()) | ||
| 112 | ## Return | ||
| 113 | #set ($return = $NULL) | ||
| 114 | #setVariable("$return" []) | ||
| 115 | #else | ||
| 116 | ## Fix some properties | ||
| 117 | #foreach ($element in $results[0].results) | ||
| 118 | #set ($discard = $element.put('type', $type)) | ||
| 119 | #if ($type == "attachment") | ||
| 120 | #set ($discard = $element.put('date', $element.attdate[0])) | ||
| 121 | #set ($discard = $element.put('title_', $element.filename[0])) | ||
| 122 | #set ($discard = $element.put('author', $element.attauthor[0])) | ||
| 123 | #elseif ($type == "comment") | ||
| 124 | #addCommentData($element) | ||
| 125 | #end | ||
| 126 | #end | ||
| 127 | ## Return | ||
| 128 | #set ($return = $NULL) | ||
| 129 | #setVariable("$return" $results[0].results) | ||
| 130 | #end | ||
| 131 | #end | ||
| 132 | |||
| 133 | #macro (addCommentData $comment) | ||
| 134 | ## Comment filters | ||
| 135 | #set ($fq = []) | ||
| 136 | #set ($query = $services.query.createQuery("name:$comment.name", 'solr')) | ||
| 137 | #set ($discard = $fq.add('type:OBJECT_PROPERTY')) | ||
| 138 | #set ($discard = $fq.add('class:XWiki.XWikiComments')) | ||
| 139 | #set ($discard = $fq.add("wiki:(#escapeSolr($comment.wiki))")) | ||
| 140 | #set ($discard = $fq.add("space:(#escapeSolr($comment.space))")) | ||
| 141 | #set ($discard = $fq.add("number:($comment.number)")) | ||
| 142 | ## Query params | ||
| 143 | #set ($discard = $query.bindValue('fq', $fq)) | ||
| 144 | #set ($discard = $query.setOffset(0)) | ||
| 145 | #set ($discard = $query.setLimit(3)) | ||
| 146 | ## Add data to result | ||
| 147 | #foreach ($property in $query.execute()[0].results) | ||
| 148 | #if ($property.propertyname == 'date') | ||
| 149 | #set ($date = $null) | ||
| 150 | #try('exception') | ||
| 151 | #set ($dateFormat = $datetool.getDateFormat("EEE MMM dd HH:mm:ss z yyyy", 'en', $datetool.getTimeZone())) | ||
| 152 | #set ($date = $dateFormat.parse($property.propertyvalue_[0])) | ||
| 153 | #end | ||
| 154 | #set ($discard = $comment.put('date', $date)) | ||
| 155 | #elseif ($property.propertyname == 'author') | ||
| 156 | #set ($discard = $comment.put('author', "$property.propertyvalue_[0]")) | ||
| 157 | #elseif ($property.propertyname == 'comment') | ||
| 158 | #set ($discard = $comment.put('comment', $property.propertyvalue_[0])) | ||
| 159 | #end | ||
| 160 | #end | ||
| 161 | #end | ||
| 162 | |||
| 163 | ## For each possible type of result, | ||
| 164 | ## fetch new results by executing the associated query | ||
| 165 | #macro (fetchResults_queries $queryResults $options) | ||
| 166 | #foreach ($type in $TYPES) | ||
| 167 | #set ($typeMetadata = $options.typesMetadata.get("$type")) | ||
| 168 | ## Customize options for query | ||
| 169 | #set ($typeOptions = $options.clone()) | ||
| 170 | ## Update offset based on previous used results count | ||
| 171 | ## If this is the first time fetching results, init some values | ||
| 172 | #if ("$!typeMetadata.offset" == '') | ||
| 173 | #set ($typeMetadata.offset = 0) | ||
| 174 | #set ($typeMetadata.hasNext = true) | ||
| 175 | #end | ||
| 176 | ## Set the limit to (limit + 1), so that we are able to tell | ||
| 177 | ## whether the query has more results to come after the set limit | ||
| 178 | #set ($typeOptions.offset = $typeMetadata.offset) | ||
| 179 | #set ($typeOptions.limit = $options.limit + 1) | ||
| 180 | ## Fetch results if needed | ||
| 181 | #if ($options.types.contains($type) && $typeMetadata.hasNext) | ||
| 182 | #queryElements($type $typeOptions $results) | ||
| 183 | #set ($typeMetadata.fetchedCountTotal = $results.size()) | ||
| 184 | #set ($discard = $queryResults.add($results)) | ||
| 185 | #else | ||
| 186 | #set ($typeMetadata.fetchedCountTotal = 0) | ||
| 187 | #end | ||
| 188 | #end | ||
| 189 | #end | ||
| 190 | |||
| 191 | ## Results can be of type document, attachment, or comment | ||
| 192 | ## As results are fetched from three different requests (1 for each type) | ||
| 193 | ## We need to merge those results together into one unique list of results. | ||
| 194 | ## During the merge, we have to make sure we respects two things: | ||
| 195 | ## - results are still sorted by most recent ones first | ||
| 196 | ## - the number of results is no more than the limit defined in the macro parameter | ||
| 197 | #macro (fetchResults_mergeSortLimit $resultLists $options $return) | ||
| 198 | #set ($results = []) | ||
| 199 | #foreach ($discard in [1..$options.limit]) | ||
| 200 | #set ($mostRecentDate = $NULL) | ||
| 201 | #set ($listIndex = $NULL) | ||
| 202 | ## Search throught the lists of results the most recent result | ||
| 203 | ## Lists are already sorted by themselves, | ||
| 204 | ## so we just need to compare the first result of each list | ||
| 205 | #foreach ($resultList in $resultLists) | ||
| 206 | #if ($resultList.size() > 0) | ||
| 207 | ## Flag result if it is the most recent found until now | ||
| 208 | #set ($date = $resultList[0].date) | ||
| 209 | #if ("$!mostRecentDate" == "" || $datetool.difference($mostRecentDate, $date).getMilliseconds() > 0) | ||
| 210 | #if ("$!date" != "") | ||
| 211 | #set ($mostRecentDate = $date) | ||
| 212 | #end | ||
| 213 | #set ($listIndex = $foreach.index) | ||
| 214 | #end | ||
| 215 | #end | ||
| 216 | #end | ||
| 217 | ## Use the most recent result found | ||
| 218 | #if ($listIndex == $NULL) | ||
| 219 | #break | ||
| 220 | #else | ||
| 221 | #set ($discard = $results.add($resultLists[$listIndex][0])) | ||
| 222 | #set ($discard = $resultLists[$listIndex].remove(0)) | ||
| 223 | #end | ||
| 224 | #end | ||
| 225 | ## Return | ||
| 226 | #set ($return = $NULL) | ||
| 227 | #setVariable("$return" $results) | ||
| 228 | #end | ||
| 229 | |||
| 230 | ## Resolve some properties for results | ||
| 231 | ## Properties computed here are the heavy ones to compute, | ||
| 232 | ## because they need to use the document object associated to the result. | ||
| 233 | ## So we compute those properties the latest possible, | ||
| 234 | ## only for the results we are sure to display | ||
| 235 | #macro (fetchResults_resolveProperties $results) | ||
| 236 | #foreach ($result in $results) | ||
| 237 | #set ($documentFullname = "${result.wiki}:${result.fullname}") | ||
| 238 | #set ($document = $xwiki.getDocument($documentFullname)) | ||
| 239 | #if ($result.type.equals('page') || $result.type.equals('blogpost')) | ||
| 240 | #set ($discard = $result.put('href', $document.getURL())) | ||
| 241 | #elseif ($result.type.equals('attachment')) | ||
| 242 | #set ($discard = $result.put('href', $document.getAttachmentURL($result.filename[0], 'viewattachrev'))) | ||
| 243 | #elseif ($result.type.equals('comment')) | ||
| 244 | #set ($discard = $result.put('href', "$document.getURL('view', 'viewer=comments')#xwikicomment_${result.number}")) | ||
| 245 | #set ($discard = $result.put('title_', $document.title)) | ||
| 246 | #end | ||
| 247 | #end | ||
| 248 | #end | ||
| 249 | |||
| 250 | #macro (fetchResults_updateMetadata $results $options) | ||
| 251 | ## Init needed metadata | ||
| 252 | #foreach ($type in $TYPES) | ||
| 253 | #set ($typeMetadata = $options.typesMetadata.get("$type")) | ||
| 254 | #set ($typeMetadata.fetchedCountUsed = 0) | ||
| 255 | #end | ||
| 256 | ## Find how many results of each type were used in the end | ||
| 257 | #foreach ($result in $results) | ||
| 258 | #set ($typeMetadata = $options.typesMetadata.get("$result.type")) | ||
| 259 | #set ($typeMetadata.fetchedCountUsed = $typeMetadata.fetchedCountUsed + 1) | ||
| 260 | #end | ||
| 261 | ## Update type metadata | ||
| 262 | #set ($options.typesMetadata.hasNext = false) | ||
| 263 | #foreach ($type in $TYPES) | ||
| 264 | #set ($typeMetadata = $options.typesMetadata.get("$type")) | ||
| 265 | #set ($typeMetadata.offset = $typeMetadata.offset + $typeMetadata.fetchedCountUsed) | ||
| 266 | ## Compute if there are next results | ||
| 267 | #set ($hasReachedLimit = $typeMetadata.fetchedCountTotal <= $options.limit) | ||
| 268 | #set ($hasUsedAllCurrentResults = $typeMetadata.fetchedCountTotal == $typeMetadata.fetchedCountUsed) | ||
| 269 | #set ($hasUsedEveryResults = $hasReachedLimit && $hasUsedAllCurrentResults) | ||
| 270 | #set ($typeMetadata.hasNext = !$hasUsedEveryResults) | ||
| 271 | #if ($typeMetadata.hasNext) | ||
| 272 | #set ($options.typesMetadata.hasNext = true) | ||
| 273 | #end | ||
| 274 | #end | ||
| 275 | #end | ||
| 276 | |||
| 277 | #macro (fetchResults $options $return) | ||
| 278 | #set ($queryResults = []) | ||
| 279 | #fetchResults_queries($queryResults, $options) | ||
| 280 | #fetchResults_mergeSortLimit($queryResults $options $results) | ||
| 281 | #fetchResults_resolveProperties($results) | ||
| 282 | #fetchResults_updateMetadata($results, $options) | ||
| 283 | ## Return | ||
| 284 | #set ($return = $NULL) | ||
| 285 | #setVariable("$return" $results) | ||
| 286 | #end | ||
| 287 | |||
| 288 | |||
| 289 | ## ----------------------------------------------------------------- | ||
| 290 | ## ----------------------------------------------------------------- | ||
| 291 | ## ----------------------------------------------------------------- | ||
| 292 | ## Display results macros | ||
| 293 | ## ----------------------------------------------------------------- | ||
| 294 | |||
| 295 | #set ($authorExceptions = ['XWiki.superadmin', 'XWiki.XWikiGuest']) | ||
| 296 | |||
| 297 | #macro (displayResultAuthorName $result) | ||
| 298 | <span class="result-last-author-name"> | ||
| 299 | #if ($authorExceptions.contains($result.author)) | ||
| 300 | $escapetool.xml($stringtool.substringAfter("$result.author", '.')) | ||
| 301 | #else | ||
| 302 | $xwiki.getUserName($result.author) | ||
| 303 | #end | ||
| 304 | </span> | ||
| 305 | #end | ||
| 306 | |||
| 307 | #macro (displayResultAuthorAvatarURL $result $options) | ||
| 308 | #set ($noavatarURL = $xwiki.getSkinFile('icons/xwiki/noavatar.png', true)) | ||
| 309 | #if ("$options.showProfilePic" == "true") | ||
| 310 | <span class="result-last-author-avatar"> | ||
| 311 | #if ($authorExceptions.contains($result.author)) | ||
| 312 | <img class="avatar avatar_48" src="$noavatarURL" alt="$escapetool.xml($result.author)" /> | ||
| 313 | #else | ||
| 314 | #resizedUserAvatar("$result.author", 48) | ||
| 315 | #end | ||
| 316 | </span> | ||
| 317 | #end | ||
| 318 | #end | ||
| 319 | |||
| 320 | #macro (displayResultTitle $result) | ||
| 321 | #set ($title = $result.title_) | ||
| 322 | #if ("$!title" == '') | ||
| 323 | #set ($title = $result.name) | ||
| 324 | #end | ||
| 325 | <span class="result-title"> | ||
| 326 | <a href="$result.href">$escapetool.xml("$title")</a> | ||
| 327 | </span> | ||
| 328 | #end | ||
| 329 | |||
| 330 | #macro (displayResultDate $result) | ||
| 331 | <span class="result-date"> | ||
| 332 | #if ($datetool.difference($datetool.getDate(), $result.date).getDays() == 0) | ||
| 333 | $services.date.displayTimeAgo($result.date) | ||
| 334 | #else | ||
| 335 | $xwiki.formatDate($result.date) | ||
| 336 | #end | ||
| 337 | </span> | ||
| 338 | #end | ||
| 339 | |||
| 340 | #macro (displayResultTypeIcon $result) | ||
| 341 | <span class="result-icon"> | ||
| 342 | $services.icon.renderHTML($$ICON_BY_TYPE[$result.type]) | ||
| 343 | </span> | ||
| 344 | #end | ||
| 345 | |||
| 346 | #macro (displayResult_concise $result $options) | ||
| 347 | <div class="result-item"> | ||
| 348 | #displayResultTypeIcon($result) | ||
| 349 | #displayResultTitle($result) | ||
| 350 | <span class="result-metadata"> | ||
| 351 | #displayResultDate($result) | ||
| 352 | <span class="separator">⋅</span> | ||
| 353 | <span class="result-last-author-text">$AUTHORING_TEXT_BY_TYPE[$result.type] by</span> | ||
| 354 | #displayResultAuthorAvatarURL($result, $options) | ||
| 355 | #displayResultAuthorName($result) | ||
| 356 | </span> | ||
| 357 | </div> | ||
| 358 | #end | ||
| 359 | |||
| 360 | #macro (displayResult_social $result $options) | ||
| 361 | #set ($normalizedAuthor = "$result.author") | ||
| 362 | #if (!$normalizedAuthor.contains(":")) | ||
| 363 | #set($normalizedAuthor = "$xcontext.getDatabase():$normalizedAuthor") | ||
| 364 | #end | ||
| 365 | #set ($socialThemeIsSameAuthor = $normalizedAuthor.equals($options.socialThemePreviousAuthor)) | ||
| 366 | #set ($options.socialThemePreviousAuthor = $normalizedAuthor) | ||
| 367 | #set ($socialThemeCollapseClass = '') | ||
| 368 | #if ($socialThemeIsSameAuthor) | ||
| 369 | #set ($socialThemeCollapseClass = 'collapsed') | ||
| 370 | #end | ||
| 371 | <div class="result-container $socialThemeCollapseClass"> | ||
| 372 | #displayResultAuthorAvatarURL($result, $options) | ||
| 373 | <div class="result-items"> | ||
| 374 | #displayResultAuthorName($result) | ||
| 375 | <div class="result-item"> | ||
| 376 | #displayResultTypeIcon($result) | ||
| 377 | #displayResultTitle($result) | ||
| 378 | <span class="result-metadata"> | ||
| 379 | <span class="result-last-author-text">$AUTHORING_TEXT_BY_TYPE[$result.type]</span> | ||
| 380 | #displayResultDate($result) | ||
| 381 | </span> | ||
| 382 | </div> | ||
| 383 | </div> | ||
| 384 | </div> | ||
| 385 | #end | ||
| 386 | |||
| 387 | #macro (displayResult_sidebar $result $options) | ||
| 388 | <div class="result-item"> | ||
| 389 | #displayResultTypeIcon($result) | ||
| 390 | #displayResultTitle($result) | ||
| 391 | <span class="result-metadata"> | ||
| 392 | <span class="result-last-author-text">$AUTHORING_TEXT_BY_TYPE[$result.type]</span> | ||
| 393 | #displayResultAuthorAvatarURL($result, $options) | ||
| 394 | #displayResultDate($result) | ||
| 395 | </span> | ||
| 396 | </div> | ||
| 397 | #end | ||
| 398 | |||
| 399 | #macro (displayResults $results $options) | ||
| 400 | #foreach ($result in $results) | ||
| 401 | <li> | ||
| 402 | #if ($options.theme == 'sidebar') | ||
| 403 | #displayResult_sidebar($result $options) | ||
| 404 | #elseif ($options.theme == 'social') | ||
| 405 | #displayResult_social($result $options) | ||
| 406 | #else | ||
| 407 | #displayResult_concise($result $options) | ||
| 408 | #end | ||
| 409 | </li> | ||
| 410 | #end | ||
| 411 | ## Add hidden div containing options and metadata from those results, | ||
| 412 | ## that will be passed back to the service when clicking the "show more" button | ||
| 413 | <div | ||
| 414 | class="recently-updated-options hidden" | ||
| 415 | data-options="$!escapetool.xml($jsontool.serialize($options))" | ||
| 416 | data-has-next="$!escapetool.xml($options.typesMetadata.hasNext)"> | ||
| 417 | </div> | ||
| 418 | #end | ||
| 419 | {{/velocity}} | ||
| 420 | |||
| 421 | {{velocity}} | ||
| 422 | #if ("$!request.fromAjax" == "true") | ||
| 423 | #set ($options = $jsontool.fromString($request.macroOptions)) | ||
| 424 | #fetchResults($options, $results) | ||
| 425 | #displayResults($results $options) | ||
| 426 | #end | ||
| 427 | {{/velocity}} |