From ff96bf4753a5c5e0e3e28c0ebca265e019ca0cc8 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Oct 2020 20:01:24 -0500 Subject: [PATCH 1/7] begin working on thumbnail (with viewer) for files, change to class based helper --- viewer/helpers.py | 50 +++++++++++++++++++++---------- viewer/templates/browse.html | 58 ++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 22 deletions(-) diff --git a/viewer/helpers.py b/viewer/helpers.py index 101871b..915ca15 100644 --- a/viewer/helpers.py +++ b/viewer/helpers.py @@ -1,8 +1,40 @@ +import mimetypes import os from typing import List, Tuple +from django.urls import reverse -def extra_listdir(path: str) -> List[Tuple[str, str]]: +from viewer.models import ServedDirectory + + +class File: + def __init__(self, head, tail): + self.filename = head + self.fullpath = os.path.join(tail, head) + self.mediatype = self.get_mediatype() + + def get_url(self, directory: ServedDirectory) -> str: + return reverse('file', args=(directory.id, self.filename)) + + def get_mediatype(self) -> str: + """Simple media type categorization based on the given mimetype""" + if os.path.exists(self.fullpath): + if os.path.isdir(self.fullpath): + return 'folder' + mimetype = mimetypes.guess_type(self.filename)[0] + if mimetype is not None: + if mimetype.startswith('image'): + return 'image' + elif mimetype.startswith('video'): + return 'video' + return 'file' + return 'unknown' + + def __str__(self) -> str: + return self.filename + + +def extra_listdir(path: str) -> List[File]: """ Helper function used for identifying file media type for every file in a given directory, extending os.listdir @@ -11,11 +43,7 @@ def extra_listdir(path: str) -> List[Tuple[str, str]]: """ files = [] for file in os.listdir(path): - mediatype = get_all_mediatype(file, path) - if mediatype == 'folder': - files.append((file, mediatype, os.path.join(path, file))) - else: - files.append((file, mediatype)) + files.append(File(file, path)) return files @@ -30,13 +58,3 @@ def get_all_mediatype(head: str, tail: str) -> str: if os.path.isfile(os.path.join(tail, head)): return get_file_mediatype(head) return "folder" - - -def get_file_mediatype(mimetype: str) -> str: - """Simple media type categorization based on the given mimetype""" - if mimetype is not None: - if mimetype.startswith('image'): - return 'image' - elif mimetype.startswith('video'): - return 'video' - return 'file' diff --git a/viewer/templates/browse.html b/viewer/templates/browse.html index 5cbecf4..e823b57 100644 --- a/viewer/templates/browse.html +++ b/viewer/templates/browse.html @@ -1,5 +1,52 @@ {% extends 'base.html' %} +{% block head %} + {{ block.super }} + +{% endblock head %} {% block content %} +
+
+

+ {{ directory.path }} + + {{ files|length }} files + +

+
+
+
+ {% for directory in directories %} +
+ +
+ + {% endfor %} + {% for file in files %} +
+ {% load thumbnail %} + +{# #} + + + {{ file.filename }} + + +
+ {% endfor %} +
+
+
+
{{ directory.path }} @@ -15,16 +62,15 @@ {% for file in files %} From 02c58830f7fc0d7ec2f7f49ab70fc9b1d217b8cc Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Oct 2020 20:37:31 -0500 Subject: [PATCH 2/7] create new File model for better Directory and file thumbnail state management --- viewer/migrations/0003_auto_20201031_2007.py | 18 +++++++ viewer/migrations/0004_file.py | 24 +++++++++ viewer/models.py | 56 ++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 viewer/migrations/0003_auto_20201031_2007.py create mode 100644 viewer/migrations/0004_file.py diff --git a/viewer/migrations/0003_auto_20201031_2007.py b/viewer/migrations/0003_auto_20201031_2007.py new file mode 100644 index 0000000..ba6b560 --- /dev/null +++ b/viewer/migrations/0003_auto_20201031_2007.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.2 on 2020-11-01 01:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('viewer', '0002_auto_20201031_0526'), + ] + + operations = [ + migrations.AlterField( + model_name='serveddirectory', + name='regex_pattern', + field=models.CharField(default='', max_length=100, verbose_name='RegEx Matching Pattern'), + ), + ] diff --git a/viewer/migrations/0004_file.py b/viewer/migrations/0004_file.py new file mode 100644 index 0000000..0999c99 --- /dev/null +++ b/viewer/migrations/0004_file.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.2 on 2020-11-01 01:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('viewer', '0003_auto_20201031_2007'), + ] + + operations = [ + migrations.CreateModel( + name='File', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('path', models.CharField(max_length=300, verbose_name='Full Filepath')), + ('filename', models.CharField(max_length=160, verbose_name='Filename')), + ('mediatype', models.CharField(max_length=30, verbose_name='Mediatype')), + ('directory', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='viewer.serveddirectory')), + ], + ), + ] diff --git a/viewer/models.py b/viewer/models.py index 9259d89..a7a1674 100644 --- a/viewer/models.py +++ b/viewer/models.py @@ -1,6 +1,13 @@ +import mimetypes +import os import uuid from django.db import models +from django.urls import reverse +from easy_thumbnails.alias import aliases + +if not aliases.get('small'): + aliases.set('small', {'size': (150, 80), 'crop': True}) class ServedDirectory(models.Model): @@ -20,5 +27,54 @@ class ServedDirectory(models.Model): regex = models.BooleanField('Directory RegEx Option', default=False) match_filename = models.BooleanField('RegEx Matches Against Filename', default=True) + def refresh(self): + """Refresh the directory listing to see if any new files have appeared and add them to the list.""" + for file in os.listdir(self.path): + # Check if the file has been entered before + entry = self.files.filter(filename__exact=file).first() + if entry is None: + # create the file entry + entry = File.create(full_path=os.path.join(self.path, file)) + entry.save() + def __str__(self): return self.path + + +class File(models.Model): + path = models.CharField('Full Filepath', max_length=300) + filename = models.CharField('Filename', max_length=160) + mediatype = models.CharField('Mediatype', max_length=30) + directory = models.ForeignKey(ServedDirectory, on_delete=models.CASCADE, related_name='files') + + @classmethod + def create(cls, full_path: str) -> 'File': + """Simple shortcut for creating a File database entry with just the path.""" + return File( + path=full_path, + filename=os.path.basename(full_path), + mediatype=File.get_mediatype(full_path), + directory=os.path.dirname(full_path) + ) + + def get_url(self, directory: ServedDirectory) -> str: + """Retrieve the direct URL for a given file.""" + return reverse('file', args=(directory.id, self.filename)) + + @staticmethod + def get_mediatype(path) -> str: + """Simple media type categorization based on the given path.""" + if os.path.exists(path): + if os.path.isdir(path): + return 'folder' + mimetype = mimetypes.guess_type(path)[0] + if mimetype is not None: + if mimetype.startswith('image'): + return 'image' + elif mimetype.startswith('video'): + return 'video' + return 'file' + return 'unknown' + + def __str__(self) -> str: + return self.filename From fbd623c0f2054bcde8871487a828579cd160dbd7 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Oct 2020 20:45:40 -0500 Subject: [PATCH 3/7] add subdirectories field with django-jsonfield, separate actual files and subdirectories, note some TODOs --- viewer/models.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/viewer/models.py b/viewer/models.py index a7a1674..ac5378b 100644 --- a/viewer/models.py +++ b/viewer/models.py @@ -1,7 +1,9 @@ +import json import mimetypes import os import uuid +import jsonfield from django.db import models from django.urls import reverse from easy_thumbnails.alias import aliases @@ -26,16 +28,30 @@ class ServedDirectory(models.Model): regex_pattern = models.CharField('RegEx Matching Pattern', max_length=100, default='') regex = models.BooleanField('Directory RegEx Option', default=False) match_filename = models.BooleanField('RegEx Matches Against Filename', default=True) + known_subdirectories = jsonfield.JSONField('Tracked Subdirectories JSON', default=[]) def refresh(self): """Refresh the directory listing to see if any new files have appeared and add them to the list.""" + # TODO: Implement separate recursive file matching implementation + # TODO: Implement RegEx filtering step + directories = [] for file in os.listdir(self.path): - # Check if the file has been entered before - entry = self.files.filter(filename__exact=file).first() - if entry is None: - # create the file entry - entry = File.create(full_path=os.path.join(self.path, file)) - entry.save() + file_path = os.path.join(self.path, file) + + if os.path.isfile(file_path): + # Check if the file has been entered before + entry = self.files.filter(filename__exact=file).first() + if entry is None: + # create the file entry + entry = File.create(full_path=file_path) + entry.save() + else: + # directory found, remember it + directories.append(file_path) + + # Dump subdirectories found + self.known_subdirectories = directories + def __str__(self): return self.path From d565e82c5003235cf56cd471ce0087cf4a37302e Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Oct 2020 20:59:00 -0500 Subject: [PATCH 4/7] added manual refresh functionality, fixed mistake with meaning of File directory field (foreign key not path) --- ...05_serveddirectory_known_subdirectories.py | 19 +++++++++++++ viewer/models.py | 8 ++---- viewer/templates/browse.html | 5 ++++ viewer/urls.py | 3 +- viewer/views.py | 28 +++++++++++-------- 5 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 viewer/migrations/0005_serveddirectory_known_subdirectories.py diff --git a/viewer/migrations/0005_serveddirectory_known_subdirectories.py b/viewer/migrations/0005_serveddirectory_known_subdirectories.py new file mode 100644 index 0000000..c321fd9 --- /dev/null +++ b/viewer/migrations/0005_serveddirectory_known_subdirectories.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.2 on 2020-11-01 01:46 + +from django.db import migrations +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('viewer', '0004_file'), + ] + + operations = [ + migrations.AddField( + model_name='serveddirectory', + name='known_subdirectories', + field=jsonfield.fields.JSONField(default=[], verbose_name='Tracked Subdirectories JSON'), + ), + ] diff --git a/viewer/models.py b/viewer/models.py index ac5378b..a86bdd8 100644 --- a/viewer/models.py +++ b/viewer/models.py @@ -1,4 +1,3 @@ -import json import mimetypes import os import uuid @@ -43,7 +42,7 @@ class ServedDirectory(models.Model): entry = self.files.filter(filename__exact=file).first() if entry is None: # create the file entry - entry = File.create(full_path=file_path) + entry = File.create(full_path=file_path, parent=self) entry.save() else: # directory found, remember it @@ -52,7 +51,6 @@ class ServedDirectory(models.Model): # Dump subdirectories found self.known_subdirectories = directories - def __str__(self): return self.path @@ -64,13 +62,13 @@ class File(models.Model): directory = models.ForeignKey(ServedDirectory, on_delete=models.CASCADE, related_name='files') @classmethod - def create(cls, full_path: str) -> 'File': + def create(cls, full_path: str, parent: ServedDirectory) -> 'File': """Simple shortcut for creating a File database entry with just the path.""" return File( path=full_path, filename=os.path.basename(full_path), mediatype=File.get_mediatype(full_path), - directory=os.path.dirname(full_path) + directory=parent ) def get_url(self, directory: ServedDirectory) -> str: diff --git a/viewer/templates/browse.html b/viewer/templates/browse.html index e823b57..384c2a6 100644 --- a/viewer/templates/browse.html +++ b/viewer/templates/browse.html @@ -21,6 +21,11 @@ {{ files|length }} files + + + + +

diff --git a/viewer/urls.py b/viewer/urls.py index a479eec..89e1f40 100644 --- a/viewer/urls.py +++ b/viewer/urls.py @@ -7,5 +7,6 @@ urlpatterns = [ path('add/', views.add, name='add'), path('add/submit', views.submit_new, name='add_submit'), path('/', views.browse, name='browse'), - path('//', views.file, name='file') + path('//', views.file, name='file'), + path('/refresh', views.refresh, name='refresh') ] diff --git a/viewer/views.py b/viewer/views.py index 712ed85..7fc9a99 100644 --- a/viewer/views.py +++ b/viewer/views.py @@ -4,7 +4,6 @@ from django.http import FileResponse, HttpResponseRedirect from django.shortcuts import render, get_object_or_404 from django.urls import reverse -from viewer.helpers import extra_listdir from viewer.models import ServedDirectory @@ -17,29 +16,29 @@ def index(request): def browse(request, directory_id): - dir = get_object_or_404(ServedDirectory, id=directory_id) + directory = get_object_or_404(ServedDirectory, id=directory_id) - if os.path.isdir(dir.path): + if os.path.isdir(directory.path): context = { - 'title': f'Browse - {os.path.dirname(dir.path)}', - 'files': extra_listdir(dir.path), - 'directory': dir + 'title': f'Browse - {os.path.dirname(directory.path)}', + 'files': directory.files.all(), + 'directory': directory } return render(request, 'browse.html', context) else: context = { 'title': 'Invalid Directory', 'message': 'The path this server directory points to {}.'.format( - 'exists, but is not a directory' if os.path.exists(dir.path) else 'does not exist' + 'exists, but is not a directory' if os.path.exists(directory.path) else 'does not exist' ) } return render(request, 'message.html', context, status=500) def file(request, directory_id, file): - dir = get_object_or_404(ServedDirectory, id=directory_id) - if os.path.isdir(dir.path): - path = os.path.join(dir.path, file) + directory = get_object_or_404(ServedDirectory, id=directory_id) + if os.path.isdir(directory.path): + path = os.path.join(directory.path, file) if os.path.exists(path): return FileResponse(open(path, 'rb')) else: @@ -51,7 +50,7 @@ def file(request, directory_id, file): context = { 'title': 'Invalid Directory', 'message': 'The path this server directory points to {}.'.format( - 'exists, but is not a directory' if os.path.exists(dir.path) else 'does not exist' + 'exists, but is not a directory' if os.path.exists(directory.path) else 'does not exist' ) } return render(request, 'message.html', context, status=500) @@ -64,6 +63,13 @@ def add(request): return render(request, 'add.html', context) +def refresh(request, directory_id): + """A simple API view for refreshing a directory. May schedule new thumbnail generation.""" + directory = get_object_or_404(ServedDirectory, id=directory_id) + directory.refresh() + return HttpResponseRedirect(reverse('browse', args=(directory.id,))) + + def submit_new(request): try: s = ServedDirectory( From 176b3273ce82ec365a2d7d9ececce8384f7bd087 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Oct 2020 22:47:10 -0500 Subject: [PATCH 5/7] basic cv2 & PIL based thumbnail generation helper --- viewer/helpers.py | 72 +++++++++++++---------------------------------- 1 file changed, 20 insertions(+), 52 deletions(-) diff --git a/viewer/helpers.py b/viewer/helpers.py index 915ca15..b37ee7b 100644 --- a/viewer/helpers.py +++ b/viewer/helpers.py @@ -1,60 +1,28 @@ -import mimetypes -import os -from typing import List, Tuple +""" +helpers.py -from django.urls import reverse - -from viewer.models import ServedDirectory +Contains helper functions used as refactored shortcuts or in order to separate code for readability. +""" +import cv2 +from PIL import Image -class File: - def __init__(self, head, tail): - self.filename = head - self.fullpath = os.path.join(tail, head) - self.mediatype = self.get_mediatype() - - def get_url(self, directory: ServedDirectory) -> str: - return reverse('file', args=(directory.id, self.filename)) - - def get_mediatype(self) -> str: - """Simple media type categorization based on the given mimetype""" - if os.path.exists(self.fullpath): - if os.path.isdir(self.fullpath): - return 'folder' - mimetype = mimetypes.guess_type(self.filename)[0] - if mimetype is not None: - if mimetype.startswith('image'): - return 'image' - elif mimetype.startswith('video'): - return 'video' - return 'file' - return 'unknown' - - def __str__(self) -> str: - return self.filename - - -def extra_listdir(path: str) -> List[File]: +def generate_thumbnail(path: str, output_path: str) -> None: """ - Helper function used for identifying file media type for every file in a given directory, extending os.listdir + Helper function which completes the process of generating thumbnails for both pictures and videos. - :param path: The path to the directory. - :return: A list of tuples, each containing two strings, the file or directory name, and the media type. + :param path: The absolute path to the file. + :param output_path: The absolute path to the intended output thumbnail file. """ - files = [] - for file in os.listdir(path): - files.append(File(file, path)) - return files + vidcap = cv2.VideoCapture(path) + success, image = vidcap.read() + if success: + img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + im_pil = Image.fromarray(img) + # Resize, crop, thumbnail + im_pil.thumbnail((300, 300)) + # im_pil.crop((0, 0, 200, 66)) + # im_pil.resize((200, 66)) -def get_all_mediatype(head: str, tail: str) -> str: - """ - A extra media type function supporting directories on top of files. - - :param head: The head of the path, usually the directory name or filename at the very end. - :param tail: The rest of the path, everything that comes before the head. - :return: A media type in string form. - """ - if os.path.isfile(os.path.join(tail, head)): - return get_file_mediatype(head) - return "folder" + im_pil.save(output_path) From 041e2a6e42ef5716c37eb604e76aecb692774e5f Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Oct 2020 23:01:59 -0500 Subject: [PATCH 6/7] specific thumbnail generation view + manual refresh thumbnail generation, progress bar with error handling and thumbnail deletion --- viewer/migrations/0006_file_thumbnail.py | 18 +++++++++ viewer/models.py | 47 +++++++++++++++++++++++- viewer/urls.py | 4 +- viewer/views.py | 10 ++++- 4 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 viewer/migrations/0006_file_thumbnail.py diff --git a/viewer/migrations/0006_file_thumbnail.py b/viewer/migrations/0006_file_thumbnail.py new file mode 100644 index 0000000..1d7f0bb --- /dev/null +++ b/viewer/migrations/0006_file_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.2 on 2020-11-01 03:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('viewer', '0005_serveddirectory_known_subdirectories'), + ] + + operations = [ + migrations.AddField( + model_name='file', + name='thumbnail', + field=models.CharField(default=None, max_length=160, null=True, verbose_name='Thumbnail Filename'), + ), + ] diff --git a/viewer/models.py b/viewer/models.py index a86bdd8..b167910 100644 --- a/viewer/models.py +++ b/viewer/models.py @@ -7,6 +7,8 @@ from django.db import models from django.urls import reverse from easy_thumbnails.alias import aliases +from viewer import helpers + if not aliases.get('small'): aliases.set('small', {'size': (150, 80), 'crop': True}) @@ -34,16 +36,22 @@ class ServedDirectory(models.Model): # TODO: Implement separate recursive file matching implementation # TODO: Implement RegEx filtering step directories = [] - for file in os.listdir(self.path): + files = os.listdir(self.path) + for i, file in enumerate(files): + print(f'{i} / {len(files)}') file_path = os.path.join(self.path, file) if os.path.isfile(file_path): # Check if the file has been entered before + entry: File entry = self.files.filter(filename__exact=file).first() if entry is None: # create the file entry entry = File.create(full_path=file_path, parent=self) entry.save() + else: + if entry.thumbnail is None: + entry.generate_thumbnail() else: # directory found, remember it directories.append(file_path) @@ -60,6 +68,7 @@ class File(models.Model): filename = models.CharField('Filename', max_length=160) mediatype = models.CharField('Mediatype', max_length=30) directory = models.ForeignKey(ServedDirectory, on_delete=models.CASCADE, related_name='files') + thumbnail = models.CharField('Thumbnail Filename', max_length=160, null=True, default=None) @classmethod def create(cls, full_path: str, parent: ServedDirectory) -> 'File': @@ -75,6 +84,42 @@ class File(models.Model): """Retrieve the direct URL for a given file.""" return reverse('file', args=(directory.id, self.filename)) + def delete_thumbnail(self) -> None: + if self.thumbnail: + try: + os.remove(os.path.join(self.thumbs_dir, self.thumbnail)) + self.thumbnail = None + self.save() + except FileNotFoundError: + pass + + @property + def thumbnail_url(self): + if self.thumbnail: + return f'/thumbnails/{self.thumbnail}' + return '' + + @property + def thumbs_dir(self): + return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'thumbnails') + + def generate_thumbnail(self) -> None: + # TODO: Add django-background-task scheduling + + self.delete_thumbnail() + + thumb_file = f'{uuid.uuid4()}.jpeg' + self.thumbnail = thumb_file + + # Generate thumbnail + try: + helpers.generate_thumbnail(self.path, os.path.join(self.thumbs_dir, self.thumbnail)) + except Exception: + print(f'Could not thumbnail: {self.filename}') + self.delete_thumbnail() + + self.save() + @staticmethod def get_mediatype(path) -> str: """Simple media type categorization based on the given path.""" diff --git a/viewer/urls.py b/viewer/urls.py index 89e1f40..c63ee4d 100644 --- a/viewer/urls.py +++ b/viewer/urls.py @@ -7,6 +7,8 @@ urlpatterns = [ path('add/', views.add, name='add'), path('add/submit', views.submit_new, name='add_submit'), path('/', views.browse, name='browse'), + path('/refresh', views.refresh, name='refresh'), path('//', views.file, name='file'), - path('/refresh', views.refresh, name='refresh') + path('//generate', views.generate_thumb, name='generate_thumb'), + ] diff --git a/viewer/views.py b/viewer/views.py index 7fc9a99..0d9fc4f 100644 --- a/viewer/views.py +++ b/viewer/views.py @@ -4,7 +4,7 @@ from django.http import FileResponse, HttpResponseRedirect from django.shortcuts import render, get_object_or_404 from django.urls import reverse -from viewer.models import ServedDirectory +from viewer.models import ServedDirectory, File def index(request): @@ -93,3 +93,11 @@ def submit_new(request): 'message': 'The directory you specified was not a valid directory, either it doesn\'t ' 'exist or it isn\'t a directory.'}) return HttpResponseRedirect(reverse('browse', args=(s.id,))) + + +def generate_thumb(request, directory_id, file: str): + """View for regenerating a thumbnail for a specific file.""" + directory = get_object_or_404(ServedDirectory, id=directory_id) + file = directory.files.filter(filename=file).first() + file.generate_thumbnail() + return HttpResponseRedirect(reverse('browse', args=(directory.id,))) From 8ef09f8618c162fd83c396cf37421abb53965e1f Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Oct 2020 23:03:37 -0500 Subject: [PATCH 7/7] update .gitignore for thumbnail files, add proper directory with icons to content, add proper thumbnail img loading with lazy loading for performance --- .gitignore | 1 + viewer/templates/browse.html | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 43660d2..9159069 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Repository specific .idea/** +viewer/static/thumbnails/** # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/viewer/templates/browse.html b/viewer/templates/browse.html index 384c2a6..296a96f 100644 --- a/viewer/templates/browse.html +++ b/viewer/templates/browse.html @@ -21,7 +21,7 @@ {{ files|length }} files - + @@ -32,15 +32,17 @@
{% for directory in directories %}
- + + + + {{ directory }}
{% endfor %} {% for file in files %}