import React, { useState, useEffect, useCallback } from 'react';
import { Link, Search, RefreshCw, ChevronDown, ChevronRight, X } from 'lucide-react';
import { TFile, App } from 'obsidian';
import type ObsidianClipperCatalog from './main';
interface ClipperCatalogProps {
app: App;
plugin: ObsidianClipperCatalog;
}
interface Article {
title: string;
url: string;
path: string;
date: number;
tags: string[];
basename: string;
content: string;
}
interface SortConfig {
key: keyof Article;
direction: 'asc' | 'desc';
}
interface AdvancedSettings {
ignoredDirectories: string[];
isExpanded: boolean;
}
const ArticleTitle = ({ file, content, title }: { file: TFile, content: string, title: string }) => {
const isUntitled = /^Untitled( \d+)?$/.test(file.basename);
const headerMatch = content.match(/^#+ (.+)$/m);
if (isUntitled && headerMatch) {
return (
{headerMatch[1].trim()}
({file.basename})
);
}
return {file.basename} ;
};
const ClipperCatalog: React.FC = ({ app, plugin }) => {
const [articles, setArticles] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({ key: 'date', direction: 'desc' });
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState(null);
const [advancedSettings, setAdvancedSettings] = useState({
ignoredDirectories: plugin.settings.ignoredDirectories,
isExpanded: plugin.settings.isAdvancedSettingsExpanded
});
const [newDirectory, setNewDirectory] = useState('');
// Save advanced settings to localStorage whenever they change
useEffect(() => {
// Update plugin settings when advanced settings change
plugin.settings.ignoredDirectories = advancedSettings.ignoredDirectories;
plugin.settings.isAdvancedSettingsExpanded = advancedSettings.isExpanded;
plugin.saveSettings();
}, [advancedSettings, plugin]);
// Helper function to check if a path should be ignored
const isPathIgnored = (filePath: string): boolean => {
return advancedSettings.ignoredDirectories.some(dir => {
// Normalize both paths to use forward slashes and remove trailing slashes
const normalizedDir = dir.replace(/\\/g, '/').replace(/\/$/, '');
const normalizedPath = filePath.replace(/\\/g, '/');
// Split the paths into segments
const dirParts = normalizedDir.split('/');
const pathParts = normalizedPath.split('/');
// Check if the number of path parts is at least equal to directory parts
if (pathParts.length < dirParts.length) return false;
// Compare each segment
for (let i = 0; i < dirParts.length; i++) {
if (dirParts[i].toLowerCase() !== pathParts[i].toLowerCase()) {
return false;
}
}
// Only match if we've matched all segments exactly
return dirParts.length === pathParts.length - 1 || // Directory contains files
dirParts.length === pathParts.length; // Directory is exactly matched
});
};
const loadArticles = useCallback(async () => {
setIsRefreshing(true);
setError(null);
try {
const articleFiles: Article[] = [];
const files = app.vault.getMarkdownFiles();
for (const file of files) {
try {
if (isPathIgnored(file.parent?.path || '')) {
continue;
}
const content = await app.vault.read(file);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
const sourcePropertyPattern = `^${plugin.settings.sourcePropertyName}:\\s*["']?([^"'\\s]+)["']?\\s*$`
const sourcePropertyRegex = new RegExp(sourcePropertyPattern, 'm')
const sourceMatch = frontmatter.match(sourcePropertyRegex);
if (sourceMatch) {
const content = await app.vault.read(file);
const title = file.basename;
let tags: string[] = [];
// Get all hashtags from the entire content (including frontmatter)
const hashtagMatches = content.match(/#[\w\d-_/]+/g) || [];
// Add content tags (requiring # prefix)
tags = [...new Set(hashtagMatches.map(tag => tag.slice(1)))].filter(Boolean);
// Remove duplicates using Set
tags = [...new Set(tags)].filter(Boolean);
articleFiles.push({
title,
url: sourceMatch[1],
path: file.path,
date: file.stat.ctime,
tags,
basename: file.basename,
content: content
});
}
}
} catch (error) {
console.error(`Error processing file ${file.path}:`, error);
}
}
setArticles(articleFiles);
} catch (error) {
setError("Failed to load articles");
} finally {
setIsRefreshing(false);
setIsLoading(false);
}
}, [app.vault, advancedSettings.ignoredDirectories]);
// Initial load
useEffect(() => {
loadArticles();
}, [loadArticles, advancedSettings.ignoredDirectories]);
useEffect(() => {
const intervalId = setInterval(() => {
loadArticles();
}, 60000);
return () => clearInterval(intervalId);
}, [loadArticles]);
if (error) {
return (
{error}
);
}
if (isLoading) {
return (
);
}
const handleRefresh = () => {
loadArticles();
};
const handleSort = (key: keyof Article) => {
setSortConfig(prevConfig => ({
key,
direction: prevConfig.key === key && prevConfig.direction === 'asc' ? 'desc' : 'asc'
}));
};
const sortedArticles = [...articles].sort((a, b) => {
if (sortConfig.key === 'date') {
return sortConfig.direction === 'asc'
? a.date - b.date
: b.date - a.date;
}
const aValue = String(a[sortConfig.key]).toLowerCase();
const bValue = String(b[sortConfig.key]).toLowerCase();
if (sortConfig.direction === 'asc') {
return aValue.localeCompare(bValue);
}
return bValue.localeCompare(aValue);
});
const filteredArticles = sortedArticles.filter(article =>
article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
article.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())) ||
searchTerm.startsWith('#') && article.tags.some(tag =>
tag.toLowerCase() === searchTerm.slice(1).toLowerCase()
)
);
const getSortIcon = (key: keyof Article) => {
if (sortConfig.key === key) {
return sortConfig.direction === 'asc' ? '↑' : '↓';
}
return null;
};
const openArticle = (path: string) => {
const file = app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
const leaf = app.workspace.getLeaf(false);
leaf.openFile(file);
}
};
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const handleAddDirectory = () => {
if (!newDirectory.trim()) return;
// Split by commas and clean up each directory path
const directoriesToAdd = newDirectory
.split(',')
.map(dir => dir.trim())
.filter(dir => dir.length > 0);
if (directoriesToAdd.length === 0) return;
setAdvancedSettings(prev => {
const updatedDirectories = [...prev.ignoredDirectories];
directoriesToAdd.forEach(dir => {
if (!updatedDirectories.includes(dir)) {
updatedDirectories.push(dir);
}
});
return {
...prev,
ignoredDirectories: updatedDirectories
};
});
setNewDirectory('');
};
const handleRemoveDirectory = (dir: string) => {
setAdvancedSettings(prev => ({
...prev,
ignoredDirectories: prev.ignoredDirectories.filter(d => d !== dir)
}));
// Articles will reload automatically due to the useEffect dependency on ignoredDirectories
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && newDirectory.trim()) {
handleAddDirectory();
}
};
const toggleAdvancedSettings = () => {
setAdvancedSettings(prev => ({
...prev,
isExpanded: !prev.isExpanded
}));
};
const handleClearAllDirectories = () => {
setAdvancedSettings(prev => ({
...prev,
ignoredDirectories: []
}));
};
const renderAdvancedSettingsHeader = () => {
const excludedCount = advancedSettings.ignoredDirectories.length;
return (
{advancedSettings.isExpanded ? : }
Advanced Search Options
{!advancedSettings.isExpanded && excludedCount > 0 && (
Note: There {excludedCount === 1 ? 'is' : 'are'} {excludedCount} path{excludedCount === 1 ? '' : 's'} excluded from showing up in the results
)}
);
};
return (
{/* Search input container */}
setSearchTerm(e.target.value)}
className="cc-w-full cc-bg-transparent cc-outline-none cc-text-sm cc-pr-16 clipper-catalog-input"
/>
{searchTerm && (
setSearchTerm('')}
className="cc-absolute cc-right-2 cc-top-[20%] cc-flex cc-items-center cc-gap-1 cc-cursor-pointer cc-transition-colors clipper-catalog-clear-btn"
>
clear
)}
{/* Advanced Settings Section */}
{renderAdvancedSettingsHeader()}
{advancedSettings.isExpanded && (
{advancedSettings.ignoredDirectories.length > 0 && (
Excluded Paths:
Clear All Excluded Paths
{advancedSettings.ignoredDirectories.map((dir) => (
handleRemoveDirectory(dir)}
className="cc-inline-flex cc-items-center cc-bg-chip cc-px-3 cc-py-1.5 cc-rounded-full cc-text-xs hover:cc-bg-chip-hover cc-transition-colors cc-cursor-pointer"
aria-label={`Remove ${dir} from excluded paths`}
>
{dir}
×
))}
)}
)}
{/* Refresh link */}
refresh list
handleSort('title')}
className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer clipper-catalog-header-cell"
>
Note Title {getSortIcon('title')}
handleSort('date')}
className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer cc-whitespace-nowrap clipper-catalog-header-cell"
>
Date {getSortIcon('date')}
handleSort('path')}
className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer clipper-catalog-header-cell"
>
Path {getSortIcon('path')}
#Tags
Link
{filteredArticles.map((article) => (
openArticle(article.path)}
className="cc-flex cc-items-center cc-gap-2 cc-cursor-pointer cc-transition-colors cc-min-h-[1.5rem] clipper-catalog-title"
>
{(() => {
const abstractFile = app.vault.getAbstractFileByPath(article.path);
if (abstractFile instanceof TFile) {
return (
);
}
return {article.title} ;
})()}
{formatDate(article.date)}
{article.path.split('/').slice(0, -1).join('/')}
{article.tags.map((tag, i) => (
setSearchTerm(`#${tag}`)}
className="cc-px-2 cc-py-1 cc-text-xs cc-rounded-full cc-cursor-pointer cc-transition-colors clipper-catalog-tag"
>
#{tag}
))}
Original
))}
{filteredArticles.length === 0 && (
No articles found matching your search.
Note: This catalog shows any markdown files containing a URL in their frontmatter under the property: "{plugin.settings.sourcePropertyName}".
You can change this property name in plugin settings to match your preferred clipping workflow.
)}
);
};
export default ClipperCatalog;