From 56348006ce530bfe4c48a537615c7efd2056be12 Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Fri, 7 Apr 2023 18:40:35 +0200 Subject: [PATCH] #74 - Integration with Bookmarks core plugin and support for indirect drag & drop arrangement - feature code complete - not reviewed - not tested - no unit tests coverage --- src/custom-sort/custom-sort-types.ts | 3 + src/custom-sort/custom-sort.spec.ts | 95 +++++++++++- src/custom-sort/custom-sort.ts | 123 ++++++++++++--- .../sorting-spec-processor.spec.ts | 38 +++++ src/custom-sort/sorting-spec-processor.ts | 10 ++ src/main.ts | 22 ++- src/utils/BookmarksCorePluginSignature.ts | 145 ++++++++++++++++++ 7 files changed, 407 insertions(+), 29 deletions(-) create mode 100644 src/utils/BookmarksCorePluginSignature.ts diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 9a88b7f..efbeb01 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -9,6 +9,7 @@ export enum CustomSortGroupType { ExactHeadAndTail, // Like W...n or Un...ed, which is shorter variant of typing the entire title HasMetadataField, // Notes (or folder's notes) containing a specific metadata field StarredOnly, + BookmarkedOnly, HasIcon } @@ -30,6 +31,8 @@ export enum CustomSortOrder { byMetadataFieldAlphabeticalReverse, byMetadataFieldTrueAlphabeticalReverse, standardObsidian, // Let the folder sorting be in hands of Obsidian, whatever user selected in the UI + byBookmarkOrder, + byBookmarkOrderReverse, default = alphabetical } diff --git a/src/custom-sort/custom-sort.spec.ts b/src/custom-sort/custom-sort.spec.ts index ed0677a..b558fc7 100644 --- a/src/custom-sort/custom-sort.spec.ts +++ b/src/custom-sort/custom-sort.spec.ts @@ -5,7 +5,7 @@ import { determineFolderDatesIfNeeded, determineSortingGroup, FolderItemForSorting, - matchGroupRegex, + matchGroupRegex, sorterByBookmarkOrder, sorterByMetadataField, SorterFn, Sorters } from './custom-sort'; @@ -16,6 +16,7 @@ import { ObsidianIconFolder_PluginInstance, ObsidianIconFolderPlugin_Data } from "../utils/ObsidianIconFolderPluginSignature"; +import {determineBookmarkOrder} from "../utils/BookmarksCorePluginSignature"; const mockTFile = (basename: string, ext: string, size?: number, ctime?: number, mtime?: number): TFile => { return { @@ -2216,7 +2217,7 @@ describe('CustomSortOrder.byMetadataFieldAlphabeticalReverse', () => { const itemB: Partial = { sortString: 'n123' } - const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabeticalReverse] + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabetical] // when const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) @@ -2226,6 +2227,25 @@ describe('CustomSortOrder.byMetadataFieldAlphabeticalReverse', () => { expect(result1).toBe(SORT_FIRST_GOES_EARLIER) expect(result2).toBe(SORT_FIRST_GOES_LATER) }) + it('should put the item with metadata later if the second one has no metadata (reverse order)', () => { + // given + const itemA: Partial = { + metadataFieldValue: '15', + sortString: 'n123' + } + const itemB: Partial = { + sortString: 'n123' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabeticalReverse] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_LATER) + expect(result2).toBe(SORT_FIRST_GOES_EARLIER) + }) it('should correctly fallback to alphabetical reverse if no metadata on both items', () => { // given const itemA: Partial = { @@ -2247,3 +2267,74 @@ describe('CustomSortOrder.byMetadataFieldAlphabeticalReverse', () => { expect(result3).toBe(SORT_ITEMS_ARE_EQUAL) }) }) + +describe('sorterByMetadataField', () => { + it.each([ + [true,'abc','def',-1, 'a', 'a'], + [true,'xyz','klm',1, 'b', 'b'], + [true,'mmm','mmm',0, 'c', 'c'], + [true,'mmm','mmm',-1, 'd', 'e'], + [true,'mmm','mmm',1, 'e', 'd'], + [true,'abc',undefined,-1, 'a','a'], + [true,undefined,'klm',1, 'b','b'], + [true,undefined,undefined,0, 'a','a'], + [true,undefined,undefined,-1, 'a','b'], + [true,undefined,undefined,1, 'd','c'], + [false,'abc','def',1, 'a', 'a'], + [false,'xyz','klm',-1, 'b', 'b'], + [false,'mmm','mmm',0, 'c', 'c'], + [false,'mmm','mmm',1, 'd', 'e'], + [false,'mmm','mmm',-1, 'e', 'd'], + [false,'abc',undefined,1, 'a','a'], + [false,undefined,'klm',-1, 'b','b'], + [false,undefined,undefined,0, 'a','a'], + [false,undefined,undefined,1, 'a','b'], + [false,undefined,undefined,-1, 'd','c'], + + ])('straight order %s, comparing %s and %s should return %s for sortStrings %s and %s', + (straight: boolean, metadataA: string|undefined, metadataB: string|undefined, order: number, sortStringA: string, sortStringB) => { + const sorterFn = sorterByMetadataField(!straight, false) + const itemA: Partial = {metadataFieldValue: metadataA, sortString: sortStringA} + const itemB: Partial = {metadataFieldValue: metadataB, sortString: sortStringB} + const result = sorterFn(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + + // then + expect(result).toBe(order) + }) +}) + +describe('sorterByBookmarkOrder', () => { + it.each([ + [true,10,20,-1, 'a', 'a'], + [true,20,10,1, 'b', 'b'], + [true,30,30,0, 'c', 'c'], // not possible in reality - each bookmark order is unique by definition - covered for clarity + [true,1,1,0, 'd', 'e'], // ----//---- + [true,2,2,0, 'e', 'd'], // ----//---- + [true,3,undefined,-1, 'a','a'], + [true,undefined,4,1, 'b','b'], + [true,undefined,undefined,0, 'a','a'], + [true,undefined,undefined,-1, 'a','b'], + [true,undefined,undefined,1, 'd','c'], + [false,10,20,1, 'a', 'a'], + [false,20,10,-1, 'b', 'b'], + [false,30,30,0, 'c', 'c'], // not possible in reality - each bookmark order is unique by definition - covered for clarity + [false,1,1,0, 'd', 'e'], // ------//----- + [false,2,2,0, 'e', 'd'], // ------//----- + [false,3,undefined,1, 'a','a'], + [false,undefined,4,-1, 'b','b'], + [false,undefined,undefined,0, 'a','a'], + [false,undefined,undefined,1, 'a','b'], + [false,undefined,undefined,-1, 'd','c'], + + ])('straight order %s, comparing %s and %s should return %s for sortStrings %s and %s', + (straight: boolean, bookmarkA: number|undefined, bookmarkB: number|undefined, order: number, sortStringA: string, sortStringB) => { + const sorterFn = sorterByBookmarkOrder(!straight, false) + const itemA: Partial = {bookmarkedIdx: bookmarkA, sortString: sortStringA} + const itemB: Partial = {bookmarkedIdx: bookmarkB, sortString: sortStringB} + const result = sorterFn(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const normalizedResult = result < 0 ? -1 : ((result > 0) ? 1 : result) + + // then + expect(normalizedResult).toBe(order) + }) +}) diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index a7f7354..378528d 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -1,26 +1,9 @@ -import { - App, - CommunityPlugin, - FrontMatterCache, - InstalledPlugin, - requireApiVersion, - TAbstractFile, - TFile, - TFolder -} from 'obsidian'; -import { - determineStarredStatusOf, - getStarredPlugin, - Starred_PluginInstance, - StarredPlugin_findStarredFile_methodName -} from '../utils/StarredPluginSignature'; +import {FrontMatterCache, requireApiVersion, TAbstractFile, TFile, TFolder} from 'obsidian'; +import {determineStarredStatusOf, getStarredPlugin, Starred_PluginInstance} from '../utils/StarredPluginSignature'; import { determineIconOf, getIconFolderPlugin, - FolderIconObject, - ObsidianIconFolder_PluginInstance, - ObsidianIconFolderPlugin_Data, - ObsidianIconFolderPlugin_getData_methodName + ObsidianIconFolder_PluginInstance } from '../utils/ObsidianIconFolderPluginSignature' import { CustomSortGroup, @@ -32,6 +15,11 @@ import { RegExpSpec } from "./custom-sort-types"; import {isDefined} from "../utils/utils"; +import { + Bookmarks_PluginInstance, + determineBookmarkOrder, + getBookmarksPlugin +} from "../utils/BookmarksCorePluginSignature"; let CollatorCompare = new Intl.Collator(undefined, { usage: "sort", @@ -45,6 +33,21 @@ let CollatorTrueAlphabeticalCompare = new Intl.Collator(undefined, { numeric: false, }).compare; + +export const SORTSPEC_FOR_AUTOMATIC_BOOKMARKS_INTEGRATION: CustomSortSpec = { + defaultOrder: CustomSortOrder.byBookmarkOrder, + groups: [ + { + order: CustomSortOrder.byBookmarkOrder, + type: CustomSortGroupType.Outsiders + } + ], + outsidersGroupIdx: 0, + targetFoldersPaths: [ + "Spec applied automatically to folder not having explicit spec when automatic integration with bookmarks is enabled" + ] +} + export interface FolderItemForSorting { path: string groupIdx?: number // the index itself represents order for groups @@ -55,6 +58,7 @@ export interface FolderItemForSorting { mtime: number // for a file mtime is obvious, for a folder = date of most recently modified child file isFolder: boolean folder?: TFolder + bookmarkedIdx?: number // derived from Bookmarks core plugin position } export type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number @@ -65,7 +69,7 @@ const TrueAlphabetical: boolean = true const ReverseOrder: boolean = true const StraightOrder: boolean = false -const sorterByMetadataField:(reverseOrder?: boolean, trueAlphabetical?: boolean) => SorterFn = (reverseOrder: boolean, trueAlphabetical?: boolean) => { +export const sorterByMetadataField:(reverseOrder?: boolean, trueAlphabetical?: boolean) => SorterFn = (reverseOrder: boolean, trueAlphabetical?: boolean) => { const collatorCompareFn: CollatorCompareFn = trueAlphabetical ? CollatorTrueAlphabeticalCompare : CollatorCompare return (a: FolderItemForSorting, b: FolderItemForSorting) => { if (reverseOrder) { @@ -81,13 +85,31 @@ const sorterByMetadataField:(reverseOrder?: boolean, trueAlphabetical?: boolean) } } // Item with metadata goes before the w/o metadata - if (a.metadataFieldValue) return reverseOrder ? 1 : -1 - if (b.metadataFieldValue) return reverseOrder ? -1 : 1 + if (a.metadataFieldValue) return -1 + if (b.metadataFieldValue) return 1 // Fallback -> requested sort by metadata, yet none of two items contain it, use alphabetical by name return collatorCompareFn(a.sortString, b.sortString) } } +export const sorterByBookmarkOrder:(reverseOrder?: boolean, trueAlphabetical?: boolean) => SorterFn = (reverseOrder: boolean, trueAlphabetical?: boolean) => { + const collatorCompareFn: CollatorCompareFn = trueAlphabetical ? CollatorTrueAlphabeticalCompare : CollatorCompare + return (a: FolderItemForSorting, b: FolderItemForSorting) => { + if (reverseOrder) { + [a, b] = [b, a] + } + if (a.bookmarkedIdx && b.bookmarkedIdx) { + // By design the bookmark idx is unique per each item, so no need for secondary sorting if they are equal + return a.bookmarkedIdx - b.bookmarkedIdx + } + // Item with bookmark order goes before the w/o bookmark info + if (a.bookmarkedIdx) return -1 + if (b.bookmarkedIdx) return 1 + // Fallback -> requested sort by bookmark order, yet none of two items contain it, use alphabetical by name + return collatorCompareFn(a.sortString, b.sortString) + } +} + export let Sorters: { [key in CustomSortOrder]: SorterFn } = { [CustomSortOrder.alphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(a.sortString, b.sortString), [CustomSortOrder.trueAlphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorTrueAlphabeticalCompare(a.sortString, b.sortString), @@ -105,6 +127,8 @@ export let Sorters: { [key in CustomSortOrder]: SorterFn } = { [CustomSortOrder.byMetadataFieldTrueAlphabetical]: sorterByMetadataField(StraightOrder, TrueAlphabetical), [CustomSortOrder.byMetadataFieldAlphabeticalReverse]: sorterByMetadataField(ReverseOrder), [CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, TrueAlphabetical), + [CustomSortOrder.byBookmarkOrder]: sorterByBookmarkOrder(StraightOrder), + [CustomSortOrder.byBookmarkOrderReverse]: sorterByBookmarkOrder(ReverseOrder), // This is a fallback entry which should not be used - the plugin code should refrain from custom sorting at all [CustomSortOrder.standardObsidian]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(a.sortString, b.sortString), @@ -164,6 +188,7 @@ export const matchGroupRegex = (theRegex: RegExpSpec, nameForMatching: string): export interface Context { starredPluginInstance?: Starred_PluginInstance + bookmarksPluginInstance?: Bookmarks_PluginInstance iconFolderPluginInstance?: ObsidianIconFolder_PluginInstance } @@ -171,6 +196,7 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus let groupIdx: number let determined: boolean = false let matchedGroup: string | null | undefined + let bookmarkedIdx: number | undefined let metadataValueToSortBy: string | undefined const aFolder: boolean = isFolder(entry) const aFile: boolean = !aFolder @@ -264,12 +290,20 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus break case CustomSortGroupType.StarredOnly: if (ctx?.starredPluginInstance) { - let starred: boolean = determineStarredStatusOf(entry, aFile, ctx.starredPluginInstance) + const starred: boolean = determineStarredStatusOf(entry, aFile, ctx.starredPluginInstance) if (starred) { determined = true } } break + case CustomSortGroupType.BookmarkedOnly: + if (ctx?.bookmarksPluginInstance) { + const bookmarkOrder: number | undefined = determineBookmarkOrder(entry.path, ctx.bookmarksPluginInstance) + if (bookmarkOrder) { // safe ==> orders intentionally start from 1 + determined = true + bookmarkedIdx = bookmarkOrder + } + } case CustomSortGroupType.HasIcon: if(ctx?.iconFolderPluginInstance) { let iconName: string | undefined = determineIconOf(entry, ctx.iconFolderPluginInstance) @@ -363,7 +397,8 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus folder: aFolder ? (entry as TFolder) : undefined, path: entry.path, ctime: aFile ? entryAsTFile.stat.ctime : DEFAULT_FOLDER_CTIME, - mtime: aFile ? entryAsTFile.stat.mtime : DEFAULT_FOLDER_MTIME + mtime: aFile ? entryAsTFile.stat.mtime : DEFAULT_FOLDER_MTIME, + bookmarkedIdx: bookmarkedIdx } } @@ -380,6 +415,17 @@ export const sortOrderNeedsFolderDates = (order: CustomSortOrder | undefined, se || SortOrderRequiringFolderDate.has(secondary ?? CustomSortOrder.standardObsidian) } +const SortOrderRequiringBookmarksOrder = new Set([ + CustomSortOrder.byBookmarkOrder, + CustomSortOrder.byBookmarkOrderReverse +]) + +export const sortOrderNeedsBookmarksOrder = (order: CustomSortOrder | undefined, secondary?: CustomSortOrder): boolean => { + // The CustomSortOrder.standardObsidian used as default because it doesn't require bookmarks order + return SortOrderRequiringBookmarksOrder.has(order ?? CustomSortOrder.standardObsidian) + || SortOrderRequiringBookmarksOrder.has(secondary ?? CustomSortOrder.standardObsidian) +} + // Syntax sugar for readability export type ModifiedTime = number export type CreatedTime = number @@ -422,10 +468,32 @@ export const determineFolderDatesIfNeeded = (folderItems: Array, sortingSpec: CustomSortSpec, plugin: Bookmarks_PluginInstance) => { + if (!plugin) return + + folderItems.forEach((item) => { + const folderDefaultSortRequiresBookmarksOrder: boolean = !!(sortingSpec.defaultOrder && sortOrderNeedsBookmarksOrder(sortingSpec.defaultOrder)) + let groupSortRequiresBookmarksOrder: boolean = false + if (!folderDefaultSortRequiresBookmarksOrder) { + const groupIdx: number | undefined = item.groupIdx + if (groupIdx !== undefined) { + const groupOrder: CustomSortOrder | undefined = sortingSpec.groups[groupIdx].order + groupSortRequiresBookmarksOrder = sortOrderNeedsBookmarksOrder(groupOrder) + } + } + if (folderDefaultSortRequiresBookmarksOrder || groupSortRequiresBookmarksOrder) { + item.bookmarkedIdx = determineBookmarkOrder(item.path, plugin) + } + }) +} + export const folderSort = function (sortingSpec: CustomSortSpec, order: string[]) { let fileExplorer = this.fileExplorer sortingSpec._mCache = sortingSpec.plugin?.app.metadataCache const starredPluginInstance: Starred_PluginInstance | undefined = getStarredPlugin(sortingSpec?.plugin?.app) + const bookmarksPluginInstance: Bookmarks_PluginInstance | undefined = getBookmarksPlugin(sortingSpec?.plugin?.app) const iconFolderPluginInstance: ObsidianIconFolder_PluginInstance | undefined = getIconFolderPlugin(sortingSpec?.plugin?.app) const folderItems: Array = (sortingSpec.itemsToHide ? @@ -437,6 +505,7 @@ export const folderSort = function (sortingSpec: CustomSortSpec, order: string[] .map((entry: TFile | TFolder) => { const itemForSorting: FolderItemForSorting = determineSortingGroup(entry, sortingSpec, { starredPluginInstance: starredPluginInstance, + bookmarksPluginInstance: bookmarksPluginInstance, iconFolderPluginInstance: iconFolderPluginInstance }) return itemForSorting @@ -445,6 +514,10 @@ export const folderSort = function (sortingSpec: CustomSortSpec, order: string[] // Finally, for advanced sorting by modified date, for some folders the modified date has to be determined determineFolderDatesIfNeeded(folderItems, sortingSpec) + if (bookmarksPluginInstance) { + determineBookmarksOrderIfNeeded(folderItems, sortingSpec, bookmarksPluginInstance) + } + folderItems.sort(function (itA: FolderItemForSorting, itB: FolderItemForSorting) { return compareTwoItems(itA, itB, sortingSpec); }); diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index f774270..cbfcc8b 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -37,6 +37,13 @@ starred: /:files starred: /folders starred: +:::: folder of bookmarks +< by-bookmarks-order +/: bookmarked: + < by-bookmarks-order +/ Abc + > by-bookmarks-order + :::: Conceptual model /: Entities % @@ -95,6 +102,13 @@ target-folder: tricky folder 2 /:files starred: /folders starred: +target-folder: folder of bookmarks +order-asc: by-bookmarks-order +/:files bookmarked: + order-asc: by-bookmarks-order +/folders Abc + order-desc: by-bookmarks-order + :::: Conceptual model /:files Entities % @@ -205,6 +219,30 @@ const expectedSortSpecsExampleA: { [key: string]: CustomSortSpec } = { 'tricky folder 2' ] }, + "folder of bookmarks": { + defaultOrder: CustomSortOrder.byBookmarkOrder, + groups: [ + { + filesOnly: true, + order: CustomSortOrder.byBookmarkOrder, + type: CustomSortGroupType.BookmarkedOnly + }, + { + exactText: "Abc", + foldersOnly: true, + order: CustomSortOrder.byBookmarkOrderReverse, + type: CustomSortGroupType.ExactName + }, + { + order: CustomSortOrder.byBookmarkOrder, + type: CustomSortGroupType.Outsiders + } + ], + outsidersGroupIdx: 2, + targetFoldersPaths: [ + "folder of bookmarks" + ] + }, "Conceptual model": { groups: [{ exactText: "Entities", diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 937f5ba..d323b43 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -114,6 +114,7 @@ const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = { 'modified': {asc: CustomSortOrder.byModifiedTime, desc: CustomSortOrder.byModifiedTimeReverse}, 'advanced modified': {asc: CustomSortOrder.byModifiedTimeAdvanced, desc: CustomSortOrder.byModifiedTimeReverseAdvanced}, 'advanced created': {asc: CustomSortOrder.byCreatedTimeAdvanced, desc: CustomSortOrder.byCreatedTimeReverseAdvanced}, + 'by-bookmarks-order': {asc: CustomSortOrder.byBookmarkOrder, desc: CustomSortOrder.byBookmarkOrderReverse}, // Advanced, for edge cases of secondary sorting, when if regexp match is the same, override the alphabetical sorting by full name 'a-z, created': { @@ -207,6 +208,8 @@ const HideItemVerboseLexeme: string = '/--hide:' const MetadataFieldIndicatorLexeme: string = 'with-metadata:' +const BookmarkedItemIndicatorLexeme: string = 'bookmarked:' + const StarredItemsIndicatorLexeme: string = 'starred:' const IconIndicatorLexeme: string = 'with-icon:' @@ -1514,6 +1517,13 @@ export class SortingSpecProcessor { foldersOnly: spec.foldersOnly, matchFilenameWithExt: spec.matchFilenameWithExt } + } else if (theOnly.startsWith(BookmarkedItemIndicatorLexeme)) { + return { + type: CustomSortGroupType.BookmarkedOnly, + filesOnly: spec.filesOnly, + foldersOnly: spec.foldersOnly, + matchFilenameWithExt: spec.matchFilenameWithExt + } } else if (theOnly.startsWith(IconIndicatorLexeme)) { const iconName: string | undefined = extractIdentifier(theOnly.substring(IconIndicatorLexeme.length)) return { diff --git a/src/main.ts b/src/main.ts index 093d27a..d370284 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,7 @@ import { Vault } from 'obsidian'; import {around} from 'monkey-around'; -import {folderSort} from './custom-sort/custom-sort'; +import {folderSort, SORTSPEC_FOR_AUTOMATIC_BOOKMARKS_INTEGRATION} from './custom-sort/custom-sort'; import {SortingSpecProcessor, SortSpecsCollection} from './custom-sort/sorting-spec-processor'; import {CustomSortOrder, CustomSortSpec} from './custom-sort/custom-sort-types'; @@ -36,6 +36,7 @@ interface CustomSortPluginSettings { statusBarEntryEnabled: boolean notificationsEnabled: boolean mobileNotificationsEnabled: boolean + enableAutomaticBookmarksOrderIntegration: boolean } const DEFAULT_SETTINGS: CustomSortPluginSettings = { @@ -43,7 +44,8 @@ const DEFAULT_SETTINGS: CustomSortPluginSettings = { suspended: true, // if false by default, it would be hard to handle the auto-parse after plugin install statusBarEntryEnabled: true, notificationsEnabled: true, - mobileNotificationsEnabled: false + mobileNotificationsEnabled: false, + enableAutomaticBookmarksOrderIntegration: false } const SORTSPEC_FILE_NAME: string = 'sortspec.md' @@ -347,6 +349,9 @@ export default class CustomSortPlugin extends Plugin { sortSpec = null // A folder is explicitly excluded from custom sorting plugin } } + if (!sortSpec && plugin.settings.enableAutomaticBookmarksOrderIntegration) { + sortSpec = SORTSPEC_FOR_AUTOMATIC_BOOKMARKS_INTEGRATION + } if (sortSpec) { sortSpec.plugin = plugin return folderSort.call(this, sortSpec, ...args); @@ -476,5 +481,18 @@ class CustomSortSettingTab extends PluginSettingTab { this.plugin.settings.mobileNotificationsEnabled = value; await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName('Enable automatic integration with core Bookmarks plugin') + // TODO: add a nice description here + .setDesc('Details TBD. TODO: add a nice description here') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableAutomaticBookmarksOrderIntegration) + .onChange(async (value) => { + this.plugin.settings.enableAutomaticBookmarksOrderIntegration = value; + await this.plugin.saveSettings(); + })); + + // TODO: expose additional configuration setting to specify group path in Bookmarks, if auto-integration with bookmarks is enabled } } diff --git a/src/utils/BookmarksCorePluginSignature.ts b/src/utils/BookmarksCorePluginSignature.ts new file mode 100644 index 0000000..e0cd5e0 --- /dev/null +++ b/src/utils/BookmarksCorePluginSignature.ts @@ -0,0 +1,145 @@ +import {App, InstalledPlugin, PluginInstance} from "obsidian"; + +const BookmarksPlugin_getBookmarks_methodName = 'getBookmarks' + +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 + title?: string +} + +interface BookmarkedFolder { + type: 'folder' + path: Path + title?: string +} + +interface BookmarkedGroup { + type: 'group' + items: Array + title?: string +} + +export type BookmarkedItemPath = string + +export interface OrderedBookmarkedItem { + file: boolean + folder: boolean + path: BookmarkedItemPath + order: number +} + +interface OrderedBookmarks { + [key: BookmarkedItemPath]: OrderedBookmarkedItem +} + +export interface Bookmarks_PluginInstance extends PluginInstance { + [BookmarksPlugin_getBookmarks_methodName]: () => Array | undefined +} + +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) => boolean | void + +const traverseBookmarksCollection = (items: Array, callback: TraverseCallback) => { + const recursiveTraversal = (collection: Array) => { + for (let idx = 0, collectionRef = collection; idx < collectionRef.length; idx++) { + const item = collectionRef[idx]; + if (callback(item)) return; + if ('group' === item.type) recursiveTraversal(item.items); + } + }; + recursiveTraversal(items); +} + +// TODO: extend this function to take a scope as parameter: a path to Bookmarks group to start from +// Initially consuming all bookmarks is ok - finally the starting point (group) should be configurable +const getOrderedBookmarks = (plugin: Bookmarks_PluginInstance): OrderedBookmarks | undefined => { + const bookmarks: Array | undefined = plugin?.[BookmarksPlugin_getBookmarks_methodName]() + if (bookmarks) { + const orderedBookmarks: OrderedBookmarks = {} + let order: number = 0 + const consumeItem = (item: BookmarkedItem) => { + const isFile: boolean = item.type === 'file' + const isAnchor: boolean = isFile && !!(item as BookmarkedFile).subpath + const isFolder: boolean = item.type === 'folder' + if ((isFile && !isAnchor) || isFolder) { + const path = (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 + } + } + } + } + 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): number | undefined => { + if (!bookmarksCache) { + bookmarksCache = getOrderedBookmarks(plugin) + bookmarksCacheTimestamp = Date.now() + } + + const bookmarkedItemPosition: number | undefined = bookmarksCache?.[path]?.order + + return (bookmarkedItemPosition !== undefined && bookmarkedItemPosition >= 0) ? (bookmarkedItemPosition + 1) : undefined +}