import { MarkdownPostProcessorContext, parseYaml, TAbstractFile, TFolder, TFile, stringifyYaml, Notice } from 'obsidian'; import { extractFolderName, getFolderNote } from '../functions/folderNoteFunctions'; import FolderNotesPlugin from '../main'; import { FolderOverviewSettings } from './ModalSettings'; import { getExcludedFolder } from '../excludedFolder'; import * as path from 'path'; export type includeTypes = 'folder' | 'markdown' | 'canvas' | 'other' | 'pdf' | 'image' | 'audio' | 'video' | 'all'; export type yamlSettings = { id: string; folderPath: string; title: string; showTitle: boolean; depth: number; includeTypes: includeTypes[]; style: 'list' | 'grid' | 'explorer'; disableFileTag: boolean; sortBy: 'name' | 'created' | 'modified'; sortByAsc: boolean; showEmptyFolders: boolean; onlyIncludeSubfolders: boolean; storeFolderCondition: boolean; showFolderNotes: boolean; }; export class FolderOverview { yaml: yamlSettings; plugin: FolderNotesPlugin; ctx: MarkdownPostProcessorContext; source: string; folderName: string | null; el: HTMLElement; pathBlacklist: string[] = []; folders: TFolder[] = []; sourceFilePath: string; constructor(plugin: FolderNotesPlugin, ctx: MarkdownPostProcessorContext, source: string, el: HTMLElement) { let yaml: yamlSettings = parseYaml(source); if (!yaml) { yaml = {} as yamlSettings; } const includeTypes = yaml?.includeTypes || plugin.settings.defaultOverview.includeTypes || ['folder', 'markdown']; this.plugin = plugin; this.ctx = ctx; this.source = source; this.el = el; this.sourceFilePath = this.ctx.sourcePath this.yaml = { id: yaml?.id || crypto.randomUUID(), folderPath: yaml?.folderPath === undefined || yaml?.folderPath === null ? plugin.getFolderPathFromString(ctx.sourcePath) : yaml?.folderPath, title: yaml?.title || plugin.settings.defaultOverview.title, showTitle: yaml?.showTitle === undefined || yaml?.showTitle === null ? plugin.settings.defaultOverview.showTitle : yaml?.showTitle, depth: yaml?.depth || plugin.settings.defaultOverview.depth, style: yaml?.style || 'list', includeTypes: includeTypes.map((type) => type.toLowerCase()) as includeTypes[], disableFileTag: yaml?.disableFileTag === undefined || yaml?.disableFileTag === null ? plugin.settings.defaultOverview.disableFileTag : yaml?.disableFileTag, sortBy: yaml?.sortBy || plugin.settings.defaultOverview.sortBy, sortByAsc: yaml?.sortByAsc === undefined || yaml?.sortByAsc === null ? plugin.settings.defaultOverview.sortByAsc : yaml?.sortByAsc, showEmptyFolders: yaml?.showEmptyFolders === undefined || yaml?.showEmptyFolders === null ? plugin.settings.defaultOverview.showEmptyFolders : yaml?.showEmptyFolders, onlyIncludeSubfolders: yaml?.onlyIncludeSubfolders === undefined || yaml?.onlyIncludeSubfolders === null ? plugin.settings.defaultOverview.onlyIncludeSubfolders : yaml?.onlyIncludeSubfolders, storeFolderCondition: yaml?.storeFolderCondition === undefined || yaml?.storeFolderCondition === null ? plugin.settings.defaultOverview.storeFolderCondition : yaml?.storeFolderCondition, showFolderNotes: yaml?.showFolderNotes === undefined || yaml?.showFolderNotes === null ? plugin.settings.defaultOverview.showFolderNotes : yaml?.showFolderNotes, } } create(plugin: FolderNotesPlugin, source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) { el.empty(); el.parentElement?.classList.add('folder-overview-container'); const root = el.createEl('div', { cls: 'folder-overview' }); const titleEl = root.createEl('h1', { cls: 'folder-overview-title' }); const ul = root.createEl('ul', { cls: 'folder-overview-list' }); if (this.yaml.includeTypes.length === 0) { return this.addEditButton(root); } let files: TAbstractFile[] = []; const sourceFile = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath); if (!sourceFile) return; let sourceFolderPath = this.yaml.folderPath || plugin.getFolderPathFromString(ctx.sourcePath); let sourceFolder: TFolder | undefined; if (sourceFolderPath !== '/') { if (this.yaml.folderPath === '') { sourceFolder = plugin.app.vault.getAbstractFileByPath(plugin.getFolderPathFromString(ctx.sourcePath)) as TFolder; } else { sourceFolder = plugin.app.vault.getAbstractFileByPath(this.yaml.folderPath) as TFolder; } } if (this.yaml.showTitle) { if (sourceFolder && sourceFolderPath !== '/') { titleEl.innerText = this.yaml.title.replace('{{folderName}}', sourceFolder.name); } else if (sourceFolderPath == '/') { titleEl.innerText = this.yaml.title.replace('{{folderName}}', 'Vault'); } else { titleEl.innerText = this.yaml.title.replace('{{folderName}}', ''); } } if (!sourceFolder && (sourceFolderPath !== '/' && sourceFolderPath !== '')) { return new Notice('Folder overview: Couldn\'t find the folder'); } if (!sourceFolder && sourceFolderPath == '') { sourceFolderPath = '/'; } if (sourceFolderPath == '/') { const rootFiles: TAbstractFile[] = []; plugin.app.vault.getAllLoadedFiles().filter(f => f.parent?.path === '/').forEach((file) => { if (!file.path.includes('/')) { rootFiles.push(file); } }); files = rootFiles; } else if (sourceFolder) { files = sourceFolder.children; } files = this.filterFiles(files, plugin, sourceFolderPath, this.yaml.depth, this.pathBlacklist); if (!this.yaml.includeTypes.includes('folder')) { files = this.getAllFiles(files, sourceFolderPath, this.yaml.depth); } if (files.length === 0) { return this.addEditButton(root) } files = this.sortFiles(files); if (this.yaml.style === 'grid') { const grid = root.createEl('div', { cls: 'folder-overview-grid' }); files.forEach(async (file) => { const gridItem = grid.createEl('div', { cls: 'folder-overview-grid-item' }); const gridArticle = gridItem.createEl('article', { cls: 'folder-overview-grid-item-article' }); if (file instanceof TFile) { const fileContent = await plugin.app.vault.read(file); const descriptionEl = gridArticle.createEl('p', { cls: 'folder-overview-grid-item-description' }); let description = fileContent.split('\n')[0]; if (description.length > 64) { description = description.slice(0, 64) + '...'; } descriptionEl.innerText = description; const link = gridArticle.createEl('a', { cls: 'folder-overview-grid-item-link internal-link' }); const title = link.createEl('h1', { cls: 'folder-overview-grid-item-link-title' }); title.innerText = file.name.replace('.md', '').replace('.canvas', ''); link.href = file.path; } else if (file instanceof TFolder) { const folderItem = gridArticle.createEl('div', { cls: 'folder-overview-grid-item-folder' }); const folderName = folderItem.createEl('h1', { cls: 'folder-overview-grid-item-folder-name' }); folderName.innerText = file.name; } }); } else if (this.yaml.style === 'list') { const folders = this.sortFiles(files.filter(f => f instanceof TFolder)); files = this.sortFiles(files.filter(f => f instanceof TFile)); folders.forEach((file) => { if (file instanceof TFolder) { const folderItem = this.addFolderList(plugin, ul, this.pathBlacklist, file); if (!folderItem) { return; } this.goThroughFolders(plugin, folderItem, file, this.yaml.depth, sourceFolderPath, ctx, this.yaml, this.pathBlacklist, this.yaml.includeTypes, this.yaml.disableFileTag); } }); files.forEach((file) => { if (file instanceof TFile) { this.addFileList(plugin, ul, this.pathBlacklist, file, this.yaml.includeTypes, this.yaml.disableFileTag); } }); } else if (this.yaml.style === 'explorer') { if (this.plugin.app.workspace.layoutReady) { this.cloneFileExplorerView(plugin, ctx, root, this.yaml, this.pathBlacklist); } else { this.plugin.app.workspace.onLayoutReady(() => { this.cloneFileExplorerView(plugin, ctx, root, this.yaml, this.pathBlacklist); }); } } const overviewListEl = el.childNodes[0].childNodes[1]; if (overviewListEl && overviewListEl.childNodes.length === 0) { if (this.yaml.style === 'explorer') { const overview = el.childNodes[0]; if (!overview.childNodes[2]) { return this.addEditButton(root); } } else { return this.addEditButton(root); } } if (this.yaml.includeTypes.length > 1 && (!this.yaml.showEmptyFolders || this.yaml.onlyIncludeSubfolders) && this.yaml.style === 'list') { this.removeEmptyFolders(ul, 1, this.yaml); } } addEditButton(root: HTMLElement) { const editButton = root.createEl('button', { cls: 'folder-overview-edit-button' }); editButton.innerText = 'Edit overview'; editButton.addEventListener('click', (e) => { e.stopImmediatePropagation(); e.preventDefault(); e.stopPropagation(); new FolderOverviewSettings(this.plugin.app, this.plugin, this.yaml, this.ctx, this.el).open(); }, { capture: true }); } cloneFileExplorerView(plugin: FolderNotesPlugin, ctx: MarkdownPostProcessorContext, root: HTMLElement, yaml: yamlSettings, pathBlacklist: string[]) { const folder = plugin.getEL(this.yaml.folderPath) let folderElement = folder?.parentElement; let tFolder = plugin.app.vault.getAbstractFileByPath(this.yaml.folderPath); if (!tFolder && yaml.folderPath.trim() == '') { tFolder = plugin.app.vault.getAbstractFileByPath(plugin.getFolderPathFromString(ctx.sourcePath)); } if (!folderElement && yaml.folderPath.trim() !== '') return; folderElement = document.querySelector('div.nav-files-container') as HTMLElement; if (!folderElement) return; const newFolderElement = folderElement.cloneNode(true) as HTMLElement; newFolderElement.querySelectorAll('div.nav-folder-title ').forEach((el) => { const folder = plugin.app.vault.getAbstractFileByPath(el.getAttribute('data-path') || ''); if (!(folder instanceof TFolder)) return; if (this.yaml.storeFolderCondition) { if (folder.collapsed) { el.classList.add('is-collapsed'); } else { el.classList.remove('is-collapsed'); } } else { if (el.parentElement?.classList.contains('is-collapsed')) { folder.collapsed = true; } else { folder.collapsed = false; } } if (el.classList.contains('has-folder-note')) { const folderNote = getFolderNote(plugin, folder.path); if (folderNote) { this.pathBlacklist.push(folderNote.path); } } }); if (tFolder instanceof TFolder) { this.addFiles(tFolder.children, root); } else if (yaml.folderPath.trim() === '/') { const rootFiles: TAbstractFile[] = []; plugin.app.vault.getAllLoadedFiles().filter(f => f instanceof TFolder).forEach((file) => { if (!file.path.includes('/')) { rootFiles.push(file); } }); this.addFiles(rootFiles, root); } newFolderElement.querySelectorAll('div.tree-item-icon').forEach((el) => { if (el instanceof HTMLElement) { el.onclick = () => { const path = el.parentElement?.getAttribute('data-path'); if (!path) return; const folder = plugin.app.vault.getAbstractFileByPath(path); this.handleCollapseClick(el, plugin, yaml, pathBlacklist, this.source, folder); } } }); } async addFiles(files: TAbstractFile[], childrenElement: HTMLElement) { const folders = this.sortFiles(files.filter((file) => file instanceof TFolder)); const filesWithoutFolders = this.sortFiles(files.filter((file) => !(file instanceof TFolder))); for (const child of folders) { if (child instanceof TFolder) { const folderNote = getFolderNote(this.plugin, child.path); if (folderNote) { this.pathBlacklist.push(folderNote.path); } const excludedFolder = getExcludedFolder(this.plugin, child.path); if (excludedFolder?.excludeFromFolderOverview) { continue; } const svg = ''; const folderElement = childrenElement.createDiv({ cls: 'tree-item nav-folder', }); const folderTitle = folderElement.createDiv({ cls: 'tree-item-self is-clickable nav-folder-title', attr: { 'data-path': child.path, 'draggable': 'true' }, }) if (!child.collapsed) { folderTitle.classList.remove('is-collapsed'); const childrenElement = folderElement?.createDiv({ cls: 'tree-item-children nav-folder-children' }); this.addFiles(child.children, childrenElement); } else { folderTitle.classList.add('is-collapsed'); } if (folderNote) { folderTitle.classList.add('has-folder-note') } const collapseIcon = folderTitle.createDiv({ cls: 'tree-item-icon collapse-icon nav-folder-collapse-indicator', }); if (child.collapsed) { collapseIcon.classList.add('is-collapsed'); } collapseIcon.innerHTML = svg; collapseIcon.onclick = () => { this.handleCollapseClick(collapseIcon, this.plugin, this.yaml, this.pathBlacklist, this.source, child); } folderTitle.createDiv({ cls: 'tree-item-inner nav-folder-title-content', text: child.name, }); } } for (const child of filesWithoutFolders) { if (child instanceof TFile) { if (this.pathBlacklist.includes(child.path) && !this.yaml.showFolderNotes) { continue; } const extension = child.extension.toLowerCase() == 'md' ? 'markdown' : child.extension.toLowerCase(); const includeTypes = this.yaml.includeTypes; if (includeTypes.length > 0 && !includeTypes.includes('all')) { if ((extension === 'md' || extension === 'markdown') && !includeTypes.includes('markdown')) continue; if (extension === 'canvas' && !includeTypes.includes('canvas')) continue; if (extension === 'pdf' && !includeTypes.includes('pdf')) continue; const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; if (imageTypes.includes(extension) && !includeTypes.includes('image')) continue; const videoTypes = ['mp4', 'webm', 'ogv', 'mov', 'mkv']; if (videoTypes.includes(extension) && !includeTypes.includes('video')) continue; const audioTypes = ['mp3', 'wav', 'm4a', '3gp', 'flac', 'ogg', 'oga', 'opus']; if (audioTypes.includes(extension) && includeTypes.includes('audio')) continue; const allTypes = ['markdown', 'md', 'canvas', 'pdf', ...imageTypes, ...videoTypes, ...audioTypes]; if (!allTypes.includes(extension) && !includeTypes.includes('other')) continue; } const fileElement = childrenElement.createDiv({ cls: 'tree-item nav-file', }); const fileTitle = fileElement.createDiv({ cls: 'tree-item-self is-clickable nav-file-title pointer-cursor', attr: { 'data-path': child.path, 'draggable': 'true' }, }) fileTitle.onclick = () => { this.plugin.app.workspace.openLinkText(child.path, child.path, true); } fileTitle.createDiv({ cls: 'tree-item-inner nav-file-title-content', text: child.basename, }); if (child.extension !== 'md') { fileTitle.createDiv({ cls: 'nav-file-tag', text: child.extension }); } } } } handleCollapseClick(el: HTMLElement, plugin: FolderNotesPlugin, yaml: yamlSettings, pathBlacklist: string[], sourcePath: string, folder?: TFolder | undefined | null | TAbstractFile) { el.classList.toggle('is-collapsed'); if (el.classList.contains('is-collapsed')) { if (!(folder instanceof TFolder)) return; folder.collapsed = true; el.parentElement?.parentElement?.childNodes[1]?.remove(); } else { if (!(folder instanceof TFolder)) return; folder.collapsed = false; const folderElement = el.parentElement?.parentElement; if (!folderElement) return; const childrenElement = folderElement.createDiv({ cls: 'tree-item-children nav-folder-children' }); let files = this.sortFiles(folder.children); files = this.filterFiles(files, plugin, folder.path, this.yaml.depth || 1, pathBlacklist); this.addFiles(files, childrenElement); } } goThroughFolders(plugin: FolderNotesPlugin, list: HTMLLIElement | HTMLUListElement, folder: TFolder, depth: number, sourceFolderPath: string, ctx: MarkdownPostProcessorContext, yaml: yamlSettings, pathBlacklist: string[], includeTypes: string[], disableFileTag: boolean) { if (sourceFolderPath === '') { depth--; } let files = this.filterFiles(folder.children, plugin, sourceFolderPath, depth, pathBlacklist); files = this.sortFiles(files.filter((file) => !(file instanceof TFolder))); if (this.yaml.sortByAsc) { files = files.reverse(); } const folders = this.sortFiles(files.filter((file) => file instanceof TFolder)); const ul = list.createEl('ul', { cls: 'folder-overview-list' }); folders.forEach((file) => { if (file instanceof TFolder) { const folderItem = this.addFolderList(plugin, ul, pathBlacklist, file); if (!folderItem) return; this.goThroughFolders(plugin, folderItem, file, depth, sourceFolderPath, ctx, yaml, pathBlacklist, includeTypes, disableFileTag); } }); files.forEach((file) => { if (file instanceof TFile) { this.addFileList(plugin, ul, pathBlacklist, file, includeTypes, disableFileTag); } }); } filterFiles(files: TAbstractFile[], plugin: FolderNotesPlugin, sourceFolderPath: string, depth: number, pathBlacklist: string[]) { return files.filter((file) => { if (pathBlacklist.includes(file.path) && !this.yaml.showFolderNotes) { return false; } const folderPath = plugin.getFolderPathFromString(file.path); if (!folderPath.startsWith(sourceFolderPath) && sourceFolderPath !== '/') { return false; } if (file.path === this.sourceFilePath) { return false; } const excludedFolder = getExcludedFolder(plugin, file.path); if (excludedFolder?.excludeFromFolderOverview) { return false; } if ((file.path.split('/').length - sourceFolderPath.split('/').length) - 1 < depth) { return true; } }); } sortFiles(files: TAbstractFile[]) { const yaml = this.yaml; if (!yaml?.sortBy) { yaml.sortBy = this.plugin.settings.defaultOverview.sortBy || 'name'; yaml.sortByAsc = this.plugin.settings.defaultOverview.sortByAsc || false; } files.sort((a, b) => { if (a instanceof TFolder && !(b instanceof TFolder)) { return -1; } if (!(a instanceof TFolder) && b instanceof TFolder) { return 1; } if ((a instanceof TFolder) && (b instanceof TFolder)) { if (a.name.localeCompare(b.name) < 0) { return -1; } else if (a.name.localeCompare(b.name) > 0) { return 1; } } if (!(a instanceof TFile) || !(b instanceof TFile)) { return -1; } if (yaml.sortBy === 'created') { if (a.stat.ctime > b.stat.ctime) { return -1; } else if (a.stat.ctime < b.stat.ctime) { return 1; } } else if (yaml.sortBy === 'modified') { if (a.stat.mtime > b.stat.mtime) { return -1; } else if (a.stat.mtime < b.stat.mtime) { return 1; } } else if (yaml.sortBy === 'name') { if (a.basename.localeCompare(b.basename) < 0) { return -1; } else if (a.basename.localeCompare(b.basename) > 0) { return 1; } } return 0; }); if (!yaml?.sortByAsc) { files = files.reverse(); } return files; } removeEmptyFolders(ul: HTMLUListElement | HTMLLIElement, depth: number, yaml: yamlSettings) { const childrensToRemove: ChildNode[] = []; ul.childNodes.forEach((el) => { if ((el.childNodes[0] as HTMLElement)?.classList && (el.childNodes[0] as HTMLElement)?.classList.contains('internal-link')) { return; } const childrens = (el as Element).querySelector('ul'); if (!childrens || childrens === null) { return; } if (childrens && !childrens?.hasChildNodes() && !(el instanceof HTMLUListElement)) { childrensToRemove.push(el); } else if (el instanceof HTMLUListElement || el instanceof HTMLLIElement) { this.removeEmptyFolders(el, depth + 1, yaml); } }); childrensToRemove.forEach((el) => { if (yaml.onlyIncludeSubfolders && depth === 1) { return; } el.remove(); }); } addFolderList(plugin: FolderNotesPlugin, list: HTMLUListElement | HTMLLIElement, pathBlacklist: string[], folder: TFolder) { const folderItem = list.createEl('li', { cls: 'folder-overview-list folder-list' }); const folderNote = getFolderNote(plugin, folder.path); if (folderNote instanceof TFile) { const folderNoteLink = folderItem.createEl('a', { cls: 'folder-overview-list-item folder-name-item internal-link', href: folderNote.path }); folderNoteLink.innerText = folder.name; pathBlacklist.push(folderNote.path); } else { const folderName = folderItem.createEl('span', { cls: 'folder-overview-list-item folder-name-item' }); folderName.innerText = folder.name; } return folderItem; } addFileList(plugin: FolderNotesPlugin, list: HTMLUListElement | HTMLLIElement, pathBlacklist: string[], file: TFile, includeTypes: string[], disableFileTag: boolean) { if (includeTypes.length > 0 && !includeTypes.includes('all')) { if (file.extension === 'md' && !includeTypes.includes('markdown')) return; if (file.extension === 'canvas' && !includeTypes.includes('canvas')) return; if (file.extension === 'pdf' && !includeTypes.includes('pdf')) return; const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; if (imageTypes.includes(file.extension) && !includeTypes.includes('image')) return; const videoTypes = ['mp4', 'webm', 'ogv', 'mov', 'mkv']; if (videoTypes.includes(file.extension) && !includeTypes.includes('video')) return; const audioTypes = ['mp3', 'wav', 'm4a', '3gp', 'flac', 'ogg', 'oga', 'opus']; if (audioTypes.includes(file.extension) && includeTypes.includes('audio')) return; const allTypes = ['md', 'canvas', 'pdf', ...imageTypes, ...videoTypes, ...audioTypes]; if (!allTypes.includes(file.extension) && !includeTypes.includes('other')) return; } if (!this.yaml.showFolderNotes) { if (pathBlacklist.includes(file.path)) return; } const listItem = list.createEl('li', { cls: 'folder-overview-list file-link' }); const nameItem = listItem.createEl('div', { cls: 'folder-overview-list-item' }); const link = nameItem.createEl('a', { cls: 'internal-link', href: file.path }); link.innerText = file.basename; if (file.extension !== 'md' && !disableFileTag) { nameItem.createDiv({ cls: 'nav-file-tag' }).innerText = file.extension; } } getAllFiles(files: TAbstractFile[], sourceFolderPath: string, depth: number) { const allFiles: TAbstractFile[] = []; files.forEach((file) => { if (file instanceof TFolder) { if ((file.path.split('/').length - sourceFolderPath.split('/').length) - 1 < depth - 1) { allFiles.push(...this.getAllFiles(file.children, sourceFolderPath, depth)); } } else { allFiles.push(file); } }); return allFiles; } } export async function updateYaml(plugin: FolderNotesPlugin, ctx: MarkdownPostProcessorContext, el: HTMLElement, yaml: yamlSettings) { const file = plugin.app.vault.getAbstractFileByPath(ctx.sourcePath); if (!(file instanceof TFile)) return; let stringYaml = stringifyYaml(yaml); await plugin.app.vault.process(file, (text) => { const info = ctx.getSectionInfo(el); // check if stringYaml ends with a newline if (stringYaml[stringYaml.length - 1] !== '\n') { stringYaml += '\n'; } if (info) { const { lineStart } = info; const lineEnd = getCodeBlockEndLine(text, lineStart); if (lineEnd === -1 || !lineEnd) return text; const lineLength = lineEnd - lineStart; const lines = text.split('\n'); lines.splice(lineStart, lineLength + 1, `\`\`\`folder-overview\n${stringYaml}\`\`\``); return lines.join('\n'); } return `\`\`\`folder-overview\n${stringYaml}\`\`\``; }); } export function getCodeBlockEndLine(text: string, startLine: number, count = 1) { let line = startLine + 1; const lines = text.split('\n'); while (line < lines.length) { if (count > 50) { return -1; } if (lines[line].startsWith('```')) { return line; } line++; count++; } return line; }