I think it would be wonderful to have a Link Explorer plugin inspired by Tagfolder. I gave the beta plugin ‘datacore’ a try, but since I don’t have any coding experience, I had to rely entirely on AI.
I’m wondering if anyone else feels there’s a need for a plugin like this.
I don’t know much about coding, so I’m not sure how helpful this will be, but here’s a code snippet generated by AI that I used with the Datacore plugin:
```datacorejsx
const FOLDER_TYPES = {
TASKS: "_tasks",
ALL: "_all",
BOARD: "_board",
ISOLATED: "_isolated",
ATTACHMENTS: "_attachments"
};
const FOLDER_TYPE_LABELS = {
[FOLDER_TYPES.TASKS]: "Task Files",
[FOLDER_TYPES.ALL]: "All Files",
[FOLDER_TYPES.BOARD]: "Whiteboard Files",
[FOLDER_TYPES.ISOLATED]: "Isolated Files",
[FOLDER_TYPES.ATTACHMENTS]: "Attachment Files"
};
const TASK_CATEGORIES = {
URGENT_IMPORTANT: {
tags: ["#_urgent", "#_important"],
icon: "🔥",
priority: 1
},
URGENT: {
tags: ["#_urgent"],
icon: "⚠️",
priority: 2
},
IMPORTANT: {
tags: ["#_important"],
icon: "❗",
priority: 3
},
NORMAL: {
tags: ["#_chores"],
icon: "📋",
priority: 4
}
};
const FILTER_TYPES = {
ALL: "all",
LINKS: "links",
BACKLINKS: "backlinks",
};
const FILTER_TYPE_ICONS = {
[FILTER_TYPES.ALL]: "🔛",
[FILTER_TYPES.LINKS]: "🔜",
[FILTER_TYPES.BACKLINKS]: "🔙",
};
let connectionsCache = null;
let lastCacheTime = 0;
const CACHE_LIFETIME = 30000;
const MAX_TEXT_LENGTH = 40;
const styleTokens = {
spacing: {
gap: "var(--size-4-1)",
padding: "var(--size-4-2)"
},
border: {
default: "1px solid var(--background-modifier-border)",
dashed: "1px dashed var(--background-modifier-border)"
},
typography: {
uiSmall: "var(--font-ui-small)",
uiMedium: "var(--font-ui-medium)",
uiSmaller: "var(--font-ui-smaller)"
},
radius: {
none: "0",
small: "2px"
},
colors: {
muted: "var(--text-muted)",
normal: "var(--text-normal)",
accent: "var(--text-accent)",
error: "var(--text-error)",
onAccent: "var(--text-on-accent)",
bgHover: "var(--background-modifier-hover)",
bgBorder: "var(--background-modifier-border)",
interactiveAccent: "var(--interactive-accent)",
interactiveAccentHover: "var(--interactive-accent-hover)"
}
};
const createUtilities = () => {
const pathUtils = {
removeMarkdownExt: (path) => {
return path.endsWith('.md') ? path.slice(0, -3) : path;
},
truncateText: (text, maxLength = MAX_TEXT_LENGTH) => {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '…';
},
getFileName: (path) => {
return path.split('/').pop();
},
isValidFilePath: (path) => {
return path && path !== '/' && path.trim() !== '';
}
};
const safeOpenInternalLink = (linkPath) => {
try {
dc.app.workspace.openLinkText(linkPath, '', false);
} catch (error) {
console.error("Internal link error:", { linkPath, error });
dc.notice?.show?.("Cannot open internal link.");
}
};
const withHoverEffect = (baseStyle) => ({
...baseStyle,
transition: "all 0.2s ease",
"&:hover": {
backgroundColor: styleTokens.colors.bgHover,
transform: "scale(1.01)"
}
});
const fileTypeUtils = {
isMarkdownFile: (file) => file.extension === 'md' || file.extension === 'markdown',
isBoardFile: (file) => file.extension === 'canvas' || file.extension === 'excalidraw',
isAttachmentFile: (file) => {
if (!fileTypeUtils.isMarkdownFile(file) && !fileTypeUtils.isBoardFile(file)) {
if (file.path.startsWith('.obsidian/')) {
return file.extension === 'css';
}
return true;
}
return false;
},
shouldIncludeFile: (file) => {
if (file.children) return false;
if (file.path.startsWith('.obsidian/')) {
return file.extension === 'css';
}
return true;
}
};
const hasTagsInFile = (file, tags) => {
if (!fileTypeUtils.isMarkdownFile(file)) return false;
const cache = dc.app.metadataCache.getFileCache(file);
if (!cache || !cache.tags) return false;
const fileTags = cache.tags.map(tag => tag.tag.toLowerCase());
return tags.every(tag => fileTags.includes(tag.toLowerCase()));
};
const getAllConnectionsData = () => {
const currentTime = Date.now();
if (connectionsCache && (currentTime - lastCacheTime < CACHE_LIFETIME)) {
return connectionsCache;
}
const connections = new Map();
const fileConnections = new Map();
const taskStats = {
urgentImportant: 0,
urgent: 0,
important: 0,
normal: 0,
total: 0
};
Object.values(FOLDER_TYPES).forEach(type => {
fileConnections.set(type, []);
});
const allFiles = dc.app.vault.getAllLoadedFiles().filter(file =>
fileTypeUtils.shouldIncludeFile(file)
);
const markdownFiles = dc.app.vault.getMarkdownFiles();
allFiles.forEach(file => {
if (!pathUtils.isValidFilePath(file.path)) return;
fileConnections.get(FOLDER_TYPES.ALL).push(file.path);
if (fileTypeUtils.isBoardFile(file)) {
fileConnections.get(FOLDER_TYPES.BOARD).push(file.path);
} else if (fileTypeUtils.isAttachmentFile(file)) {
fileConnections.get(FOLDER_TYPES.ATTACHMENTS).push(file.path);
}
});
markdownFiles.forEach(file => {
if (hasTagsInFile(file, TASK_CATEGORIES.URGENT_IMPORTANT.tags)) {
fileConnections.get(FOLDER_TYPES.TASKS).push(file.path);
taskStats.urgentImportant++;
taskStats.total++;
}
else if (hasTagsInFile(file, TASK_CATEGORIES.URGENT.tags)) {
fileConnections.get(FOLDER_TYPES.TASKS).push(file.path);
taskStats.urgent++;
taskStats.total++;
}
else if (hasTagsInFile(file, TASK_CATEGORIES.IMPORTANT.tags)) {
fileConnections.get(FOLDER_TYPES.TASKS).push(file.path);
taskStats.important++;
taskStats.total++;
}
else if (hasTagsInFile(file, TASK_CATEGORIES.NORMAL.tags)) {
fileConnections.get(FOLDER_TYPES.TASKS).push(file.path);
taskStats.normal++;
taskStats.total++;
}
});
markdownFiles.forEach(file => {
connections.set(file.path, {
links: new Set(),
backlinks: new Set(),
});
});
markdownFiles.forEach(file => {
const cache = dc.app.metadataCache.getFileCache(file);
const fileLinks = cache?.links || [];
fileLinks.forEach(link => {
const linkTarget = link.link;
const targetFile = dc.app.metadataCache.getFirstLinkpathDest(linkTarget, '');
if (targetFile) {
connections.get(file.path).links.add(targetFile.path);
if (connections.has(targetFile.path)) {
connections.get(targetFile.path).backlinks.add(file.path);
}
}
});
});
markdownFiles.forEach(file => {
const fileData = connections.get(file.path);
const hasLinks = fileData.links.size > 0;
const hasBacklinks = fileData.backlinks.size > 0;
if (!hasLinks && !hasBacklinks) {
fileConnections.get(FOLDER_TYPES.ISOLATED).push(file.path);
return;
}
const allConnections = new Set([...fileData.links, ...fileData.backlinks]);
allConnections.forEach(connectedFile => {
if (!fileConnections.has(connectedFile)) {
fileConnections.set(connectedFile, []);
}
const connectedFiles = fileConnections.get(connectedFile);
if (!connectedFiles.includes(file.path)) {
connectedFiles.push(file.path);
}
});
});
connectionsCache = { connections, fileConnections, taskStats };
lastCacheTime = currentTime;
return connectionsCache;
};
const hasExistingLink = (linkTarget) => {
const targetFile = dc.app.metadataCache.getFirstLinkpathDest(linkTarget, '');
return !!targetFile;
};
const sortFiles = (files) => {
return [...files].sort((a, b) => {
const aFile = dc.app.vault.getAbstractFileByPath(a);
const bFile = dc.app.vault.getAbstractFileByPath(b);
if (aFile && bFile) {
const aPriority = getTaskPriority(aFile);
const bPriority = getTaskPriority(bFile);
if (aPriority !== bPriority) {
return aPriority - bPriority;
}
}
const fileNameA = pathUtils.getFileName(a);
const fileNameB = pathUtils.getFileName(b);
return fileNameA.localeCompare(fileNameB);
});
};
const getTaskPriority = (file) => {
if (!fileTypeUtils.isMarkdownFile(file)) return 999;
if (hasTagsInFile(file, TASK_CATEGORIES.URGENT_IMPORTANT.tags)) {
return TASK_CATEGORIES.URGENT_IMPORTANT.priority;
} else if (hasTagsInFile(file, TASK_CATEGORIES.URGENT.tags)) {
return TASK_CATEGORIES.URGENT.priority;
} else if (hasTagsInFile(file, TASK_CATEGORIES.IMPORTANT.tags)) {
return TASK_CATEGORIES.IMPORTANT.priority;
} else if (hasTagsInFile(file, TASK_CATEGORIES.NORMAL.tags)) {
return TASK_CATEGORIES.NORMAL.priority;
}
return 999;
};
const getTaskIcon = (file) => {
if (!fileTypeUtils.isMarkdownFile(file)) return "";
if (hasTagsInFile(file, TASK_CATEGORIES.URGENT_IMPORTANT.tags)) {
return TASK_CATEGORIES.URGENT_IMPORTANT.icon;
} else if (hasTagsInFile(file, TASK_CATEGORIES.URGENT.tags)) {
return TASK_CATEGORIES.URGENT.icon;
} else if (hasTagsInFile(file, TASK_CATEGORIES.IMPORTANT.tags)) {
return TASK_CATEGORIES.IMPORTANT.icon;
} else if (hasTagsInFile(file, TASK_CATEGORIES.NORMAL.tags)) {
return TASK_CATEGORIES.NORMAL.icon;
}
return "";
};
return {
pathUtils,
fileTypeUtils,
safeOpenInternalLink,
withHoverEffect,
getAllConnectionsData,
hasExistingLink,
sortFiles,
hasTagsInFile,
getTaskPriority,
getTaskIcon
};
};
const createStyles = (styleTokens) => {
return {
container: {
width: "100%",
display: "flex",
height: "100%",
overflow: "hidden"
},
leftPanel: {
width: "45%",
borderRight: styleTokens.border.default,
overflowY: "auto",
display: "flex",
flexDirection: "column",
padding: styleTokens.spacing.padding
},
rightPanel: {
width: "55%",
overflowY: "auto",
padding: styleTokens.spacing.padding
},
header: {
fontSize: styleTokens.typography.uiMedium,
fontWeight: "var(--font-medium)",
marginBottom: styleTokens.spacing.padding,
display: "flex",
justifyContent: "space-between",
alignItems: "center"
},
tabContainer: {
display: "flex",
borderBottom: styleTokens.border.default,
marginBottom: styleTokens.spacing.padding,
},
tab: {
padding: "8px 16px",
fontSize: styleTokens.typography.uiSmall,
cursor: "pointer",
backgroundColor: "transparent",
border: "none",
borderBottom: "2px solid transparent",
color: styleTokens.colors.muted,
transition: "all 0.2s ease",
borderRadius: styleTokens.radius.none,
"&:hover": {
color: styleTokens.colors.normal,
borderBottom: `2px solid ${styleTokens.colors.bgBorder}`
}
},
activeTab: {
color: styleTokens.colors.accent,
borderBottom: `2px solid ${styleTokens.colors.interactiveAccent}`,
fontWeight: "var(--font-medium)",
"&:hover": {
borderBottom: `2px solid ${styleTokens.colors.interactiveAccent}`
}
},
connectionCount: {
fontSize: styleTokens.typography.uiSmaller,
color: styleTokens.colors.muted,
backgroundColor: styleTokens.colors.bgBorder,
padding: "2px 6px",
borderRadius: styleTokens.radius.small,
marginLeft: "8px"
},
selectedConnectionCount: {
color: styleTokens.colors.onAccent,
backgroundColor: styleTokens.colors.interactiveAccentHover
},
nonExistingLink: {
color: styleTokens.colors.error,
fontStyle: "italic"
},
multiSelectHint: {
fontSize: styleTokens.typography.uiSmaller,
color: styleTokens.colors.muted,
marginBottom: styleTokens.spacing.gap
},
textContainer: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1
},
sectionHeader: {
fontSize: styleTokens.typography.uiSmall,
fontWeight: "var(--font-medium)",
color: styleTokens.colors.muted,
marginTop: styleTokens.spacing.padding,
marginBottom: styleTokens.spacing.gap,
borderBottom: styleTokens.border.dashed,
paddingBottom: "4px",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
},
icon: {
marginRight: "4px",
opacity: 0.7,
fontSize: styleTokens.typography.uiSmaller
},
listContainer: {
marginBottom: styleTokens.spacing.padding,
},
emptyListMessage: {
color: styleTokens.colors.muted,
fontSize: styleTokens.typography.uiSmall,
fontStyle: "italic",
padding: styleTokens.spacing.gap
},
taskIcon: {
marginRight: "6px",
fontSize: styleTokens.typography.uiSmall
},
priorityTaskCount: {
color: styleTokens.colors.accent,
fontWeight: "var(--font-medium)"
}
};
};
const createItemStyles = (styleTokens, withHoverEffect) => {
const baseItemStyle = {
padding: "6px 10px",
marginBottom: "2px",
backgroundColor: "transparent",
color: styleTokens.colors.normal,
cursor: "pointer",
fontSize: styleTokens.typography.uiSmall,
borderRadius: styleTokens.radius.none,
borderLeft: "2px solid transparent"
};
return {
connectionListItem: withHoverEffect({
...baseItemStyle,
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}),
selectedConnectionListItem: {
backgroundColor: styleTokens.colors.bgHover,
borderLeft: `2px solid ${styleTokens.colors.interactiveAccent}`,
fontWeight: "var(--font-medium)"
},
fileListItem: withHoverEffect({
...baseItemStyle,
display: "flex",
alignItems: "center"
})
};
};
const createExplorerComponents = (utilities, styles, itemStyles) => {
const { pathUtils, hasExistingLink, getTaskIcon } = utilities;
const renderConnection = (connection, files, handleConnectionClick, isSelected = false, taskStats = null) => {
let connectionText = connection.startsWith("_")
? FOLDER_TYPE_LABELS[connection] || connection
: pathUtils.truncateText(pathUtils.removeMarkdownExt(connection));
if (connection === FOLDER_TYPES.TASKS && taskStats) {
connectionText = 'Task Files';
}
const isNonExisting = !connection.startsWith("_") && !hasExistingLink(connection);
const itemStyle = Object.assign({},
itemStyles.connectionListItem,
isSelected ? itemStyles.selectedConnectionListItem : {},
isNonExisting ? styles.nonExistingLink : {}
);
const countStyle = Object.assign({},
styles.connectionCount,
isSelected ? styles.selectedConnectionCount : {}
);
const connectionCount = (connection === FOLDER_TYPES.TASKS && taskStats)
? `${taskStats.urgentImportant + taskStats.urgent + taskStats.important} (${taskStats.total})`
: files.length;
return (
<div
key={connection}
onClick={e => handleConnectionClick(connection, e)}
title={pathUtils.removeMarkdownExt(connection)}
style={itemStyle}
>
<span style={styles.textContainer}>
{connectionText}
</span>
<span style={countStyle}>{connectionCount}</span>
</div>
);
};
const renderFile = (file, handleFileClick, selectedConnection) => {
const isTaskView = selectedConnection === FOLDER_TYPES.TASKS;
const icon = isTaskView ? getTaskIcon(dc.app.vault.getAbstractFileByPath(file)) : "";
return (
<div
key={file}
onClick={() => handleFileClick(file)}
title={pathUtils.removeMarkdownExt(file)}
style={itemStyles.fileListItem}
>
{icon && <span style={styles.taskIcon}>{icon}</span>}
<span style={{marginRight: icon ? "0" : "4px"}}>{!icon && "•"}</span>
<span style={styles.textContainer}>
{pathUtils.truncateText(pathUtils.removeMarkdownExt(file))}
</span>
</div>
);
};
const renderTabs = (connectionType, handleFilterChange) => {
return (
<div style={styles.tabContainer}>
{Object.entries(FILTER_TYPE_ICONS).map(([type, icon]) => (
<button
key={type}
style={Object.assign({},
styles.tab,
connectionType === type ? styles.activeTab : {}
)}
onClick={() => handleFilterChange(type)}
>
{icon}
</button>
))}
</div>
);
};
const renderSection = (title, count = null, children) => {
return (
<>
<div style={styles.sectionHeader}>
{title} {count !== null && `(${count})`}
</div>
<div style={styles.listContainer}>
{children}
</div>
</>
);
};
const renderEmptyMessage = (message) => {
return (
<div style={styles.emptyListMessage}>{message}</div>
);
};
return {
renderConnection,
renderFile,
renderTabs,
renderSection,
renderEmptyMessage
};
};
const { useState, useEffect, useMemo, useCallback } = dc;
return function Explorer() {
const utilities = createUtilities();
const { pathUtils, sortFiles, safeOpenInternalLink, withHoverEffect } = utilities;
const styles = createStyles(styleTokens);
const itemStyles = createItemStyles(styleTokens, withHoverEffect);
const components = createExplorerComponents(
utilities,
styles,
itemStyles
);
const [connectionsData, setConnectionsData] = useState({
connections: new Map(),
fileConnections: new Map(),
taskStats: {
urgentImportant: 0,
urgent: 0,
important: 0,
normal: 0,
total: 0
}
});
const [selectedConnections, setSelectedConnections] = useState(new Set());
const [relatedFiles, setRelatedFiles] = useState([]);
const [connectionType, setConnectionType] = useState(FILTER_TYPES.ALL);
useEffect(() => {
const data = utilities.getAllConnectionsData();
setConnectionsData(data);
if (data.fileConnections.size > 0) {
setSelectedConnections(new Set([FOLDER_TYPES.TASKS]));
}
const eventRef = dc.app.metadataCache.on('changed', () => {
const updatedData = utilities.getAllConnectionsData();
setConnectionsData(updatedData);
});
return () => dc.app.metadataCache.offref(eventRef);
}, []);
useEffect(() => {
if (selectedConnections.size === 0) {
setRelatedFiles([]);
return;
}
let finalRelatedFiles = null;
selectedConnections.forEach(connection => {
let filesForThisConnection = [];
if (connection.startsWith("_")) {
filesForThisConnection = connectionsData.fileConnections.get(connection) || [];
} else if (connectionsData.fileConnections.has(connection)) {
const files = connectionsData.fileConnections.get(connection) || [];
filesForThisConnection = files.filter(file => {
if (connectionType === FILTER_TYPES.ALL) return true;
const fileData = connectionsData.connections.get(file);
if (!fileData) return false;
if (connectionType === FILTER_TYPES.LINKS && fileData.links.has(connection)) return true;
if (connectionType === FILTER_TYPES.BACKLINKS && fileData.backlinks.has(connection)) return true;
return false;
});
}
if (finalRelatedFiles === null) {
finalRelatedFiles = new Set(filesForThisConnection);
} else {
finalRelatedFiles = new Set(
[...finalRelatedFiles].filter(file => filesForThisConnection.includes(file))
);
}
});
const sortedFiles = finalRelatedFiles
? selectedConnections.has(FOLDER_TYPES.TASKS)
? sortFiles([...finalRelatedFiles])
: sortFiles(finalRelatedFiles)
: [];
const selectedConnectionsArray = Array.from(selectedConnections);
const firstSelectedConnection = selectedConnectionsArray[0];
if (selectedConnectionsArray.length === 1 && !firstSelectedConnection.startsWith("_")) {
setRelatedFiles([firstSelectedConnection, ...sortedFiles.filter(file => file !== firstSelectedConnection)]);
} else {
setRelatedFiles(sortedFiles);
}
}, [selectedConnections, connectionType, connectionsData]);
const handleConnectionClick = useCallback((connection, event) => {
setSelectedConnections(prev => {
const next = new Set(prev);
if (event.ctrlKey || event.metaKey) {
next.has(connection) ? next.delete(connection) : next.add(connection);
} else {
next.clear();
next.add(connection);
}
if (next.size === 0 && connectionsData.fileConnections.size > 0) {
next.add(FOLDER_TYPES.ALL);
}
return next;
});
}, [connectionsData]);
const handleFileClick = useCallback(filePath => {
safeOpenInternalLink(filePath);
}, []);
const handleFilterChange = useCallback(type => {
setConnectionType(type);
}, []);
const sortedConnections = useMemo(() => {
return Array.from(connectionsData.fileConnections.entries()).sort((a, b) => {
const specialFolders = {
[FOLDER_TYPES.TASKS]: 0,
[FOLDER_TYPES.ALL]: 1,
[FOLDER_TYPES.BOARD]: 2,
[FOLDER_TYPES.ISOLATED]: 3,
[FOLDER_TYPES.ATTACHMENTS]: 4
};
if (a[0] in specialFolders && b[0] in specialFolders) {
return specialFolders[a[0]] - specialFolders[b[0]];
}
if (a[0] in specialFolders) return -1;
if (b[0] in specialFolders) return 1;
return a[0].localeCompare(b[0]);
});
}, [connectionsData.fileConnections]);
const groupedConnections = useMemo(() => {
const groups = {
pinned: [],
selected: [],
others: []
};
sortedConnections.forEach(([connection, files]) => {
if (connection.startsWith("_")) {
groups.pinned.push([connection, files]);
}
else if (files.length === 0) {
return;
}
else if (selectedConnections.has(connection)) {
groups.selected.push([connection, files]);
}
else {
groups.others.push([connection, files]);
}
});
return groups;
}, [sortedConnections, selectedConnections]);
const firstSelectedConnection = useMemo(() => {
return selectedConnections.size > 0 ? Array.from(selectedConnections)[0] : null;
}, [selectedConnections]);
const isTaskFolderSelected = useMemo(() => {
return firstSelectedConnection === FOLDER_TYPES.TASKS;
}, [firstSelectedConnection]);
return (
<div style={styles.container}>
<div style={styles.leftPanel}>
<div style={styles.header}>
<span>Folders</span>
</div>
<div style={styles.multiSelectHint}>
Ctrl/Cmd + Click to select multiple connections
</div>
{groupedConnections.pinned.length > 0 && components.renderSection("Quick Access", groupedConnections.pinned.length,
groupedConnections.pinned.map(([connection, files]) =>
components.renderConnection(
connection,
files,
handleConnectionClick,
selectedConnections.has(connection),
connection === FOLDER_TYPES.TASKS ? connectionsData.taskStats : null
)
)
)}
{groupedConnections.selected.length > 0 && selectedConnections.size > 0 && components.renderSection("Selected", groupedConnections.selected.length,
groupedConnections.selected.map(([connection, files]) =>
components.renderConnection(
connection,
files,
handleConnectionClick,
true
)
)
)}
{groupedConnections.others.length > 0 && components.renderSection("All Connections", groupedConnections.others.length,
groupedConnections.others.map(([connection, files]) =>
components.renderConnection(
connection,
files,
handleConnectionClick
)
)
)}
</div>
<div style={styles.rightPanel}>
<div style={styles.header}>
<span>
{'Files'}
</span>
<span>
{components.renderTabs(connectionType, handleFilterChange)}
</span>
</div>
{relatedFiles.length === 0 ? (
components.renderEmptyMessage("No related files found")
) : (
relatedFiles.map(file => components.renderFile(file, handleFileClick, firstSelectedConnection))
)}
</div>
</div>
);
}
```