MojoPad

Scripting: API Reference

Every call the mojo API offers, what it takes, what it hands back, and when it refuses. That last column is the one worth reading. Most of these answer null or false rather than throwing, so a plugin that quietly does nothing is usually a call that quietly refused — and the reason is almost always one of three: the page is locked, the page is a file, or the page is a board that cannot hold loose text.

A ref is a page name or a page id. Names are matched the way links are, so aliases and different capitalization work.

Reading

CallHands backRefuses when
mojo.pages()an array of {id, name, type, tags, aliases} for every pagenever
mojo.currentPage()the same shape, for the page you are looking atnull if no page is open
mojo.read(ref)the page's plain textnull if there is no such page, or it is locked
mojo.readHtml(ref)the page's stored HTMLnull, same reasons
mojo.exists(ref)true if a page answers to this namenever
mojo.getMeta(ref, key)one page-meta valuenull if the page or key is absent. Throws if you pass only one argument
await mojo.readData(ref)the bytes of a file page — a PDF, an image — as an ArrayBuffer. The other half of addFilenull if there is no such page, it is locked, or it is a page of writing rather than a file

Writing

CallHands backRefuses when
mojo.write(ref, text)true if writtenfalse for a missing or locked page, a file page, a canvas, or a card board
mojo.append(ref, text, opts?)true if appendedfalse for a missing or locked page, a file page, or a canvas. Throws if you pass only one argument
mojo.insertAtCursor(text, opts?)true if insertedfalse if no page is open, or it is a file page
await mojo.create(name, text?, opts?)the new page's id — or the existing page's id if the name is takennever; it does not overwrite. A page that was already there keeps the folder it is in
mojo.move(ref, folder){ok, moved, reason?, message?, page?, folder?, from?}ok is whether the page is in that folder now, moved whether anything changedok: false when no page answers to the name, when the text names no folder, or while a preview window or an unsettled change means filing would not last. It never makes a page
mojo.setMeta(ref, key, value)true if writtenfalse for a missing or locked page
await mojo.exportFolder(files, label?)the folder you chose, having written the files into it. files is an array of {name, text}null if you close the chooser. It asks every time and remembers no folder — a plugin that runs when a document opens runs with no gesture from you, and a remembered folder would make that a silent write

Where a script's page lands

A page a script makes, and a file it keeps, arrive Unfiled unless you say otherwise. For one page that is fine. For a plugin that collects every paper a report cites, it is the difference between a library and a heap.

Both create and addFile take a folder:

await mojo.addFile('PMC1234567.pdf', bytes, { folder: 'PDF Library' })
await mojo.create('Reading list', '', { folder: 'Reading/2026' })

A slash nests: 'Reading/2026' means the folder 2026 inside the folder Reading. Folders that are not there yet are made. One that is already there is used again rather than duplicated — matched without regard to case or spacing, so a plugin that runs every morning asking for pdf library lands in the PDF Library it made on the first morning instead of leaving you a folder for every day of the week.

The path is read from the top down. 'Papers' means a folder called Papers at the top level — never one of that name buried further in, which would put a reader's files somewhere the person who wrote the plugin has never seen.

Two things it will not do. A folder name cannot contain a slash, because the slash always nests. And create files only a page it actually made: if the name was already taken it hands back the page that was there and leaves it where you had put it, because a call that does nothing should not quietly move your work.

opts is { format: 'text' } unless you say otherwise. Pass { format: 'html' } to add real markup — a table, a list, a link — rather than the characters that would have written one. Either way the markup is scrubbed the same as anything else that reaches a page.

Moving a page that is already here

Saying where a page lands is only half of filing. The other half is the one that comes up later:

  • A run collected two hundred papers into PDF Library six months ago, and you now want them under Reading by the year they were published.
  • Everything your assistant added last week went to Unfiled, and you want a script to put each one where its Kind says it belongs.
  • You clip all week into Clippings and triage on Fridays, and you would rather a plugin did the obvious ones.

None of those is about a page arriving. They are about a library that already exists, which is what mojo.move is for:

const r = mojo.move('Gait retraining 2021', 'Reading/2021')
if (!r.ok) mojo.log(r.message)

The folder is read exactly as create and addFile read theirs — a slash nests, a folder that is not there is made, and one that is already there is used again whatever the case or spacing. One rule, so the same text means the same folder whichever call you write it in.

It hands back an answer rather than a yes or a no, because there is more than one way for a move not to happen and "false" cannot tell you which:

FieldSays
okwhether the page is in that folder now
movedwhether anything actually changed
reasonone word to switch on: already-there, no-page, no-folder, cannot-file-now
messagethe same thing in a sentence, to show somebody
page / foldertheir names as this wiki spells them
fromthe folder it came out of — absent when it was in none

Branch on ok, never on the answer itself, which is always there and always truthy. A page already in the right folder is ok and not moved: going through a shelf, most of it is usually already in order, and that is a success rather than a failure to report.

let fixed = 0
for (const p of mojo.pages()) {
  const year = mojo.getMeta(p.id, 'Year')
  if (!year) continue
  const r = mojo.move(p.id, `Reading/${year}`)
  if (r.moved) fixed++
  else if (!r.ok) mojo.log(r.message)
}
mojo.log(`${fixed} moved`)

It never makes a page. A name that answers to nothing is refused and says so, rather than becoming a new empty page — a repair run should not turn a typo into an entry in your wiki. It never removes one either: a move is a page changing folders and nothing else.

And it always needs a folder. An empty name is refused rather than read as "take it out of its folder", because that text is nearly always a name that was built out of something that turned out to be blank — and unfiling a library is not a thing to arrive at by accident.

A folder that mirrors one on disk is filed into, not written into. Dragging a page onto a followed folder yourself offers to write it out as a file there, and that offer is a real decision — the page converts, leaves the wiki, and lives on your disk from then on. A script moving a page into one files it in the sidebar and leaves the page where it is, exactly as the sidebar’s own Move does. Writing a file out is a thing you ask for, not something a plugin can arrive at by naming a folder.

A page with a password moves like any other. Everything else a script cannot do to a locked page is about its words; which folder holds it is not one of its words, and dragging one in the sidebar has always worked. The alternative would be that putting a password on your papers means never being able to have a script tidy them, in exchange for hiding nothing.

What a script did to your filing, and taking it back

Filing is an arrangement you made, so a script that rearranges it says so. What a script moves is written into that run’s entry in the Scripts palette, a line per move naming the page and the folder — and when the run ends, one Put back appears covering the whole run, not just its last move.

That is deliberate. Moving a page in the sidebar yourself offers an Undo for that move; a script moves in a loop, so an offer per move would replace itself twenty times over and leave you able to take back only the last one — a way back that looks like one and is not. So the run is the unit: press Put back and every page the run moved goes back where it was, in the position it held, not merely into the right folder. Any folder the run had to invent goes with them, so long as nothing has been put in it since — pages home and nine empty year folders left standing is not the arrangement you had either.

The offer waits. A script on an Event: page finishes while you are looking at something else, so unlike every other pill in the app this one carries no timer: it sits there until you answer it. Press Put back to undo the run’s filing, or close the notice — its , or Escape — to leave the pages where the run put them. If a second run finishes before you have answered the first, the newest is shown and says how many are still waiting behind it; each answer brings the next one forward.

The lasting record is the run’s entry in the Scripts palette, which ends with a line counting what it moved. A long run lists the first twenty-five moves and then says how many it has not listed, so its own output stays readable and you are not left counting twenty-five lines and taking them for the whole run — Put back covers every page either way. Moves it could not make are listed and counted the same way, so a run over a shelf where nothing resolves cannot bury its own log under a line per failure.

Reading from the web

CallHands backRefuses when
await mojo.fetch(url){status, headers, body}, with body as a stringstatus: 0 and an error in words: not https, refused by you, a private address, too large, or too slow
await mojo.fetchData(url)the bytes, as an ArrayBuffer. The one to use for a PDF — fetch decodes what comes back as text, which ruins onenull, with the reason written into this run’s log
await mojo.fetchResult(url)the same bytes with the reason attached: {ok, status, reason, message, url, bytes}never — it always answers, and ok says whether anything arrived

When something does not arrive, fetchResult says which kind of nothing it was. Collecting the papers a report cites, a publisher that refuses automated downloads, a paper that was never there, a file over the size ceiling and a question you declined yourself all used to arrive as the same empty answer. reason is one of http-status, too-large, timeout, network, not-https, blocked-address, declined-now, declined-earlier, redirect-refused, bad-redirect, not-an-address, no-document, unreadable or too-many-redirects — so a script can try another source for one and stop asking for another.

url is where the answer actually came from, after any redirects. A publisher that answers a PDF address with its landing page does so with status 200, and this is how you tell.

The first time a plugin asks for a site, MojoPad asks you — naming the site and the document — and remembers your answer for that document. You are not asked twice for the same site, and a plugin cannot reach anywhere you have not agreed to.

const r = await mojo.fetch('https://api.example.com/thing.json')
if (r.status !== 200) { await mojo.alert(r.error || `Got ${r.status}`); return }
const data = JSON.parse(r.body)

Only https, and only public addresses. A plugin is code you are running against your own notes; a plaintext request would put the whole exchange in front of anyone in between, and an address on your own network is not the web. Redirects are followed, but each hop is checked again — a site you allowed cannot hand you to one you did not.

Responses are capped and requests time out, so a plugin cannot pull something endless into memory or hang while you wait.

Everything else

CallDoes
mojo.navigate(ref)opens a page in this window. Returns nothing, and does nothing if the page is not found
await mojo.alert(message)shows a message and waits for it to be dismissed — the way to interrupt on purpose
await mojo.prompt(label, value?)asks for a line of text. null if canceled
mojo.today()today's date as a string, in your date format
mojo.log(…)collects output, shown when the run finishes
mojo.eventpresent only on an Event: page — what triggered the run
mojo.collections()the folder tree of this wiki — each folder with its id, its title and its nesting — so a script can file what it makes rather than leaving it Unfiled, and can name a folder by something that survives a rename
await mojo.mcp.servers()the tools you have connected, by the names you gave them
await mojo.mcp.tools(name)what one of them can do
await mojo.mcp.call(name, tool, args)asks one to do it

A plugin can reach the tools you have connected. This reference did not say so, which read as though it could not — the opposite of the truth. A reader was told by an assistant to write a separate program and install it alongside, for something that was already one call. They are reached by the name you gave them in Settings, and a wiki reaches only the ones you granted it.

Seeing what went wrong

The Scripts palette keeps the last few runs — from the runner, from the Plugin menu and from Event pages alike. Each one says what ran, when, what it printed, and what failed. Nothing else in MojoPad writes there, so everything in it came from your own code.

A failure that happened while running names the line it happened on. A script that would not parse has no line to name — the failure is found before any of it runs — and MojoPad says so rather than pointing at a line whose code is fine.

If your Mac has curled your quotes

macOS replaces straight quotes with typographic ones as you type" becomes and — which is right for writing and is not valid code. Code that looks perfect stops parsing, and the error names a line with nothing wrong on it.

MojoPad recovers: if a script does not parse as written, it tries again with the quotes straightened, and says so in the output when that is what fixed it. A script that already parses is run exactly as you wrote it, curled quotes and all — a typographic quote inside a string is your output, not a mistake.

To stop it happening at all: Edit ▸ Substitutions ▸ Smart Quotes. That is your Mac's setting, not MojoPad's, and it applies wherever you are typing.

Two calls throw rather than refuse

Both used to fail silently, and both were reported by somebody who lost an afternoon to them. mojo.append(text) with one argument resolved your CONTENT as a page name, found no page called that, and returned false. mojo.getMeta(key) with one argument failed inside on a key that was never passed. They now throw, and the message names the shape they wanted.

Where what a plugin says goes

mojo.log records; mojo.alert interrupts. Anything a plugin logs is kept in Scripts in the command palette, under Recent runs — the last few runs, each with what it printed, and, when one failed, the error and the line it failed on. Comparing a run with the one before it is half of debugging, so they are held rather than shown once and lost.

Watching a long run, and stopping it

A run appears under Recent runs the moment it starts, not when it finishes, with the time it has been going beside it. A plugin that collects a few hundred papers takes as long as it takes; what matters is being able to tell that it is still working rather than stuck.

Stop is beside it while it runs. A script cannot be interrupted in the middle of a calculation — nothing can reach into a loop from outside. What Stop does is refuse the next thing the script asks MojoPad to do: the next page it reads, file it keeps, or address it fetches. Any script that is actually doing work ends within moments. A script doing nothing but arithmetic has to be waited out, and closing the wiki ends it.

Only one Event: page runs at a time, so a script that never finishes holds up the rest. If an event does not fire and the log says another script was still running, that is what has happened — the run will be listed here, and stopping it releases the others.

A run that logs something says so in the status line rather than stopping you. When you do want to stop the reader — a question, a warning, something they must see before carrying on — that is await mojo.alert(…). An error always interrupts, whether you asked for it or not.

What a script cannot do

Three boundaries, and each one is yours to set. This chapter used to say a script could not open a connection, seventy lines below the table documenting mojo.fetch. That was wrong, and it was the sentence somebody careful would have relied on. What is actually true:

  • Pages: the open wiki, and only that one. Every reading and writing call above works on the document you have open. A script cannot reach your other wikis, and cannot open one.
  • The web: only where you allow it, one site at a time. mojo.fetch, mojo.fetchData and mojo.fetchResult ask you the first time a script reaches a given site from a given wiki, and remember the answer only if you say to. Addresses on your own network are refused outright, and so is anything that is not https.
  • Files outside the wiki: only into a folder you choose, every time. mojo.exportFolder opens a folder chooser on each call and writes only there. It has no remembered destination, deliberately: a plugin that runs when a document opens runs with no gesture from you at all, and a remembered folder would make that a silent write.

A script cannot change any setting, cannot start another program, and cannot read a file from your disk that is not already a page in this wiki.

Scripts run only in a document you have said may run them, and only from pages you wrote yourself. See Nothing runs until you say so under Scripting: Plugins.

A worked example

A plugin that adds a formatted summary to the page you are looking at:

const here = mojo.currentPage()
if (!here) { await mojo.alert('Open a page first.'); return }
const words = (mojo.read(here.id) || '').split(/\s+/).length
const ok = mojo.append(here.id,
  `<p><b>${words}</b> words, counted ${mojo.today()}.</p>`,
  { format: 'html' })
if (!ok) await mojo.alert('That page will not take an append — is it locked?')

Note the last line. Checking what a call handed back is the difference between a plugin that works and a plugin that appears to.