import Folder from '@joplin/lib/models/Folder'; import Tag from '@joplin/lib/models/Tag'; import BaseModel from '@joplin/lib/BaseModel'; import Setting from '@joplin/lib/models/Setting'; import { _ } from '@joplin/lib/locale'; import { FolderEntity, TagEntity } from '@joplin/lib/services/database/types'; import { getDisplayParentId, getTrashFolderId, } from '@joplin/lib/services/trash'; const ListWidget = require('tkwidgets/ListWidget.js'); interface SearchItem { id: string; title: string; type_: number; } type FolderListItem = FolderEntity | TagEntity | SearchItem | '-'; export default class FolderListWidget extends ListWidget { private folders_: FolderEntity[] = []; public constructor() { super(); this.tags_ = []; this.searches_ = []; this.selectedFolderId_ = null; this.selectedTagId_ = null; this.selectedSearchId_ = null; this.notesParentType_ = 'Folder'; this.updateIndexFromSelectedFolderId_ = false; this.updateItems_ = false; this.trimItemTitle = false; this.showIds = false; this.itemRenderer = (item: FolderListItem) => { const output = []; if (item === '-') { output.push('-'.repeat(this.innerWidth)); } else if (item.type_ === Folder.modelType()) { const folder = item as FolderEntity & { note_count?: number }; const depth = this.folderDepth(this.folders, folder.id); output.push(' '.repeat(depth)); // Add collapse/expand indicator const hasChildren = this.folderHasChildren_(this.folders, folder.id); if (hasChildren) { const collapsedFolders = Setting.value('collapsedFolderIds'); const isCollapsed = collapsedFolders.includes(folder.id); output.push(isCollapsed ? '[+] ' : '[-] '); } else { output.push(' '); // Space for alignment } if (this.showIds) { output.push(Folder.shortId(folder.id)); } output.push(Folder.displayTitle(folder)); if (Setting.value('showNoteCounts') && !folder.deleted_time && folder.id !== getTrashFolderId()) { let noteCount = folder.note_count; if (this.folderHasChildren_(this.folders, folder.id)) { for (let i = 0; i < this.folders.length; i++) { if (this.folders[i].parent_id === folder.id) { noteCount -= (this.folders[i] as FolderEntity & { note_count?: number }).note_count; } } } output.push(noteCount); } } else if (item.type_ === Tag.modelType()) { output.push(`[${Folder.displayTitle(item)}]`); } else if (item.type_ === BaseModel.TYPE_SEARCH) { output.push(_('Search:')); output.push(item.title); } return output.join(' '); }; } public folderDepth(folders: FolderEntity[], folderId: string) { let output = 0; while (true) { const folder = BaseModel.byId(folders, folderId); const folderParentId = getDisplayParentId( folder, folders.find((f) => f.id === folder.parent_id), ); if (!folder || !folderParentId) return output; output++; folderId = folderParentId; } } public get selectedFolderId() { return this.selectedFolderId_; } public set selectedFolderId(v) { this.selectedFolderId_ = v; this.updateIndexFromSelectedItemId(); this.invalidate(); } public get selectedSearchId() { return this.selectedSearchId_; } public set selectedSearchId(v) { this.selectedSearchId_ = v; this.updateIndexFromSelectedItemId(); this.invalidate(); } public get selectedTagId() { return this.selectedTagId_; } public set selectedTagId(v) { this.selectedTagId_ = v; this.updateIndexFromSelectedItemId(); this.invalidate(); } public get notesParentType() { return this.notesParentType_; } public set notesParentType(v) { this.notesParentType_ = v; this.updateIndexFromSelectedItemId(); this.invalidate(); } public get searches() { return this.searches_; } public set searches(v) { this.searches_ = v; this.updateItems_ = true; this.updateIndexFromSelectedItemId(); this.invalidate(); } public get tags() { return this.tags_; } public set tags(v) { this.tags_ = v; this.updateItems_ = true; this.updateIndexFromSelectedItemId(); this.invalidate(); } public get folders() { return this.folders_; } public set folders(v) { this.folders_ = v; this.updateItems_ = true; this.updateIndexFromSelectedItemId(); this.invalidate(); } public toggleShowIds() { this.showIds = !this.showIds; this.invalidate(); } public folderHasChildren_(folders: FolderEntity[], folderId: string) { for (let i = 0; i < folders.length; i++) { const folder = folders[i]; const folderParentId = getDisplayParentId( folder, folders.find((f) => f.id === folder.parent_id), ); if (folderParentId === folderId) return true; } return false; } public render() { if (this.updateItems_) { this.logger().debug( 'Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId, ); const wasSelectedItemId = this.selectedJoplinItemId; const previousParentType = this.notesParentType; let newItems: FolderListItem[] = []; const orderFolders = (parentId: string) => { for (let i = 0; i < this.folders.length; i++) { const f = this.folders[i]; const originalParent = this.folders_.find( (f) => f.id === f.parent_id, ); const folderParentId = getDisplayParentId(f, originalParent); // f.parent_id ? f.parent_id : ''; if (folderParentId === parentId) { newItems.push(f); // Only recurse into children if the folder is not collapsed if (this.folderHasChildren_(this.folders, f.id)) { const collapsedFolders = Setting.value('collapsedFolderIds'); if (!collapsedFolders.includes(f.id)) { orderFolders(f.id); } } } } }; orderFolders(''); if (this.tags.length) { if (newItems.length) newItems.push('-'); newItems = newItems.concat(this.tags); } if (this.searches.length) { if (newItems.length) newItems.push('-'); newItems = newItems.concat(this.searches); } this.items = newItems; this.notesParentType = previousParentType; this.updateIndexFromSelectedItemId(wasSelectedItemId); this.updateItems_ = false; } super.render(); } public get selectedJoplinItemId() { if (!this.notesParentType) return ''; if (this.notesParentType === 'Folder') return this.selectedFolderId; if (this.notesParentType === 'Tag') return this.selectedTagId; if (this.notesParentType === 'Search') return this.selectedSearchId; throw new Error(`Unknown parent type: ${this.notesParentType}`); } public get selectedJoplinItem() { const id = this.selectedJoplinItemId; const index = this.itemIndexByKey('id', id); return this.itemAt(index); } public updateIndexFromSelectedItemId(itemId: string = null) { if (itemId === null) itemId = this.selectedJoplinItemId; const index = this.itemIndexByKey('id', itemId); this.currentIndex = index >= 0 ? index : 0; } public toggleFolderCollapse() { const item = this.currentItem; if (item && item.type_ === Folder.modelType() && this.folderHasChildren_(this.folders, item.id)) { const collapsedFolders = Setting.value('collapsedFolderIds'); const isCollapsed = collapsedFolders.includes(item.id); if (isCollapsed) { const newCollapsed = collapsedFolders.filter((id: string) => id !== item.id); Setting.setValue('collapsedFolderIds', newCollapsed); } else { Setting.setValue('collapsedFolderIds', [...collapsedFolders, item.id]); } this.updateItems_ = true; this.invalidate(); return true; } return false; } public expandToFolder(folderId: string) { // Find all parent folders and expand them const parentsToExpand: string[] = []; let currentId = folderId; while (currentId) { const folder = BaseModel.byId(this.folders, currentId); if (!folder) break; const parentId = getDisplayParentId( folder, this.folders.find((f) => f.id === folder.parent_id), ); if (parentId) { parentsToExpand.unshift(parentId); currentId = parentId; } else { break; } } // Expand all parent folders const collapsedFolders = Setting.value('collapsedFolderIds'); const newCollapsed = collapsedFolders.filter((id: string) => !parentsToExpand.includes(id)); Setting.setValue('collapsedFolderIds', newCollapsed); this.updateItems_ = true; this.invalidate(); } }