Lost in translation

I work, write and gather information in DEVONthink in four languages. That’s simply how my work is, and that’s fine – but text search is a mess. I end up running four separate searches, one after another. I might forget a language and miss important material for what I’m writing.

So I’d like to solve this. My idea: create a German search query with all the search operators, then, with a single keyboard shortcut, automatically translate that entire query (with all its operators) into the other three languages. Something like this:

  1. I type into the search field (in German):
    (German word NEAR/2 German word) OR …
  2. I press a keyboard shortcut.
  3. The search phrase is translated into English and the other two languages, preserving the operators:
    (English word NEAR/2 English word) OR … etc.
  4. Those translated queries are appended to the original German query in the search field.

I have no idea how to implement this. Would this require an AppleScript and a Keyboard Maestro shortcut? Since I have no coding skills, I’d probably have to rely on something like Claude Code to generate the script.

Does anyone have an idea how to do this?
Any help or suggestions would be greatly appreciated. Thanks!

Or DEVONthink’s script assistant (see Data > New > Script… if a provider is configured in Settings > AI > Chat). Maybe a little bit bloated but seems to work:

-- ============================================================================
--  Multilingual Search Query Translator for DEVONthink
--  ------------------------------------------------------------------------
--  What it does:
--  1. Reads the query that is currently in the search field of the frontmost
--     main window (if empty, it asks you to type one).
--  2. Sends it to DEVONthink's built-in AI with the instruction to translate
--     ONLY the search terms and to keep all operators (AND, OR, NOT, NEAR/n,
--     parentheses, quotes, wildcards, field prefixes) untouched.
--  3. Appends the translated queries to the original one, combined with OR,
--     writes the result back into the search field and runs the search.
--     The combined query is also put on the clipboard as a backup.
--
--  Requirements: DEVONthink 4 with a configured AI engine
--                (Settings > AI > Chat).
--
--  How to trigger it:
--  - Save as script in ~/Library/Application Scripts/com.devon-technologies.think/Menu/
--    (then it appears in DEVONthink's script menu and can get a shortcut), or
--  - Keyboard Maestro: new macro, hot key trigger (e.g. control-option-command-F),
--    action "Execute an AppleScript" and paste this script into it.
--
--  Adjust the languages below to your four languages.
-- ============================================================================

property sourceLanguage : "German"
property targetLanguages : {"English", "French", "Spanish"}
property chatTemperature : 0.0
property chatModel : "" -- empty = DEVONthink's default model, e.g. "gpt-4.1"

-- ============================== main routine ===============================

tell application id "DNtp"
	try
		if (count of main windows) is 0 then error "Please open a DEVONthink main window first."
		set theWindow to main window 1
		
		-- current content of the search field
		set originalQuery to ""
		try
			set originalQuery to my trimBlanks((search query of theWindow) as string)
		end try
		
		if originalQuery is "" then
			set enteredQuery to display name editor "Multilingual Search" info "Enter the " & sourceLanguage & " search query (operators allowed):" default answer ""
			if enteredQuery is missing value then return
			set originalQuery to my trimBlanks(enteredQuery as string)
		end if
		if originalQuery is "" then error "No search query given."
		
		set thePrompt to my buildPrompt(originalQuery, sourceLanguage, targetLanguages)
		
		show progress indicator "Translating search query…"
		try
			if chatModel is "" then
				set responseText to get chat response for message thePrompt temperature chatTemperature as "text"
			else
				set responseText to get chat response for message thePrompt model chatModel temperature chatTemperature as "text"
			end if
		on error errMsg
			hide progress indicator
			error "The AI request failed: " & errMsg
		end try
		hide progress indicator
		
		if responseText is missing value then error "The AI returned an empty response."
		if my trimBlanks(responseText as string) is "" then error "The AI returned an empty response."
		
		-- parse the answer: one translated query per line
		set fenceMarker to my codeFence()
		set translatedQueries to {}
		repeat with aParagraph in (paragraphs of (responseText as string))
			set rawLine to my trimBlanks(aParagraph as string)
			if rawLine is not "" and (rawLine does not start with fenceMarker) then
				set cleanedLine to my stripDecoration(rawLine, targetLanguages)
				if cleanedLine is not "" then set end of translatedQueries to cleanedLine
			end if
		end repeat
		
		if (count of translatedQueries) is 0 then error "No translated queries could be extracted from the AI response."
		if (count of translatedQueries) > (count of targetLanguages) then
			set translatedQueries to items 1 thru (count of targetLanguages) of translatedQueries
		end if
		
		-- combine original + translations
		set combinedQuery to "(" & originalQuery & ")"
		repeat with aQuery in translatedQueries
			set combinedQuery to combinedQuery & " OR (" & (aQuery as string) & ")"
		end repeat
		
		my setClipboardTo(combinedQuery)
		set search query of theWindow to combinedQuery
		show search combinedQuery
		
		if (count of translatedQueries) < (count of targetLanguages) then
			my showMessage("Multilingual Search", "Only " & ((count of translatedQueries) as string) & " of " & ((count of targetLanguages) as string) & " translations were returned. The search was started with the available ones.")
		end if
		
	on error errMsg number errNum
		if errNum is not -128 then
			try
				hide progress indicator
			end try
			my showMessage("Multilingual Search failed", errMsg)
		end if
	end try
end tell

-- ================================ handlers =================================

on codeFence()
	set backTick to character id 96
	return backTick & backTick & backTick
end codeFence

on buildPrompt(theQuery, srcLang, theLanguages)
	set nl to linefeed
	set langCount to count of theLanguages
	set langList to ""
	repeat with i from 1 to langCount
		set langList to langList & (item i of theLanguages as string)
		if i < langCount then set langList to langList & ", "
	end repeat
	
	set promptText to "You translate search queries for the document manager DEVONthink." & nl & nl
	set promptText to promptText & "Translate the following search query from " & srcLang & " into these languages, in exactly this order: " & langList & "." & nl & nl
	set promptText to promptText & "Rules:" & nl
	set promptText to promptText & "1. Translate ONLY the search terms (single words and quoted phrases)." & nl
	set promptText to promptText & "2. Keep every operator and all syntax completely unchanged: AND, OR, NOT, NEAR/n, BEFORE/n, AFTER/n, parentheses, double quotes, wildcards (* and ?), ~ and field prefixes such as name:, text:, tag:, comment:, kind:, date:, filename:." & nl
	set promptText to promptText & "3. Never translate operators, field prefixes, numbers, units or dates." & nl
	set promptText to promptText & "4. Keep the structure, the order and the nesting of the original query exactly as they are." & nl
	set promptText to promptText & "5. Use the wording a professional of that language would use. If a single term translates into several words, put the translation in double quotes so that it stays one phrase." & nl
	set promptText to promptText & "6. Answer with exactly " & (langCount as string) & " lines: one complete translated query per line, in the order of the languages listed above." & nl
	set promptText to promptText & "7. No language names, no numbering, no bullet points, no quotes around a whole line, no explanations, no code fences." & nl & nl
	set promptText to promptText & "Query:" & nl & theQuery
	return promptText
end buildPrompt

on stripDecoration(givenLine, theLanguages)
	set resultLine to my trimBlanks(givenLine)
	
	-- remove a leading language label like "English:" or "English -"
	repeat with aLang in theLanguages
		set langName to (aLang as string)
		try
			if resultLine starts with (langName & ":") then
				set resultLine to my trimBlanks(characters ((count of langName) + 2) thru -1 of resultLine)
				exit repeat
			else if resultLine starts with (langName & " -") then
				set resultLine to my trimBlanks(characters ((count of langName) + 3) thru -1 of resultLine)
				exit repeat
			end if
		end try
	end repeat
	
	-- remove leading list markers or numbering
	try
		if (count of resultLine) > 2 then
			set firstChar to (character 1 of resultLine) as string
			set secondChar to (character 2 of resultLine) as string
			if firstChar is in {"-", "*", "•"} then
				set resultLine to my trimBlanks(characters 2 thru -1 of resultLine)
			else if firstChar is in {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} and secondChar is in {".", ")", ":"} then
				set resultLine to my trimBlanks(characters 3 thru -1 of resultLine)
			end if
		end if
	end try
	
	-- remove quotes wrapping the complete query (would break the operators)
	try
		if (count of resultLine) > 2 and my quoteCount(resultLine) is 2 then
			if ((character 1 of resultLine) as string) is (my quoteChar()) and ((character -1 of resultLine) as string) is (my quoteChar()) then
				set innerPart to characters 2 thru -2 of resultLine
				if innerPart contains "(" or innerPart contains " OR " or innerPart contains " AND " or innerPart contains " NOT " or innerPart contains "NEAR/" then
					set resultLine to my trimBlanks(innerPart)
				end if
			end if
		end if
	end try
	
	return resultLine
end stripDecoration

on quoteChar()
	return character id 34
end quoteChar

on quoteCount(givenText)
	set savedDelims to AppleScript's text item delimiters
	set AppleScript's text item delimiters to my quoteChar()
	set theCount to (count of text items of givenText) - 1
	set AppleScript's text item delimiters to savedDelims
	return theCount
end quoteCount

on trimBlanks(givenText)
	set blankChars to {" ", tab, return, linefeed}
	set trimmedText to givenText as string
	repeat while trimmedText is not "" and (((character 1 of trimmedText) as string) is in blankChars)
		if (count of trimmedText) is 1 then
			set trimmedText to ""
		else
			set trimmedText to characters 2 thru -1 of trimmedText
		end if
	end repeat
	repeat while trimmedText is not "" and (((character -1 of trimmedText) as string) is in blankChars)
		if (count of trimmedText) is 1 then
			set trimmedText to ""
		else
			set trimmedText to characters 1 thru -2 of trimmedText
		end if
	end repeat
	return trimmedText
end trimBlanks

on setClipboardTo(givenText)
	try
		set the clipboard to givenText
	end try
end setClipboardTo

on showMessage(theTitle, theMessage)
	try
		display alert theTitle message (theMessage as string)
	end try
end showMessage
1 Like

Thanks a lot, Christian! :folded_hands::+1:
I didn’t know that you could use DEVONthink’s Script Assistant for such extensive scriptwriting.
I’m not sure, though, whether I’d be able to formulate prompts for it myself.
I’m looking forward to trying it out as soon as I have the time.
Thanks again!

Usually I just paste raw forum posts to test what the script assistant will deliver. And most of the time the result is useful and functional. E.g. in this case I used your complete first post and Claude Opus 5.

OK! I didn’t know this was possible! I have to try it out! Unfortunately, I am not on one of the high-end AIs, but maybe Perplexity can do the job too once I manage to get it connected. So, thank you for your answer, Christian.

Perplexity’s Sonar Reasoning Pro is supported in the Script Assistant.

Thank you, Jim! Great to know :grinning_face:

You’re welcome.