Help with AppleScript loop problem

For months, since upgrading to Sonoma, I have had a problem with a repeat loop in what is for me a key AppleScript script. Having reached the stage where I wish either to throw myself at the mercy of the forum, or under a bus, I have for the moment decided on the former.

The script uses a loop to convert Day One links to (manually provided) DEVONthink links. It worked like a dream before the Sonoma upgrade but now fails erratically. By watching the DEVONthink links inspector, and also running the script in Script Editor, I can say the following:

  • The script invariably works on the first link to be converted.
  • More often than not (but not invariably) the script will fail when the loop tackles the second link. When that happens the link inspector shows clearly that the Day One link remainsā€”but, on occasion, the Script Editor debug output shows that the link has in fact been replaced with a DEVONthink link (but it has not when I inspect the document in DEVONthink).
  • Clearly if the script fails on the second link conversion of further links goes haywire (in the sense that the second link is converted with the date provided for the third link).
  • If the second link is successfully converted conversion of the third link will work but conversion of the fourth will more often than not (but not invariably) fail.

I have looked at, and meddled with, the script until blue in the face but am clearly missing something obvious. Can anyone tell me what that is?

Here is the script (apologies for the length but you can ignore the handlersā€”which Iā€™ve left in for completeness):

(* This script replaces Day One links in a single selected diary record
with DEVONThink links. It assumes each diary record is markdown
with the name of the record in the format "2022-08-30".
The name format can be changed in the getName handler. You are invited to
input the date of the linked record - obtained manually from checking
the Day One link in Day One - and the script then substitutes
the DEVONThink link for the Day One link in the selected
diary record, looping through the record until all Day One links
have been replaced. *)

-- Set the database we want to use
property pDatabase : "[redacted]"

-- Set the current date as the initial default date for the input dialog
set theDate to short date string of (current date)

tell application id "DNtp"
	try
		set theDatabase to open database pDatabase
		-- Test for selection of single markdown record
		set theSelection to the selection
		if (count of theSelection) is 1 then
			set theRecord to the content record
			if (type of theRecord) is markdown then
				set theText to plain text of theRecord
				set theURL to "dayone://"
				set dAnswer to theDate
				with timeout of 3600 seconds
					-- Set up a repeat loop to replace Day One links with DEVONThink links
					repeat while theText contains theURL
						-- Ask for the date of the target record in the Diaries database
						-- and provide clear guidance as to the format required
						set userEntry to display dialog Ā¬
							"Enter the link date as DD/MM/YYYY" default answer dAnswer with title "Replace Day One link with DEVONThink link"
						-- Hold over the first response for the next loop
						-- if there is more than one link to be processed
						set dAnswer to text returned of userEntry as string
						-- Now reformat it to match the name of a diary entry record
						-- Here we use the getName handler to construct
						-- a name like "2022-08-30"
						set theTarget to my getName(dAnswer)
						-- Search for the named record in the database
						set foundRecord to (search "name:" & theTarget & " kind:markdown") in theDatabase
						if foundRecord = {} then
							error theTarget & " was not found in " & pDatabase
						end if
						-- Get the reference URL of the record
						set theDTLink to reference URL of item 1 of foundRecord
						-- Extract part of the Day One linkā€¦
						set theDOurl to my extractBetween(theText, theURL, ")")
						-- ā€¦and assemble it
						set theDOLink to theURL & theDOurl
						-- Now replace the Day One link with the DT link
						set amendedText to my replaceText(theText, theDOLink, theDTLink)
						set plain text of theRecord to amendedText
						set theText to plain text of theRecord
					end repeat
				end timeout
				display dialog "There are no more Day One links in this record" with title "Convert Day One Links to DEVONthink links" buttons {"OK"} default button "OK" with icon 1
			else
				error "Please select a single diary markdown record."
			end if
		else
			error "Please select a single diary record."
		end if
		
	on error error_message number error_number
		if the error_number is not -128 then display alert "DEVONthink" message error_message as warning
		if the error_number is -128 then
			set plain text of theRecord to theText
		end if
	end try
end tell

on getName(dateString)
	set theDay to characters 1 through 2 of dateString
	set theMonth to characters 3 through 5 of dateString
	set theYear to characters -4 through -1 of dateString
	set formattedDate to (theYear & "-" & theMonth & "-" & theDay)
	return formattedDate
end getName

on extractBetween(SearchText, startText, endText)
	set tid to AppleScript's text item delimiters -- save them for later.
	set AppleScript's text item delimiters to startText -- find the first one.
	set endItems to text of text item 2 of SearchText -- everything after the first.
	set AppleScript's text item delimiters to endText -- find the end one.
	set beginningToEnd to text of text item 1 of endItems -- get the first part.
	set AppleScript's text item delimiters to tid -- back to original values.
	return beginningToEnd -- pass back the piece.
end extractBetween

on replaceText(theString, find, replace)
	-- with acknowledgment and thanks to @cgruenberg
	-- https://discourse.devontechnologies.com/t/search-replace-text-in-rtf-document/65757/10
	local od
	set {od, text item delimiters of AppleScript} to {text item delimiters of AppleScript, find}
	set theString to text items of theString
	set text item delimiters of AppleScript to replace
	set theString to "" & theString
	set text item delimiters of AppleScript to od
	return theString
end replaceText

Stephen

In my experience, the forum is more reliable than buses.

First off, Iā€™d suggest replacing the selection by selected records. Thatā€™s the recommended command for quite some time now.

Secondly: did you try running the code in Script Editor/Script Debugger, following the sequence of AppleEvents and responses?

Thirdly: what is set theRecord to the content record supposed to do? Or rather ā€“ why donā€™t you simply loop over the content of theSelection?

If I understand you/the code correctly,

  • you are working with a single DT document in MD format
  • this document contains URLs with the dayone protocol
  • you want to replace these URLs with DT item links
    • for that, the user inputs a date
    • the script searches for a record containing this date in its name
    • if it finds at least one, it retrieves its reference URL (Iā€™m not sure what that is, though)

ā€¦ No, thatā€™s not what is supposed to be happening. Iā€™m sorry, all these set theSomething to something else lines are too confusing for me. Could you ask Dayone for the date of the document referred to by the URL?

I guess (!) that somewhere in extractBetween and replaceText, thereā€™s a bug. But that stuff is unintelligible to me. Could you provide a before/after example that shows what you want to achieve?

2 Likes

Many thanks for the kind response.

Thank you: done.

Yesā€”see:

Sorry if it wasnā€™t clear that I was in fact watching the actual sequence of events in Script Editor, but I was.

Um, erā€¦oh dear, I canā€™t now recall.

Your summary of the code is correctā€”and there will only ever be one record in DEVONthink containing in its name the date for which Iā€™m searching.

Asking Day One for the date of a linked record is, Iā€™m afraid, well beyond my programming powers as it involves interrogation of a SQLite database.

Yes, of course: I should have done that before. This is what comes from Day One:

[21 February 2024](dayone://view?entryId=6B69F090ED8C496E8D089CF23953ACD5)

[16 February 2024](dayone://view?entryId=D55FC3B6702C49DEB0E09AA0687033B4)

[31 December 2023](dayone://view?entryId=FC43F460385048E8959DA10438E1199E)

[6 January 2024](dayone://view?entryId=44E7F982F4D045EC86789A6F040667BA)

This is what should be achieved:

[21 February 2024](x-devonthink-item://BD971A08-9A5F-4ADA-B8D8-CAD2B53A8E02)

[16 February 2024](x-devonthink-item://16D58A16-EC9E-4CBD-A3A2-5C86B905ED3A)

[31 December 2023](x-devonthink-item://236E67CA-6FC2-4372-9B54-5DDEB4C86D23)

[6 January 2024](x-devonthink-item://8C3ADCD6-44B7-4C84-BDD0-057328AD5FB0)

Stephen

Am I right to assume that 21 February 2024 in the link name means that youā€™re searching for a DT record with 2024-02-21 in its name? Assuming that, I suggest this JavaScript code (sorry, thatā€™s what I speak fluently. String operations are just a PITA in AppleScript). Thereā€™s a very chatty explanation of the code following it.

(() => {
  const databaseName = 'Whatever';
  const monthNames = ["ignore", "January", "February", "March",
    "April", "May", "June", "July", "August", "September",
    "October", "November", "December"];
  const linkMapping = {};
  const app = Application("DEVONthink 3");
  const record = app.selectedRecords() ? app.selectedRecords[0] : undefined;
  if (record && record.type() === 'markdown') {
    const database = app.databases[databaseName].root();
    const allDayOneLinks = record.plainText().matchAll(/\[(.*?)\]\((.*?)\)/g);
    [...allDayOneLinks].forEach(link => {
      const linkTitle = link[1];
      const dateMatch = linkTitle.match(/(\d+)\s+(\w+)\s(\d{4})/);
      if (!dateMatch) return;
      const monthNumeral = monthNames.indexOf(dateMatch[2]);
      if (monthNumeral < 1) return;
      const dateString = `${dateMatch[3]}-${(''+monthNumeral).padStart(2,'0')}-${dateMatch[1].padStart(2,'0')}`;
      const dtRecord = app.search(`name: ${dateString} type: markdown`, {in: database});
      if (!dtRecord || dtRecord.length < 1) return;
      linkMapping[link[2]] = `x-devonthink-item://${dtRecord.uuid()}`;
    })
    Object.keys(linkMapping).forEach(originalLink => {
      record.plainText = record.plainText().replaceAll(originalLink, linkMapping[originalLink]);
    })
  }
})()

Letā€™s dissect that.

  • The code is contained in an anonymous, self-executing function (() => {ā€¦})()
  • It defines some constants at the very beginning, notably the array monthNames (a list in AppleScript) containing all English month names, the Application object for DT (roughly equivalent to a tell block in AS) and the currently selected record.
    • the latter is set to undefined if nothing is selected
  • If the record is not undefined and its type is MD
    • the database to search in is defined in a constant
    • all links matching a regular expression are extracted from the recordā€™s text into the object allDayOneLinks
    • this regular expression is \[(.*?)\]\((.*?)\), translating into
      • opening [
      • followed by any number of anything, but not too much (see below), collecting in the first capturing group
      • followed by ]
      • followed by (
      • followed by any number of anything, but not too much (see below, again), collected in the second capturing group
      • followed by )
    • Now, [...allDayOneLinks] creates an array, over which the code iterates with forEach. Every element of this array is again an array whose 2nd and 3rd elements are the content of the first and second capturing group from the regular expression. So, for your first link, the link looks like this: [..., "21 February 2024", "dayone://view?entryId=6Bā€¦"]
    • The link title (ā€œ21 February 2024ā€) is stored in linkTitle
    • Then, a match dissects this title into the date components using another regular expression
      • namely (\d+)\s+(\w+)\s(\d{4}): Any number of digits (1st capturing group), followed by at least one space, any number of word characters (2nd capturing group), followed by at least one space, exactly for digits (3rd capturing group)
    • If that regular expression is not matched, the code continues with the next link. Thatā€™s just a very basic error check
    • Then, the month name from the 2nd capturing group is converted to a number by finding the name in the monthNames array and using its index there.
    • If the month is not found, the code continues with the next link (another basic error check)
    • Now the dateString is built from the 1st and last capturing groups of the previous regular expression and the month number just determined. Month and day numbers are 0-padded at the front
    • The script then tries to find a matching markdown record in the database
    • If itā€™s successful, it adds this recordā€™s reference URL to the object linkMapping
      • This is similar to a record in AppleScript, the keys being the DayOne URLs and their values being the corresponding DT reference URL
  • When all DayOne links have been handled,
    • the code iterates over the linkMapping array, using forEach and replaces every occurrence of a DayOne link in the recordā€™s text with the corresponding DT item link.

Here comes the ā€œsee belowā€ part from the dissection of the first regular expression:
In regular expressions, a * (asterisk) means, ā€œmatch the preceding character any number of times (also zero)ā€. So, .* means: ā€œmatch any character any number of timesā€. \[.*\] would therefore _not_do what we want here because the .* eats the closing ] ā€“ it is ā€œgreedyā€. Appending ? to .* makes the * ā€œungreedyā€ ā€“ it stops matching as early as possible.

Yes, thatā€™s correct.

Many thanks for all your most kind workā€”and particularly for the explanation. You obviously recall:

  • I have a JavaScript allergy; and
  • I hate using languages I donā€™t understand.

Thatā€™s why I really appreciate the highly detailed explanation. I shall still be rather sad to move from code I do (more or less) understand to JavaScript but what you kindly provide may well provide the solution I have to use for what is, for me, a crucial step. Let me experiment a littleā€¦

(Iā€™d still like to know whatā€™s wrong with the AppleScript, of course, if anyone has any idea!)

Stephen

I regret I seem to be falling at the first hurdle. Having inserted the name of the database (which is simply ā€˜Diariesā€™) I get two sequential errors when I try to run the script twice:

Error: TypeError: dtRecord.referenceUrl is not a function. (In 'dtRecord.referenceUrl()', 'dtRecord.referenceUrl' is undefined)
and
Error: SyntaxError: Can't create duplicate variable: 'databaseName'

Perhaps I should mention the database is located in ~/Documents/DEVONthinkā€¦just in case that makes any difference.

Stephen

Interestingly, the referenceUrl is not defined for the record class (or at least I donā€™t see it there).
So, replace this
linkMapping[link[2]] = dtRecord.referenceUrl();
with this
linkMapping[link[2]] = `x-devonthink-item://${dtRecord.uuid()}`;

Thatā€™s unfortunately in the part I couldnā€™t test because I didnā€™t have matching records.

That goes away if you move
const databaseName = 'Whatever';
right behind
(() => {
on a new line. The error happens in every run after the first one in Script Editor, because than databaseName is redefined, which isnā€™t allowed for a constant.

It doesnā€™t ā€“ the script is using the name of the database, as was yours.

On a meta level: Itā€™s the language that is ā€œwrongā€, or rather not up to the purpose of string processing. The loops it forces people to do something that should be simple (like finding or replacing text) are just a waste of time and energy.

But you were asking about your codeā€¦ I suggest peppering it with log statements or running it stepwise in Script Debugger. That allows you to see exactly what happens in every step, on a finer-grained level than looking at Apple Events.

In addition, Iā€™d use other variable names. All these ā€œtheSomethingā€ and ā€œtheSomethingElseā€ doesnā€™t help legibility (and consequently doesnā€™t help to understand) ā€“ one always has to first read and then strip the ā€œtheā€. I know that this is not your invention, but it was never a good one.

After some back and fro behind the scenes, we now have working code. Iā€™ve updated the script accordingly.

1 Like