Skip to content

Commit df503ed

Browse files
hepplerjclaude
andcommitted
v1.2.0: {name} filename placeholder + collision numbering
Adds a `{name}` placeholder to the filename pattern that produces a human-readable filename component: case and spaces preserved, only filesystem-illegal characters (< > : " / \ | ? *) and control chars are stripped, plus a few characters that confuse Obsidian's wiki-link parser (# ^ [ ]). Suitable for vaults that want titled files like "The Sagebrush Rebellion.md" rather than slug-style names. When two items would produce the same filename — for example two articles titled "The Sagebrush Rebellion" from different sources, both rendered with `{name}` — the second and later get OS-style suffixes: "The Sagebrush Rebellion (2).md", "The Sagebrush Rebellion (3).md", etc. The check is case-insensitive (matches typical Windows/macOS filesystem behavior) and looks at both files already on disk and filenames used earlier in the same export batch. Default filename pattern is unchanged. The new behavior is fully opt-in by writing a pattern that uses {name}. Idempotency continues to work via the frontmatter-embedded tropy_hash, so any pattern still re-runs safely. Refactored applyFilenamePattern: filename variables are now pre-formatted to their natural display form (slugified or sanitized as appropriate) and the pattern substitution is a pure 1:1 replacement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6626ced commit df503ed

3 files changed

Lines changed: 85 additions & 15 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,10 @@ Template for the output filenames (`.md` is appended automatically).
9999
Default: `tropy-{hash}-{slug}`. Available placeholders:
100100

101101
- `{hash}` — 8-character content hash (drives idempotency)
102-
- `{slug}` — short slugified title (≤60 chars)
102+
- `{slug}` — short slugified title, lowercase + hyphens (≤60 chars)
103103
- `{title}` — full slugified title (no length cap)
104+
- `{name}` — human-readable title: case and spaces preserved, only
105+
filesystem-illegal characters (`< > : " / \ | ? *`) stripped
104106
- `{date}` — the item's date as it appears in Tropy
105107
- `{type}` — the doc type (e.g. `letter`, `newspaper`)
106108
- `{creator}` — slugified creator name
@@ -109,11 +111,19 @@ Missing values collapse cleanly so a missing `{date}` doesn't leave a stray
109111
hyphen. Idempotency reads the `tropy_hash:` value from existing files'
110112
frontmatter, so any pattern works without breaking new exports.
111113
114+
When two items would produce the same filename — for example two
115+
articles titled "The Sagebrush Rebellion" from different newspapers,
116+
both rendered with `{name}` — the second and later get an OS-style
117+
suffix: `The Sagebrush Rebellion (2).md`, `The Sagebrush Rebellion (3).md`,
118+
and so on.
119+
112120
Examples:
113121

114122
| Pattern | Result |
115123
|---|---|
116124
| `tropy-{hash}-{slug}` (default) | `tropy-a1b2c3d4-letter-from-pinchot.md` |
125+
| `{name}` | `Letter from Pinchot to Roosevelt.md` |
126+
| `{date} {name}` | `1907-10-15 Letter from Pinchot to Roosevelt.md` |
117127
| `{date}-{slug}` | `1907-10-15-letter-from-pinchot.md` |
118128
| `{type}/{slug}` | `letter/letter-from-pinchot.md` |
119129

index.js

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

3-
// Tropy.md — v1.1.1
3+
// Tropy.md — v1.2.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
@@ -156,6 +156,27 @@ function slugify(s, maxLen = 60) {
156156
.replace(/^-+|-+$/g, '')
157157
}
158158

159+
function sanitizeFilename(s, maxLen = 150) {
160+
// Produces a filesystem-safe but human-readable filename component:
161+
// case and spaces preserved, only the characters that confuse Windows,
162+
// macOS, or Obsidian's wiki-link parser are stripped. Suitable for
163+
// titles like "The Sagebrush Rebellion" — the result reads naturally
164+
// in a vault while still being a valid filename on every common OS.
165+
return String(s || '')
166+
// Filesystem-illegal everywhere: < > : " / \ | ? * and control chars.
167+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
168+
// Stripped to keep Obsidian's wiki-link parser happy: # ^ [ ].
169+
.replace(/[#^[\]]/g, '')
170+
// Collapse runs of whitespace.
171+
.replace(/\s+/g, ' ')
172+
.trim()
173+
// Trim trailing dots — Windows treats "name." and "name" as the same.
174+
.replace(/\.+$/, '')
175+
.trim()
176+
.slice(0, maxLen)
177+
.trim()
178+
}
179+
159180
function shortHash(item) {
160181
// Hash a content fingerprint built from the whole item, not just the first
161182
// photo's checksum. Tropy can ship `checksum: "d41d8cd9..."` (md5 of empty
@@ -182,28 +203,36 @@ function shortHash(item) {
182203
}
183204

184205
function applyFilenamePattern(pattern, vars) {
185-
// Substitutes {key} placeholders. Missing/empty values render as empty,
186-
// and the result is then cleaned of double-hyphens / leading-trailing
187-
// hyphens so a missing piece doesn't leave a stray separator.
206+
// Substitutes {key} placeholders with values from `vars`. Values are
207+
// expected to be pre-formatted (slugified or sanitized as appropriate
208+
// for that variable). Missing/empty values render as empty; the result
209+
// is then cleaned of double-hyphens and leading/trailing hyphens or
210+
// whitespace so a missing piece doesn't leave a stray separator.
188211
let out = String(pattern || '').replace(/\{(\w+)\}/g, (_, k) => {
189212
const v = vars[k]
190-
return v == null ? '' : slugify(v, 1000)
213+
return v == null ? '' : String(v)
191214
})
192215
out = out
193216
.replace(/-{2,}/g, '-')
194-
.replace(/^-+|-+$/g, '')
217+
.replace(/^[-\s]+|[-\s]+$/g, '')
195218
.trim()
196219
return out
197220
}
198221

199222
function filenameFor(item, hash, opts) {
223+
// Variables come in two flavors: slug-style (lowercase + hyphens, safe
224+
// for any filesystem and any URL) and human-readable (case + spaces
225+
// preserved via sanitizeFilename). Pattern authors choose between them
226+
// by picking the right placeholder name.
227+
const title = item.title || ''
200228
const vars = {
201229
hash,
202-
slug: slugify(item.title),
203-
title: slugify(item.title, 1000),
204-
date: item.date || '',
205-
type: item.type || '',
206-
creator: item.creator || ''
230+
slug: slugify(title),
231+
title: slugify(title, 1000),
232+
name: sanitizeFilename(title),
233+
date: item.date || '',
234+
type: item.type ? slugify(item.type, 1000) : '',
235+
creator: slugify(item.creator || '', 1000)
207236
}
208237
const stem = applyFilenamePattern(opts.filenamePattern, vars)
209238
|| `tropy-${hash}` // fallback if pattern collapses to empty
@@ -545,6 +574,20 @@ class MarkdownPlugin {
545574
return dir || null
546575
}
547576

577+
async existingFilenames(outDir) {
578+
// Returns a case-insensitive Set of filenames already in the output
579+
// directory. Used to resolve filename collisions when two items would
580+
// otherwise want the same name (e.g. two articles titled "The
581+
// Sagebrush Rebellion" from different sources, both rendered with
582+
// the human-readable {name} placeholder).
583+
try {
584+
const files = await fs.readdir(outDir)
585+
return new Set(files.map(f => f.toLowerCase()))
586+
} catch {
587+
return new Set()
588+
}
589+
}
590+
548591
async existingHashes(outDir) {
549592
// Reads the `tropy_hash:` value out of each existing .md file's
550593
// frontmatter. This is robust to any filename pattern the user has
@@ -636,6 +679,7 @@ class MarkdownPlugin {
636679

637680
const opts = this.buildOpts()
638681
const seen = await this.existingHashes(outDir)
682+
const usedNames = await this.existingFilenames(outDir)
639683
let wrote = 0
640684
let skipped = 0
641685
let failed = 0
@@ -658,7 +702,22 @@ class MarkdownPlugin {
658702
continue
659703
}
660704
const md = assembleMarkdown(item, hash, opts)
661-
const target = path.join(outDir, filenameFor(item, hash, opts))
705+
// Resolve filename collisions: if the computed filename already
706+
// exists in the output directory (or has been used earlier in
707+
// this batch), append `(2)`, `(3)`, etc. until unique. Matches
708+
// the OS-level rename behavior users already recognize.
709+
let candidate = filenameFor(item, hash, opts)
710+
if (usedNames.has(candidate.toLowerCase())) {
711+
const stem = candidate.replace(/\.md$/, '')
712+
let n = 2
713+
do {
714+
candidate = `${stem} (${n}).md`
715+
n++
716+
} while (usedNames.has(candidate.toLowerCase()))
717+
}
718+
usedNames.add(candidate.toLowerCase())
719+
720+
const target = path.join(outDir, candidate)
662721
await fs.writeFile(target, md, 'utf8')
663722
seen.add(hash)
664723
wrote++
@@ -698,6 +757,7 @@ module.exports._internals = {
698757
renameYamlKey,
699758
dispatchTags,
700759
slugify,
760+
sanitizeFilename,
701761
shortHash,
702762
filenameFor,
703763
applyFilenamePattern,

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "tropymd",
33
"productName": "Tropy.md",
4-
"version": "1.1.1",
4+
"version": "1.2.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",
@@ -79,7 +79,7 @@
7979
"type": "string",
8080
"label": "Filename pattern",
8181
"default": "tropy-{hash}-{slug}",
82-
"hint": "Template for output filenames (`.md` is appended). Placeholders: {hash} 8-char content hash, {slug} short title (≤60 chars), {title} full slugified title, {date}, {type}, {creator}. Missing values collapse cleanly. Idempotency reads the `tropy_hash:` value from frontmatter, so any pattern works without breaking re-runs."
82+
"hint": "Template for output filenames (`.md` is appended). Placeholders: {hash} 8-char content hash, {slug} short slugified title (≤60 chars), {title} full slugified title, {name} human-readable title with case and spaces preserved (filesystem-illegal characters stripped), {date}, {type}, {creator}. Missing values collapse cleanly. When two items would produce the same filename, the second and later get `(2)`, `(3)`, etc. suffixes. Idempotency reads the `tropy_hash:` value from frontmatter, so any pattern works without breaking re-runs."
8383
},
8484
{
8585
"field": "embedPhotos",

0 commit comments

Comments
 (0)