From eec23a42c5441392e71c2a2e6f0103d0dfdf7873 Mon Sep 17 00:00:00 2001 From: wz <44930227+wenlzhang@users.noreply.github.com> Date: Fri, 3 Oct 2025 23:53:44 +0200 Subject: [PATCH] feat: add tag sorting options with frequency, name, and added time methods --- src/settings.ts | 12 ++ src/settingsTab.ts | 26 ++- src/tagIndexView.ts | 479 +++++++++++++++++++++++++++++++++++++------- styles.css | 15 ++ 4 files changed, 454 insertions(+), 78 deletions(-) diff --git a/src/settings.ts b/src/settings.ts index 6b8ece8..fc1c67a 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,6 +4,7 @@ export interface ImportantTag { name: string; position: number; isNested?: boolean; // Whether this tag was added from a hierarchical context + addedTime?: number; // Timestamp when tag was added to index } export type NoteSortMethod = @@ -14,6 +15,15 @@ export type NoteSortMethod = | "created-new" | "created-old"; +export type TagSortMethod = + | "custom" + | "frequency-high" + | "frequency-low" + | "name-asc" + | "name-desc" + | "added-new" + | "added-old"; + export interface TagIndexSettings { importantTags: ImportantTag[]; addTagsToTop: boolean; @@ -26,6 +36,7 @@ export interface TagIndexSettings { cursorPosition: "start" | "end"; // Where to place cursor when jumping to line refreshDelay: number; // Delay in milliseconds before refreshing after file changes (0-3600000ms = 0-60min) noteSortMethod: NoteSortMethod; // How to sort notes under tags + tagSortMethod: TagSortMethod; // How to sort tags in the index } export const DEFAULT_SETTINGS: TagIndexSettings = { @@ -40,4 +51,5 @@ export const DEFAULT_SETTINGS: TagIndexSettings = { cursorPosition: "end", refreshDelay: 500, noteSortMethod: "modified-new", + tagSortMethod: "custom", }; diff --git a/src/settingsTab.ts b/src/settingsTab.ts index 2f268c1..587ad4b 100644 --- a/src/settingsTab.ts +++ b/src/settingsTab.ts @@ -136,10 +136,34 @@ export class TagIndexSettingTab extends PluginSettingTab { ); } + new Setting(containerEl) + .setName("Default tag sort method") + .setDesc( + "Choose how tags in the index are sorted. Custom order allows drag-and-drop. You can also change this by clicking the left sort button (list icon) in the tag panel.", + ) + .addDropdown((dropdown) => + dropdown + .addOption("custom", "Custom order") + .addOption("frequency-high", "Frequency (high to low)") + .addOption("frequency-low", "Frequency (low to high)") + .addOption("name-asc", "Tag name (A to Z)") + .addOption("name-desc", "Tag name (Z to A)") + .addOption("added-new", "Added time (new to old)") + .addOption("added-old", "Added time (old to new)") + .setValue(this.plugin.settings.tagSortMethod) + .onChange(async (value) => { + this.plugin.settings.tagSortMethod = value as any; + await this.plugin.saveSettings(); + if (this.plugin.tagIndexView) { + await this.plugin.tagIndexView.renderTagsAndRestoreExpansion(); + } + }), + ); + new Setting(containerEl) .setName("Default note sort method") .setDesc( - "Choose how notes under each tag should be sorted. You can also change this by clicking the sort button in the tag index panel.", + "Choose how notes under each tag are sorted. You can also change this by clicking the right sort button (arrow icon) in the tag panel.", ) .addDropdown((dropdown) => dropdown diff --git a/src/tagIndexView.ts b/src/tagIndexView.ts index 8c99b04..7136041 100644 --- a/src/tagIndexView.ts +++ b/src/tagIndexView.ts @@ -11,7 +11,7 @@ import { MarkdownView, } from "obsidian"; import type TagIndexPlugin from "./main"; -import { ImportantTag, NoteSortMethod } from "./settings"; +import { ImportantTag, NoteSortMethod, TagSortMethod } from "./settings"; export const TAG_INDEX_VIEW_TYPE = "tag-index-view"; @@ -66,30 +66,45 @@ export class TagIndexView extends ItemView { const container = this.containerEl.children[1]; container.empty(); - // Create a minimal header with an icon instead of the text heading + // Create a minimal header with icons const headerContainer = container.createDiv({ cls: "tag-index-header", }); - // Add sort button - const sortButton = headerContainer.createSpan({ + // Left side: Sort buttons container + const leftButtons = headerContainer.createDiv({ + cls: "tag-index-header-left", + }); + + // Add tag sort button (leftmost) + const tagSortButton = leftButtons.createSpan({ cls: "tag-index-sort-button", }); - setIcon(sortButton, "arrow-up-down"); - sortButton.setAttribute("title", this.getSortMethodLabel()); + setIcon(tagSortButton, "list-ordered"); + tagSortButton.setAttribute("title", this.getTagSortMethodLabel()); - // Add click handler for sort button - sortButton.addEventListener("click", (e: MouseEvent) => { - this.showSortMenu(e, sortButton); + // Add click handler for tag sort button + tagSortButton.addEventListener("click", (e: MouseEvent) => { + this.showTagSortMenu(e, tagSortButton); }); - // Add a small info icon that can show a tooltip on hover + // Add note sort button (next to tag sort) + const noteSortButton = leftButtons.createSpan({ + cls: "tag-index-sort-button", + }); + setIcon(noteSortButton, "arrow-up-down"); + noteSortButton.setAttribute("title", this.getNoteSortMethodLabel()); + + // Add click handler for note sort button + noteSortButton.addEventListener("click", (e: MouseEvent) => { + this.showNoteSortMenu(e, noteSortButton); + }); + + // Right side: Info icon const infoIcon = headerContainer.createSpan({ cls: "tag-index-info-icon", }); setIcon(infoIcon, "info"); - - // Use a single title attribute for the tooltip infoIcon.setAttribute("title", "Important tags from tag index"); this.tagContainer = container.createDiv({ cls: "tag-index-container" }); @@ -196,17 +211,147 @@ export class TagIndexView extends ItemView { return; } - // Build tree structure from all tags - const tree = this.buildTree(); + // Sort tags according to user preference + const sortedTags = this.sortTags([ + ...this.plugin.settings.importantTags, + ]); + + // Build tree structure from sorted tags + const tree = this.buildTree(sortedTags); // Render the tree this.renderTree(tree, this.tagContainer, 0); } - private buildTree(): Map { - const root = new Map(); + private sortTags(tags: ImportantTag[]): ImportantTag[] { + const method = this.plugin.settings.tagSortMethod; - for (const tag of this.plugin.settings.importantTags) { + // Custom order: use position-based sorting (current behavior) + if (method === "custom") { + return tags.sort((a, b) => a.position - b.position); + } + + // Calculate frequencies for frequency-based sorting + const frequencies = new Map(); + if (method === "frequency-high" || method === "frequency-low") { + tags.forEach((tag) => { + const count = this.getTagFrequency(tag.name); + frequencies.set(tag.name, count); + }); + } + + // Apply sorting + const sorted = [...tags]; + switch (method) { + case "frequency-high": + sorted.sort((a, b) => { + const freqA = frequencies.get(a.name) || 0; + const freqB = frequencies.get(b.name) || 0; + if (freqB !== freqA) return freqB - freqA; + // Secondary sort by name + return a.name.localeCompare(b.name, undefined, { + numeric: true, + sensitivity: "base", + }); + }); + break; + case "frequency-low": + sorted.sort((a, b) => { + const freqA = frequencies.get(a.name) || 0; + const freqB = frequencies.get(b.name) || 0; + if (freqA !== freqB) return freqA - freqB; + // Secondary sort by name + return a.name.localeCompare(b.name, undefined, { + numeric: true, + sensitivity: "base", + }); + }); + break; + case "name-asc": + sorted.sort((a, b) => + a.name.localeCompare(b.name, undefined, { + numeric: true, + sensitivity: "base", + }), + ); + break; + case "name-desc": + sorted.sort((a, b) => + b.name.localeCompare(a.name, undefined, { + numeric: true, + sensitivity: "base", + }), + ); + break; + case "added-new": + sorted.sort((a, b) => { + const timeA = a.addedTime || 0; + const timeB = b.addedTime || 0; + if (timeB !== timeA) return timeB - timeA; + // Secondary sort by position for tags without timestamp + return a.position - b.position; + }); + break; + case "added-old": + sorted.sort((a, b) => { + const timeA = a.addedTime || 0; + const timeB = b.addedTime || 0; + if (timeA !== timeB) return timeA - timeB; + // Secondary sort by position for tags without timestamp + return a.position - b.position; + }); + break; + } + + return sorted; + } + + private getTagFrequency(tagName: string): number { + const tagNameWithoutHash = tagName.startsWith("#") + ? tagName.substring(1) + : tagName; + + // Count files that contain this tag + const count = this.app.vault + .getMarkdownFiles() + .filter((file: TFile) => { + const cache = this.app.metadataCache.getFileCache(file); + if (!cache) return false; + + // Check inline tags + if (cache.tags) { + const hasInlineTag = cache.tags.some( + (tagCache) => tagCache.tag === `#${tagNameWithoutHash}`, + ); + if (hasInlineTag) return true; + } + + // Check frontmatter tags + if (cache.frontmatter && cache.frontmatter.tags) { + const frontmatterTags = cache.frontmatter.tags; + if (Array.isArray(frontmatterTags)) { + return frontmatterTags.some( + (tag) => tag === tagNameWithoutHash, + ); + } else if (typeof frontmatterTags === "string") { + return frontmatterTags + .split(",") + .map((t) => t.trim()) + .includes(tagNameWithoutHash); + } + } + + return false; + }).length; + + return count; + } + + private buildTree(tags?: ImportantTag[]): Map { + const root = new Map(); + const tagsToUse = tags || this.plugin.settings.importantTags; + + for (const tag of tagsToUse) { const segments = tag.name.split("/"); let currentLevel = root; let currentPath = ""; @@ -272,17 +417,45 @@ export class TagIndexView extends ItemView { level: number, ): void { const sortedNodes = Array.from(nodes.values()).sort((a, b) => { - const posA = this.getEffectivePosition(a); - const posB = this.getEffectivePosition(b); + // For custom sort, use position + if (this.plugin.settings.tagSortMethod === "custom") { + const posA = this.getEffectivePosition(a); + const posB = this.getEffectivePosition(b); - // Sort by position - if (posA !== posB) { - return posA - posB; + if (posA !== posB) { + return posA - posB; + } + + return a.name.localeCompare(b.name); } - // If positions are equal (shouldn't happen for well-formed data), - // fall back to alphabetical sorting - return a.name.localeCompare(b.name); + // For other sort methods, we've already sorted the tags before building the tree + // But for tree nodes (intermediate nodes without tags), we sort by name + const tagA = a.tag; + const tagB = b.tag; + + // If both are actual tags, they should already be in the right order from sortTags + // Just maintain their relative order by comparing the sorted tag list + if (tagA && tagB) { + const sortedTags = this.sortTags([ + ...this.plugin.settings.importantTags, + ]); + const indexA = sortedTags.findIndex( + (t) => t.name === tagA.name, + ); + const indexB = sortedTags.findIndex( + (t) => t.name === tagB.name, + ); + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + } + + // For intermediate nodes or fallback, sort alphabetically + return a.fullPath.localeCompare(b.fullPath, undefined, { + numeric: true, + sensitivity: "base", + }); }); for (const node of sortedNodes) { @@ -347,48 +520,75 @@ export class TagIndexView extends ItemView { }); } - // Make all nodes draggable (both actual tags and intermediate nodes) - headerEl.setAttribute("draggable", "true"); - headerEl.addEventListener("dragstart", (e: DragEvent) => { - this.draggedTag = node.fullPath; - this.draggedNode = node; - this.draggedElement = nodeEl; - nodeEl.addClass("tag-being-dragged"); - if (e.dataTransfer) { - e.dataTransfer.setData("text/plain", node.fullPath); - e.dataTransfer.effectAllowed = "move"; - } - }); + // Only enable drag-and-drop in custom sort mode + const isCustomMode = this.plugin.settings.tagSortMethod === "custom"; - headerEl.addEventListener("dragend", () => { - nodeEl.removeClass("tag-being-dragged"); - this.draggedTag = null; - this.draggedNode = null; - this.draggedElement = null; - }); + if (isCustomMode) { + // Make all nodes draggable (both actual tags and intermediate nodes) + headerEl.setAttribute("draggable", "true"); + headerEl.addEventListener("dragstart", (e: DragEvent) => { + this.draggedTag = node.fullPath; + this.draggedNode = node; + this.draggedElement = nodeEl; + nodeEl.addClass("tag-being-dragged"); + if (e.dataTransfer) { + e.dataTransfer.setData("text/plain", node.fullPath); + e.dataTransfer.effectAllowed = "move"; + } + }); - nodeEl.addEventListener("dragover", (e: DragEvent) => { - e.preventDefault(); - if (this.draggedTag && this.draggedTag !== node.fullPath) { - nodeEl.addClass("tag-drag-over"); - } - }); + headerEl.addEventListener("dragend", () => { + nodeEl.removeClass("tag-being-dragged"); + this.draggedTag = null; + this.draggedNode = null; + this.draggedElement = null; + }); - nodeEl.addEventListener("dragleave", () => { - nodeEl.removeClass("tag-drag-over"); - }); + nodeEl.addEventListener("dragover", (e: DragEvent) => { + e.preventDefault(); + if (this.draggedTag && this.draggedTag !== node.fullPath) { + nodeEl.addClass("tag-drag-over"); + } + }); - nodeEl.addEventListener("drop", (e: DragEvent) => { - e.preventDefault(); - nodeEl.removeClass("tag-drag-over"); - if ( - this.draggedTag && - this.draggedTag !== node.fullPath && - this.draggedNode - ) { - this.moveNodeOrTag(this.draggedNode, node); - } - }); + nodeEl.addEventListener("dragleave", () => { + nodeEl.removeClass("tag-drag-over"); + }); + + nodeEl.addEventListener("drop", (e: DragEvent) => { + e.preventDefault(); + nodeEl.removeClass("tag-drag-over"); + if ( + this.draggedTag && + this.draggedTag !== node.fullPath && + this.draggedNode + ) { + this.moveNodeOrTag(this.draggedNode, node); + } + }); + + // Add visual indicator for draggable items + headerEl.addClass("tag-index-draggable"); + } else { + // In non-custom mode, show a notice when user tries to interact + headerEl.setAttribute("draggable", "false"); + let dragAttemptNoticeShown = false; + + headerEl.addEventListener("mousedown", () => { + // Reset the flag when user starts a new interaction + dragAttemptNoticeShown = false; + }); + + headerEl.addEventListener("dragstart", (e: DragEvent) => { + e.preventDefault(); + if (!dragAttemptNoticeShown) { + new Notice( + "Drag and drop is only available in custom sort mode. Change sort method to 'Custom order' to rearrange tags.", + ); + dragAttemptNoticeShown = true; + } + }); + } // Add children container const childrenContainer = nodeEl.createDiv({ @@ -441,27 +641,118 @@ export class TagIndexView extends ItemView { await this.plugin.saveSettings(); } - private getSortMethodLabel(): string { + private getNoteSortMethodLabel(): string { const method = this.plugin.settings.noteSortMethod; switch (method) { case "file-name-asc": - return "Sort by: File name (A to Z)"; + return "Sort notes: File name (A to Z)"; case "file-name-desc": - return "Sort by: File name (Z to A)"; + return "Sort notes: File name (Z to A)"; case "modified-new": - return "Sort by: Modified time (new to old)"; + return "Sort notes: Modified time (new to old)"; case "modified-old": - return "Sort by: Modified time (old to new)"; + return "Sort notes: Modified time (old to new)"; case "created-new": - return "Sort by: Created time (new to old)"; + return "Sort notes: Created time (new to old)"; case "created-old": - return "Sort by: Created time (old to new)"; + return "Sort notes: Created time (old to new)"; default: - return "Sort by: File name (A to Z)"; + return "Sort notes: File name (A to Z)"; } } - private showSortMenu(event: MouseEvent, buttonEl: HTMLElement): void { + private getTagSortMethodLabel(): string { + const method = this.plugin.settings.tagSortMethod; + switch (method) { + case "custom": + return "Sort tags: Custom order"; + case "frequency-high": + return "Sort tags: Frequency (high to low)"; + case "frequency-low": + return "Sort tags: Frequency (low to high)"; + case "name-asc": + return "Sort tags: Tag name (A to Z)"; + case "name-desc": + return "Sort tags: Tag name (Z to A)"; + case "added-new": + return "Sort tags: Added time (new to old)"; + case "added-old": + return "Sort tags: Added time (old to new)"; + default: + return "Sort tags: Custom order"; + } + } + + private showTagSortMenu(event: MouseEvent, buttonEl: HTMLElement): void { + const menu = new Menu(); + const currentMethod = this.plugin.settings.tagSortMethod; + + menu.addItem((item) => { + item.setTitle("Custom order") + .setChecked(currentMethod === "custom") + .onClick(async () => { + await this.setTagSortMethod("custom"); + }); + }); + + menu.addSeparator(); + + menu.addItem((item) => { + item.setTitle("Frequency (high to low)") + .setChecked(currentMethod === "frequency-high") + .onClick(async () => { + await this.setTagSortMethod("frequency-high"); + }); + }); + + menu.addItem((item) => { + item.setTitle("Frequency (low to high)") + .setChecked(currentMethod === "frequency-low") + .onClick(async () => { + await this.setTagSortMethod("frequency-low"); + }); + }); + + menu.addSeparator(); + + menu.addItem((item) => { + item.setTitle("Tag name (A to Z)") + .setChecked(currentMethod === "name-asc") + .onClick(async () => { + await this.setTagSortMethod("name-asc"); + }); + }); + + menu.addItem((item) => { + item.setTitle("Tag name (Z to A)") + .setChecked(currentMethod === "name-desc") + .onClick(async () => { + await this.setTagSortMethod("name-desc"); + }); + }); + + menu.addSeparator(); + + menu.addItem((item) => { + item.setTitle("Added time (new to old)") + .setChecked(currentMethod === "added-new") + .onClick(async () => { + await this.setTagSortMethod("added-new"); + }); + }); + + menu.addItem((item) => { + item.setTitle("Added time (old to new)") + .setChecked(currentMethod === "added-old") + .onClick(async () => { + await this.setTagSortMethod("added-old"); + }); + }); + + menu.showAtMouseEvent(event); + } + + private showNoteSortMenu(event: MouseEvent, buttonEl: HTMLElement): void { const menu = new Menu(); const currentMethod = this.plugin.settings.noteSortMethod; @@ -524,12 +815,44 @@ export class TagIndexView extends ItemView { this.plugin.settings.noteSortMethod = method; await this.plugin.saveSettings(); - // Update the sort button tooltip - const sortButton = this.containerEl.querySelector( - ".tag-index-sort-button", + // Update the note sort button tooltip (second button in left container) + const leftButtons = this.containerEl.querySelector( + ".tag-index-header-left", ); - if (sortButton) { - sortButton.setAttribute("title", this.getSortMethodLabel()); + if (leftButtons) { + const noteSortButton = leftButtons.querySelectorAll( + ".tag-index-sort-button", + )[1]; + if (noteSortButton) { + noteSortButton.setAttribute( + "title", + this.getNoteSortMethodLabel(), + ); + } + } + + // Re-render to apply new sorting + await this.renderTagsAndRestoreExpansion(); + } + + private async setTagSortMethod(method: TagSortMethod): Promise { + this.plugin.settings.tagSortMethod = method; + await this.plugin.saveSettings(); + + // Update the tag sort button tooltip (first button in left container) + const leftButtons = this.containerEl.querySelector( + ".tag-index-header-left", + ); + if (leftButtons) { + const tagSortButton = leftButtons.querySelector( + ".tag-index-sort-button", + ); + if (tagSortButton) { + tagSortButton.setAttribute( + "title", + this.getTagSortMethodLabel(), + ); + } } // Re-render to apply new sorting @@ -1067,6 +1390,7 @@ export class TagIndexView extends ItemView { name: cleanTagName, position: 0, isNested: shouldTreatAsNested, + addedTime: Date.now(), }; // Add to the beginning of the array @@ -1078,6 +1402,7 @@ export class TagIndexView extends ItemView { name: cleanTagName, position: newPosition, isNested: shouldTreatAsNested, + addedTime: Date.now(), }; // Add to the end of the array diff --git a/styles.css b/styles.css index 07b1c4a..ed847f4 100644 --- a/styles.css +++ b/styles.css @@ -296,6 +296,12 @@ margin-bottom: 4px; } +.tag-index-header-left { + display: flex; + align-items: center; + gap: 6px; +} + .tag-index-sort-button { color: var(--text-muted); cursor: pointer; @@ -326,6 +332,15 @@ color: var(--text-normal); } +/* Draggable indicator */ +.tag-index-draggable { + cursor: grab; +} + +.tag-index-draggable:active { + cursor: grabbing; +} + /* Notes container within a tag - OLD STYLE, KEEPING FOR COMPATIBILITY */ .tag-index-tag-notes.legacy { padding-left: 32px;