ANN: JSON Convert

JSON Convert — turn arbitrary JSON into tiddlers, with reusable profiles and a staging step

I’ve been working on a plugin for the case where the core JSON importer falls short: source JSON whose shape doesn’t already match TiddlyWiki’s tiddler shape, or fields that aren’t strings. Inspired directly by this thread — the Moodle imports pain points there were the motivating example.

Demo wiki: https://crosseye.github.io/TW5-JSON_Convert/
Source: https://github.com/crosseye/TW5-JSON_Convert

What it does

You write a small JSON document — a profile — that describes how each record in your source JSON maps onto a tiddler. You can do this manually or with the tool supplied. Paste the source into the Console, pick a profile, hit ‘‘Convert’’. The results land in a preview area where you can resolve title collisions (skip / overwrite / rename) before committing.

A profile looks like this:

{
  "records": "{{books[*]}}",
  "tw-fields": {
    "title": "{{author}}: {{title}}",
    "tags":  "Book {{genre|split-commas}}"
  },
  "custom-fields": {
    "year": "{{year}}",
    "categoory": "{{group|to-title-case}}"
  }
}
  • records is a path to the array you want to iterate — one tiddler per element. It supports nested iteration like {{groups[*].subgroups[*].items[*]}}, and bindings can use ../ to reach an enclosing scope.
  • tw-fields and custom-fields are bindings — template strings with {{path}} tokens that resolve against the current record. Numbers, booleans, and nested objects are coerced rather than dropped.
  • Tokens can pipe through transforms: {{title|to-lower-case|slugify}}. The plugin ships a handful (html-to-wikitext, split-commas, split-to-titles, timestamp-to-date, to-upper-case, to-lower-case, to-title-case, slugify) and you can drop in your own as tiddlers tagged $:/tags/json-convert/transform — JavaScript bodies for arbitrary logic, or wikitext bodies for filter-operator chains.

There’s a form-based editor for profiles with a Browse modal that walks the parsed JSON’s shape and inserts paths on click, and the parser tolerates BOMs and stray non-JSON wrapping (the noisy Moodle exports from the inspiration thread parse cleanly with a recoverable warning).

Try it

The demo wiki ships six sample profiles with matching data tiddlers (Moodle quiz, Moodle forum, music library two ways, reading list, trip itinerary). Click the {} icon in the page-controls toolbar to open the Console.

To install in your own wiki, drag $:/plugins/crosseye/json-convert from the demo wiki onto yours and save.

Documentation

An end-to-end walkthrough, the profile schema, path expressions, transforms, recipes, and reference all live in a single tiddler in the plugin: Usage guide.

JSON Mangler Contrast

Joshua Fontany’s JSON Mangler and JSON Convert work on opposite ends of the JSON-meets-TiddlyWiki problem and complement rather than compete. JSON Mangler extends what you can do with JSON that already lives in your wiki as a JSON-typed data tiddler — text references reach into nested values, new filter operators (indexes[] , hasindex[] , encodeindex[] , comparefield[] , etc.) walk and sort nested structures, a $jsonmangler widget edits JSON tiddlers in place, and there’s even CSV support and a Plugin Management UI in the Control Panel.

JSON Convert, by contrast, doesn’t touch the JSON-tiddler shape at all: it’s an importer that takes arbitrary external JSON (an LMS export, an API response, a config dump) and writes ordinary tiddlers based on a reusable mapping profile, with a staging step to preview and resolve collisions before committing.

So if you have a JSON-shaped knowledge base inside TiddlyWiki and want better tools to query and edit it, reach for JSON Mangler; if you have a chunk of foreign JSON and want each record to become its own first-class tiddler with the field names you choose, reach for JSON Convert.

Feedback

This was a little itch I had to scratch, not a big idea; it’s simply a grown-up version of what I did a year ago. It’s also something I don’t expect to use a lot myself, as I have other regular workflows that I use to convert JSON before importing. But I do expect to update and maintain this, so long as doing so doesn’t become too burdensome.

Bug reports, feature requests, design objections, “have you considered X?” — all welcome, here or on the issue tracker. This is at 0.9.2 and the API is approaching stability but not frozen.

I can’t test right away, but I’ve gotta say, Scott: It’s as if you’re churning out carefully-developed resources — for the benefit of this community — at the rate of a full-time volunteer!

This tool, assuming it works as you describe (and I have every reason to expect that it does!) is likely to save me a up to a cumulative half-hour of pre-import tinkering and/or post-import batch-processing on a regular basis.

One question / related need I have — which may or may not be quickly confirmed when I check this out — is whether it’s possible to disregard certain fields. I regularly need to delete a set of cruft (and/or confidential) fields that come in from my LMS JSON. I can do this with post-import batch-processing, but if the converter has a place for fields to disregard.

(I’m guessing that the default on your tool would be: if no special handling is indicated, key-value pairs get parsed as fieldnames and fieldvalues… if disregarding fields that aren’t “opted in” is the default, then my nudge would be in the other direction — to have an option to parse out all key-value pairs except for these named keys…)

It really helps that, for whatever reason, in the last 6 - 8 weeks, I’ve found myself unable to sleep much more than four hours per night. If it keeps up for too long, I’ll have to go to the doctor, but I was at a sleep specialist a year ago for almost the opposite problem, and he was phenomenally unhelpful; I’m a little gun-shy. In the meantime, I’ve spent many hours of late-night and early-morning coding. I’ve spent a nearly equal amount of time in research around local issues and political campaigns – generating reams of reports.

I know it can’t last, but I’m enjoying the ride for now! And here I’m using it to pick up work that I feel I’ve abandoned incomplete. Maybe at some point I’ll work my way back to the Periodic Table! :wink:

This is what it would take, and I’d have to think about it. My idea was to be explicit about what you wanted, especially as to how field values might be derived in various ways from various combinations of the input. It might make sense to also include a section for “copy these fields exactly with no changes” that kept the names and values intact, with only the adjustment to convert everything to a string. I believe it makes sense, but I need to give it a bit more thought.

Would you object if this were a one-way move? That is, if it offers you the choice to also include a certain list of potential unchanged fields:

  • age
  • color
  • size
  • location
  • message

and after you make your choice and approve, it updates the current structure by adding

  {
     ...
    "age": "{{age}}",
    "location": "{{location}}",
    "message": "{{message}}",
    ...
  }

but then doesn’t roundtrip them? That is, it doesn’t maintain any distinction between these pass-through fields and others you’ve configured more explicitly.

I ask because that would be simpler to implement and would require changes only to the user interface, not the underlying data structure. If that covers the real need, then I’m not sure I would spend the time to handle the roundtripping. (And no, the roundtripping would not be that much more work, so if the need is there, it’s certainly possible.)

Oh, if it’s a real-time “hey there seem to be these potential incoming fieldnames, check all/none/some (as appropriate) to process at face value” that would be fantastic!

Quick update: 0.9.4 ships a pass-through field picker.

In the profile editor, the Custom Fields header now has a Pick fields… button. It opens a modal showing every leaf field in the source JSON’s record-scoped shape, each with a checkbox. Tick the fields you want; click Apply. Each ticked leaf becomes a custom-fields entry like "<name>": "{{<name>}}" — pure pass-through, no transform.

The intended workflow for your Moodle-cruft case: open the picker, tick the All checkbox at the top, untick the four or five fields you don’t want, Apply. Reopening the picker on the same profile pre-ticks every binding it recognizes as a pass-through, so a second round of additions or removals is just another round-trip. Bindings you’ve customized with transforms or templates are out of the picker’s purview — it leaves them alone, so an accidental untick can’t destroy custom work.

Demo wiki has a new sample to try it on: Example Moodle Gradebook (six records, 20-ish fields, four deliberate “cruft” fields). Full docs in §7.6 of the Usage guide.

Thanks for the suggestion — turned out to be a clean fit.

Mamma Mia! that combo is super-cool!

Will comment more laters,
TT

In the “No good deed goes unpunished” column…

My current JSON challenge takes output from handwritingOCR.com

… which insists on outputting a nested JSON structure that looks like this (ellipsis-condensed):

example file

{
    "id": "YG42vvgq47",
    "file_name": "scan_esp_2026-05-17-17-32-04.pdf",
    "action": "transcribe",
    "page_count": 3,
    "status": "processed",
    "results": [
        {
            "page_number": 1,
            "transcript": "399999\n\nPg 1\n\nSTUDENT NAME HERE topic #2 - ideology.\n\nPhil XYZ final\n\nMany people assume that morality is rational, honest and helps human flourishing… through education and habituation."
        },
        {
            "page_number": 2,
            "transcript": "STUDENT NAME HERE\n\nPg 2\n\n399999\n\nPhil XYZ final\n\nMoving on to Marx, … individuals are accepting beliefs and morals at face value even…"
        },
        {
            "page_number": 3,
            "transcript": "3\n\nSTUDENT NAME HERE  \n399999\n\nPg 3  \nPhil XYZ final\n\nif they are old traditions. … skeptical of trusting morality set by society as it is ideologies set to protect interests of ruling class…."
        }
    ],
    "automatically_deleted_at": "2026-05-31T21:36:24.000000Z",
    "created_at": "2026-05-17T21:36:24.000000Z",
    "updated_at": "2026-05-17T21:36:30.000000Z"
}

Since the overall JSON has only a single curly-bracket at the start, it seems your JSON-convert solutions wants to start parsing usable stuff only within the results frame… But then there’s nothing like a usable title there. It would make sense to concatenate with the file_name — but that string lies outside that usable branch / scope.

If I modify the source file to add square brackets at start and finish, then file_name is reachable as a tiddler title…, at the cost of apparently not being able to import content from within the “results” branch. (I can get one tiddler imported (from the file_name level), but its contents are empty apart from some bracketed placeholders… where I had hoped maybe I’d see a mini-json array swallowed whole — which at least would get data imported so it’s “in there” in my wiki for further handling).

With further experimentation, I was able to work up a profile that digs into nested content and gets me three tiddlers titled “1” and “2” and “3” — which are of course terrible tiddler titles, but even if some unique title-conferring solution were available, these tiddlers would carry no trace of what holds them together. And… I do want to be pulling in lots of these for an automated workflow.

(I deliberately started with a JSON that holds just one student’s multipage OCR record, but ideally what I’d want to be importing is either one nested file with multiple root-level records, or a batch of per-student json files, each structured like what’s above.)

ZOOMING OUT:

First an apology: I’m a bit guilty of a bait-and-switch here. My old thread here focused on flat–structured .json files (the most common structure coming from my LMS, moodle), and here I have a nested file — and one with no usable keys at the end-tiddler level (since the only two keys there are a simple page_number and a long transcript text field!

And a related concern: On some level, I think that asking for support from you for such a niche use is unfair. Especially because I don’t have enough “common sense” about JSON to be able to navigate your plugin with confidence. It’s possible that I’m just not orienting properly to info that’s right there in front of my nose. I don’t want plugin developers to feel beholden to making up for individual ignorance!

Still, on another level, I do think that having a solution that allows TiddlyWiki to scoop up data from a “non-compliant” external json file is an expansion that makes TiddlyWiki a much more powerful tool.

So, if you think the troubles I’m having are representative of troubles that TW community folks may have in getting TW to parse json data from the wild — or if explaining how/why this tool must be limited to flat data that has a ready title-field candidate (if that’s the case), then I hope my post contributes to that end — whether or not my TW ends up being able to import this particular data-set neatly.

Not at all. I wouldn’t have posted this if I wasn’t expecting — and hoping for — feedback.

One thing that your response shows me is something I was pretty sure of already: my documentation isn’t good enough. I did have a gap in date handling that is fixed in the latest version. But the basics were already in place.

With this profile:

{
  "records": "{{results[*]}}",
  "tw-fields": {
    "title":    "{{../file_name}} — page {{page_number}}",
    "tags":     "[[OCR Output]]",
    "text":     "{{transcript|html-to-wikitext}}",
    "created":  "{{../created_at|iso-to-date}}",
    "modified": "{{../updated_at|iso-to-date}}"
  },
  "custom-fields": {
    "expires":  "{{../automatically_deleted_at|iso-to-date}}"
  }
}

We get these three records:

OCT_Output.json (1.1 KB)

Here’s the first one:

title: scan_esp_2026-05-17-17-32-04.pdf — page 1
tags: [[OCR Output]]
created: 20260517213624000
modified: 20260517213630000
expires: 20260531213624000

399999

Pg 1

STUDENT NAME HERE topic #2 - ideology.

Phil XYZ final

Many people assume that morality is rational, honest and helps human flourishing… through education and habituation.

The trick there is the ../ in {{../file_name}} and the like. It moves back up one level in the hierarchy from the records we’re iterating over. ../../ would move up two levels and so on. There is no way to cover all possible JSON transformations needs, but this was definitely something I considered. I’m sorry the documentation isn’t yet up to snuff.

The example has been added to the latest version, as has a new transform, iso-to-date — which I really would have sworn I’d already created. That changes 2026-05-31T21:36:24.000000Z to the TW format of 20260531213624000. Obviously there’s not much we can do with the actual OCR’ed text. That would be a very different sort of tool.

If the structure looked like this:

{
  "students": [
    { "id": "...", "file_name": "...", "results": [ ... ], "created_at": "..." },
    { "id": "...", "file_name": "...", "results": [ ... ], "created_at": "..." }
  ]
}

the profile change would just be the records line:

"records": "{{students[*].results[*]}}",

And similarly, if you had a bare array like this:

[
  { "id": "...", "file_name": "...", "results": [ ... ], "created_at": "..." },
  { "id": "...", "file_name": "...", "results": [ ... ], "created_at": "..." }
]

we would have this:

"records": "{{[*].results[*]}}",

Nothing else in the profile changes.

The tool would let you do these one at a time, and since it supports drag-and-drop, as long as there aren’t too many of them, I think that’s doable. I don’t expect the tool to ever extend to handle batch imports.

You’re not. I took something that I saw as a gap in the TW environment and am trying to plug it. In fact (except for the missing date transform) examples like this were entirely part of my design. They are better covered in the core transformation code than in the UI, though. And I’m not sure how much effort I’ll put it into updating the UI. I will probably go back to it, but I don’t yet have great ideas for how to cover these ascending levels changes.

What I’m trying to do is take techniques I use all time on my own transformations, and turn them into something reusable for others who don’t live and breathe JS/JSON the way I do.

I’m very specifically asking for feedback. It was your case that got me started on this. So I most especially want your feedback. Please don’t ever feel bad about giving that feedback.

I do think they’re representative, and while my documentation is clearly lacking, I am trying to cover these needs.

@Springer — following up on the documentation point from my recent message. That conversation pushed me to do a real pass on the docs, and 0.9.7 (just released) is the result. There are three new long-form guides in the demo wiki:

  • Profile Guide — the one most relevant to your nested-records question. What a profile is, the binding language, path expressions (including the .. ancestor refs you’d need to pull file_name from outside the results array), and a tour of the editor with screenshots.

  • Console Guide — Source → Convert → Staging → Apply, end to end.

  • Transforms Guide — built-in transforms (including the new iso-to-date for ISO 8601 strings like the timestamps in your OCR response) and how to write your own.

Your handwritingOCR.com response shape also became a worked sample in the demo wiki: Example OCR Output. Open the editor on it to see the records path ({{results[*]}}), ancestor refs ({{../file_name}}, etc.), and iso-to-date against created_at / updated_at / automatically_deleted_at — all wired up against your actual JSON structure.

The Usage guide (the kitchen-sink reference) still exists, and it’s what will be shipped with the plugin, to keep it small. The new Documentation entry is intended to supply more depth.

I also included a few path-picker improvements at the same time; these should allow for more complete editing of the profiles through the editor. But nothing in the syntax changed.

If any of the new content is unclear or missing things you’d hoped to find, please flag it-- your feedback is what got me to do this overhaul in the first place.