308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
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<BookmarkedItem>
|
|
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<BookmarkedItem> | undefined
|
|
[BookmarksPlugin_items_collectionName]: Array<BookmarkedItem>
|
|
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<BookmarkedItem>, callback: TraverseCallback) => {
|
|
const recursiveTraversal = (collection: Array<BookmarkedItem>, 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<BookmarkedItem> | 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<BookmarkedItem> // reference to group.items or to root collection of bookmarks
|
|
}
|
|
|
|
interface ItemInBookmarks {
|
|
parentItemsCollection: Array<BookmarkedItem>
|
|
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<string> = 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<TAbstractFile>, 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)
|
|
}
|