src/main/markdown-io.ts
Rama main · réplica de lectura del repositorio original.
// Pure Markdown export/import helpers: no fs or electron, so the handlers own all I/O.
export interface MarkdownNote {
title: string
content: string
tags: string[]
}
const ILLEGAL_CHARS = /[<>:"/\\|?*]/g
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?=\.|$)/i
// APFS and ext4 cap a name at 255 bytes and NTFS at 255 UTF-16 units; 200 UTF-8 bytes
// fits all three with room left for the " (n).md" suffix.
const MAX_NAME_BYTES = 200
function isControlChar(ch: string): boolean {
const code = ch.codePointAt(0)!
return code < 0x20 || code === 0x7f
}
export function toSafeFilename(title: string): string {
let name = ''
let bytes = 0
// for..of walks code points, so the cap never lands inside a surrogate pair.
for (const raw of title.replace(ILLEGAL_CHARS, '_').trim()) {
const ch = isControlChar(raw) ? '_' : raw
bytes += Buffer.byteLength(ch, 'utf8')
if (bytes > MAX_NAME_BYTES) break
name += ch
}
// Windows drops trailing dots and spaces, which would break de-duplication.
name = name.replace(/[. ]+$/, '')
if (!name) return 'Untitled'
return name.replace(WINDOWS_RESERVED, '$1_')
}
// `used` holds lowercased names: the macOS and Windows filesystems are case-insensitive.
export function dedupeName(base: string, used: Set<string>): string {
let file = `${base}.md`
for (let n = 2; used.has(file.toLowerCase()); n++) file = `${base} (${n}).md`
used.add(file.toLowerCase())
return file
}
// Quote anything a YAML flow sequence would read as syntax rather than as a plain scalar.
const TAG_NEEDS_QUOTES = /^[\s\-?'&*!|>%@`{}]|[,[\]{}:#"]|\s$/
function yamlTag(tag: string): string {
// A JSON string is a valid YAML double-quoted scalar.
return tag === '' || TAG_NEEDS_QUOTES.test(tag) ? JSON.stringify(tag) : tag
}
export function serializeNote({ title, content, tags }: MarkdownNote): string {
// split + trim rather than /\s*[\r\n]+\s*/, which backtracks quadratically on long whitespace.
const lines = title.split(/[\r\n]+/).map((line) => line.trim())
const heading = lines.filter(Boolean).join(' ') || 'Untitled'
const body = `# ${heading}\n\n${content.replace(/\r\n/g, '\n')}`
if (tags.length === 0) return body
return `---\ntags: [${tags.map(yamlTag).join(', ')}]\n---\n${body}`
}
// ─── Parsing ─────────────────────────────────────────────────────────────────
// Not a YAML parser: it reads `title` and `tags` in the forms Obsidian and serializeNote
// write, and ignores every other line, so real-world front matter never blocks an import.
function parseScalar(raw: string): string | undefined {
const s = raw.trim()
if (s.startsWith('"')) {
try {
const value: unknown = JSON.parse(s)
return typeof value === 'string' ? value : undefined
} catch {
return undefined
}
}
if (s.startsWith("'")) {
return s.length >= 2 && s.endsWith("'") ? s.slice(1, -1).replace(/''/g, "'") : undefined
}
// A plain scalar ends at " #" (a YAML comment). Found with a one-character search rather than
// /\s+#.*$/, which backtracks quadratically on a long whitespace run.
const comment = s.search(/\s#/)
return comment >= 0 ? s.slice(0, comment).trimEnd() : s
}
function parseFlowList(raw: string): string[] | undefined {
const s = raw.trim()
if (!s.startsWith('[') || !s.endsWith(']')) return undefined
const items: string[] = []
let current = ''
let quote = ''
for (let i = 1; i < s.length - 1; i++) {
const ch = s[i]
if (quote === '"' && ch === '\\') {
current += ch + s[++i]
continue
}
if (quote) {
if (ch === quote) quote = ''
} else if ((ch === '"' || ch === "'") && current.trim() === '') {
// YAML only reads a quote as one at the start of a scalar: `it's` is a plain tag.
quote = ch
}
if (ch === ',' && !quote) {
items.push(current)
current = ''
} else {
current += ch
}
}
items.push(current)
return items.map(parseScalar).filter((t): t is string => !!t)
}
function parseFrontMatter(lines: string[]): { title?: string; tags?: string[] } {
const meta: { title?: string; tags?: string[] } = {}
for (let i = 0; i < lines.length; i++) {
const match = /^([A-Za-z_][\w-]*):(?:[ \t]+(.*))?$/.exec(lines[i])
if (!match) continue
const key = match[1].toLowerCase()
const value = (match[2] ?? '').trim()
if (key === 'title') {
meta.title = parseScalar(value)?.trim()
} else if (key === 'tags' && value === '') {
const tags: string[] = []
while (i + 1 < lines.length) {
const item = /^\s*-[ \t]+(.*)$/.exec(lines[i + 1])
if (!item) break
const tag = parseScalar(item[1])
if (tag) tags.push(tag)
i++
}
meta.tags = tags
} else if (key === 'tags' && value.startsWith('[')) {
meta.tags = parseFlowList(value)
} else if (key === 'tags') {
const tag = parseScalar(value)
meta.tags = tag ? [tag] : []
}
}
return meta
}
// An imported file can carry a title of megabytes; the sidebar lists every title.
const IMPORT_TITLE_MAX = 500
function capTitle(title: string): string {
if (title.length <= IMPORT_TITLE_MAX) return title
// Array.from splits by code point, so the cut never lands inside a surrogate pair.
const codePoints = Array.from(title.slice(0, IMPORT_TITLE_MAX * 2))
return codePoints.slice(0, IMPORT_TITLE_MAX).join('').trimEnd()
}
// `---` is also a Markdown horizontal rule, so a note can open with two of them around plain
// text. Only a block made entirely of YAML-shaped lines, with at least one `key:`, is front
// matter; anything else stays in the content instead of being silently dropped.
const YAML_KEY_LINE = /^[A-Za-z_][\w-]*:/
const YAML_OTHER_LINE = /^(\s|-(\s|$)|#|$)/
function looksLikeFrontMatter(block: string[]): boolean {
return (
block.some((line) => YAML_KEY_LINE.test(line)) &&
block.every((line) => YAML_KEY_LINE.test(line) || YAML_OTHER_LINE.test(line))
)
}
export function parseMarkdownFile(text: string, filename: string): MarkdownNote {
const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
const lines = withoutBom.replace(/\r\n?/g, '\n').split('\n')
let meta: { title?: string; tags?: string[] } = {}
let body = lines
if (lines[0].trimEnd() === '---') {
const end = lines.findIndex((line, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(line))
const block = end > 0 ? lines.slice(1, end) : []
if (looksLikeFrontMatter(block)) {
meta = parseFrontMatter(block)
body = lines.slice(end + 1)
}
}
let title = meta.title
if (!title) {
const first = body.findIndex((line) => line.trim() !== '')
const marker = first >= 0 ? /^#[ \t]+/.exec(body[first]) : null
const heading = marker ? body[first].slice(marker[0].length).trim() : ''
if (heading) {
title = heading
// serializeNote writes "# title" plus a blank line; dropping both keeps a round trip exact.
body = body.slice(body[first + 1] === '' ? first + 2 : first + 1)
}
}
return {
title: capTitle(title || filename.replace(/\.[^.]*$/, '').trim() || 'Untitled'),
content: body.join('\n'),
tags: meta.tags ?? []
}
}