Gutenberg Contributors Get Organized to Move Block-Based Navigation Forward

The block-based Navigation editor screen got a status check last week as part of a Hallway Hangout meeting aimed at identifying what needs to happen to bring the screen out from behind the “experimental” flag. Once the Navigation screen is available by default in the Gutenberg plugin, the team working on the feature will be able to gather more feedback.

“The navigation block and navigation screen projects have been underway for quite some time and are a main target for 5.9,” Gutenberg lead developer Matias Ventura said in a post outlining the main focus items planned for the block editor in WordPress 5.9.

“A large part of the remaining work is to improve the user experience, reduce complexity, and test as much as possible on themes.”

Contributors participating in the meeting agreed that in order to move the Navigation screen out of the experimental stage, it will need to have UI/UX feature parity with what will soon be the classic Navigation screen (nav-menus.php). Participants came prepared with notes comparing features from the existing Navigation screen to the new block-based one. These are listed in a Google doc with a rough priority assignment.

Trudging through the many discrepancies between the two Navigation editing experiences allowed the team to update the project’s tracking issue on GitHub. It is being reorganized to focus on the tasks required to move the block-based Navigation screen out of “experimental” status. Nearly two dozen issues have been designated as high priority and 32 are marked as normal.

Work on the Navigation screen has stalled considerably since it was sidelined from consideration for WordPress 5.5 in July 2020. The previous tracking issue for the project became obsolete in February, forcing the creation of a new one that now aggregates all of the priority items for moving block-based Navigation forward. The recorded Hallway Hangout was a transparent discussion about what the UI is lacking and where it needs to go. It was a necessary, albeit tedious, accounting of issues that will get the project back on track.

The UI is still in a very rough state. Nesting is rudimentary. It’s not possible to assign menu locations. Adding menu items between existing items is very difficult, among a number of other critical issues. At this point, it would require an extraordinary effort to extract the block-based Navigation screen from its quagmiry state in order to have it ready for prime time in WordPress 5.9. The release is expected in December 2021 – just three months away.

David Smith, who facilitated the meeting, tempered expectations for the block-based Navigation screen with a few clarifications for what it will mean to take the feature out from under the “experimental” flag:

  • We wouldn’t commit to feature parity of developer focused APIs at this stage.
  • Removing “experimental” in the Gutenberg plugin, would not automatically make the feature ready for merging into Core (that won’t happen until WordPress 5.9 at the earliest).

While the block-based Navigation screen landing in 5.9 doesn’t seem likely, contributors’ recent organizational efforts put them well on their way towards getting the project out from under the “experimental” flag. Check out the recorded meeting for a deep dive into the Navigation screen UI and a glimpse of where it’s headed.

The Dogmatic Scrum Master Anti-Pattern

TL; DR: The Dogmatic Scrum Master Anti-Pattern

There are plenty of failure possibilities with Scrum. Since Scrum is an intentionally incomplete framework with a reasonable yet short "manual," this effect should not surprise anyone. For example, how do we communicate with members of the Scrum team that take the Scrum Guide literally? What about a dogmatic Scrum Master?

Join me and delve into the effects of Scrum dogmatism in less than 120 seconds.

Java Security – Reduce Attack Surface by Constructing Secure Objects

Introduction

There are many ways to design secure Java objects. In this tutorial, we are going to learn how to create secure Java objects by understanding Accessibility, Extensibility, and Immutability.

Goals

At the end of this tutorial, you would have learned:

  1. Understand how and why to limit accessibility to a class.
  2. Understand how and why to limit extensibility in a class.
  3. Understand why Immutability is important and how to create an immutable object.
Prerequisite Knowledge
  1. Basic Java.
Tools Required
  1. A Java IDE.
Limit Accessibility

Whenever we design a Java object, we should always strive to follow the principle of least privilege, which means we should limit access as much as possible. There are 4 access modifiers in Java, from least to most accessible: private, package-private, protected, and public. Developers should make use of these access modifiers to limit accessibility to data encapsulated within a class.

Let us take a look at a bad example.

    package com.example;

    import java.time.ZonedDateTime;
    import java.util.List;

    public class InsecureBirthdaysHolder {
       public List<ZonedDateTime> birthdays;
    }

The code snippet above uses the public access modifier for the birthdays List, which anybody can access.

Maybe the developer who wrote this piece of code thought that birthdays are not sensitive data as compared to social security numbers, so they did not bother limiting access to the list of birthdays. Little did they know, birthdays can usually be combined with some other publicly available information to impersonate a persons identity for authentication with insecure services.

A better way to write this code is:

    package com.example;

    import java.time.ZonedDateTime;
    import java.util.List;

    public class SecureBirthdaysHolder {
       private List<ZonedDateTime> birthdays;

       public boolean isCorrectBirthday(ZonedDateTime input){
           return birthdays.contains(input);
       }
    }

The class above looks a lot more secure than the previous one. This time, the birthdays property is private, therefore not visible outside of this class.

Callers can only check if a birthday is in the birthday list, but they are not able to get all birthdays anymore. This method can be coupled with a limit of 3-5 queries before locking out to prevent brute force attacks as well.

Restrict Extensibility

Java classes are open to inheritance by default, which means a malicious actor can override parent methods and change the behavior.

Consider the class below, which is used to authenticate users. This class is also open to inheritance by anybody.

    package com.example;

    import java.util.List;
    import java.util.UUID;

    public class Authenticate {
       private List<UUID> uuids;

       public boolean isCorrectUUID(UUID uuid){
           return uuids.contains(uuid);
       }
    }

A malicious actor can now subclass Authenticate and override the isCorrectUUID method to always return true, therefore allowing all authentication attempts to succeed, bypassing checking in with the uuids list.

    class MaliciousAuthenticate extends Authenticate {
       @Override
       public boolean isCorrectUUID(UUID uuid){
           return true;
       }
    }

To secure the Authenticate class, make it final, and any further attempt to subclass it will fail.

    public final class Authenticate {
       private List<UUID> uuids;

       public boolean isCorrectUUID(UUID uuid){
           return uuids.contains(uuid);
       }
    }
Immutability

Value classes should ensure that their internal values are immutable. If a reference property is mutable, attackers can modify its value even if it is marked as final. One example of a mutable API is the Date class in java.util.Date.

Consider a value class MutableBirthday that contains a Date property below:

    package com.example;

    import java.util.Date;

    public final class MutableBirthday {
       private final Date birthday = new Date(2000, 1, 1);

       public Date getBirthday() {
           return birthday;
       }

       @Override
       public String toString() {
           return "birthday= " + birthday;
       }
    }

Notice that there is no setter for the class above, but because the birthday object itself is mutable, a malicious caller can just call the getter() to get a reference to the birthday object, and then the attacker can modify the object at will.

    var mutableBirthday = new MutableBirthday();
    System.out.println(mutableBirthday);
    mutableBirthday.getBirthday().setDate(10);
    System.out.println(mutableBirthday);

The code above prints:

    birthday= Thu Feb 01 00:00:00 EST 3900
    birthday= Sat Feb 10 00:00:00 EST 3900

As we can see, the caller was able to modify the internal state of the MutableBirthday class.

A solution to the code above is to use the LocalDate/LocalDateTime class, which is immutable. The methods such as plusYears or minusDays actually return a completely new instance instead of the same instance like the Date class does.

The code below is a much better implementation of the Birthday class.

    package com.example;

    import java.time.LocalDate;

    public final class ImmutableBirthday {
       private final LocalDate birthday = LocalDate.of(2000, 1, 1);

       public LocalDate getBirthday() {
           return birthday;
       }

       @Override
       public String toString() {
           return "birthday= " + birthday;
       }
    }

At the call site, regardless of what methods are called on the birthday reference, the original instance state does not change.

    var immutableBirthday = new ImmutableBirthday();
    System.out.println(immutableBirthday);
    immutableBirthday.getBirthday().plusYears(10);
    System.out.println(immutableBirthday);

The code above returns:

    birthday= 2000-01-01
    birthday= 2000-01-01
Summary

We have learned how to limit accessibility, restrict extensibility and implement immutable data classes in this tutorial.

Regarding accessibility, modules can provide another level of access control as well. Immutable data classes also have the extra benefit of being simpler to deal with when concurrency is needed.

The project code can be downloaded here: https://github.com/dmitrilc/DaniWebJavaSecureObjects

Java Security – Restrict IO Permissions using SecurityManager

Introduction

In this tutorial, we will focus on how to manage IO permissions to read, write, and delete local files using the SecurityManager class.

Goals

At the end of this tutorial, you would have learned:

  1. What policy files are and how to use them.
  2. How to use policy files and the SecurityManager class to manage IO permissions.
Prerequisite Knowledge
  1. Basic Java.
  2. Java IO/NIO Concepts.
Tools Required
  1. A Java IDE that supports JDK 11 (I prefer NIO2/Path over older IO/File).
Concept Overview

The Java class SecurityManager can be used to limit the permissions of a program to perform tasks in these categories: File, Socket, Net, Security, Runtime, Property, AWT, Reflect, and Serializable. The focus of this tutorial would only be on File read, write, and delete permissions.

By default, a SecurityManager is not provided in the runtime environment. In order to use the SecurityManager, one must either set the runtime flag java.security.manager on the command line or provide an instance of SecurityManager in code. Our example will use the latter approach, which will create an instance of SecurityManager manually.

It is possible to extend the SecurityManager class, but that is out of scope of this tutorial, so the instance of SecurityManager that we will create later will be of the default implementation.

The java.policy File

When the SecurityManager instance is loaded, it will automatically look for java.policy files located at the paths below:

    java.home/lib/security/java.policy (Solaris/Linux)
    java.home\lib\security\java.policy (Windows)

    user.home/.java.policy (Solaris/Linux)
    user.home\.java.policy (Windows)

In case you are unaware, java.home and user.home are environment variables that can be retrieved with the System#getProperty Java method. For this tutorial, we will create a java.policy file at the user.home location (on Windows).

Go ahead and create a blank file java.policy under your user home directory. For example, if my username is John, then the java.policy file would be located at:

C:\Users\John\java.policy
The grant keyword

Copy and paste the text below into the java.policy file.

    grant {
      //permission java.io.FilePermission "c:/ioPractice/test.txt", "read";
      //permission java.io.FilePermission "c:/ioPractice/test.txt", "write";
      //permission java.io.FilePermission "c:/ioPractice/test.txt", "delete";
      //permission java.io.FilePermission "c:/ioPractice/test.txt", "read, write, delete";
    };

To allow read, write, or delete permissions for a file, we can use the grant keyword with the syntax above.

Note that all of the permissions are commented out at this stage. You can either combine all of the permissions together or add them separately.

Setting up the Test environment

To see how the SecurityManger works, we need to perform the steps below to set up our test environment.

  1. Create a folder called IOPractice in the C:\ Drive (or / if you are on Linux/Mac).

  2. Inside IOPractice, create a file called test.txt.

  3. Add the text Hello World! (without quotes) into test.txt.

  4. The path to your test.txt file should look like this

     C:\IOPractice\test.txt
  5. Create a new Java project.

  6. Create a new package com.example.

  7. Create a new Java class Entry.java inside the com.example package.

  8. Copy and paste the code below into Entry.java.

     package com.example;
    
     import java.io.IOException;
     import java.nio.file.Files;
     import java.nio.file.Path;
     import java.nio.file.StandardOpenOption;
    
     public class Entry {
        private static final SecurityManager SECURITY_MANAGER = new SecurityManager(); //1
    
        static {
            //System.setSecurityManager(SECURITY_MANAGER); //2
        }
    
        public static void main(String[] args) {
            Path testFile = Path.of("C:\\ioPractice\\test.txt"); //3
            readFile(testFile); //4
            writeFile(testFile); //5
            deleteFile(testFile); //6
        }
    
        private static void readFile(Path file) { //7
            try {
                String content = Files.readString(file);
                System.out.println(content);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        private static void writeFile(Path file) { //8
            try {
                Files.writeString(file, "Hello World Again!", StandardOpenOption.APPEND);
                readFile(file);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        private static void deleteFile(Path file) { //9
            try {
                Files.delete(file);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
     }

Here are the explanations for the code above:

  1. Line 1 is where we instantiate a SecurityManager object using the default implementation.
  2. But creating a SecurityManagerobject is not enough for the program to use it, we also have to set it manually at line 2, but that is commented out for now to show you the effect later.
  3. At line 3, we create a Path object with an absolute path to the test.txt file.
  4. Lines 4, 5, 6 are call sites for the 3 convenient methods readFile, writeFile, and deleteFile.
  5. Lines 7, 8, 9 are where the convenient methods were created.
Test the code

Because the SecurityManager has not been set yet, when we execute the code above, everything will run without any exception(but you must re-create the test.txt file each time because it would be deleted on a successful run).

Now uncomment out line 2, and we would see the code failing to run, throwing this exception:

Exception in thread "main" java.security.AccessControlException: access denied ("java.io.FilePermission" "C:\ioPractice\test.txt" "read")

If you uncomment the very first permission, read, in the java.policy file and save, you can see that your program is able to read the file content, but it is still unable to write to the file, so we would see an exception regarding the write permission:

Exception in thread "main" java.security.AccessControlException: access denied ("java.io.FilePermission" "C:\ioPractice\test.txt" "write")

Once again, after we uncomment the write permission in the file, our program can now write to the file successfully, but obviously we will still run into an exception with the delete permission.

    Exception in thread "main" java.security.AccessControlException: access denied ("java.io.FilePermission" "C:\ioPractice\test.txt" "delete")

Uncomment the delete permission in java.policy, and all methods will have permission to run successfully.

Solution Code
    package com.example;

    import java.io.IOException;
    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.nio.file.StandardOpenOption;

    public class Entry {
       private static final SecurityManager SECURITY_MANAGER = new SecurityManager(); //1

       static {
           System.setSecurityManager(SECURITY_MANAGER); //2
       }

       public static void main(String[] args) {
           Path testFile = Path.of("C:\\ioPractice\\test.txt"); //3
           readFile(testFile); //4
           writeFile(testFile); //5
           deleteFile(testFile); //6
       }

       private static void readFile(Path file) { //7
           try {
               String content = Files.readString(file);
               System.out.println(content);
           } catch (IOException e) {
               e.printStackTrace();
           }
       }

       private static void writeFile(Path file) { //8
           try {
               Files.writeString(file, "Hello World Again!", StandardOpenOption.APPEND);
               readFile(file);
           } catch (IOException e) {
               e.printStackTrace();
           }
       }

       private static void deleteFile(Path file) { //9
           try {
               Files.delete(file);
           } catch (IOException e) {
               e.printStackTrace();
           }
       }

    }
java.policy
grant {
  permission java.io.FilePermission "c:/ioPractice/test.txt", "read";
  permission java.io.FilePermission "c:/ioPractice/test.txt", "write";
  permission java.io.FilePermission "c:/ioPractice/test.txt", "delete";
  //permission java.io.FilePermission "c:/ioPractice/test.txt", "read, write, delete";
};
Summary

We have successfully learned how to enable the SecurityManager and manage IO permissions using the java.policy file.

Even though we have this capability, be aware that SecurityManager usage should be reserved as a last resort to secure your APIs. Your APIs should be designed for security in the first place.

The full project code can be downloaded here: https://github.com/dmitrilc/DaniWebJavaIoSecurityManager

Ask the Bartender: How To Find Project Partners?

I was wondering, where should I go if I want to find a developer to work with on an idea? I have an idea for a product. I know the market well, I’m part of the target audience, and I just need someone else that would be passionate and interested in the idea just as much as myself to have to agree to work on an open-source project. Tinder for project partners?

Derin

If I am being honest, your question reminds me of my cousin. He is what I call an “ideas” guy. Every few weeks or so, he calls me up with several new rough concepts of things that could make some money. Most of these conversations end with him asking if I could build him a website or an app. “We can split the profits 50/50,” he would say. I then tell him that I would rather be paid upfront and show him my rates. He can reap 100% of the profits down the line. He moves on to the next thing.

As I said, he has loads of ideas. His problem is with the follow-through. Anyone can dream up the perfect product or service. The stumbling blocks tend to be all the steps between concept and production.

It will be hard to sell any legitimate developer on a dream alone. Feeding, sheltering, and clothing one’s family comes first. You must have a way to pay for those things in almost all scenarios.

I have built projects on nothing but faith with others. Some have worked out. Most have not. Having cash on hand to pay for those months in development will provide a smidgeon of security for the programmer putting in the time to turn a dream into reality.

One of those projects I completed for my cousin in my younger and less-financially-intelligent days was a hunting and fishing “magazine” website. It actually saw some early success. The accompanying Facebook group grew to about 1,500 members in the first year or so. The audience was there, but there was no business plan. There were no products or services. No advertising deals. No payday coming for Justin.

I know 100s of developers who have been in the same boat at one point or another. Most of them wise up after the first project or two that goes nowhere.

Most dream projects that folks build will be personal itches that they are scratching. If there is no guarantee of a paycheck, it is something they are already passionate about. It sounds like that is the sort of person you want to work with, so you will need to find someone likely already motivated about the same market as you. Without knowing your particular market, it is hard to say where your starting point might be.

Let us assume your idea is the Next Big Thing. If you need someone on the development end, you should be prepared to take on the other roles to make the project successful. Do you have a business plan? What is your marketing approach? Do you have research that shows there is a market for the idea? Mockups of a potential UI? If you want to pitch someone on coming along for your journey, make sure you have done everything possible to show that it is something worthwhile.

Where to find that elusive partner, though? It tends to be easier to find open doors when you are involved in the WordPress development community. It is about making connections. That can be through blogging or joining a business-friendly community like Post Status. The more involved you are, the more people you can meet who may share your passions or be able to point you to others who do.

My usual advice would be to visit your local WordCamp to meet others in person. Of course, during this Covid-era, such conferences are virtual. There are tons of online-only events that can help you connect with people in the community.

Those human-to-human connections are your foundation, even if they are just over the web.

I do like the notion of a “Tinder” for WordPress project partners, or at least some type of networking place for folks. That could be a unique site and service you could build without a developer — just a domain, hosting plan, and a business model. It could even be the launchpad for finding the partner for your dream project.

If all else fails, there is always the DIY route — I am guessing you are not a developer. Many plugin authors have been born from a dream and not a lick of coding knowledge. I started in this industry primarily because I needed my website to have specific functionality. With no money to pay for it, I just started learning. I even enjoyed the art of programming and built a semi-successful business that I ran for over a decade. It is not some magical skill that only a certain few possess. Anyone can pick up the trade with time and effort.

If you do not have a developer in your corner, that may just need to be one of the hats you must wear as you kick-start your project. Once you start turning a profit, you can hire out that position.

I have probably not adequately answered your question. The truth is that anything I have ever done with success has started by connecting with others in the WordPress community. So, I am going to kick this can down to our readers. How would you approach finding the right development partner for a great idea?

10+ Best Heatmap WordPress Plugins

Best Heatmap WordPress Plugins and ServicesIf you’re a content marketing expert and tracking traffic is the norm, you’re probably already familiar with heatmaps. If you’re a beginner and don’t know what this heatmapping business is all about, you’re in the right place as well. No matter which side of the divide you stand, you will love today’s post where we […]

The post 10+ Best Heatmap WordPress Plugins appeared first on WPExplorer.

Solo.io Launches New Enterprise API Gateway and Updated Service Mesh

Solo.io, a cloud-native enterprise solutions provider, has announced the release of Gloo Mesh Gateway, an all-new enterprise API gateway, and the latest iteration of the company’s Gloo Mesh product. Solo hopes that this release will provide a unified platform for developers. 

Solo.io’s blog post announcing the new products outlined the evolution of the company’s technology in stating that:

Help writing a java anagram solver

I am attempting to write a program that will take a word as input from the user. The program will then find all of the words that can be used from the letters in the word the user inputted. A letter cannot be used twice if it only appears in the inputted word once, so if the inputted word has one o, no word with 2 o's can be formed. There will be a file that the code references that contains a dictionary of words that will be able to be formed. The code must be case insensitive. After a word is inputted, all of the anagrams for the word will be outputted, and the user will be asked for another word. This will continue until the user inputs a blank line.

I'm not sure how to approach writing this code, so any hints or help will be appreciated.

You want enabling CSS selectors, not disabling ones

I think this is good advice from Silvestar Bistrović:

An enabling selector is what I call a selector that does a job without disabling the particular rule.

The classic example is applying margin to everything, only to have to remove it from the final element because it adds space in a place you don’t want.

.card {
  margin-bottom: 1rem;
}

/* Wait but not on the last one!! */
.parent-of-cards :last-child {
  margin-bottom: 0;
}

You might also do…

/* "Disabling" rule */
.card:last-child {
  margin-bottom: 0;
}

But that’s maybe not as contextual as selecting from the parent.

Another variation is:

.card:not(:last-of-child) {
  margin-bottom: 1rem;
}

That’s what Silvestar refers to as “enabling” because you’re only ever applying this rule — not applying it and then removing it with another selector later. I agree that’s harder to understand and error-prone.

Yet another example is a scoped version of Lobotomized Owls:

/* Only space them out if they stack */
.card + .card {
  margin-top: 1rem;
}

I think gap is where this is all headed in the long term. Put the onus on the parent, not the child, and keep it an enabling selector:

.parent-of-cards {
  display: grid;
  gap: 1rem;
}

Direct Link to ArticlePermalink


The post You want enabling CSS selectors, not disabling ones appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.

Sending an id from ejs to nodejs

Hello ...

i have a table which has one button i need to send the id of that record in the table to the next page the will be directed to once i click the butoon i don't want to send it in the url ... so what i did is i tried to add the id in a hidden celli in my table and once i click my button i get it as req.body.hiddenId , but this is not working ...check below code ...

<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>
  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">

</head>

    <body >
        <%- include("header") %>
       <div>
           <h2 style="text-align: center; font-family: 'Courier New', Courier, monospace; font-weight: bold;font-size: 50px; ">
            <%=title%>
             </h2>

               <div class="row justify-content-center">
                <div class="col-auto">
                <!-- <a type="button" class="btn btn-success"href="/add_help_family">help</a> -->
                <br/>
                <br/>

               <table class="table table-dark table-striped ">
                   <thead>
                       <tr>
                           <th style="display:none;"></th>
                           <th>name</th>
                           <th>school value</th>

                       </tr>
                   </thead>
                   <tbody>
                       <%families.forEach(function(user){ %>
                        <tr>
                            <td style="display:none;" name="hiddenId" value="<%= user.familyId %>" ></td>
                            <td data-label="name"><%= user.name %></td>

                            <td data-label="schoolPaymentValue"><%= user.schoolPaymentValue %></td>

                            <td>

                                <a href="/helpFamilyForm" class="btn btn-sm btn-danger">New Help</a>
                            </td>

                        </tr>
                        <% }); %>

                   </tbody>
               </table>
               </div>
               </div>



       </div>
    </body>
</html>


--------------------------------------------------------------------------------------



app.get('/helpFamilyForm', (req, res)=>{
    var id = req.body.hiddenId;
    console.log("El id Hna : ",id);
    res.render('help_family_form',{
        title:"ZZZ",
        id : id
        }
        )

});

when i console log the id i got it undefined ...........

so what is wrong here ???

thanks in advance

An Enterprise Data Stack Using TypeDB

At Bayer, one of the largest pharmaceutical companies in the world, gaining a deep understanding of biological systems is paramount for the discovery of new therapeutics. This is inspiring the adoption of technologies that can accelerate and automate discovery, spanning all of the components of their data infrastructure, starting with the database.

The challenges posed within the data and discovery process are not unique to Bayer:

Some Essential Features of Data Mapping

The world is being ruled by data. In the current disruptive era, enterprises around the world are dealing with ever-increasing, highly complex, bidirectional data. To enable the smooth migration of data between myriad data sources and deliver value, organizations must employ effective data transformation strategies for better insight delivery and ultimately decision-making. This is easier said than done, however. 

Data has multiple formats, languages, and schemas, and analyzing this highly complex data to extract insights and make decisions is challenging. For this, organizations need to integrate all the data sources – and thus all the data. Data mapping has a huge role to play here. 

Image File Tagging App in Python/wxPython

Requires:

  1. Python version 3.8 or newer
  2. wxPython version 4.0 or newer
  3. exif module
  4. Windows with NTFS file system

To ensure you have the required packages please run the following

  1. python -m pip install --upgrade pip
  2. pip install exif
  3. pip install wxPython

I have lots of digitized photos on my computer. To keep track of them I use two programs. The first is called Everything Indexer. It's free, fast, and I use it daily. It maintains, in real time, a database of all files on your computer. The search window allows you to see a list, updated as you type, of all file names containing strings that you enter. Filters allow you to easily restrict the results to files of several pre-canned types (pictures, videos, executables, documents, etc.). Like Windows Explorer, it also supports a preview pane. I literally cannot say enough good things about it.

The other program is one I wrote myself. Without it (or something similar) Everything would be pointless (sorry for the pun).

Several programs are already available for tagging files, but most, or at least the useful ones, maintain a database in proprietary format which makes moving to another program difficult if not impossible. I used ACDC for years until it just got too bloated. I now use FastStone Image Viewer. It's small and fast, and except for rare cases (for which I use gimp), it handles all my imaging needs.

My program displays a list of all image files in a selected folder. It shows the selected file in a scaled panel plus two lists of tags, where tags are simply blank delimited strings generated from the selected file name. You can modify a file name by

  1. Typing new text in the current file name text area
  2. Deleting tags from the current tag list
  3. Adding tags from a common tag list

Let's have a look at the GUI.

tagger.jpg

  1. Folder tree showing all drives and folder currently available. Selecting a folder will cause the file list (2) to show all image files in the selected folder.

  2. A file list of all image files in the currently selected folder. This is a static list that is not updated with file changes outside the application. However, as you rename files within the app this list is updated (but not resorted) to reflect the changes.

  3. A status bar displaying the original name of the file if it has been changed. The first time you change a file name with this app, the original name is saved in an alternate data stream named ":tagger_undo". This assumes that the disk containing the files has NTFS. A support script (which I will eventually incorporate into this app) can be used to restore the original name. This name will not be updated on successive renames so the original name should always be available.

  4. A preview pane showing the currently selected image. If you move the mouse over the picture you will see the image's EXIF date/time stamp (if available) in a tooltip. You can zoom in and out using CTRL+Scroll Wheel.

  5. The name (mostly) that you are building by modifying tags. This name will be combined with the index (if non-zero) and the original file extension when you do a save. Moving the mouse over this control will display the proposed new file name in a tooltip.

  6. An index number. This number may change as you add and remove tags. If the current tag you are building would cause a file name conflict with an existing file then this index will be auto-incremented to generate a suffix of the form " (##)" to ensure the new file name is unique.

  7. A list of current tags created by extracting all of the blank delimited strings in the original file name. This list is displayed in the order in which the tags appear in the file name. You can copy tags from here to the common tag list by drag&drop or by right clicking on a tag. Pressing DEL, CTRL+D or CTRL+X deletes the current tag.

  8. A list of common tags that is maintained between runs. You can use this to keep tags that you plan to add to multiple files. This list is sorted. Copy tags to the current tag list by drag&drop or by right clicking on a tag. Pressing DEL, CTRL+D or CTRL+X deletes the current tag.

  9. Digital cameras generate their own file names which I like to strip out. Clicking this will try to do that. You may need to add patterns (regular expressions) for other cameras. Click Strip (or use CTRL+1) to try to massage the new name.

  10. If the current file has previously been renamed by this program, clicking Restore (or CTRL+R) will attempt to restore the original name. This will fail if a file already exists with that name.

  11. Save the file name changes. This will automatically select the next file in the list. Click this or press CTRL+S.

If you press CTRL+H you will be taken to the Windows "My Pictures" folder. Please be advised that if the folder contains a large number of files it may take a few seconds to repopulate the file list.

Adding a tag to the current list will automatically update the new name (4) and possibly the index (5).

Almost all GUI panels are implemented with splitter panels so you can resize most areas as needed. Program settings and common tags are maintained between runs in the file Tagger.ini. Be very careful if you edit this file manually.

Hotkeys

**F1**      - Help
**UP**      - Select previous file
**DOWN**    - Select next file
**CTRL+1**  - Strip camera crud
**CTRL+S**  - Save
**DEL**     - Delete the currently selected tag from the currently selected list
**CTRL+X**  - Same as DEL
**CTRL+R**  - Restore the original file name
**CTRL+H**  - Select the "My Pictures" folder

Most cameras save meta data in EXIF tags. This program uses the module "exif" to extract the date/time stamp if available.

This program is a work in progress. For example, it could benefit from more extensive error checking and further refactoring. Suggestions and bug reports are welcome.

The Code:

Tagger.py

"""
    Name:

        Tagger.pyw

    Description:

        This program implements an interface to easily maintain tags on image files.
        A tag in a file name will be any string of characters delimited by a blank.
        It is up to the user to avoid entering characters that are no valid in file
        names.

        The interface consists of three main panels. The left panel contains a tree
        from which all available drives and folders may be browsed. Selecting a folder
        from the tree will cause a list of all image files in that folder to be
        displayed in a list below the folder tree.

        Selecting a file from the file list will cause that image to be displayed
        in the upper portion of the centre panel. At the bottom of the centre panel
        the file name will be displayed in two parts, a base file name - no path, no
        file extension, and no index number. An index number is the end portion of a
        file of the form (##).

        The right panel consists of an upper list containing all of the tags in the
        currently selected file. The lower portion consists of a list of common tags
        that is maintained between runs.

        Tags can be added either by dragging tags from the common (lower) list to
        the current (upper) list, or by manually typing them into the file name
        below the displayed image. As new tags are added the file name and current
        tag list are kept in sync.

        Tags can also be copied between current and common lists by right clicking

        If you see a tag in the current list that you want to add to the common
        list you can drag it from the lower list to the upper list. Similarly, you
        can delete a tag from either list by selecting it, then pressing the delete
        key.

        As you make changes to the displayed file name the index will automatically
        be modified to avoid conflict with existing names in the file list.

        The first time a file is renamed with tagger, the original file name is saved
        in the alternate data stream ':tagger_undo'. If the current file has undo info
        available, it can be restored by clicking Restore or CTRL+R. If you find you 
        have totally botched a bunch of renames you can undo them by running 
        tagger_undo.py from a command shell in the picture folder. Please note that 
        running this without specifying a file or wildcard pattern will undo ALL 
        renames that were ever done to ALL files in that folder.


    Audit:

        2021-07-13  rj  original code

"""

TITLE = 'Image Tagger (v 1.2)'

ADS   = ':tagger_undo'

import os
import re
import wx
import inspect

import ImagePanel       as ip   # Control to display selected image file
import GetSpecialFolder as gs   # To determine Windows <My Pictures> folder
import perceivedType    as pt   # To determine (by extension) if file is an image file

from exif import Image          # For reading EXIF datetime stamp


DEBUG   = False
INSPECT = False

if INSPECT: 
    import wx.lib.mixins.inspection


def iam():
    """
    Returns the name of the function that called this function. This
    is useful for printing out debug information, for example, you only
    need to code:

        if DEBUG: print('enter',iam())
    """

    return inspect.getouterframes(inspect.currentframe())[1].function


class MyTarget(wx.TextDropTarget):
    """
    Drag & drop implementation between two list controls in single column
    report mode. The two lists must have a custom property, 'type' with the
    values 'curr' and 'comm' (for this app meaning current and common tags).
    """

    def __init__(self, srce, dest):

        wx.TextDropTarget.__init__(self) 

        self.srce = srce
        self.dest = dest

        if DEBUG: print(f'create target {srce.Name=} {dest.Name=}')

    def OnDropText(self, x, y, text):

        if DEBUG: print(iam(),f'{self.srce.Name=} {self.dest.Name=} {text=}')

        if self.dest.Name in ('curr', 'comm'):
            if self.dest.FindItem(-1,text) == -1:
                self.dest.InsertItem(self.dest.ItemCount, text)

        return True


class MyFrame(wx.Frame):

    def __init__(self, *args, **kwds):

        kwds["style"] = kwds.get("style", 0) | wx.DEFAULT_FRAME_STYLE
        wx.Frame.__init__(self, *args, **kwds)

        self.SetSize((1400, 800))
        self.SetTitle(TITLE)

        self.status = self.CreateStatusBar(2)
        self.status.SetStatusWidths([100, -1])        

        # split1 contains the folder/file controls on the left and all else on the right

        self.split1 = wx.SplitterWindow(self, wx.ID_ANY)
        self.split1.SetMinimumPaneSize(250)

        self.split1_pane_1 = wx.Panel(self.split1, wx.ID_ANY)

        sizer_1 = wx.BoxSizer(wx.HORIZONTAL)

        # split2 contains the folder tree on the top, and the file list on the bottom

        self.split2 = wx.SplitterWindow(self.split1_pane_1, wx.ID_ANY)
        self.split2.SetMinimumPaneSize(200)
        sizer_1.Add(self.split2, 1, wx.EXPAND, 0)

        self.split2_pane_1 = wx.Panel(self.split2, wx.ID_ANY)

        sizer_2 = wx.BoxSizer(wx.HORIZONTAL)

        self.folders = wx.GenericDirCtrl(self.split2_pane_1, wx.ID_ANY, style=wx.DIRCTRL_DIR_ONLY)
        sizer_2.Add(self.folders, 1, wx.EXPAND, 0)

        self.split2_pane_2 = wx.Panel(self.split2, wx.ID_ANY)

        sizer_3 = wx.BoxSizer(wx.HORIZONTAL)

        self.lstFiles = wx.ListCtrl(self.split2_pane_2, wx.ID_ANY, style=wx.LC_NO_HEADER | wx.LC_REPORT | wx.LC_SINGLE_SEL)
        self.lstFiles.AppendColumn('', width=600)
        self.lstFiles.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Courier New"))
        sizer_3.Add(self.lstFiles, 1, wx.EXPAND, 0)

        self.split1_pane_2 = wx.Panel(self.split1, wx.ID_ANY)

        sizer_4 = wx.BoxSizer(wx.HORIZONTAL)

        # split3 contains the image display and controls on the left, and the current/common tags on the right

        self.split3 = wx.SplitterWindow(self.split1_pane_2, wx.ID_ANY)
        self.split3.SetMinimumPaneSize(150)
        sizer_4.Add(self.split3, 1, wx.EXPAND, 0)

        self.split3_pane_1 = wx.Panel(self.split3, wx.ID_ANY)

        sizer_5 = wx.BoxSizer(wx.VERTICAL)

        self.pnlImage = ip.ImagePanel(self.split3_pane_1, wx.ID_ANY)
        sizer_5.Add(self.pnlImage, 1, wx.EXPAND, 0)

        sizer_6 = wx.BoxSizer(wx.HORIZONTAL)
        sizer_5.Add(sizer_6, 0, wx.EXPAND, 0)

        self.btnStrip = wx.Button(self.split3_pane_1, wx.ID_ANY, "Strip")
        self.btnStrip.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
        self.btnStrip.SetToolTip('Click or CTRL+1 to remove HH-MM, DSC*, IMG* tags')
        sizer_6.Add(self.btnStrip, 1, wx.ALL | wx.EXPAND, 4)

        sizer_6.Add((10,-1), 0, 0, 0)

        self.btnRestore = wx.Button(self.split3_pane_1, wx.ID_ANY, "Restore")
        self.btnRestore.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
        self.btnRestore.SetToolTip('Click or CTRL+R to restore original name')
        self.btnRestore.Disable()
        sizer_6.Add(self.btnRestore, 1, wx.ALL | wx.EXPAND, 4)

        sizer_6.Add((10,-1), 0, 0, 0)

        self.btnSave = wx.Button(self.split3_pane_1, wx.ID_ANY, "Save")
        self.btnSave.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
        self.btnSave.SetToolTip ('Click or CTRL+S to save file name changes')
        sizer_6.Add(self.btnSave, 1, wx.ALL | wx.EXPAND, 4)

        #Delete tag available by hotkey only
        self.btnDelete = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Delete')
        self.btnDelete.Visible = False
        self.Bind(wx.EVT_BUTTON, self.evt_DeleteTag, self.btnDelete)

        #Home available by hotkey only
        self.btnHome = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Home')
        self.btnHome.Visible = False
        self.Bind(wx.EVT_BUTTON, self.evt_Home, self.btnHome)

        #Next and prev available by hotkey only
        self.btnPrev = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Prev')
        self.btnPrev.Visible = False
        self.Bind(wx.EVT_BUTTON, self.evt_Prev, self.btnPrev)

        self.btnNext = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Next')
        self.btnNext.Visible = False
        self.Bind(wx.EVT_BUTTON, self.evt_Next, self.btnNext)

        #Help available by hotkey only
        self.btnHelp = wx.Button(self.split3_pane_1, wx.ID_ANY, 'Help')
        self.btnHelp.Visible = False
        self.Bind(wx.EVT_BUTTON, self.evt_Help, self.btnHelp)

        sizer_7 = wx.BoxSizer(wx.HORIZONTAL)
        sizer_5.Add(sizer_7, 0, wx.EXPAND, 0)

        self.txtName = wx.TextCtrl(self.split3_pane_1, wx.ID_ANY, "", style=wx.TE_PROCESS_ENTER)
        self.txtName.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
        self.txtName.SetMinSize((400, -1))        
        sizer_7.Add(self.txtName, 1, wx.BOTTOM | wx.EXPAND | wx.LEFT | wx.RIGHT, 4)

        self.txtIndex = wx.TextCtrl(self.split3_pane_1, wx.ID_ANY, "")
        self.txtIndex.SetMinSize((40, -1))
        self.txtIndex.SetMaxSize((40, -1))
        self.txtIndex.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
        sizer_7.Add(self.txtIndex, 0, wx.BOTTOM | wx.EXPAND | wx.LEFT | wx.RIGHT, 4)

        self.split3_pane_2 = wx.Panel(self.split3, wx.ID_ANY)

        sizer_8 = wx.BoxSizer(wx.HORIZONTAL)

        # split4 contains the current tags on the top and the common tags on the bottom

        self.split4 = wx.SplitterWindow(self.split3_pane_2, wx.ID_ANY)
        self.split4.SetMinimumPaneSize(20)
        sizer_8.Add(self.split4, 1, wx.EXPAND, 0)

        self.split4_pane_1 = wx.Panel(self.split4, wx.ID_ANY)

        sizer_9 = wx.BoxSizer(wx.HORIZONTAL)

        self.lstCurr = wx.ListCtrl(self.split4_pane_1, wx.ID_ANY, name='curr', style=wx.LC_NO_HEADER | wx.LC_REPORT | wx.LC_SINGLE_SEL)
        self.lstCurr.AppendColumn('', width=600)
        self.lstCurr.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
        self.lstCurr.SetToolTip ('Drag to lower pane or right-click to save in common tags\n(DEL or CTRL-X to delete tag)')
        sizer_9.Add(self.lstCurr, 1, wx.EXPAND, 0)

        self.split4_pane_2 = wx.Panel(self.split4, wx.ID_ANY)

        sizer_10 = wx.BoxSizer(wx.HORIZONTAL)

        self.lstComm = wx.ListCtrl(self.split4_pane_2, wx.ID_ANY, name='comm', style=wx.LC_NO_HEADER | wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_SORT_ASCENDING)
        self.lstComm.AppendColumn('', width=600)
        self.lstComm.SetFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, 0, "Segoe UI"))
        self.lstComm.SetToolTip ('Drag to upper pane or right-click to add to current tags\n(DEL or CTRL-X to delete tag)')
        sizer_10.Add(self.lstComm, 1, wx.EXPAND, 0)

        self.split4_pane_2.SetSizer(sizer_10)
        self.split4_pane_1.SetSizer(sizer_9)

        self.split4.SplitHorizontally(self.split4_pane_1, self.split4_pane_2)

        self.split3_pane_2.SetSizer(sizer_8)
        self.split3_pane_1.SetSizer(sizer_5)

        self.split3.SplitVertically(self.split3_pane_1, self.split3_pane_2)

        self.split1_pane_2.SetSizer(sizer_4)
        self.split2_pane_2.SetSizer(sizer_3)
        self.split2_pane_1.SetSizer(sizer_2)

        self.split2.SplitHorizontally(self.split2_pane_1, self.split2_pane_2)

        self.split1_pane_1.SetSizer(sizer_1)

        self.split1.SplitVertically(self.split1_pane_1, self.split1_pane_2)

        self.Layout()

        self.Bind(wx.EVT_DIRCTRL_SELECTIONCHANGED, self.evt_FolderSelected, self.folders)
        self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.evt_FileSelected, self.lstFiles)
        self.Bind(wx.EVT_BUTTON, self.evt_Strip, self.btnStrip)
        self.Bind(wx.EVT_BUTTON, self.evt_Restore, self.btnRestore)
        self.Bind(wx.EVT_BUTTON, self.evt_Save, self.btnSave)
        self.Bind(wx.EVT_TEXT, self.evt_NameChanged, self.txtName)
        self.Bind(wx.EVT_TEXT_ENTER, self.evt_TextEnter, self.txtName)
        self.Bind(wx.EVT_LIST_BEGIN_DRAG, self.evt_StartDrag, self.lstCurr)
        self.Bind(wx.EVT_LIST_ITEM_RIGHT_CLICK, self.evt_RightClick, self.lstCurr)        
        self.Bind(wx.EVT_LIST_DELETE_ITEM, self.evt_TagDeleted, self.lstCurr)
        self.Bind(wx.EVT_LIST_INSERT_ITEM, self.evt_TagAdded, self.lstCurr)
        self.Bind(wx.EVT_LIST_BEGIN_DRAG, self.evt_StartDrag, self.lstComm)
        self.Bind(wx.EVT_LIST_ITEM_RIGHT_CLICK, self.evt_RightClick, self.lstComm)
        self.Bind(wx.EVT_LIST_DELETE_ITEM, self.evt_TagDeleted, self.lstComm)
        self.Bind(wx.EVT_LIST_INSERT_ITEM, self.evt_TagAdded, self.lstComm)
        self.Bind(wx.EVT_CLOSE, self.evt_Close, self)

        #Define drag & drop
        dtCurr = MyTarget(self.lstCurr, self.lstComm) 
        self.lstComm.SetDropTarget(dtCurr) 
        self.Bind(wx.EVT_LIST_BEGIN_DRAG,  self.evt_StartDrag,  self.lstCurr)
        self.Bind(wx.EVT_LIST_INSERT_ITEM, self.evt_TagAdded,   self.lstCurr)
        self.Bind(wx.EVT_LIST_DELETE_ITEM, self.evt_TagDeleted, self.lstCurr)

        dtComm = MyTarget(self.lstComm, self.lstCurr) 
        self.lstCurr.SetDropTarget(dtComm) 
        self.Bind(wx.EVT_LIST_BEGIN_DRAG,  self.evt_StartDrag,  self.lstComm)
        self.Bind(wx.EVT_LIST_DELETE_ITEM, self.evt_TagDeleted, self.lstComm)

        #Define hotkeys
        hotkeys = [wx.AcceleratorEntry() for i in range(9)]
        hotkeys[0].Set(wx.ACCEL_NORMAL, wx.WXK_DELETE, self.btnDelete.Id)
        hotkeys[1].Set(wx.ACCEL_CTRL, ord('X'), self.btnDelete.Id)
        hotkeys[2].Set(wx.ACCEL_CTRL, ord('S'), self.btnSave.Id)
        hotkeys[3].Set(wx.ACCEL_CTRL, ord('1'), self.btnStrip.Id)
        hotkeys[4].Set(wx.ACCEL_CTRL, ord('R'), self.btnRestore.Id)
        hotkeys[5].Set(wx.ACCEL_CTRL, ord('H'), self.btnHome.Id)
        hotkeys[6].Set(wx.ACCEL_NORMAL, wx.WXK_DOWN, self.btnNext.Id)
        hotkeys[7].Set(wx.ACCEL_NORMAL, wx.WXK_UP, self.btnPrev.Id)
        hotkeys[8].Set(wx.ACCEL_NORMAL, wx.WXK_F1, self.btnHelp.Id)
        accel = wx.AcceleratorTable(hotkeys)
        self.SetAcceleratorTable(accel)

        #Define state indicators
        self.currfile = None  #current unqualified file name                    
        self.currindx = None  #index of select file in file list                
        self.currextn = None  #current file extension                           
        self.currbase = None  #current unqualified base file name (no extension)
        self.fullfile = None  #current fully qualified file name                
        self.original = None  #original file name from ADS if available         

        #Set default config
        self.currpath = gs.myPictures()

        self.split1.SetSashPosition(300)
        self.split2.SetSashPosition(300)
        self.split3.SetSashPosition(900)
        self.split4.SetSashPosition(400)

        #Load last used config
        self.LoadConfig()

        self.folders.ExpandPath(self.currpath)

        if INSPECT: wx.lib.inspection.InspectionTool().Show()

    def LoadConfig(self):
        """Load the settings from the previous run."""     
        if DEBUG: print('enter',iam())

        self.config = os.path.splitext(__file__)[0] + ".ini"

        if DEBUG: print(f'LoadConfig {self.config=}')

        try:
            with open(self.config,'r') as file:
                for line in file.read().splitlines():
                    if DEBUG: print(line)

                    #Disable event handling during common tag restore
                    self.EvtHandlerEnabled = False
                    exec(line)
                    self.EvtHandlerEnabled = True
        except: 
            print("Error during ini read")
            self.EvtHandlerEnabled = True

    def SaveConfig(self):
        """Save the current settings for the next run"""        
        if DEBUG: print('enter',iam())

        x,y = self.GetPosition()
        w,h = self.GetSize()

        with open(self.config,'w') as file:

            file.write('#Window size and position\n\n')
            file.write('self.SetPosition((%d,%d))\n' % (x,y))
            file.write('self.SetSize((%d,%d))\n' % (w,h))

            file.write('\n#Splitter settings\n\n')
            file.write('self.split1.SetSashPosition(%d)\n' % (self.split1.GetSashPosition()))
            file.write('self.split2.SetSashPosition(%d)\n' % (self.split2.GetSashPosition()))
            file.write('self.split3.SetSashPosition(%d)\n' % (self.split3.GetSashPosition()))
            file.write('self.split4.SetSashPosition(%d)\n' % (self.split4.GetSashPosition()))

            file.write('\n#Last folder\n\n')
            file.write('self.currpath = "%s\"\n' % self.currpath.replace("\\","/"))

            file.write('\n#Common tags\n\n')
            for indx in range(self.lstComm.ItemCount):
                line = 'self.lstComm.InsertItem(%d,"%s")\n' % (indx,self.lstComm.GetItemText(indx))
                file.write(line)

    def evt_FolderSelected(self, event):
        """User selected a folder from the directory tree"""
        if DEBUG: print('enter',iam())

        self.lstFiles.DeleteAllItems()  #Clear file list        
        self.pnlImage.Clear()           #Clear displayed image  
        self.txtName.Clear()            #Clear new name         
        self.txtIndex.Value = '0'       #Reset index            
        self.lstCurr.DeleteAllItems()   #Clear current tags     

        #reset current state indicators
        self.currpath = self.folders.GetPath()
        self.currfile = None    
        self.currindx = None         
        self.currextn = None          
        self.currbase = None  
        self.fullfile = None              

        #read image files from new current folder
        self.RefreshFileList()
        self.Select(0)

        event.Skip()

    def evt_FileSelected(self, event):
        """User selected a file from the file list"""
        if DEBUG: print('enter',iam())

        #Update state indicators

        file,indx = self.GetSelected(self.lstFiles)

        self.currfile = file
        self.currindx = indx
        self.currextn = os.path.splitext(self.currfile)[-1]
        self.fullfile = os.path.join(self.folders.GetPath(), self.currfile)
        self.currbase = os.path.splitext(self.currfile)[0]
        self.original = self.GetOriginalName()

        self.SetStatus()

        self.pnlImage.Load(self.fullfile)
        self.GetNameFromFile()        
        self.RefreshTags()

        self.pnlImage.bmpImage.SetToolTip(self.GetExifDate(self.fullfile))

        event.Skip()

    def evt_Strip(self, event):
        """Strip HH-MM and DSC/IMG tags"""
        if DEBUG: print('enter',iam())

        name = self.txtName.Value

        name = re.sub(' \d\d\-\d\d', '', name)      #HH-MM time tag  
        name = re.sub(' DSC\d+', '', name)          #Sonk camera tag 
        name = re.sub(' DSCF\d+', '', name)         #Fuji camera tag 
        name = re.sub(' IMG_\d+_\d+', '', name)     #FIGO cell phone 
        name = re.sub(' IMG_\d+', '', name)         #other camera tag

        #If name starts with yyyy-mm-dd, make sure it is followed by a space
        if re.search('^\d\d\d\d\-\d\d\-\d\d', name):
            if len(name) > 10 and name[10] != ' ':
                name = name[:10] + ' ' + name[10:]

        #Add a trailing space so user doesn't have to when adding tags
        if name[-1] != ' ': name += ' '

        self.txtName.Value = name        
        self.txtName.SetFocus()
        self.txtName.SetInsertionPointEnd()

        event.Skip()

    def evt_Home(self, event):
        """Select My Pictures special folder"""
        if DEBUG: print('enter',iam())

        self.currpath = gs.myPictures()
        self.folders.ExpandPath(self.currpath)

        event.Skip()        

    def evt_Restore(self, event):
        """Restore original name if available"""
        if DEBUG: print('enter',iam())

        if not self.original:
            return

        oldname = self.fullfile
        newname = os.path.join(self.currpath, self.original)

        try:

            #Restore original name and remove undo ADS
            os.rename(oldname, newname)
            ads = newname + ADS
            os.remove(newname + ADS)

            #Update state variables
            self.currfile = os.path.split(newname)[-1]
            self.fullfile = os.path.join(self.folders.GetPath(), self.currfile)
            self.currbase = os.path.splitext(self.currfile)[0]
            self.currextn = os.path.splitext(self.currfile)[-1]
            self.original = ''
            self.SetStatus()

            self.GetNameFromFile()

            ##Update file list
            self.lstFiles.SetItemText(self.currindx, self.currfile)

        except OSError:
            self.Message('Could not restore original name. Undo info invalid')
        except FileExistsError:
            self.Message('Could not restore original name. A file with that name already exists')                  

        event.Skip()

    def evt_Save(self, event):
        """Rename the image file using new name plus index (if non-zero)"""
        if DEBUG: print('enter',iam())

        if self.txtName.Value == '':
            self.Message('New name can not be blank')
            return

        oldname = self.fullfile
        newname = os.path.join(self.currpath, self.CreateNewName())

        if DEBUG: print(f'{oldname=}\n{newname=}\n')

        try:
            os.rename(oldname, newname)

            #Save original file name for undo
            ads = newname + ADS

            if not os.path.isfile(ads):
                if DEBUG: print('save original name to',ads)
                open(ads, 'w').write(self.currfile)

            self.original = self.currfile            
            self.SetStatus()

            self.currfile = self.CreateNewName()
            self.fullfile = os.path.join(self.folders.GetPath(), self.currfile)
            self.currbase = os.path.splitext(self.currfile)[0]
            self.currextn = os.path.splitext(self.currfile)[-1]

            #Update the file list with the new name
            self.lstFiles.SetItemText(self.currindx, self.CreateNewName())
            self.SelectNext()
        except OSError:
            self.Message('The new name is not a valid file name')
        except FileExistsError:
            self.Message('A file with that name already exists')       

        event.Skip()

    def evt_NameChanged(self, event):
        """The new name has been changed either by dragging a tag from the common tag list
        or by manually typing in the new name text control. Because refreshing the current
        tag list can cause removal of extra blanks in the new name, we must disable events
        when calling RefreshTags from within this handler."""

        if DEBUG: print('enter',iam())

        if DEBUG: print(f'{self.txtName.Value=}')

        self.EvtHandlerEnabled = False
        ip = self.txtName.GetInsertionPoint()
        self.RefreshTags() 
        self.txtName.SetInsertionPoint(ip)            
        self.RecalcIndex()
        self.EvtHandlerEnabled = True

        try:    self.txtName.SetToolTip('NEW NAME: ' + self.CreateNewName())
        except: pass

        event.Skip()

    def evt_RightClick(self, event):
        """Common tag item right clicked"""
        if DEBUG: print('enter',iam()) 

        if self.currfile:

            srce = event.GetEventObject()
            text = self.GetSelected(srce)[0]
            dest = self.lstComm if srce == self.lstCurr else self.lstCurr

            #copy from srce to dest if not already in list

            if dest.FindItem(-1,text) == -1:
                dest.InsertItem(srce.ItemCount, text)

        event.Skip()

    def evt_StartDrag(self, event):
        """Starting drag from current or common tags control"""
        if DEBUG: print('enter',iam())

        obj  = event.GetEventObject()               #get the control initiating the drag
        text = obj.GetItemText(event.GetIndex())    #get the currently selected text    
        tobj = wx.TextDataObject(text)              #convert it to a text object        
        srce = wx.DropSource(obj)                   #create a drop source object        
        srce.SetData(tobj)                          #save text object in the new object 
        srce.DoDragDrop(True)                       #complete the drag/drop             

        event.Skip()

    def evt_DeleteTag(self, event):
        """Delete the tag from whichever list control currently has focus"""
        if DEBUG: print('enter',iam())

        if self.lstCurr.HasFocus():
            #delete from the current tag list
            text,indx = self.GetSelected(self.lstCurr)
            self.lstCurr.DeleteItem(indx)
            self.RefreshName()
        elif self.lstComm.HasFocus():
            #delete from the common tag list
            text,indx = self.GetSelected(self.lstComm)
            self.lstComm.DeleteItem(indx)
        else:
            return

        event.Skip()

    def evt_TagDeleted(self, event):
        if DEBUG: print('enter',iam())

        self.RefreshName()

        event.Skip()

    def evt_TagAdded(self, event):
        if DEBUG: print('enter',iam())

        self.RefreshName()

        event.Skip()

    def evt_Close(self, event):
        if DEBUG: print('enter',iam())

        self.SaveConfig()

        event.Skip()

    def evt_TextEnter(self, event):
        if DEBUG: print('enter',iam())
        event.Skip()

    def evt_Prev(self, event):
        if DEBUG: print('enter',iam())
        self.SelectPrevious()
        event.Skip()

    def evt_Next(self, event):
        if DEBUG: print('enter',iam())
        self.SelectNext()
        event.Skip()

    def evt_Help(self, event):
        self.Message(self.Help())
        event.Skip()

    def RefreshFileList(self):
        """Refresh the file list by reading all image files in the current folder"""
        if DEBUG: print('enter',iam())

        self.lstFiles.DeleteAllItems()

        for item in os.scandir(self.currpath):
            if pt.isImage(item.name):
                self.lstFiles.InsertItem(self.lstFiles.ItemCount, item.name)

        self.btnRestore.Disable()

    def Select(self, indx):
        """Select the file with the given zero-relative index"""
        if DEBUG: print('enter',iam())

        if indx < 0 or indx >= self.lstFiles.ItemCount:
            return

        if (focus := self.lstFiles.FocusedItem) != indx:
            #unselect the current item
            self.lstFiles.SetItemState(focus, 0, wx.LIST_STATE_SELECTED)

        #select the new item
        self.lstFiles.Focus(indx)
        self.lstFiles.SetItemState(indx, wx.LIST_STATE_SELECTED, wx.LIST_STATE_SELECTED)

    def SelectPrevious(self):
        """Select the previous file in the list if available"""
        if DEBUG: print('enter',iam())
        self.Select(self.lstFiles.FocusedItem - 1)

    def SelectNext(self):
        """Select the next file in the list if available"""
        if DEBUG: print('enter',iam())
        self.Select(self.lstFiles.FocusedItem + 1)

    def RefreshTags(self):
        """Rebuild current tag list from new file name"""
        if DEBUG: print('enter',iam())

        self.EvtHandlerEnabled = False

        self.lstCurr.DeleteAllItems()

        for tag in self.txtName.Value.split():
            if self.lstCurr.FindItem(-1,tag) == -1:
                self.lstCurr.InsertItem(self.lstCurr.ItemCount,tag)

        self.EvtHandlerEnabled = True


    def RefreshName(self):
        """Rebuild the new name by combining all tags in the current tag list"""
        if DEBUG: print('enter',iam())

        #combine all list items separated by one space
        name = ''
        for indx in range(self.lstCurr.ItemCount):
            name += self.lstCurr.GetItemText(indx) + ' '

        #disable events to prevent infinite recursion
        self.EvtHandlerEnabled = False
        self.txtName.Value = name
        self.EvtHandlerEnabled = True

        self.RecalcIndex()

        self.txtName.SetFocus()
        self.txtName.SetInsertionPointEnd()


    def GetSelected(self, lst):
        "Returns (text,index) of the currently selected item in a list control"""

        indx = lst.GetFocusedItem()
        text = lst.GetItemText(indx)

        return text, indx


    def GetNameFromFile(self):
        """
        Given a base file name (no path & no extension), strip off
        any index information at the end of the name (an integer enclosed
        in parentheses) and copy what is left to the NewName control. Any
        index found goes to the Index control.
        """
        if DEBUG: print('enter',iam())

        if (m := re.search('\(\d*\)$', self.currbase)):
            self.txtIndex.Value = self.currbase[m.start()+1:-1]
            self.txtName.Value = self.currbase[:m.start()-1].strip() + ' '
        else:
            self.txtIndex.Value = '0'
            self.txtName.Value = self.currbase + ' '

    def RecalcIndex(self):
        """Calculate an index to ensure unique file name"""
        if DEBUG: print('enter', iam())

        if self.txtName.Value == '': return

        #Look for the first free file name starting with index = 0       
        self.txtIndex.Value = '0'
        while self.FileExists(self.CreateNewName()):
            if DEBUG: print('trying',self.CreateNewName())
            self.txtIndex.Value = str(int(self.txtIndex.Value) + 1)       

    def CreateNewName(self):
        """Create a new name by combining the displayed new name with the index"""
        if DEBUG: print('enter', iam())

        indx = int(self.txtIndex.Value)

        if indx != 0:
            name = self.txtName.Value.strip() + (' (%02d)' % indx) + self.currextn.lower()
        else:
            name = self.txtName.Value.strip() + self.currextn.lower()

        return name.replace('  ',' ')

    def FileExists(self, file):
        """
        Scans the current file list (except for the currently selected
        file) and returns True if the given file is in the list.
        """
        if DEBUG: print(f'look for {file=}')
        for i in range(self.lstFiles.ItemCount):
            if i != self.currindx:
                if file.lower() == self.lstFiles.GetItemText(i).lower():
                    return True
        return False

    def GetExifDate(self, file):
        """Returns the EXIF data/time stamp if found in the file"""
        try:
            with open(file, 'rb') as fh:
                img = Image(fh)
                return 'EXIF date/time: ' + img.datetime
        except:
            return 'No EXIF data'

    def GetOriginalName(self):
        """Get original name if available"""

        ads = self.fullfile + ADS

        if os.path.isfile(ads):
            with open(ads) as fh:
                return fh.read()
        else:
            return ''

    def SetStatus(self):

        if self.original:            
            self.status.SetStatusText('Original Name:', 0)
            self.status.SetStatusText(self.original, 1)
            self.btnRestore.Enable()
        else:
            self.status.SetStatusText('No undo', 0)
            self.status.SetStatusText('', 1)
            self.btnRestore.Disable()

    def Message(self, text):
        wx.MessageBox(text, TITLE, wx.OK)

    def Help(self):
        return"""
        Tags from the selected file are displayed in the current (upper right panel) list. The
        lower right panel contains commonly used tags. Both lists can be modified by

        1. dragging tags from one to the other
        2. pressing DEL or CTRL+X to delete

        Deleting a tag from the current list will remove it fron the edited file name. Typing
        in the edited file name will update the current tag list. Changes to the common tag list
        will be retained for future use.

        The original file name is saved and may be restored by either

        1. clicking Restore
        2. pressing CTRL+R

        The file will not be renamed until you either

        1. Click Save
        2. press CTRL-S

        You will not be prompted to apply unsaved changes.

        Hotkeys are:

            Arrow Up   - select previous file
            Arrow Down - select next file
            CTRL+1     - strip camera crud
            CTRL+S     - save file name changes
            CTRL+X     - delete selected current or common tag
            CTRL+R     - restore original file name
            CTRL+H     - select home (My Pictures) folder
        """


class MyApp(wx.App):
    def OnInit(self):
        self.frame = MyFrame(None, wx.ID_ANY, "")
        self.SetTopWindow(self.frame)
        self.frame.Show()
        return True


if __name__ == "__main__":
    app = MyApp(0)
    app.MainLoop()

perceivedType.py

"""                                                                                 
    Name:                                                                           

        perceivedType.py                                                            

    Description:                                                                    

        This is a set of methods that use the Windows registry to return a string   
        describing how Windows interprets the given file. The current methods will  
        return a string description as provided by Windows, or "unknown" if Windows 
        does not have an associated file type. The auxiliary functions return True  
        or False for tests for specific file types.                                 

     Auxiliary Functions:                                                           

          isVideo(file)   -   returns True if PerceivedType = "video"               
          isAudio(file)   -   returns True if PerceivedType = "audio"               
          isImage(file)   -   returns True if PerceivedType = "image"               
          isText (file)   -   returns True if PerceivedType = "text"                

    Parameters:                                                                     

        file:str    a file name                                                     
        degug:bool  print debug info if True (default=False)                        

    Audit:                                                                          

        2021-07-17  rj  original code                                               

"""

import os
import winreg


def perceivedType(file: str, debug: bool = False) -> str:
    """Returns the windows registry perceived type string for the given file"""

    if debug:
        print(f'\nchecking {file=}')

    try:
        key = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, os.path.splitext(file)[-1])
        inf = winreg.QueryInfoKey(key)

        for i in range(0, inf[1]):
            res = winreg.EnumValue(key, i)
            if debug:
                print(f'    {res=}')
            if res[0] == 'PerceivedType':
                return res[1].lower()
    except:
        pass

    return "unknown"

def isVideo(file: str) -> str: return perceivedType(file) == 'video'
def isAudio(file: str) -> str: return perceivedType(file) == 'audio'
def isImage(file: str) -> str: return perceivedType(file) == 'image'
def isText(file: str) -> str: return perceivedType(file) == 'text'


if __name__ == '__main__':
    for file in ('file.avi', 'file.mov', 'file.txt', 'file.jpg', 'file.mp3', 'file.pdf', 'file.xyz'):
        print('Perceived type of "%s" is %s' % (file, perceivedType(file, debug=True)))

ImagePanel.py

"""                                                                                 
    Name:                                                                           

        ImagePanel.py                                                               

    Description:                                                                    

        A panel containing a wx.StaticBitmap control that can be used to display    
        an image. The image is scale to fit inside the panel while maintaining the  
        image's original aspect ratio. The image size is recaulated whenever the    
        panel is resized.                                                           

        You can zoom in/out using CTRL+Scroll Wheel. The image is displayed in a    
        panel with scroll bars. If zoomed in you can scroll to see portions of the  
        image that are off the display.                                             

    Methods:                                                                        

        Load(file)  - load and display the image from the given file                
        Clear()     - clear the display                                             

        All common image formats are supported.                                     

    Audit:                                                                          

        2021-07-20  rj  original code                                               

"""

import wx
#import wx.lib.mixins.inspection


import wx.lib.scrolledpanel as scrolled


class ImagePanel(scrolled.ScrolledPanel):
    """
    This control implements a basic image viewer. As the control is
    resized the image is resized (aspect preserved) to fill the panel.

    Methods:

        Load(filename)   display the image from the given file
        Clear()          clear the displayed image
    """

    def __init__(self, parent, id=wx.ID_ANY,
                 pos=wx.DefaultPosition,
                 size=wx.DefaultSize,
                 style=wx.BORDER_SUNKEN
                 ):

        super().__init__(parent, id, pos, size, style=style)

        self.bmpImage = wx.StaticBitmap(self, wx.ID_ANY)
        sizer = wx.BoxSizer(wx.HORIZONTAL)
        sizer.Add(self.bmpImage, 1, wx.EXPAND, 0)
        self.SetSizer(sizer)

        self.bitmap = None  # loaded image in bitmap format
        self.image = None  # loaded image in image format
        self.aspect = None  # loaded image aspect ratio
        self.zoom = 1.0  # zoom factor

        self.blank = wx.Bitmap(1, 1)

        self.Bind(wx.EVT_SIZE, self.OnSize)
        self.Bind(wx.EVT_MOUSEWHEEL, self.OnMouseWheel)

        self.SetupScrolling()

        # wx.lib.inspection.InspectionTool().Show()

    def OnSize(self, event):
        """When panel is resized, scale the image to fit"""
        self.ScaleToFit()
        event.Skip()

    def OnMouseWheel(self, event):
        """zoom in/out on CTRL+scroll"""
        m = wx.GetMouseState()

        if m.ControlDown():
            delta = 0.1 * event.GetWheelRotation() / event.GetWheelDelta()
            self.zoom = max(1, self.zoom + delta)
            self.ScaleToFit()

        event.Skip()

    def Load(self, file: str) -> None:
        """Load the image file into the control for display"""
        self.bitmap = wx.Bitmap(file, wx.BITMAP_TYPE_ANY)
        self.image = wx.Bitmap.ConvertToImage(self.bitmap)
        self.aspect = self.image.GetSize()[1] / self.image.GetSize()[0]
        self.zoom = 1.0

        self.bmpImage.SetBitmap(self.bitmap)

        self.ScaleToFit()

    def Clear(self):
        """Set the displayed image to blank"""
        self.bmpImage.SetBitmap(self.blank)
        self.zoom = 1.0

    def ScaleToFit(self) -> None:
        """
        Scale the image to fit in the container while maintaining
        the original aspect ratio.
        """
        if self.image:

            # get container (c) dimensions
            cw, ch = self.GetSize()

            # calculate new (n) dimensions with same aspect ratio
            nw = cw
            nh = int(nw * self.aspect)

            # if new height is too large then recalculate sizes to fit
            if nh > ch:
                nh = ch
                nw = int(nh / self.aspect)

            # Apply zoom
            nh = int(nh * self.zoom)
            nw = int(nw * self.zoom)

            # scale the image to new dimensions and display
            image = self.image.Scale(nw, nh)
            self.bmpImage.SetBitmap(image.ConvertToBitmap())
            self.Layout()

            if self.zoom > 1.0:
                self.ShowScrollBars = True
                self.SetupScrolling()
            else:
                self.ShowScrollBars = False
                self.SetupScrolling()


if __name__ == "__main__":
    app = wx.App()
    frame = wx.Frame(None)
    panel = ImagePanel(frame)
    frame.SetSize(800, 625)
    frame.Show()
    panel.Load('D:\\test.jpg')
    app.MainLoop()