Is it possible to build a summary including thumbnails?

Context: very old (20+ years) databases

Goal: to summarize what’s in the various groups - which have text, pdf, images - and present it in a kind of “magazine” way so that purely text records have a brief summary, images have a title and a thumbnail.

I tried using MCP, it can’t (says I would need to export the images locally, use APIs, other tools, etc). So I believe I really can’t - but who knows?

It would be nice to have a kind of a summarized, with little images, view of what’s in these older databases.

Any ideas?

A script can do that. Of course :wink: But it’s a bit of work, since it would need to get the quicklook image from the record.

I did something similar some years ago:

AppleScript. The following script was created by DEVONthink’s script assistant using Claude Opus 5.5 and simply pasting your post.

-- Magazine-style overview of selected DEVONthink groups
-- Text records get a summary or excerpt, images and PDFs get a thumbnail.
-- Select one or more groups (or individual documents), then run.

property maxItemsPerGroup : 80 -- cap cards per section to keep the page usable
property maxDepth : 6 -- how deep to descend into subgroups
property excerptLength : 450 -- characters for fast excerpts
property aiInputLength : 6000 -- characters sent to the AI per record
property gSections : {}

on run
	set my gSections to {}
	
	tell application id "DNtp"
		try
			set theSelection to selected records
			if theSelection is {} then error "Please select one or more groups (or documents) first."
			
			set modeChoice to button returned of (display dialog "Create a magazine-style overview of the selection." & return & return & "Text summaries:" buttons {"Cancel", "AI Summary (slow)", "Excerpt (fast)"} default button 3 cancel button 1 with title "Magazine Overview")
			set useAI to (modeChoice starts with "AI")
			if useAI and (current chat engine is missing value) then
				display dialog "No AI chat engine is configured in DEVONthink's settings. Excerpts will be used instead." buttons {"OK"} default button 1 with title "Magazine Overview"
				set useAI to false
			end if
			
			-- Gather sections: one per group (recursively), plus loose selected documents
			set looseDocs to {}
			repeat with aRecord in theSelection
				set recType to record type of aRecord
				if recType is group then
					my collectSections(aRecord, 1)
				else if recType is not smart group then
					set end of looseDocs to aRecord
				end if
			end repeat
			if looseDocs is not {} then
				set end of my gSections to {sectionTitle:"Selected Documents", sectionItems:looseDocs, totalCount:(count of looseDocs)}
			end if
			
			if (count of my gSections) is 0 then error "No documents were found in the selection."
			
			set totalCards to 0
			repeat with aSection in my gSections
				set totalCards to totalCards + (count of (sectionItems of aSection))
			end repeat
			
			show progress indicator "Building magazine overview…" steps totalCards with cancel button
			
			set htmlParts to {}
			set end of htmlParts to my htmlHeader()
			set dbName to name of database of (item 1 of theSelection)
			set end of htmlParts to "<h1>" & my htmlEscape(dbName) & "</h1>"
			set end of htmlParts to "<div class=\"subtitle\">An overview of " & totalCards & " documents in " & (count of my gSections) & " sections · compiled " & my formatDate(current date) & "</div>"
			
			set wasCancelled to false
			repeat with aSection in my gSections
				if wasCancelled then exit repeat
				set theItems to sectionItems of aSection
				set textCount to 0
				set pdfCount to 0
				set imageCount to 0
				set otherCount to 0
				set cardParts to {}
				
				repeat with anItem in theItems
					if cancelled progress then
						set wasCancelled to true
						exit repeat
					end if
					step progress indicator (name of anItem as string)
					set cardResult to my buildCard(anItem, useAI)
					set end of cardParts to cardHTML of cardResult
					set cardCategory to category of cardResult
					if cardCategory is "text" then
						set textCount to textCount + 1
					else if cardCategory is "pdf" then
						set pdfCount to pdfCount + 1
					else if cardCategory is "image" then
						set imageCount to imageCount + 1
					else
						set otherCount to otherCount + 1
					end if
				end repeat
				
				set end of htmlParts to "<h2>" & my htmlEscape(sectionTitle of aSection) & "</h2>"
				set end of htmlParts to "<div class=\"stats\">" & (totalCount of aSection) & " documents · " & textCount & " text · " & pdfCount & " PDF · " & imageCount & " images · " & otherCount & " other</div>"
				set end of htmlParts to "<div class=\"grid\">" & my joinList(cardParts, "") & "</div>"
				if (totalCount of aSection) > (count of theItems) then
					set end of htmlParts to "<div class=\"more\">… and " & ((totalCount of aSection) - (count of theItems)) & " more documents not shown.</div>"
				end if
			end repeat
			
			set end of htmlParts to "</body></html>"
			hide progress indicator
			
			set htmlSource to my joinList(htmlParts, return)
			set destGroup to incoming group of database of (item 1 of theSelection)
			if destGroup is missing value then set destGroup to root of database of (item 1 of theSelection)
			
			set newRecord to create record with {name:"Magazine Overview - " & dbName & " - " & my formatDate(current date), type:HTML, source:htmlSource} in destGroup
			if newRecord is not missing value then
				open window for record newRecord
				if wasCancelled then display dialog "Cancelled - a partial overview was created." buttons {"OK"} default button 1 with title "Magazine Overview"
			else
				error "The overview document could not be created."
			end if
			
		on error errMsg number errNum
			try
				hide progress indicator
			end try
			if errNum is not -128 then display alert "Magazine Overview" message errMsg as warning
		end try
	end tell
end run

-- Recursively collect one section per group containing documents
on collectSections(theGroup, depthLevel)
	tell application id "DNtp"
		set docList to {}
		set subGroups to {}
		set theChildren to children of theGroup
		repeat with aChild in theChildren
			set childType to record type of aChild
			if childType is group then
				set end of subGroups to aChild
			else if childType is not smart group then
				set end of docList to aChild
			end if
		end repeat
		
		set totalDocs to count of docList
		if totalDocs > (my maxItemsPerGroup) then set docList to items 1 thru (my maxItemsPerGroup) of docList
		if totalDocs > 0 then
			set groupLocation to location with name of theGroup
			if groupLocation is missing value then set groupLocation to name of theGroup
			set sectionLabel to (name of database of theGroup) & " ▸ " & groupLocation
			set end of my gSections to {sectionTitle:sectionLabel, sectionItems:docList, totalCount:totalDocs}
		end if
		
		if depthLevel < (my maxDepth) then
			repeat with aSub in subGroups
				my collectSections(aSub, depthLevel + 1)
			end repeat
		end if
	end tell
end collectSections

-- Build a single card; returns {cardHTML:..., category:...}
on buildCard(theRecord, useAI)
	tell application id "DNtp"
		set recName to name of theRecord
		set recType to record type of theRecord
		set recLink to reference URL of theRecord
		set recKind to kind of theRecord
		if recKind is missing value then set recKind to ""
		set recDate to creation date of theRecord
		set recPath to path of theRecord
		
		set thumbSrc to ""
		set summaryText to ""
		set metaExtra to ""
		set cardCategory to "other"
		
		if recType is picture then
			set cardCategory to "image"
			if recPath is not missing value and recPath is not "" then set thumbSrc to my fileURL(recPath)
			try
				set recDims to dimensions of theRecord
				if (count of recDims) is 2 then set metaExtra to ((item 1 of recDims) as integer as string) & "×" & ((item 2 of recDims) as integer as string) & " px"
			end try
			
		else if recType is PDF document then
			set cardCategory to "pdf"
			-- WebKit renders the first page of a PDF inside an img tag
			if recPath is not missing value and recPath is not "" then set thumbSrc to my fileURL(recPath)
			try
				set metaExtra to ((page count of theRecord) as string) & " pages"
			end try
			set summaryText to my summarizeRecord(theRecord, useAI)
			if summaryText is "" then set summaryText to "(No text layer - possibly a scan.)"
			
		else if recType is in {txt, RTF, RTFD, formatted note, markdown, HTML, webarchive, email, XML} then
			set cardCategory to "text"
			try
				set metaExtra to ((word count of theRecord) as string) & " words"
			end try
			set summaryText to my summarizeRecord(theRecord, useAI)
			if summaryText is "" then set summaryText to "(Empty document.)"
			
		else if recType is bookmark then
			set recURL to URL of theRecord
			if recURL is not missing value then set summaryText to recURL
			
		else if recType is multimedia then
			try
				set recDuration to duration of theRecord
				set metaExtra to ((round (recDuration / 60) rounding down) as string) & " min " & (((recDuration as integer) mod 60) as string) & " s"
			end try
			
		else if recType is sheet then
			try
				set recColumns to columns of theRecord
				if recColumns is not missing value then set summaryText to "Columns: " & my joinList(recColumns, ", ")
			end try
		end if
	end tell
	
	-- Assemble card HTML
	set metaLine to my htmlEscape(recKind)
	if recDate is not missing value then set metaLine to metaLine & " · " & my formatDate(recDate)
	if metaExtra is not "" then set metaLine to metaLine & " · " & my htmlEscape(metaExtra)
	
	set cardHTML to "<div class=\"card " & cardCategory & "\">"
	if thumbSrc is not "" then
		set cardHTML to cardHTML & "<a href=\"" & recLink & "\"><img src=\"" & thumbSrc & "\" loading=\"lazy\" onerror=\"this.style.display='none'\"></a>"
	end if
	set cardHTML to cardHTML & "<div class=\"body\"><h3><a href=\"" & recLink & "\">" & my htmlEscape(recName) & "</a></h3>"
	set cardHTML to cardHTML & "<div class=\"meta\">" & metaLine & "</div>"
	if summaryText is not "" then
		set cardHTML to cardHTML & "<div class=\"summary\">" & my replaceText(my htmlEscape(summaryText), linefeed, "<br>") & "</div>"
	end if
	set cardHTML to cardHTML & "</div></div>"
	
	return {cardHTML:cardHTML, category:cardCategory}
end buildCard

-- Returns an AI summary or a plain excerpt
on summarizeRecord(theRecord, useAI)
	tell application id "DNtp"
		set recText to ""
		try
			set recText to plain text of theRecord
		end try
		if recText is missing value then set recText to ""
	end tell
	if recText is "" then return ""
	
	if useAI then
		try
			set aiInput to my truncateText(recText, my aiInputLength)
			tell application id "DNtp"
				set aiResult to summarize text aiInput as text summary
			end tell
			if aiResult is not missing value and aiResult is not "" then return aiResult
		end try
	end if
	
	-- Fallback / fast mode: cleaned excerpt
	set cleanText to my replaceText(recText, return, " ")
	set cleanText to my replaceText(cleanText, linefeed, " ")
	set cleanText to my replaceText(cleanText, tab, " ")
	repeat 4 times
		set cleanText to my replaceText(cleanText, "  ", " ")
	end repeat
	if (length of cleanText) > (my excerptLength) then
		return (my truncateText(cleanText, my excerptLength)) & "…"
	end if
	return cleanText
end summarizeRecord

on truncateText(theString, maxLength)
	if (length of theString) > maxLength then
		return characters 1 thru maxLength of theString
	end if
	return theString
end truncateText

on fileURL(posixPath)
	set encodedPath to my replaceText(posixPath, "%", "%25")
	set encodedPath to my replaceText(encodedPath, " ", "%20")
	set encodedPath to my replaceText(encodedPath, "#", "%23")
	set encodedPath to my replaceText(encodedPath, "?", "%3F")
	set encodedPath to my replaceText(encodedPath, "\"", "%22")
	set encodedPath to my replaceText(encodedPath, "'", "%27")
	set encodedPath to my replaceText(encodedPath, "<", "%3C")
	set encodedPath to my replaceText(encodedPath, ">", "%3E")
	return "file://" & encodedPath
end fileURL

on htmlEscape(theString)
	set theString to theString as string
	set theString to my replaceText(theString, "&", "&amp;")
	set theString to my replaceText(theString, "<", "&lt;")
	set theString to my replaceText(theString, ">", "&gt;")
	set theString to my replaceText(theString, "\"", "&quot;")
	return theString
end htmlEscape

on replaceText(theString, findString, replaceString)
	set oldDelims to AppleScript's text item delimiters
	set AppleScript's text item delimiters to findString
	set textParts to text items of theString
	set AppleScript's text item delimiters to replaceString
	set theString to textParts as string
	set AppleScript's text item delimiters to oldDelims
	return theString
end replaceText

on joinList(theList, theDelimiter)
	set oldDelims to AppleScript's text item delimiters
	set AppleScript's text item delimiters to theDelimiter
	set joinedText to theList as string
	set AppleScript's text item delimiters to oldDelims
	return joinedText
end joinList

on formatDate(theDate)
	try
		return short date string of theDate
	on error
		return ""
	end try
end formatDate

on htmlHeader()
	set cssText to "body{font-family:Georgia,'Times New Roman',serif;background:#f4f1ea;color:#222;margin:0;padding:30px 40px;}" & ¬
		"h1{font-size:2.6em;border-bottom:4px double #333;padding-bottom:8px;margin-bottom:4px;}" & ¬
		".subtitle{font-style:italic;color:#666;margin-bottom:30px;}" & ¬
		"h2{font-family:'Helvetica Neue',Arial,sans-serif;font-size:1.3em;margin-top:44px;border-bottom:1px solid #999;padding-bottom:4px;}" & ¬
		".stats{font-family:'Helvetica Neue',Arial,sans-serif;font-size:0.8em;color:#777;margin-bottom:14px;}" & ¬
		".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:20px;}" & ¬
		".card{background:#fff;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.15);overflow:hidden;display:flex;flex-direction:column;}" & ¬
		".card img{width:100%;height:180px;object-fit:cover;background:#ddd;display:block;}" & ¬
		".card.pdf img{object-fit:contain;object-position:top;background:#eee;}" & ¬
		".body{padding:12px 14px;}" & ¬
		".card h3{font-size:1.05em;margin:0 0 6px 0;line-height:1.25;}" & ¬
		".card h3 a{color:#111;text-decoration:none;}" & ¬
		".card h3 a:hover{text-decoration:underline;}" & ¬
		".meta{font-family:'Helvetica Neue',Arial,sans-serif;font-size:0.72em;color:#888;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;}" & ¬
		".summary{font-size:0.9em;line-height:1.45;color:#333;}" & ¬
		".card.text{border-top:4px solid #8a5a44;}" & ¬
		".card.image{border-top:4px solid #44708a;}" & ¬
		".card.pdf{border-top:4px solid #a33;}" & ¬
		".card.other{border-top:4px solid #888;}" & ¬
		".more{font-style:italic;color:#888;margin-top:10px;}"
	return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Magazine Overview</title><style>" & cssText & "</style></head><body>"
end htmlHeader

Alternatively a skill for DEVONthink, see below. Again created by simply pasting your post and using Claude Opus 5.5, this time in Settings > AI > Library.

Database Magazine Overview.dtSkill.zip (2.6 KB)

That’s almost shocking. I’m fiddling around with this task for some hours now and haven’t even understood it as the AI has. OTOH, I’m learning a lot on the way, so the time was well spend.

As to the code: Some comments might not have harmed. And perhaps thumbnails (QuickLook?) for those documents for which them are possible (yes, that was not in the requirements).

And I’d of course have used something like list.join(theDelimiter), theString.replace(findString, replaceString), encodeURIComponent, theString.truncate(maxLength) etc instead of verbose functions :wink: Perhaps even `cleanText = recText.replaceAll(/[\n\t\r]/g,’ ').replaceAll(" “,” "). But I guess an AI doesn’t mind writing such code over and over again. Or even check the length for a truncation condition twice… Ah, well. It’s fast.

2 Likes

Learning how to quickly and efficiently use AI is also time well spent, IMHO. In the end it’s just another tool, like a compiler or editor is. And times are changing or would anyone still prefer to do everything using punched card programming or assembler? :wink:

P.S: And I did a lot of assembler when I was young(er) :rofl:

Thanks so much @cgrunenberg !!

I am digesting (no pun intended) all you and @chrillek have posted, and I aam thrilled - thanks again guys!

I ran both @cgrunenberg applescript and the skill, pointing them to a group with 125 records. It is getting there, and I have to study them more.

I got the following:

Applescript Excerpt (fast) - click the images navigates to original file

Applescript AI Summary - this one didn’t generate what I expected. The result was exactly the same as Excerpt, and it took the same time to run, and no summary…

The new Skill.

This was interesting. I ran the following prompt “using skill magazine create a magazine for the contents of record “:1978 TEST Randonization” in database TESTING”

The md had a neat summary, but I guess I have to adjust the skill (?) because I really want something different (more at the bottom).

I can refine a bit better what I want as:

List everything inside a group, showing a thumbnail and a 1-2 line description of the metadata of every record inside, including groups. If the record is an image or a pdf without a text layer, analyze the image and provide a 1-3 line summary of what the image or pdf is. If the record has text, summarize it.

  • I am sure this is horribly deficient, but it is a start.

:slight_smile: IBM cards here

My prompt:

List everything inside “1978 TEST Randonization” in the TESTING database., showing a thumbnail and a 1-2 line description of the metadata of every record inside, including groups. If the record is an image or a pdf without a text layer, analyze the image and provide a 1-3 line summary of what the image or pdf is. If the record has text, summarize it.

The results:

“1978 TEST Randonization”contains 123 records: 116 images, 5 PDFs, and 2 AppleScript records. It has no child groups.

I can’t embed actual thumbnails in a complete catalog with the available tools: I can inspect an image, but can’t export its image data for use in an HTML or Markdown list. The 116 images would also make a very long catalog.

Would you like me to provide the inventory here in chat, with linked titles and descriptions based on available title and metadata, or create a report record without embedded thumbnails?

So, why can’t I embed actual thumbnails, etc?

Which provider & model do you use in DEVONthink? See Settings > AI > Chat. By default the skill uses Claude Sonnet 5.5, this can be changed in Settings > AI > Library. To use it, simply type /magazine in the chat assistant, the groups should be selected first. It will create a document in your database (just like the script)

Thanks! it created a nice magazine:

Also, taking your Applescript and iterating a bit in Claude Desktop, I also got this - which I am very happy about.

1 Like