Skip to content

Conversation

@SKaplanOfficial
Copy link
Contributor

@SKaplanOfficial SKaplanOfficial commented Apr 5, 2025

I've added AppleScript equivalents for all of the Shortcuts, plus support for the AppleScript standard dictionary and text suite. The full supported terminology can be viewed via Script Editor's File->Open Dictionary menu or in MarkEditMac/Resources/MarkEdit.sdef

I haven't done much Swift development, so I probably used odd approaches in some places. Let me know if I need to rework anything. Also, I'm not sure why Info.plist got all rearranged — is there something I can do to fix that?

Here's a test script demonstrating what's currently supported:

tell application "MarkEdit"
	-- Create new documents
	set doc1 to make new document with properties {name:"test1.md"}
	set doc2 to make new document with properties {name:"test2.md"}
	set doc3 to make new document with properties {name:"test3.md"}

	-- Modify file content
	delay 0.5
	set source of doc1 to "# Hello, world!

The *quick* brown fox **jumps** over the lazy *dog*.

- Item 1
- Item 2
- Item 3"
	set source of doc2 to "# Testing..."
	set source of doc3 to source of doc2

	set targetDoc to first document whose source contains "dog"

	-- Get rich text details
	set italicWords to words of formatted text of targetDoc whose font contains "Oblique"
	if italicWords is {"quick", "dog"} then
		set targetWindow to first window where its document is targetDoc
		set index of targetWindow to 1

		tell targetDoc
			-- Run JavaScript on documents
			evaluate JavaScript "document.body.style.zoom = '200%'"

			-- Save documents
			set outputPath to POSIX file (POSIX path of (path to downloads folder) & "test1.md")
			close saving in outputPath
		end tell
	end if

	close doc2 without saving
	close doc3 without saving
end tell

Known issues:

  • Specifying the source property when using the make command raises an error. This is because setting source relies on there being an existing webview to call replaceText on. Current workaround: Use a momentary delay after make, then use set source to ....
  • The color property of rich text elements is always {0, 0, 0}. This is expected since there is no color information included in the document's source. A future improvement could be to process the markdown source and add colors based on the current theme.

Closes #854.

@cyanzhong
Copy link
Contributor

Thank you for the contribution, Stephen!

Regarding the plist change, it looks strange to me too. Can you share the actual changes needed here?

Is it just:

<key>NSAppleScriptEnabled</key>
<true/>
<key>OSAScriptingDefinition</key>
<string>MarkEdit.sdef</string>

INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMainStoryboardFile = Main;
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_NAME).Application";
INFOPLIST_KEY_NSPrincipalClass = MarkEdit.Application;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is using static name here required?

get {
return self.stringValue
}
set(newValue) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: (newValue) can be omitted.

import Carbon
import MarkEditKit

extension EditorDocument {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we name source and formattedText to be more scripting specific? Since we are creating extension that can be used in the entire app.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these are something to be used in the script, it's fine to keep them clean (or follow some conventions).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, would prefixing them with scripting be enough? E.g. scriptingSource?

/// Executes a user-provided JS script in the document's webview.
@objc func handleEvaluateCommand(_ command: NSScriptCommand) -> Any? {
guard let inputString = command.evaluatedArguments?["script"] as? String else {
let scriptError = ScriptError.missingInput(message: "No JavaScript script provided")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this user-facing string? I can localize it later if so.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would get presented to users as an AppleScript error, but I think it would only occur if the incoming event data gets corrupted, which is very rare. Just omitting the script parameter or supplying a non-text value gets handled by AppleScript before sending any messages to the application.

Maybe it's not worth keeping the message? The error number would still appear anyway.

return nil
}

guard let targetEditor = self.windowControllers.first?.contentViewController as? EditorViewController else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: code convention wise, I prefer to hide self. if we can. I can do a cleanup later.

static let RGBColorCoefficient: CGFloat = 65535

/// Unpacks incoming color descriptors into NSColor objects.
@objc func scriptRGBColor(with descriptor: NSAppleEventDescriptor) -> NSColor? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, are these functions called by scripts? Don't see a caller in the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get called automatically by the Apple Event Manager when users send/receive color objects via AppleScript. They're normally automatically generated at runtime, but that's skipped for colors due to how the text suite defines them.

}

return NSColor(
calibratedRed: CGFloat(rgbColor.red) / Self.RGBColorCoefficient,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not an expert of this, just making sure we are using correct initializer, is calibrated needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, the only clear reference for those methods is iterm2. I think the unpack method can use any NSColor init method, but I'd need to check.

// Created by Stephen Kaplan on 4/2/25.
//

import Carbon
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is deprecated, does Foundation work?

Copy link
Contributor Author

@SKaplanOfficial SKaplanOfficial Apr 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, keyASUserRecordFields is only defined under Carbon, but without it, I don't know another way to construct valid NSAppleEventDescriptors for dictionaries with unknown keys, which is needed for handling results from JavaScript evaluation. Apple still uses it in non-archived documentation.

I can use the raw OSType int value instead, which I guess it better.

case .noActiveEditor:
return -3
case .jsEvalError:
return -1702
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why -1702?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, those values were placeholders. Was going to use the OSType of the AS event class instead.

@cyanzhong
Copy link
Contributor

cyanzhong commented Apr 6, 2025

Thanks again, Stephen. The change overall LGTM, I just left some comments.

Regarding this:

Specifying the source property when using the make command raises an error. This is because setting source relies on there being an existing webview to call replaceText on. Current workaround: Use a momentary delay after make, then use set source to ....

I couldn't think of a better way either. I used similar delays in the app to handle a similar case (see newFileFromClipboard).

I have one more general question about the change, since MarkEdit only handles plain text, why is rich text support needed?

@SKaplanOfficial
Copy link
Contributor Author

Thank you for the contribution, Stephen!

No problem, it was a fun challenge to tackle!

Is it just:

<key>NSAppleScriptEnabled</key>
<true/>
<key>OSAScriptingDefinition</key>
<string>MarkEdit.sdef</string>

I believe so, but I'll have to make sure. Apple Events are very picky. Won't get the changes done until tomorrow, but I'll respond to your other comments now.

I couldn't think of a better way either. I used similar delays in the app to handle a similar case (see newFileFromClipboard).

Hm, I was thinking about looking into @Observable. If we separate the stringValue and other properties derived from the webview bridge into an observable class, then both EditorDocument and EditorViewController could react to changes more easily, maybe. Could be a big change, so definitely out of scope for this.

I have one more general question about the change, since MarkEdit only handles plain text, why is rich text support needed?

The text suite (which includes the 'rich text' AppleScript class) is the recommended way to access individual words, characters, etc. from AppleScript since repeating over elements is much, much faster in Swift/ObjC, so that's the reason for the additional property in general.

Then it just seemed reasonable to use Swift's markdown support in NSAttributedString so you can detect when text is italic/bold without including the surrounding symbols in the "words" subproperty.

@cyanzhong
Copy link
Contributor

I would like to update the wiki after we merge the change and looking for these example:

  • Getting the content of an opened document
  • Replacing the selection with some text

Could you please kindly provide some example scripts? Thanks!

@cyanzhong
Copy link
Contributor

For the plist change, did you use Xcode to generate the change, or did you modify the plist file directly?

Xcode may re-generate the entire file which may lead to unwanted changes.

@SKaplanOfficial
Copy link
Contributor Author

SKaplanOfficial commented Apr 6, 2025

Made those changes, and added the error message strings to AppResources.Localized.Scripting.

* Replacing the selection with some text

Knew I was forgetting something. Added the selection property in the latest commit.

Made some examples here: https://gist.github.com/SKaplanOfficial/9637cd69e2b21b1749521aafa1f71c95

For the plist change, did you use Xcode to generate the change, or did you modify the plist file directly?

I used Xcode, so that explains it! I've reverted those changes and manually added the ones for AppleScript.


<class name="document" code="docu" description="A MarkEdit document." plural="documents" inherits="document">
<cocoa class="MarkEdit.EditorDocument"/>
<property name="source" code="mdsc" type="text" access="rw" description="The raw markdown source of the document.">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean the variable name in scripts is source, which uses scriptingSource in code?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly.

- Fast: edits 10 MB files easily
- Lightweight: installer size is about 3 MB
- Extensible: seamless Shortcuts integration
- Extensible: seamless integration with Shortcuts and AppleScript
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@cyanzhong
Copy link
Contributor

cyanzhong commented Apr 7, 2025

I just tested and it works really well, going to check this in and do some follow-up changes like localization and cleanup, if you don't mind.

@SKaplanOfficial
Copy link
Contributor Author

Sounds good. Let me know if you need anything from me.

@cyanzhong cyanzhong merged commit 461983c into MarkEdit-app:main Apr 7, 2025
1 check passed
}

// Preserve newlines for better error reporting
let jsText = inputString.replacingOccurrences(of: "\\n", with: "\\\\n")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SKaplanOfficial this quite interesting to me, does this really work?

If we are replacing \n to \\n in the scripts, should it be inputString.replacingOccurrences(of: "\n", with: "\\n")?

I don't quite get why we need double backslashes, and why we need to replace them at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we can remove it. You're right, it doesn't look like it'd work anyway.

When I was testing earlier, the evaluate JavaScript command would report errors as if there were only one line (e.g. line 1, column n where n was the length of the entire string up to that point).

Something fixed that problem, but I don't think it was this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Support Automation via AppleScript

2 participants