Skip to content

Commit ae7d8a3

Browse files
hepplerjclaude
andcommitted
v1.3.0: emit tropy_url deep link recovered from Tropy's Redux store
Each exported file now gets a `tropy_url:` field in its frontmatter, pointing back to the source item in Tropy: tropy_url: "tropy://project/current/items/14410/14411" Clicking the URL from a Markdown editor opens Tropy to that item with its cover photo selected — the canonical way to review a transcription against the original scan without leaving the analytical layer. The challenge is that Tropy's JSON-LD export payload contains no internal item IDs. The plugin recovers them by reading `state.items` and `state.photos` from Tropy's Redux store (via `this.context.window.store.getState()`) and building a Map<photoPath, itemId> at the start of each export. Each JSON-LD item is then matched to its store record by its first photo's path (more reliable than checksum, which Tropy occasionally ships as md5-of-empty-string for unprocessed photos). Once matched, the URL is built from the item ID and either `cover_image_id` (rare) or the first photo ID (typical), matching the behavior of the original Python export script. Graceful degradation: if the store isn't accessible (e.g. an older Tropy version with a different shape), the lookup returns null and the `tropy_url:` field is simply omitted. Nothing else in the export is affected. The field is renamable via the fieldRename setting, like any other top-level YAML key. README updated with a "Tropy deep links" section explaining the behavior and a sample tropy_url entry in the output- shape example. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent df503ed commit ae7d8a3

3 files changed

Lines changed: 83 additions & 11 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ publication: ""
238238
date: "1907-10-15"
239239
doc_type: "letter"
240240
source: "Library of Congress, Gifford Pinchot Papers, Box 12, Folder 3"
241+
tropy_url: "tropy://project/current/items/14410/14411"
241242
people:
242243
- "[[Gifford Pinchot]]"
243244
- "[[Theodore Roosevelt]]"
@@ -281,6 +282,20 @@ is found, the key falls back to the URI's local name. **Collisions across
281282
namespaces are still possible** when two distinct URIs share the same
282283
local name and neither has an ontology label.
283284
285+
## Tropy deep links
286+
287+
Each exported file's frontmatter includes a `tropy_url:` field with a
288+
`tropy://project/current/items/<id>/<photo>` URL that, when clicked,
289+
opens Tropy to that item with its cover photo selected. Useful for
290+
jumping back to the source scan from inside your Markdown editor —
291+
particularly when reviewing a transcription against the original.
292+
293+
The plugin recovers Tropy's internal item ID from the live project
294+
state (Tropy's JSON-LD export doesn't include it directly). If the
295+
state isn't accessible for any reason — e.g. an older Tropy version
296+
with a different store shape — the field is simply omitted; nothing
297+
else in the export is affected.
298+
284299
## Idempotency
285300

286301
Each output filename embeds an 8-character `tropy_hash` derived from a

index.js

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
// Tropy.md — v1.2.0
3+
// Tropy.md — v1.3.0
44
//
55
// Exports each selected Tropy item to its own Markdown file in a chosen
66
// directory. Markdown-editor neutral by default — no wiki-links, no opinionated
@@ -330,6 +330,49 @@ function extractPhotoPaths(item) {
330330
return photos.map(p => (p && p.path) || '').filter(Boolean)
331331
}
332332

333+
function buildItemIdIndex(state) {
334+
// Builds a Map<photoPath, itemId> from Tropy's Redux store so we can
335+
// recover the internal item ID for a JSON-LD item — the export hook
336+
// doesn't surface item IDs anywhere in its payload, but the store knows
337+
// them. Matching on first-photo path is reliable: paths are unique per
338+
// item and stable across exports (unlike checksums, which Tropy
339+
// sometimes ships as md5-of-empty-string when it hasn't fully processed
340+
// an import).
341+
const items = (state && state.items) || {}
342+
const photos = (state && state.photos) || {}
343+
const index = new Map()
344+
for (const item of Object.values(items)) {
345+
const photoIds = (item && item.photos) || []
346+
if (photoIds.length === 0) continue
347+
const firstPhoto = photos[photoIds[0]]
348+
if (firstPhoto && firstPhoto.path) {
349+
index.set(firstPhoto.path, item.id)
350+
}
351+
}
352+
return index
353+
}
354+
355+
function tropyUrlFor(item, state, itemIndex) {
356+
// Returns a `tropy://project/current/items/<itemId>/<photoId>` URL when
357+
// we can recover the internal IDs from the store, or null otherwise.
358+
// The plugin emits the URL into the frontmatter as `tropy_url:` when
359+
// present so users can click back into Tropy from their Markdown editor.
360+
if (!itemIndex || !state) return null
361+
const photos = Array.isArray(item.photo) ? item.photo : []
362+
if (photos.length === 0 || !photos[0] || !photos[0].path) return null
363+
const itemId = itemIndex.get(photos[0].path)
364+
if (itemId == null) return null
365+
const storeItem = state.items && state.items[itemId]
366+
if (!storeItem) return `tropy://project/current/items/${itemId}`
367+
// Prefer cover_image_id when set (rare in practice); otherwise the
368+
// first photo in the item's photos array — matches the Python script.
369+
const coverId = storeItem.cover_image_id != null
370+
? storeItem.cover_image_id
371+
: (Array.isArray(storeItem.photos) ? storeItem.photos[0] : null)
372+
if (coverId == null) return `tropy://project/current/items/${itemId}`
373+
return `tropy://project/current/items/${itemId}/${coverId}`
374+
}
375+
333376
function photoEmbedMarkdown(photo) {
334377
// Returns a Markdown embed line for a photo, or null if the photo has
335378
// no path. Format:
@@ -434,6 +477,12 @@ function buildFrontmatter(item, hash, opts) {
434477
}
435478
}
436479

480+
// Tropy deep-link back to the source item. Constructed from internal
481+
// IDs recovered via the Redux store; null when the lookup fails (e.g.
482+
// running against a Tropy version with a different store shape).
483+
const tropyUrl = tropyUrlFor(item, opts.state, opts.itemIndex)
484+
if (tropyUrl) lines.push(`${k('tropy_url')}: ${yamlScalar(tropyUrl)}`)
485+
437486
// Custom template properties — anything else on the item that isn't
438487
// structural or already rendered. Lets users with custom Tropy templates
439488
// see their data without us needing to know each field in advance.
@@ -626,19 +675,23 @@ class MarkdownPlugin {
626675
}
627676

628677
buildOpts() {
629-
// Pull Tropy's ontology if available so we can resolve custom-property
630-
// URIs to human-readable labels. Older Tropy versions or different
631-
// store shapes degrade gracefully — `ontologyLabel` returns null when
632-
// a lookup fails.
633-
let ontology = null
678+
// Pull Tropy's ontology + items/photos index from the Redux store. The
679+
// ontology gives us human-readable labels for custom-property URIs;
680+
// the index lets us recover internal item IDs (not in the JSON-LD
681+
// payload) so we can build `tropy://` URLs back to each item. Older
682+
// Tropy versions or different store shapes degrade gracefully — both
683+
// lookups return null on any failure and the rest of the plugin
684+
// adjusts.
685+
let state = null
634686
try {
635-
const state = this.context.window && this.context.window.store
687+
state = this.context.window && this.context.window.store
636688
? this.context.window.store.getState()
637689
: null
638-
ontology = (state && state.ontology) || null
639690
} catch {
640-
ontology = null
691+
state = null
641692
}
693+
const ontology = (state && state.ontology) || null
694+
const itemIndex = state ? buildItemIdIndex(state) : null
642695

643696
return {
644697
workflowTags: parseCsvSet(this.options.workflowTags),
@@ -650,7 +703,9 @@ class MarkdownPlugin {
650703
filenamePattern: this.options.filenamePattern || 'tropy-{hash}-{slug}',
651704
embedPhotos: this.options.embedPhotos === true,
652705
fieldRename: parseFieldRename(this.options.fieldRename),
653-
ontology
706+
ontology,
707+
state,
708+
itemIndex
654709
}
655710
}
656711

@@ -769,6 +824,8 @@ module.exports._internals = {
769824
extractPages,
770825
extractPhotoPaths,
771826
photoEmbedMarkdown,
827+
buildItemIdIndex,
828+
tropyUrlFor,
772829
localName,
773830
looksLikeUri,
774831
ontologyLabel,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "tropymd",
33
"productName": "Tropy.md",
4-
"version": "1.2.0",
4+
"version": "1.3.0",
55
"description": "Export Tropy items as Markdown files (one per item) with YAML frontmatter, suitable for Obsidian and other Markdown editors.",
66
"icon": "icon.svg",
77
"main": "index.js",

0 commit comments

Comments
 (0)