"use strict";
/**
 * obsidian-parser — pure functions for parsing an Obsidian vault on disk.
 *
 * No DB access, no MCP, no I/O side effects other than fs reads. Composed by
 * the `obsidian-vault-import` MCP tool in the memory plugin.
 *
 * Doctrine: every regex is linear-time (no catastrophic backtracking), every
 * function is deterministic. The agent never calls this code; the server does.
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.walkVault = walkVault;
exports.parsePage = parsePage;
exports.splitFrontmatter = splitFrontmatter;
exports.normalizeTag = normalizeTag;
exports.detectDailyNote = detectDailyNote;
exports.buildVaultIndex = buildVaultIndex;
exports.resolveWikilinkInVault = resolveWikilinkInVault;
exports.makeImportId = makeImportId;
const node_crypto_1 = require("node:crypto");
const node_fs_1 = require("node:fs");
const path = __importStar(require("node:path"));
// ---------------------------------------------------------------------------
// Vault walk
// ---------------------------------------------------------------------------
/**
 * Walk the vault directory and return one ParsedPage per `.md` file that
 * survives the optional filter set.
 *
 * Path-traversal defence: paths containing `..` segments are rejected at
 * entry. The `vaultRoot` passed in is assumed to have already been verified
 * by the caller as living under the account's archive directory.
 */
async function walkVault(vaultRoot, opts = {}) {
    const pages = [];
    const queue = [vaultRoot];
    while (queue.length > 0) {
        const dir = queue.shift();
        let entries;
        try {
            entries = await node_fs_1.promises.readdir(dir, { withFileTypes: true });
        }
        catch (err) {
            throw new Error(`obsidian-parser: cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}`);
        }
        for (const entry of entries) {
            if (entry.name.startsWith("."))
                continue;
            const abs = path.join(dir, entry.name);
            if (entry.isDirectory()) {
                queue.push(abs);
                continue;
            }
            if (!entry.isFile())
                continue;
            if (!entry.name.toLowerCase().endsWith(".md"))
                continue;
            const rel = toPosixRelative(vaultRoot, abs);
            if (rel.split("/").includes(".."))
                continue;
            if (opts.folderPrefixes && opts.folderPrefixes.length > 0) {
                const matchesPrefix = opts.folderPrefixes.some((p) => rel === p || rel.startsWith(p.replace(/\/?$/, "/")));
                if (!matchesPrefix)
                    continue;
            }
            let stat;
            let bytes;
            try {
                stat = await node_fs_1.promises.stat(abs);
                bytes = await node_fs_1.promises.readFile(abs);
            }
            catch (err) {
                // Per-file isolation — log and skip, don't abort the walk.
                process.stderr.write(`[obsidian-parser] event=read-failed file=${rel} reason=${err instanceof Error ? err.message : String(err)}\n`);
                continue;
            }
            if (opts.modifiedSinceMs && stat.mtimeMs < opts.modifiedSinceMs)
                continue;
            let parsed;
            try {
                parsed = parsePage(rel, bytes, stat.mtimeMs);
            }
            catch (err) {
                process.stderr.write(`[obsidian-parser] event=parse-failed file=${rel} reason=${err instanceof Error ? err.message : String(err)}\n`);
                continue;
            }
            if (opts.tagFilter && opts.tagFilter.length > 0) {
                const wanted = new Set(opts.tagFilter.map(normalizeTag));
                const hit = parsed.tags.some((t) => wanted.has(normalizeTag(t)));
                if (!hit)
                    continue;
            }
            pages.push(parsed);
        }
    }
    pages.sort((a, b) => a.obsidianPath.localeCompare(b.obsidianPath));
    return pages;
}
function toPosixRelative(root, abs) {
    const rel = path.relative(root, abs);
    return rel.split(path.sep).join("/");
}
// ---------------------------------------------------------------------------
// Page parser
// ---------------------------------------------------------------------------
const DAILY_NOTE_FILENAME_RE = /^(\d{4})-(\d{2})-(\d{2})(?:\.md)?$/;
const DAILY_NOTE_FOLDER_NAMES = new Set([
    "daily",
    "daily-notes",
    "daily notes",
    "journal",
    "journals",
]);
function parsePage(obsidianPath, bytes, mtimeMs) {
    const contentHash = (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
    const text = bytes.toString("utf8");
    const { frontmatter, body } = splitFrontmatter(text);
    const title = path
        .basename(obsidianPath)
        .replace(/\.md$/i, "")
        .normalize("NFC");
    const aliases = extractAliases(frontmatter);
    const frontmatterTags = extractFrontmatterTags(frontmatter);
    const { bodyTags, wikilinks, attachments } = scanBody(body, obsidianPath);
    const tags = dedupeNormalized([...frontmatterTags, ...bodyTags]);
    const noteDate = detectDailyNote(obsidianPath, frontmatter);
    return {
        obsidianPath,
        title,
        aliases: aliases.map((a) => a.normalize("NFC")),
        tags,
        body,
        contentHash,
        noteDate,
        wikilinks,
        attachments,
        mtimeMs,
    };
}
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function splitFrontmatter(text) {
    const match = text.match(FRONTMATTER_RE);
    if (!match) {
        return { frontmatter: { raw: "", fields: new Map() }, body: text };
    }
    const raw = match[1];
    const body = text.slice(match[0].length);
    // Minimal YAML-subset parser: top-level `key: value` and `key:` followed by
    // `  - item` lines. Not a full YAML — we explicitly limit the surface so a
    // malformed frontmatter degrades to "no fields found" rather than throwing.
    const fields = new Map();
    const lines = raw.split(/\r?\n/);
    let currentKey = null;
    let currentBlock = [];
    for (const line of lines) {
        if (/^[#]/.test(line.trim()))
            continue; // comment
        const flush = () => {
            if (currentKey !== null) {
                const existing = fields.get(currentKey);
                const next = currentBlock.join("\n");
                fields.set(currentKey, existing ? existing + "\n" + next : next);
            }
            currentKey = null;
            currentBlock = [];
        };
        const topMatch = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
        if (topMatch && !line.startsWith(" ") && !line.startsWith("\t")) {
            flush();
            currentKey = topMatch[1];
            const value = topMatch[2];
            if (value.trim() === "") {
                currentBlock = [];
            }
            else {
                currentBlock = [value];
                flush();
            }
            continue;
        }
        if (currentKey !== null) {
            currentBlock.push(line);
        }
    }
    if (currentKey !== null) {
        const existing = fields.get(currentKey);
        const next = currentBlock.join("\n");
        fields.set(currentKey, existing ? existing + "\n" + next : next);
    }
    return { frontmatter: { raw, fields }, body };
}
function extractAliases(fm) {
    return parseYamlList(fm.fields.get("aliases"));
}
function extractFrontmatterTags(fm) {
    return parseYamlList(fm.fields.get("tags")).map(normalizeTag);
}
function parseYamlList(value) {
    if (!value)
        return [];
    const trimmed = value.trim();
    if (!trimmed)
        return [];
    // Flow-style: [a, b, c]
    if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
        return trimmed
            .slice(1, -1)
            .split(",")
            .map((s) => s.trim().replace(/^["']|["']$/g, ""))
            .filter(Boolean);
    }
    // Block-style: - item per line, or single scalar
    const lines = trimmed.split(/\r?\n/);
    if (lines.length === 1 && !lines[0].startsWith("-")) {
        return [lines[0].replace(/^["']|["']$/g, "").trim()].filter(Boolean);
    }
    const out = [];
    for (const line of lines) {
        const m = line.match(/^\s*-\s*(.*?)\s*$/);
        if (m) {
            const v = m[1].replace(/^["']|["']$/g, "").trim();
            if (v)
                out.push(v);
        }
    }
    return out;
}
// ---------------------------------------------------------------------------
// Body scan: wikilinks, tags, attachments — all code-fence-aware
// ---------------------------------------------------------------------------
const WIKILINK_RE = /\[\[([^\]\r\n]+)\]\]/g;
const EMBED_RE = /!\[\[([^\]\r\n]+)\]\]/g;
const IMAGE_RE = /!\[([^\]\r\n]*)\]\(([^)\r\n]+)\)/g;
const INLINE_TAG_RE = /(^|[\s(])#([A-Za-z][\w/-]*)/g;
const ATTACHMENT_EXT_RE = /\.(png|jpe?g|gif|webp|svg|pdf|mp3|mp4|mov|wav|m4a|webm)$/i;
function scanBody(body, obsidianPath) {
    const bodyTags = [];
    const wikilinks = [];
    const attachments = [];
    for (const segment of splitOnCodeFences(body)) {
        if (segment.isCode)
            continue;
        const text = segment.text;
        // Embeds first — they share `[[…]]` with wikilinks but the `!` prefix
        // routes them to attachments.
        const embedSpans = [];
        for (const m of text.matchAll(EMBED_RE)) {
            const inner = m[1].trim();
            const resolved = resolveAttachmentPath(inner, obsidianPath);
            if (resolved && ATTACHMENT_EXT_RE.test(resolved)) {
                attachments.push({ rawPath: inner, resolvedPath: resolved, alt: null });
            }
            else {
                // Non-attachment embed (e.g. another note transclusion). Treat as a
                // wikilink so the cross-reference is preserved.
                wikilinks.push(parseWikilinkInner(inner));
            }
            embedSpans.push([m.index ?? 0, (m.index ?? 0) + m[0].length]);
        }
        for (const m of text.matchAll(WIKILINK_RE)) {
            const start = m.index ?? 0;
            if (embedSpans.some(([s, e]) => start >= s - 1 && start < e))
                continue;
            wikilinks.push(parseWikilinkInner(m[1]));
        }
        for (const m of text.matchAll(IMAGE_RE)) {
            const rawPath = m[2].trim();
            const alt = m[1].trim() || null;
            if (/^https?:\/\//i.test(rawPath))
                continue; // remote
            const resolved = resolveAttachmentPath(rawPath, obsidianPath);
            if (resolved && ATTACHMENT_EXT_RE.test(resolved)) {
                attachments.push({ rawPath, resolvedPath: resolved, alt });
            }
        }
        for (const m of text.matchAll(INLINE_TAG_RE)) {
            bodyTags.push(normalizeTag(m[2]));
        }
    }
    return {
        bodyTags: dedupeNormalized(bodyTags),
        wikilinks,
        attachments,
    };
}
function parseWikilinkInner(inner) {
    const trimmed = inner.trim();
    const aliasIdx = trimmed.indexOf("|");
    let raw = trimmed;
    let alias = null;
    if (aliasIdx >= 0) {
        raw = trimmed.slice(0, aliasIdx).trim();
        alias = trimmed.slice(aliasIdx + 1).trim() || null;
    }
    const anchorIdx = raw.indexOf("#");
    let target = raw;
    let anchor = null;
    if (anchorIdx >= 0) {
        target = raw.slice(0, anchorIdx).trim();
        anchor = raw.slice(anchorIdx + 1).trim() || null;
    }
    return { rawTarget: raw, target: target.normalize("NFC"), anchor, alias };
}
function resolveAttachmentPath(rawPath, obsidianPath) {
    if (!rawPath)
        return null;
    if (rawPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(rawPath))
        return null;
    if (rawPath.split("/").includes(".."))
        return null;
    // Obsidian's default: vault-root relative. We also accept page-relative.
    // For the resolved form we prefer vault-root-relative (more deterministic
    // for MERGE), normalising path separators.
    const norm = rawPath.replace(/\\/g, "/");
    if (norm.includes("/"))
        return norm;
    // Bare filename — could be in any attachments folder. Caller's index does
    // the lookup; we record the bare form.
    return norm;
}
function splitOnCodeFences(text) {
    const out = [];
    const fenceRe = /(^|\n)(```|~~~)[^\n]*\n([\s\S]*?)\n(```|~~~)/g;
    let lastIdx = 0;
    for (const m of text.matchAll(fenceRe)) {
        const start = (m.index ?? 0) + (m[1] ? m[1].length : 0);
        out.push({ text: text.slice(lastIdx, start), isCode: false });
        out.push({ text: m[0], isCode: true });
        lastIdx = start + m[0].length - (m[1] ? m[1].length : 0);
    }
    out.push({ text: text.slice(lastIdx), isCode: false });
    return out;
}
// ---------------------------------------------------------------------------
// Tag normalization
// ---------------------------------------------------------------------------
function normalizeTag(tag) {
    return tag
        .replace(/^#/, "")
        .normalize("NFC")
        .trim()
        .toLowerCase();
}
function dedupeNormalized(values) {
    const seen = new Set();
    const out = [];
    for (const v of values) {
        const k = v.normalize("NFC").trim().toLowerCase();
        if (!k)
            continue;
        if (seen.has(k))
            continue;
        seen.add(k);
        out.push(v);
    }
    return out;
}
// ---------------------------------------------------------------------------
// Daily-notes detection
// ---------------------------------------------------------------------------
function detectDailyNote(obsidianPath, fm) {
    // 1. Frontmatter `date:` or `created:` wins if it's an ISO date.
    for (const key of ["date", "created"]) {
        const v = fm.fields.get(key);
        if (v) {
            const m = v.trim().match(/^(\d{4}-\d{2}-\d{2})/);
            if (m)
                return m[1];
        }
    }
    // 2. Filename matches YYYY-MM-DD.md.
    const basename = path.basename(obsidianPath);
    const fileMatch = basename.match(DAILY_NOTE_FILENAME_RE);
    if (fileMatch)
        return `${fileMatch[1]}-${fileMatch[2]}-${fileMatch[3]}`;
    // 3. Filename is YYYY-MM-DD inside a known daily-notes folder.
    const parts = obsidianPath.split("/");
    const inDailyFolder = parts
        .slice(0, -1)
        .some((p) => DAILY_NOTE_FOLDER_NAMES.has(p.toLowerCase()));
    if (inDailyFolder) {
        const stem = basename.replace(/\.md$/i, "");
        const m = stem.match(/^(\d{4})-(\d{2})-(\d{2})$/);
        if (m)
            return `${m[1]}-${m[2]}-${m[3]}`;
    }
    return null;
}
function buildVaultIndex(pages) {
    const byTitle = new Map();
    const byAlias = new Map();
    for (const p of pages) {
        pushKey(byTitle, p.title.toLowerCase(), p.obsidianPath);
        for (const a of p.aliases) {
            pushKey(byAlias, a.toLowerCase(), p.obsidianPath);
        }
    }
    return { byTitle, byAlias };
}
function pushKey(m, k, v) {
    const arr = m.get(k);
    if (arr) {
        if (!arr.includes(v))
            arr.push(v);
    }
    else {
        m.set(k, [v]);
    }
}
/**
 * Look up a wikilink target inside the vault, returning candidate page paths.
 *
 * The target is matched against (a) page titles and (b) declared aliases.
 * Path-form targets (`Folder/Page`) match directly. Returns an empty array
 * when no intra-vault candidate exists — the caller falls through to entity
 * resolution.
 */
function resolveWikilinkInVault(target, index, knownPaths) {
    const t = target.normalize("NFC").trim();
    if (!t)
        return [];
    // Path-form: contains a slash or ends with `.md`.
    if (t.includes("/") || t.toLowerCase().endsWith(".md")) {
        const norm = t.replace(/\\/g, "/").replace(/\.md$/i, "") + ".md";
        if (knownPaths.has(norm))
            return [norm];
        return [];
    }
    const titleHits = index.byTitle.get(t.toLowerCase()) ?? [];
    const aliasHits = index.byAlias.get(t.toLowerCase()) ?? [];
    const seen = new Set();
    const out = [];
    for (const p of [...titleHits, ...aliasHits]) {
        if (seen.has(p))
            continue;
        seen.add(p);
        out.push(p);
    }
    return out;
}
// ---------------------------------------------------------------------------
// Import-id helper
// ---------------------------------------------------------------------------
function makeImportId() {
    return `obs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
//# sourceMappingURL=index.js.map