/*****************************************************************
* Linkify DFD — v1.4 (2025-08-12)
* ---------------------------------------------------------------
* Fixed: Only process elements with explicit markers/text
* Fixed: Smart matching for object=name syntax (reuse existing pages)
* Fixed: Skip auto-generation if legitimate page already linked
* Added: Better existing page detection and reuse
******************************************************************/
/************* USER SETTINGS ************************************/
const DEBUG = true;
const REQUIRE_EXPLICIT_MARKER = true; // CHANGED: Now true by default to prevent auto-linking everything
// SMART MATCHING - NEW FEATURE
const SMART_CUSTOM_NAME_MATCHING = true; // If true, "asset=Customer DB" tries to find existing "Customer DB.md" first
const SEARCH_ALL_SUBFOLDERS = true; // When smart matching, search all relevant subfolders
// Storage options
const DB_PLACEMENT = "db_folder";
const DB_FOLDER_NAME = "DFD Objects Database";
const DB_DB_PARENT_PATH = "📦 VAULT SANDBOX TESTING/Data Flow Diagram Testing";
// Config folder
const CFG_DIR = "./DFD Object Configuration";
// Optional inline fields
const WRITE_INLINE_FIELDS = false;
// Filename behavior for custom names
const CUSTOM_NAME_MODE = "replace"; // "replace" | "inject"
// ARROW DIRECTION DETERMINATION
const DIRECTION_DETERMINATION = "arrowhead_priority";
const BIDIRECTIONAL_MODE = "single_bidirectional";
const TRANSFER_DIRECTION_WORD = "tnf";
const OBJECT_SEPARATOR = "_";
// TRANSFER PROPERTY BEHAVIOR
const INCLUDE_FROM_TO_PROPERTIES = true;
const BIDIRECTIONAL_FROM_TO_BOTH = false;
/****************************************************************/
/* ---------- helpers ---------- */
const exists = p => !!app.vault.getAbstractFileByPath(p);
const create = (p,c) => app.vault.create(p,c);
const read = (p) => app.vault.read(app.vault.getAbstractFileByPath(p));
const write = (p,c) => app.vault.modify(app.vault.getAbstractFileByPath(p), c);
const bn = p => p.split("/").pop().replace(/\.md$/i,"");
const slug = s => s.replace(/[\\/#^|?%*:<>"]/g," ").trim().replace(/\s+/g,"-").toLowerCase() || "unnamed";
const rnd4 = () => Math.random().toString(36).slice(2,6);
const nowISO = () => new Date().toISOString();
const note = m => DEBUG && new Notice(m, 4000);
const clog = m => DEBUG && console.log("🔧 DFD:", m);
// Normalize vault paths
function normalizePath(path) {
if (!path) return "";
return path.replace(/^\/+|\/+$/g, "");
}
// NEW: Smart search for existing pages by name
function findExistingPageByName(targetName, kind) {
clog(`🔍 Smart searching for existing page: "${targetName}" (${kind})`);
const searchName = slug(targetName);
const exactFileName = `${searchName}.md`;
// Strategy 1: Look in the target folder first
const targetFolder = getTargetFolder(kind);
const primaryPath = `${targetFolder}/${exactFileName}`;
clog(` Checking primary path: ${primaryPath}`);
if (exists(primaryPath)) {
clog(` ✓ Found exact match: ${primaryPath}`);
return primaryPath;
}
if (!SEARCH_ALL_SUBFOLDERS) {
clog(` ✗ Not found, search limited to primary folder`);
return null;
}
// Strategy 2: Search all relevant subfolders
const searchFolders = [];
if (kind === "asset") searchFolders.push("Assets", "Entities");
else if (kind === "entity") searchFolders.push("Entities", "Assets");
for (const folder of searchFolders) {
const searchPath = `${ROOT}/${folder}/${exactFileName}`;
clog(` Checking ${folder}: ${searchPath}`);
if (exists(searchPath)) {
clog(` ✓ Found in ${folder}: ${searchPath}`);
return searchPath;
}
}
// Strategy 3: Search by basename across entire vault (last resort)
clog(` Searching vault-wide for basename: ${searchName}`);
const allFiles = app.vault.getMarkdownFiles();
const found = allFiles.find(f => f.basename.toLowerCase() === searchName.toLowerCase());
if (found) {
clog(` ✓ Found vault-wide: ${found.path}`);
return found.path;
}
clog(` ✗ No existing page found for "${targetName}"`);
return null;
}
// Detect if arrow is bidirectional
function isBidirectional(arrow) {
const hasStart = arrow.startArrowhead && arrow.startArrowhead !== null;
const hasEnd = arrow.endArrowhead && arrow.endArrowhead !== null;
const result = hasStart && hasEnd;
clog(`Arrow ${arrow.id}: startArrowhead=${arrow.startArrowhead}, endArrowhead=${arrow.endArrowhead} → bidirectional: ${result}`);
return result;
}
// Determine arrow direction
function determineArrowDirection(arrow) {
clog(`\n--- Determining direction for arrow ${arrow.id} ---`);
clog(` Direction method: ${DIRECTION_DETERMINATION}`);
const hasStartArrowhead = arrow.startArrowhead && arrow.startArrowhead !== null;
const hasEndArrowhead = arrow.endArrowhead && arrow.endArrowhead !== null;
const hasStartBinding = arrow.startBinding?.elementId;
const hasEndBinding = arrow.endBinding?.elementId;
clog(` Arrowheads: start=${arrow.startArrowhead}, end=${arrow.endArrowhead}`);
clog(` Bindings: start=${hasStartBinding}, end=${hasEndBinding}`);
let fromElementId = null;
let toElementId = null;
let directionSource = "unknown";
switch (DIRECTION_DETERMINATION) {
case "binding_only":
fromElementId = hasStartBinding;
toElementId = hasEndBinding;
directionSource = "binding";
clog(` Using binding_only: ${fromElementId} → ${toElementId}`);
break;
case "arrowhead_priority":
if (hasEndArrowhead && !hasStartArrowhead) {
fromElementId = hasStartBinding;
toElementId = hasEndBinding;
directionSource = "end_arrowhead";
clog(` End arrowhead detected: ${fromElementId} → ${toElementId}`);
} else if (hasStartArrowhead && !hasEndArrowhead) {
fromElementId = hasEndBinding;
toElementId = hasStartBinding;
directionSource = "start_arrowhead";
clog(` Start arrowhead detected (reversed): ${fromElementId} → ${toElementId}`);
} else if (hasEndArrowhead && hasStartArrowhead) {
fromElementId = hasStartBinding;
toElementId = hasEndBinding;
directionSource = "bidirectional";
clog(` Bidirectional arrows: ${fromElementId} ⟷ ${toElementId}`);
} else {
fromElementId = hasStartBinding;
toElementId = hasEndBinding;
directionSource = "binding_fallback";
clog(` No arrowheads, using binding: ${fromElementId} → ${toElementId}`);
}
break;
case "arrowhead_fallback_binding":
if (hasEndArrowhead && !hasStartArrowhead) {
fromElementId = hasStartBinding;
toElementId = hasEndBinding;
directionSource = "end_arrowhead";
clog(` End arrowhead: ${fromElementId} → ${toElementId}`);
} else if (hasStartArrowhead && !hasEndArrowhead) {
fromElementId = hasEndBinding;
toElementId = hasStartBinding;
directionSource = "start_arrowhead";
clog(` Start arrowhead (reversed): ${fromElementId} → ${toElementId}`);
} else {
fromElementId = hasStartBinding;
toElementId = hasEndBinding;
directionSource = hasStartArrowhead && hasEndArrowhead ? "bidirectional" : "binding_fallback";
clog(` Fallback to binding: ${fromElementId} → ${toElementId}`);
}
break;
default:
fromElementId = hasStartBinding;
toElementId = hasEndBinding;
directionSource = "binding_default";
clog(` Default binding: ${fromElementId} → ${toElementId}`);
}
const result = {
fromElementId,
toElementId,
directionSource,
isBidirectional: hasStartArrowhead && hasEndArrowhead
};
clog(` Final direction: ${result.fromElementId} → ${result.toElementId} (source: ${result.directionSource})`);
return result;
}
// Resolve wikilink to actual file path
function resolveLink(linkText, fromPath) {
clog(`Resolving link: "${linkText}" from ${fromPath}`);
let cleanLink = linkText;
if (linkText.startsWith("[[") && linkText.endsWith("]]")) {
cleanLink = linkText.slice(2, -2);
}
if (cleanLink.includes("|")) {
cleanLink = cleanLink.split("|")[0];
}
let resolved = null;
// Strategy 1: Use Obsidian's built-in resolver
resolved = app.metadataCache.getFirstLinkpathDest(cleanLink, fromPath);
// Strategy 2: Direct path lookup
if (!resolved) {
if (exists(cleanLink)) {
resolved = app.vault.getAbstractFileByPath(cleanLink);
} else if (exists(`${cleanLink}.md`)) {
resolved = app.vault.getAbstractFileByPath(`${cleanLink}.md`);
}
}
// Strategy 3: Search by filename
if (!resolved) {
const filename = cleanLink.split("/").pop();
const allFiles = app.vault.getMarkdownFiles();
resolved = allFiles.find(f => f.basename === filename || f.name === filename);
}
const result = resolved ? resolved.path : null;
clog(` → Resolved to: ${result}`);
return result;
}
/* shortest wiki link */
function shortWiki(path, fromPath) {
const file = app.vault.getAbstractFileByPath(path);
if (!file) {
clog(`⚠️ File not found for shortWiki: ${path}`);
return `[[${path}]]`;
}
const linkText = app.metadataCache.fileToLinktext(file, fromPath);
const result = `[[${linkText}]]`;
clog(`shortWiki: ${path} → ${result} (from: ${fromPath})`);
return result;
}
/* ensure folder chain */
async function ensureFolder(path) {
if (!path) return;
const normalizedPath = normalizePath(path);
clog(`Ensuring folder: ${normalizedPath}`);
const parts = normalizedPath.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
if (!exists(current)) {
try {
await app.vault.createFolder(current);
clog(` Created folder: ${current}`);
} catch(e) {
clog(` Failed to create folder ${current}: ${e.message}`);
}
}
}
}
/* frontmatter helpers */
async function setFM(fp, updater) {
const f = app.vault.getAbstractFileByPath(fp);
if (f) await app.fileManager.processFrontMatter(f, updater);
}
async function pushArr(fp, key, value) {
await setFM(fp, fm => {
const arr = Array.isArray(fm[key]) ? fm[key] : (fm[key] ? [fm[key]] : []);
if (!arr.includes(value)) arr.push(value);
fm[key] = arr;
});
}
async function dvInline(fp, field, val) {
if (!WRITE_INLINE_FIELDS) return;
const content = await read(fp);
const line = `${field}:: ${val}`;
if (!content.includes(line)) {
await write(fp, content.endsWith("\n") ? content + line + "\n" : content + "\n" + line + "\n");
}
}
/* ---------- environment ---------- */
ea.reset();
ea.setView("active");
const view = ea.targetView;
if (!view?.file) {
new Notice("Open an Excalidraw file first");
return;
}
const DRAW_DIR = view.file.parent?.path || "";
clog(`Drawing directory: ${DRAW_DIR}`);
// Resolve CFG_DIR relative to drawing
const RESOLVED_CFG_DIR = CFG_DIR.startsWith("./") ?
`${DRAW_DIR}/${CFG_DIR.slice(2)}` :
CFG_DIR;
clog(`Config directory: ${RESOLVED_CFG_DIR}`);
// Database root path resolution
const ROOT = (() => {
switch(DB_PLACEMENT) {
case "flat":
return DRAW_DIR;
case "diagram_named":
return `${DRAW_DIR}/${bn(view.file.path)}`;
case "db_folder":
if (DB_DB_PARENT_PATH) {
const normalizedParent = normalizePath(DB_DB_PARENT_PATH);
const result = `${normalizedParent}/${DB_FOLDER_NAME}`;
clog(`Database path: ${normalizedParent} + ${DB_FOLDER_NAME} = ${result}`);
return result;
} else {
return `${DRAW_DIR}/${DB_FOLDER_NAME}`;
}
default:
return DRAW_DIR;
}
})();
clog(`Storage root: ${ROOT}`);
/* ---------- load config notes ---------- */
function allMarkdown(dir) {
clog(`Looking for markdown files in: ${dir}`);
const root = app.vault.getAbstractFileByPath(dir);
if (!root) {
clog(` Directory not found: ${dir}`);
return [];
}
const out = [];
const walk = f => {
if (f.children) f.children.forEach(walk);
else if (f.extension === "md") out.push(f);
};
walk(root);
clog(` Found ${out.length} markdown files`);
return out;
}
const CFG = new Map();
const DEFAULT_SUBFOLDERS = { asset: "Assets", entity: "Entities", transfer: "Transfers" };
// Load config notes
for (const f of allMarkdown(RESOLVED_CFG_DIR)) {
clog(`Processing config file: ${f.path}`);
const fc = app.metadataCache.getFileCache(f);
const fm = fc?.frontmatter || {};
const kind = (fm["DFD__KIND"] || "").toLowerCase();
if (!["asset", "entity", "transfer"].includes(kind)) {
clog(` Skipping - invalid kind: ${kind}`);
continue;
}
const markers = fm["DFD__MARKER"];
const markerList = Array.isArray(markers) ? markers : [markers || kind];
const subfolder = fm["DFD__SUBFOLDER"] || DEFAULT_SUBFOLDERS[kind];
const defaults = Object.fromEntries(
Object.entries(fm).filter(([k]) => !k.startsWith("DFD__"))
);
const pos = fc?.frontmatterPosition;
const body = pos ? (await app.vault.read(f)).slice(pos.end.offset).trim() : "";
const cfg = { kind, defaults, subfolder, body };
for (const marker of markerList) {
const key = marker.toString().toLowerCase();
CFG.set(key, cfg);
clog(` Registered marker "${key}" → ${kind}`);
}
}
function getConfig(key) {
const config = CFG.get(key.toLowerCase()) || {
kind: key,
defaults: { schema: `dfd-${key}-v1`, type: key },
subfolder: DEFAULT_SUBFOLDERS[key] || key,
body: ""
};
clog(`getConfig("${key}") → kind: ${config.kind}, subfolder: ${config.subfolder}`);
return config;
}
function getTargetFolder(kind) {
const config = getConfig(kind);
const result = `${ROOT}/${config.subfolder}`;
clog(`getTargetFolder(${kind}) → ${result}`);
return result;
}
await ensureFolder(getTargetFolder("asset"));
await ensureFolder(getTargetFolder("entity"));
await ensureFolder(getTargetFolder("transfer"));
/* ---------- scene parsing ---------- */
const MARK = /^(?:\[\[)?(?:tpl:)?\s*(asset|entity|transfer)\s*(?:=\s*([^\]]+))?(?:\]\])?$/i;
const els = ea.getViewElements ? ea.getViewElements() : ea.getElements();
const byId = Object.fromEntries(els.map(e => [e.id, e]));
clog(`Found ${els.length} elements on canvas`);
function getGroupEls(el) {
const gid = el.groupIds?.at(-1);
const group = gid ? els.filter(x => x.groupIds?.includes(gid)) : [el];
clog(`Element ${el.id} (${el.type}) has group of ${group.length} elements`);
return group;
}
function firstText(group) {
const textEl = group.find(e => e.type === "text" && (e.text || "").trim());
const result = textEl?.text.trim() || "";
if (result) clog(` Found text in group: "${result}"`);
return result;
}
function parseMarker(s) {
if (!s) return null;
const m = s.trim().match(MARK);
const result = m ? { kind: m[1].toLowerCase(), customName: (m[2] || "").trim() || null } : null;
if (result) clog(` Parsed marker "${s}" → kind: ${result.kind}, customName: ${result.customName}`);
return result;
}
// IMPROVED: More strict classification - only processes explicit markers
function classifyElement(el) {
clog(`\n--- Classifying element ${el.id} (${el.type}) ---`);
const group = getGroupEls(el);
// Check customData first
const cd = el.customData?.dfd || el.customData?.DFD;
if (cd?.kind && ["asset", "entity", "transfer"].includes(cd.kind)) {
clog(` ✓ Found in customData: ${cd.kind}`);
return { kind: cd.kind, customName: null };
}
// Check element.link (what "Add link" sets) - MUST have marker text
if (typeof el.link === "string" && el.link.trim()) {
clog(` Checking element.link: "${el.link}"`);
const linkMarker = parseMarker(el.link);
if (linkMarker) {
clog(` ✓ Found valid marker in element.link: ${linkMarker.kind}`);
return linkMarker;
}
// NEW: Check if element.link is already a legitimate wikilink to existing file
if (el.link.includes("[[") && el.link.includes("]]")) {
const resolvedPath = resolveLink(el.link, view.file.path);
if (resolvedPath && exists(resolvedPath)) {
clog(` ✓ Element already links to legitimate file: ${resolvedPath}`);
return { kind: "existing", customName: null, existingPath: resolvedPath };
}
}
}
// Check grouped text - MUST have marker text
const groupText = firstText(group);
if (groupText) {
clog(` Checking group text: "${groupText}"`);
const textMarker = parseMarker(groupText);
if (textMarker) {
clog(` ✓ Found valid marker in group text: ${textMarker.kind}`);
return textMarker;
}
}
// REMOVED: No more fallback by element type when REQUIRE_EXPLICIT_MARKER = true
// This prevents every rectangle from getting auto-processed
if (!REQUIRE_EXPLICIT_MARKER) {
if (el.type === "arrow") {
clog(` ✓ Fallback: arrow → transfer`);
return { kind: "transfer", customName: null };
}
if (["rectangle", "ellipse", "diamond", "image", "frame"].includes(el.type)) {
clog(` ✓ Fallback: ${el.type} → asset`);
return { kind: "asset", customName: null };
}
}
clog(` ✗ No valid classification found`);
return null;
}
/* ---------- create/ensure nodes ---------- */
async function createNodeNote(kind, customName, shapeType) {
clog(`\n--- Creating ${kind} note ---`);
const config = getConfig(kind);
const folder = getTargetFolder(kind);
await ensureFolder(folder);
let fileName;
if (customName && CUSTOM_NAME_MODE === "replace") {
fileName = slug(customName);
clog(` Using custom name (replace): ${fileName}`);
} else if (customName && CUSTOM_NAME_MODE === "inject") {
fileName = `${kind}-${shapeType}-${slug(customName)}-${rnd4()}`;
clog(` Using custom name (inject): ${fileName}`);
} else {
fileName = `${kind}-${shapeType}-${rnd4()}`;
clog(` Using generated name: ${fileName}`);
}
let path = `${folder}/${fileName}.md`;
let counter = 2;
while (exists(path)) {
path = `${folder}/${fileName}-${counter}.md`;
counter++;
}
clog(` Final path: ${path}`);
const fm = Object.assign({}, config.defaults, {
name: customName || config.defaults.name || kind,
created: nowISO()
});
const fmLines = ["---"];
for (const [key, value] of Object.entries(fm)) {
fmLines.push(`${key}: ${typeof value === "string" ? `"${value.replace(/"/g, '\\"')}"` : JSON.stringify(value)}`);
}
fmLines.push("---");
const content = config.body ?
fmLines.join("\n") + "\n\n" + config.body :
fmLines.join("\n") + "\n\n";
await create(path, content);
clog(` ✓ Created note: ${path}`);
return path;
}
// IMPROVED: Better existing link detection and smart custom name matching
async function ensureNodeLinked(el, kind, customName, existingPath = null) {
if (!["asset", "entity"].includes(kind) && kind !== "existing") return null;
clog(`\n--- Ensuring ${kind} node linked ---`);
const group = getGroupEls(el);
// NEW: If classified as "existing", just normalize the link and return
if (kind === "existing" && existingPath) {
clog(` ✓ Element already has legitimate link, normalizing: ${existingPath}`);
const wikiLink = shortWiki(existingPath, view.file.path);
const largest = group.reduce((a, b) =>
(a.width * a.height >= b.width * b.height ? a : b), group[0]
);
largest.link = wikiLink;
ea.copyViewElementsToEAforEditing([largest]);
return existingPath;
}
// Check for existing legitimate wikilinks
const existingLinkEl = group.find(e =>
typeof e.link === "string" &&
e.link.includes("[[") &&
e.link.includes("]]") &&
!parseMarker(e.link) // Exclude marker links like "asset=name"
);
if (existingLinkEl) {
clog(` Found existing wikilink: ${existingLinkEl.link}`);
const actualPath = resolveLink(existingLinkEl.link, view.file.path);
if (actualPath && exists(actualPath)) {
clog(` ✓ Existing file found, reusing: ${actualPath}`);
const wikiLink = shortWiki(actualPath, view.file.path);
const largest = group.reduce((a, b) =>
(a.width * a.height >= b.width * b.height ? a : b), group[0]
);
largest.link = wikiLink;
ea.copyViewElementsToEAforEditing([largest]);
return actualPath;
} else {
clog(` ✗ Existing link points to non-existent file: ${actualPath}`);
}
}
// NEW: Smart custom name matching - check if page with exact name already exists
if (customName && SMART_CUSTOM_NAME_MATCHING) {
const existingByName = findExistingPageByName(customName, kind);
if (existingByName) {
clog(` ✓ Found existing page for custom name "${customName}": ${existingByName}`);
const wikiLink = shortWiki(existingByName, view.file.path);
// Update all elements in group
group.forEach(e => {
e.link = wikiLink;
// Remove marker text to prevent duplicates
if (typeof e.text === "string" && e.text.match(MARK)) {
const oldText = e.text;
e.text = e.text.replace(MARK, "").trim();
clog(` Cleaned marker text: "${oldText}" → "${e.text}"`);
}
});
ea.copyViewElementsToEAforEditing(group);
note(`✓ ${kind} (reused) → ${wikiLink}`);
clog(` ✓ Linked to existing ${kind} → ${wikiLink}`);
return existingByName;
}
}
// Create new note
const path = await createNodeNote(kind, customName, el.type);
const wikiLink = shortWiki(path, view.file.path);
// Update all elements in group
group.forEach(e => {
e.link = wikiLink;
// Remove marker text to prevent duplicates
if (typeof e.text === "string" && e.text.match(MARK)) {
const oldText = e.text;
e.text = e.text.replace(MARK, "").trim();
clog(` Cleaned marker text: "${oldText}" → "${e.text}"`);
}
});
ea.copyViewElementsToEAforEditing(group);
note(`✓ ${kind} (new) → ${wikiLink}`);
clog(` ✓ Linked ${kind} → ${wikiLink}`);
return path;
}
/* ---------- Transfer creation helper ---------- */
async function createTransferNote(objectAPath, objectBPath, classification, isBidirectional = false, suffix = "") {
const config = getConfig("transfer");
const folder = getTargetFolder("transfer");
const direction = isBidirectional ? TRANSFER_DIRECTION_WORD : "to";
const objA = slug(bn(objectAPath));
const objB = slug(bn(objectBPath));
const fileName = `transfer${OBJECT_SEPARATOR}${objA}${OBJECT_SEPARATOR}${direction}${OBJECT_SEPARATOR}${objB}${suffix}`;
let path = `${folder}/${fileName}.md`;
if (exists(path)) {
path = `${folder}/${fileName}-${rnd4()}.md`;
clog(` File exists, using: ${path}`);
} else {
clog(` Using: ${path}`);
}
const fm = Object.assign({}, config.defaults, {
name: classification.customName || config.defaults.name || "transfer",
created: nowISO()
});
if (isBidirectional) {
fm.direction = "bidirectional";
fm.note = "This transfer represents bidirectional data flow";
}
const fmLines = ["---"];
for (const [key, value] of Object.entries(fm)) {
fmLines.push(`${key}: ${typeof value === "string" ? `"${value.replace(/"/g, '\\"')}"` : JSON.stringify(value)}`);
}
fmLines.push("---");
const content = config.body ?
fmLines.join("\n") + "\n\n" + config.body :
fmLines.join("\n") + "\n\n";
await create(path, content);
clog(` ✓ Created transfer note: ${path}`);
return path;
}
/* ---------- transfers with improved direction detection ---------- */
async function ensureTransfer(arr) {
clog(`\n--- Processing arrow ${arr.id} ---`);
const classification = classifyElement(arr);
if (!classification || classification.kind !== "transfer") {
clog(` ✗ Not classified as transfer`);
return;
}
clog(` ✓ Classified as transfer, customName: ${classification.customName}`);
const direction = determineArrowDirection(arr);
if (!direction.fromElementId || !direction.toElementId) {
clog(` ✗ Could not determine arrow direction`);
note("↯ could not determine arrow direction");
return;
}
const startEl = byId[direction.fromElementId];
const endEl = byId[direction.toElementId];
if (!startEl || !endEl) {
clog(` ✗ Direction objects not found in element map`);
note("↯ direction objects not found");
return;
}
clog(` ✓ Found objects: ${startEl.type} → ${endEl.type} (via ${direction.directionSource})`);
const bidirectional = direction.isBidirectional;
if (bidirectional) {
clog(` 🔄 Detected bidirectional arrow (mode: ${BIDIRECTIONAL_MODE})`);
}
// Ensure both objects are linked - handle existing links properly
const startClass = classifyElement(startEl) || { kind: "asset", customName: null };
const endClass = classifyElement(endEl) || { kind: "asset", customName: null };
const startPath = await ensureNodeLinked(
startEl,
startClass.kind === "existing" ? "existing" : startClass.kind,
startClass.customName,
startClass.existingPath
);
const endPath = await ensureNodeLinked(
endEl,
endClass.kind === "existing" ? "existing" : endClass.kind,
endClass.customName,
endClass.existingPath
);
if (!startPath || !endPath) {
clog(` ✗ Could not ensure object notes`);
note("↯ could not ensure object notes");
return;
}
clog(` ✓ Objects: ${startPath} → ${endPath}`);
// Check for existing arrow link (exclude marker links)
if (typeof arr.link === "string" && arr.link.includes("[[") && arr.link.includes("]]") && !parseMarker(arr.link)) {
clog(` Found existing arrow link: ${arr.link}`);
const existingPath = resolveLink(arr.link, view.file.path);
if (existingPath && exists(existingPath)) {
clog(` ✓ Reusing existing transfer: ${existingPath}`);
const wikiLink = shortWiki(existingPath, view.file.path);
// Update object arrays based on bidirectional mode
if (bidirectional && BIDIRECTIONAL_MODE === "single_bidirectional") {
await pushArr(startPath, "dfd_out", wikiLink);
await pushArr(startPath, "dfd_in", wikiLink);
await pushArr(endPath, "dfd_out", wikiLink);
await pushArr(endPath, "dfd_in", wikiLink);
} else {
await pushArr(startPath, "dfd_out", wikiLink);
await pushArr(endPath, "dfd_in", wikiLink);
}
return;
}
}
// Create transfer based on bidirectional mode (rest of transfer logic remains the same)
if (bidirectional && BIDIRECTIONAL_MODE === "dual_transfers") {
// Dual transfer logic...
const path1 = await createTransferNote(startPath, endPath, classification, false, `${OBJECT_SEPARATOR}forward`);
const path2 = await createTransferNote(endPath, startPath, classification, false, `${OBJECT_SEPARATOR}reverse`);
const startWiki = shortWiki(startPath, view.file.path);
const endWiki = shortWiki(endPath, view.file.path);
const sourceWiki = shortWiki(view.file.path, view.file.path);
await setFM(path1, fm => {
fm.object_a = startWiki;
fm.object_b = endWiki;
fm.source_drawing = sourceWiki;
fm.paired_transfer = shortWiki(path2, view.file.path);
fm.direction_source = direction.directionSource;
if (INCLUDE_FROM_TO_PROPERTIES) {
fm.from = startWiki;
fm.to = endWiki;
}
});
await setFM(path2, fm => {
fm.object_a = endWiki;
fm.object_b = startWiki;
fm.source_drawing = sourceWiki;
fm.paired_transfer = shortWiki(path1, view.file.path);
fm.direction_source = direction.directionSource;
if (INCLUDE_FROM_TO_PROPERTIES) {
fm.from = endWiki;
fm.to = startWiki;
}
});
const transferWiki = shortWiki(path1, view.file.path);
arr.link = transferWiki;
const transfer1Wiki = shortWiki(path1, view.file.path);
const transfer2Wiki = shortWiki(path2, view.file.path);
await pushArr(startPath, "dfd_out", transfer1Wiki);
await pushArr(startPath, "dfd_in", transfer2Wiki);
await pushArr(endPath, "dfd_in", transfer1Wiki);
await pushArr(endPath, "dfd_out", transfer2Wiki);
note(`✓ bidirectional transfers → ${transfer1Wiki} ⟷ ${transfer2Wiki}`);
clog(` ✓ Created dual transfers: ${transfer1Wiki} ⟷ ${transfer2Wiki}`);
} else if (bidirectional && BIDIRECTIONAL_MODE === "single_bidirectional") {
// Single bidirectional logic...
const path = await createTransferNote(startPath, endPath, classification, true);
const startWiki = shortWiki(startPath, view.file.path);
const endWiki = shortWiki(endPath, view.file.path);
const sourceWiki = shortWiki(view.file.path, view.file.path);
await setFM(path, fm => {
fm.object_a = startWiki;
fm.object_b = endWiki;
fm.source_drawing = sourceWiki;
fm.direction_source = direction.directionSource;
if (INCLUDE_FROM_TO_PROPERTIES) {
if (BIDIRECTIONAL_FROM_TO_BOTH) {
fm.from = [startWiki, endWiki];
fm.to = [startWiki, endWiki];
} else {
fm.from = startWiki;
fm.to = endWiki;
}
}
});
const transferWiki = shortWiki(path, view.file.path);
arr.link = transferWiki;
await pushArr(startPath, "dfd_out", transferWiki);
await pushArr(startPath, "dfd_in", transferWiki);
await pushArr(endPath, "dfd_out", transferWiki);
await pushArr(endPath, "dfd_in", transferWiki);
note(`✓ bidirectional transfer → ${transferWiki}`);
clog(` ✓ Created bidirectional transfer: ${transferWiki}`);
} else {
// Normal unidirectional transfer
const path = await createTransferNote(startPath, endPath, classification, false);
const startWiki = shortWiki(startPath, view.file.path);
const endWiki = shortWiki(endPath, view.file.path);
const sourceWiki = shortWiki(view.file.path, view.file.path);
await setFM(path, fm => {
fm.object_a = startWiki;
fm.object_b = endWiki;
fm.source_drawing = sourceWiki;
fm.direction_source = direction.directionSource;
if (INCLUDE_FROM_TO_PROPERTIES) {
fm.from = startWiki;
fm.to = endWiki;
}
});
const transferWiki = shortWiki(path, view.file.path);
arr.link = transferWiki;
await pushArr(startPath, "dfd_out", transferWiki);
await pushArr(endPath, "dfd_in", transferWiki);
note(`✓ transfer → ${transferWiki}`);
clog(` ✓ Created unidirectional transfer: ${transferWiki}`);
}
// Clear marker and add metadata
const edgeId = "TR-" + rnd4().toUpperCase();
arr.customData = {
...(arr.customData || {}),
dfd: {
kind: "transfer",
edgeId,
bidirectional: bidirectional && BIDIRECTIONAL_MODE !== "ignore_bidirectional",
mode: BIDIRECTIONAL_MODE,
directionSource: direction.directionSource
}
};
// Add label to straight arrows
try {
if (Array.isArray(arr.points) && arr.points.length === 2) {
const label = bidirectional && BIDIRECTIONAL_MODE !== "ignore_bidirectional" ? `${edgeId}⟷` : edgeId;
ea.addLabelToLine(arr.id, label);
clog(` ✓ Added label: ${label}`);
}
} catch(e) {
clog(` ✗ Failed to add label: ${e.message}`);
}
ea.copyViewElementsToEAforEditing([arr]);
}
/* ---------- main execution ---------- */
(async () => {
clog("\n🚀 Starting Linkify DFD v1.4");
clog(`📋 Explicit markers required: ${REQUIRE_EXPLICIT_MARKER}`);
clog(`📋 Smart custom name matching: ${SMART_CUSTOM_NAME_MATCHING}`);
clog(`📋 Search all subfolders: ${SEARCH_ALL_SUBFOLDERS}`);
clog(`📋 Direction determination: ${DIRECTION_DETERMINATION}`);
clog(`📋 Bidirectional mode: ${BIDIRECTIONAL_MODE}`);
// Process nodes first
const nodeElements = els.filter(e => e.type !== "arrow");
clog(`\n📦 Processing ${nodeElements.length} node elements`);
for (const el of nodeElements) {
const classification = classifyElement(el);
if (classification && (["asset", "entity"].includes(classification.kind) || classification.kind === "existing")) {
await ensureNodeLinked(el, classification.kind, classification.customName, classification.existingPath);
}
}
// Process arrows
const arrowElements = els.filter(e => e.type === "arrow");
clog(`\n🏹 Processing ${arrowElements.length} arrow elements`);
for (const el of arrowElements) {
await ensureTransfer(el);
}
await ea.addElementsToView(false, true, true, true);
clog("\n✅ Linkify DFD v1.4: finished");
note("Linkify DFD v1.4: finished");
})();