Counting Files Whose Names Share the Same Number

I would like to know the answer to this question as well…

I know there is a way to do this if keyboard maestro receives each files name one by one and

From there you can have a counter for 1z or 2z to increase by 1 every time the tittle matches

But I am interested to know if keyboard maestro can do this in one go.

Easy mode is to call on the power of Unix utilities. You want to list the directory in single column format (ls -1) then grab everything before the first "z" (cut -d 'z' -f 1), sort the output numerically (sort -n) then get summary of value and the number of time it occurred (uniq -c).

Unfortunately, uniqs output will be padding space(s), the count, a space, then the value -- but we can make that more user and KM-friendly by stripping the spaces and switching the numbers round, using a , to separate them. So the final output would be:

1,3
2,6
3,1
...

...which will be really easy to iterate through in KM, treating each line as a 2-element array.

Here's an example that does the above, pops a text window for each value showing how many times it occurred (array access) then a final window to show the full list. Don't have too many files in your test folder or you'll be spammed with windows!

Summary File Count.kmmacros (5.9 KB)

Image

But this would also be easy to do in KM, and you might want to give it a go yourself as practice. The pseudocode would be something like:

pick a folder
repeat with each file in the folder
    get theNumber from the start of the file name
    add theNumber to the end of listOfNumbers
end repeat
set numHolder to ""
set counter to 0
set output to ""
repeat with each line in listOfNumbers
    if contents of the line = numHolder
        add 1 to counter
    else
        add numHolder and "," and counter to end of output
        set numHolder to contents of the line
        set counter to 1
    end if
end repeat
sort output
display output

That's totally off the top of my head and not tested, but should give you an idea of how to do it. Almost every step should translate to a KM action.

Instead of creating text where each line is an array of value, count, you could use a dictionary -- that's more work and would need more care (dictionaries are global and persistent) but would make it easier to access individual counts because you could use value as a key to get count directly rather than searching a variable's text block to find the right line then access that as an array. It really depends on what you'll be doing with the list as to whether that's worth the extra effort.

2 Likes

Thank you so much! I'll play with it and will try to see what I can do

I got started on this last night and decided to finish it today even though you have a good solution from @Nige_S. I wanted to learn how to create and use dictionaries in Keyboard Maestro, a feature I've never used before.

File Prefix Count.kmmacros (4.9 KB)

Image of macro

This macro uses a dummy list of files:

1z_thing
2z thing
3z_thing1
3z thing2
3z_thing3
6z thing1
6z_thing2
12z thing

The real list of files could come from an ls command as @Nige_S suggests.

The Perl script is this:

#!/usr/bin/perl

# Make a hash of the prefix counts
while (<>) {
	@file_parts = split /z/;
	$count{$file_parts[0]}++;
}

# Use the hash to make an array of JSON parts
while( ($k, $v) = each %count ){
	push @json_parts, qq("$k": $v);
}

# Assemble the parts into a complete JSON string
print "{" . join(", ", @json_parts) . "}";

Perl has a well-known way of creating a dictionary (called a hash in Perl) of occurrence counts. I used that in the first few lines, with the keys of the hash being the prefix before the “z.” The rest of the code prints the hash out in JSON format. The output of the script is

{"12": 1, "3": 3, "2": 1, "6": 2, "1": 1}

As you can see, Perl hashes aren't sorted by key, but that's OK.

This script could just as easily been written in Python, but since Apple has stopped installing Python by default, I decided to go with Perl.

The Set Dictionary action uses the JSON output of the Perl script to create a Keyboard Maestro dictionary, which you can then use to get counts of any of the prefixes using tokens like

%Dictionary[LocalFilePrefixCount, 6]%

which would return 2 because there are two files with a prefix of “6z.”

In the last few steps, I loop through the dictionary keys collection to print out all the dictionary keys and their values.

4 Likes

Wow this looks very interesting! Will check it out! Thanks!!

Hello Nige, this was working soooo great but for some reason, it doesn't anymore and I don't understand why...Is there something in my filenames that throws it off?

Screen Shot 2022-10-13 at 11.27.05 PM

There's nothing obviously wrong. Assuming you are selecting the correct folder in the first action, are your zs actually zeds, and not some Unicode variant?

If you'd messed with your PATH environment variable I'd expect more of an error, but you could try explicit references in your shell script action:

/bin/ls -1 "$KMVAR_Local_folder" | /usr/bin/cut -d 'z' -f 1 | /usr/bin/sort -n | /usr/bin/uniq -c
1 Like

The problem is in the regular expression used to flip the order of the count and the prefix:

.*(\d+) +(\d+)

The .* at the beginning greedily takes up all the characters except the last digit of the count. The first capture group then gets only the last digit. You didn’t run into this problem earlier because you never had two-digit counts before.

Try

 *(\d+) +(\d+)

instead, where the first character is a space.

2 Likes

Well, slap me twice and call me stupid...

Thanks @drdrang for the correction (and for not laughing!) and apologies @hnt007 for such a silly mistake in the first place...

1 Like

Wow great catch!!! This is now working great again, thank you so much!

@Nige_S you are BOTH amazing, thanks!

How could I possibly laugh? Not only did I fail to catch it when I first read your answer, I’ve made similar mistakes more times then I can count.

I would write that a bit differently for readability sake:

[ ]*(\d+) +(\d+)

OR

\h*(\d+) +(\d+)

-ccs

3 Likes

I should have mentioned that I do this, because I've been bitten many times by "invisible" characters in regular expressions...

I want to be able to tell at a glance what I'm looking at and not have to copy to a text editor and turn on display invisibles to decipher a pattern.

2 Likes

Is there a way I could extract variables from this result?
Meaning getting the number of 1z as a variable and the number of 2z as another variable?

I need to do some actions x amount of 1z times, and x amount of 2z times...so if I have 3 1zs, 5 2zs, I need to do some actions 3 times, and some 5 times. Thanks!

If you return the frequency counts for each file prefix in the format of a JSON dictionary (JSON Object), you can use Keyboard Maestro's %JSONValue% token to extract particular values by key:

Frequency of the "1z" prefix is:
%JSONValue%local_PrefixFreqs.1z%

Here, for example, counting the frequency of each (two character) prefix of the files in ~/Desktop, and returning the results as a JSON Object string

(This example assumes that there are indeed some file prefixed by "1z" in the ~/Desktop folder, but you can adjust the details :slight_smile: )

Filename prefix frequencies.kmmacros (8.5 KB)


Expand disclosure triangle to view JS source
(() => {
    "use strict";

    // A JSON dictionary with filename-prefix keys
    // to the frequency of those prefixes in
    // a given folder.

    // Rob Trew @2022

    const main = () =>
        either(
            alert("Count of filename prefixes")
        )(
            dict => dict
        )(
            fmapLR(
                xs => stringCounts(
                    xs.map(
                    // For example, the first two
                    // characters of each filename.
                        fp => fp.slice(0, 2)
                    )
                )
            )(
                getDirectoryContentsLR(
                    Application("Keyboard Maestro Engine")
                    .getvariable("someFolderPath")
                )
            )
        );


    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };


    // getDirectoryContentsLR :: FilePath ->
    // Either String IO [FilePath]
    const getDirectoryContentsLR = fp => {
        const
            error = $(),
            xs = $.NSFileManager.defaultManager
            .contentsOfDirectoryAtPathError(
                $(fp).stringByStandardizingPath,
                error
            );

        return xs.isNil() ? (
            Left(ObjC.unwrap(error.localizedDescription))
        ) : Right(ObjC.deepUnwrap(xs));
    };


    // --------------------- GENERIC ---------------------

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // add (+) :: Num a => a -> a -> a
    const add = a =>
        // Curried addition.
        b => a + b;


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
    // Application of the function fl to the
    // contents of any Left value in e, or
    // the application of fr to its Right value.
        fr => e => "Left" in e ? (
            fl(e.Left)
        ) : fr(e.Right);


    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
    // Either f mapped into the contents of any Right
    // value in e, or e unchanged if is a Left value.
        e => "Left" in e ? (
            e
        ) : Right(f(e.Right));


    // stringCounts :: String -> Dict Char Int
    const stringCounts = cs =>
    // Dictionary of the given list of strings,
    // with the frequency of each in the sample.
        [...cs].reduce(
            (a, c) => insertWith(add)(c)(
                1
            )(a), {}
        );


    // insertWith :: Ord k => (a -> a -> a) ->
    // k -> a -> Map k a -> Map k a
    const insertWith = f =>
    // A new dictionary updated with a (k, f(v)(x)) pair.
    // Where there is no existing v for k, the supplied
    // x is used directly.
        k => x => dict => Object.assign(
            dict, {
                [k]: k in dict ? (
                    f(dict[k])(x)
                ) : x
            });


    // ----------- LOGGING AND STRINFIFICATION -----------

    // sj :: a -> String
    const sj = (...args) =>
    // Abbreviation of showJSON for quick testing.
    // Default indent size is two, which can be
    // overriden by any integer supplied as the
    // first argument of more than one.
        JSON.stringify.apply(
            null,
            1 < args.length && !isNaN(args[0]) ? [
                args[1], null, args[0]
            ] : [args[0], null, 2]
        );

    return sj(main());
})();

WOW this is so beyond my head but it's working really really well, thank you!! Very useful macro to have!

This is great, my only issue is that I won't be able to use it in all the scenarios because I won't know what would be before z, it could be 51z, 108z, meaning I won't be able to plan actions ahead with a variable because I would need hundreds of them like %JSONValue%local_PrefixFreqs.164z% or whatever. But this works for one of my macros where I know that I will only have 1z and 2z in my working folder, thanks.

Nige_S also provided an excellent macro where it does For Each Local_line meaning I could loop my actions for x amount of times based on my Zs no matter how many kinds I have, but for some reason, sometimes it doesn't work, I’ll have to keep trying to search for what I’m doing wrong in it. Thanks again everyone!

Easily adjusted with a redefinition of the kind of prefix that is counted.

The first draft defined the prefixes of interest as simply consisting of the first two characters.

xs.map(
    // For example, the first two
    // characters of each filename.
    fp => fp.slice(0, 2)
)

We can redefine, to:

  1. consider only filenames that contain a "z", and
  2. count the prefix consisting of all characters up the first "z":
xs.flatMap(
    // For example, the first two
    // characters of each filename.
    //  fp => fp.slice(0, 2)

    // Alternatively any prefix, of any
    // length, that ends with a "z",
    // ignoring filenames that lack a "z".
    fp => {
        const parts = fp.split("z");

        return 1 < parts.length ? (
            [`${parts[0]}z`]
        ) : [];
    }
);

And then, as before, write expressions like:

Frequency of the "164z" prefix is:
%JSONValue%local_PrefixFreqs.164z%

e.g.

Filename prefix frequencies (and list of prefixes found).kmmacros (11 KB)


Expand disclosure triangle to view JS source
(() => {
    "use strict";

    // A JSON dictionary with filename-prefix keys
    // to the frequency of those prefixes in
    // a given folder.
    const main = () =>
        either(
            alert("Count of filename prefixes")
        )(
            dict => dict
        )(
            fmapLR(
                xs => stringCounts(
                    xs.flatMap(
                        // Any prefix, of any
                        // length, that ends with a "z",
						 // ignoring filenames that lack a "z".
                        fp => {
                            const parts = fp.split("z");

                            return 1 < parts.length ? (
                                [`${parts[0]}z`]
                            ) : [];
                        }
                    )
                )
            )(
                getDirectoryContentsLR(
                    Application("Keyboard Maestro Engine")
                    .getvariable("someFolderPath")
                )
            )
        );


    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };


    // getDirectoryContentsLR :: FilePath ->
    // Either String IO [FilePath]
    const getDirectoryContentsLR = fp => {
        const
            error = $(),
            xs = $.NSFileManager.defaultManager
            .contentsOfDirectoryAtPathError(
                $(fp).stringByStandardizingPath,
                error
            );

        return xs.isNil() ? (
            Left(ObjC.unwrap(error.localizedDescription))
        ) : Right(ObjC.deepUnwrap(xs));
    };


    // --------------------- GENERIC ---------------------

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // add (+) :: Num a => a -> a -> a
    const add = a =>
        // Curried addition.
        b => a + b;


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
    // Application of the function fl to the
    // contents of any Left value in e, or
    // the application of fr to its Right value.
        fr => e => "Left" in e ? (
            fl(e.Left)
        ) : fr(e.Right);


    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
    // Either f mapped into the contents of any Right
    // value in e, or e unchanged if is a Left value.
        e => "Left" in e ? (
            e
        ) : Right(f(e.Right));


    // stringCounts :: String -> Dict Char Int
    const stringCounts = cs =>
    // Dictionary of the given list of strings,
    // with the frequency of each in the sample.
        [...cs].reduce(
            (a, c) => insertWith(add)(c)(
                1
            )(a), {}
        );


    // insertWith :: Ord k => (a -> a -> a) ->
    // k -> a -> Map k a -> Map k a
    const insertWith = f =>
    // A new dictionary updated with a (k, f(v)(x)) pair.
    // Where there is no existing v for k, the supplied
    // x is used directly.
        k => x => dict => Object.assign(
            dict, {
                [k]: k in dict ? (
                    f(dict[k])(x)
                ) : x
            });


    // ----------- LOGGING AND STRINFIFICATION -----------

    // sj :: a -> String
    const sj = (...args) =>
    // Abbreviation of showJSON for quick testing.
    // Default indent size is two, which can be
    // overriden by any integer supplied as the
    // first argument of more than one.
        JSON.stringify.apply(
            null,
            1 < args.length && !isNaN(args[0]) ? [
                args[1], null, args[0]
            ] : [args[0], null, 2]
        );

    return sj(main());
})();

1 Like

Very interesting! I think I see a way to combine both macros to automatically figure out the Zs it finds and to generate the appropriate variables that I need, THANk YOU!

1 Like

FWIW you probably know that once you have a Keyboard Maestro variable in the form of a JSON object string, you can loop through its keys:

collection:JSON Keys [Keyboard Maestro Wiki]

Filename prefix frequencies (and list of prefixes found).kmmacros (11.4 KB)

1 Like