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
This commit is contained in:
SebastianMC 2023-10-21 23:16:30 +02:00 committed by GitHub
parent 23b50da6cf
commit fe4f28b46b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2593 additions and 41 deletions

View File

@ -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\

View File

@ -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"

View File

@ -1,8 +1,6 @@
import {
FolderItemForSorting,
getComparator,
getSorterFnFor,
getMdata,
OS_byCreatedTime,
OS_byModifiedTime,
OS_byModifiedTimeReverse, SortingLevelId

View File

@ -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
}

View File

@ -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<CustomSortSpec>|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<CustomSortSpec>|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<CustomSortSpec>|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<CustomSortSpec>|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<CustomSortSpec>|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<CustomSortSpec>|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<CustomSortSpec>|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<CustomSortSpec>|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<string> = 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

View File

@ -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
}

View File

@ -9,12 +9,24 @@ import {
getSorterFnFor,
matchGroupRegex,
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<FolderItemForSorting> = {bookmarkedIdx: bookmarkA, sortString: sortStringA}
const itemB: Partial<FolderItemForSorting> = {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)
})
})

View File

@ -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>([
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<FolderItemForSor
})
}
// Order by bookmarks order can be applied independently of grouping by bookmarked status
// This function determines the bookmarked order if the sorting criteria (of group or entire folder) requires it
export const determineBookmarksOrderIfNeeded = (folderItems: Array<FolderItemForSorting>, 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<TAbstractFile>, sortingSpec: CustomSortSpec|null|undefined, ctx: ProcessingContext, uiSortOrder: string): Array<TAbstractFile> {
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<FolderItemForSorting> = 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<TAbstractFile> = 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<TAbstractFile> = items.map((entry: TFile | TFolder) => entry)
const plainSorterFn: PlainSorterFn = StandardPlainObsidianComparator(uiSortOrder)
folderItems.sort(plainSorterFn)
return folderItems
}
};

View File

@ -1,4 +1,6 @@
import {FolderWildcardMatching} from './folder-matching-rules'
import {
FolderWildcardMatching
} from './folder-matching-rules'
type SortingSpec = string

View File

@ -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([

View File

@ -12,7 +12,6 @@ import {
WordInASCIIRegexStr,
WordInAnyLanguageRegexStr
} from "./matchers";
import {SortingSpecProcessor} from "./sorting-spec-processor";
describe('Plain numbers regexp', () => {
let regexp: RegExp;

View File

@ -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",

View File

@ -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 {

View File

@ -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<CustomSortPluginSettings> = {
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<TAbstractFile> = 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<TAbstractFile> = 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<TAbstractFile> {
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'
+ ' <pre style="display: inline;">sorting-spec:</pre> configurations and they can nicely cooperate.'
+ '<br>'
+ '<p>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 '
+ "'<i>" + DEFAULT_SETTINGS.bookmarksGroupToConsumeAsOrderingReference + "</i>' "
+ 'and you can change the group name in the configuration field below.'
+ '<br>'
+ 'If left empty, all the bookmarked items will be used to impose the order in File Explorer.</p>'
+ '<p>More information on this functionality in the '
+ '<a href="https://github.com/SebastianMC/obsidian-custom-sort/blob/master/docs/manual.md#bookmarks-plugin-integration">'
+ 'manual</a> of this custom-sort plugin.'
+ '</p>'
)
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 <i>Custom-sort: bookmark for sorting</i> and <i>Custom-sort: bookmark+siblings for sorting.</i> 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();
}))
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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<BookmarkItemSuperset, 'path'> {
}
interface BookmarkedFile extends BookmarkItemSuperset {
type: 'file'
}
interface BookmarkedFolder extends Omit<BookmarkItemSuperset, 'subpath'> {
type: 'folder'
}
interface BookmarkedGroup extends Omit<BookmarkItemSuperset, 'subpath'|'path'> {
type: 'group'
items: Array<BookmarkedItem>
}
export type BookmarkedItemPath = string
export interface OrderedBookmarkedItemWithMetadata {
isGroup?: boolean
path: BookmarkedItemPath
hasSortingIndicator?: boolean
order: number
bookmarkPathMatches?: boolean
}
export type OrderedBookmarkedItem = Pick<OrderedBookmarkedItemWithMetadata, 'order'>
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<BookmarkedItem> | undefined
[BookmarksPlugin_items_collectionName]: Array<BookmarkedItem>
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<TAbstractFile>, inTheTop?: boolean): void
unbookmarkSiblings(siblings: Array<TAbstractFile>): 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<BookmarkedItem>): boolean => {
if (!items || items?.length === 0) return true
for (let it of items) {
if (it.type !== 'group' || !it.title || !isGroupTransparentForSorting(it.title)) {
return false
}
// it is a group transparent for sorting
const isEmptyOrTransparent: boolean = checkSubtreeForOnlyTransparentGroups(it.items)
if (!isEmptyOrTransparent) {
return false
}
}
return true
}
const bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants = (group: BookmarkedGroup): boolean => {
return checkSubtreeForOnlyTransparentGroups(group.items)
}
class BookmarksPluginWrapper implements BookmarksPluginInterface {
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<TAbstractFile>, 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<TAbstractFile>) => {
if (siblings.length === 0) return // for sanity
const bookmarksContainer: BookmarkedParentFolder|undefined = findGroupForItemPathInBookmarks(
siblings[0].path,
DontCreateIfMissing,
this.plugin!,
this.groupNameForSorting
)
if (bookmarksContainer) { // for sanity
const bookmarkedItemsToRemove: Array<BookmarkedItem> = []
siblings.forEach((aSibling) => {
const siblingName = lastPathComponent(aSibling.path)
const aGroup = bookmarksContainer.items.find(
(it) => (it.type === 'group' && groupNameForPath(it.title||'') === siblingName)
)
if (aGroup) {
const canBeRemoved = bookmarkedGroupEmptyOrOnlyTransparentForSortingDescendants(aGroup as BookmarkedGroup)
if (canBeRemoved) {
bookmarksContainer.items.remove(aGroup)
cleanupBookmarkTreeFromTransparentEmptyGroups(bookmarksContainer, this.plugin!, this.groupNameForSorting)
} else {
if (!isGroupTransparentForSorting(aGroup.title)) {
aGroup.title = groupNameTransparentForSorting(aGroup.title||'')
}
}
} else {
const aFileOrFolder = bookmarksContainer.items.find(
(it) => ((it.type === 'folder' || it.type === 'file') && it.path === aSibling.path)
)
if (aFileOrFolder) {
bookmarksContainer.items.remove(aFileOrFolder)
cleanupBookmarkTreeFromTransparentEmptyGroups(bookmarksContainer, this.plugin!, this.groupNameForSorting)
}
}
});
}
}
updateSortingBookmarksAfterItemRenamed = (renamedItem: TAbstractFile, oldPath: string): void => {
updateSortingBookmarksAfterItemRenamed(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<BookmarkedItem>, callbackConsumeItem: TraverseCallback) => {
if (!Array.isArray(items)) return
const recursiveTraversal = (collection: Array<BookmarkedItem>, 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<BookmarkedItem> | 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<BookmarkedItem> // 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<string> = 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<BookmarkedItem> = []
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
}

View File

@ -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)

View File

@ -9,10 +9,10 @@ export function last<T>(o: Array<T>): 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) : ''
}

View File

@ -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"