diff --git a/lib/DAV/Browser/Plugin.php b/lib/DAV/Browser/Plugin.php index 5b453ac751..2da36b8da0 100644 --- a/lib/DAV/Browser/Plugin.php +++ b/lib/DAV/Browser/Plugin.php @@ -261,10 +261,11 @@ public function generateDirectoryIndex($path) $html = $this->generateHeader($path ?: '/', $path); $node = $this->server->tree->getNodeForPath($path); - if ($node instanceof DAV\ICollection) { - $html .= "

Nodes

\n"; - $html .= ''; + $subNodes = null; + $numSubNodes = 0; + $maxNodesAtTopSection = 20; + if ($node instanceof DAV\ICollection) { $subNodes = $this->server->getPropertiesForChildren($path, [ '{DAV:}displayname', '{DAV:}resourcetype', @@ -272,59 +273,13 @@ public function generateDirectoryIndex($path) '{DAV:}getcontentlength', '{DAV:}getlastmodified', ]); - - foreach ($subNodes as $subPath => $subProps) { - $subNode = $this->server->tree->getNodeForPath($subPath); - $fullPath = $this->server->getBaseUri().HTTP\encodePath($subPath); - list(, $displayPath) = Uri\split($subPath); - - $subNodes[$subPath]['subNode'] = $subNode; - $subNodes[$subPath]['fullPath'] = $fullPath; - $subNodes[$subPath]['displayPath'] = $displayPath; + $numSubNodes = count($subNodes); + if ($numSubNodes && $numSubNodes <= $maxNodesAtTopSection) { + $html .= $this->generateNodesSection($subNodes, $numSubNodes); + $numSubNodes = 0; } - uasort($subNodes, [$this, 'compareNodes']); - - foreach ($subNodes as $subProps) { - $type = [ - 'string' => 'Unknown', - 'icon' => 'cog', - ]; - if (isset($subProps['{DAV:}resourcetype'])) { - $type = $this->mapResourceType($subProps['{DAV:}resourcetype']->getValue(), $subProps['subNode']); - } - - $html .= ''; - $html .= ''; - $html .= ''; - $html .= ''; - - $buttonActions = ''; - if ($subProps['subNode'] instanceof DAV\IFile) { - $buttonActions = ''; - } - $this->server->emit('browserButtonActions', [$subProps['fullPath'], $subProps['subNode'], &$buttonActions]); - - $html .= ''; - $html .= ''; - } - - $html .= '
'.$this->escapeHTML($subProps['displayPath']).''.$this->escapeHTML($type['string']).''; - if (isset($subProps['{DAV:}getcontentlength'])) { - $html .= $this->escapeHTML($subProps['{DAV:}getcontentlength'].' bytes'); - } - $html .= ''; - if (isset($subProps['{DAV:}getlastmodified'])) { - $lastMod = $subProps['{DAV:}getlastmodified']->getTime(); - $html .= $this->escapeHTML($lastMod->format('F j, Y, g:i a')); - } - $html .= ''; - if (isset($subProps['{DAV:}displayname'])) { - $html .= $this->escapeHTML($subProps['{DAV:}displayname']); - } - $html .= ''.$buttonActions.'
'; } - $html .= '
'; $html .= '

Properties

'; $html .= ''; @@ -358,6 +313,11 @@ public function generateDirectoryIndex($path) $html .= "\n"; } + // If there are nodes and they are more than the max number to show at the top of the page + if ($numSubNodes) { + $html .= $this->generateNodesSection($subNodes, $numSubNodes); + } + $html .= $this->generateFooter(); $this->server->httpResponse->setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self';"); @@ -365,6 +325,73 @@ public function generateDirectoryIndex($path) return $html; } + /** + * Generates the Nodes section block of HTML. + * + * @param array $subNodes + * @param int $numSubNodes + * + * @return string + */ + protected function generateNodesSection($subNodes, $numSubNodes) + { + $html = '

Nodes ('.$numSubNodes.")

\n"; + $html .= '
'; + + foreach ($subNodes as $subPath => $subProps) { + $subNode = $this->server->tree->getNodeForPath($subPath); + $fullPath = $this->server->getBaseUri().HTTP\encodePath($subPath); + list(, $displayPath) = Uri\split($subPath); + + $subNodes[$subPath]['subNode'] = $subNode; + $subNodes[$subPath]['fullPath'] = $fullPath; + $subNodes[$subPath]['displayPath'] = $displayPath; + } + uasort($subNodes, [$this, 'compareNodes']); + + foreach ($subNodes as $subProps) { + $type = [ + 'string' => 'Unknown', + 'icon' => 'cog', + ]; + if (isset($subProps['{DAV:}resourcetype'])) { + $type = $this->mapResourceType($subProps['{DAV:}resourcetype']->getValue(), $subProps['subNode']); + } + + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + $buttonActions = ''; + if ($subProps['subNode'] instanceof DAV\IFile) { + $buttonActions = ''; + } + $this->server->emit('browserButtonActions', [$subProps['fullPath'], $subProps['subNode'], &$buttonActions]); + + $html .= ''; + $html .= ''; + } + + $html .= '
'.$this->escapeHTML($subProps['displayPath']).''.$this->escapeHTML($type['string']).''; + if (isset($subProps['{DAV:}getcontentlength'])) { + $html .= $this->escapeHTML($subProps['{DAV:}getcontentlength'].' bytes'); + } + $html .= ''; + if (isset($subProps['{DAV:}getlastmodified'])) { + $lastMod = $subProps['{DAV:}getlastmodified']->getTime(); + $html .= $this->escapeHTML($lastMod->format('F j, Y, g:i a')); + } + $html .= ''; + if (isset($subProps['{DAV:}displayname'])) { + $html .= $this->escapeHTML($subProps['{DAV:}displayname']); + } + $html .= ''.$buttonActions.'
'; + $html .= '
'; + + return $html; + } + /** * Generates the 'plugins' page. * @@ -589,8 +616,8 @@ protected function serveAsset($assetName) } /** - * Sort helper function: compares two directory entries based on type and - * display name. Collections sort above other types. + * Sort helper function: compares two directory entries based on type, last modified date + * and display name. Collections sort above other types. * * @param array $a * @param array $b @@ -607,8 +634,15 @@ protected function compareNodes($a, $b) ? (in_array('{DAV:}collection', $b['{DAV:}resourcetype']->getValue())) : false; - // If same type, sort alphabetically by filename: if ($typeA === $typeB) { + $lastModifiedA = isset($a['{DAV:}getlastmodified']) ? $a['{DAV:}getlastmodified']->getTime()->getTimestamp() : 0; + $lastModifiedB = isset($b['{DAV:}getlastmodified']) ? $b['{DAV:}getlastmodified']->getTime()->getTimestamp() : 0; + + if ($lastModifiedA !== $lastModifiedB) { + return $lastModifiedB <=> $lastModifiedA; // Descending order + } + + // If same type and last modified datetime, sort alphabetically by filename: return strnatcasecmp($a['displayPath'], $b['displayPath']); } diff --git a/tests/Sabre/DAV/Browser/PluginTest.php b/tests/Sabre/DAV/Browser/PluginTest.php index 6efb00b081..1f23ab996c 100644 --- a/tests/Sabre/DAV/Browser/PluginTest.php +++ b/tests/Sabre/DAV/Browser/PluginTest.php @@ -4,7 +4,9 @@ namespace Sabre\DAV\Browser; +use DateTime; use Sabre\DAV; +use Sabre\DAV\Xml\Property\GetLastModified; use Sabre\HTTP; class PluginTest extends DAV\AbstractServerTestCase @@ -82,8 +84,22 @@ public function testCollectionGetRoot() $body = $this->response->getBodyAsString(); self::assertTrue(false !== strpos($body, '/'), $body); + self::assertTrue(false !== strpos($body, 'Nodes (3)'), $body); self::assertTrue(false !== strpos($body, '<a href="/dir/">')); self::assertTrue(false !== strpos($body, '<span class="btn disabled">')); + + $dom = new \DOMDocument('1.0', 'utf-8'); + $dom->loadXML($body); + $xpath = new \DOMXPath($dom); + + $sections = $xpath->query('//section'); + + $firstSectionContainsNodes = false; + if ($sections->length > 0) { + $firstH1 = $xpath->query('.//h1[text()="Nodes (3)"]', $sections->item(0)); + $firstSectionContainsNodes = $firstH1->length > 0; + } + self::assertTrue($firstSectionContainsNodes, 'First section is listing Nodes (3)'); } public function testGETPassthru() @@ -182,4 +198,72 @@ public function testGetAssetEscapeBasePath() self::assertEquals(404, $this->response->getStatus(), 'Error: '.$this->response->getBodyAsString()); } + + public function testCollectionWithManyNodesGetSubdir() + { + $dir = $this->server->tree->getNodeForPath('dir2'); + $dir->createDirectory('subdir'); + $maxNodes = 20; // directory + 20 files + for ($i = 1; $i <= $maxNodes; ++$i) { + $dir->createFile("file$i"); + } + + $request = new HTTP\Request('GET', '/dir2'); + $this->server->httpRequest = ($request); + $this->server->exec(); + + $body = $this->response->getBodyAsString(); + self::assertTrue(false !== strpos($body, 'Nodes (21)'), $body); + self::assertTrue(false !== strpos($body, '<a href="/dir2/subdir/">')); + + $dom = new \DOMDocument('1.0', 'utf-8'); + $dom->loadXML($body); + $xpath = new \DOMXPath($dom); + + $sections = $xpath->query('//section'); + + $lastSectionContainsNodes = false; + if ($sections->length > 1) { + $lastH1 = $xpath->query('.//h1[text()="Nodes (21)"]', $sections->item($sections->length - 1)); + $lastSectionContainsNodes = $lastH1->length > 0; + } + self::assertTrue($lastSectionContainsNodes, 'Last section is listing Nodes (21)'); + } + + public function testCollectionNodesOrder() + { + $compareNodes = new \ReflectionMethod($this->plugin, 'compareNodes'); + $compareNodes->setAccessible(true); + + $day1 = new GetLastModified(new DateTime('2000-01-01')); + $day2 = new GetLastModified(new DateTime('2000-01-02')); + + $file1 = [ + '{DAV:}getlastmodified' => $day1, + 'displayPath' => 'file1', + ]; + $file1_clon = [ + '{DAV:}getlastmodified' => $day1, + 'displayPath' => 'file1', + ]; + $file2 = [ + '{DAV:}getlastmodified' => $day1, + 'displayPath' => 'file2', + ]; + $file2_newer = [ + '{DAV:}getlastmodified' => $day2, + 'displayPath' => 'file2', + ]; + + // Case 1: Newer node should come before older node + self::assertEquals(-1, $compareNodes->invoke($this->plugin, $file2_newer, $file2)); + self::assertEquals(1, $compareNodes->invoke($this->plugin, $file1, $file2_newer)); + + // Case 2: Nodes with same lastmodified but different displayPath (alphabetically) + self::assertEquals(-1, $compareNodes->invoke($this->plugin, $file1_clon, $file2)); + self::assertEquals(1, $compareNodes->invoke($this->plugin, $file2, $file1)); + + // Case 3: Nodes with same lastmodified and same displayPath + self::assertEquals(0, $compareNodes->invoke($this->plugin, $file1, $file1_clon)); + } }