From fe4f28b46b7fbc9eef098e8c52caf4810eaf798c Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Sat, 21 Oct 2023 23:16:30 +0200 Subject: [PATCH] 74 integration with bookmarks core plugin (#84) * #74 - Integration with Bookmarks core plugin and support for indirect drag & drop arrangement - added new plugin setting to enable auto-integration with bookmarks - full integration with standard sorting at folder level and at sorting group level - refined support for implicit sorting for bookmarks plugin integration - documentation update (partial, sketchy) - context menu for 'bookmark this' and 'bookmark+siblings' for sorting - order of bookmarked siblings reflects the current sorting in File Explorer, whatever it is (!!!) - handler for 'changed' event of the vault to reflect files/folders locations change in the bookmarks automatically, whenever applicable - adjusted behavior of by-bookmark-comparator to adhere to the multi-level sorting support implemented in #89 and merged recently to this branch. Basically, each comparator is responsible only for its own comparison and should no fallback into other default comparisons - instead it should return 0, which indicates item which are equal from the perspective of the comparator - adjusted bookmarks integration to the merged from master multi-level sorting support and its implications - fix suggested by github code check - advanced performance optimizations: bookmarks cache finetuning, switching to Obsidian sort code if the implicit bookmark-integration sorting spec is in effect for a folder and there are no bookmarked items in the folder - update of treatment of whitespace in parent path extraction: spaces allowed as leading/trailing in path elements - increased coverage of the new functionality with unit tests - basic manual tests done - next step: real-life usage tests --- docs/manual.md | 42 + package.json | 1 + .../custom-sort-getComparator.spec.ts | 2 - src/custom-sort/custom-sort-types.ts | 7 +- src/custom-sort/custom-sort-utils.spec.ts | 187 +++ src/custom-sort/custom-sort-utils.ts | 80 ++ src/custom-sort/custom-sort.spec.ts | 56 +- src/custom-sort/custom-sort.ts | 120 +- src/custom-sort/folder-matching-rules.spec.ts | 4 +- src/custom-sort/macros.spec.ts | 10 +- src/custom-sort/matchers.spec.ts | 1 - .../sorting-spec-processor.spec.ts | 49 +- src/custom-sort/sorting-spec-processor.ts | 10 + src/main.ts | 290 ++++- ...ookmarks Core Plugin integration design.md | 55 + .../BookmarksCorePluginSignature.spec.ts | 1065 +++++++++++++++++ src/utils/BookmarksCorePluginSignature.ts | 623 ++++++++++ src/utils/utils.spec.ts | 13 +- src/utils/utils.ts | 4 +- yarn.lock | 15 + 20 files changed, 2593 insertions(+), 41 deletions(-) create mode 100644 src/custom-sort/custom-sort-utils.spec.ts create mode 100644 src/custom-sort/custom-sort-utils.ts create mode 100644 src/utils/Bookmarks Core Plugin integration design.md create mode 100644 src/utils/BookmarksCorePluginSignature.spec.ts create mode 100644 src/utils/BookmarksCorePluginSignature.ts diff --git a/docs/manual.md b/docs/manual.md index 8e0a4fb..a712be5 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -181,6 +181,48 @@ sorting-spec: | The artificial separator `---+---` defines a sorting group, which will not match any folders or files and is used here to logically separate the series of combined groups into to logical sets +## Bookmarks plugin integration + +Integration with the __Bookmarks core plugin__ allows for ordering of items via drag & drop in Bookmarks view and reflecting the same order in File Explorer automatically + +TODO: the simple scenario presented on movie + +A separate group of bookmarks is designated by default, to separate from ... + +If at least one item in folder is bookmarked and the auto-enabled, the order of the items becomes managed by the custom sort plugin + +Auto-integration works without any need for sorting configuration files. Under the hood it is equivalent to applying the following global sorting specification: +```yaml +--- +sorting-spec: | + target-folder: /* + bookmarked: + < by-bookmarks-order + sorting: standard +--- +``` + +Auto-integration doesn't apply to folders, for which explicit sorting specification is defined in YAML. +In that case, if you want to employ the grouping and/or ordering by bookmarks order, you need to use explicit syntax: + +```yaml +--- +sorting-spec: | + target-folder: My folder + bookmarked: + < by-bookmarks-order +--- +``` + +TODO: more instructions plus movie of advanced integration, where bookmarks reflect the folders structure + +Also hints for updating + +A folder is excluded from auto-integration if: +- has custom sorting spec + - you can (if needed) enable the auto-integration for part of the items +- has explicitly applied 'sorting: standard' + ## Matching starred items The Obsidian core plugin `Starred` allows the user to 'star' files\ diff --git a/package.json b/package.json index aee27ae..9655eb6 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "jest": "^28.1.1", "monkey-around": "^2.3.0", "obsidian": "^0.15.4", + "obsidian-1.4.11": "npm:obsidian@1.4.11", "ts-jest": "^28.0.5", "tslib": "2.4.0", "typescript": "4.7.4" diff --git a/src/custom-sort/custom-sort-getComparator.spec.ts b/src/custom-sort/custom-sort-getComparator.spec.ts index 1afb404..d942af3 100644 --- a/src/custom-sort/custom-sort-getComparator.spec.ts +++ b/src/custom-sort/custom-sort-getComparator.spec.ts @@ -1,8 +1,6 @@ import { FolderItemForSorting, getComparator, - getSorterFnFor, - getMdata, OS_byCreatedTime, OS_byModifiedTime, OS_byModifiedTimeReverse, SortingLevelId diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 6bafbb1..aa80327 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -1,5 +1,3 @@ -import {MetadataCache, Plugin} from "obsidian"; - export enum CustomSortGroupType { Outsiders, // Not belonging to any of other groups MatchAll, // like a wildard *, used in connection with foldersOnly or filesOnly. The difference between the MatchAll and Outsiders is @@ -9,6 +7,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 } @@ -29,7 +28,9 @@ export enum CustomSortOrder { byMetadataFieldTrueAlphabetical, byMetadataFieldAlphabeticalReverse, byMetadataFieldTrueAlphabeticalReverse, - standardObsidian, // Let the folder sorting be in hands of Obsidian, whatever user selected in the UI + standardObsidian, // whatever user selected in the UI + byBookmarkOrder, + byBookmarkOrderReverse, default = alphabetical } diff --git a/src/custom-sort/custom-sort-utils.spec.ts b/src/custom-sort/custom-sort-utils.spec.ts new file mode 100644 index 0000000..4c55453 --- /dev/null +++ b/src/custom-sort/custom-sort-utils.spec.ts @@ -0,0 +1,187 @@ +import { + CustomSortGroupType, + CustomSortOrder, + CustomSortSpec +} from "./custom-sort-types"; +import { + collectSortingAndGroupingTypes, + hasOnlyByBookmarkOrStandardObsidian, + HasSortingOrGrouping, + ImplicitSortspecForBookmarksIntegration +} from "./custom-sort-utils"; +import {SortingSpecProcessor, SortSpecsCollection} from "./sorting-spec-processor"; + +type NM = number + +const getHas = (gTotal?: NM, gBkmrk?: NM, gStar?: NM, gIcon?: NM, sTot?: NM, sBkmrk?: NM, sStd?: NM) => { + const has: HasSortingOrGrouping = { + grouping: { + total: gTotal ||0, + byBookmarks: gBkmrk ||0, + byStarred: gStar ||0, + byIcon: gIcon ||0 + }, + sorting: { + total: sTot ||0, + byBookmarks: sBkmrk ||0, + standardObsidian: sStd ||0 + } + } + return has +} + +describe('hasOnlyByBookmarkOrStandardObsidian and collectSortingAndGroupingTypes', () => { + it('should handle empty spec correctly', () => { + const spec: Partial|undefined|null = undefined + const expectedHas: HasSortingOrGrouping = getHas() + const has = collectSortingAndGroupingTypes(spec) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(has).toEqual(expectedHas) + expect(hasOnly).toBeTruthy() + }) + it('should handle empty spec correctly (null variant)', () => { + const spec: Partial|undefined|null = null + const expectedHas: HasSortingOrGrouping = getHas() + const has = collectSortingAndGroupingTypes(spec) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(hasOnly).toBeTruthy() + expect(has).toEqual(expectedHas) + }) + it('should handle spec with empty orders correctly', () => { + const spec: Partial|undefined = { + groups: [ + {type: CustomSortGroupType.Outsiders, filesOnly: true}, + {type: CustomSortGroupType.Outsiders} + ] + } + const expectedHas: HasSortingOrGrouping = getHas() + const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(hasOnly).toBeTruthy() + expect(has).toEqual(expectedHas) + }) + it('should detect not matching default order', () => { + const spec: Partial|undefined = { + defaultOrder: CustomSortOrder.default, + groups: [ + { + type: CustomSortGroupType.ExactName, + }, + { + type: CustomSortGroupType.Outsiders, + } + ] + } + const expectedHas: HasSortingOrGrouping = getHas(1, 0, 0, 0, 1, 0, 0) + const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(hasOnly).toBeFalsy() + expect(has).toEqual(expectedHas) + }) + it('should detect not matching default secondary order', () => { + const spec: Partial|undefined = { + defaultOrder: CustomSortOrder.byBookmarkOrder, + defaultSecondaryOrder: CustomSortOrder.default, + groups: [ + { + type: CustomSortGroupType.BookmarkedOnly, + }, + { + type: CustomSortGroupType.Outsiders, + } + ] + } + const expectedHas: HasSortingOrGrouping = getHas(1, 1, 0, 0, 2, 1, 0) + const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(hasOnly).toBeFalsy() + expect(has).toEqual(expectedHas) + }) + it('should detect not matching order in group', () => { + const spec: Partial|undefined = { + defaultOrder: CustomSortOrder.byBookmarkOrder, + defaultSecondaryOrder: CustomSortOrder.standardObsidian, + groups: [ + { + type: CustomSortGroupType.ExactName, + order: CustomSortOrder.byCreatedTimeReverse + }, + { + type: CustomSortGroupType.Outsiders, + } + ] + } + const expectedHas: HasSortingOrGrouping = getHas(1, 0, 0, 0, 3, 1, 1) + const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(hasOnly).toBeFalsy() + expect(has).toEqual(expectedHas) + }) + it('should detect not matching secondary order in group', () => { + const spec: Partial|undefined = { + defaultOrder: CustomSortOrder.byBookmarkOrder, + defaultSecondaryOrder: CustomSortOrder.standardObsidian, + groups: [ + { + type: CustomSortGroupType.ExactName, + order: CustomSortOrder.byBookmarkOrderReverse, + secondaryOrder: CustomSortOrder.standardObsidian + }, + { + type: CustomSortGroupType.Outsiders, + order: CustomSortOrder.byBookmarkOrder, + secondaryOrder: CustomSortOrder.alphabetical + } + ] + } + const expectedHas: HasSortingOrGrouping = getHas(1, 0, 0, 0, 6, 3, 2) + const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(hasOnly).toBeFalsy() + expect(has).toEqual(expectedHas) + }) + it('should detect matching orders at all levels', () => { + const spec: Partial|undefined = { + defaultOrder: CustomSortOrder.byBookmarkOrder, + defaultSecondaryOrder: CustomSortOrder.standardObsidian, + groups: [ + { + type: CustomSortGroupType.BookmarkedOnly, + order: CustomSortOrder.byBookmarkOrderReverse, + secondaryOrder: CustomSortOrder.standardObsidian + }, + { + type: CustomSortGroupType.Outsiders, + order: CustomSortOrder.byBookmarkOrder, + secondaryOrder: CustomSortOrder.byBookmarkOrderReverse + } + ] + } + const expectedHas: HasSortingOrGrouping = getHas(1, 1, 0, 0, 6, 4, 2) + const has = collectSortingAndGroupingTypes(spec as CustomSortSpec) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(hasOnly).toBeTruthy() + expect(has).toEqual(expectedHas) + }) +}) + +describe('ImplicitSortspecForBookmarksIntegration', () => { + it('should correctly be recognized as only bookmark and obsidian standard', () => { + const processor: SortingSpecProcessor = new SortingSpecProcessor(); + const inputTxtArr: Array = ImplicitSortspecForBookmarksIntegration.replace(/\t/gi, '').split('\n') + const spec: SortSpecsCollection|null|undefined = processor.parseSortSpecFromText( + inputTxtArr, + 'mock-folder', + 'custom-name-note.md', + null, + true + ) + const expectedHas: HasSortingOrGrouping = getHas(1, 1, 0, 0, 2, 1, 1) + const has = collectSortingAndGroupingTypes(spec?.sortSpecByPath!['/']) + const hasOnly = hasOnlyByBookmarkOrStandardObsidian(has) + expect(hasOnly).toBeTruthy() + expect(has).toEqual(expectedHas) + }) +}) + +// TODO - czy tamto sprawdzanie dla itemów w rootowym filderze hasBookmarkInFolder dobrze zadziala diff --git a/src/custom-sort/custom-sort-utils.ts b/src/custom-sort/custom-sort-utils.ts new file mode 100644 index 0000000..e3f0ed0 --- /dev/null +++ b/src/custom-sort/custom-sort-utils.ts @@ -0,0 +1,80 @@ +import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types"; + +// Put here to allow unit tests coverage of this specific implicit sorting spec +export const ImplicitSortspecForBookmarksIntegration: string = ` +target-folder: /* +bookmarked: + < by-bookmarks-order +sorting: standard +` + +export interface HasSortingTypes { + byBookmarks: number + standardObsidian: number + total: number +} + +export interface HasGroupingTypes { + byBookmarks: number + byStarred: number + byIcon: number + total: number +} + +export interface HasSortingOrGrouping { + sorting: HasSortingTypes + grouping: HasGroupingTypes +} + +export const checkByBookmark = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType ) => { + groupType === CustomSortGroupType.BookmarkedOnly && has.grouping.byBookmarks++; + (order === CustomSortOrder.byBookmarkOrder || order === CustomSortOrder.byBookmarkOrderReverse) && has.sorting.byBookmarks++; +} + +export const checkByStarred = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType ) => { + groupType === CustomSortGroupType.StarredOnly && has.grouping.byStarred++; +} + +export const checkByIcon = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType ) => { + groupType === CustomSortGroupType.HasIcon && has.grouping.byIcon++; +} + +export const checkStandardObsidian = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType ) => { + order === CustomSortOrder.standardObsidian && has.sorting.standardObsidian++; +} + +export const doCheck = (has: HasSortingOrGrouping, order?: CustomSortOrder, groupType?: CustomSortGroupType) => { + checkByBookmark(has, order, groupType) + checkByStarred(has, order, groupType) + checkByIcon(has, order, groupType) + checkStandardObsidian(has, order, groupType) + + order !== undefined && has.sorting.total++ + groupType !== undefined && groupType !== CustomSortGroupType.Outsiders && has.grouping.total++; +} + +export const collectSortingAndGroupingTypes = (sortSpec?: CustomSortSpec|null): HasSortingOrGrouping => { + const has: HasSortingOrGrouping = { + grouping: { + byIcon: 0, byStarred: 0, byBookmarks: 0, total: 0 + }, + sorting: { + byBookmarks: 0, standardObsidian: 0, total: 0 + } + } + if (!sortSpec) return has + doCheck(has, sortSpec.defaultOrder) + doCheck(has, sortSpec.defaultSecondaryOrder) + if (sortSpec.groups) { + for (let group of sortSpec.groups) { + doCheck(has, group.order, group.type) + doCheck(has, group.secondaryOrder) + } + } + return has +} + +export const hasOnlyByBookmarkOrStandardObsidian = (has: HasSortingOrGrouping): boolean => { + return has.sorting.total === has.sorting.standardObsidian + has.sorting.byBookmarks && + has.grouping.total === has.grouping.byBookmarks +} diff --git a/src/custom-sort/custom-sort.spec.ts b/src/custom-sort/custom-sort.spec.ts index bc07fed..d0ee7c8 100644 --- a/src/custom-sort/custom-sort.spec.ts +++ b/src/custom-sort/custom-sort.spec.ts @@ -8,13 +8,25 @@ import { FolderItemForSorting, getSorterFnFor, matchGroupRegex, - ProcessingContext, + ProcessingContext, + sorterByBookmarkOrder, sorterByMetadataField, SorterFn } from './custom-sort'; -import {CustomSortGroupType, CustomSortOrder, CustomSortSpec, RegExpSpec} from './custom-sort-types'; -import {CompoundDashNumberNormalizerFn, CompoundDotRomanNumberNormalizerFn} from "./sorting-spec-processor"; -import {findStarredFile_pathParam, Starred_PluginInstance} from "../utils/StarredPluginSignature"; +import { + CustomSortGroupType, + CustomSortOrder, + CustomSortSpec, + RegExpSpec +} from './custom-sort-types'; +import { + CompoundDashNumberNormalizerFn, + CompoundDotRomanNumberNormalizerFn +} from "./sorting-spec-processor"; +import { + findStarredFile_pathParam, + Starred_PluginInstance +} from "../utils/StarredPluginSignature"; import { ObsidianIconFolder_PluginInstance, ObsidianIconFolderPlugin_Data @@ -2854,3 +2866,39 @@ describe('sorterByMetadataField', () => { 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,0, 'a','b'], + [true,undefined,undefined,0, '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,0, 'a','b'], + [false,undefined,undefined,0, '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 93268c1..6d1fb23 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -9,11 +9,11 @@ import { } from 'obsidian'; import { determineStarredStatusOf, - Starred_PluginInstance, + Starred_PluginInstance } from '../utils/StarredPluginSignature'; import { determineIconOf, - ObsidianIconFolder_PluginInstance, + ObsidianIconFolder_PluginInstance } from '../utils/ObsidianIconFolderPluginSignature' import { CustomSortGroup, @@ -30,13 +30,16 @@ import { import { expandMacros } from "./macros"; +import { + BookmarksPluginInterface +} from "../utils/BookmarksCorePluginSignature"; export interface ProcessingContext { // For internal transient use plugin?: Plugin // to hand over the access to App instance to the sorting engine _mCache?: MetadataCache starredPluginInstance?: Starred_PluginInstance - + bookmarksPluginInstance?: BookmarksPluginInterface, iconFolderPluginInstance?: ObsidianIconFolder_PluginInstance } @@ -64,6 +67,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 enum SortingLevelId { @@ -115,6 +119,23 @@ export const sorterByMetadataField = (reverseOrder?: boolean, trueAlphabetical?: } } +export const sorterByBookmarkOrder:(reverseOrder?: boolean, trueAlphabetical?: boolean) => SorterFn = (reverseOrder: boolean, trueAlphabetical?: boolean) => { + 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 + + return EQUAL_OR_UNCOMPARABLE + } +} + const 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), @@ -132,6 +153,8 @@ const Sorters: { [key in CustomSortOrder]: SorterFn } = { [CustomSortOrder.byMetadataFieldTrueAlphabetical]: sorterByMetadataField(StraightOrder, TrueAlphabetical, SortingLevelId.forPrimary), [CustomSortOrder.byMetadataFieldAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, !TrueAlphabetical, SortingLevelId.forPrimary), [CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, TrueAlphabetical, SortingLevelId.forPrimary), + [CustomSortOrder.byBookmarkOrder]: sorterByBookmarkOrder(StraightOrder), + [CustomSortOrder.byBookmarkOrderReverse]: sorterByBookmarkOrder(ReverseOrder), // This is a fallback entry which should not be used - the getSorterFor() function below should protect against it [CustomSortOrder.standardObsidian]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(a.sortString, b.sortString), @@ -294,6 +317,7 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus let groupIdx: number let determined: boolean = false let derivedText: string | null | undefined + let bookmarkedIdx: number | undefined const aFolder: boolean = isFolder(entry) const aFile: boolean = !aFolder @@ -402,6 +426,14 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus } } break + case CustomSortGroupType.BookmarkedOnly: + if (ctx?.bookmarksPluginInstance) { + const bookmarkOrder: number | undefined = ctx?.bookmarksPluginInstance.determineBookmarkOrder(entry.path) + 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) @@ -486,7 +518,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 } } @@ -503,6 +536,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 @@ -546,13 +590,33 @@ export const determineFolderDatesIfNeeded = (folderItems: Array, sortingSpec: CustomSortSpec, plugin: BookmarksPluginInterface) => { + if (!plugin) return + + folderItems.forEach((item) => { + const folderDefaultSortRequiresBookmarksOrder: boolean = !!(sortingSpec.defaultOrder && sortOrderNeedsBookmarksOrder(sortingSpec.defaultOrder, sortingSpec.defaultSecondaryOrder)) + let groupSortRequiresBookmarksOrder: boolean = false + if (!folderDefaultSortRequiresBookmarksOrder) { + const groupIdx: number | undefined = item.groupIdx + if (groupIdx !== undefined) { + const groupOrder: CustomSortOrder | undefined = sortingSpec.groups[groupIdx].order + const groupSecondaryOrder: CustomSortOrder | undefined = sortingSpec.groups[groupIdx].secondaryOrder + groupSortRequiresBookmarksOrder = sortOrderNeedsBookmarksOrder(groupOrder, groupSecondaryOrder) + } + } + if (folderDefaultSortRequiresBookmarksOrder || groupSortRequiresBookmarksOrder) { + item.bookmarkedIdx = plugin.determineBookmarkOrder(item.path) + } + }) +} + export const folderSort = function (sortingSpec: CustomSortSpec, ctx: ProcessingContext) { 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)) - - // expand folder-specific macros const parentFolderName: string|undefined = this.file.name expandMacros(sortingSpec, parentFolderName) @@ -570,6 +634,10 @@ export const folderSort = function (sortingSpec: CustomSortSpec, ctx: Processing // Finally, for advanced sorting by modified date, for some folders the modified date has to be determined determineFolderDatesIfNeeded(folderItems, sortingSpec) + if (ctx.bookmarksPluginInstance) { + determineBookmarksOrderIfNeeded(folderItems, sortingSpec, ctx.bookmarksPluginInstance) + } + const comparator: SorterFn = getComparator(sortingSpec, fileExplorer.sortOrder) folderItems.sort(comparator) @@ -583,3 +651,41 @@ export const folderSort = function (sortingSpec: CustomSortSpec, ctx: Processing this.children = items; } }; + +// Returns a sorted copy of the input array, intentionally to keep it intact +export const sortFolderItemsForBookmarking = function (folder: TFolder, items: Array, sortingSpec: CustomSortSpec|null|undefined, ctx: ProcessingContext, uiSortOrder: string): Array { + if (sortingSpec) { + 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 = items.map((entry: TFile | TFolder) => { + folderItemsByPath[entry.path] = entry + const itemForSorting: FolderItemForSorting = determineSortingGroup(entry, sortingSpec, ctx) + return itemForSorting + }) + + // Finally, for advanced sorting by modified date, for some folders the modified date has to be determined + determineFolderDatesIfNeeded(folderItems, sortingSpec) + + if (ctx.bookmarksPluginInstance) { + determineBookmarksOrderIfNeeded(folderItems, sortingSpec, ctx.bookmarksPluginInstance) + } + + const comparator: SorterFn = getComparator(sortingSpec, uiSortOrder) + + folderItems.sort(comparator) + + const sortedItems: Array = folderItems.map((entry) => folderItemsByPath[entry.path]) + + return sortedItems + } else { // No custom sorting or the custom sort disabled - apply standard Obsidian sorting (internally 1:1 recreated implementation) + const folderItems: Array = items.map((entry: TFile | TFolder) => entry) + const plainSorterFn: PlainSorterFn = StandardPlainObsidianComparator(uiSortOrder) + folderItems.sort(plainSorterFn) + return folderItems + } +}; diff --git a/src/custom-sort/folder-matching-rules.spec.ts b/src/custom-sort/folder-matching-rules.spec.ts index a69ccdd..1cabf1f 100644 --- a/src/custom-sort/folder-matching-rules.spec.ts +++ b/src/custom-sort/folder-matching-rules.spec.ts @@ -1,4 +1,6 @@ -import {FolderWildcardMatching} from './folder-matching-rules' +import { + FolderWildcardMatching +} from './folder-matching-rules' type SortingSpec = string diff --git a/src/custom-sort/macros.spec.ts b/src/custom-sort/macros.spec.ts index 487fadf..3011360 100644 --- a/src/custom-sort/macros.spec.ts +++ b/src/custom-sort/macros.spec.ts @@ -1,6 +1,12 @@ -import {expandMacros, expandMacrosInString} from "./macros"; +import { + expandMacros, + expandMacrosInString +} from "./macros"; import * as MacrosModule from './macros' -import {CustomSortGroup, CustomSortSpec} from "./custom-sort-types"; +import { + CustomSortGroup, + CustomSortSpec +} from "./custom-sort-types"; describe('expandMacrosInString', () => { it.each([ diff --git a/src/custom-sort/matchers.spec.ts b/src/custom-sort/matchers.spec.ts index 823c0f8..aef520e 100644 --- a/src/custom-sort/matchers.spec.ts +++ b/src/custom-sort/matchers.spec.ts @@ -12,7 +12,6 @@ import { WordInASCIIRegexStr, WordInAnyLanguageRegexStr } from "./matchers"; -import {SortingSpecProcessor} from "./sorting-spec-processor"; describe('Plain numbers regexp', () => { let regexp: RegExp; diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index 2e2d3be..8656ccd 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -14,8 +14,16 @@ import { RomanNumberNormalizerFn, SortingSpecProcessor } from "./sorting-spec-processor" -import {CustomSortGroupType, CustomSortOrder, CustomSortSpec, IdentityNormalizerFn} from "./custom-sort-types"; -import {FolderMatchingRegexp, FolderMatchingTreeNode} from "./folder-matching-rules"; +import { + CustomSortGroupType, + CustomSortOrder, + CustomSortSpec, + IdentityNormalizerFn +} from "./custom-sort-types"; +import { + FolderMatchingRegexp, + FolderMatchingTreeNode +} from "./folder-matching-rules"; const txtInputExampleA: string = ` order-asc: a-z @@ -37,6 +45,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 +110,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 % @@ -194,6 +216,29 @@ 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 + }, + { + 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 bef3c97..8a41145 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -120,6 +120,7 @@ const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = { 'advanced created': {asc: CustomSortOrder.byCreatedTimeAdvanced, desc: CustomSortOrder.byCreatedTimeReverseAdvanced}, 'standard': {asc: CustomSortOrder.standardObsidian, desc: CustomSortOrder.standardObsidian}, 'ui selected': {asc: CustomSortOrder.standardObsidian, desc: CustomSortOrder.standardObsidian}, + 'by-bookmarks-order': {asc: CustomSortOrder.byBookmarkOrder, desc: CustomSortOrder.byBookmarkOrderReverse}, } const OrderByMetadataLexeme: string = 'by-metadata:' @@ -241,6 +242,8 @@ const HideItemVerboseLexeme: string = '/--hide:' const MetadataFieldIndicatorLexeme: string = 'with-metadata:' +const BookmarkedItemIndicatorLexeme: string = 'bookmarked:' + const StarredItemsIndicatorLexeme: string = 'starred:' const IconIndicatorLexeme: string = 'with-icon:' @@ -1600,6 +1603,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 022f4d9..3ab223a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,27 +1,38 @@ import { + apiVersion, App, FileExplorerView, + Menu, + MenuItem, MetadataCache, - Notice, normalizePath, + Notice, Platform, Plugin, PluginSettingTab, + requireApiVersion, sanitizeHTMLToDom, setIcon, Setting, TAbstractFile, TFile, TFolder, - Vault + Vault, WorkspaceLeaf } from 'obsidian'; import {around} from 'monkey-around'; import { folderSort, + ObsidianStandardDefaultSortingName, ProcessingContext, + sortFolderItemsForBookmarking } from './custom-sort/custom-sort'; -import {SortingSpecProcessor, SortSpecsCollection} from './custom-sort/sorting-spec-processor'; -import {CustomSortOrder, CustomSortSpec} from './custom-sort/custom-sort-types'; +import { + SortingSpecProcessor, + SortSpecsCollection +} from './custom-sort/sorting-spec-processor'; +import { + CustomSortSpec +} from './custom-sort/custom-sort-types'; import { addIcons, @@ -33,9 +44,19 @@ import { ICON_SORT_SUSPENDED_SYNTAX_ERROR } from "./custom-sort/icons"; import {getStarredPlugin} from "./utils/StarredPluginSignature"; - +import { + BookmarksPluginInterface, + getBookmarksPlugin, + groupNameForPath +} from "./utils/BookmarksCorePluginSignature"; import {getIconFolderPlugin} from "./utils/ObsidianIconFolderPluginSignature"; import {lastPathComponent} from "./utils/utils"; +import { + collectSortingAndGroupingTypes, + hasOnlyByBookmarkOrStandardObsidian, + HasSortingOrGrouping, + ImplicitSortspecForBookmarksIntegration +} from "./custom-sort/custom-sort-utils"; interface CustomSortPluginSettings { additionalSortspecFile: string @@ -43,6 +64,9 @@ interface CustomSortPluginSettings { statusBarEntryEnabled: boolean notificationsEnabled: boolean mobileNotificationsEnabled: boolean + automaticBookmarksIntegration: boolean + bookmarksContextMenus: boolean + bookmarksGroupToConsumeAsOrderingReference: string } const DEFAULT_SETTINGS: CustomSortPluginSettings = { @@ -50,7 +74,16 @@ 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, + automaticBookmarksIntegration: false, + bookmarksContextMenus: false, + bookmarksGroupToConsumeAsOrderingReference: 'sortspec' +} + +// On API 1.2.x+ enable the bookmarks integration by default +const DEFAULT_SETTING_FOR_1_2_0_UP: Partial = { + automaticBookmarksIntegration: true, + bookmarksContextMenus: true } const SORTSPEC_FILE_NAME: string = 'sortspec.md' @@ -87,6 +120,16 @@ export default class CustomSortPlugin extends Plugin { this.sortSpecCache = null const processor: SortingSpecProcessor = new SortingSpecProcessor() + if (this.settings.automaticBookmarksIntegration) { + this.sortSpecCache = processor.parseSortSpecFromText( + ImplicitSortspecForBookmarksIntegration.split('\n'), + 'System internal path', // Dummy unused value, there are no errors in the internal spec + 'System internal file', // Dummy unused value, there are no errors in the internal spec + this.sortSpecCache, + true // Implicit sorting spec generation + ) + } + Vault.recurseChildren(app.vault.getRoot(), (file: TAbstractFile) => { if (failed) return if (file instanceof TFile) { @@ -198,6 +241,12 @@ export default class CustomSortPlugin extends Plugin { } } + // Syntax sugar + const ForceFlushCache = true + if (!this.settings.suspended) { + getBookmarksPlugin(this.settings.bookmarksGroupToConsumeAsOrderingReference, ForceFlushCache) + } + if (fileExplorerView) { if (this.fileExplorerFolderPatched) { fileExplorerView.requestSort(); @@ -290,6 +339,131 @@ export default class CustomSortPlugin extends Plugin { } }) ); + + this.registerEvent( + app.workspace.on("file-menu", (menu: Menu, file: 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 bookmarkThisMenuItem = (item: MenuItem) => { + item.setTitle('Custom sort: bookmark for sorting.'); + item.setIcon('hashtag'); + item.onClick(() => { + const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference) + if (bookmarksPlugin) { + bookmarksPlugin.bookmarkFolderItem(file) + bookmarksPlugin.saveDataAndUpdateBookmarkViews(true) + } + }); + }; + 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) => { + item.setTitle('Custom sort: bookmark+siblings for sorting.'); + item.setIcon('hashtag'); + item.onClick(() => { + const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference) + if (bookmarksPlugin) { + const orderedChildren: Array = plugin.orderedFolderItemsForBookmarking(file.parent, bookmarksPlugin) + bookmarksPlugin.bookmarkSiblings(orderedChildren) + bookmarksPlugin.saveDataAndUpdateBookmarkViews(true) + } + }); + }; + 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 = file.parent.children.map((entry: TFile | TFolder) => entry) + bookmarksPlugin.unbookmarkSiblings(orderedChildren) + bookmarksPlugin.saveDataAndUpdateBookmarkViews(true) + } + }); + }; + + const itemAlreadyBookmarkedForSorting: boolean = bookmarksPlugin.isBookmarkedForSorting(file) + if (!itemAlreadyBookmarkedForSorting) { + menu.addItem(bookmarkThisMenuItem) + } else { + menu.addItem(unbookmarkThisMenuItem) + } + 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( + app.vault.on("rename", (file: TAbstractFile, oldPath: string) => { + const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference) + if (bookmarksPlugin) { + bookmarksPlugin.updateSortingBookmarksAfterItemRenamed(file, oldPath) + bookmarksPlugin.saveDataAndUpdateBookmarkViews(true) + } + }) + ) + app.vault.on("delete", (file: TAbstractFile) => { + const bookmarksPlugin = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference) + if (bookmarksPlugin) { + bookmarksPlugin.updateSortingBookmarksAfterItemDeleted(file) + bookmarksPlugin.saveDataAndUpdateBookmarkViews(true) + } + }) } registerCommands() { @@ -328,12 +502,12 @@ export default class CustomSortPlugin extends Plugin { return sortSpec } - createProcessingContextForSorting(): ProcessingContext { + createProcessingContextForSorting(has: HasSortingOrGrouping): ProcessingContext { const ctx: ProcessingContext = { _mCache: app.metadataCache, - starredPluginInstance: getStarredPlugin(), - - iconFolderPluginInstance: getIconFolderPlugin(), + starredPluginInstance: has.grouping.byStarred ? getStarredPlugin() : undefined, + bookmarksPluginInstance: has.grouping.byBookmarks || has.sorting.byBookmarks ? getBookmarksPlugin(this.settings.bookmarksGroupToConsumeAsOrderingReference, false, true) : undefined, + iconFolderPluginInstance: has.grouping.byIcon ? getIconFolderPlugin() : undefined, plugin: this } return ctx @@ -365,8 +539,18 @@ export default class CustomSortPlugin extends Plugin { const folder: TFolder = this.file let sortSpec: CustomSortSpec | null | undefined = plugin.determineSortSpecForFolder(folder.path, folder.name) + // Performance optimization + // Primary intention: when the implicit bookmarks integration is enabled, remain on std Obsidian, if no need to involve bookmarks + let has: HasSortingOrGrouping = collectSortingAndGroupingTypes(sortSpec) + if (hasOnlyByBookmarkOrStandardObsidian(has)) { + const bookmarksPlugin: BookmarksPluginInterface|undefined = getBookmarksPlugin(plugin.settings.bookmarksGroupToConsumeAsOrderingReference, false, true) + if ( !bookmarksPlugin?.bookmarksIncludeItemsInFolder(folder.path)) { + sortSpec = null + } + } + if (sortSpec) { - return folderSort.call(this, sortSpec, plugin.createProcessingContextForSorting()); + return folderSort.call(this, sortSpec, plugin.createProcessingContextForSorting(has)); } else { return old.call(this, ...args); } @@ -380,6 +564,24 @@ export default class CustomSortPlugin extends Plugin { } } + orderedFolderItemsForBookmarking(folder: TFolder, bookmarksPlugin: BookmarksPluginInterface): Array { + let sortSpec: CustomSortSpec | null | undefined = undefined + if (!this.settings.suspended) { + sortSpec = this.determineSortSpecForFolder(folder.path, folder.name) + } + let uiSortOrder: string = this.getFileExplorer()?.sortOrder || ObsidianStandardDefaultSortingName + + const has: HasSortingOrGrouping = collectSortingAndGroupingTypes(sortSpec) + + return sortFolderItemsForBookmarking( + folder, + folder.children, + sortSpec, + this.createProcessingContextForSorting(has), + uiSortOrder + ) + } + // Credits go to https://github.com/nothingislost/obsidian-bartender getFileExplorer(): FileExplorerView | undefined { let fileExplorer: FileExplorerView | undefined = app.workspace.getLeavesOfType("file-explorer")?.first() @@ -397,7 +599,12 @@ export default class CustomSortPlugin extends Plugin { } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + const data: any = await this.loadData() || {} + const isFreshInstall: boolean = Object.keys(data).length === 0 + this.settings = Object.assign({}, DEFAULT_SETTINGS, data); + if (requireApiVersion('1.2.0')) { + this.settings = Object.assign(this.settings, DEFAULT_SETTING_FOR_1_2_0_UP) + } } async saveSettings() { @@ -405,6 +612,10 @@ export default class CustomSortPlugin extends Plugin { } } +const pathToFlatString = (path: string): string => { + return path.replace(/\//g,'_').replace(/\\/g, '_') +} + class CustomSortSettingTab extends PluginSettingTab { plugin: CustomSortPlugin; @@ -492,5 +703,60 @@ class CustomSortSettingTab extends PluginSettingTab { this.plugin.settings.mobileNotificationsEnabled = value; await this.plugin.saveSettings(); })); + + containerEl.createEl('h2', {text: 'Bookmarks integration'}); + const bookmarksIntegrationDescription: DocumentFragment = sanitizeHTMLToDom( + 'If enabled, order of files and folders in File Explorer will reflect the order ' + + 'of bookmarked items in the bookmarks (core plugin) view. Automatically, without any ' + + 'need for sorting configuration. At the same time, it integrates seamlessly with' + + '
sorting-spec:
configurations and they can nicely cooperate.' + + '
' + + '

To separate regular bookmarks from the bookmarks created for sorting, you can put ' + + 'the latter in a separate dedicated bookmarks group. The default name of the group is ' + + "'" + DEFAULT_SETTINGS.bookmarksGroupToConsumeAsOrderingReference + "' " + + 'and you can change the group name in the configuration field below.' + + '
' + + 'If left empty, all the bookmarked items will be used to impose the order in File Explorer.

' + + '

More information on this functionality in the ' + + '' + + 'manual of this custom-sort plugin.' + + '

' + ) + + new Setting(containerEl) + .setName('Automatic integration with core Bookmarks plugin (for indirect drag & drop ordering)') + .setDesc(bookmarksIntegrationDescription) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.automaticBookmarksIntegration) + .onChange(async (value) => { + this.plugin.settings.automaticBookmarksIntegration = value; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Name of the group in Bookmarks from which to read the order of items') + .setDesc('See above.') + .addText(text => text + .setPlaceholder('e.g. Group for sorting') + .setValue(this.plugin.settings.bookmarksGroupToConsumeAsOrderingReference) + .onChange(async (value) => { + value = groupNameForPath(value.trim()).trim() + this.plugin.settings.bookmarksGroupToConsumeAsOrderingReference = value ? pathToFlatString(normalizePath(value)) : ''; + await this.plugin.saveSettings(); + })); + + const bookmarksIntegrationContextMenusDescription: DocumentFragment = sanitizeHTMLToDom( + 'Enable Custom-sort: bookmark for sorting and Custom-sort: bookmark+siblings for sorting. entries ' + + 'in context menu in File Explorer' + ) + new Setting(containerEl) + .setName('Context menus for Bookmarks integration') + .setDesc(bookmarksIntegrationContextMenusDescription) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.bookmarksContextMenus) + .onChange(async (value) => { + this.plugin.settings.bookmarksContextMenus = value; + await this.plugin.saveSettings(); + })) } } diff --git a/src/utils/Bookmarks Core Plugin integration design.md b/src/utils/Bookmarks Core Plugin integration design.md new file mode 100644 index 0000000..7307afa --- /dev/null +++ b/src/utils/Bookmarks Core Plugin integration design.md @@ -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 + + + diff --git a/src/utils/BookmarksCorePluginSignature.spec.ts b/src/utils/BookmarksCorePluginSignature.spec.ts new file mode 100644 index 0000000..a83d0eb --- /dev/null +++ b/src/utils/BookmarksCorePluginSignature.spec.ts @@ -0,0 +1,1065 @@ +import { + _unitTests, + BookmarkedParentFolder, + OrderedBookmarks +} from "./BookmarksCorePluginSignature"; +import {extractParentFolderPath} from "./utils"; + +type Bookmarks_PluginInstance = any + +const getEmptyBookmarksMock = (): Bookmarks_PluginInstance => ({} as Bookmarks_PluginInstance) + +const getNullBookmarksMock = (): Bookmarks_PluginInstance => ({ + items: null + } as unknown as Bookmarks_PluginInstance) + +const getBookmarksMock = (items: any): Bookmarks_PluginInstance => ({ + items: items +} as Bookmarks_PluginInstance) + +const getBrokenBookmarksMock1 = (): Bookmarks_PluginInstance => ({ + items: 123 +} as Bookmarks_PluginInstance) + +const getBrokenBookmarksMock2 = (): Bookmarks_PluginInstance => ({ + items: () => { return [] } +} as Bookmarks_PluginInstance) + +const derivePath = (previousPath: string|undefined, path: string): string => { + const match = path?.match(/(^\s+\/)/) + if (match && previousPath) { + const lengthToTake = match[1].length + let derivedPart = previousPath.substring(0, lengthToTake) + derivedPart = derivedPart.endsWith('/') ? derivedPart : `${derivedPart}/` + const newPathSuffix = path.substring(lengthToTake) + return `${derivedPart}${newPathSuffix}` + } else { + return path + } +} + +interface PathAndOrder { + path: string + order: number +} + +class bkCacheMock { + entries: Array = [] + coveredPaths: {[key: string]: boolean} = {} + + getCache() { + const cache: OrderedBookmarks = {} + this.entries.forEach((it) => { + cache[it.path] = it.order + }) + return cache + } + getPathsCoverage() { + return this.coveredPaths + } + getExpected() { + return [this.getCache(), this.getPathsCoverage()] + } + add(path: string, order: number): bkCacheMock { + path = derivePath(this.entries.slice(-1)?.[0]?.path, path) + this.entries.push({ + path: path, + order: order + }) + this.coveredPaths[extractParentFolderPath(path) || '/'] = true + return this + } +} + +const attachRemoveFunction = (arr: Array): Array => { + arr.remove = (itemToRemove: any) => { + if (itemToRemove) { + let index + do { + index = arr?.findIndex((it) => it === itemToRemove) + if (index !== -1) { + arr?.splice(index, 1) + } + } while (index !== -1) + } + } + return arr +} + +const consumeBkMock = (tree: any): Array => { + const bkmrks: Array = attachRemoveFunction([]) + const pathOf = (s: string) => { + const match = s.match(/^\d+:(.+)$/) + return match ? match[1] : s + } + const typeOf = (s: string) => { + const match = s.match(/(file|folder)/) + return match ? match[1] : 'unknown' + } + const hasIndicator = (s: string) => { + return s.endsWith('^') + } + const consumeLevel = (entries: any, container?: Array) => { + Object.keys(entries).forEach((entryKey) => { + container = container ?? attachRemoveFunction([]) + const value = entries[entryKey] + if ('string' === typeof value) { // file or folder + const path = pathOf(entryKey) + container.push({ + type: typeOf(value), + path: pathOf(entryKey), + subpath: hasIndicator(value) ? '#^-' : undefined + }) + } else { // group + container.push({ + type: 'group', + items: consumeLevel(value), + title: entryKey + }) + } + }) + return container || attachRemoveFunction([]) + } + consumeLevel(tree, bkmrks) + return bkmrks +} + +describe('getOrderedBookmarks - basic scenarios', () => { + it('should correctly handle no bookmarks plugin scenario', () => { + const result = _unitTests.getOrderedBookmarks(null!) + expect(result).toEqual([undefined, undefined]) + }) + it('should correctly handle no bookmarks scenario', () => { + const result = _unitTests.getOrderedBookmarks(getEmptyBookmarksMock()) + expect(result).toEqual([undefined, undefined]) + }) + it('should correctly handle edge case of the bookmarks plugin responding with null', () => { + const result = _unitTests.getOrderedBookmarks(getNullBookmarksMock()) + expect(result).toEqual([undefined, undefined]) + }) + it('should correctly handle bookmarks plugin responding with empty collection', () => { + const items: [] = [] + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items)) + expect(result).toEqual([{}, {}]) + }) + it('should correctly handle basic scenario - one bookmarked item at root level - file', () => { + const items: Array = consumeBkMock({'some note.md': 'file'}) + const expected = new bkCacheMock() + .add('some note.md', 1) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items)) + expect(result).toEqual(expected) + }) + it('should correctly handle basic scenario - one bookmarked item at root level - file with subpath', () => { + const items: Array = consumeBkMock({'some note.md': 'file^'}) + const expected = new bkCacheMock() + .add('some note.md', 1) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items)) + expect(result).toEqual(expected) + }) + it('should correctly handle basic scenario - one bookmarked item at root level - folder', () => { + const items: Array = consumeBkMock({'some folder': 'folder'}) + const expected = new bkCacheMock() + .add('some folder', 1) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items)) + expect(result).toEqual(expected) + }) + it('should correctly handle basic scenario - one bookmarked item at root level - group', () => { + const items: Array = consumeBkMock({'sortspec': {}}) + const expected = new bkCacheMock() + .add('sortspec', 1) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items)) + expect(result).toEqual(expected) + }) + it('should correctly handle basic scenario - only the group with expected name - the container', () => { + const items: Array = consumeBkMock({'sortspec': {}}) + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), 'sortspec') + expect(result).toEqual([{}, {}]) + }) + it('should correctly handle basic scenario - one bookmarked item in the group of expected name', () => { + const items: [] = [] + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items)) + expect(result).toEqual([{}, {}]) + }) +}) + +describe('getOrderedBookmarks edge cases', () => { + it('should correctly handle bookmarks plugin not returning collection (a number)', () => { + const result = _unitTests.getOrderedBookmarks(getBrokenBookmarksMock1()) + expect(result).toEqual([undefined, undefined]) + }) + it('should correctly handle bookmarks plugin not returning collection (a function)', () => { + const result = _unitTests.getOrderedBookmarks(getBrokenBookmarksMock1()) + expect(result).toEqual([undefined, undefined]) + }) + it('edge case - group vs group', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\folder l1": {}, // order 1 + "folder l1": {} + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('edge case - folder vs group', () => { + const items = consumeBkMock({ + "sortspec": { + "1:folder l1": "folder", // order 1 + "folder l1": {} // order 2 + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 2) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('edge case - file w/ and w/o indicator vs group - group overwrites w/o indicator, regardless of matching (or not) path of file', () => { + const items = consumeBkMock({ + "sortspec": { + "1:artificial": "file", // order 1 + "artificial": {}, // order 2 + "subf": { // order 3 + "subf/item in subf": "file", // order 4 + "item in subf": {} // order 5 + } + } + }) + + const expected = new bkCacheMock() + .add("artificial", 2) + .add("subf", 3) + .add(" /item in subf", 5) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('edge case - file w/ and w/o indicator vs group - group is not overwritten by w/o indicator', () => { + const items = consumeBkMock({ + "sortspec": { + "artificial": {}, // order 1 + "1:artificial": "file", + "\\\\subf": { + "item in subf": {}, // order 2 + "subf/item in subf": "file", + } + } + }) + + const expected = new bkCacheMock() + .add("artificial", 1) + .add("subf/item in subf", 2) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('edge case - file w/ and w/o indicator vs group - group doesn`t overwrite w/ indicator', () => { + const items = consumeBkMock({ + "sortspec": { + "1:artificial": "file", // order 1 + "2:artificial": "file^", // order 2 + "artificial": {} // order 3 + } + }) + + const expected = new bkCacheMock() + .add("artificial", 2) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('edge case - file w/ and w/o indicator vs group - w/ indicator overwrites group', () => { + const items = consumeBkMock({ + "sortspec": { + "1:artificial": "file", // order 1 + "artificial": {}, // order 2 + "2:artificial": "file^", // order 3 + } + }) + + const expected = new bkCacheMock() + .add("artificial", 3) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('edge case - folder is treated as a file w/o indicator - scenario 1', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group": { + "1:group/artificial": "folder", // order 1 + "2:group/artificial": "file", // order 2 + } + } + }) + + const expected = new bkCacheMock() + .add("group/artificial", 1) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('edge case - folder is treated as a file w/o indicator - scenario 2', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group": { + "1:group/artificial": "folder", // order 1 + "2:group/artificial": "file^", // order 2 + } + } + }) + + const expected = new bkCacheMock() + .add("group/artificial", 2) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) +}) + +const DEFAULT_BKMRK_FOLDER = 'sortspec' + +describe('getOrderedBookmarks', () => { + describe('files', () => { + it('case 1 - both w/ indicator and not matching path. Ignore the latter.', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1/not relevant for test.md": "file^", // order 1 + "1:folder l1/folder l2/file 1 at level 2.md": "file^", // order 2 + "2:folder l1/folder l2/file 1 at level 2.md": "file^" // order 3 -> reject, a duplicate + } + }) + const expected = new bkCacheMock() + .add('folder l1/not relevant for test.md', 1) + .add(' /folder l2/file 1 at level 2.md', 2) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 2 - both w/ indicator, matching path wins.', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\folder l1": { + "folder l1/folder l2/file 1 at level 2.md": "file^", // ignore => invalid location + "\\\\folder l2": { + "folder l1/folder l2/not relevant for test.md": "file^", // order 1 + "\\\\folder l3": { + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 2, to be taken -> location match + }, + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", + "2:folder l1/folder l2/folder l3/file 1 at level 3.md": "folder", + } + }, + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // reject => already found in correct location + } + }) + + const expected = new bkCacheMock() + .add('folder l1/folder l2/not relevant for test.md', 1) + .add(" /folder l3/file 1 at level 3.md", 2) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 3 - w/ indicator wins', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 1 -> overwritten by w/ indicator + "folder l1/folder l2/not relevant for test.md": "file^", // order 2 + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 3 -> taken, overwrites item w/o indicator + } + }) + + const expected = new bkCacheMock() + .add('folder l1/folder l2/not relevant for test.md', 2) + .add(" /folder l3/file 1 at level 3.md", 3) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 4 - w/ indicator wins', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1/folder l2/not relevant for test.md": "file^", // order 1 + "folder l1": { // order 2 + "\\\\folder l2": { + "\\\\folder l3": { + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 3 -> overwritten by w/ indicator + } + } + }, + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 4 -> taken, overwrites item w/o indicator + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 2) + .add("folder l1/folder l2/not relevant for test.md", 1) + .add(" /folder l3/file 1 at level 3.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 5 - both w/ indicator, matching path wins', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1/folder l2/not relevant for test.md": "file^", // order 1 + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 2 -> overwritten by matching path + "\\\\folder l1": { + "folder l2": { // order 3 + "\\\\folder l3": { + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 4 -> overwrites item at root path + } + } + }, + } + }) + + const expected = new bkCacheMock() + .add("folder l1/folder l2", 3) + .add("folder l1/folder l2/not relevant for test.md", 1) + .add(" /folder l3/file 1 at level 3.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 6 - both w/ indicator and matching path. Ignore the latter', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\folder l1": { + "folder l2": { // order 1 + "\\\\folder l3": { + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 2 -> taken + "2:folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // rejected as duplicate + }, + "folder l1/folder l2/not relevant for test.md": "file", // order 3 + } + }, + } + }) + + const expected = new bkCacheMock() + .add("folder l1/folder l2", 1) + .add("folder l1/folder l2/not relevant for test.md", 3) + .add(" /folder l3/file 1 at level 3.md", 2) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 7 - w/ indicator wins', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 1 -> overwritten by w/ indicator + "folder l1": { // order 2 + "\\\\folder l2": { + "folder l3": { // order 3 + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 4 -> taken, overwrites w/o indicator + }, + "folder l1/folder l2/not relevant for test.md": "file", // order 5 + } + }, + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 2) + .add("folder l1/folder l2/folder l3", 3) + .add("folder l1/folder l2/not relevant for test.md", 5) + .add(" /folder l3/file 1 at level 3.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 8 - w/ indicator wins', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "\\\\folder l2": { + "folder l3": { // order 2 + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 3 -> overwritten by w/ indicator + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 4 -> taken, overwrites w/o indicator + }, + "folder l1/folder l2/not relevant for test.md": "file", // order 5 + } + }, + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add("folder l1/folder l2/folder l3", 2) + .add("folder l1/folder l2/not relevant for test.md", 5) + .add(" /folder l3/file 1 at level 3.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 9 - Item w/o indicator never overwrites the one with the indicator', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "\\\\folder l2": { + "folder l3": { // order 2 + }, + "folder l1/folder l2/not relevant for test.md": "file", // order 3 + } + }, + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 4 -> taken, + "2:folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // ignored as duplicate w/o indicator + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add("folder l1/folder l2/folder l3", 2) + .add("folder l1/folder l2/not relevant for test.md", 3) + .add(" /folder l3/file 1 at level 3.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 10 - Item w/o indicator never overwrites the one with the indicator', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "\\\\folder l2": { + "folder l3": { // order 2 + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 3 -> taken, + }, + "folder l1/folder l2/not relevant for test.md": "file", // order 4 + } + }, + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // ignored as duplicate w/o indicator and not matching path + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add("folder l1/folder l2/folder l3", 2) + .add("folder l1/folder l2/not relevant for test.md", 4) + .add(" /folder l3/file 1 at level 3.md", 3) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 11 - both w/o indicator and not matching path. Ignore the latter.', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "\\\\folder l2": { + "folder l3": { // order 2 + }, + }, + }, + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 3 -> taken + "2:folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // ignored as duplicate w/o indicator and not matching path + "folder l1/folder l2/not relevant for test.md": "folder", // order 4 + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add("folder l1/folder l2/folder l3", 2) + .add(" /folder l3/file 1 at level 3.md", 3) + .add("folder l1/folder l2/not relevant for test.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 12 - both w/o indicator, matching path is not overwritten by not matching', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "\\\\folder l2": { + "folder l3": { // order 2 + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 3 -> taken + }, + }, + }, + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // ignored as duplicate w/o indicator and not matching path + "folder l1/folder l2/not relevant for test.md": "folder", // order 4 + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add("folder l1/folder l2/folder l3", 2) + .add(" /folder l3/file 1 at level 3.md", 3) + .add("folder l1/folder l2/not relevant for test.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 13 - w/ indicator wins', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "\\\\folder l2": { + "folder l3": { // order 2 + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 3 -> overwritten by w/ indicator + }, + }, + }, + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 4 -> w/ indicator overwrites w/o indicator + "folder l1/folder l2/not relevant for test.md": "folder", // order 5 + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add("folder l1/folder l2/folder l3", 2) + .add(" /folder l3/file 1 at level 3.md", 4) + .add("folder l1/folder l2/not relevant for test.md", 5) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 14 - w/ indicator wins', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "\\\\folder l2": { + "folder l3": { // order 2 + "1:folder l1/folder l2/folder l3/file 1 at level 3.md": "file^", // order 3 + "2:folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // duplicate, ignored regardless of w/ indicator and matching path + }, + }, + }, + + "folder l1/folder l2/not relevant for test.md": "folder", // order 5 + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add(" /folder l2/folder l3", 2) + .add(" /file 1 at level 3.md", 3) + .add(" /not relevant for test.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 15 - both w/o indicator, matching path wins', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 1 -> overwritten by matching path + "folder l1": { // order 2 + "\\\\folder l2": { + "folder l3": { // order 3 + "folder l1/folder l2/folder l3/file 1 at level 3.md": "file", // order 4 -> overwrites not matching path + }, + }, + }, + "folder l1/folder l2/not relevant for test.md": "folder", // order 5 + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 2) + .add("folder l1/folder l2/folder l3", 3) + .add(" /file 1 at level 3.md", 4) + .add(" /not relevant for test.md", 5) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 15 - both w/o indicator and matching path. Ignore the latter.', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "folder l2": { // order 2 + "folder l3": { // order 3 + }, + "1:folder l1/folder l2/file 1 at level 2.md": "file", // order 4 -> overwrites not matching path + "2:folder l1/folder l2/file 1 at level 2.md": "file", // ignored as duplicate + }, + }, + "folder l1/folder l2/not relevant for test.md": "folder", // order 5 + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add(" /folder l2", 2) + .add(" /folder l3", 3) + .add(" /file 1 at level 2.md", 4) + .add(" /not relevant for test.md", 5) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + it('case 16 and 17 - ignore invalid locations', () => { + const items = consumeBkMock({ + "sortspec": { + "folder l1": { // order 1 + "folder l1/folder l2/file 1 at level 2.md": "file^", + "folder l2": { // order 2 + "folder l3": { // order 3 + "folder l1/folder l2/file 1 at level 2.md": "file^", + }, + "folder l1/file 1 at level 1.md": "file", + }, + }, + "folder l1/folder l2/not relevant for test.md": "folder", // order 4 + } + }) + + const expected = new bkCacheMock() + .add("folder l1", 1) + .add(" /folder l2", 2) + .add(" /folder l3", 3) + .add(" /not relevant for test.md", 4) + .getExpected() + const result = _unitTests.getOrderedBookmarks(getBookmarksMock(items), DEFAULT_BKMRK_FOLDER) + expect(result).toEqual(expected) + }) + }) +}) + +/* +Duplicate elimination logic matrix + +Originally table created in Excel, then copied & pasted toObsidian, which creates a good md format +Then converted from md to reStructuredText grid format via https://tableconvert.com/markdown-to-restructuredtext +(a great tables generator/converter in various formats) + ++---------+--------+---------------------+---------------------+----------------+--+---------------------+---------------------+--------+--+-------------+----------------------------------------------------------------------+ +| Case id | | alreadyConsumed | | | | new | | | | reject new? | Comment / scenario | +| | Object | hasSortingIndicator | bookmarkPathMatches | path=/ | | hasSortingIndicator | bookmarkPathMatches | path=/ | | | | ++=========+========+=====================+=====================+================+==+=====================+=====================+========+==+=============+======================================================================+ +| 1 | file | yes | no | yes | | yes | no | yes | | reject | both w/ indicator and not matching path. Ignore the latter. | +| 2 | file | yes | yes | N/R | | yes | no | yes | | reject | both w/ indicator, matching path wins | +| 3 | file | no | no | yes | | yes | no | yes | | take | w/ indicator wins | +| 4 | file | no | yes | N/R | | yes | no | yes | | take | w/ indicator wins | +| 5 | file | yes | no | yes | | yes | yes | N/R | | take | both w/ indicator, matching path wins | +| 6 | file | yes | yes | N/R | | yes | yes | N/R | | reject | both w/ indicator and matching path. Ignore the latter | +| 7 | file | no | no | yes | | yes | yes | N/R | | take | w/ indicator wins | +| 8 | file | no | yes | N/R | | yes | yes | N/R | | take | w/ indicator wins | +| 9 | file | yes | no | yes | | no | no | yes | | reject | Item w/o indicator never overwrites the one with the indicator | +| 10 | file | yes | yes | N/R | | no | no | yes | | reject | Item w/o indicator never overwrites the one with the indicator | +| 11 | file | no | no | yes | | no | no | yes | | reject | both w/o indicator and not matching path. Ignore the latter. | +| 12 | file | no | yes | N/R | | no | no | yes | | reject | both w/o indicator, matching path is not overwritten by not matching | +| 13 | file | yes | no | yes | | no | yes | N/R | | reject | w/ indicator wins | +| 14 | file | yes | yes | N/R | | no | yes | N/R | | reject | w/ indicator wins | +| 15 | file | no | no | yes | | no | yes | N/R | | take | both w/o indicator, matching path wins | +| 16 | file | no | yes | N/R | | no | yes | N/R | | reject | both w/o indicator and matching path. Ignore the latter. | +| | | | | | | | | | | | | +| 17 | file | Doesn't matter | Doesn't matter | Doesn't matter | | no | no | no | | reject | An item in neither of correct bookmarks locations. | +| 18 | file | Doesn't matter | Doesn't matter | Doesn't matter | | yes | no | no | | reject | An item in neither of correct bookmarks locations. | ++---------+--------+---------------------+---------------------+----------------+--+---------------------+---------------------+--------+--+-------------+----------------------------------------------------------------------+ + + +- N/R = Not Relevant +- file and folder bookmark of the same path ==> not distinguished. Folders are a subset of the above matrix. + +edge cases when path of group or folder or file overlaps + - group vs group - the first one wins, to have consistent rules (should never occur, defensive programming) + - group always wins over folder or a file w/o indicator + - group vs file w/ indicator - w/ indicator always wins + - folder vs file - not distinguished, folder treated as a file w/o indicator + +Group name with double backslash prefix: \\groupName represent a bookmark not used for sorting, only used for the structure + */ + +describe('bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants', () => { + it('empty (undefined)', () => { + const sortspecGroup = { + type: 'group', + title: 'sortspec' + } + + const result = _unitTests.bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(sortspecGroup as any) + expect(result).toBeTruthy() + }) + it('empty (zero length array)', () => { + const items = consumeBkMock({ + "sortspec": { + } + }) + + const result = _unitTests.bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(items[0]) + expect(result).toBeTruthy() + }) + it('complex empty structure', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "\\\\group l3.1": {} + }, + "\\\\group l2.2": {} + }, + "\\\\group l1.2": { + "\\\\group l2.1": {} + } + } + }) + + const result = _unitTests.bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(items[0]) + expect(result).toBeTruthy() + }) + it('complex structure - one nested not transparent group', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "group l3.1": {} + }, + "\\\\group l2.2": {} + }, + "\\\\group l1.2": { + "\\\\group l2.1": {} + } + } + }) + + const result = _unitTests.bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(items[0]) + expect(result).toBeFalsy() + }) + it('complex structure - one nested file', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "\\\\group l3.1": {} + }, + "\\\\group l2.2": { + "some file (invalid location, yet anyways a file)": "file" + } + }, + "\\\\group l1.2": { + "\\\\group l2.1": {} + } + } + }) + + const result = _unitTests.bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(items[0]) + expect(result).toBeFalsy() + }) + it('complex structure - one not nested folder', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "\\\\group l3.1": {} + }, + "\\\\group l2.2": {} + }, + "\\\\group l1.2": { + "\\\\group l2.1": {} + }, + "a folder (must be manual)": "folder" + } + }) + + const result = _unitTests.bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(items[0]) + expect(result).toBeFalsy() + }) +}) + +describe('cleanupBookmarkTreeFromTransparentEmptyGroups', () => { + it('should delete the structure up to the bookmark container group (starting point deep in hierarchy)', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "\\\\group l3.1": { + "\\\\deepest": {} + } + }, + "\\\\group l2.2": {} + }, + "\\\\group l1.2": { + "\\\\group l2.1": {} + } + } + }) + const plugin = getBookmarksMock(items) + const parentFolder: BookmarkedParentFolder|undefined = _unitTests.findGroupForItemPathInBookmarks( + 'group l1.1/group l2.1/group l3.1/deepest', + false, + plugin, + 'sortspec' + ) + _unitTests.cleanupBookmarkTreeFromTransparentEmptyGroups( + parentFolder, + plugin, + 'sortspec' + ) + expect(JSON.parse(JSON.stringify(items))).toEqual(JSON.parse(JSON.stringify(consumeBkMock({ + "sortspec": {} + })))) + }) + it('should delete 1st level group and not delete the bookmark container group (starting point flat)', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.2": {} + } + }) + const plugin = getBookmarksMock(items) + const parentFolder: BookmarkedParentFolder|undefined = _unitTests.findGroupForItemPathInBookmarks( + 'group l1.2', + false, + plugin, + 'sortspec' + ) + _unitTests.cleanupBookmarkTreeFromTransparentEmptyGroups( + parentFolder, + plugin, + 'sortspec' + ) + expect(JSON.parse(JSON.stringify(items))).toEqual(JSON.parse(JSON.stringify(consumeBkMock({ + "sortspec": {} + })))) + }) + it('should delete the structure up to the non-empty group', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "\\\\group l3.1": { + "\\\\deepest": {} + } + }, + "\\\\group l2.2": {} + }, + "\\\\group l1.2": { + "\\\\group l2.1": {}, + "group l2.2": {} + } + } + }) + const plugin = getBookmarksMock(items) + const parentFolder: BookmarkedParentFolder|undefined = _unitTests.findGroupForItemPathInBookmarks( + 'group l1.1/group l2.1/group l3.1/deepest', + false, + plugin, + 'sortspec' + ) + _unitTests.cleanupBookmarkTreeFromTransparentEmptyGroups( + parentFolder, + plugin, + 'sortspec' + ) + expect(JSON.parse(JSON.stringify(items))).toEqual(JSON.parse(JSON.stringify(consumeBkMock({ + "sortspec": { + "\\\\group l1.2": { + "\\\\group l2.1": {}, + "group l2.2": {} + } + } + })))) + }) + it('should not delete the structure because of sibling', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "\\\\group l3.1": { + "\\\\deepest 1": {}, + "deepest 2": "file^" + + } + }, + "\\\\group l2.2": { + "\\\\deepest 1": {} + } + }, + "\\\\group l1.2": { + "\\\\group l2.1": { + "\\\\deepest 1": {} + } + } + } + }) + const plugin = getBookmarksMock(items) + const parentFolder: BookmarkedParentFolder|undefined = _unitTests.findGroupForItemPathInBookmarks( + 'group l1.1/group l2.1/group l3.1/deepest', + false, + plugin, + 'sortspec' + ) + _unitTests.cleanupBookmarkTreeFromTransparentEmptyGroups( + parentFolder, + plugin, + 'sortspec' + ) + expect(JSON.parse(JSON.stringify(items))).toEqual(JSON.parse(JSON.stringify(consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "\\\\group l3.1": { + "\\\\deepest 1": {}, + "deepest 2": "file^" + + } + }, + "\\\\group l2.2": { + "\\\\deepest 1": {} + } + }, + "\\\\group l1.2": { + "\\\\group l2.1": { + "\\\\deepest 1": {} + } + } + } + })))) + }) + it('should delete the structure after multi-clean invocation', () => { + const items = consumeBkMock({ + "sortspec": { + "\\\\group l1.1": { + "\\\\group l2.1": { + "\\\\group l3.1": { + "\\\\deepest 1": {} + } + }, + "\\\\group l2.2": { + "\\\\deepest 2": {} + } + }, + "\\\\group l1.2": { + "\\\\group l2.1": { + "\\\\deepest 3": {} + } + } + } + }) + const plugin = getBookmarksMock(items) + const parentFolder1: BookmarkedParentFolder|undefined = _unitTests.findGroupForItemPathInBookmarks( + 'group l1.1/group l2.1/group l3.1/deepest 1', + false, + plugin, + 'sortspec' + ) + _unitTests.cleanupBookmarkTreeFromTransparentEmptyGroups( + parentFolder1, + plugin, + 'sortspec' + ) + const parentFolder2: BookmarkedParentFolder|undefined = _unitTests.findGroupForItemPathInBookmarks( + 'group l1.2/group l2.1/deepest 3', + false, + plugin, + 'sortspec' + ) + _unitTests.cleanupBookmarkTreeFromTransparentEmptyGroups( + parentFolder2, + plugin, + 'sortspec' + ) + expect(JSON.parse(JSON.stringify(items))).toEqual(JSON.parse(JSON.stringify(consumeBkMock({ + "sortspec": {} + })))) + }) +}) diff --git a/src/utils/BookmarksCorePluginSignature.ts b/src/utils/BookmarksCorePluginSignature.ts new file mode 100644 index 0000000..a2fff74 --- /dev/null +++ b/src/utils/BookmarksCorePluginSignature.ts @@ -0,0 +1,623 @@ +import { + InstalledPlugin, + PluginInstance, + TAbstractFile, + TFile, + TFolder +} from "obsidian"; +import { + extractParentFolderPath, + lastPathComponent +} from "./utils"; +import {Arr} from "tern"; +import * as process from "process"; + +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 BookmarkItemSuperset { + path: Path + title?: string + ctime: number + subpath?: string // Anchor within the file (heading and/or block ref) +} + +interface BookmarkWithPath extends Pick { +} + +interface BookmarkedFile extends BookmarkItemSuperset { + type: 'file' +} + +interface BookmarkedFolder extends Omit { + type: 'folder' +} + +interface BookmarkedGroup extends Omit { + type: 'group' + items: Array +} + +export type BookmarkedItemPath = string + +export interface OrderedBookmarkedItemWithMetadata { + isGroup?: boolean + path: BookmarkedItemPath + hasSortingIndicator?: boolean + order: number + bookmarkPathMatches?: boolean +} + +export type OrderedBookmarkedItem = Pick +export type Order = number + +export interface OrderedBookmarks { + [key: BookmarkedItemPath]: Order +} + +export interface OrderedBookmarksWithMetadata { + [key: BookmarkedItemPath]: OrderedBookmarkedItemWithMetadata +} + +interface Bookmarks_PluginInstance extends PluginInstance { + [BookmarksPlugin_getBookmarks_methodName]: () => Array | undefined + [BookmarksPlugin_items_collectionName]: Array + saveData(): void + onItemsChanged(saveData: boolean): void +} + +export interface BookmarksPluginInterface { + determineBookmarkOrder(path: string): number|undefined + bookmarkFolderItem(item: TAbstractFile): void + unbookmarkFolderItem(item: TAbstractFile): void + saveDataAndUpdateBookmarkViews(updateBookmarkViews: boolean): void + bookmarkSiblings(siblings: Array, inTheTop?: boolean): void + unbookmarkSiblings(siblings: Array): void + updateSortingBookmarksAfterItemRenamed(renamedItem: TAbstractFile, oldPath: string): void + updateSortingBookmarksAfterItemDeleted(deletedItem: TAbstractFile): void + isBookmarkedForSorting(item: TAbstractFile): boolean + + // To support performance optimization + bookmarksIncludeItemsInFolder(folderPath: string): boolean +} + +const checkSubtreeForOnlyTransparentGroups = (items: Array): 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 { + + plugin: Bookmarks_PluginInstance|undefined + groupNameForSorting: string|undefined + + constructor () { + } + + // 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 + // + // Parameterless invocation enforces cache population, if empty + determineBookmarkOrder = (path?: string): Order | undefined => { + if (!bookmarksCache) { + [bookmarksCache, bookmarksFoldersCoverage] = getOrderedBookmarks(this.plugin!, this.groupNameForSorting) + bookmarksCacheTimestamp = Date.now() + } + + if (path && path.length > 0) { + const bookmarkedItemPosition: Order | undefined = bookmarksCache?.[path] + + return (bookmarkedItemPosition && bookmarkedItemPosition > 0) ? bookmarkedItemPosition : undefined + } else { + return undefined + } + } + + bookmarkFolderItem = (item: TAbstractFile) => { + this.bookmarkSiblings([item], true) + } + + unbookmarkFolderItem = (item: TAbstractFile) => { + this.unbookmarkSiblings([item]) + } + + saveDataAndUpdateBookmarkViews = (updateBookmarkViews: boolean = true) => { + this.plugin!.onItemsChanged(true) + if (updateBookmarkViews) { + const bookmarksLeafs = app.workspace.getLeavesOfType('bookmarks') + bookmarksLeafs?.forEach((leaf) => { + (leaf.view as any)?.update?.() + }) + } + } + + bookmarkSiblings = (siblings: Array, inTheTop?: boolean) => { + if (siblings.length === 0) return // for sanity + + const bookmarksContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks( + siblings[0].path, + CreateIfMissing, + this.plugin!, + this.groupNameForSorting + ) + + if (bookmarksContainer) { // for sanity, the group should be always created if missing + siblings.forEach((aSibling) => { + const siblingName = lastPathComponent(aSibling.path) + 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 === 'group' && it.title === siblingName))) { + const newEntry: BookmarkedItem = (aSibling instanceof TFolder) ? createBookmarkGroupEntry(siblingName) : createBookmarkFileEntry(aSibling.path); + if (inTheTop) { + bookmarksContainer.items.unshift(newEntry) + } else { + bookmarksContainer.items.push(newEntry) + } + } + }); + } + } + + unbookmarkSiblings = (siblings: Array) => { + 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 = [] + 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(this.plugin!, renamedItem, oldPath, this.groupNameForSorting) + } + + updateSortingBookmarksAfterItemDeleted = (deletedItem: TAbstractFile): void => { + updateSortingBookmarksAfterItemDeleted(this.plugin!, deletedItem, this.groupNameForSorting) + } + + isBookmarkedForSorting = (item: TAbstractFile): boolean => { + const itemsCollection: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(item.path, DontCreateIfMissing, this.plugin!, this.groupNameForSorting) + if (itemsCollection) { + if (item instanceof TFile) { + return itemsCollection.items?.some((bkmrk) => bkmrk.type === 'file' && bkmrk.path === item.path) + } else { + const folderName: string = lastPathComponent(item.path) + return itemsCollection.items?.some((bkmrk) => + (bkmrk.type === 'group' && bkmrk.title === folderName) || + (bkmrk.type === 'folder' && bkmrk.path === item.path) + ) + } + } + return false + } + + bookmarksIncludeItemsInFolder = (folderPath: string): boolean => { + return !! bookmarksFoldersCoverage?.[folderPath] + } +} + +export const BookmarksCorePluginId: string = 'bookmarks' + +export const getBookmarksPlugin = (bookmarksGroupName?: string, forceFlushCache?: boolean, ensureCachePopulated?: boolean): BookmarksPluginInterface | undefined => { + invalidateExpiredBookmarksCache(forceFlushCache) + const installedBookmarksPlugin: InstalledPlugin | undefined = app?.internalPlugins?.getPluginById(BookmarksCorePluginId) + if (installedBookmarksPlugin && installedBookmarksPlugin.enabled && installedBookmarksPlugin.instance) { + const bookmarksPluginInstance: Bookmarks_PluginInstance = installedBookmarksPlugin.instance as Bookmarks_PluginInstance + // defensive programming, in case Obsidian changes its internal APIs + if (typeof bookmarksPluginInstance?.[BookmarksPlugin_getBookmarks_methodName] === 'function' && + Array.isArray(bookmarksPluginInstance?.[BookmarksPlugin_items_collectionName])) { + bookmarksPlugin.plugin = bookmarksPluginInstance + bookmarksPlugin.groupNameForSorting = bookmarksGroupName + if (ensureCachePopulated && !bookmarksCache) { + bookmarksPlugin.determineBookmarkOrder() + } + return bookmarksPlugin + } + } +} + +// cache can outlive the wrapper instances +let bookmarksCache: OrderedBookmarks | undefined = undefined +let bookmarksCacheTimestamp: number | undefined = undefined +type FolderPath = string +type FoldersCoverage = { [key: FolderPath]: boolean } +let bookmarksFoldersCoverage: FoldersCoverage | undefined = undefined + +const bookmarksPlugin: BookmarksPluginWrapper = new BookmarksPluginWrapper() + +const CacheExpirationMilis = 1000 // One second seems to be reasonable + +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 + bookmarksFoldersCoverage = undefined + } + } +} + +type TraverseCallback = (item: BookmarkedItem, parentsGroupsPath: string) => boolean | void + +const traverseBookmarksCollection = (items: Array, callbackConsumeItem: TraverseCallback) => { + if (!Array.isArray(items)) return + const recursiveTraversal = (collection: Array, groupsPath: string) => { + if (!Array.isArray(collection)) return + for (let idx = 0, collectionRef = collection; idx < collectionRef.length; idx++) { + const item = collectionRef[idx]; + if (callbackConsumeItem(item, groupsPath)) return; + if ('group' === item.type) { + const groupNameToUseInPath: string = groupNameForPath(item.title || '') + recursiveTraversal(item.items, `${groupsPath}${groupsPath?'/':''}${groupNameToUseInPath}`); + } + } + }; + recursiveTraversal(items, ''); +} + +const ARTIFICIAL_ANCHOR_SORTING_BOOKMARK_INDICATOR = '#^-' + +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] => { + let bookmarksItems: Array | undefined = plugin?.[BookmarksPlugin_items_collectionName] + let bookmarksCoveredFolders: FoldersCoverage = {} + if (bookmarksItems && Array.isArray(bookmarksItems)) { + if (bookmarksGroupName) { + // scanning only top level items because by design the bookmarks group for sorting is a top level item + const bookmarksGroup: BookmarkedGroup|undefined = bookmarksItems.find( + (item) => item.type === 'group' && item.title === bookmarksGroupName + ) as BookmarkedGroup + bookmarksItems = bookmarksGroup ? bookmarksGroup.items : undefined + } + if (bookmarksItems) { + const orderedBookmarksWithMetadata: OrderedBookmarksWithMetadata = {} + let order: number = 1 // Intentionally start > 0 to allow easy check: if (order) ... + const consumeItem = (item: BookmarkedItem, parentGroupsPath: string) => { + if ('group' === item.type) { + if (!isGroupTransparentForSorting(item.title)) { + const path: string = `${parentGroupsPath}${parentGroupsPath ? '/' : ''}${item.title}` + const alreadyConsumed = orderedBookmarksWithMetadata[path] + if (alreadyConsumed) { + if (alreadyConsumed.isGroup) return // Defensive programming + if (alreadyConsumed.hasSortingIndicator) return + } + + orderedBookmarksWithMetadata[path] = { + path: path, + order: order++, + isGroup: true + } + } + } 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) + + 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 [undefined, undefined] +} + +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 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 + items: Array // reference to group.items or to root collection of bookmarks +} + +const findGroupForItemPathInBookmarks = (itemPath: string, createIfMissing: boolean, plugin: Bookmarks_PluginInstance, bookmarksGroup?: string): BookmarkedParentFolder|undefined => { + let items = plugin?.[BookmarksPlugin_items_collectionName] + + if (!Array.isArray(items)) return undefined + + 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, index) => { + group = items.find((it) => it.type === 'group' && groupNameForPath(it.title||'') === pathSegment) as BookmarkedGroup + if (!group) { + if (createIfMissing) { + const theSortingBookmarksContainerGroup = (bookmarksGroup && index === 0) + const groupName: string = theSortingBookmarksContainerGroup ? pathSegment : groupNameTransparentForSorting(pathSegment) + group = createBookmarkGroupEntry(groupName) + items.push(group) + } else { + return undefined + } + } + + items = group.items + }) + + return { + items: items, + group: group, + pathOfGroup: parentPath + } +} + +const CreateIfMissing = true +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) => { + + if (renamedItem.path === oldPath) return; // sanity + + const aFolder: boolean = renamedItem instanceof TFolder + 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 + + // 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) + + if (!originalContainer) return; + + const item: BookmarkedItem|undefined = aFolder ? + originalContainer.items.find((it) => ( + it.type === 'group' && groupNameForPath(it.title||'') === oldName + )) + : // aFile + originalContainer.items.find((it) => ( + it.type === 'file' && it.path === renamedItem.path + )) + + if (!item) return; + + // 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 + + 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) { + originalContainer.group?.items.remove(aGroup) + const targetContainer: BookmarkedParentFolder | undefined = findGroupForItemPathInBookmarks(renamedItem.path, CreateIfMissing, plugin, bookmarksGroup) + if (targetContainer) { + targetContainer.group?.items.push(aGroup) + // the group in new location becomes by design transparent for sorting. + // 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 (renamed) { + // unrealistic scenario when a folder is moved and renamed at the same time + renameGroup(aGroup, newName, undefined) + } + } + } +} + +const updateSortingBookmarksAfterItemDeleted = (plugin: Bookmarks_PluginInstance, deletedItem: TAbstractFile, bookmarksGroup?: string) => { + + // Obsidian automatically deletes all bookmark instances of a file, nothing to be done here + if (deletedItem instanceof TFile) return; + + let items = plugin[BookmarksPlugin_items_collectionName] + + if (!Array.isArray(items)) return + + const aFolder: boolean = deletedItem instanceof TFolder + const aFile: boolean = !aFolder + + // 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 (!bookmarksContainer && !bookmarksRootContainer) return; + + [bookmarksContainer, bookmarksRootContainer].forEach((container) => { + const bookmarkEntriesToRemove: Array = [] + 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) + }) +} + +export const _unitTests = { + getOrderedBookmarks: getOrderedBookmarks, + bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants: bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants, + cleanupBookmarkTreeFromTransparentEmptyGroups: cleanupBookmarkTreeFromTransparentEmptyGroups, + findGroupForItemPathInBookmarks: findGroupForItemPathInBookmarks +} diff --git a/src/utils/utils.spec.ts b/src/utils/utils.spec.ts index 5189d85..eeb8eb5 100644 --- a/src/utils/utils.spec.ts +++ b/src/utils/utils.spec.ts @@ -1,4 +1,7 @@ -import {lastPathComponent, extractParentFolderPath} from "./utils"; +import { + lastPathComponent, + extractParentFolderPath +} from "./utils"; describe('lastPathComponent and extractParentFolderPath', () => { it.each([ @@ -6,14 +9,14 @@ describe('lastPathComponent and extractParentFolderPath', () => { ['a/subfolder', 'a', 'subfolder'], ['parent/child', 'parent', 'child'], ['','',''], - [' ','',''], + [' ','',' '], ['/strange', '', 'strange'], ['a/b/c/', 'a/b/c', ''], ['d d d/e e e/f f f/ggg ggg', 'd d d/e e e/f f f', 'ggg ggg'], ['/','',''], - [' / ','',''], - [' /','',''], - ['/ ','',''] + [' / ',' ',' '], + [' /',' ',''], + ['/ ','',' '] ])('should from %s extract %s and %s', (path: string, parentPath: string, lastComponent: string) => { const extractedParentPath: string = extractParentFolderPath(path) const extractedLastComponent: string = lastPathComponent(path) diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 5afece4..4e767d5 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -9,10 +9,10 @@ export function last(o: Array): T | undefined { export function lastPathComponent(path: string): string { const lastPathSeparatorIdx = (path ?? '').lastIndexOf('/') - return lastPathSeparatorIdx >= 0 ? path.substring(lastPathSeparatorIdx + 1).trim() : path.trim() + return lastPathSeparatorIdx >= 0 ? path.substring(lastPathSeparatorIdx + 1) : path } export function extractParentFolderPath(path: string): string { const lastPathSeparatorIdx = (path ?? '').lastIndexOf('/') - return lastPathSeparatorIdx > 0 ? path.substring(0, lastPathSeparatorIdx).trim() : '' + return lastPathSeparatorIdx > 0 ? path.substring(0, lastPathSeparatorIdx) : '' } diff --git a/yarn.lock b/yarn.lock index 4a78e36..ba84ad8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -763,6 +763,13 @@ dependencies: "@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@*": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" @@ -2396,6 +2403,14 @@ npm-run-path@^4.0.1: dependencies: 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: version "0.15.9" resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-0.15.9.tgz#b6e0b566952643db6b55f7e74fbba6d7140fe4a2"