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 (
Loading articles...
); } 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 && 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 && (
setNewDirectory(e.target.value)} onKeyPress={handleKeyPress} className="cc-flex-1 cc-px-2 cc-py-1 cc-text-sm cc-rounded clipper-catalog-input" />
Tip: You can enter multiple paths separated by commas
{advancedSettings.ignoredDirectories.length > 0 && (
Excluded Paths:
{advancedSettings.ignoredDirectories.map((dir) => ( ))}
)}
)}
{/* Refresh link */}
refresh list
{filteredArticles.map((article) => ( ))}
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
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;