Find Variable In File

I have a file that contains a list of customers, one per line.

When I start working with a new customer I’d like to add their name to the list.

So I want to prompt for the new customer name - easy to do.

But how do I search with the returned value in the file? If there is a match I don’t want to add the customer name to the end but if there isn’t I do.

I think it boils down to reading the file into a variable and searching for the customer name (stored as a variable) in that variable.

How do I do this? Or is there a better way, perhaps with AppleScript?

There almost certainly is a better way to do this, but here's one method that seems to work in my (limited) testing:

Check Customer List, Add if New Customer Unlisted.kmmacros (3.6 KB)

Just change this option in the For Each action to match your file (along with making sure the variable names used here are consistent with what you're using now, of course) and I think you should be good to go:

Thanks!

One thing I didn’t say was that I want the macro to do a series of such searches - with different prompted strings against different files. 3 in all. So the “Cancel Macro” bit worries me. I’ll play with it though.

OK. I modified it to set a “Found” variable and then break out of the loop. This I can do thrice - for Customer, Study, and System.

1 Like

Assembling these things with the basic Keyboard Maestro building blocks has real advantages of simplicity and maintainability.

If you are going to do any part of it with Execute Script actions you probably need both a motive and a means:

  1. Motive - something that doesn't seem to be included yet among the pre-fabricated blocks, or that you want more fine control with, and
  2. Means - pre-fabricated scripting building blocks, so that you don't serve too many years in the wheel-reinvention business.

In this particular case, once you have read two variables, let's call them:

  • nameListPath, and
  • nameMaybeNew

into the script, (using the Keyboard Maestro Engine getvariable function), you might, depending on division of labour between Execute Script Action and other KM actions, need a readFile function, to read your name list with.

JavaScript for Automation and Applescript versions might look something like:

JXA

// readFile :: FilePath -> IO String
const readFile = strPath => {
    var error = $(),
        str = ObjC.unwrap(
            $.NSString.stringWithContentsOfFileEncodingError(
                ObjC.wrap(strPath)
                .stringByStandardizingPath,
                $.NSUTF8StringEncoding,
                error
            )
        );
    return typeof error.code !== 'string' ? (
        str
    ) : 'Could not read ' + strPath;
};

Applescript

-- readFile :: FilePath -> IO String
on readFile(strPath)
    set ca to current application
    set str to unwrap(ca's NSString's stringWithContentsOfFile:(wrap(strPath)'s ¬
        stringByStandardizingPath()) encoding:(ca's NSUTF8StringEncoding) |error|:(missing value))
    if str is missing value then
        "Could not read file at " & strPath
    else
        str
    end if
end readFile

then, a lines function to split your names text into a list or array of lines:

JXA

// lines :: String -> [String]
const lines = s => s.split(/[\r\n]/);

Applescript

-- lines :: String -> [String]
on |lines|(xs)
    paragraphs of xs
end |lines|

next, checking for a matching name in a list of names would need some kind of find function, and perhaps an isPrefixOf function to go with it, so that you can define the kind of match that you are looking for:

JXA

// find :: (a -> Bool) -> [a] -> Maybe a
const find = (p, xs) => {
    for (var i = 0, lng = xs.length; i < lng; i++) {
        var x = xs[i];
        if (p(x)) return just(x);
    }
    return nothing('Not found');
};

// isPrefixOf :: [a] -> [a] -> Bool
const isPrefixOf = (xs, ys) => {
    const pfx = (xs, ys) => xs.length ? (
        ys.length ? xs[0] === ys[0] && pfx(
            xs.slice(1), ys.slice(1)
        ) : false
    ) : true;
    return typeof xs !== 'string' ? pfx(xs, ys) : ys.startsWith(xs);
};

AppleScript

-- find :: (a -> Bool) -> [a] -> Maybe a
on find(p, xs)
    tell mReturn(p)
        set lng to length of xs
        repeat with i from 1 to lng
            if |λ|(item i of xs) then return {nothing:false, just:item i of xs}
        end repeat
        {nothing:true}
    end tell
end find

-- isPrefixOf :: [a] -> [a] -> Bool
-- isPrefixOf :: String -> String -> Bool
on isPrefixOf(xs, ys)
    if class of xs is string then
        (offset of xs in ys) = 1
    else
        if length of xs = 0 then
            true
        else
            if length of ys = 0 then
                false
            else
                set {x, xt} to uncons(xs)
                set {y, yt} to uncons(ys)
                (x = y) and isPrefixOf(xt, yt)
            end if
        end if
    end if
end isPrefixOf

-- Lift 2nd class handler function into 1st class script wrapper
-- mReturn :: First-class m => (a -> b) -> m (a -> b)
on mReturn(f)
    if class of f is script then
        f
    else
        script
            property |λ| : f
        end script
    end if
end mReturn

Finally, if the name wasn't found, and you wanted to add it to your list, you could just append it to the existing file, but your example shows sorted names, so you might want to do two things:

  • first add the new name anywhere in the existing list, perhaps at the beginning (a cons function, as in construct an expanded list from a new atom and an existing list), and then
  • apply a sort function.

JXA

// cons :: a -> [a] -> [a]
const cons = (x, xs) => [x].concat(xs);

// sort :: Ord a => [a] -> [a]
const sort = xs => xs.slice()
    .sort();

AppleScript

-- cons :: a -> [a] -> [a]
on cons(x, xs)
    {x} & xs
end cons

-- sort :: Ord a => [a] -> [a]
on sort(xs)
    ((current application's NSArray's arrayWithArray:xs)'s ¬
        sortedArrayUsingSelector:"compare:") as list
end sort

and to get the expanded and sorted version back into a text file, you would need to first convert the list back to a continuous string (an unlines function), and then write it out (a writeFile function)

JXA

// unlines :: [String] -> String
const unlines = xs => xs.join('\n');

// writeFile :: FilePath -> String -> IO ()
const writeFile = (strPath, strText) =>
    $.NSString.alloc.initWithUTF8String(strText)
    .writeToFileAtomicallyEncodingError(
        ObjC.wrap(strPath)
        .stringByStandardizingPath, false,
        $.NSUTF8StringEncoding, null
    );

Applescript

-- unlines :: [String] -> String
on unlines(xs)
    intercalate(linefeed, xs)
end unlines

-- intercalate :: String -> [String] -> String
on intercalate(s, xs)
    set {dlm, my text item delimiters} to {my text item delimiters, s}
    set str to xs as text
    set my text item delimiters to dlm
    return str
end intercalate

-- writeFile :: FilePath -> String -> IO ()
on writeFile(strPath, strText)
    set ca to current application
    (ca's NSString's stringWithString:strText)'s ¬
        writeToFile:(stringByStandardizingPath of ¬
            (ca's NSString's stringWithString:strPath)) atomically:true ¬
            encoding:(ca's NSUTF8StringEncoding) |error|:(missing value)
end writeFile

but before you overwrite the old file with writeFile you should certainly back up the original file somewhere, perhaps with a derived (e.g. time-stamped) name. Now we need splitFileName and showCurrentTime functions to build the name of our backup file with ...

JXA

// splitFileName :: FilePath -> (String, String)
const splitFileName = strPath =>
    strPath !== '' ? (
        strPath[strPath.length - 1] !== '/' ? (() => {
            const
                xs = strPath.split('/'),
                stem = xs.slice(0, -1);
            return stem.length > 0 ? (
                [stem.join('/') + '/', xs.slice(-1)[0]]
            ) : ['./', xs.slice(-1)[0]];
        })() : [strPath, '']
    ) : ['./', ''];

// showCurrentTime :: () -> String
const showCurrentTime = () =>
    (new Date())
    .toISOString();

Applescript

-- splitFileName :: FilePath -> (String, String)
on splitFileName(strPath)
    if strPath ≠ "" then
        if last character of strPath ≠ "/" then
            set xs to splitOn("/", strPath)
            set stem to text 1 thru -2 of xs
            if stem ≠ {} then
                {intercalate("/", stem) & "/", item -1 of xs}
            else
                {"./", item -1 of xs}
            end if
        else
            {strPath, ""}
        end if
    else
        {"./", ""}
    end if
end splitFileName

-- showCurrentTime :: () -> String
on showCurrentTime()
    set dteNow to current date
    tell (dteNow - (time to GMT)) to set {y, m, d, t} to ¬
        {its year, its month as integer, its day, its time}
    intercalate("-", {y, m, d}) & "T" & ¬
        intercalate(":", {t div 3600, (t mod 3600) div 60, t mod 60}) & "Z"
end showCurrentTime

-- splitOn :: String -> String -> [String]
on splitOn(strDelim, strMain)
    set {dlm, my text item delimiters} to {my text item delimiters, strDelim}
    set xs to text items of strMain
    set my text item delimiters to dlm
    return xs
end splitOn

We're probably almost done, but by now, Peter's drag and drop building blocks may well be looking easier to find, assemble and maintain, and more thoroughly tested too : -)

Still, you could pull it all together into a script, though you still might need to paste a couple more generic helper functions.

And unless your scripting languages are in fairly well-practiced shape, it might well take more time (and certainly need more testing), than with the KM building blocks.

For reference, a couple of preliminary (non-production) sketches:

JXA
Ensure that list contains name (thru JavaScript for Automation).kmmacros (23.2 KB)

Applescript
Ensure that list contains name (Applescript).kmmacros (24.1 KB)

1 Like

Here is my approach to solving this problem, that just uses one simple RegEx Search:

The Variable Local__ItemInList will be empty if the Item is NOT found.

Here's my example macro, written for adding items in general without respect to what the item or item list is called (Customers, Vendors, etc). It would be easy to mod this macro to:

  1. Provide option of which list to use (Customers, Vendors, Employees, etc)
  2. Provide option to delete an item from the list.
  3. Use a fixed file path instead of choosing file for list.

Please let us know if this answers your question or if you have further related questions.

Example Output

Choose Input File


###MACRO:&nbsp;&nbsp;&nbsp;Add Item To List If It Does Not Exist

~~~ VER: 1.0&nbsp;&nbsp;&nbsp;&nbsp;2017-11-26 ~~~

####DOWNLOAD:
<a class="attachment" href="/uploads/default/original/3X/c/c/ccc891d88739c62eab8fc5649f9f11450dd09a27.kmmacros">Add Item To List If It Does Not Exist.kmmacros</a> (13 KB)
**Note: This Macro was uploaded in a DISABLED state. You must enable before it can be triggered.**

---

###ReleaseNotes

Author.@JMichaelTX

**PURPOSE:**

* **Add New Items to List**

**NOTICE: This macro/script is just an _Example_**

* It is provided only for _educational purposes_, and may not be suitable for any specific purpose.
* It has had very limited testing.
* You need to test further before using in a production environment.
* It does not have extensive error checking/handling.
* It may not be complete.  It is provided as an example to show you one approach to solving a problem.

**REQUIRES:**

1. **KM 8.0.4+**
  * But it can be written in KM 7.3.1+
  * It is KM8 specific just because some of the Actions have changed to make things simpler, but equivalent Actions are available in KM 7.3.1.
.
2. **macOS 10.11.6 (El Capitan)**
  * KM 8 Requires Yosemite or later, so this macro will probably run on Yosemite, but I make no guarantees.  :wink: 


**MACRO SETUP**

* **Carefully review the Release Notes and the Macro Actions**
  * Make sure you understand what the Macro will do.  
  * You are responsible for running the Macro, not me.  😉
.
* Assign a Trigger to this maro.
* Move this macro to a Macro Group that is only Active when you need this Macro.
* ENABLE this Macro.
.
* **REVIEW/CHANGE THE FOLLOWING MACRO ACTIONS:**
  * ALL Actions that are shown in the magenta color

**USE AT YOUR OWN RISK**

* While I have given this limited testing, and to the best of my knowledge it will do no harm, I cannot guarantee it.
* If you have any doubts or questions:
  * **Ask first**
  * Turn on the KM Debugger from the KM Status Menu, and step through the macro, making sure you understand what it is doing with each Action.

---

<img src="/uploads/default/original/3X/c/4/c40702bd30a8f604a29bb5f2adc153d2b18a8159.jpg" width="536" height="1863">
1 Like

NOTE — the AppleScript depends on the Satimage.osax for find text, sortlist, join, and writetext. If it is not installed the script will throw an error.


Hey Martin,

I think others have more than adequately covered the nuts and bolts of doing the job with Keyboard Maestro, so I'm going to give an AppleScript example.

If I understand your task correctly you want to enter three strings and write them to three files if and only if the given file doesn't contain it already.

It seems cumbersome to have 3 different user-input dialogs in the same macro, when it's possible to enter them all at once. (But perhaps I'm missing something.)

Here's the approach I'd take (if possible):

The user must input the correct paths to the three files in the script at this point, and the path string can be a tilde-based path or a full POSIX Path.

----------------------------------------------------------------
# User settings - target file paths
----------------------------------------------------------------

set customerListFile to "~/test_directory/KM_TEST/Customer List.txt"
set studyListFile to "~/test_directory/KM_TEST/Study List.txt"
set systemListFile to "~/test_directory/KM_TEST/System List.txt"

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

Martin - Write User-Input to File.kmmacros (10 KB)

If you discount the handlers there are only 26 lines of germane code, because of all the goodies added to AppleScript by the Satimage.osax.


** I probably won't bother to do any of the following **


Everything could be written in vanilla AppleScript with relative brevity, except for sorting the the item lists in the files which would require a fairly large (but fast) vanilla handler, or shelling out to sort or using AppleScriptObjC.

All could be written in AppleScriptObjC.

The job could also be written very economically in Bash.


-Chris

The job could also be written very economically in Bash.

Very true, in a sense – brevity has an elegance and appeal. It can even be effective ...

(the OP's title for this thread, for example, is wonderfully succinct and generic - well deserves this rich crop of approaches, easily found by anyone looking for similar needles in analogous haystacks).

But ... do we feel sure that brevity is 'economic' ? Blaise Pascal (and others) suggest that it could be expensive:

I would have written a shorter letter, but I did not have the time

A few lines of code (Bash or whatever) certainly seem to imply something quickly dashed off, and perhaps that's really what others do, but my own experience is a bit more consistent with the Mark Twain / Blaise Pascal picture – it takes a lot more time (learning, testing, pruning, shaving yaks) to make something short.

A longish sequence of Keyboard Maestro blocks may look a lot more elaborate, at first glance, than 'a few lines of code', but perhaps it's really much more economic ?


Footnote:

Which of these two approaches is more economic ? Different lengths, but pretty much identical. (One was cheap – a copy of the other, and an automated paste of identical generics – but possibly hard to tell which, and not necessarily proportional to length : -)

Applescript

-- updatedAndBackedUp :: FilePath -> String -> Maybe FilePath
on updatedAndBackedUp(strPath, strName)
    set maybeFound to nameMatchInFile(strPath, strName)
    if nothing of maybeFound then
        set backup to intercalate("backup" & ¬
            showCurrentTime(), splitFileName(strPath))
        set strLines to readFile(strPath)
        
        writeFile(backup, strLines)
        writeFile(strPath, unlines(sort(cons(strName, |lines|(strLines)))))
        
        just(strName & " added to " & strPath & ¬
            "\nOriginal backed up at:" & backup)
    else
        nothing("Name " & just of maybeFound & " already in " & strPath)
    end if
end updatedAndBackedUp

JavaScript

// updatedAndBackedUp :: FilePath -> String -> Maybe FilePath
const updatedAndBackedUp = (strPath, strName) => {
    const maybeFound = nameMatchInFile(strPath, strName);
    return maybeFound.nothing ? (() => {
        const
            backup = intercalate(
                'backup' + showCurrentTime(),
                splitFileName(strPath)
            ),
            strLines = readFile(strPath);
        return (
            writeFile(backup, strLines),
            writeFile(strPath,
                unlines(sort(cons(strName, lines(strLines))))
            ),
            just(strName + ' added to ' + strPath +
                '\nOriginal backed up at: ' + backup)
        );
    })() : nothing(
        'Name ' + maybeFound.just + ' already in ' + strPath
    );
};
1 Like

Clue:

In the case below, the longer function was very much more economic than the shorter one …
(just copy + paste)

JavaScript

// nameMatchInFile :: FilePath -> String -> Maybe String
const nameMatchInFile = (strPath, strName) =>
    find(
        strLine => isPrefixOf(strName, strLine),
        lines(readFile(strPath))
    );

Applescript

-- nameMatchInFile :: FilePath -> String -> Maybe String
on nameMatchInFile(strPath, strName)
    script match
        on |λ|(strLine)
            isPrefixOf(strName, strLine)
        end |λ|
    end script
    find(match, |lines|(readFile(strPath)))
end nameMatchInFile

Thank you everybody. This has been quite educational. :slight_smile:

1 Like