Repository: wagtail/wagtail

Issue:
Feature Request:  Refine the creation of the default file title when uploading an image
As follows:

- Remove filename extension (including the period) from default image title
- Replace underscores and dashes with spaces.  These characters are many times found in image filenames but would not typically be in a title.
- Title case the title


Discussion:
Title case is an English thing to do and not very useful in other languages. We should keep other languages in mind when 'improving' this feature. What about multi language setups? It would be cool if the filename convert rules are configurable via settings file.
I would add that this feature request could apply to uploaded document filenames as well.
Here is a rough idea of how we could implement this, this only works currently on the multiple add (ie, not adding an image via a modal, it appears that the modal logic does not have any filename parsing yet).

This is a bit regex heavy so we may want to do it differently, but essentially it will turn
`filename    mobile-l_ots-_of -.messages.png` into `filename mobile l ots of messages`. removing additional spaces, replacing dash, underscore with spaces and removing the file extension (but only if it appears at the end).

The idea is that this is a static method on the abstract model so it can be overridden with custom image model implementations.

If this is sufficient, we could implement something similar for documents, or potentially all files but it might be good to do that on a separate issue.

I agree we should not title case anything, and doing this via settings would be quite verbose/confusing (maybe an array of regex strings?) I think that devs that want the power to change this would want 100% flexibility (hence a static method).


```python
# wagtail/images/models.py
import re # add import
#...
class AbstractImage(CollectionMember, index.Indexed, models.Model):
    #...
    @staticmethod
    def get_title_from_filename(filename = ''):
        # remove file extension
        filename = re.sub(r'\.[^/.]+$', ' ', filename)
        
        # remove special characters
        filename = re.sub(r'[-_.]+', ' ', filename) # could use \W|\D possibly

        # remove duplicate whitespace
        filename = re.sub(r' +', ' ', filename)

        # return without trailing/leading whitespace
        return filename.strip()

```

```python
# wagtail/images/views/multiple.py

def add(request):
    # ...
    if request.method == 'POST':
        #...
        # Build a form for validation
        form = ImageForm({
            'title': Image.get_title_from_filename(request.FILES['files[]'].name), # this is the only line replaced
```

Note: This would need tests, including tests to show overriding the static method and docs, plus find other locations where images are created and cover those (eg. in modal).
@DanAtShenTech  + @allcaps  - would love your feedback on the above
@lb Looks like a very elegant solution.  I hope to see this feature implemented soon!  I could do docs on this if you would like me to (or at least review proposed docs).
Thank you for pushing this forward. Great!

Isn't the filename to title conversion something that happens in the frontend? Via javascript?
That will give the user the ability to edit the title before the image is saved.

Documents follow the same pattern and should also have the filename to title in place.

I think these are the places (the model might be the same as the page, I'm not sure):
- Single image upload modal
- Single image upload page
- Multiple image upload
- Single document upload modal
- Single document upload page
- Multiple document upload

Maybe the filename to title can live in an admin hook that injects the right javascript OR an ajax call with endpoint (then the conversion can be done in Python). But both ways it is customisable by de integrator.

FYI: 
- os.path has great tools to strip filenames `os.path.basename(os.path.splitext("/path/should/not/be/filename.keep.lose")[0])
'filename.keep'`
- `string.replace('-', ' ')` is more Pythonic
- `" ".join("\tSomething with  various \n whitespace".split())` is a great way to remove any white.
@allcaps - yes I think you are right that sometimes the filename gets pre-populated in the front end before the upload happens. I'll need to do some more digging. 

Agree that regex overload is not ideal. Will adjust accordingly. 

Any other thoughts?
@allcaps 

I have put together a full proof of concept (works locally for me), not yet ready for PR (needs tests/ docs changes + confirmation of approach).

Could you and @DanAtShenTech take a look and let me know what you think.

https://github.com/wagtail/wagtail/compare/master...lb-:feature/4945-ability-to-customise-title-from-filename?expand=1

Basic approach is there is now a global utils at `window.wagtail.utils` which contains a function `getTitleFromFilename`, this can be changed to be a custom function and has the signature:

```
    /**
     * getTitleFromFilename expects a string as a result or any null/undefined if
     * the value is not meant to be updated at all.
     * @param {string=} filename 
     * @param {Object} data 
     * @param {string=} data.currentTitle - current entered value (if available), will be undefined for `ADD_MULTIPLE`
     * @param {Number=} data.maxLength - maximum title length (if available), will be undefined for `ADD_MULTIPLE`
     * @param {string} data.model - `DOCUMENT` or `IMAGE`
     * @param {string} data.widget - `CHOOSER_MODAL`, `ADD` or `ADD_MULTIPLE`
     * @returns {string=} if a string is not returned the title field will not be updated
     */
```

*Notes:*

* Docs - not sure where to put this yet, probably should go on wagtail_hooks page but maybe further down.
* JS approach, I could not find a simple and reliable (ie. not making lots of API calls) way to do the title logic server side that covered all six scenarios (docs/images + modal, add, multiple add). So this is a client side approach.
* The current function does not actually change the title, simply makes it equal to the filename - but this will already be an improvement as this basic feature was not even implemented across the board.
* We can then leave it to devs to do what they want with the filename (OR we can just do some basic parsing, like pulling out file extension & cleaning out some chars).
* Hooking into the global wagtail object, I tried a few different ways but this seems to be an approach used for a few other shared data / functions although I do not think they are documented anywhere. Personally not sure how else we can provide a hook to devs + also make sure it works well across all types of upload fields.
* Devs could technically easily override the `getPopulateTitleHandler` if they wanted but this would be not really useful and is a bit more specific to the usage implemented for the various field types.
* maxLength seemed like a useful thing to get but it will *not* be available for add multiple, this can be added if we feel it is important though
@allcaps + @DanAtShenTech 

I am hoping to finish this off soon + get back into this and sort out the tests.

Any other thoughts on my last comment and the POC code?

@DanielSwain  - would love your feedback on https://github.com/wagtail/wagtail/pull/5616 if you get the chance.

Resolving patch:
diff --git a/wagtail/admin/views/generic/multiple_upload.py b/wagtail/admin/views/generic/multiple_upload.py
--- a/wagtail/admin/views/generic/multiple_upload.py
+++ b/wagtail/admin/views/generic/multiple_upload.py
@@ -125,7 +125,7 @@ def post(self, request):
         # Build a form for validation
         upload_form_class = self.get_upload_form_class()
         form = upload_form_class({
-            'title': request.FILES['files[]'].name,
+            'title': request.POST.get('title', request.FILES['files[]'].name),
             'collection': request.POST.get('collection'),
         }, {
             'file': request.FILES['files[]'],
diff --git a/wagtail/images/views/multiple.py b/wagtail/images/views/multiple.py
--- a/wagtail/images/views/multiple.py
+++ b/wagtail/images/views/multiple.py
@@ -55,6 +55,7 @@ def get_context_data(self, **kwargs):
 
         context.update({
             'max_filesize': self.form.fields['file'].max_upload_size,
+            'max_title_length': self.form.fields['title'].max_length,
             'allowed_extensions': ALLOWED_EXTENSIONS,
             'error_max_file_size': self.form.fields['file'].error_messages['file_too_large_unknown_size'],
             'error_accepted_file_types': self.form.fields['file'].error_messages['invalid_image_extension'],


Test patch:
diff --git a/wagtail/images/tests/test_admin_views.py b/wagtail/images/tests/test_admin_views.py
--- a/wagtail/images/tests/test_admin_views.py
+++ b/wagtail/images/tests/test_admin_views.py
@@ -13,6 +13,7 @@
 
 from wagtail.admin.admin_url_finder import AdminURLFinder
 from wagtail.core.models import Collection, GroupCollectionPermission, get_root_collection_id
+from wagtail.images import get_image_model
 from wagtail.images.models import UploadedImage
 from wagtail.images.utils import generate_signature
 from wagtail.tests.testapp.models import CustomImage, CustomImageWithAuthor
@@ -1376,6 +1377,7 @@ def test_add_post(self):
         This tests that a POST request to the add view saves the image and returns an edit form
         """
         response = self.client.post(reverse('wagtailimages:add_multiple'), {
+            'title': 'test title',
             'files[]': SimpleUploadedFile('test.png', get_test_image_file().file.getvalue()),
         })
 
@@ -1386,22 +1388,60 @@ def test_add_post(self):
 
         # Check image
         self.assertIn('image', response.context)
-        self.assertEqual(response.context['image'].title, 'test.png')
+        self.assertEqual(response.context['image'].title, 'test title')
         self.assertTrue(response.context['image'].file_size)
         self.assertTrue(response.context['image'].file_hash)
 
+        # Check image title
+        image = get_image_model().objects.get(title='test title')
+        self.assertNotIn('title', image.filename)
+        self.assertIn('.png', image.filename)
+
         # Check form
         self.assertIn('form', response.context)
-        self.assertEqual(response.context['form'].initial['title'], 'test.png')
         self.assertEqual(response.context['edit_action'], '/admin/images/multiple/%d/' % response.context['image'].id)
         self.assertEqual(response.context['delete_action'], '/admin/images/multiple/%d/delete/' % response.context['image'].id)
+        self.assertEqual(response.context['form'].initial['title'], 'test title')
 
         # Check JSON
         response_json = json.loads(response.content.decode())
         self.assertIn('form', response_json)
+        self.assertIn('image_id', response_json)
         self.assertIn('success', response_json)
+        self.assertEqual(response_json['image_id'], response.context['image'].id)
         self.assertTrue(response_json['success'])
 
+    def test_add_post_no_title(self):
+        """
+        A POST request to the add view without the title value saves the image and uses file title if needed
+        """
+        response = self.client.post(reverse('wagtailimages:add_multiple'), {
+            'files[]': SimpleUploadedFile('no-title.png', get_test_image_file().file.getvalue()),
+        }, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+        # Check response
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['Content-Type'], 'application/json')
+
+        # Check image
+        self.assertIn('image', response.context)
+        self.assertTrue(response.context['image'].file_size)
+        self.assertTrue(response.context['image'].file_hash)
+
+        # Check image title
+        image = get_image_model().objects.get(title='no-title.png')
+        self.assertEqual('no-title.png', image.filename)
+        self.assertIn('.png', image.filename)
+
+        # Check form
+        self.assertIn('form', response.context)
+        self.assertEqual(response.context['form'].initial['title'], 'no-title.png')
+
+        # Check JSON
+        response_json = json.loads(response.content.decode())
+        self.assertIn('form', response_json)
+        self.assertIn('success', response_json)
+
     def test_add_post_nofile(self):
         """
         This tests that the add view checks for a file when a user POSTs to it