import {App, InstalledPlugin, Plugin, PluginInstance, TAbstractFile, TFolder} from "obsidian"; import {extractParentFolderPath, lastPathComponent} from "./utils"; const BookmarksPlugin_getBookmarks_methodName = 'getBookmarks' const BookmarksPlugin_items_collectionName = 'items' type Path = string // Only relevant types of bookmarked items considered here // The full set of types also includes 'search', canvas, graph, maybe more to come type BookmarkedItem = BookmarkedFile | BookmarkedFolder | BookmarkedGroup // Either a file, a folder or header/block inside a file interface BookmarkWithPath { path: Path } interface BookmarkedFile { type: 'file' path: Path subpath?: string // Anchor within the file (heading and/or block ref) title?: string ctime: number } interface BookmarkedFolder { type: 'folder' path: Path title?: string ctime: number } interface BookmarkedGroup { type: 'group' items: Array title?: string ctime: number } export type BookmarkedItemPath = string export interface OrderedBookmarkedItem { file: boolean folder: boolean group: boolean path: BookmarkedItemPath order: number } interface OrderedBookmarks { [key: BookmarkedItemPath]: OrderedBookmarkedItem } export interface Bookmarks_PluginInstance extends PluginInstance { [BookmarksPlugin_getBookmarks_methodName]: () => Array | undefined [BookmarksPlugin_items_collectionName]: Array saveData(): void onItemsChanged(saveData: boolean): void } let bookmarksCache: OrderedBookmarks | undefined = undefined let bookmarksCacheTimestamp: number | undefined = undefined const CacheExpirationMilis = 1000 // One second seems to be reasonable export const invalidateExpiredBookmarksCache = (force?: boolean): void => { if (bookmarksCache) { let flush: boolean = true if (!force && !!bookmarksCacheTimestamp) { if (Date.now() - CacheExpirationMilis <= bookmarksCacheTimestamp) { flush = false } } if (flush) { bookmarksCache = undefined bookmarksCacheTimestamp = undefined } } } export const BookmarksCorePluginId: string = 'bookmarks' export const getBookmarksPlugin = (app?: App): Bookmarks_PluginInstance | undefined => { invalidateExpiredBookmarksCache() const bookmarksPlugin: InstalledPlugin | undefined = app?.internalPlugins?.getPluginById(BookmarksCorePluginId) console.log(bookmarksPlugin) const bookmarks = (bookmarksPlugin?.instance as any) ?.['getBookmarks']() console.log(bookmarks) if (bookmarksPlugin && bookmarksPlugin.enabled && bookmarksPlugin.instance) { const bookmarksPluginInstance: Bookmarks_PluginInstance = bookmarksPlugin.instance as Bookmarks_PluginInstance // defensive programming, in case Obsidian changes its internal APIs if (typeof bookmarksPluginInstance?.[BookmarksPlugin_getBookmarks_methodName] === 'function') { return bookmarksPluginInstance } } } type TraverseCallback = (item: BookmarkedItem, parentsGroupsPath: string) => boolean | void const traverseBookmarksCollection = (items: Array, callback: TraverseCallback) => { const recursiveTraversal = (collection: Array, groupsPath: string) => { for (let idx = 0, collectionRef = collection; idx < collectionRef.length; idx++) { const item = collectionRef[idx]; if (callback(item, groupsPath)) return; if ('group' === item.type) recursiveTraversal(item.items, `${groupsPath}${groupsPath?'/':''}${item.title}`); } }; recursiveTraversal(items, ''); } const ARTIFICIAL_ANCHOR_SORTING_BOOKMARK_INDICATOR = '#^.' const getOrderedBookmarks = (plugin: Bookmarks_PluginInstance, bookmarksGroupName?: string): OrderedBookmarks | undefined => { console.log(`Populating bookmarks cache with group scope ${bookmarksGroupName}`) let bookmarks: Array | undefined = plugin?.[BookmarksPlugin_getBookmarks_methodName]() if (bookmarks) { if (bookmarksGroupName) { const bookmarksGroup: BookmarkedGroup|undefined = bookmarks.find( (item) => item.type === 'group' && item.title === bookmarksGroupName) as BookmarkedGroup bookmarks = bookmarksGroup ? bookmarksGroup.items : undefined } if (bookmarks) { const orderedBookmarks: OrderedBookmarks = {} let order: number = 0 const consumeItem = (item: BookmarkedItem, parentGroupsPath: string) => { const isFile: boolean = item.type === 'file' const hasSortspecAnchor: boolean = isFile && (item as BookmarkedFile).subpath === ARTIFICIAL_ANCHOR_SORTING_BOOKMARK_INDICATOR const isFolder: boolean = item.type === 'folder' const isGroup: boolean = item.type === 'group' if ((isFile && hasSortspecAnchor) || isFolder || isGroup) { const pathOfGroup: string = `${parentGroupsPath}${parentGroupsPath?'/':''}${item.title}` const path = isGroup ? pathOfGroup : (item as BookmarkWithPath).path // Consume only the first occurrence of a path in bookmarks, even if many duplicates can exist const alreadyConsumed = orderedBookmarks[path] if (!alreadyConsumed) { orderedBookmarks[path] = { path: path, order: order++, file: isFile, folder: isFile, group: isGroup } } } } traverseBookmarksCollection(bookmarks, consumeItem) return orderedBookmarks } } } // Result: // undefined ==> item not found in bookmarks // > 0 ==> item found in bookmarks at returned position // Intentionally not returning 0 to allow simple syntax of processing the result export const determineBookmarkOrder = (path: string, plugin: Bookmarks_PluginInstance, bookmarksGroup?: string): number | undefined => { if (!bookmarksCache) { bookmarksCache = getOrderedBookmarks(plugin, bookmarksGroup) bookmarksCacheTimestamp = Date.now() } const bookmarkedItemPosition: number | undefined = bookmarksCache?.[path]?.order return (bookmarkedItemPosition !== undefined && bookmarkedItemPosition >= 0) ? (bookmarkedItemPosition + 1) : undefined } // EXPERIMENTAL - operates on internal structures of core Bookmarks plugin const createBookmarkFileEntry = (path: string): BookmarkedFile => { // Artificial subpath added intentionally to prevent Bookmarks context menu from finding this item in bookmarks // and - in turn - allow bookmarking it by the user for regular (non sorting) purposes return { type: "file", ctime: Date.now(), path: path, subpath: ARTIFICIAL_ANCHOR_SORTING_BOOKMARK_INDICATOR } } const createBookmarkGroupEntry = (title: string): BookmarkedGroup => { return { type: "group", ctime: Date.now(), items: [], title: title } } export const bookmarkFolderItem = (item: TAbstractFile, plugin: Bookmarks_PluginInstance, bookmarksGroup?: string) => { bookmarkSiblings([item], plugin, bookmarksGroup) } interface BookmarkedParentFolder { group?: BookmarkedGroup // undefined when the item is at root level of bookmarks items: Array // reference to group.items or to root collection of bookmarks } interface ItemInBookmarks { parentItemsCollection: Array item: BookmarkedItem } const findGroupForItemPathInBookmarks = (itemPath: string, createIfMissing: boolean, plugin: Bookmarks_PluginInstance, bookmarksGroup?: string): BookmarkedParentFolder|undefined => { let items = plugin[BookmarksPlugin_items_collectionName] if (!itemPath || !itemPath.trim()) return undefined // for sanity const parentPath: string = extractParentFolderPath(itemPath) const parentPathComponents: Array = parentPath ? parentPath.split('/')! : [] if (bookmarksGroup) { parentPathComponents.unshift(bookmarksGroup) } let group: BookmarkedGroup|undefined = undefined parentPathComponents.forEach((pathSegment) => { let group: BookmarkedGroup|undefined = items.find((it) => it.type === 'group' && it.title === pathSegment) as BookmarkedGroup if (!group) { if (createIfMissing) { group = createBookmarkGroupEntry(pathSegment) items.push(group) } else { return undefined } } items = group.items }) return { items: items, group: group } } const CreateIfMissing = true const DontCreateIfMissing = false export const bookmarkSiblings = (siblings: Array, plugin: Bookmarks_PluginInstance, bookmarksGroup?: string) => { if (siblings.length === 0) return // for sanity const bookmarksContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(siblings[0].path, CreateIfMissing, plugin, bookmarksGroup) if (bookmarksContainer) { // for sanity, the group should be always created if missing siblings.forEach((aSibling) => { const siblingName = lastPathComponent(aSibling.path) if (!bookmarksContainer.items.find((it) => ((it.type === 'folder' || it.type === 'file') && it.path === aSibling.path) || (it.type === 'group' && it.title === siblingName))) { const newEntry: BookmarkedItem = (aSibling instanceof TFolder) ? createBookmarkGroupEntry(siblingName) : createBookmarkFileEntry(aSibling.path) bookmarksContainer.items.push(newEntry) } }); } } export const saveDataAndUpdateBookmarkViews = (plugin: Bookmarks_PluginInstance, app: App) => { plugin.onItemsChanged(true) const bookmarksLeafs = app.workspace.getLeavesOfType('bookmarks') bookmarksLeafs?.forEach((leaf) => { (leaf.view as any)?.update?.() }) } export const updateSortingBookmarksAfterItemRename = (plugin: Bookmarks_PluginInstance, renamedItem: TAbstractFile, oldPath: string, bookmarksGroup?: string) => { if (renamedItem.path === oldPath) return; // sanity let items = plugin[BookmarksPlugin_items_collectionName] const aFolder: boolean = renamedItem instanceof TFolder const aFolderWithChildren: boolean = aFolder && (renamedItem as TFolder).children.length > 0 const aFile: boolean = !aFolder const oldParentPath: string = extractParentFolderPath(oldPath) const oldName: string = lastPathComponent(oldPath) const newParentPath: string = extractParentFolderPath(renamedItem.path) const newName: string = lastPathComponent(renamedItem.path) const moved: boolean = oldParentPath !== newParentPath const renamed: boolean = oldName !== newName const originalContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(oldPath, DontCreateIfMissing, plugin, bookmarksGroup) if (!originalContainer) return; const item: BookmarkedItem|undefined = originalContainer.items.find((it) => { if (aFolder && it.type === 'group' && it.title === oldName) return true; if (aFile && it.type === 'file' && it.path === oldPath) return true; }) if (!item) return; // The renamed/moved item was located in bookmarks, apply the necessary bookmarks updates let itemRemovedFromBookmarks: boolean = false if (moved) { originalContainer.items.remove(item) const createTargetLocation: boolean = aFolderWithChildren const targetContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(renamedItem.path, createTargetLocation, plugin, bookmarksGroup) if (targetContainer) { targetContainer.items.push(item) } else { itemRemovedFromBookmarks = true // open question: remove from bookmarks indeed, if target location was not under bookmarks control? } } if (aFolder && renamed && !itemRemovedFromBookmarks) { // Renames of files are handled automatically by Bookmarks core plugin, only need to handle folder rename // because folders are represented (for sorting purposes) by groups with exact name (item as BookmarkedGroup).title = newName } console.log(`Folder renamel from ${oldPath} to ${renamedItem.path}`) plugin.onItemsChanged(true) }