#74 - Integration with Bookmarks core plugin and support for indirect drag & drop arrangement

- functionality completed!!!
- increased coverage of the new functionality with unit tests
- more unit tests possible
- basic manual tests done
- next step: real-life usage tests
This commit is contained in:
SebastianMC 2023-10-21 00:40:38 +02:00
parent cd933cb4f0
commit b854ce14ce
7 changed files with 1508 additions and 114 deletions

View File

@ -27,6 +27,7 @@
"jest": "^28.1.1", "jest": "^28.1.1",
"monkey-around": "^2.3.0", "monkey-around": "^2.3.0",
"obsidian": "^0.15.4", "obsidian": "^0.15.4",
"obsidian-1.4.11": "npm:obsidian@1.4.11",
"ts-jest": "^28.0.5", "ts-jest": "^28.0.5",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "typescript": "4.7.4"

View File

@ -615,10 +615,8 @@ export const determineBookmarksOrderIfNeeded = (folderItems: Array<FolderItemFor
export const folderSort = function (sortingSpec: CustomSortSpec, ctx: ProcessingContext) { export const folderSort = function (sortingSpec: CustomSortSpec, ctx: ProcessingContext) {
let fileExplorer = this.fileExplorer let fileExplorer = this.fileExplorer
// shallow copy of groups // shallow copy of groups and expand folder-specific macros on them
sortingSpec.groupsShadow = sortingSpec.groups?.map((group) => Object.assign({} as CustomSortGroup, group)) sortingSpec.groupsShadow = sortingSpec.groups?.map((group) => Object.assign({} as CustomSortGroup, group))
// expand folder-specific macros
const parentFolderName: string|undefined = this.file.name const parentFolderName: string|undefined = this.file.name
expandMacros(sortingSpec, parentFolderName) expandMacros(sortingSpec, parentFolderName)
@ -655,10 +653,15 @@ export const folderSort = function (sortingSpec: CustomSortSpec, ctx: Processing
}; };
// Returns a sorted copy of the input array, intentionally to keep it intact // Returns a sorted copy of the input array, intentionally to keep it intact
export const sortFolderItemsForBookmarking = function (items: Array<TAbstractFile>, sortingSpec: CustomSortSpec|null|undefined, ctx: ProcessingContext, uiSortOrder: string): Array<TAbstractFile> { export const sortFolderItemsForBookmarking = function (folder: TFolder, items: Array<TAbstractFile>, sortingSpec: CustomSortSpec|null|undefined, ctx: ProcessingContext, uiSortOrder: string): Array<TAbstractFile> {
if (sortingSpec) { if (sortingSpec) {
const folderItemsByPath: { [key: string]: TAbstractFile } = {} const folderItemsByPath: { [key: string]: TAbstractFile } = {}
// shallow copy of groups and expand folder-specific macros on them
sortingSpec.groupsShadow = sortingSpec.groups?.map((group) => Object.assign({} as CustomSortGroup, group))
const parentFolderName: string|undefined = folder.name
expandMacros(sortingSpec, parentFolderName)
const folderItems: Array<FolderItemForSorting> = items.map((entry: TFile | TFolder) => { const folderItems: Array<FolderItemForSorting> = items.map((entry: TFile | TFolder) => {
folderItemsByPath[entry.path] = entry folderItemsByPath[entry.path] = entry
const itemForSorting: FolderItemForSorting = determineSortingGroup(entry, sortingSpec, ctx) const itemForSorting: FolderItemForSorting = determineSortingGroup(entry, sortingSpec, ctx)

View File

@ -46,7 +46,8 @@ import {
import {getStarredPlugin} from "./utils/StarredPluginSignature"; import {getStarredPlugin} from "./utils/StarredPluginSignature";
import { import {
BookmarksPluginInterface, BookmarksPluginInterface,
getBookmarksPlugin getBookmarksPlugin,
groupNameForPath
} from "./utils/BookmarksCorePluginSignature"; } from "./utils/BookmarksCorePluginSignature";
import {getIconFolderPlugin} from "./utils/ObsidianIconFolderPluginSignature"; import {getIconFolderPlugin} from "./utils/ObsidianIconFolderPluginSignature";
import {lastPathComponent} from "./utils/utils"; import {lastPathComponent} from "./utils/utils";
@ -357,6 +358,17 @@ export default class CustomSortPlugin extends Plugin {
} }
}); });
}; };
const unbookmarkThisMenuItem = (item: MenuItem) => {
item.setTitle('Custom sort: UNbookmark from sorting.');
item.setIcon('hashtag');
item.onClick(() => {
const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference)
if (bookmarksPlugin) {
bookmarksPlugin.unbookmarkFolderItem(file)
bookmarksPlugin.saveDataAndUpdateBookmarkViews(true)
}
});
};
const bookmarkAllMenuItem = (item: MenuItem) => { const bookmarkAllMenuItem = (item: MenuItem) => {
item.setTitle('Custom sort: bookmark+siblings for sorting.'); item.setTitle('Custom sort: bookmark+siblings for sorting.');
item.setIcon('hashtag'); item.setIcon('hashtag');
@ -369,15 +381,73 @@ export default class CustomSortPlugin extends Plugin {
} }
}); });
}; };
const unbookmarkAllMenuItem = (item: MenuItem) => {
item.setTitle('Custom sort: UNbookmark+all siblings from sorting.');
item.setIcon('hashtag');
item.onClick(() => {
const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference)
if (bookmarksPlugin) {
const orderedChildren: Array<TAbstractFile> = file.parent.children.map((entry: TFile | TFolder) => entry)
bookmarksPlugin.unbookmarkSiblings(orderedChildren)
bookmarksPlugin.saveDataAndUpdateBookmarkViews(true)
}
});
};
const itemAlreadyBookmarkedForSorting: boolean = bookmarksPlugin.isBookmarkedForSorting(file) const itemAlreadyBookmarkedForSorting: boolean = bookmarksPlugin.isBookmarkedForSorting(file)
if (!itemAlreadyBookmarkedForSorting) { if (!itemAlreadyBookmarkedForSorting) {
menu.addItem(bookmarkThisMenuItem) menu.addItem(bookmarkThisMenuItem)
} else {
menu.addItem(unbookmarkThisMenuItem)
} }
menu.addItem(bookmarkAllMenuItem) menu.addItem(bookmarkAllMenuItem)
menu.addItem(unbookmarkAllMenuItem)
}) })
) )
if (requireApiVersion('1.4.11')) {
this.registerEvent(
// "files-menu" event was exposed in 1.4.11
// @ts-ignore
app.workspace.on("files-menu", (menu: Menu, files: TAbstractFile[], source: string, leaf?: WorkspaceLeaf) => {
if (!this.settings.bookmarksContextMenus) return; // Don't show the context menus at all
const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference)
if (!bookmarksPlugin) return; // Don't show context menu if bookmarks plugin not available and not enabled
const bookmarkSelectedMenuItem = (item: MenuItem) => {
item.setTitle('Custom sort: bookmark selected for sorting.');
item.setIcon('hashtag');
item.onClick(() => {
const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference)
if (bookmarksPlugin) {
files.forEach((file) => {
bookmarksPlugin.bookmarkFolderItem(file)
})
bookmarksPlugin.saveDataAndUpdateBookmarkViews(true)
}
});
};
const unbookmarkSelectedMenuItem = (item: MenuItem) => {
item.setTitle('Custom sort: UNbookmark selected from sorting.');
item.setIcon('hashtag');
item.onClick(() => {
const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference)
if (bookmarksPlugin) {
files.forEach((file) => {
bookmarksPlugin.unbookmarkFolderItem(file)
})
bookmarksPlugin.saveDataAndUpdateBookmarkViews(true)
}
});
};
menu.addItem(bookmarkSelectedMenuItem)
menu.addItem(unbookmarkSelectedMenuItem)
})
)
}
this.registerEvent( this.registerEvent(
app.vault.on("rename", (file: TAbstractFile, oldPath: string) => { app.vault.on("rename", (file: TAbstractFile, oldPath: string) => {
const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference) const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference)
@ -504,6 +574,7 @@ export default class CustomSortPlugin extends Plugin {
const has: HasSortingOrGrouping = collectSortingAndGroupingTypes(sortSpec) const has: HasSortingOrGrouping = collectSortingAndGroupingTypes(sortSpec)
return sortFolderItemsForBookmarking( return sortFolderItemsForBookmarking(
folder,
folder.children, folder.children,
sortSpec, sortSpec,
this.createProcessingContextForSorting(has), this.createProcessingContextForSorting(has),
@ -669,7 +740,8 @@ class CustomSortSettingTab extends PluginSettingTab {
.setPlaceholder('e.g. Group for sorting') .setPlaceholder('e.g. Group for sorting')
.setValue(this.plugin.settings.bookmarksGroupToConsumeAsOrderingReference) .setValue(this.plugin.settings.bookmarksGroupToConsumeAsOrderingReference)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.bookmarksGroupToConsumeAsOrderingReference = value.trim() ? pathToFlatString(normalizePath(value)) : ''; value = groupNameForPath(value.trim()).trim()
this.plugin.settings.bookmarksGroupToConsumeAsOrderingReference = value ? pathToFlatString(normalizePath(value)) : '';
await this.plugin.saveSettings(); await this.plugin.saveSettings();
})); }));

View File

@ -0,0 +1,55 @@
Integration with Bookmarks core plugin:
- support two approaches _at the same time_:
- (A) structured bookmarks inside a dedicated bookmarks group, and
- (B) a flat list of bookmarks inside the dedicated bookmarks group
For (A):
- preferred
- a folder is represented by a group in bookmarks
- a file is represented by a file-with-block
- this also applied to non-md files, like jpg and others
- guarantees _'hiding'_ the bookmarks-for-sorting from regular bookmarks usage scenarios
- bookmark entries for sorting are encapsulated in the dedicated group
- they don't interfere with bookmarking of files and folders via standard bookmarking
- only exact location of file bookmark / group matches for sorting order in file explorer
- the contextual bookmark menus always work in (A) mode
- the contextual menus create / modify the bookmarks structure on-the-fly
For (B):
- discouraged, yet supported (exception for some edge cases)
- typically a result of manual bookmarks management
- for small number of items seems reasonable
- for flat vaults it could look same as for (A)
- groups don't have a 'path' attribute, their path is determined by their location
- bookmarked folders represent folders if inside the bookmarks group for sorting
- yet in this way they interfere with regular bookmarks scenario
- file bookmarks work correctly in non-interfering way thanks to the _'artificial block reference'_
- file bookmarks not having the _'artificial block ref'_ work as well
- if they are in the designated bookmarks group
- if there isn't a duplicate, which has the _'artificial block ref'_
- yet in this way they interfere with regular bookmarks scenario
-[ ] TODO: review again the 'item moved' and 'item deleted' scenarios (they look ok, check if they don't delete/move too much)
- [x] fundamental question 1: should 'move' create a bookmark entry/structure if it is not covered by bookmarks?
- Answer: the moved item is removed from bookmarks. If it is a group with descendants not transparent for sorting,
it is renamed to become transparent for sorting.
By design, the order of items is property of the parent folder (the container) and not the items
- [x] fundamental question 2: should 'move' create a bookmark entry if moved item was not bookmarked, yet is moved to a folder covered by bookmarks?
- Answer: same as for previous point.
- [x] review from (A) and (B) perspective
- Answer: scenario (A) is fully handled by 'item moved' and 'item deleted'.
scenario (B) is partially handled for 'item moved'. Details to be read from code (too complex to cover here)
- [x] consider deletion of item outside of bookmarks sorting container group
Answer: bookmark items outside of bookmarks sorting container are not manipulated by custom-sort plugin
to not interfere with standard Bookmarks scenarios
- [x] consider moving an item outside of bookmarks group
- Answer: question not relevant. Items are moved in file explorer and bookmarks only reflect that, if needed.
Hence there is no concept of 'moving an item outside of bookmarks group' - bookmarks group only exists in bookmarks
- [x] edge case: bookmarked item is a group, the deleted/moved is a file, not a folder --> what to do?
- Answer: for moved files, only file bookmarks are scanned (and handles), for moved folders, only groups are scanned (and handled).
- [x] delete all instances at any level of bookmarks structure in 'delete' handler
- Answer: only instances of (A) or (B) are deleted. Items outside of bookmarks container for sorting or
in invalid locations in bookmarks hierarchy are ignored

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,8 @@ import {
extractParentFolderPath, extractParentFolderPath,
lastPathComponent lastPathComponent
} from "./utils"; } from "./utils";
import {Arr} from "tern";
import * as process from "process";
const BookmarksPlugin_getBookmarks_methodName = 'getBookmarks' const BookmarksPlugin_getBookmarks_methodName = 'getBookmarks'
@ -21,45 +23,48 @@ type Path = string
type BookmarkedItem = BookmarkedFile | BookmarkedFolder | BookmarkedGroup type BookmarkedItem = BookmarkedFile | BookmarkedFolder | BookmarkedGroup
// Either a file, a folder or header/block inside a file // Either a file, a folder or header/block inside a file
interface BookmarkWithPath { interface BookmarkItemSuperset {
path: Path
}
interface BookmarkedFile {
type: 'file'
path: Path path: Path
title?: string
ctime: number
subpath?: string // Anchor within the file (heading and/or block ref) subpath?: string // Anchor within the file (heading and/or block ref)
title?: string
ctime: number
} }
interface BookmarkedFolder { interface BookmarkWithPath extends Pick<BookmarkItemSuperset, 'path'> {
}
interface BookmarkedFile extends BookmarkItemSuperset {
type: 'file'
}
interface BookmarkedFolder extends Omit<BookmarkItemSuperset, 'subpath'> {
type: 'folder' type: 'folder'
path: Path
title?: string
ctime: number
} }
interface BookmarkedGroup { interface BookmarkedGroup extends Omit<BookmarkItemSuperset, 'subpath'|'path'> {
type: 'group' type: 'group'
items: Array<BookmarkedItem> items: Array<BookmarkedItem>
title?: string
ctime: number
} }
export type BookmarkedItemPath = string export type BookmarkedItemPath = string
export interface OrderedBookmarkedItem { export interface OrderedBookmarkedItemWithMetadata {
file: boolean isGroup?: boolean
folder: boolean
group: boolean
path: BookmarkedItemPath path: BookmarkedItemPath
hasSortingIndicator?: boolean
order: number order: number
bookmarkPathOverlap: number|true // how much the location in bookmarks hierarchy matches the actual file/folder path bookmarkPathMatches?: boolean
} }
interface OrderedBookmarks { export type OrderedBookmarkedItem = Pick<OrderedBookmarkedItemWithMetadata, 'order'>
[key: BookmarkedItemPath]: OrderedBookmarkedItem export type Order = number
export interface OrderedBookmarks {
[key: BookmarkedItemPath]: Order
}
export interface OrderedBookmarksWithMetadata {
[key: BookmarkedItemPath]: OrderedBookmarkedItemWithMetadata
} }
interface Bookmarks_PluginInstance extends PluginInstance { interface Bookmarks_PluginInstance extends PluginInstance {
@ -72,8 +77,10 @@ interface Bookmarks_PluginInstance extends PluginInstance {
export interface BookmarksPluginInterface { export interface BookmarksPluginInterface {
determineBookmarkOrder(path: string): number|undefined determineBookmarkOrder(path: string): number|undefined
bookmarkFolderItem(item: TAbstractFile): void bookmarkFolderItem(item: TAbstractFile): void
unbookmarkFolderItem(item: TAbstractFile): void
saveDataAndUpdateBookmarkViews(updateBookmarkViews: boolean): void saveDataAndUpdateBookmarkViews(updateBookmarkViews: boolean): void
bookmarkSiblings(siblings: Array<TAbstractFile>, inTheTop?: boolean): void bookmarkSiblings(siblings: Array<TAbstractFile>, inTheTop?: boolean): void
unbookmarkSiblings(siblings: Array<TAbstractFile>): void
updateSortingBookmarksAfterItemRenamed(renamedItem: TAbstractFile, oldPath: string): void updateSortingBookmarksAfterItemRenamed(renamedItem: TAbstractFile, oldPath: string): void
updateSortingBookmarksAfterItemDeleted(deletedItem: TAbstractFile): void updateSortingBookmarksAfterItemDeleted(deletedItem: TAbstractFile): void
isBookmarkedForSorting(item: TAbstractFile): boolean isBookmarkedForSorting(item: TAbstractFile): boolean
@ -82,6 +89,25 @@ export interface BookmarksPluginInterface {
bookmarksIncludeItemsInFolder(folderPath: string): boolean bookmarksIncludeItemsInFolder(folderPath: string): boolean
} }
const checkSubtreeForOnlyTransparentGroups = (items: Array<BookmarkedItem>): boolean => {
if (!items || items?.length === 0) return true
for (let it of items) {
if (it.type !== 'group' || !it.title || !isGroupTransparentForSorting(it.title)) {
return false
}
// it is a group transparent for sorting
const isEmptyOrTransparent: boolean = checkSubtreeForOnlyTransparentGroups(it.items)
if (!isEmptyOrTransparent) {
return false
}
}
return true
}
const bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants = (group: BookmarkedGroup): boolean => {
return checkSubtreeForOnlyTransparentGroups(group.items)
}
class BookmarksPluginWrapper implements BookmarksPluginInterface { class BookmarksPluginWrapper implements BookmarksPluginInterface {
plugin: Bookmarks_PluginInstance|undefined plugin: Bookmarks_PluginInstance|undefined
@ -96,16 +122,16 @@ class BookmarksPluginWrapper implements BookmarksPluginInterface {
// Intentionally not returning 0 to allow simple syntax of processing the result // Intentionally not returning 0 to allow simple syntax of processing the result
// //
// Parameterless invocation enforces cache population, if empty // Parameterless invocation enforces cache population, if empty
determineBookmarkOrder = (path?: string): number | undefined => { determineBookmarkOrder = (path?: string): Order | undefined => {
if (!bookmarksCache) { if (!bookmarksCache) {
[bookmarksCache, bookmarksFoldersCoverage] = getOrderedBookmarks(this.plugin!, this.groupNameForSorting) [bookmarksCache, bookmarksFoldersCoverage] = getOrderedBookmarks(this.plugin!, this.groupNameForSorting)
bookmarksCacheTimestamp = Date.now() bookmarksCacheTimestamp = Date.now()
} }
if (path && path.length > 0) { if (path && path.length > 0) {
const bookmarkedItemPosition: number | undefined = bookmarksCache?.[path]?.order const bookmarkedItemPosition: Order | undefined = bookmarksCache?.[path]
return (bookmarkedItemPosition !== undefined && bookmarkedItemPosition >= 0) ? (bookmarkedItemPosition + 1) : undefined return (bookmarkedItemPosition && bookmarkedItemPosition > 0) ? bookmarkedItemPosition : undefined
} else { } else {
return undefined return undefined
} }
@ -115,6 +141,10 @@ class BookmarksPluginWrapper implements BookmarksPluginInterface {
this.bookmarkSiblings([item], true) this.bookmarkSiblings([item], true)
} }
unbookmarkFolderItem = (item: TAbstractFile) => {
this.unbookmarkSiblings([item])
}
saveDataAndUpdateBookmarkViews = (updateBookmarkViews: boolean = true) => { saveDataAndUpdateBookmarkViews = (updateBookmarkViews: boolean = true) => {
this.plugin!.onItemsChanged(true) this.plugin!.onItemsChanged(true)
if (updateBookmarkViews) { if (updateBookmarkViews) {
@ -138,7 +168,13 @@ class BookmarksPluginWrapper implements BookmarksPluginInterface {
if (bookmarksContainer) { // for sanity, the group should be always created if missing if (bookmarksContainer) { // for sanity, the group should be always created if missing
siblings.forEach((aSibling) => { siblings.forEach((aSibling) => {
const siblingName = lastPathComponent(aSibling.path) const siblingName = lastPathComponent(aSibling.path)
if (!bookmarksContainer.items.find((it) => const groupTransparentForSorting = bookmarksContainer.items.find((it) => (
it.type === 'group' && groupNameForPath(it.title||'') === siblingName && isGroupTransparentForSorting(it.title)
))
if (groupTransparentForSorting) {
// got a group transparent for sorting
groupTransparentForSorting.title = groupNameForPath(groupTransparentForSorting.title||'')
} else if (!bookmarksContainer.items.find((it) =>
((it.type === 'folder' || it.type === 'file') && it.path === aSibling.path) || ((it.type === 'folder' || it.type === 'file') && it.path === aSibling.path) ||
(it.type === 'group' && it.title === siblingName))) { (it.type === 'group' && it.title === siblingName))) {
const newEntry: BookmarkedItem = (aSibling instanceof TFolder) ? createBookmarkGroupEntry(siblingName) : createBookmarkFileEntry(aSibling.path); const newEntry: BookmarkedItem = (aSibling instanceof TFolder) ? createBookmarkGroupEntry(siblingName) : createBookmarkFileEntry(aSibling.path);
@ -152,6 +188,46 @@ class BookmarksPluginWrapper implements BookmarksPluginInterface {
} }
} }
unbookmarkSiblings = (siblings: Array<TAbstractFile>) => {
if (siblings.length === 0) return // for sanity
const bookmarksContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(
siblings[0].path,
DontCreateIfMissing,
this.plugin!,
this.groupNameForSorting
)
if (bookmarksContainer) { // for sanity
const bookmarkedItemsToRemove: Array<BookmarkedItem> = []
siblings.forEach((aSibling) => {
const siblingName = lastPathComponent(aSibling.path)
const aGroup = bookmarksContainer.items.find(
(it) => (it.type === 'group' && groupNameForPath(it.title||'') === siblingName)
)
if (aGroup) {
const canBeRemoved = bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(aGroup as BookmarkedGroup)
if (canBeRemoved) {
bookmarksContainer.items.remove(aGroup)
cleanupBookmarkTreeFromTransparentEmptyGroups(bookmarksContainer, this.plugin!, this.groupNameForSorting)
} else {
if (!isGroupTransparentForSorting(aGroup.title)) {
aGroup.title = groupNameTransparentForSorting(aGroup.title||'')
}
}
} else {
const aFileOrFolder = bookmarksContainer.items.find(
(it) => ((it.type === 'folder' || it.type === 'file') && it.path === aSibling.path)
)
if (aFileOrFolder) {
bookmarksContainer.items.remove(aFileOrFolder)
cleanupBookmarkTreeFromTransparentEmptyGroups(bookmarksContainer, this.plugin!, this.groupNameForSorting)
}
}
});
}
}
updateSortingBookmarksAfterItemRenamed = (renamedItem: TAbstractFile, oldPath: string): void => { updateSortingBookmarksAfterItemRenamed = (renamedItem: TAbstractFile, oldPath: string): void => {
updateSortingBookmarksAfterItemRenamed(this.plugin!, renamedItem, oldPath, this.groupNameForSorting) updateSortingBookmarksAfterItemRenamed(this.plugin!, renamedItem, oldPath, this.groupNameForSorting)
} }
@ -177,7 +253,6 @@ class BookmarksPluginWrapper implements BookmarksPluginInterface {
} }
bookmarksIncludeItemsInFolder = (folderPath: string): boolean => { bookmarksIncludeItemsInFolder = (folderPath: string): boolean => {
console.error(`C: for ${folderPath} is ${bookmarksFoldersCoverage?.[folderPath]}`)
return !! bookmarksFoldersCoverage?.[folderPath] return !! bookmarksFoldersCoverage?.[folderPath]
} }
} }
@ -190,7 +265,8 @@ export const getBookmarksPlugin = (bookmarksGroupName?: string, forceFlushCache?
if (installedBookmarksPlugin && installedBookmarksPlugin.enabled && installedBookmarksPlugin.instance) { if (installedBookmarksPlugin && installedBookmarksPlugin.enabled && installedBookmarksPlugin.instance) {
const bookmarksPluginInstance: Bookmarks_PluginInstance = installedBookmarksPlugin.instance as Bookmarks_PluginInstance const bookmarksPluginInstance: Bookmarks_PluginInstance = installedBookmarksPlugin.instance as Bookmarks_PluginInstance
// defensive programming, in case Obsidian changes its internal APIs // defensive programming, in case Obsidian changes its internal APIs
if (typeof bookmarksPluginInstance?.[BookmarksPlugin_getBookmarks_methodName] === 'function') { if (typeof bookmarksPluginInstance?.[BookmarksPlugin_getBookmarks_methodName] === 'function' &&
Array.isArray(bookmarksPluginInstance?.[BookmarksPlugin_items_collectionName])) {
bookmarksPlugin.plugin = bookmarksPluginInstance bookmarksPlugin.plugin = bookmarksPluginInstance
bookmarksPlugin.groupNameForSorting = bookmarksGroupName bookmarksPlugin.groupNameForSorting = bookmarksGroupName
if (ensureCachePopulated && !bookmarksCache) { if (ensureCachePopulated && !bookmarksCache) {
@ -231,11 +307,16 @@ const invalidateExpiredBookmarksCache = (force?: boolean): void => {
type TraverseCallback = (item: BookmarkedItem, parentsGroupsPath: string) => boolean | void type TraverseCallback = (item: BookmarkedItem, parentsGroupsPath: string) => boolean | void
const traverseBookmarksCollection = (items: Array<BookmarkedItem>, callbackConsumeItem: TraverseCallback) => { const traverseBookmarksCollection = (items: Array<BookmarkedItem>, callbackConsumeItem: TraverseCallback) => {
if (!Array.isArray(items)) return
const recursiveTraversal = (collection: Array<BookmarkedItem>, groupsPath: string) => { const recursiveTraversal = (collection: Array<BookmarkedItem>, groupsPath: string) => {
if (!Array.isArray(collection)) return
for (let idx = 0, collectionRef = collection; idx < collectionRef.length; idx++) { for (let idx = 0, collectionRef = collection; idx < collectionRef.length; idx++) {
const item = collectionRef[idx]; const item = collectionRef[idx];
if (callbackConsumeItem(item, groupsPath)) return; if (callbackConsumeItem(item, groupsPath)) return;
if ('group' === item.type) recursiveTraversal(item.items, `${groupsPath}${groupsPath?'/':''}${item.title}`); if ('group' === item.type) {
const groupNameToUseInPath: string = groupNameForPath(item.title || '')
recursiveTraversal(item.items, `${groupsPath}${groupsPath?'/':''}${groupNameToUseInPath}`);
}
} }
}; };
recursiveTraversal(items, ''); recursiveTraversal(items, '');
@ -243,69 +324,94 @@ const traverseBookmarksCollection = (items: Array<BookmarkedItem>, callbackConsu
const ARTIFICIAL_ANCHOR_SORTING_BOOKMARK_INDICATOR = '#^-' const ARTIFICIAL_ANCHOR_SORTING_BOOKMARK_INDICATOR = '#^-'
const bookmarkLocationAndPathOverlap = (bookmarkParentGroupPath: string, fileOrFolderPath: string): number => {
return fileOrFolderPath?.startsWith(bookmarkParentGroupPath) ? bookmarkParentGroupPath.length : 0
}
const ROOT_FOLDER_PATH = '/' const ROOT_FOLDER_PATH = '/'
const TRANSPARENT_FOR_SORTING_PREFIX = '\\\\'
const isGroupTransparentForSorting = (name?: string): boolean => {
return !!name?.startsWith(TRANSPARENT_FOR_SORTING_PREFIX)
}
const groupNameTransparentForSorting = (name: string): string => {
return isGroupTransparentForSorting(name) ? name : `${TRANSPARENT_FOR_SORTING_PREFIX}${name}`
}
export const groupNameForPath = (name: string): string => {
return isGroupTransparentForSorting(name) ? name.substring(TRANSPARENT_FOR_SORTING_PREFIX.length) : name
}
const getOrderedBookmarks = (plugin: Bookmarks_PluginInstance, bookmarksGroupName?: string): [OrderedBookmarks, FoldersCoverage] | [undefined, undefined] => { const getOrderedBookmarks = (plugin: Bookmarks_PluginInstance, bookmarksGroupName?: string): [OrderedBookmarks, FoldersCoverage] | [undefined, undefined] => {
console.log(`getOrderedBookmarks()`)
let bookmarksItems: Array<BookmarkedItem> | undefined = plugin?.[BookmarksPlugin_items_collectionName] let bookmarksItems: Array<BookmarkedItem> | undefined = plugin?.[BookmarksPlugin_items_collectionName]
let bookmarksCoveredFolders: FoldersCoverage = {} let bookmarksCoveredFolders: FoldersCoverage = {}
if (bookmarksItems) { if (bookmarksItems && Array.isArray(bookmarksItems)) {
if (bookmarksGroupName) { if (bookmarksGroupName) {
// scanning only top level items because by desing the bookmarks group for sorting is a top level item // scanning only top level items because by design the bookmarks group for sorting is a top level item
const bookmarksGroup: BookmarkedGroup|undefined = bookmarksItems.find( const bookmarksGroup: BookmarkedGroup|undefined = bookmarksItems.find(
(item) => item.type === 'group' && item.title === bookmarksGroupName (item) => item.type === 'group' && item.title === bookmarksGroupName
) as BookmarkedGroup ) as BookmarkedGroup
bookmarksItems = bookmarksGroup ? bookmarksGroup.items : undefined bookmarksItems = bookmarksGroup ? bookmarksGroup.items : undefined
} }
if (bookmarksItems) { if (bookmarksItems) {
const orderedBookmarks: OrderedBookmarks = {} const orderedBookmarksWithMetadata: OrderedBookmarksWithMetadata = {}
let order: number = 0 let order: number = 1 // Intentionally start > 0 to allow easy check: if (order) ...
const consumeItem = (item: BookmarkedItem, parentGroupsPath: string) => { const consumeItem = (item: BookmarkedItem, parentGroupsPath: string) => {
const isFile: boolean = item.type === 'file' if ('group' === item.type) {
const hasSortspecAnchor: boolean = isFile && (item as BookmarkedFile).subpath === ARTIFICIAL_ANCHOR_SORTING_BOOKMARK_INDICATOR if (!isGroupTransparentForSorting(item.title)) {
const isFolder: boolean = item.type === 'folder' const path: string = `${parentGroupsPath}${parentGroupsPath ? '/' : ''}${item.title}`
const isGroup: boolean = item.type === 'group' const alreadyConsumed = orderedBookmarksWithMetadata[path]
if ((isFile && hasSortspecAnchor) || isFolder || isGroup) { if (alreadyConsumed) {
const pathOfGroup: string = `${parentGroupsPath}${parentGroupsPath?'/':''}${item.title}` if (alreadyConsumed.isGroup) return // Defensive programming
const path = isGroup ? pathOfGroup : (item as BookmarkWithPath).path if (alreadyConsumed.hasSortingIndicator) return
const alreadyConsumed = orderedBookmarks[path]
const parentFolderPathOfBookmarkedItem = isGroup ? parentGroupsPath : extractParentFolderPath(path)
console.log(`Add ${path}`)
bookmarksCoveredFolders[parentFolderPathOfBookmarkedItem.length > 0 ? parentFolderPathOfBookmarkedItem : ROOT_FOLDER_PATH] = true
console.log(bookmarksCoveredFolders)
// for groups (they represent folders from sorting perspective) bookmark them unconditionally
// the idea of better match is not applicable
if (alreadyConsumed && isGroup && alreadyConsumed.group) {
return
} }
// for files and folders (folder can be only manually bookmarked, the plugin uses groups to represent folders) orderedBookmarksWithMetadata[path] = {
// the most closely matching location in bookmarks hierarchy is preferred
let pathOverlapLength: number|undefined
if (alreadyConsumed && (isFile || isFolder)) {
pathOverlapLength = bookmarkLocationAndPathOverlap(parentGroupsPath, path)
if (pathOverlapLength <= alreadyConsumed.bookmarkPathOverlap) {
return
}
}
orderedBookmarks[path] = {
path: path, path: path,
order: order++, order: order++,
file: isFile, isGroup: true
folder: isFile, }
group: isGroup, }
bookmarkPathOverlap: isGroup || (pathOverlapLength ?? bookmarkLocationAndPathOverlap(parentGroupsPath, path)) } else if ('file' === item.type || 'folder' === item.type) {
const itemWithPath = (item as BookmarkWithPath)
const itemFile = 'file' === item.type ? (item as BookmarkedFile) : undefined
const alreadyConsumed = orderedBookmarksWithMetadata[itemWithPath.path]
const hasSortingIndicator: boolean|undefined = itemFile ? itemFile.subpath === ARTIFICIAL_ANCHOR_SORTING_BOOKMARK_INDICATOR : undefined
const parentFolderPathOfBookmarkedItem = extractParentFolderPath(itemWithPath.path)
const bookmarkPathMatches: boolean = parentGroupsPath === parentFolderPathOfBookmarkedItem
const bookmarkPathIsRoot: boolean = !(parentGroupsPath?.length > 0)
// Bookmarks not in root (group) or in matching path are ignored
if (!bookmarkPathMatches && !bookmarkPathIsRoot) return
// For bookmarks in root or in matching path, apply the prioritized duplicate elimination logic
if (alreadyConsumed) {
if (hasSortingIndicator) {
if (alreadyConsumed.hasSortingIndicator && alreadyConsumed.bookmarkPathMatches) return
if (alreadyConsumed.hasSortingIndicator && !bookmarkPathMatches) return
} else { // no sorting indicator on new
if (alreadyConsumed.hasSortingIndicator) return
if (!bookmarkPathMatches || alreadyConsumed.bookmarkPathMatches || alreadyConsumed.isGroup) return
}
}
orderedBookmarksWithMetadata[itemWithPath.path] = {
path: itemWithPath.path,
order: order++,
isGroup: false,
bookmarkPathMatches: bookmarkPathMatches,
hasSortingIndicator: hasSortingIndicator
} }
} }
} }
traverseBookmarksCollection(bookmarksItems, consumeItem) traverseBookmarksCollection(bookmarksItems, consumeItem)
const orderedBookmarks: OrderedBookmarks = {}
for (let path in orderedBookmarksWithMetadata) {
orderedBookmarks[path] = orderedBookmarksWithMetadata[path].order
const parentFolderPath: Path = extractParentFolderPath(path)
bookmarksCoveredFolders[parentFolderPath.length > 0 ? parentFolderPath : ROOT_FOLDER_PATH] = true
}
return [orderedBookmarks, bookmarksCoveredFolders] return [orderedBookmarks, bookmarksCoveredFolders]
} }
} }
@ -322,18 +428,16 @@ const createBookmarkGroupEntry = (title: string): BookmarkedGroup => {
return { type: "group", ctime: Date.now(), items: [], title: title } return { type: "group", ctime: Date.now(), items: [], title: title }
} }
interface BookmarkedParentFolder { export interface BookmarkedParentFolder {
pathOfGroup?: Path // undefined when the container is the root of bookmarks
group?: BookmarkedGroup // undefined when the item is at root level of bookmarks 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 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 => { const findGroupForItemPathInBookmarks = (itemPath: string, createIfMissing: boolean, plugin: Bookmarks_PluginInstance, bookmarksGroup?: string): BookmarkedParentFolder|undefined => {
let items = plugin[BookmarksPlugin_items_collectionName] let items = plugin?.[BookmarksPlugin_items_collectionName]
if (!Array.isArray(items)) return undefined
if (!itemPath || !itemPath.trim()) return undefined // for sanity if (!itemPath || !itemPath.trim()) return undefined // for sanity
@ -347,11 +451,13 @@ const findGroupForItemPathInBookmarks = (itemPath: string, createIfMissing: bool
let group: BookmarkedGroup|undefined = undefined let group: BookmarkedGroup|undefined = undefined
parentPathComponents.forEach((pathSegment) => { parentPathComponents.forEach((pathSegment, index) => {
let group: BookmarkedGroup|undefined = items.find((it) => it.type === 'group' && it.title === pathSegment) as BookmarkedGroup group = items.find((it) => it.type === 'group' && groupNameForPath(it.title||'') === pathSegment) as BookmarkedGroup
if (!group) { if (!group) {
if (createIfMissing) { if (createIfMissing) {
group = createBookmarkGroupEntry(pathSegment) const theSortingBookmarksContainerGroup = (bookmarksGroup && index === 0)
const groupName: string = theSortingBookmarksContainerGroup ? pathSegment : groupNameTransparentForSorting(pathSegment)
group = createBookmarkGroupEntry(groupName)
items.push(group) items.push(group)
} else { } else {
return undefined return undefined
@ -363,19 +469,50 @@ const findGroupForItemPathInBookmarks = (itemPath: string, createIfMissing: bool
return { return {
items: items, items: items,
group: group group: group,
pathOfGroup: parentPath
} }
} }
const CreateIfMissing = true const CreateIfMissing = true
const DontCreateIfMissing = false const DontCreateIfMissing = false
const renameGroup = (group: BookmarkedGroup, newName: string, makeTransparentForSorting: boolean|undefined) => {
if (makeTransparentForSorting === true) {
group.title = groupNameTransparentForSorting(newName)
} else if (makeTransparentForSorting === false) {
group.title = newName
} else { // no transparency status, retain the status as-is
group.title = isGroupTransparentForSorting(group.title) ? groupNameTransparentForSorting(newName) : newName
}
}
const cleanupBookmarkTreeFromTransparentEmptyGroups = (parentGroup: BookmarkedParentFolder|undefined, plugin: Bookmarks_PluginInstance, bookmarksGroup?: string) => {
if (!parentGroup) return // invalid invocation - exit
if (!parentGroup.group) return // root folder of the bookmarks - do not touch items in root folder
if (checkSubtreeForOnlyTransparentGroups(parentGroup.items)) {
parentGroup.group.items = []
const parentContainerOfGroup = findGroupForItemPathInBookmarks(
parentGroup.pathOfGroup || '',
DontCreateIfMissing,
plugin,
bookmarksGroup
)
if (parentContainerOfGroup) {
parentContainerOfGroup.group?.items?.remove(parentGroup.group)
cleanupBookmarkTreeFromTransparentEmptyGroups(parentContainerOfGroup, plugin, bookmarksGroup)
}
}
}
const updateSortingBookmarksAfterItemRenamed = (plugin: Bookmarks_PluginInstance, renamedItem: TAbstractFile, oldPath: string, bookmarksGroup?: string) => { const updateSortingBookmarksAfterItemRenamed = (plugin: Bookmarks_PluginInstance, renamedItem: TAbstractFile, oldPath: string, bookmarksGroup?: string) => {
if (renamedItem.path === oldPath) return; // sanity if (renamedItem.path === oldPath) return; // sanity
const aFolder: boolean = renamedItem instanceof TFolder const aFolder: boolean = renamedItem instanceof TFolder
const aFolderWithChildren: boolean = aFolder && (renamedItem as TFolder).children.length > 0
const aFile: boolean = !aFolder const aFile: boolean = !aFolder
const oldParentPath: string = extractParentFolderPath(oldPath) const oldParentPath: string = extractParentFolderPath(oldPath)
const oldName: string = lastPathComponent(oldPath) const oldName: string = lastPathComponent(oldPath)
@ -384,35 +521,58 @@ const updateSortingBookmarksAfterItemRenamed = (plugin: Bookmarks_PluginInstance
const moved: boolean = oldParentPath !== newParentPath const moved: boolean = oldParentPath !== newParentPath
const renamed: boolean = oldName !== newName const renamed: boolean = oldName !== newName
// file renames are handled automatically by Obsidian in bookmarks, no need for additional actions
if (aFile && renamed) return
const originalContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(oldPath, DontCreateIfMissing, plugin, bookmarksGroup) const originalContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(oldPath, DontCreateIfMissing, plugin, bookmarksGroup)
if (!originalContainer) return; if (!originalContainer) return;
const item: BookmarkedItem|undefined = originalContainer.items.find((it) => { const item: BookmarkedItem|undefined = aFolder ?
if (aFolder && it.type === 'group' && it.title === oldName) return true; originalContainer.items.find((it) => (
if (aFile && it.type === 'file' && it.path === oldPath) return true; it.type === 'group' && groupNameForPath(it.title||'') === oldName
}) ))
: // aFile
originalContainer.items.find((it) => (
it.type === 'file' && it.path === renamedItem.path
))
if (!item) return; if (!item) return;
// The renamed/moved item was located in bookmarks, apply the necessary bookmarks updates // The renamed/moved item was located in bookmarks, actions depend on item type
if (aFile) {
if (moved) { // sanity
originalContainer.group?.items.remove(item)
cleanupBookmarkTreeFromTransparentEmptyGroups(originalContainer, plugin, bookmarksGroup)
}
} else { // a group
const aGroup: BookmarkedGroup = item as BookmarkedGroup
let itemRemovedFromBookmarks: boolean = false if (bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(aGroup)) {
if (moved) { // sanity
originalContainer.group?.items.remove(aGroup)
cleanupBookmarkTreeFromTransparentEmptyGroups(originalContainer, plugin, bookmarksGroup)
} else if (renamed) {
renameGroup(aGroup, newName, undefined)
}
} else { // group has some descendants not transparent for sorting
if (moved) { if (moved) {
originalContainer.items.remove(item) originalContainer.group?.items.remove(aGroup)
const createTargetLocation: boolean = aFolderWithChildren const targetContainer: BookmarkedParentFolder | undefined = findGroupForItemPathInBookmarks(renamedItem.path, CreateIfMissing, plugin, bookmarksGroup)
const targetContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(renamedItem.path, createTargetLocation, plugin, bookmarksGroup)
if (targetContainer) { if (targetContainer) {
targetContainer.items.push(item) targetContainer.group?.items.push(aGroup)
} else { // the group in new location becomes by design transparent for sorting.
itemRemovedFromBookmarks = true // open question: remove from bookmarks indeed, if target location was not under bookmarks control? // The sorting order is a property of the parent folder, not the item itself
renameGroup(aGroup, groupNameForPath(aGroup.title||''), true)
} }
cleanupBookmarkTreeFromTransparentEmptyGroups(originalContainer, plugin, bookmarksGroup)
} }
if (aFolder && renamed && !itemRemovedFromBookmarks) { if (renamed) {
// Renames of files are handled automatically by Bookmarks core plugin, only need to handle folder rename // unrealistic scenario when a folder is moved and renamed at the same time
// because folders are represented (for sorting purposes) by groups with exact name renameGroup(aGroup, newName, undefined)
(item as BookmarkedGroup).title = newName }
}
} }
} }
@ -422,19 +582,42 @@ const updateSortingBookmarksAfterItemDeleted = (plugin: Bookmarks_PluginInstance
if (deletedItem instanceof TFile) return; if (deletedItem instanceof TFile) return;
let items = plugin[BookmarksPlugin_items_collectionName] let items = plugin[BookmarksPlugin_items_collectionName]
if (!Array.isArray(items)) return
const aFolder: boolean = deletedItem instanceof TFolder const aFolder: boolean = deletedItem instanceof TFolder
const aFile: boolean = !aFolder const aFile: boolean = !aFolder
const originalContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(deletedItem.path, DontCreateIfMissing, plugin, bookmarksGroup) // Delete all instances of deleted item from two handled locations:
// - in bookmark groups hierarchy matching the item path in file explorer
// - in the bookmark group designated as container for bookmarks (immediate children)
const bookmarksContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(deletedItem.path, DontCreateIfMissing, plugin, bookmarksGroup)
const itemInRootFolder = !!extractParentFolderPath(deletedItem.path)
const bookmarksRootContainer: BookmarkedParentFolder|undefined =
(bookmarksGroup && !itemInRootFolder) ? findGroupForItemPathInBookmarks('intentionally-in-root-path', DontCreateIfMissing, plugin, bookmarksGroup) : undefined
if (!originalContainer) return; if (!bookmarksContainer && !bookmarksRootContainer) return;
const item: BookmarkedItem|undefined = originalContainer.items.find((it) => { [bookmarksContainer, bookmarksRootContainer].forEach((container) => {
if (aFolder && it.type === 'group' && it.title === deletedItem.name) return true; const bookmarkEntriesToRemove: Array<BookmarkedItem> = []
if (aFile && it.type === 'file' && it.path === deletedItem.path) return true; container?.items.forEach((it) => {
if (aFolder && it.type === 'group' && groupNameForPath(it.title||'') === deletedItem.name) {
bookmarkEntriesToRemove.push(it)
}
if (aFile && it.type === 'file' && it.path === deletedItem.path) {
bookmarkEntriesToRemove.push(it)
}
})
bookmarkEntriesToRemove.forEach((itemToRemove) =>{
container?.group?.items.remove(itemToRemove)
})
cleanupBookmarkTreeFromTransparentEmptyGroups(container, plugin, bookmarksGroup)
}) })
}
if (!item) return;
export const _unitTests = {
originalContainer.items.remove(item) getOrderedBookmarks: getOrderedBookmarks,
bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants: bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants,
cleanupBookmarkTreeFromTransparentEmptyGroups: cleanupBookmarkTreeFromTransparentEmptyGroups,
findGroupForItemPathInBookmarks: findGroupForItemPathInBookmarks
} }

View File

@ -763,6 +763,13 @@
dependencies: dependencies:
"@types/tern" "*" "@types/tern" "*"
"@types/codemirror@5.60.8":
version "5.60.8"
resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.8.tgz#b647d04b470e8e1836dd84b2879988fc55c9de68"
integrity sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==
dependencies:
"@types/tern" "*"
"@types/estree@*": "@types/estree@*":
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2"
@ -2396,6 +2403,14 @@ npm-run-path@^4.0.1:
dependencies: dependencies:
path-key "^3.0.0" path-key "^3.0.0"
"obsidian-1.4.11@npm:obsidian@1.4.11":
version "1.4.11"
resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-1.4.11.tgz#5cba594c83a74ebad58b630c610265018abdadaa"
integrity sha512-BCVYTvaXxElJMl6MMbDdY/CGK+aq18SdtDY/7vH8v6BxCBQ6KF4kKxL0vG9UZ0o5qh139KpUoJHNm+6O5dllKA==
dependencies:
"@types/codemirror" "5.60.8"
moment "2.29.4"
obsidian@^0.15.4: obsidian@^0.15.4:
version "0.15.9" version "0.15.9"
resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-0.15.9.tgz#b6e0b566952643db6b55f7e74fbba6d7140fe4a2" resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-0.15.9.tgz#b6e0b566952643db6b55f7e74fbba6d7140fe4a2"