├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── components │ ├── header-settings.tsx │ ├── icon.tsx │ ├── name-input-modal.tsx │ ├── property-filter-select.tsx │ ├── save-filter-menu.tsx │ ├── table-columns-select.tsx │ ├── tag-title-row.tsx │ ├── tags-list.tsx │ ├── tags-table.tsx │ └── tags.tsx ├── constants.ts ├── main.ts ├── settings.tsx ├── types.ts ├── utils.ts └── views │ ├── root-view.tsx │ ├── settings-view.tsx │ └── tags-view.tsx ├── styles.css ├── styles.scss ├── tsconfig.json ├── version-bump.mjs └── versions.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:react/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint", 18 | "react" 19 | ], 20 | "rules": { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | .hotreload 21 | 22 | # Exclude macOS Finder (System Explorer) View States 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 christianwannerstedt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tags overview - Obsidian plugin 2 | 3 | This plugin for [Obsidian](https://obsidian.md/) adds an extended tags panel where tagged files can be overviewed, filtered and accessed in an easy way. 4 | 5 | ## Features 6 | 7 | - Display tagged files directly. 8 | - Toggle between list and table view. 9 | - User friendly filter field. 10 | - Additional sort options. 11 | - Displays related tags (more info below). 12 | 13 | ### Related tags 14 | When you filter the list of tags through a search, you can choose to display related tags. By default, the list will only include the tag(s) that are included in the search, but by showing related tags, tags that the files in the search contain are also included. The setting only affects which tags appear in the search results, not the files. 15 | 16 | For example: 17 | If a file contains the tags `#vehicle` and `#car`, then a search for `#vehicle` will show both tags in the result. However, files that only contain the `#car` tag will not be presented in the list. 18 | 19 | ![related-tags](https://github.com/christianwannerstedt/obsidian-tags-overview/assets/25314/9ed3edd0-b6a3-4669-aec1-6bc9158d93ad) 20 | 21 | ### Nested tags 22 | The plugin supports [nested tags](https://help.obsidian.md/Editing+and+formatting/Tags#Nested+tags), with an option to display nested tags as a tree or a flat list. You can choose to expand or collapse each nested level separately by clicking the arrow next to it. 23 | 24 | ![nested-tags](https://github.com/christianwannerstedt/obsidian-tags-overview/assets/25314/3c551140-1c97-4fa4-aeb0-a8bef7608bb3) 25 | 26 | ### Filter 27 | Filter the list easily by selecting one or more tags in the dropdown menu. You can choose whether the results must match all search criterias (AND) or just any of them (OR). It is also possible to toggle a tag in the search by clicking on the tag in the results list while holding down ctrl/cmd. 28 | 29 | ![filter](https://github.com/christianwannerstedt/obsidian-tags-overview/assets/25314/f8374340-17da-4fd0-bde3-cebde2e74815) 30 | 31 | #### Filter on custom properties (Front matter) 32 | It is possible to extend the filter functionality by adding filters for specific properties (YAML/Front matter). This is easily done from the plugin's settings page. 33 | 34 | ![property-filters-settings-II](https://github.com/christianwannerstedt/obsidian-tags-overview/assets/25314/7aa5e43c-36fb-4e72-86c2-a260eaf47034) 35 | 36 | In the example above, you can see how to add and remove properties to be used for filtering. It is also possible to determine the position on the filter row, as well as the type of filter. There are three different filter types: 37 | - Select: The filter is displayed as a dropdown where all existing values are selectable (same widget as for filtering tags). 38 | - Text: Filtering takes place with free text. 39 | - Number: Only numeric input is allowed. It's possible to choose which compare operator to use (`=`, `!=`, `>`, `>=`, `<=`, `<`). 40 | 41 | In the above scenario, the result looks like this: 42 | 43 | ![property-filters-II](https://github.com/christianwannerstedt/obsidian-tags-overview/assets/25314/5f79431f-41a1-4d3e-802e-fc8a9f9f151e) 44 | 45 | 46 | ### Different views 47 | Choose between a table view or a more minimalistic list view. The table view will display the date when the file was last modified. It is possible to change the format of the dates in the plugin settings. 48 | 49 | ![display-types](https://github.com/christianwannerstedt/obsidian-tags-overview/assets/25314/bc677992-f1e9-4eb3-93bb-59955aee7120) 50 | 51 | #### Table view customizations 52 | It is possible to customize which columns should be displayed in the table view, as well as how the content should be aligned. Use the dropdown below the table to add a new column, then use the arrow icons to change the position of the column. The column at the top of the list will appear at the far left of the table. 53 | 54 | ![table-columns](https://github.com/christianwannerstedt/obsidian-tags-overview/assets/25314/73dda1c8-c75f-4994-b206-f6067b2552f8) 55 | 56 | It is also possible to add properties (Front Matter). Just select the option `Property` in the *"Add column"* dropdown, and then select which property you want the column to hold. If you add a property column it will also be possible to order the files based on that property. 57 | 58 | ![table-columns-property](https://github.com/christianwannerstedt/obsidian-tags-overview/assets/25314/dcc36907-114b-490d-badf-5620333495dd) 59 | 60 | ## Ignore files 61 | It is possible to exclude files to be picked up by the plugin, by adding the custom property `tagsoverview`and set the value to `ignore`. No tags will be retrieved from excluded files, nor will they show in the list views. 62 | 63 | ## Install 64 | 65 | ### Manual installation 66 | Unzip the [latest release](https://github.com/christianwannerstedt/obsidian-tags-overview/releases/latest) into your `/.obsidian/plugins/` folder. 67 | 68 | ### Within Obsidian 69 | 1. Go to `Settings > Community plugins` 70 | 2. Ensure that Safe mode is turned off 71 | 3. Click `Community plugins > Browse` 72 | 4. Search for `Tags overview` 73 | 5. Click install 74 | 6. Once installed, close the community plugins window and activate the newly installed plugin 75 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "tags-overview", 3 | "name": "Tags Overview", 4 | "version": "1.0.4", 5 | "minAppVersion": "0.15.0", 6 | "description": "Adds an extended tags panel where tagged files can be overviewed, filtered and accessed in an easy way.", 7 | "author": "Christian Wannerstedt", 8 | "authorUrl": "https://github.com/christianwannerstedt", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-tags-overview", 3 | "version": "1.0.4", 4 | "description": "Adds an overview page of your tags in Obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "lint": "eslint ./src" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/node": "^16.11.6", 17 | "@types/react": "^18.2.17", 18 | "@types/react-dom": "^18.2.7", 19 | "@typescript-eslint/eslint-plugin": "^6.9.1", 20 | "@typescript-eslint/parser": "^6.9.1", 21 | "builtin-modules": "3.3.0", 22 | "esbuild": "0.17.3", 23 | "eslint": "^8.52.0", 24 | "eslint-plugin-react": "^7.33.2", 25 | "obsidian": "latest", 26 | "tslib": "2.4.0", 27 | "typescript": "4.7.4" 28 | }, 29 | "dependencies": { 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "react-select": "^5.7.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/header-settings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SetStateAction } from "react"; 3 | import { HeaderSetting } from "src/types"; 4 | 5 | export const HeaderSettings = ({ 6 | title, 7 | value, 8 | settings, 9 | setFunction, 10 | className, 11 | }: { 12 | title: string; 13 | value: string | boolean; 14 | settings: HeaderSetting[]; 15 | setFunction: (arg0: SetStateAction | SetStateAction) => void; 16 | className?: string; 17 | }) => { 18 | return ( 19 |
20 |

{title}

21 |
22 | {settings.map((setting: HeaderSetting) => { 23 | return ( 24 | setFunction(setting.value)} 28 | > 29 | {setting.label} 30 | 31 | ); 32 | })} 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { MouseEvent } from "react"; 3 | 4 | export const ICON_TYPE = { 5 | arrow: "arrow", 6 | nested: "nested", 7 | sort: "sort", 8 | collapse: "collapse", 9 | expand: "expand", 10 | tags: "tags", 11 | moveUp: "moveUp", 12 | moveDown: "moveDown", 13 | trash: "trash", 14 | save: "save", 15 | }; 16 | Object.freeze(ICON_TYPE); 17 | 18 | export const Icon = ({ 19 | iconType, 20 | onClick, 21 | className, 22 | label, 23 | active, 24 | disabled, 25 | }: { 26 | iconType: string; 27 | onClick?: (e: MouseEvent) => void; 28 | className: string; 29 | label?: string; 30 | active?: boolean; 31 | disabled?: boolean; 32 | }) => { 33 | let classes = `custom-icon ${className}`; 34 | if (active) { 35 | classes += " is-active"; 36 | } 37 | if (disabled) { 38 | classes += " is-disabled"; 39 | } 40 | return ( 41 |
{ 47 | if (!disabled) { 48 | onClick(e); 49 | } 50 | } 51 | : undefined 52 | } 53 | > 54 | 66 | {iconType === ICON_TYPE.arrow ? ( 67 | 68 | ) : iconType === ICON_TYPE.sort ? ( 69 | <> 70 | 71 | 72 | 73 | 74 | 75 | 76 | ) : iconType === ICON_TYPE.nested ? ( 77 | <> 78 | 79 | 80 | 81 | 82 | 83 | ) : iconType === ICON_TYPE.collapse ? ( 84 | <> 85 | 86 | 87 | 88 | ) : iconType === ICON_TYPE.moveUp ? ( 89 | <> 90 | {/* 91 | 92 | */} 93 | 94 | 95 | 96 | ) : iconType === ICON_TYPE.moveDown ? ( 97 | <> 98 | {/* 99 | */} 100 | 101 | 102 | 103 | ) : iconType === ICON_TYPE.trash ? ( 104 | <> 105 | 106 | 107 | 108 | 109 | 110 | 111 | ) : iconType === ICON_TYPE.tags ? ( 112 | <> 113 | 114 | 115 | 116 | 117 | ) : iconType === ICON_TYPE.save ? ( 118 | <> 119 | 120 | 121 | 122 | 123 | ) : ( 124 | <> 125 | 126 | 127 | 128 | )} 129 | 130 |
131 | ); 132 | }; 133 | -------------------------------------------------------------------------------- /src/components/name-input-modal.tsx: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | 3 | export class NameInputModal extends Modal { 4 | result: string; 5 | onSubmit: (result: string) => void; 6 | 7 | constructor(app: App, onSubmit: (result: string) => void) { 8 | super(app); 9 | this.onSubmit = onSubmit; 10 | } 11 | 12 | onOpen() { 13 | const { contentEl } = this; 14 | contentEl.createEl("h4", { text: "Enter the name of the filter" }); 15 | 16 | new Setting(contentEl).setName("Name:").addText((text) => 17 | text.onChange((value) => { 18 | this.result = value; 19 | }) 20 | ); 21 | 22 | new Setting(contentEl).addButton((btn) => 23 | btn 24 | .setButtonText("Save") 25 | .setCta() 26 | .onClick(() => { 27 | this.close(); 28 | this.onSubmit(this.result); 29 | }) 30 | ); 31 | } 32 | 33 | onClose() { 34 | const { contentEl } = this; 35 | contentEl.empty(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/property-filter-select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEffect, useState } from "react"; 3 | import { ICON_TYPE, Icon } from "./icon"; 4 | import TagsOverviewPlugin from "src/main"; 5 | import { PropertyFilter } from "src/types"; 6 | import { FILTER_TYPES } from "src/constants"; 7 | 8 | export const PropertyFilterSelector = ({ 9 | plugin, 10 | frontMatterProperties, 11 | }: { 12 | plugin: TagsOverviewPlugin; 13 | frontMatterProperties: string[]; 14 | }) => { 15 | const [selectedPropertyFilters, setSelectedPropertyFilters] = useState< 16 | PropertyFilter[] 17 | >(plugin.settings.propertyFilters); 18 | 19 | useEffect(() => { 20 | savePropertyFilters(); 21 | }, [selectedPropertyFilters]); 22 | 23 | const addPropertyFilter = (property: string) => { 24 | if ( 25 | !selectedPropertyFilters.find( 26 | (selectedProperty) => selectedProperty.property === property 27 | ) 28 | ) { 29 | setSelectedPropertyFilters([ 30 | ...selectedPropertyFilters, 31 | { property, type: FILTER_TYPES.select }, 32 | ]); 33 | } 34 | }; 35 | 36 | const removePropertyFilter = (index: number) => { 37 | const tempPropertyFilters = [...selectedPropertyFilters]; 38 | tempPropertyFilters.splice(index, 1); 39 | setSelectedPropertyFilters([...tempPropertyFilters]); 40 | }; 41 | 42 | const movePropertyFilter = ( 43 | propertyFilter: PropertyFilter, 44 | direction: number 45 | ) => { 46 | const index = selectedPropertyFilters.indexOf(propertyFilter); 47 | const tempPropertyFilters = [...selectedPropertyFilters]; 48 | const temp = tempPropertyFilters[index + direction]; 49 | tempPropertyFilters[index + direction] = propertyFilter; 50 | tempPropertyFilters[index] = temp; 51 | setSelectedPropertyFilters(tempPropertyFilters); 52 | }; 53 | 54 | const setFilterType = (propertyFilter: PropertyFilter, value: string) => { 55 | const index = selectedPropertyFilters.indexOf(propertyFilter); 56 | const tempPropertyFilters = [...selectedPropertyFilters]; 57 | tempPropertyFilters[index].type = value; 58 | setSelectedPropertyFilters(tempPropertyFilters); 59 | }; 60 | 61 | const savePropertyFilters = async () => { 62 | plugin.settings.propertyFilters = selectedPropertyFilters; 63 | await plugin.saveData(plugin.settings); 64 | plugin.refreshView(); 65 | }; 66 | 67 | const selectedTypes: string[] = selectedPropertyFilters.map( 68 | (propertyFilter: PropertyFilter) => propertyFilter.property 69 | ); 70 | 71 | const tableRows = 72 | selectedPropertyFilters.length === 0 ? ( 73 | 74 | 75 | No extra filters added 76 |

The default ones will be displayed

77 | 78 | 79 | ) : ( 80 | selectedPropertyFilters.map( 81 | (propertyFilter: PropertyFilter, index: number) => ( 82 | 83 | {propertyFilter.property} 84 | 85 | 98 | 99 | 100 | {index > 0 && ( 101 | { 105 | movePropertyFilter(propertyFilter, -1); 106 | }} 107 | /> 108 | )} 109 | {index < selectedPropertyFilters.length - 1 && ( 110 | { 114 | movePropertyFilter(propertyFilter, 1); 115 | }} 116 | /> 117 | )} 118 | { 122 | removePropertyFilter(index); 123 | }} 124 | /> 125 | 126 | 127 | ) 128 | ) 129 | ); 130 | 131 | return ( 132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | {tableRows} 142 | 143 | 144 | 162 | 163 | 164 |
PropertyFilter type
145 | 146 | Add filter: 147 | 148 | 161 |
165 |
166 | ); 167 | }; 168 | -------------------------------------------------------------------------------- /src/components/save-filter-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ICON_TYPE, Icon } from "./icon"; 3 | import { SavedFilter } from "src/types"; 4 | 5 | export const SaveFilterMenu = ({ 6 | savedFilters, 7 | loadSavedFilter, 8 | saveFilter, 9 | removeFilter, 10 | }: { 11 | savedFilters: SavedFilter[]; 12 | loadSavedFilter: (filter: SavedFilter) => void; 13 | saveFilter: () => void; 14 | removeFilter: (index: number) => void; 15 | }) => { 16 | const [showPopover, setShowPopover] = React.useState(false); 17 | const ref = React.useRef(null); 18 | const iconRef = React.useRef(null); 19 | 20 | // Handle click outside of popover 21 | React.useEffect(() => { 22 | const handleClickOutside = (event: MouseEvent) => { 23 | if (ref.current && event.target && iconRef.current) { 24 | const node = ref.current as Element; 25 | const iconNode = iconRef.current as Element; 26 | const elem = event.target as Element; 27 | if ( 28 | !node.contains(elem) && 29 | !iconNode.contains(elem) && 30 | !elem.classList.contains("save-load-filters-icon-svg") 31 | ) { 32 | setShowPopover(false); 33 | event.preventDefault(); 34 | event.stopPropagation(); 35 | } 36 | } 37 | }; 38 | 39 | document.addEventListener("mousedown", handleClickOutside); 40 | return () => { 41 | document.removeEventListener("mousedown", handleClickOutside); 42 | }; 43 | }, []); 44 | 45 | return ( 46 |
47 |
48 | { 53 | setShowPopover(!showPopover); 54 | }} 55 | /> 56 |
57 | 58 | {showPopover && ( 59 |
60 |

Saved filters

61 | {savedFilters && savedFilters.length ? ( 62 |
    63 | {savedFilters.map((filter: SavedFilter, index: number) => ( 64 |
  • 65 | { 67 | loadSavedFilter(filter); 68 | setShowPopover(false); 69 | }} 70 | > 71 | {filter.name} 72 | 73 | 74 | { 78 | removeFilter(index); 79 | }} 80 | /> 81 |
  • 82 | ))} 83 |
84 | ) : ( 85 |

No saved filters

86 | )} 87 |
88 | 103 |
104 | )} 105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/table-columns-select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEffect, useState } from "react"; 3 | import { ICON_TYPE, Icon } from "./icon"; 4 | import TagsOverviewPlugin from "src/main"; 5 | import { TableColumn } from "src/types"; 6 | import { 7 | ALIGN_OPTIONS, 8 | TABLE_COLUMN_LABELS, 9 | TABLE_COLUMN_TYPES, 10 | } from "src/constants"; 11 | 12 | export const TableColumnsSelector = ({ 13 | plugin, 14 | frontMatterProperties, 15 | }: { 16 | plugin: TagsOverviewPlugin; 17 | frontMatterProperties: string[]; 18 | }) => { 19 | const [selectedColumns, setSelectedColumns] = useState( 20 | plugin.settings.tableColumns 21 | ); 22 | 23 | useEffect(() => { 24 | saveColumns(); 25 | }, [selectedColumns]); 26 | 27 | const addColumn = (columnType: string) => { 28 | // Front matter is the only column that can be added multiple times 29 | if ( 30 | columnType === "frontMatter" || 31 | !selectedColumns.find((col) => col.type === columnType) 32 | ) { 33 | setSelectedColumns([ 34 | ...selectedColumns, 35 | { type: columnType, align: "left" }, 36 | ]); 37 | } 38 | }; 39 | 40 | const removeColumn = (index: number) => { 41 | const tempColumns = [...selectedColumns]; 42 | tempColumns.splice(index, 1); 43 | setSelectedColumns([...tempColumns]); 44 | }; 45 | 46 | const moveColumn = (column: TableColumn, direction: number) => { 47 | const index = selectedColumns.indexOf(column); 48 | const tempColumns = [...selectedColumns]; 49 | const temp = tempColumns[index + direction]; 50 | tempColumns[index + direction] = column; 51 | tempColumns[index] = temp; 52 | setSelectedColumns(tempColumns); 53 | }; 54 | 55 | const setAlignment = (column: TableColumn, value: string) => { 56 | const index = selectedColumns.indexOf(column); 57 | const tempColumns = [...selectedColumns]; 58 | tempColumns[index].align = value; 59 | setSelectedColumns(tempColumns); 60 | }; 61 | 62 | const setProperty = (column: TableColumn, value: string) => { 63 | const index = selectedColumns.indexOf(column); 64 | const tempColumns = [...selectedColumns]; 65 | tempColumns[index].data = value; 66 | setSelectedColumns(tempColumns); 67 | }; 68 | 69 | const saveColumns = async () => { 70 | plugin.settings.tableColumns = selectedColumns; 71 | await plugin.saveData(plugin.settings); 72 | plugin.refreshView(); 73 | }; 74 | 75 | const selectedTypes: string[] = selectedColumns.map( 76 | (column: TableColumn) => column.type 77 | ); 78 | 79 | const tableRows = 80 | selectedColumns.length === 0 ? ( 81 | 82 | 83 | No columns added 84 |

The default ones will be displayed

85 | 86 | 87 | ) : ( 88 | selectedColumns.map((column: TableColumn, index: number) => ( 89 | 90 | 91 | {column.type !== "frontMatter" && TABLE_COLUMN_LABELS[column.type]} 92 | {column.type === "frontMatter" && ( 93 | <> 94 | Property: 95 | 110 | 111 | )} 112 | 113 | 114 | 127 | 128 | 129 | {index > 0 && ( 130 | { 134 | moveColumn(column, -1); 135 | }} 136 | /> 137 | )} 138 | {index < selectedColumns.length - 1 && ( 139 | { 143 | moveColumn(column, 1); 144 | }} 145 | /> 146 | )} 147 | { 151 | removeColumn(index); 152 | }} 153 | /> 154 | 155 | 156 | )) 157 | ); 158 | 159 | return ( 160 |
161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | {tableRows} 170 | 171 | 172 | 196 | 197 | 198 |
Column typeAligment
173 | 174 | Add column: 175 | 176 | 195 |
199 |
200 | ); 201 | }; 202 | -------------------------------------------------------------------------------- /src/components/tag-title-row.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const TagTitleRow = ({ 4 | title, 5 | filesInfo, 6 | onTagClick, 7 | }: { 8 | title: string; 9 | filesInfo: string; 10 | onTagClick: (arg0: React.MouseEvent) => void; 11 | }) => ( 12 |
13 |

14 | {title} 15 |

16 | {filesInfo} 17 |
18 | ); 19 | -------------------------------------------------------------------------------- /src/components/tags-list.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { TagData, TaggedFile } from "../types"; 4 | import { addOrRemove } from "src/utils"; 5 | import { ICON_TYPE, Icon } from "./icon"; 6 | import { TagTitleRow } from "./tag-title-row"; 7 | import { TFile } from "obsidian"; 8 | 9 | export const TagsList = ({ 10 | tags, 11 | onFileClick, 12 | collapsedTags, 13 | setCollapsedTags, 14 | onTagClick, 15 | }: { 16 | tags: TagData[]; 17 | onFileClick: (file: TFile, inNewLeaf: boolean) => void; 18 | collapsedTags: string[]; 19 | setCollapsedTags: (arg0: string[]) => void; 20 | onTagClick: (tagData: TagData) => void; 21 | }) => { 22 | const getTagList = (tagLevel: TagData, depth: number) => { 23 | const hasSubTags: boolean = !!tagLevel.sub.length; 24 | const isCollapsable: boolean = hasSubTags || !!tagLevel.files.length; 25 | const isCollapsed: boolean = 26 | isCollapsable && collapsedTags.includes(tagLevel.tagPath); 27 | let containerClasses: string = `nested-tags-container tags-level-${depth}`; 28 | if (isCollapsable) { 29 | containerClasses += " has-sub-tags"; 30 | } 31 | if (isCollapsed) { 32 | containerClasses += " is-collapsed"; 33 | } 34 | return ( 35 |
36 | {isCollapsable && ( 37 | { 41 | setCollapsedTags(addOrRemove(collapsedTags, tagLevel.tagPath)); 42 | }} 43 | /> 44 | )} 45 | 50 | ) => { 51 | if (isCollapsable && (event.ctrlKey || event.metaKey)) { 52 | onTagClick(tagLevel); 53 | } else { 54 | setCollapsedTags(addOrRemove(collapsedTags, tagLevel.tagPath)); 55 | } 56 | }} 57 | /> 58 | {!isCollapsed && ( 59 |
60 | {!!tagLevel.files.length && ( 61 |
62 | {tagLevel.files.map((file: TaggedFile, index: number) => ( 63 | 66 | onFileClick(file.file, event.ctrlKey || event.metaKey) 67 | } 68 | className="file-link" 69 | > 70 | {file.file.basename} 71 | 72 | ))} 73 |
74 | )} 75 | {!!tagLevel.sub && 76 | !collapsedTags.includes(tagLevel.tagPath) && 77 | tagLevel.sub.map((subTagData: TagData) => 78 | getTagList(subTagData, depth + 1) 79 | )} 80 |
81 | )} 82 |
83 | ); 84 | }; 85 | 86 | return <>{tags.map((tag: TagData) => getTagList(tag, 0))}; 87 | }; 88 | -------------------------------------------------------------------------------- /src/components/tags-table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { ICON_TYPE, Icon } from "./icon"; 4 | import { TagTitleRow } from "./tag-title-row"; 5 | import { TableColumn, TagData, TaggedFile } from "../types"; 6 | import { addOrRemove, pluralize, upperCaseFirstChar } from "../utils"; 7 | import TagsOverviewPlugin from "src/main"; 8 | import { DEFAULT_SETTINGS } from "src/settings"; 9 | import { TFile } from "obsidian"; 10 | import { TABLE_COLUMN_TYPES } from "src/constants"; 11 | 12 | export const TagsTable = ({ 13 | plugin, 14 | tags, 15 | onFileClick, 16 | collapsedTags, 17 | setCollapsedTags, 18 | onTagClick, 19 | }: { 20 | plugin: TagsOverviewPlugin; 21 | tags: TagData[]; 22 | onFileClick: (file: TFile, inNewLeaf: boolean) => void; 23 | collapsedTags: string[]; 24 | setCollapsedTags: (arg0: string[]) => void; 25 | onTagClick: (tagData: TagData) => void; 26 | }) => { 27 | const getTagTable = (tagLevel: TagData, depth: number) => { 28 | const hasSubTags: boolean = !!tagLevel.sub.length; 29 | const isCollapsable: boolean = hasSubTags || !!tagLevel.files.length; 30 | const isCollapsed: boolean = 31 | isCollapsable && collapsedTags.includes(tagLevel.tagPath); 32 | let containerClasses: string = `tags-table-container tags-level-${depth}`; 33 | if (isCollapsable) { 34 | containerClasses += " has-sub-tags"; 35 | } 36 | if (isCollapsed) { 37 | containerClasses += " is-collapsed"; 38 | } 39 | 40 | let filesInfo: string = pluralize(tagLevel.files.length, "file", "files"); 41 | if (tagLevel.subFilesCount) { 42 | filesInfo += ` (${tagLevel.files.length + tagLevel.subFilesCount} total)`; 43 | } 44 | 45 | const tableColumns: TableColumn[] = plugin.settings.tableColumns.length 46 | ? plugin.settings.tableColumns 47 | : DEFAULT_SETTINGS.tableColumns; 48 | 49 | const getColStyle = (column: TableColumn) => { 50 | if (column.type == TABLE_COLUMN_TYPES.name) { 51 | return { 52 | minWidth: "200px", 53 | }; 54 | } else if ( 55 | [TABLE_COLUMN_TYPES.modified, TABLE_COLUMN_TYPES.created].includes( 56 | column.type 57 | ) 58 | ) { 59 | return { 60 | minWidth: "150px", 61 | }; 62 | } 63 | return {}; 64 | }; 65 | 66 | return ( 67 |
68 | {isCollapsable && ( 69 | { 73 | setCollapsedTags(addOrRemove(collapsedTags, tagLevel.tagPath)); 74 | }} 75 | /> 76 | )} 77 | 82 | ) => { 83 | if (isCollapsable && (event.ctrlKey || event.metaKey)) { 84 | onTagClick(tagLevel); 85 | } else { 86 | setCollapsedTags(addOrRemove(collapsedTags, tagLevel.tagPath)); 87 | } 88 | }} 89 | /> 90 | {!isCollapsed && ( 91 |
92 | 93 | {plugin.settings.displayHeaders && ( 94 | 95 | 96 | {tableColumns.map((column: TableColumn) => ( 97 | 107 | ))} 108 | 109 | 110 | )} 111 | 112 | {tagLevel.files && 113 | tagLevel.files.map((file: TaggedFile, fileIndex: number) => ( 114 | 117 | 120 | onFileClick(file.file, event.ctrlKey || event.metaKey) 121 | } 122 | > 123 | {tableColumns.map( 124 | (column: TableColumn, index: number) => ( 125 | 154 | ) 155 | )} 156 | 157 | 158 | ))} 159 | {!!tagLevel.sub.length && 160 | !collapsedTags.includes(tagLevel.tagPath) && ( 161 | 162 | 167 | 168 | )} 169 | 170 |
101 | {upperCaseFirstChar( 102 | column.type === TABLE_COLUMN_TYPES.frontMatter 103 | ? column.data || "-" 104 | : column.type 105 | )} 106 |
130 | {column.type === TABLE_COLUMN_TYPES.name && 131 | file.file.basename} 132 | {column.type === TABLE_COLUMN_TYPES.modified && 133 | file.formattedModified} 134 | {column.type === TABLE_COLUMN_TYPES.created && 135 | file.formattedCreated} 136 | {column.type === TABLE_COLUMN_TYPES.size && 137 | file.file.stat.size} 138 | {column.type === TABLE_COLUMN_TYPES.frontMatter && 139 | file.frontMatter && 140 | column.data && 141 | file.frontMatter[column.data] !== undefined && 142 | (Array.isArray(file.frontMatter[column.data]) 143 | ? file.frontMatter[column.data].join(", ") 144 | : typeof file.frontMatter[column.data] === 145 | "boolean" 146 | ? file.frontMatter[column.data] 147 | ? "Yes" 148 | : "No" 149 | : typeof file.frontMatter[column.data] === 150 | "object" 151 | ? "" 152 | : file.frontMatter[column.data])} 153 |
163 | {tagLevel.sub.map((subTagData: TagData) => 164 | getTagTable(subTagData, depth + 1) 165 | )} 166 |
171 |
172 | )} 173 |
174 | ); 175 | }; 176 | 177 | return <>{tags.map((tag: TagData) => getTagTable(tag, 0))}; 178 | }; 179 | -------------------------------------------------------------------------------- /src/components/tags.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEffect, useState, MouseEvent } from "react"; 3 | 4 | import { ContextMenuOption, TagData } from "../types"; 5 | import { TagsList } from "./tags-list"; 6 | import { TagsTable } from "./tags-table"; 7 | import { 8 | DISPLAY_TYPE, 9 | SORT_FILES_OPTIONS, 10 | SORT_TAGS_OPTIONS, 11 | } from "../constants"; 12 | import { pluralize, sortTagsAndFiles } from "src/utils"; 13 | import TagsOverviewPlugin from "src/main"; 14 | import { ICON_TYPE, Icon } from "./icon"; 15 | import { HeaderSettings } from "./header-settings"; 16 | import { Menu, TFile } from "obsidian"; 17 | import { DEFAULT_SETTINGS } from "src/settings"; 18 | 19 | export const Tags = ({ 20 | plugin, 21 | tags, 22 | onFileClick, 23 | hasFilters, 24 | showNested, 25 | setShowNested, 26 | showRelatedTags, 27 | setShowRelatedTags, 28 | tagsCount, 29 | filesCount, 30 | onTagClick, 31 | }: { 32 | plugin: TagsOverviewPlugin; 33 | tags: TagData[]; 34 | onFileClick: (file: TFile, inNewLeaf: boolean) => void; 35 | hasFilters: boolean; 36 | showNested: boolean; 37 | setShowNested: (arg0: boolean) => void; 38 | showRelatedTags: boolean; 39 | setShowRelatedTags: (arg0: boolean) => void; 40 | tagsCount: number; 41 | filesCount: number; 42 | onTagClick: (tagData: TagData) => void; 43 | }) => { 44 | const [showCollapseAll, setShowCollapseAll] = useState(true); 45 | const [displayType, setDisplayType] = useState(plugin.settings.displayType); 46 | const [sortTags, setSortTags] = useState(plugin.settings.sortTags); 47 | const [sortFiles, setSortFiles] = useState(plugin.settings.sortFiles); 48 | const [collapsedTags, setCollapsedTags] = useState([]); 49 | 50 | const props = { 51 | tags, 52 | onFileClick, 53 | collapsedTags, 54 | setCollapsedTags, 55 | onTagClick, 56 | }; 57 | 58 | useEffect(() => { 59 | plugin.saveSettings({ 60 | displayType, 61 | sortTags, 62 | sortFiles, 63 | }); 64 | }, [displayType, sortTags, sortFiles]); 65 | 66 | const collectTags = (tags: TagData[]): string[] => { 67 | const nestedTags: string[] = []; 68 | tags.forEach((tag: TagData) => { 69 | if (showNested) { 70 | nestedTags.push(tag.tagPath); 71 | const subTags: string[] = collectTags(tag.sub); 72 | subTags.forEach((subTag: string) => { 73 | nestedTags.push(subTag); 74 | }); 75 | } else { 76 | const parts: string[] = tag.tag.split("/"); 77 | for (let i = 1; i <= parts.length; i++) { 78 | nestedTags.push(parts.slice(0, i).join("/")); 79 | } 80 | } 81 | }); 82 | return nestedTags; 83 | }; 84 | 85 | const collapseAll = () => { 86 | setCollapsedTags(collectTags(tags)); 87 | setShowCollapseAll(false); 88 | }; 89 | const expandAll = () => { 90 | setCollapsedTags([]); 91 | setShowCollapseAll(true); 92 | }; 93 | 94 | const showContextMenu = (event: MouseEvent) => { 95 | const menu = new Menu(); 96 | 97 | SORT_TAGS_OPTIONS.forEach((menuItem: ContextMenuOption) => { 98 | menu.addItem((item) => 99 | item 100 | .setTitle(`Sort tags on ${menuItem.label}`) 101 | .setChecked(plugin.settings.sortTags == menuItem.key) 102 | .onClick(() => { 103 | setSortTags(menuItem.key); 104 | }) 105 | ); 106 | }); 107 | 108 | menu.addSeparator(); 109 | 110 | // Construct options for sorting files. 111 | // If the user has added additional property columns, add those to the list. 112 | const tableColumns = plugin.settings.tableColumns.length 113 | ? plugin.settings.tableColumns 114 | : DEFAULT_SETTINGS.tableColumns; 115 | const additionalProperties: string[] = tableColumns 116 | .filter((column) => column.type === "frontMatter" && column.data) 117 | .map((column): string => column.data || ""); 118 | [ 119 | ...SORT_FILES_OPTIONS, 120 | ...additionalProperties.reduce( 121 | (acc: ContextMenuOption[], property: string) => { 122 | acc.push({ 123 | key: `property__${property}`, 124 | label: `property: ${property} (ascending)`, 125 | }); 126 | acc.push({ 127 | key: `-property__${property}`, 128 | label: `property: ${property} (descending)`, 129 | }); 130 | return acc; 131 | }, 132 | [] 133 | ), 134 | ].forEach((menuItem: ContextMenuOption) => { 135 | menu.addItem((item) => 136 | item 137 | .setTitle(`Sort files on ${menuItem.label}`) 138 | .setChecked(plugin.settings.sortFiles == menuItem.key) 139 | .onClick(() => { 140 | setSortFiles(menuItem.key); 141 | }) 142 | ); 143 | }); 144 | 145 | menu.showAtMouseEvent(event.nativeEvent); 146 | }; 147 | 148 | // Sort the tags 149 | sortTagsAndFiles(tags, sortTags, sortFiles); 150 | 151 | const hasAnySub: boolean = !!tags.find( 152 | (tag: TagData) => tag.sub.length || tag.tag.includes("/") 153 | ); 154 | 155 | return ( 156 | <> 157 | 166 |
167 | 168 | {`Showing ${pluralize(tagsCount, "tag", "tags")} (${pluralize( 169 | filesCount, 170 | "file", 171 | "files" 172 | )})`} 173 | 174 | 175 |
176 | setShowRelatedTags(!showRelatedTags)} 181 | active={showRelatedTags} 182 | disabled={!hasFilters} 183 | /> 184 | showContextMenu(e)} 189 | /> 190 | setShowNested(!showNested)} 195 | active={hasAnySub && showNested} 196 | disabled={!hasAnySub} 197 | /> 198 | {showCollapseAll && ( 199 | collapseAll()} 204 | /> 205 | )} 206 | {!showCollapseAll && ( 207 | expandAll()} 212 | /> 213 | )} 214 |
215 |
216 | 217 |
218 | {displayType === DISPLAY_TYPE.compact ? ( 219 | 220 | ) : ( 221 | 222 | )} 223 |
224 | 225 | ); 226 | }; 227 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { ContextMenuOption } from "./types"; 2 | 3 | export const DISPLAY_TYPE: Record = { 4 | compact: "compact", 5 | list: "list", 6 | }; 7 | Object.freeze(DISPLAY_TYPE); 8 | 9 | export const SORT_TAGS: Record = { 10 | nameAsc: "name", 11 | nameDesc: "-name", 12 | frequencyAsc: "frequency", 13 | frequencyDesc: "-frequency", 14 | modifiedAsc: "modified", 15 | modifiedDesc: "-modified", 16 | createdAsc: "created", 17 | createdDesc: "-created", 18 | }; 19 | Object.freeze(SORT_TAGS); 20 | 21 | export const SORT_FILES: Record = { 22 | nameAsc: "name", 23 | nameDesc: "-name", 24 | modifiedAsc: "modified", 25 | modifiedDesc: "-modified", 26 | createdAsc: "created", 27 | createdDesc: "-created", 28 | }; 29 | Object.freeze(SORT_FILES); 30 | 31 | export const SORT_TAGS_OPTIONS: ContextMenuOption[] = [ 32 | { label: "name (A to Z)", key: SORT_TAGS.nameAsc }, 33 | { label: "name (Z to A)", key: SORT_TAGS.nameDesc }, 34 | { 35 | label: "frequency (high to low)", 36 | key: SORT_TAGS.frequencyAsc, 37 | }, 38 | { 39 | label: "frequency (low to high)", 40 | key: SORT_TAGS.frequencyDesc, 41 | }, 42 | { 43 | label: "modified time (new to old)", 44 | key: SORT_TAGS.modifiedAsc, 45 | }, 46 | { 47 | label: "modified time (old to new)", 48 | key: SORT_TAGS.modifiedDesc, 49 | }, 50 | { 51 | label: "created time (new to old)", 52 | key: SORT_TAGS.createdAsc, 53 | }, 54 | { 55 | label: "created time (old to new)", 56 | key: SORT_TAGS.createdDesc, 57 | }, 58 | ]; 59 | 60 | export const SORT_FILES_OPTIONS: ContextMenuOption[] = [ 61 | { label: "name (A to Z)", key: SORT_FILES.nameAsc }, 62 | { label: "name (Z to A)", key: SORT_FILES.nameDesc }, 63 | { 64 | label: "modified time (new to old)", 65 | key: SORT_FILES.modifiedAsc, 66 | }, 67 | { 68 | label: "modified time (old to new)", 69 | key: SORT_FILES.modifiedDesc, 70 | }, 71 | { 72 | label: "created time (new to old)", 73 | key: SORT_FILES.createdAsc, 74 | }, 75 | { 76 | label: "created time (old to new)", 77 | key: SORT_FILES.createdDesc, 78 | }, 79 | ]; 80 | 81 | export const TABLE_COLUMN_TYPES: Record = { 82 | name: "name", 83 | modified: "modified", 84 | created: "created", 85 | size: "size", 86 | frontMatter: "frontMatter", 87 | }; 88 | 89 | export const TABLE_COLUMN_LABELS: Record = { 90 | name: "File name", 91 | modified: "Modified time", 92 | created: "Created time", 93 | size: "Size", 94 | frontMatter: "Property", 95 | }; 96 | 97 | export const ALIGN_OPTIONS: Record = { 98 | left: "left", 99 | center: "center", 100 | right: "right", 101 | }; 102 | 103 | export const FILTER_TYPES: Record = { 104 | select: "Select", 105 | text: "Text", 106 | number: "Number", 107 | }; 108 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, WorkspaceLeaf } from "obsidian"; 2 | 3 | import { 4 | DEFAULT_SETTINGS, 5 | TagsOverviewSettingTab, 6 | TagsOverviewSettings, 7 | } from "./settings"; 8 | import { RootView, VIEW_TYPE } from "./views/root-view"; 9 | 10 | export default class TagsOverviewPlugin extends Plugin { 11 | settings: TagsOverviewSettings; 12 | 13 | async onload() { 14 | await this.loadSettings(); 15 | 16 | // Add the view 17 | this.registerView(VIEW_TYPE, (leaf) => new RootView(leaf, this)); 18 | this.addRibbonIcon("tag", "Tags overview", () => { 19 | this.activateView(); 20 | }); 21 | 22 | // Add a settings tab 23 | this.addSettingTab(new TagsOverviewSettingTab(this.app, this)); 24 | } 25 | 26 | async activateView() { 27 | let leaf: WorkspaceLeaf | null = this.getLeaf(); 28 | if (!leaf) { 29 | leaf = this.app.workspace.getRightLeaf(false); 30 | await leaf.setViewState({ 31 | type: VIEW_TYPE, 32 | active: true, 33 | }); 34 | } 35 | 36 | this.app.workspace.revealLeaf(leaf); 37 | } 38 | 39 | async refreshView() { 40 | const leaf = this.getLeaf(); 41 | if (leaf?.view) { 42 | (leaf.view as RootView).refresh(); 43 | } 44 | } 45 | 46 | async loadSettings() { 47 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 48 | } 49 | 50 | async saveSettings(settings: Partial) { 51 | this.settings = { 52 | ...this.settings, 53 | ...settings, 54 | }; 55 | await this.saveData(this.settings); 56 | } 57 | 58 | getLeaf(): WorkspaceLeaf | null { 59 | return this.app.workspace.getLeavesOfType(VIEW_TYPE).first() || null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/settings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { App, ButtonComponent, PluginSettingTab, Setting } from "obsidian"; 4 | import { 5 | ALIGN_OPTIONS, 6 | DISPLAY_TYPE, 7 | SORT_FILES, 8 | SORT_TAGS, 9 | TABLE_COLUMN_TYPES, 10 | } from "./constants"; 11 | import TagsOverviewPlugin from "./main"; 12 | import { formatDate } from "./utils"; 13 | import { createRoot } from "react-dom/client"; 14 | import { SettingsView } from "./views/settings-view"; 15 | import { PropertyFilter, SavedFilter, TableColumn } from "./types"; 16 | 17 | export interface TagsOverviewSettings { 18 | filterAnd: boolean; 19 | displayType: string; 20 | sortTags: string; 21 | sortFiles: string; 22 | keepFilters: boolean; 23 | displayHeaders: boolean; 24 | storedFilters: string; 25 | showNested: boolean; 26 | showRelatedTags: boolean; 27 | showCalendarDates: boolean; 28 | dateFormat: string; 29 | tableColumns: TableColumn[]; 30 | propertyFilters: PropertyFilter[]; 31 | savedFilters: SavedFilter[]; 32 | } 33 | 34 | export const DEFAULT_SETTINGS: TagsOverviewSettings = { 35 | filterAnd: true, 36 | displayType: DISPLAY_TYPE.list, 37 | sortTags: SORT_TAGS.nameAsc, 38 | sortFiles: SORT_FILES.nameAsc, 39 | keepFilters: true, 40 | displayHeaders: false, 41 | storedFilters: "", 42 | showNested: false, 43 | showRelatedTags: true, 44 | showCalendarDates: true, 45 | dateFormat: "YYYY-MM-DD", 46 | tableColumns: [ 47 | { type: TABLE_COLUMN_TYPES.name }, 48 | { type: TABLE_COLUMN_TYPES.modified, align: ALIGN_OPTIONS.right }, 49 | ], 50 | propertyFilters: [], 51 | savedFilters: [], 52 | }; 53 | 54 | export class TagsOverviewSettingTab extends PluginSettingTab { 55 | plugin: TagsOverviewPlugin; 56 | 57 | constructor(app: App, plugin: TagsOverviewPlugin) { 58 | super(app, plugin); 59 | this.plugin = plugin; 60 | } 61 | 62 | display(): void { 63 | const { containerEl } = this; 64 | containerEl.empty(); 65 | 66 | new Setting(containerEl) 67 | .setName("Keep filters") 68 | .setDesc("Keep any set filters between sessions") 69 | .addToggle((toggle) => 70 | toggle 71 | .setValue(this.plugin.settings.keepFilters) 72 | .onChange(async (value) => { 73 | this.plugin.settings.keepFilters = value; 74 | await this.plugin.saveData(this.plugin.settings); 75 | this.plugin.refreshView(); 76 | }) 77 | ); 78 | 79 | new Setting(containerEl) 80 | .setName("Show calendar dates") 81 | .setDesc( 82 | "Display dates relative to today. Will format a date with different strings if it is not older than a week." 83 | ) 84 | .addToggle((toggle) => 85 | toggle 86 | .setValue(this.plugin.settings.showCalendarDates) 87 | .onChange(async (value) => { 88 | this.plugin.settings.showCalendarDates = value; 89 | await this.plugin.saveData(this.plugin.settings); 90 | this.plugin.refreshView(); 91 | }) 92 | ); 93 | 94 | const date = new Date(); 95 | const dateFormats = [ 96 | "YYYY-MM-DD", 97 | "YYYY-MM-DD HH:mm", 98 | "YYYY-MM-DD HH:mm:ss", 99 | "DD/MM/YYYY", 100 | "l", 101 | "L", 102 | "LL", 103 | "LLL", 104 | "LLLL", 105 | "ll", 106 | "lll", 107 | "llll", 108 | "lll", 109 | ]; 110 | new Setting(containerEl) 111 | .setName("Date format") 112 | .setDesc("Set the date format used in the results list") 113 | .addDropdown((dropdown) => { 114 | dateFormats.forEach((format) => { 115 | dropdown.addOption(format, formatDate(date, format)); 116 | }); 117 | dropdown.setValue(this.plugin.settings.dateFormat); 118 | dropdown.onChange(async (value) => { 119 | this.plugin.settings.dateFormat = value; 120 | await this.plugin.saveData(this.plugin.settings); 121 | this.plugin.refreshView(); 122 | }); 123 | }); 124 | 125 | const root = document.createElement("div"); 126 | root.className = "tags-overview-table-settings"; 127 | containerEl.appendChild(root); 128 | 129 | const reactRoot = createRoot(root); 130 | reactRoot.render(); 131 | 132 | new Setting(containerEl) 133 | .setName("Display table headers") 134 | .setDesc("If table headers should be displayed or not in the list view.") 135 | .addToggle((toggle) => 136 | toggle 137 | .setValue(this.plugin.settings.displayHeaders) 138 | .onChange(async (value) => { 139 | this.plugin.settings.displayHeaders = value; 140 | await this.plugin.saveData(this.plugin.settings); 141 | this.plugin.refreshView(); 142 | }) 143 | ); 144 | 145 | new Setting(containerEl) 146 | .setName("Reset settings") 147 | .setDesc("Reset all settings to their default values") 148 | .addButton((button: ButtonComponent) => 149 | button.setButtonText("Reset settings").onClick(async () => { 150 | if (confirm("Are you sure you want to reset the settings?")) { 151 | this.plugin.settings = Object.assign({}, DEFAULT_SETTINGS); 152 | await this.plugin.saveData(this.plugin.settings); 153 | this.display(); 154 | this.plugin.refreshView(); 155 | } 156 | }) 157 | ); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian"; 2 | 3 | export interface HeaderSetting { 4 | value: string | boolean; 5 | label: string; 6 | } 7 | 8 | export interface SelectOption { 9 | value: string; 10 | label: string; 11 | } 12 | 13 | export interface ContextMenuOption { 14 | key: string; 15 | label: string; 16 | } 17 | 18 | export interface TaggedFile { 19 | file: TFile; 20 | frontMatter: Record; 21 | tags: string[]; 22 | formattedModified?: string; 23 | formattedCreated?: string; 24 | } 25 | 26 | export interface FilesByTag { 27 | [key: string]: TaggedFile[]; 28 | } 29 | 30 | export interface TagLevel { 31 | tag: string; 32 | files: string; 33 | sub: TagLevel[]; 34 | } 35 | 36 | export interface TagData { 37 | tag: string; 38 | tagPath: string; 39 | files: TaggedFile[]; 40 | sub: TagData[]; 41 | subFilesCount: number; 42 | maxModifiedTime?: number; 43 | maxCreatedTime?: number; 44 | } 45 | 46 | export interface TableColumn { 47 | type: string; 48 | align?: string; 49 | data?: string; 50 | } 51 | 52 | export interface PropertyFilter { 53 | property: string; 54 | type: string; 55 | } 56 | 57 | export interface PropertyFilterData { 58 | selected: string[]; 59 | filterAnd?: boolean; 60 | filterOperator?: string; 61 | } 62 | 63 | export interface PropertyFilterDataList { 64 | [key: string]: PropertyFilterData; 65 | } 66 | 67 | export interface AvailableFilterOptions { 68 | [key: string]: string[]; 69 | } 70 | 71 | export interface StringMap { 72 | [key: string]: string; 73 | } 74 | 75 | export interface SavedFilter { 76 | name: string; 77 | selectedOptions: SelectOption[]; 78 | filterAnd: boolean; 79 | properyFilters: PropertyFilterDataList; 80 | } 81 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { SelectOption, TagData, TaggedFile } from "./types"; 2 | import { App, TFile, moment, getAllTags } from "obsidian"; 3 | import { SORT_FILES, SORT_TAGS } from "./constants"; 4 | 5 | export function formatDate(date: Date, dateFormat: string): string { 6 | return moment(date).format(dateFormat); 7 | } 8 | 9 | export function formatCalendardDate(mtime: Date, dateFormat: string): string { 10 | return moment(mtime).calendar({ 11 | sameElse: dateFormat, 12 | }); 13 | } 14 | 15 | export const getNestedTags = (taggedFile: TaggedFile): string[] => { 16 | const nestedTags: string[] = []; 17 | taggedFile.tags.forEach((tag: string) => { 18 | if (tag.includes("/")) { 19 | const splitTags = tag.split("/"); 20 | for (let i = 0; i < splitTags.length; i++) { 21 | nestedTags.push(splitTags.slice(0, i + 1).join("/")); 22 | } 23 | } 24 | }); 25 | return nestedTags; 26 | }; 27 | 28 | export const addOrRemove = (arr: string[], item: string) => 29 | arr.includes(item) ? arr.filter((i) => i !== item) : [...arr, item]; 30 | 31 | export const pluralize = (count: number, singular: string, plural: string) => { 32 | return count === 1 ? `1 ${singular}` : `${count} ${plural}`; 33 | }; 34 | 35 | export const getTaggedFileFromFile = (app: App, file: TFile): TaggedFile => { 36 | const cache = app.metadataCache.getFileCache(file); 37 | const fileTags = cache 38 | ? getAllTags(cache)?.map((tag) => tag.substring(1)) || [] 39 | : []; 40 | return { 41 | file, 42 | frontMatter: { ...cache?.frontmatter }, 43 | tags: fileTags.length > 0 ? [...new Set(fileTags)] : fileTags, 44 | }; 45 | }; 46 | 47 | export const shouldIgnoreFile = (tagsoverview: string | string[]): boolean => { 48 | return Array.isArray(tagsoverview) 49 | ? tagsoverview.includes("ignore") 50 | : tagsoverview === "ignore"; 51 | }; 52 | 53 | export const getAllTagsAndFiles = (app: App) => { 54 | const taggedFilesMap = new Map(); 55 | let allTags: string[] = []; 56 | app.vault.getMarkdownFiles().forEach((markdownFile: TFile) => { 57 | const taggedFile: TaggedFile = getTaggedFileFromFile(app, markdownFile); 58 | // Check if the file should be included 59 | if ( 60 | taggedFile.tags.length && 61 | !shouldIgnoreFile(taggedFile.frontMatter?.tagsoverview) 62 | ) { 63 | allTags = [...allTags, ...getNestedTags(taggedFile), ...taggedFile.tags]; 64 | taggedFilesMap.set(markdownFile, taggedFile); 65 | } 66 | }); 67 | // Remove duplicates and sort 68 | allTags = [...new Set(allTags)].sort(); 69 | return { 70 | allTags, 71 | taggedFilesMap, 72 | }; 73 | }; 74 | 75 | export const openFile = (app: App, file: TFile, inNewLeaf = false): void => { 76 | let leaf = app.workspace.getMostRecentLeaf(); 77 | if (leaf) { 78 | if (inNewLeaf || leaf.getViewState().pinned) { 79 | leaf = app.workspace.getLeaf("tab"); 80 | } 81 | leaf.openFile(file); 82 | } 83 | }; 84 | 85 | // Set dates functions 86 | const getMaxTimesFromFiles = ( 87 | taggedFiles: TaggedFile[] 88 | ): [number | undefined, number | undefined] => { 89 | let modifiedTime: number | undefined; 90 | let createdTime: number | undefined; 91 | taggedFiles.forEach((taggedFile: TaggedFile) => { 92 | if ( 93 | !modifiedTime || 94 | (taggedFile.file.stat.mtime && taggedFile.file.stat.mtime > modifiedTime) 95 | ) { 96 | modifiedTime = taggedFile.file.stat.mtime; 97 | } 98 | if ( 99 | !createdTime || 100 | (taggedFile.file.stat.ctime && taggedFile.file.stat.ctime > createdTime) 101 | ) { 102 | createdTime = taggedFile.file.stat.ctime; 103 | } 104 | }); 105 | 106 | return [modifiedTime, createdTime]; 107 | }; 108 | export const setMaxTimesForTags = ( 109 | tags: TagData[] 110 | ): [number | undefined, number | undefined] => { 111 | let totalModifiedTime: number | undefined; 112 | let totalCreatedTime: number | undefined; 113 | tags.forEach((tagData: TagData) => { 114 | const [tagModifiedTime, tagCreatedTime] = getMaxTimesFromFiles( 115 | tagData.files 116 | ); 117 | const [subModifiedTime, subCreatedTime] = setMaxTimesForTags(tagData.sub); 118 | 119 | const modifiedTime: number | undefined = 120 | tagModifiedTime && subModifiedTime 121 | ? Math.min(tagModifiedTime, subModifiedTime) 122 | : tagModifiedTime || subModifiedTime; 123 | const createdTime: number | undefined = 124 | tagCreatedTime && subCreatedTime 125 | ? Math.max(tagCreatedTime, subCreatedTime) 126 | : tagCreatedTime || subCreatedTime; 127 | 128 | tagData.maxModifiedTime = modifiedTime; 129 | tagData.maxCreatedTime = createdTime; 130 | 131 | if ( 132 | modifiedTime && 133 | (!totalModifiedTime || modifiedTime < totalModifiedTime) 134 | ) { 135 | totalModifiedTime = modifiedTime; 136 | } 137 | if (createdTime && (!totalCreatedTime || createdTime > totalCreatedTime)) { 138 | totalCreatedTime = createdTime; 139 | } 140 | }); 141 | return [totalModifiedTime, totalCreatedTime]; 142 | }; 143 | 144 | // Sort functions 145 | export const sortTagsAndFiles = ( 146 | nestedTags: TagData[], 147 | sortTags: string, 148 | sortFiles: string 149 | ) => { 150 | // Sort tags and file 151 | const sortFilesFn = (tFileA: TaggedFile, tFileB: TaggedFile) => { 152 | const nameA: string = tFileA.file.basename.toLowerCase(); 153 | const nameB: string = tFileB.file.basename.toLowerCase(); 154 | 155 | // Sort by name 156 | if (sortFiles == SORT_FILES.nameAsc) { 157 | return nameA > nameB ? 1 : -1; 158 | } else if (sortFiles == SORT_FILES.nameDesc) { 159 | return nameA < nameB ? 1 : -1; 160 | } 161 | 162 | // Sort by modified timestamp 163 | if (tFileA.file.stat.mtime && tFileB.file.stat.mtime) { 164 | if (sortFiles == SORT_FILES.modifiedAsc) { 165 | return tFileA.file.stat.mtime < tFileB.file.stat.mtime ? 1 : -1; 166 | } else if (sortFiles == SORT_FILES.modifiedDesc) { 167 | return tFileA.file.stat.mtime < tFileB.file.stat.mtime ? -1 : 1; 168 | } 169 | } 170 | 171 | // Sort by created timestamp 172 | if (tFileA.file.stat.ctime && tFileB.file.stat.ctime) { 173 | if (sortFiles == SORT_FILES.createdAsc) { 174 | return tFileA.file.stat.ctime < tFileB.file.stat.ctime ? 1 : -1; 175 | } else if (sortFiles == SORT_FILES.createdDesc) { 176 | return tFileA.file.stat.ctime < tFileB.file.stat.ctime ? -1 : 1; 177 | } 178 | } 179 | 180 | // Sort by frontmatter property 181 | if ( 182 | sortFiles.includes("property__") && 183 | tFileA.frontMatter && 184 | tFileB.frontMatter 185 | ) { 186 | let property = sortFiles.replace("property__", ""); 187 | let desc = false; 188 | if (property.startsWith("-")) { 189 | desc = true; 190 | property = property.substring(1); 191 | } 192 | const frontMatterA = tFileA.frontMatter[property]; 193 | const frontMatterB = tFileB.frontMatter[property]; 194 | if (frontMatterA && frontMatterB) { 195 | const frontMatterValueA = Array.isArray(frontMatterA) 196 | ? frontMatterA.join("").toLowerCase() 197 | : typeof frontMatterA === "string" 198 | ? (frontMatterA as string).toLowerCase() 199 | : typeof frontMatterA === "object" 200 | ? "" 201 | : frontMatterA; 202 | const frontMatterValueB = Array.isArray(frontMatterB) 203 | ? frontMatterB.join("").toLowerCase() 204 | : typeof frontMatterB === "string" 205 | ? (frontMatterB as string).toLowerCase() 206 | : typeof frontMatterB === "object" 207 | ? "" 208 | : frontMatterB; 209 | 210 | // If both values starts with "-" we should flip the sort order 211 | if ( 212 | frontMatterValueA.startsWith("-") && 213 | frontMatterValueB.startsWith("-") 214 | ) { 215 | desc = !desc; 216 | } 217 | 218 | return desc 219 | ? frontMatterValueA < frontMatterValueB 220 | ? 1 221 | : -1 222 | : frontMatterValueA > frontMatterValueB 223 | ? 1 224 | : -1; 225 | } 226 | } 227 | 228 | // Default sort by name 229 | return nameA > nameB ? 1 : -1; 230 | }; 231 | const sortTagsFn = (tagA: TagData, tagB: TagData) => { 232 | const nameA: string = tagA.tag.toLowerCase(); 233 | const nameB: string = tagB.tag.toLowerCase(); 234 | 235 | if (sortTags == SORT_TAGS.nameAsc) { 236 | return nameA > nameB ? 1 : -1; 237 | } else if (sortTags == SORT_TAGS.nameDesc) { 238 | return nameA < nameB ? 1 : -1; 239 | } else if (sortTags == SORT_TAGS.frequencyAsc) { 240 | return tagA.files.length < tagB.files.length ? 1 : -1; 241 | } else if (sortTags == SORT_TAGS.frequencyDesc) { 242 | return tagA.files.length < tagB.files.length ? -1 : 1; 243 | } 244 | if (tagA.maxModifiedTime && tagB.maxModifiedTime) { 245 | if (sortTags == SORT_TAGS.modifiedAsc) { 246 | return tagA.maxModifiedTime < tagB.maxModifiedTime ? 1 : -1; 247 | } else if (sortTags == SORT_TAGS.modifiedDesc) { 248 | return tagA.maxModifiedTime > tagB.maxModifiedTime ? 1 : -1; 249 | } 250 | } 251 | if (tagA.maxCreatedTime && tagB.maxCreatedTime) { 252 | if (sortTags == SORT_TAGS.createdAsc) { 253 | return tagA.maxCreatedTime < tagB.maxCreatedTime ? 1 : -1; 254 | } else if (sortTags == SORT_TAGS.createdDesc) { 255 | return tagA.maxCreatedTime > tagB.maxCreatedTime ? 1 : -1; 256 | } 257 | } 258 | return 0; 259 | }; 260 | const sortNestedTags = (tags: TagData[]) => { 261 | tags.sort(sortTagsFn); 262 | tags.forEach((tagData: TagData) => { 263 | tagData.files.sort(sortFilesFn); 264 | if (tagData.sub.length) { 265 | sortNestedTags(tagData.sub); 266 | } 267 | }); 268 | }; 269 | sortNestedTags(nestedTags); 270 | }; 271 | 272 | export function convertStringsToOptions(strings: string[]): SelectOption[] { 273 | return strings.map((val: string) => ({ 274 | value: val, 275 | label: upperCaseFirstChar(val), 276 | })); 277 | } 278 | 279 | export function camelCaseString(str: string): string { 280 | return str 281 | ? str 282 | .split(" ") 283 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 284 | .join(" ") 285 | : ""; 286 | } 287 | 288 | export function upperCaseFirstChar(str: string): string { 289 | return str ? str.charAt(0).toUpperCase() + str.slice(1) : ""; 290 | } 291 | 292 | export function deepCopy(obj: T): T { 293 | return JSON.parse(JSON.stringify(obj)); 294 | } 295 | -------------------------------------------------------------------------------- /src/views/root-view.tsx: -------------------------------------------------------------------------------- 1 | import { ItemView, TFile, WorkspaceLeaf } from "obsidian"; 2 | 3 | import * as React from "react"; 4 | import { TagsView } from "./tags-view"; 5 | import { Root, createRoot } from "react-dom/client"; 6 | import TagsOverviewPlugin from "../main"; 7 | import { TaggedFile } from "src/types"; 8 | import { 9 | getAllTagsAndFiles, 10 | getNestedTags, 11 | getTaggedFileFromFile, 12 | shouldIgnoreFile, 13 | } from "src/utils"; 14 | 15 | export const VIEW_TYPE = "tags-overview-view"; 16 | 17 | export class RootView extends ItemView { 18 | plugin: TagsOverviewPlugin; 19 | root: Root; 20 | allTags: string[]; 21 | taggedFilesMap: Map; 22 | 23 | constructor(leaf: WorkspaceLeaf, plugin: TagsOverviewPlugin) { 24 | super(leaf); 25 | this.plugin = plugin; 26 | 27 | // Collect all tags and files 28 | const { 29 | allTags, 30 | taggedFilesMap, 31 | }: { allTags: string[]; taggedFilesMap: Map } = 32 | getAllTagsAndFiles(this.app); 33 | this.allTags = allTags; 34 | this.taggedFilesMap = taggedFilesMap; 35 | 36 | // Listen on file changes and update the list of tagged files 37 | plugin.registerEvent( 38 | this.app.metadataCache.on("changed", (modifiedFile: TFile) => { 39 | const taggedFile: TaggedFile = getTaggedFileFromFile( 40 | this.app, 41 | modifiedFile 42 | ); 43 | 44 | // If the file is ignored, remove it from the taggedFilesMap 45 | if (shouldIgnoreFile(taggedFile.frontMatter?.tagsoverview)) { 46 | this.taggedFilesMap.delete(modifiedFile); 47 | } else { 48 | // Otherwise, update the taggedFilesMap 49 | this.taggedFilesMap.set( 50 | modifiedFile, 51 | getTaggedFileFromFile(this.app, modifiedFile) 52 | ); 53 | } 54 | 55 | // Update the allTags list 56 | this.allTags = [ 57 | ...new Set( 58 | [...this.taggedFilesMap.values()].reduce( 59 | (tags: string[], taggedFile: TaggedFile) => [ 60 | ...getNestedTags(taggedFile), 61 | ...taggedFile.tags, 62 | ...tags, 63 | ], 64 | [] 65 | ) 66 | ), 67 | ].sort(); 68 | this.render(); 69 | }) 70 | ); 71 | 72 | // Remove deleted files from the list 73 | plugin.registerEvent( 74 | this.app.vault.on("delete", (deletedFile: TFile) => { 75 | this.taggedFilesMap.delete(deletedFile); 76 | this.render(); 77 | }) 78 | ); 79 | } 80 | 81 | refresh() { 82 | this.render(); 83 | } 84 | 85 | getViewType() { 86 | return VIEW_TYPE; 87 | } 88 | 89 | getDisplayText() { 90 | return "Tags overview"; 91 | } 92 | 93 | getIcon(): string { 94 | return "tag"; 95 | } 96 | 97 | async onOpen() { 98 | this.root = createRoot(this.containerEl.children[1]); 99 | this.render(); 100 | } 101 | 102 | render() { 103 | if (this.root) { 104 | this.root.render( 105 | 110 | ); 111 | } 112 | } 113 | 114 | async onClose() { 115 | this.root.unmount(); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/views/settings-view.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { TableColumnsSelector } from "../components/table-columns-select"; 3 | import { PropertyFilterSelector } from "../components/property-filter-select"; 4 | import TagsOverviewPlugin from "src/main"; 5 | 6 | export const SettingsView = ({ plugin }: { plugin: TagsOverviewPlugin }) => { 7 | const frontMatterPropertiesSet = new Set(); 8 | plugin.app.vault.getMarkdownFiles().forEach((file) => { 9 | const cache = plugin.app.metadataCache.getFileCache(file); 10 | if (cache?.frontmatter) { 11 | Object.keys(cache.frontmatter).forEach((key) => 12 | frontMatterPropertiesSet.add(key) 13 | ); 14 | } 15 | }); 16 | 17 | // Convert to array and sort 18 | const frontMatterProperties: string[] = Array.from(frontMatterPropertiesSet); 19 | frontMatterProperties.sort((a, b) => a.localeCompare(b)); 20 | 21 | return ( 22 | <> 23 |
24 |
25 |
Property filters
26 |
27 | Add additional filters. 28 |
29 |
30 |
31 | 35 | 36 |
37 |
38 |
Table columns
39 |
40 | Select which columns to show in the list view table. 41 |
42 |
43 |
44 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/views/tags-view.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEffect, useState } from "react"; 3 | import Select from "react-select"; 4 | import { App, TFile } from "obsidian"; 5 | 6 | import TagsOverviewPlugin from "../main"; 7 | import { RootView } from "./root-view"; 8 | import { HeaderSettings } from "../components/header-settings"; 9 | import { Tags } from "../components/tags"; 10 | import { NameInputModal } from "../components/name-input-modal"; 11 | import { SaveFilterMenu } from "../components/save-filter-menu"; 12 | import { 13 | formatDate, 14 | formatCalendardDate, 15 | openFile, 16 | setMaxTimesForTags, 17 | convertStringsToOptions, 18 | camelCaseString, 19 | deepCopy, 20 | } from "src/utils"; 21 | import { 22 | AvailableFilterOptions, 23 | FilesByTag, 24 | PropertyFilter, 25 | PropertyFilterDataList, 26 | SavedFilter, 27 | SelectOption, 28 | StringMap, 29 | TagData, 30 | TaggedFile, 31 | } from "src/types"; 32 | import { FILTER_TYPES } from "src/constants"; 33 | 34 | export const TagsView = ({ 35 | rootView, 36 | allTags, 37 | allTaggedFiles, 38 | }: { 39 | rootView: RootView; 40 | allTags: string[]; 41 | allTaggedFiles: TaggedFile[]; 42 | }) => { 43 | const app: App = rootView.app; 44 | const plugin: TagsOverviewPlugin = rootView.plugin; 45 | 46 | // Setup hooks 47 | const defaultOptions: SelectOption[] = 48 | plugin.settings.keepFilters && plugin.settings.storedFilters 49 | ? plugin.settings.storedFilters.split(",").map((tag: string) => ({ 50 | value: tag, 51 | label: tag, 52 | })) 53 | : []; 54 | const [selectedOptions, setSelectedOptions] = 55 | useState(defaultOptions); 56 | const [filterAnd, setFilterAnd] = useState(plugin.settings.filterAnd); 57 | const [showNested, setShowNested] = useState(plugin.settings.showNested); 58 | const [showRelatedTags, setShowRelatedTags] = useState( 59 | plugin.settings.showRelatedTags 60 | ); 61 | 62 | const [savedFilters, setSavedFilters] = useState( 63 | plugin.settings.savedFilters 64 | ); 65 | 66 | // PropertyFiltersDataList is a map of property filters and their selected values 67 | const [propertyFilterDataList, setSelectedFilters] = 68 | useState({}); 69 | 70 | // Construct a map of property filters and their types 71 | const propertyFilterTypeMap: StringMap = 72 | plugin.settings.propertyFilters.reduce( 73 | (typeMap: StringMap, propertyFilter: PropertyFilter) => { 74 | typeMap[propertyFilter.property] = propertyFilter.type; 75 | return typeMap; 76 | }, 77 | {} 78 | ); 79 | 80 | // Construct the map of available filter options 81 | const availableFilterOptions: AvailableFilterOptions = 82 | plugin.settings.propertyFilters.reduce( 83 | (options: AvailableFilterOptions, propertyFilter: PropertyFilter) => { 84 | options[propertyFilter.property] = []; 85 | return options; 86 | }, 87 | {} 88 | ); 89 | if (plugin.settings.propertyFilters.length) { 90 | plugin.app.vault.getMarkdownFiles().forEach((file) => { 91 | const cache = plugin.app.metadataCache.getFileCache(file); 92 | if (cache?.frontmatter) { 93 | Object.keys(cache.frontmatter).forEach((key) => { 94 | if (cache.frontmatter && availableFilterOptions[key] !== undefined) { 95 | const frontMatterVal = cache.frontmatter[key]; 96 | 97 | if (Array.isArray(frontMatterVal)) { 98 | frontMatterVal.forEach((val) => { 99 | if (val !== undefined && val !== null) { 100 | availableFilterOptions[key].push(val.toString()); 101 | } 102 | }); 103 | } else { 104 | if (frontMatterVal !== undefined && frontMatterVal !== null) { 105 | availableFilterOptions[key].push(frontMatterVal.toString()); 106 | } 107 | } 108 | } 109 | }); 110 | } 111 | }); 112 | } 113 | 114 | // Remove duplicates 115 | Object.keys(availableFilterOptions).forEach((key) => { 116 | availableFilterOptions[key] = [...new Set(availableFilterOptions[key])]; 117 | }); 118 | 119 | useEffect(() => { 120 | plugin.saveSettings({ 121 | filterAnd, 122 | savedFilters, 123 | showNested, 124 | showRelatedTags, 125 | }); 126 | }, [filterAnd, savedFilters, showNested, showRelatedTags]); 127 | 128 | useEffect(() => { 129 | plugin.saveSettings({ 130 | storedFilters: selectedOptions.map((option) => option.value).join(","), 131 | }); 132 | }, [selectedOptions]); 133 | 134 | const onFileClicked = (file: TFile, inNewLeaf: boolean = false) => { 135 | openFile(app, file, inNewLeaf); 136 | }; 137 | 138 | const onFiltersChange = (propertyFilterKey: string, values: string[]) => { 139 | const newSelectedFilters = { ...propertyFilterDataList }; 140 | if (newSelectedFilters[propertyFilterKey] === undefined) { 141 | newSelectedFilters[propertyFilterKey] = { 142 | selected: values, 143 | filterAnd: false, 144 | }; 145 | } else { 146 | newSelectedFilters[propertyFilterKey].selected = values; 147 | } 148 | setSelectedFilters(newSelectedFilters); 149 | }; 150 | 151 | const updatePropertyFilter = ( 152 | propertyFilterKey: string, 153 | filterOperator: string | undefined, 154 | filterAnd: boolean | undefined 155 | ) => { 156 | const newSelectedFilters = { ...propertyFilterDataList }; 157 | if (newSelectedFilters[propertyFilterKey] === undefined) { 158 | newSelectedFilters[propertyFilterKey] = { 159 | selected: [], 160 | filterOperator: filterOperator || "eq", 161 | filterAnd: filterAnd || false, 162 | }; 163 | } else { 164 | if (filterOperator !== undefined) { 165 | newSelectedFilters[propertyFilterKey].filterOperator = filterOperator; 166 | } 167 | if (filterAnd !== undefined) { 168 | newSelectedFilters[propertyFilterKey].filterAnd = filterAnd; 169 | } 170 | } 171 | setSelectedFilters(newSelectedFilters); 172 | }; 173 | // Get files to be displayed 174 | const selectedTags: string[] = 175 | selectedOptions?.map((option: SelectOption) => option.value) || []; 176 | let displayFiles: TaggedFile[] = selectedTags.length 177 | ? allTaggedFiles.filter((file: TaggedFile) => { 178 | return filterAnd 179 | ? selectedTags.every( 180 | (selectedTag) => 181 | file.tags.includes(selectedTag) || 182 | file.tags.some((tag) => tag.startsWith(`${selectedTag}/`)) 183 | ) 184 | : file.tags.some( 185 | (tag) => 186 | selectedTags.includes(tag) || 187 | selectedTags.some((selectedTag) => 188 | tag.startsWith(`${selectedTag}/`) 189 | ) 190 | ); 191 | }) 192 | : [...allTaggedFiles]; 193 | 194 | // Filter the list of files based on the property filters 195 | if (Object.keys(propertyFilterDataList).length > 0) { 196 | displayFiles = displayFiles.filter((file: TaggedFile) => { 197 | const frontMatter = plugin.app.metadataCache.getFileCache( 198 | file.file 199 | )?.frontmatter; 200 | 201 | for (let i = 0; i < Object.keys(propertyFilterDataList).length; i++) { 202 | const propertyFilterKey = Object.keys(propertyFilterDataList)[i]; 203 | const propertyFilterData = propertyFilterDataList[propertyFilterKey]; 204 | if ( 205 | propertyFilterData.selected.length === 0 || 206 | !propertyFilterData.selected[0] 207 | ) { 208 | continue; 209 | } 210 | const propertyFilterVal = propertyFilterData.selected[0]; 211 | const frontMatterVal = frontMatter 212 | ? frontMatter[propertyFilterKey] 213 | : false; 214 | if (!frontMatterVal) return false; 215 | 216 | let includeFile; 217 | if (propertyFilterTypeMap[propertyFilterKey] === FILTER_TYPES.text) { 218 | const searchString = propertyFilterVal.toLowerCase(); 219 | if (Array.isArray(frontMatterVal)) { 220 | includeFile = frontMatterVal.some((val) => 221 | val.toLowerCase().contains(searchString) 222 | ); 223 | } else { 224 | includeFile = frontMatterVal.contains(searchString); 225 | } 226 | } else if ( 227 | propertyFilterTypeMap[propertyFilterKey] === FILTER_TYPES.number 228 | ) { 229 | const filterOperator = propertyFilterData.filterOperator || "eq"; 230 | if (Array.isArray(frontMatterVal)) { 231 | includeFile = frontMatterVal.some((val) => { 232 | switch (filterOperator) { 233 | case "eq": 234 | return val === propertyFilterVal; 235 | case "neq": 236 | return val !== propertyFilterVal; 237 | case "gt": 238 | return val > propertyFilterVal; 239 | case "gte": 240 | return val >= propertyFilterVal; 241 | case "lt": 242 | return val < propertyFilterVal; 243 | case "lte": 244 | return val <= propertyFilterVal; 245 | default: 246 | return false; 247 | } 248 | }); 249 | } else { 250 | switch (filterOperator) { 251 | case "eq": 252 | includeFile = frontMatterVal === propertyFilterVal; 253 | break; 254 | case "neq": 255 | includeFile = frontMatterVal !== propertyFilterVal; 256 | break; 257 | case "gt": 258 | includeFile = frontMatterVal > propertyFilterVal; 259 | break; 260 | case "gte": 261 | includeFile = frontMatterVal >= propertyFilterVal; 262 | break; 263 | case "lt": 264 | includeFile = frontMatterVal < propertyFilterVal; 265 | break; 266 | case "lte": 267 | includeFile = frontMatterVal <= propertyFilterVal; 268 | break; 269 | default: 270 | includeFile = false; 271 | break; 272 | } 273 | } 274 | } else if (propertyFilterData.filterAnd || false) { 275 | includeFile = Array.isArray(frontMatterVal) 276 | ? propertyFilterData.selected.every((val) => 277 | frontMatterVal.includes(val.toString()) 278 | ) 279 | : propertyFilterData.selected.length === 1 && 280 | frontMatterVal === propertyFilterVal.toString(); 281 | } else { 282 | includeFile = Array.isArray(frontMatterVal) 283 | ? frontMatterVal.some((val) => 284 | propertyFilterData.selected.includes(val.toString()) 285 | ) 286 | : propertyFilterData.selected.includes(frontMatterVal.toString()); 287 | } 288 | 289 | if (!includeFile) return false; 290 | } 291 | return true; 292 | }); 293 | } 294 | 295 | // Curry the files with a formatted version of the last modified and created date 296 | const getFormattedDate = (date: Date): string => { 297 | return plugin.settings.showCalendarDates 298 | ? formatCalendardDate(date, plugin.settings.dateFormat) 299 | : formatDate(date, plugin.settings.dateFormat); 300 | }; 301 | displayFiles.forEach((taggedFile: TaggedFile) => { 302 | taggedFile.formattedCreated = getFormattedDate( 303 | new Date(taggedFile.file.stat.ctime) 304 | ); 305 | taggedFile.formattedModified = getFormattedDate( 306 | new Date(taggedFile.file.stat.mtime) 307 | ); 308 | }); 309 | 310 | // Get tags to be displayed 311 | const tagsTree: FilesByTag = {}; 312 | const displayTags = new Set(); 313 | 314 | // Include related tags 315 | displayFiles.forEach((taggedFile: TaggedFile) => { 316 | let tags: string[] = taggedFile.tags; 317 | if (!showRelatedTags) { 318 | tags = tags.filter( 319 | (tag: string) => 320 | !selectedTags.length || 321 | selectedTags.contains(tag) || 322 | selectedTags.some((selectedTag) => tag.startsWith(`${selectedTag}/`)) 323 | ); 324 | } 325 | tags.forEach((tag) => { 326 | displayTags.add(tag); 327 | tagsTree[tag] = tagsTree[tag] || []; 328 | tagsTree[tag].push(taggedFile); 329 | }); 330 | }); 331 | // Construct the tree of nested tags 332 | const nestedTags: TagData[] = []; 333 | let tagsCount = 0; 334 | [...displayTags].forEach((tag: string) => { 335 | let activePart: TagData[] = nestedTags; 336 | const tagPaths: string[] = []; 337 | // Split the tag into nested ones, if the setting is enabled 338 | (showNested ? tag.split("/") : [tag]).forEach((part: string) => { 339 | tagPaths.push(part); 340 | let checkPart: TagData | undefined = activePart.find( 341 | (c: TagData) => c.tag == part 342 | ); 343 | if (!checkPart) { 344 | tagsCount += 1; 345 | checkPart = { 346 | tag: part, 347 | tagPath: tagPaths.join("/"), 348 | sub: [], 349 | files: tagsTree[tagPaths.join("/")] || [], 350 | subFilesCount: 0, 351 | }; 352 | activePart.push(checkPart); 353 | } 354 | activePart = checkPart.sub; 355 | }); 356 | }); 357 | 358 | const sumUpNestedFilesCount = (tags: TagData[]) => { 359 | tags.forEach((tagData: TagData) => { 360 | if (tagData.sub.length) { 361 | tagData.subFilesCount = tagData.sub.reduce( 362 | (count: number, sub: TagData) => { 363 | return ( 364 | count + 365 | Object.keys(tagsTree) 366 | .filter((tag) => { 367 | return tag.includes(sub.tag) && tag !== sub.tag; 368 | }) 369 | .reduce((subCount: number, tag: string) => { 370 | return subCount + tagsTree[tag].length; 371 | }, 0) 372 | ); 373 | }, 374 | 0 375 | ); 376 | sumUpNestedFilesCount(tagData.sub); 377 | } 378 | }); 379 | }; 380 | 381 | // Curry the tags with counts and max dates 382 | sumUpNestedFilesCount(nestedTags); 383 | setMaxTimesForTags(nestedTags); 384 | 385 | const loadSavedFilter = (filter: SavedFilter) => { 386 | setSelectedOptions(filter.selectedOptions); 387 | setFilterAnd(filter.filterAnd); 388 | // Loop through the property filters and update the selected filters. 389 | // Ignore filters that are not in the enabled in the settings. 390 | const newPropertyFilter: PropertyFilterDataList = {}; 391 | Object.keys(filter.properyFilters).forEach((key: string) => { 392 | if (propertyFilterTypeMap[key]) { 393 | if ( 394 | propertyFilterTypeMap[key] === FILTER_TYPES.number && 395 | (filter.properyFilters[key].selected.length !== 1 || 396 | Number.isNaN(parseInt(filter.properyFilters[key].selected[0]))) 397 | ) { 398 | filter.properyFilters[key].selected = ["0"]; 399 | } 400 | newPropertyFilter[key] = deepCopy(filter.properyFilters[key]); 401 | } 402 | }); 403 | setSelectedFilters(newPropertyFilter); 404 | }; 405 | 406 | const saveFilter = () => { 407 | new NameInputModal(app, async (name: string) => { 408 | // Check if the filter already exists 409 | const filterExists = savedFilters.find( 410 | (filter: SavedFilter) => filter.name === name 411 | ); 412 | 413 | if (filterExists) { 414 | if ( 415 | await confirm( 416 | "There is already a filter with that name. Do you want to update it?" 417 | ) 418 | ) { 419 | const newFilters = [...savedFilters]; 420 | const index = newFilters.findIndex( 421 | (filter: SavedFilter) => filter.name === name 422 | ); 423 | newFilters[index] = { 424 | name, 425 | selectedOptions, 426 | filterAnd, 427 | properyFilters: deepCopy(propertyFilterDataList), 428 | }; 429 | setSavedFilters(newFilters); 430 | } 431 | return; 432 | } 433 | 434 | setSavedFilters( 435 | [ 436 | ...savedFilters, 437 | { 438 | name, 439 | selectedOptions, 440 | filterAnd, 441 | properyFilters: deepCopy(propertyFilterDataList), 442 | }, 443 | ].sort((a: SavedFilter, b: SavedFilter) => a.name.localeCompare(b.name)) 444 | ); 445 | }).open(); 446 | }; 447 | 448 | const removeFilter = async (index: number) => { 449 | if (await confirm("Do you really want to delete the filter?")) { 450 | const newFilters = [...savedFilters]; 451 | newFilters.splice(index, 1); 452 | setSavedFilters(newFilters); 453 | } 454 | }; 455 | 456 | return ( 457 |
458 | 464 | 474 | { 548 | onFiltersChange( 549 | propertyFilter.property, 550 | val.map((value: SelectOption) => value.value) 551 | ); 552 | }} 553 | options={convertStringsToOptions( 554 | availableFilterOptions[propertyFilter.property] 555 | ).sort( 556 | (optionA: SelectOption, optionB: SelectOption): number => { 557 | const lblA: string = ( 558 | optionA.label !== undefined ? optionA.label : "" 559 | ) 560 | .toString() 561 | .toLowerCase(); 562 | const lblB: string = ( 563 | optionB.label !== undefined ? optionB.label : "" 564 | ) 565 | .toString() 566 | .toLowerCase(); 567 | return lblA === lblB ? 0 : lblA > lblB ? 1 : -1; 568 | } 569 | )} 570 | name="Filter" 571 | placeholder={`Select ${propertyFilter.property}`} 572 | isMulti 573 | /> 574 | )} 575 | {propertyFilter.type === FILTER_TYPES.text && ( 576 | { 587 | const val = event.target.value; 588 | onFiltersChange(propertyFilter.property, val ? [val] : []); 589 | }} 590 | /> 591 | )} 592 | {propertyFilter.type === FILTER_TYPES.number && ( 593 | { 604 | const val = event.target.value; 605 | onFiltersChange(propertyFilter.property, val ? [val] : []); 606 | }} 607 | /> 608 | )} 609 |
610 | ))} 611 | 612 | 613 | { 625 | setSelectedOptions( 626 | selectedOptions.find((option) => option.value === tagData.tagPath) 627 | ? selectedOptions.filter( 628 | (option) => option.value !== tagData.tagPath 629 | ) 630 | : [ 631 | ...selectedOptions, 632 | { 633 | label: tagData.tagPath, 634 | value: tagData.tagPath, 635 | }, 636 | ] 637 | ); 638 | }} 639 | /> 640 | 641 | ); 642 | }; 643 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .tags-overview input[type=text]:focus { 2 | box-shadow: initial; 3 | } 4 | .tags-overview .tags-container { 5 | margin-bottom: 30px; 6 | } 7 | .tags-overview .tags-info-container { 8 | display: flex; 9 | justify-content: space-between; 10 | padding: 3px; 11 | } 12 | .tags-overview .count-label { 13 | font-size: var(--font-smallest); 14 | padding: 4px; 15 | } 16 | .tags-overview .tags-info-container .icons { 17 | display: flex; 18 | } 19 | .tags-overview .display-type-compact .file-link { 20 | display: inline-block; 21 | background-color: var(--color-base-40); 22 | border-radius: 10px; 23 | font-size: var(--font-ui-smaller); 24 | padding: 4px 8px; 25 | color: var(--color-base-100); 26 | margin: 4px 4px 0 0; 27 | cursor: pointer; 28 | } 29 | .tags-overview .display-type-compact .file-link:hover { 30 | background-color: var(--color-base-100); 31 | color: var(--color-base-40); 32 | } 33 | .tags-overview .tag-title-row { 34 | margin: 10px 0 5px; 35 | } 36 | .tags-level-0 > .tags-overview .tag-title-row { 37 | margin-top: 20px; 38 | } 39 | .tags-overview .tag-title-row span { 40 | margin: 6px 0 0 8px; 41 | vertical-align: top; 42 | display: inline-block; 43 | font-size: 12px; 44 | color: #aaa; 45 | } 46 | .tags-overview .tag-title { 47 | margin: 0; 48 | display: inline-block; 49 | } 50 | .tags-overview .tags-table-container .tag-title-row { 51 | margin: 8px 0 1px; 52 | display: flex; 53 | flex-direction: row; 54 | justify-content: space-between; 55 | } 56 | .tags-overview .tags-table-container .sub-tags-row .tag-title-row { 57 | margin-top: 2px; 58 | } 59 | .tags-overview .tags-table-container .tag-title-row span { 60 | margin: 10px 5px 0 0; 61 | vertical-align: top; 62 | display: inline-block; 63 | font-size: var(--font-ui-smaller); 64 | color: var(--color-base-70); 65 | white-space: nowrap; 66 | padding-left: 5px; 67 | } 68 | .tags-overview .tags-table-container .tag-title-row .tag-title { 69 | margin: 0; 70 | display: inline-block; 71 | } 72 | .tags-overview .tags-table-container { 73 | position: relative; 74 | } 75 | .tags-overview .tags-table-container.tags-level-0 { 76 | margin-top: 20px; 77 | } 78 | .tags-overview .tags-table-container table { 79 | width: 100%; 80 | border-spacing: 0; 81 | border-collapse: initial; 82 | border: 0 !important; 83 | } 84 | .tags-overview .tags-table-container th { 85 | text-align: left; 86 | font-size: var(--font-ui-smaller); 87 | color: var(--color-base-70); 88 | } 89 | .tags-overview .tags-table-container th.align-center { 90 | text-align: center; 91 | } 92 | .tags-overview .tags-table-container th.align-right { 93 | text-align: right; 94 | } 95 | .tags-overview .tags-table-container tbody td { 96 | border-top: 1px solid var(--color-base-40); 97 | border-left: 0 !important; 98 | border-right: 0 !important; 99 | border-bottom: 0 !important; 100 | font-size: var(--font-ui-small); 101 | padding: 4px; 102 | margin: 0; 103 | } 104 | .tags-overview .tags-table-container tbody tr.sub-tags-row > td { 105 | border-top: 0 !important; 106 | } 107 | .tags-overview .align-left { 108 | text-align: left; 109 | } 110 | .tags-overview .align-center { 111 | text-align: center; 112 | } 113 | .tags-overview .align-right { 114 | text-align: right; 115 | } 116 | .tags-overview .col-modified, .tags-overview .col-created { 117 | white-space: nowrap; 118 | } 119 | .tags-overview .col-size { 120 | width: 80px; 121 | } 122 | .tags-overview .tags-table-container tbody tr > td:first-child { 123 | color: var(--link-color); 124 | } 125 | .tags-overview .has-sub-tags > .tag-content { 126 | margin-left: -12px; 127 | padding-left: 12px; 128 | border-left: 1px solid var(--color-base-50); 129 | transition: transform 1000ms ease-in-out; 130 | } 131 | .tags-overview .tags-level-0.has-sub-tags > .tag-content { 132 | border-color: var(--color-base-40); 133 | } 134 | .tags-overview .tags-table-container.has-sub-tags { 135 | margin-left: 10px; 136 | } 137 | .tags-overview .tags-table-container tbody tr.file-row:hover { 138 | background-color: var(--nav-item-background-hover); 139 | cursor: pointer; 140 | } 141 | .tags-overview .tags-table-container tbody tr.file-row:hover > :first-child { 142 | color: var(--link-color-hover); 143 | } 144 | .tags-overview .tags-filter-select { 145 | color: #222; 146 | margin-bottom: 10px; 147 | font-size: var(--font-ui-small); 148 | } 149 | .tags-overview .tags-filter-text { 150 | background-color: hsl(0, 0%, 100%); 151 | border-color: hsl(0, 0%, 80%); 152 | border-radius: 4px; 153 | width: 100%; 154 | padding: 20px 10px; 155 | color: hsl(0, 0%, 20%); 156 | } 157 | .tags-overview .tags-filter-text::placeholder { 158 | color: hsl(0, 0%, 50%); 159 | font-size: var(--font-ui-small); 160 | } 161 | .tags-overview .tags-filter-text.tags-filter-number { 162 | margin-top: 1px; 163 | } 164 | .tags-overview .tags-filter-text.tags-filter-number:hover { 165 | border-color: hsl(0, 0%, 80%); 166 | } 167 | .tags-overview .header-with-settings { 168 | display: flex; 169 | flex-direction: row; 170 | justify-content: space-between; 171 | } 172 | .tags-overview .header-with-settings .title { 173 | margin: 3px 0 0; 174 | color: var(--color-base-70); 175 | font-size: var(--font-ui-small); 176 | } 177 | .tags-overview .header-with-settings.slim { 178 | margin: 10px 3px 2px; 179 | } 180 | .tags-overview .header-with-settings:not(.slim) { 181 | border-bottom: 1px solid var(--color-base-40); 182 | margin-top: 40px; 183 | margin-bottom: 2px; 184 | } 185 | .tags-overview .header-with-settings:not(.slim) .title { 186 | margin: 3px 0 1px; 187 | } 188 | .tags-overview .settings-switch span { 189 | font-size: 10px; 190 | border: 1px solid var(--color-base-40); 191 | padding: 3px 6px 4px; 192 | bottom: 0px; 193 | position: relative; 194 | display: inline; 195 | } 196 | .tags-overview .settings-switch span.active { 197 | background-color: var(--color-base-40); 198 | } 199 | .tags-overview .settings-switch span.inactive { 200 | color: var(--color-base-60); 201 | cursor: pointer; 202 | } 203 | .tags-overview .settings-switch span.inactive:hover { 204 | background-color: var(--color-base-20); 205 | } 206 | .tags-overview .settings-switch span:first-child { 207 | border-top-left-radius: 5px; 208 | } 209 | .tags-overview .settings-switch span:last-child { 210 | border-top-right-radius: 5px; 211 | } 212 | .tags-overview .has-sub-tags > .nested-container { 213 | margin-left: -12px; 214 | padding-left: 12px; 215 | border-left: 1px solid var(--color-base-40); 216 | transition: transform 100ms ease-in-out; 217 | } 218 | .tags-overview .tags-level-0.has-sub-tags > .nested-container { 219 | border-color: var(--background-secondary-alt); 220 | } 221 | .tags-overview .has-sub-tags.is-collapsed > .nested-container { 222 | border-left: 1px solid #333; 223 | } 224 | .tags-overview .nested-tags-container { 225 | position: relative; 226 | margin-left: 5px; 227 | } 228 | .tags-overview .nested-tags-container.has-sub-tags { 229 | margin-left: 15px; 230 | } 231 | .tags-overview .custom-icon { 232 | color: var(--icon-color); 233 | opacity: var(--icon-opacity); 234 | } 235 | .tags-overview .collapse-icon { 236 | position: absolute; 237 | top: 4px; 238 | margin-left: -17px; 239 | width: 16px; 240 | } 241 | .tags-overview .collapse-icon svg { 242 | stroke-width: 4px; 243 | width: 10px; 244 | height: 10px; 245 | transition: transform 100ms ease-in-out; 246 | } 247 | .tags-overview .is-collapsed > .collapse-icon svg { 248 | transform: rotate(-90deg); 249 | } 250 | .tags-overview .tags-info-container .custom-icon { 251 | display: flex; 252 | padding: var(--size-2-2) var(--size-2-3); 253 | } 254 | .tags-overview .custom-icon { 255 | -webkit-app-region: no-drag; 256 | background-color: transparent; 257 | align-items: center; 258 | justify-content: center; 259 | cursor: var(--cursor); 260 | border-radius: var(--clickable-icon-radius); 261 | transition: opacity 0.15s ease-in-out; 262 | height: auto; 263 | } 264 | .tags-overview .custom-icon:hover { 265 | box-shadow: none; 266 | opacity: var(--icon-opacity-hover); 267 | color: var(--icon-color-hover); 268 | background-color: var(--background-modifier-hover); 269 | } 270 | .tags-overview .custom-icon svg { 271 | height: var(--icon-size); 272 | width: var(--icon-size); 273 | stroke-width: var(--icon-stroke); 274 | } 275 | .tags-overview .custom-icon.is-active { 276 | opacity: var(--icon-opacity-hover); 277 | color: var(--icon-color-active); 278 | background-color: var(--background-modifier-active-hover); 279 | } 280 | .tags-overview .custom-icon.is-disabled, .tags-overview .custom-icon.is-disabled:hover { 281 | background-color: unset; 282 | color: var(--text-muted); 283 | opacity: 0.4; 284 | } 285 | .tags-overview .custom-icon.is-disabled:hover { 286 | background-color: unset; 287 | } 288 | 289 | .tags-overview-table-settings { 290 | padding: 0.75em 0; 291 | border-top: 1px solid var(--background-modifier-border); 292 | } 293 | 294 | .tags-overview-settings-table { 295 | width: 100%; 296 | margin-bottom: 5px; 297 | border: 0 !important; 298 | } 299 | .tags-overview-settings-table th, .tags-overview-settings-table td { 300 | border: 0 !important; 301 | } 302 | .tags-overview-settings-table th { 303 | text-align: left; 304 | padding: 8px 10px; 305 | font-size: var(--font-ui-smaller); 306 | } 307 | .tags-overview-settings-table .no-columns-added { 308 | padding: 15px; 309 | text-align: center; 310 | background-color: var(--background-secondary); 311 | } 312 | .tags-overview-settings-table .no-columns-added p { 313 | margin: 4px; 314 | font-size: 10px; 315 | } 316 | .tags-overview-settings-table thead { 317 | border-bottom: 1px solid var(--color-base-40) !important; 318 | } 319 | .tags-overview-settings-table tbody tr { 320 | background-color: var(--background-secondary); 321 | } 322 | .tags-overview-settings-table tbody tr:nth-child(odd) { 323 | background-color: var(--background-secondary-alt); 324 | } 325 | .tags-overview-settings-table tbody td { 326 | padding: 4px 8px; 327 | font-size: var(--font-ui-small); 328 | } 329 | .tags-overview-settings-table tfoot td { 330 | padding: 8px 10px; 331 | } 332 | .tags-overview-settings-table .front-matter-property-select { 333 | margin-left: 20px; 334 | } 335 | .tags-overview-settings-table .custom-icon { 336 | display: inline-block; 337 | margin: 2px 2px 0 2px; 338 | } 339 | .tags-overview-settings-table .custom-icon svg { 340 | width: 18px !important; 341 | height: 18px; 342 | } 343 | .tags-overview-settings-table .custom-icon:hover { 344 | color: var(--icon-color-hover); 345 | cursor: pointer; 346 | } 347 | 348 | .save-load-filters-icon { 349 | display: inline-block; 350 | padding: var(--size-2-2) var(--size-2-3); 351 | } 352 | 353 | .tag-view-popover { 354 | position: absolute; 355 | top: 28px; 356 | left: 0; 357 | background-color: var(--background-primary); 358 | padding: 0px 10px 15px 10px; 359 | border: 1px solid var(--background-modifier-border-focus); 360 | border-radius: 10px; 361 | z-index: 1000; 362 | } 363 | .tag-view-popover h4 { 364 | padding: 0 10px; 365 | margin-bottom: 4px; 366 | } 367 | .tag-view-popover ul { 368 | list-style: none; 369 | padding: 0; 370 | margin: 0 0 10px; 371 | } 372 | .tag-view-popover ul li { 373 | display: flex; 374 | justify-content: space-between; 375 | padding: 4px 10px; 376 | } 377 | .tag-view-popover ul li > span { 378 | flex-grow: 1; 379 | display: inline-block; 380 | padding-right: 20px; 381 | font-size: var(--font-smaller); 382 | cursor: pointer; 383 | } 384 | .tag-view-popover ul li > span:hover { 385 | color: var(--link-color-hover); 386 | } 387 | .tag-view-popover ul li .trash-icon { 388 | width: 16px; 389 | height: 16px; 390 | } 391 | .tag-view-popover ul li .trash-icon svg { 392 | width: 16px; 393 | height: 16px; 394 | } 395 | .tag-view-popover ul li:nth-child(even) { 396 | background-color: var(--background-secondary); 397 | } 398 | .tag-view-popover hr { 399 | margin: 15px 0; 400 | } 401 | .tag-view-popover .save-link { 402 | margin-left: 10px; 403 | } 404 | .tag-view-popover .save-link .save-icon { 405 | display: inline-block; 406 | width: 18px; 407 | height: 18px; 408 | vertical-align: top; 409 | margin-right: 8px; 410 | color: var(--link-color); 411 | } 412 | .tag-view-popover .save-link .save-icon svg { 413 | width: 18px; 414 | height: 18px; 415 | } 416 | .tag-view-popover .save-link:hover .save-icon { 417 | color: var(--link-color-hover); 418 | } 419 | .tag-view-popover .no-saved-filters { 420 | padding: 5px 35px; 421 | text-align: center; 422 | color: var(--color-base-70); 423 | font-size: var(--font-smaller); 424 | } 425 | -------------------------------------------------------------------------------- /styles.scss: -------------------------------------------------------------------------------- 1 | .tags-overview { 2 | input[type='text']:focus { 3 | box-shadow: initial; 4 | } 5 | 6 | .tags-container { 7 | margin-bottom: 30px; 8 | } 9 | 10 | // Tags info 11 | .tags-info-container { 12 | display: flex; 13 | justify-content: space-between; 14 | padding: 3px; 15 | } 16 | .count-label { 17 | font-size: var(--font-smallest); 18 | padding: 4px; 19 | } 20 | .tags-info-container .icons { 21 | display: flex; 22 | } 23 | 24 | 25 | 26 | // Compact list 27 | .display-type-compact .file-link { 28 | display: inline-block; 29 | background-color: var(--color-base-40); 30 | border-radius: 10px; 31 | font-size: var(--font-ui-smaller); 32 | padding: 4px 8px; 33 | color: var(--color-base-100); 34 | margin: 4px 4px 0 0; 35 | cursor: pointer; 36 | 37 | &:hover { 38 | background-color: var(--color-base-100); 39 | color: var(--color-base-40); 40 | } 41 | } 42 | 43 | // Tag titles 44 | .tag-title-row { 45 | margin: 10px 0 5px; 46 | 47 | .tags-level-0 > & { 48 | margin-top: 20px; 49 | } 50 | 51 | span { 52 | margin: 6px 0 0 8px; 53 | vertical-align: top; 54 | display: inline-block; 55 | font-size: 12px; 56 | color: #aaa; 57 | } 58 | } 59 | 60 | .tag-title { 61 | margin: 0; 62 | display: inline-block; 63 | } 64 | 65 | .tags-table-container .tag-title-row { 66 | margin: 8px 0 1px; 67 | display: flex; 68 | flex-direction: row; 69 | justify-content: space-between; 70 | } 71 | .tags-table-container .sub-tags-row .tag-title-row { 72 | margin-top: 2px; 73 | } 74 | .tags-table-container .tag-title-row span { 75 | margin: 10px 5px 0 0; 76 | vertical-align: top; 77 | display: inline-block; 78 | font-size: var(--font-ui-smaller); 79 | color: var(--color-base-70); 80 | white-space: nowrap; 81 | padding-left: 5px; 82 | } 83 | .tags-table-container .tag-title-row .tag-title { 84 | margin: 0; 85 | display: inline-block; 86 | } 87 | 88 | 89 | // Table list 90 | .tags-table-container { 91 | position: relative; 92 | 93 | &.tags-level-0 { 94 | margin-top: 20px; 95 | } 96 | 97 | table { 98 | width: 100%; 99 | border-spacing: 0; 100 | border-collapse: initial; 101 | border: 0 !important; 102 | } 103 | 104 | th { 105 | text-align: left; 106 | font-size: var(--font-ui-smaller); 107 | color: var(--color-base-70); 108 | 109 | &.align-center { 110 | text-align: center; 111 | } 112 | &.align-right { 113 | text-align: right; 114 | } 115 | } 116 | 117 | tbody td { 118 | border-top: 1px solid var(--color-base-40); 119 | border-left: 0 !important; 120 | border-right: 0 !important; 121 | border-bottom: 0 !important; 122 | font-size: var(--font-ui-small); 123 | padding: 4px; 124 | margin: 0; 125 | } 126 | 127 | tbody tr.sub-tags-row > td { 128 | border-top: 0 !important; 129 | } 130 | } 131 | 132 | .align-left { 133 | text-align: left; 134 | } 135 | .align-center { 136 | text-align: center; 137 | } 138 | .align-right { 139 | text-align: right; 140 | } 141 | 142 | .col-modified, .col-created { 143 | white-space: nowrap; 144 | } 145 | .col-size { 146 | width: 80px; 147 | } 148 | .tags-table-container tbody tr > td:first-child { 149 | color: var(--link-color); 150 | } 151 | 152 | .has-sub-tags > .tag-content { 153 | margin-left: -12px; 154 | padding-left: 12px; 155 | border-left: 1px solid var(--color-base-50); 156 | transition: transform 1000ms ease-in-out; 157 | } 158 | .tags-level-0.has-sub-tags > .tag-content { 159 | border-color: var(--color-base-40); 160 | } 161 | .tags-table-container.has-sub-tags { 162 | margin-left: 10px; 163 | } 164 | .tags-table-container tbody tr.file-row:hover { 165 | background-color: var(--nav-item-background-hover); 166 | cursor: pointer; 167 | } 168 | .tags-table-container tbody tr.file-row:hover > :first-child { 169 | color: var(--link-color-hover); 170 | } 171 | 172 | 173 | // Filter select 174 | .tags-filter-select { 175 | color: #222; 176 | margin-bottom: 10px; 177 | font-size: var(--font-ui-small); 178 | } 179 | 180 | .tags-filter-text { 181 | background-color: hsl(0, 0%, 100%); 182 | border-color: hsl(0, 0%, 80%); 183 | border-radius: 4px; 184 | width: 100%; 185 | padding: 20px 10px; 186 | color: hsl(0, 0%, 20%); 187 | &::placeholder { 188 | color: hsl(0, 0%, 50%); 189 | font-size: var(--font-ui-small); 190 | } 191 | 192 | &.tags-filter-number { 193 | margin-top: 1px; 194 | 195 | &:hover { 196 | border-color: hsl(0, 0%, 80%); 197 | } 198 | } 199 | } 200 | 201 | 202 | // Header settings component 203 | .header-with-settings { 204 | display: flex; 205 | flex-direction: row; 206 | justify-content: space-between; 207 | 208 | .title { 209 | margin: 3px 0 0; 210 | color: var(--color-base-70); 211 | font-size: var(--font-ui-small); 212 | } 213 | 214 | &.slim { 215 | margin: 10px 3px 2px; 216 | } 217 | 218 | &:not(.slim) { 219 | border-bottom: 1px solid var(--color-base-40); 220 | margin-top: 40px; 221 | margin-bottom: 2px; 222 | 223 | .title { 224 | margin: 3px 0 1px; 225 | } 226 | } 227 | } 228 | .settings-switch span { 229 | font-size: 10px; 230 | border: 1px solid var(--color-base-40); 231 | padding: 3px 6px 4px; 232 | bottom: 0px; 233 | position: relative; 234 | display: inline; 235 | 236 | &.active { 237 | background-color: var(--color-base-40); 238 | } 239 | 240 | &.inactive { 241 | color: var(--color-base-60); 242 | cursor: pointer; 243 | 244 | &:hover { 245 | background-color: var(--color-base-20); 246 | } 247 | } 248 | 249 | &:first-child { 250 | border-top-left-radius: 5px; 251 | } 252 | 253 | &:last-child { 254 | border-top-right-radius: 5px; 255 | } 256 | } 257 | 258 | .has-sub-tags > .nested-container { 259 | margin-left: -12px; 260 | padding-left: 12px; 261 | border-left: 1px solid var(--color-base-40); 262 | transition: transform 100ms ease-in-out; 263 | } 264 | .tags-level-0.has-sub-tags > .nested-container { 265 | border-color: var(--background-secondary-alt); 266 | } 267 | .has-sub-tags.is-collapsed > .nested-container { 268 | border-left: 1px solid #333; 269 | } 270 | .nested-tags-container { 271 | position: relative; 272 | margin-left: 5px; 273 | } 274 | .nested-tags-container.has-sub-tags{ 275 | margin-left: 15px; 276 | } 277 | 278 | 279 | // Icons 280 | .custom-icon { 281 | color: var(--icon-color); 282 | opacity: var(--icon-opacity); 283 | } 284 | .collapse-icon { 285 | position: absolute; 286 | top: 4px; 287 | margin-left: -17px; 288 | width: 16px; 289 | 290 | svg { 291 | stroke-width: 4px; 292 | width: 10px; 293 | height: 10px; 294 | transition: transform 100ms ease-in-out; 295 | } 296 | } 297 | .is-collapsed > .collapse-icon svg { 298 | transform: rotate(-90deg); 299 | } 300 | 301 | .tags-info-container .custom-icon { 302 | display: flex; 303 | padding: var(--size-2-2) var(--size-2-3); 304 | } 305 | 306 | .custom-icon { 307 | -webkit-app-region: no-drag; 308 | background-color: transparent; 309 | align-items: center; 310 | justify-content: center; 311 | cursor: var(--cursor); 312 | border-radius: var(--clickable-icon-radius); 313 | transition: opacity 0.15s ease-in-out; 314 | height: auto; 315 | 316 | &:hover { 317 | box-shadow: none; 318 | opacity: var(--icon-opacity-hover); 319 | color: var(--icon-color-hover); 320 | background-color: var(--background-modifier-hover); 321 | } 322 | 323 | svg { 324 | height: var(--icon-size); 325 | width: var(--icon-size); 326 | stroke-width: var(--icon-stroke); 327 | } 328 | 329 | &.is-active { 330 | opacity: var(--icon-opacity-hover); 331 | color: var(--icon-color-active); 332 | background-color: var(--background-modifier-active-hover); 333 | } 334 | 335 | &.is-disabled, &.is-disabled:hover { 336 | background-color: unset; 337 | color: var(--text-muted); 338 | opacity: 0.4; 339 | } 340 | 341 | &.is-disabled:hover { 342 | background-color: unset; 343 | } 344 | } 345 | } 346 | 347 | .tags-overview-table-settings { 348 | padding: 0.75em 0; 349 | border-top: 1px solid var(--background-modifier-border); 350 | } 351 | 352 | .tags-overview-settings-table { 353 | width: 100%; 354 | margin-bottom: 5px; 355 | border: 0 !important; 356 | 357 | th, td { 358 | border: 0 !important; 359 | } 360 | 361 | th { 362 | text-align: left; 363 | padding: 8px 10px; 364 | font-size: var(--font-ui-smaller); 365 | } 366 | 367 | .no-columns-added { 368 | padding: 15px; 369 | text-align: center; 370 | background-color: var(--background-secondary); 371 | 372 | p { 373 | margin: 4px; 374 | font-size: 10px; 375 | } 376 | } 377 | 378 | thead { 379 | border-bottom: 1px solid var(--color-base-40) !important; 380 | } 381 | 382 | tbody { 383 | tr { 384 | background-color: var(--background-secondary); 385 | 386 | &:nth-child(odd) { 387 | background-color: var(--background-secondary-alt); 388 | } 389 | } 390 | 391 | td { 392 | padding: 4px 8px; 393 | font-size: var(--font-ui-small); 394 | } 395 | } 396 | 397 | 398 | tfoot td { 399 | padding: 8px 10px; 400 | } 401 | 402 | .front-matter-property-select { 403 | margin-left: 20px; 404 | } 405 | 406 | .custom-icon { 407 | display: inline-block; 408 | margin: 2px 2px 0 2px; 409 | 410 | svg { 411 | width: 18px !important; 412 | height: 18px; 413 | } 414 | 415 | &:hover { 416 | color: var(--icon-color-hover); 417 | cursor: pointer; 418 | } 419 | } 420 | } 421 | 422 | .save-load-filters-icon { 423 | display: inline-block; 424 | padding: var(--size-2-2) var(--size-2-3); 425 | } 426 | 427 | .tag-view-popover { 428 | position: absolute; 429 | top: 28px; 430 | left: 0; 431 | background-color: var(--background-primary); 432 | padding: 0px 10px 15px 10px; 433 | border: 1px solid var(--background-modifier-border-focus); 434 | border-radius: 10px; 435 | z-index: 1000; 436 | 437 | h4 { 438 | padding: 0 10px; 439 | margin-bottom: 4px; 440 | } 441 | 442 | ul { 443 | list-style: none; 444 | padding: 0; 445 | margin: 0 0 10px; 446 | 447 | li { 448 | display: flex; 449 | justify-content: space-between; 450 | padding: 4px 10px; 451 | 452 | > span { 453 | flex-grow: 1; 454 | display: inline-block; 455 | padding-right: 20px; 456 | font-size: var(--font-smaller); 457 | cursor: pointer; 458 | 459 | &:hover { 460 | color: var(--link-color-hover) 461 | } 462 | } 463 | 464 | .trash-icon { 465 | width: 16px; 466 | height: 16px; 467 | 468 | svg { 469 | width: 16px; 470 | height: 16px; 471 | } 472 | } 473 | 474 | &:nth-child(even) { 475 | background-color: var(--background-secondary); 476 | } 477 | } 478 | } 479 | 480 | hr { 481 | margin: 15px 0; 482 | } 483 | 484 | .save-link { 485 | margin-left: 10px; 486 | 487 | .save-icon { 488 | display: inline-block; 489 | width: 18px; 490 | height: 18px; 491 | vertical-align: top; 492 | margin-right: 8px; 493 | color: var(--link-color); 494 | 495 | svg { 496 | width: 18px; 497 | height: 18px; 498 | } 499 | } 500 | 501 | &:hover .save-icon { 502 | color: var(--link-color-hover); 503 | } 504 | } 505 | 506 | .no-saved-filters { 507 | padding: 5px 35px; 508 | text-align: center; 509 | color: var(--color-base-70); 510 | font-size: var(--font-smaller); 511 | } 512 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "jsx": "react", 14 | "strictNullChecks": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts", 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.15.0", 3 | "0.1.1": "0.15.0", 4 | "0.1.2": "0.15.0", 5 | "0.1.3": "0.15.0", 6 | "0.1.4": "0.15.0", 7 | "0.1.5": "0.15.0", 8 | "0.1.6": "0.15.0", 9 | "0.1.7": "0.15.0", 10 | "0.1.8": "0.15.0", 11 | "0.1.9": "0.15.0", 12 | "0.1.10": "0.15.0", 13 | "0.1.11": "0.15.0", 14 | "0.1.12": "0.15.0", 15 | "1.0.0": "0.15.0", 16 | "1.0.1": "0.15.0", 17 | "1.0.2": "0.15.0", 18 | "1.0.3": "0.15.0", 19 | "1.0.4": "0.15.0" 20 | } 21 | --------------------------------------------------------------------------------