├── .config ├── .eslintignore ├── docs ├── .config ├── .prettierrc ├── public │ ├── favicon.ico │ ├── new-types.png │ ├── metadata-editor.png │ ├── open-graph-image.png │ ├── new-types-showcase.png │ ├── type-array-example.png │ ├── type-color-example.png │ ├── type-object-example.png │ ├── type-rating-example.png │ ├── type-select-example.png │ ├── type-slider-example.png │ ├── type-time-example.png │ ├── type-toggle-example.png │ ├── type-markdown-example.png │ ├── property-menu-showcase.png │ ├── property-settings-modal.png │ ├── type-datecustom-example.png │ ├── type-multiselect-example.png │ ├── type-numeric-example-after.png │ ├── metadata-editor-more-button.png │ ├── metadata-editor-property-menu.png │ ├── type-numeric-example-before.png │ ├── metadata-editor-resizable-label.gif │ ├── metadata-editor-collapsible-properties.gif │ ├── metadata-editor-more-button-show-hidden.gif │ ├── metadata-editor-more-button-sort-options.png │ ├── logo.svg │ └── bp-logo-circle.svg ├── app │ ├── components │ │ ├── common │ │ │ ├── ThemeSwitcher │ │ │ │ ├── index.tsx │ │ │ │ ├── switcher.tsx │ │ │ │ └── context.tsx │ │ │ ├── EditOnGithub │ │ │ │ └── index.tsx │ │ │ ├── InfoButton │ │ │ │ └── index.tsx │ │ │ ├── GithubIcon │ │ │ │ └── index.tsx │ │ │ ├── Article │ │ │ │ └── index.tsx │ │ │ └── TocSidebar │ │ │ │ └── index.tsx │ │ └── ui │ │ │ ├── skeleton.tsx │ │ │ ├── separator.tsx │ │ │ ├── input.tsx │ │ │ ├── popover.tsx │ │ │ ├── badge.tsx │ │ │ ├── alert.tsx │ │ │ ├── tooltip.tsx │ │ │ └── button.tsx │ ├── routes │ │ ├── getting-started │ │ │ ├── usage │ │ │ │ ├── article.mdx │ │ │ │ └── route.tsx │ │ │ ├── installation │ │ │ │ ├── route.tsx │ │ │ │ └── article.mdx │ │ │ └── introduction │ │ │ │ ├── route.tsx │ │ │ │ └── article.mdx │ │ └── features │ │ │ ├── bpjs │ │ │ ├── (method types) │ │ │ │ ├── import │ │ │ │ │ ├── dataContent.mdx │ │ │ │ │ └── type.mdx │ │ │ │ ├── markdown │ │ │ │ │ ├── type.mdx │ │ │ │ │ └── options.mdx │ │ │ │ ├── getProperty │ │ │ │ │ ├── type.mdx │ │ │ │ │ └── options.mdx │ │ │ │ ├── getMetadata │ │ │ │ │ ├── options.mdx │ │ │ │ │ └── type.mdx │ │ │ │ └── renderProperty │ │ │ │ │ ├── type.mdx │ │ │ │ │ └── options.mdx │ │ │ └── route.tsx │ │ │ ├── property-types │ │ │ ├── route.tsx │ │ │ └── article.mdx │ │ │ ├── metadata-editor │ │ │ ├── route.tsx │ │ │ └── article.mdx │ │ │ └── roadmap │ │ │ └── article.mdx │ ├── types.d.ts │ ├── hooks │ │ └── use-mobile.ts │ ├── routes.ts │ ├── root.tsx │ ├── entry.server.tsx │ ├── lib │ │ └── utils.ts │ └── index.css ├── README.md ├── react-router.config.ts ├── .gitignore ├── tsconfig.node.json ├── wrangler.jsonc ├── components.json ├── tsconfig.json ├── workers │ └── app.ts ├── tsconfig.cloudflare.json ├── LICENSE ├── vite.config.ts └── package.json ├── .npmrc ├── src ├── main.ts ├── CustomPropertyTypes │ ├── Color │ │ ├── registerListeners.ts │ │ ├── renderSettings.ts │ │ ├── index.ts │ │ └── renderWidget.ts │ ├── Time │ │ ├── registerListeners.ts │ │ ├── renderSettings.ts │ │ ├── index.ts │ │ └── renderWidget.ts │ ├── DateCustom │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ ├── renderSettings.ts │ │ └── renderWidget.ts │ ├── Markdown │ │ ├── registerListeners.ts │ │ ├── renderSettings.ts │ │ ├── index.ts │ │ └── renderWidget.ts │ ├── Numeric │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ └── renderSettings.ts │ ├── Rating │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ └── renderSettings.ts │ ├── Slider │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ ├── renderWidget.ts │ │ └── renderSettings.ts │ ├── Toggle │ │ ├── registerListeners.ts │ │ ├── renderSettings.ts │ │ ├── index.ts │ │ └── renderWidget.ts │ ├── MultiSelect │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ └── renderSettings.ts │ ├── Title │ │ ├── renderSettings.ts │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ └── renderWidget.ts │ ├── Created │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ ├── renderSettings.ts │ │ └── renderWidget.ts │ ├── index.ts │ ├── Array │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ └── renderSettings.ts │ ├── Object │ │ ├── registerListeners.ts │ │ ├── index.ts │ │ └── renderSettings.ts │ ├── Relation │ │ ├── index.ts │ │ └── renderSettings.ts │ ├── Select │ │ ├── index.ts │ │ ├── registerListeners.ts │ │ └── renderSettings.ts │ ├── types.ts │ └── schema.ts ├── i18next │ └── index.ts ├── bpjs │ ├── index.ts │ ├── createCodeBlockProcessor.ts │ └── createPostProcessor.ts ├── MetadataCache │ └── index.ts ├── classes │ ├── InputSuggest │ │ ├── IconSuggest │ │ │ └── index.ts │ │ ├── PropertyTypeSuggest │ │ │ └── index.ts │ │ ├── FileSuggest │ │ │ └── index.ts │ │ ├── FolderSuggest │ │ │ └── index.ts │ │ ├── TagSuggest │ │ │ └── index.ts │ │ ├── LinkSuggest │ │ │ └── index.ts │ │ ├── index.ts │ │ └── PropertySuggest │ │ │ └── index.ts │ ├── ConfirmationModal │ │ └── index.ts │ └── ColorTextComponent │ │ └── index.ts ├── MetadataTypeManager │ └── patchMetadataTypeManager.ts ├── lib │ ├── constants │ │ └── index.ts │ └── types │ │ ├── index.d.ts │ │ └── oneDotNine.d.ts ├── MetadataEditor │ ├── index.ts │ ├── propertyEditorMenu │ │ ├── delete.ts │ │ ├── icon.ts │ │ └── rename.ts │ ├── patchMenu.ts │ └── patchMetadataEditor │ │ └── patchMetadataEditorProperty.ts └── Plugin │ └── settings.ts ├── demo-assets ├── properties.png └── property-menu.png ├── .github ├── ISSUE_TEMPLATE │ ├── question---discussion.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── release.yml ├── versions.json ├── tsconfig.node.json ├── manifest.json ├── .gitignore ├── getChangelogEntry.mjs ├── version-bump.mjs ├── .eslintrc ├── tsconfig.json ├── tsconfig.app.json ├── LICENSE ├── package.json ├── README.md └── vite.config.ts /.config: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.config: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | { "plugins": ["prettier-plugin-tailwindcss"] } 2 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { BetterProperties } from "./Plugin/plugin"; 2 | 3 | export default BetterProperties; 4 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/new-types.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/new-types.png -------------------------------------------------------------------------------- /demo-assets/properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/demo-assets/properties.png -------------------------------------------------------------------------------- /demo-assets/property-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/demo-assets/property-menu.png -------------------------------------------------------------------------------- /docs/public/metadata-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/metadata-editor.png -------------------------------------------------------------------------------- /docs/public/open-graph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/open-graph-image.png -------------------------------------------------------------------------------- /docs/public/new-types-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/new-types-showcase.png -------------------------------------------------------------------------------- /docs/public/type-array-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-array-example.png -------------------------------------------------------------------------------- /docs/public/type-color-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-color-example.png -------------------------------------------------------------------------------- /docs/public/type-object-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-object-example.png -------------------------------------------------------------------------------- /docs/public/type-rating-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-rating-example.png -------------------------------------------------------------------------------- /docs/public/type-select-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-select-example.png -------------------------------------------------------------------------------- /docs/public/type-slider-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-slider-example.png -------------------------------------------------------------------------------- /docs/public/type-time-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-time-example.png -------------------------------------------------------------------------------- /docs/public/type-toggle-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-toggle-example.png -------------------------------------------------------------------------------- /docs/public/type-markdown-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-markdown-example.png -------------------------------------------------------------------------------- /docs/public/property-menu-showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/property-menu-showcase.png -------------------------------------------------------------------------------- /docs/public/property-settings-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/property-settings-modal.png -------------------------------------------------------------------------------- /docs/public/type-datecustom-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-datecustom-example.png -------------------------------------------------------------------------------- /docs/public/type-multiselect-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-multiselect-example.png -------------------------------------------------------------------------------- /docs/public/type-numeric-example-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-numeric-example-after.png -------------------------------------------------------------------------------- /docs/app/components/common/ThemeSwitcher/index.tsx: -------------------------------------------------------------------------------- 1 | export { ThemeProvider, useTheme } from "./context"; 2 | export { ThemeSwitcher } from "./switcher"; 3 | -------------------------------------------------------------------------------- /docs/public/metadata-editor-more-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/metadata-editor-more-button.png -------------------------------------------------------------------------------- /docs/public/metadata-editor-property-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/metadata-editor-property-menu.png -------------------------------------------------------------------------------- /docs/public/type-numeric-example-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/type-numeric-example-before.png -------------------------------------------------------------------------------- /docs/public/metadata-editor-resizable-label.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/metadata-editor-resizable-label.gif -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Better Properties Docs 2 | 3 | This is the documentation website for my plugin [Better Properties](https://github.com/unxok/obsidian-better-properties) 4 | -------------------------------------------------------------------------------- /docs/public/metadata-editor-collapsible-properties.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/metadata-editor-collapsible-properties.gif -------------------------------------------------------------------------------- /docs/public/metadata-editor-more-button-show-hidden.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/metadata-editor-more-button-show-hidden.gif -------------------------------------------------------------------------------- /docs/public/metadata-editor-more-button-sort-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unxok/obsidian-better-properties/HEAD/docs/public/metadata-editor-more-button-sort-options.png -------------------------------------------------------------------------------- /docs/react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | ssr: true, 5 | future: { 6 | unstable_viteEnvironmentApi: true, 7 | }, 8 | } satisfies Config; 9 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | /node_modules/ 4 | *.tsbuildinfo 5 | 6 | # React Router 7 | /.react-router/ 8 | /build/ 9 | 10 | # Cloudflare 11 | .mf 12 | .wrangler 13 | .dev.vars* 14 | worker-configuration.d.ts -------------------------------------------------------------------------------- /docs/app/routes/getting-started/usage/article.mdx: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | Stop reading the docs and go use Obsidian! 4 | 5 | Come back here if you need more information on features, or if you just want see what features are out there. 6 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Color/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Time/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/DateCustom/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Markdown/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Numeric/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Rating/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Slider/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Toggle/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question---discussion.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question / Discussion 3 | about: Ask a question or start a discussion that isn't a feature request or bug 4 | title: '' 5 | labels: "\U0001F64B‍♀️type/discussion" 6 | assignees: unxok 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/MultiSelect/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | _plugin: BetterProperties 6 | ) => {}; 7 | -------------------------------------------------------------------------------- /docs/app/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Element, MDXContent } from "mdx/types"; 2 | import { TocItem } from "rehype-mdx-toc"; 3 | 4 | declare module "*.mdx" { 5 | export const toc: undefined | TocItem[]; 6 | export default function MDXContent(props: MDXProps): Element; 7 | } 8 | 9 | export {}; 10 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/import/dataContent.mdx: -------------------------------------------------------------------------------- 1 | #### data 2 | 3 | - For file extensions `.csv` and `.tsv` - Will be used as custom delimiter. The default for `.csv` is a comma (`,`). The default for `.tsv` is a tab character (`\t`) 4 | - For all other file extensions - Does nothing. 5 | -------------------------------------------------------------------------------- /docs/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["vite.config.ts"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "strict": true, 7 | "types": ["node"], 8 | "lib": ["ES2022"], 9 | "target": "ES2022", 10 | "module": "ES2022", 11 | "moduleResolution": "bundler" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "1.5.0", 3 | "0.1.0": "1.9.1", 4 | "0.1.1": "1.9.1", 5 | "0.1.2": "1.9.1", 6 | "0.1.3": "1.9.1", 7 | "0.1.4": "1.9.1", 8 | "0.1.5": "1.9.1", 9 | "0.1.6": "1.9.1", 10 | "0.1.7": "1.9.1", 11 | "0.1.8": "1.9.1", 12 | "0.1.9": "1.9.1", 13 | "0.1.10": "1.9.1", 14 | "0.1.11": "1.9.1", 15 | "1.0.0": "1.9.14" 16 | } -------------------------------------------------------------------------------- /docs/app/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/utils"; 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ); 11 | } 12 | 13 | export { Skeleton }; 14 | -------------------------------------------------------------------------------- /docs/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "obsidian-better-properties-docs", 4 | "compatibility_date": "2025-04-04", 5 | "main": "./workers/app.ts", 6 | // "vars": { 7 | // "GITHUB_PAT": "STORED IN dev.vars", 8 | // }, 9 | "observability": { 10 | "logs": { 11 | "enabled": true, 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "better-properties", 3 | "name": "Better Properties", 4 | "version": "1.0.0", 5 | "minAppVersion": "1.9.14", 6 | "description": "Adds new property types and other property-related upgrades and features.", 7 | "author": "Unxok", 8 | "authorUrl": "https://github.com/unxok", 9 | "fundingUrl": "https://buymeacoffee.com/unxok", 10 | "isDesktopOnly": false 11 | } -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Color/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | 5 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 6 | modal, 7 | }) => { 8 | const { tabContentEl } = modal; 9 | 10 | new Setting(tabContentEl) 11 | .setName(text("common.nothingToSeeHere")) 12 | .setDesc(text("common.typeHasNoSettings")); 13 | }; 14 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Time/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | 5 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 6 | modal, 7 | }) => { 8 | const { tabContentEl } = modal; 9 | 10 | new Setting(tabContentEl) 11 | .setName(text("common.nothingToSeeHere")) 12 | .setDesc(text("common.typeHasNoSettings")); 13 | }; 14 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Title/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | 5 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 6 | modal, 7 | }) => { 8 | const { tabContentEl } = modal; 9 | 10 | new Setting(tabContentEl) 11 | .setName(text("common.nothingToSeeHere")) 12 | .setDesc(text("common.typeHasNoSettings")); 13 | }; 14 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Toggle/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | 5 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 6 | modal, 7 | }) => { 8 | const { tabContentEl } = modal; 9 | 10 | new Setting(tabContentEl) 11 | .setName(text("common.nothingToSeeHere")) 12 | .setDesc(text("common.typeHasNoSettings")); 13 | }; 14 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Markdown/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | 5 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 6 | modal, 7 | }) => { 8 | const { tabContentEl } = modal; 9 | 10 | new Setting(tabContentEl) 11 | .setName(text("common.nothingToSeeHere")) 12 | .setDesc(text("common.typeHasNoSettings")); 13 | }; 14 | -------------------------------------------------------------------------------- /.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 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | # all .env files 25 | .env 26 | .env.* -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Title/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | import { refreshPropertyEditor } from "~/MetadataEditor"; 4 | import { TITLE } from "~/lib/constants"; 5 | 6 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 7 | plugin: BetterProperties 8 | ) => { 9 | plugin.registerEvent( 10 | plugin.app.vault.on("rename", () => { 11 | refreshPropertyEditor(plugin, TITLE); 12 | }) 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Created/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | import { refreshPropertyEditor } from "~/MetadataEditor"; 4 | import { TITLE } from "~/lib/constants"; 5 | 6 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 7 | plugin: BetterProperties 8 | ) => { 9 | plugin.registerEvent( 10 | plugin.app.vault.on("rename", () => { 11 | refreshPropertyEditor(plugin, TITLE); 12 | }) 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /docs/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/app.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "~/components", 16 | "utils": "~/lib/utils", 17 | "ui": "~/components/ui", 18 | "lib": "~/lib", 19 | "hooks": "~/hooks" 20 | }, 21 | "registries": {} 22 | } 23 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.node.json" }, 5 | { "path": "./tsconfig.cloudflare.json" } 6 | ], 7 | "compilerOptions": { 8 | "checkJs": true, 9 | "verbatimModuleSyntax": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "~/*": ["./app/*"] 16 | }, 17 | "noUnusedLocals": true, 18 | "resolveJsonModule": true, 19 | "noImplicitAny": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/i18next/index.ts: -------------------------------------------------------------------------------- 1 | import { NestedPaths } from "~/lib/utils"; 2 | import en from "./languages/en.json"; 3 | 4 | const ns = "better-properties"; 5 | 6 | // add language packs below 7 | 8 | window.i18next.addResourceBundle("en", ns, en); // English 9 | 10 | ////////////////// 11 | 12 | const fixedT = window.i18next.getFixedT(null, ns); 13 | 14 | type EN = typeof en; 15 | export const text = >( 16 | key: T, 17 | variables?: Record 18 | ): string => { 19 | return fixedT(key, variables); 20 | }; 21 | -------------------------------------------------------------------------------- /src/bpjs/index.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { createInlineCodePlugin } from "./createViewPlugin"; 3 | import { createCodeBlockProcessor } from "./createCodeBlockProcessor"; 4 | import { createPostProcessor } from "./createPostProcessor"; 5 | 6 | export const registerBpJsCodeProcessors = (plugin: BetterProperties) => { 7 | plugin.registerMarkdownPostProcessor(createPostProcessor(plugin)); 8 | plugin.registerMarkdownCodeBlockProcessor( 9 | ...createCodeBlockProcessor(plugin) 10 | ); 11 | plugin.registerEditorExtension([createInlineCodePlugin(plugin)]); 12 | }; 13 | -------------------------------------------------------------------------------- /docs/app/components/common/EditOnGithub/index.tsx: -------------------------------------------------------------------------------- 1 | import { ExternalLink } from "lucide-react"; 2 | import { cn } from "~/lib/utils"; 3 | 4 | export const EditOnGithub = ({ 5 | path, 6 | className, 7 | }: { 8 | path: string; 9 | className?: string; 10 | }) => ( 11 | 18 | Edit this page on Github 19 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /docs/workers/app.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "react-router"; 2 | 3 | declare module "react-router" { 4 | export interface AppLoadContext { 5 | cloudflare: { 6 | env: Env; 7 | ctx: ExecutionContext; 8 | }; 9 | } 10 | } 11 | 12 | const requestHandler = createRequestHandler( 13 | () => import("virtual:react-router/server-build"), 14 | import.meta.env.MODE 15 | ); 16 | 17 | export default { 18 | async fetch(request, env, ctx) { 19 | return requestHandler(request, { 20 | cloudflare: { env, ctx }, 21 | }); 22 | }, 23 | } satisfies ExportedHandler; 24 | -------------------------------------------------------------------------------- /getChangelogEntry.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | 3 | const version = JSON.parse(readFileSync("package.json", "utf8")).version; 4 | const escaped = version.replaceAll(".", "\\."); 5 | const changelogContent = readFileSync("changelog.md", "utf8"); 6 | 7 | const regex = new RegExp( 8 | "^##\\s*" + escaped + "\\b[\\r\\n]+([\\s\\S]*?)(?=^##\\s|(?![\\s\\S]))", 9 | "gms" 10 | ); 11 | const entry = regex.exec(changelogContent)?.[1]; 12 | if (!entry) { 13 | throw new Error( 14 | `No changelog entry found for version "${version}" in changelog.md` 15 | ); 16 | } 17 | console.log("## Changelog\n\n" + entry); 18 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomTypeKey, 3 | CustomPropertyType, 4 | RenderCustomTypeSettings, 5 | RenderCustomTypeWidget, 6 | PropertySettings, 7 | } from "./types"; 8 | import { getDefaultPropertySettings, propertySettingsSchema } from "./schema"; 9 | import { getPropertySettings, getPropertyTypeSettings } from "./utils"; 10 | 11 | export { 12 | propertySettingsSchema, 13 | getDefaultPropertySettings, 14 | getPropertySettings, 15 | getPropertyTypeSettings, 16 | type PropertySettings, 17 | type CustomTypeKey, 18 | type CustomPropertyType, 19 | type RenderCustomTypeSettings, 20 | type RenderCustomTypeWidget, 21 | }; 22 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Array/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | plugin: BetterProperties 6 | ) => { 7 | // ensures type changes of sub-properties cause a rerender for the highest parent property 8 | plugin.registerEvent( 9 | plugin.app.metadataTypeManager.on("changed", (property) => { 10 | if (!property?.includes(".")) return; 11 | const parentKey = property.split(".")[0]?.toLowerCase(); 12 | if (!parentKey) return; 13 | plugin.app.metadataTypeManager.trigger("changed", parentKey); 14 | }) 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Object/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { CustomPropertyType } from "../types"; 3 | 4 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 5 | plugin: BetterProperties 6 | ) => { 7 | // ensures type changes of sub-properties cause a rerender for the highest parent property 8 | plugin.registerEvent( 9 | plugin.app.metadataTypeManager.on("changed", (property) => { 10 | if (!property?.includes(".")) return; 11 | const parentKey = property.split(".")[0]?.toLowerCase(); 12 | if (!parentKey) return; 13 | plugin.app.metadataTypeManager.trigger("changed", parentKey); 14 | }) 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /docs/app/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // const MOBILE_BREAKPOINT = 768 4 | const MOBILE_BREAKPOINT = 1024; 5 | 6 | export function useIsMobile() { 7 | const [isMobile, setIsMobile] = React.useState( 8 | undefined, 9 | ); 10 | 11 | React.useEffect(() => { 12 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 13 | const onChange = () => { 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 15 | }; 16 | mql.addEventListener("change", onChange); 17 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 18 | return () => mql.removeEventListener("change", onChange); 19 | }, []); 20 | 21 | return !!isMobile; 22 | } 23 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/markdown/type.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | import Options from "./options.mdx"; 3 | 4 | #### Arguments 5 | 6 | | Name | Type | Default | 7 | | ------- | -------------------------------------------------------------------- | ------- | 8 | | options | {"object"}} content={} /> | - | 9 | 10 | #### Returns 11 | 12 | `void` 13 | 14 | #### Examples 15 | 16 | ```js 17 | api.markdown({ text: "*some* **markdown** `here`" }); 18 | 19 | api.markdown({ 20 | text: "*some* **markdown** `here`", 21 | el: api.el.createDiv(), 22 | }); 23 | ``` 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: "✨type/feature request" 6 | assignees: unxok 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Color/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { CustomPropertyType, PropertyTypeSchema } from "../types"; 3 | import { registerListeners } from "./registerListeners"; 4 | import { renderSettings } from "./renderSettings"; 5 | import { renderWidget } from "./renderWidget"; 6 | import * as v from "valibot"; 7 | 8 | export const colorPropertyType: CustomPropertyType = { 9 | type: "color", 10 | name: () => text("customPropertyTypes.color.name"), 11 | icon: "lucide-paintbrush", 12 | validate: (v) => typeof v === "string", 13 | registerListeners, 14 | renderSettings, 15 | renderWidget, 16 | }; 17 | 18 | export const colorSettingsSchema = v.optional( 19 | v.object({}) 20 | ) satisfies PropertyTypeSchema; 21 | -------------------------------------------------------------------------------- /docs/app/routes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type RouteConfig, 3 | index, 4 | prefix, 5 | route, 6 | } from "@react-router/dev/routes"; 7 | 8 | export default [ 9 | index("routes/getting-started/introduction/route.tsx"), 10 | ...prefix("getting-started", [ 11 | route("installation", "routes/getting-started/installation/route.tsx"), 12 | route("usage", "routes/getting-started/usage/route.tsx"), 13 | ]), 14 | ...prefix("features", [ 15 | route("roadmap", "routes/features/roadmap/route.tsx"), 16 | route("bpjs", "routes/features/bpjs/route.tsx"), 17 | route("metadata-editor", "routes/features/metadata-editor/route.tsx"), 18 | route("property-types", "routes/features/property-types/route.tsx"), 19 | ]), 20 | ] satisfies RouteConfig; 21 | -------------------------------------------------------------------------------- /docs/tsconfig.cloudflare.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | ".react-router/types/**/*", 5 | "app/**/*", 6 | "app/**/.server/**/*", 7 | "app/**/.client/**/*", 8 | "workers/**/*", 9 | "worker-configuration.d.ts", 10 | "**/*.d.ts" 11 | ], 12 | "compilerOptions": { 13 | "composite": true, 14 | "strict": true, 15 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 16 | "types": ["vite/client"], 17 | "target": "ES2022", 18 | "module": "ES2022", 19 | "moduleResolution": "bundler", 20 | "jsx": "react-jsx", 21 | "baseUrl": ".", 22 | "rootDirs": [".", "./.react-router/types"], 23 | "paths": { 24 | "~/*": ["./app/*"] 25 | }, 26 | "esModuleInterop": true, 27 | "resolveJsonModule": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Markdown/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { CustomPropertyType, PropertyTypeSchema } from "../types"; 3 | import { registerListeners } from "./registerListeners"; 4 | import { renderSettings } from "./renderSettings"; 5 | import { renderWidget } from "./renderWidget"; 6 | import * as v from "valibot"; 7 | 8 | export const markdownPropertyType: CustomPropertyType = { 9 | type: "markdown", 10 | name: () => text("customPropertyTypes.markdown.name"), 11 | icon: "lucide-m-square", 12 | validate: (v) => typeof v === "string", 13 | registerListeners, 14 | renderSettings, 15 | renderWidget, 16 | }; 17 | 18 | export const markdownSettingsSchema = v.optional( 19 | v.object({}) 20 | ) satisfies PropertyTypeSchema; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ], 11 | "compilerOptions": { 12 | "baseUrl": ".", 13 | "noImplicitAny": true, 14 | "strict": true, 15 | "target": "ESNext", 16 | "module": "nodenext", 17 | "strictNullChecks": true, 18 | "paths": { 19 | "~/*": ["./src/*"], 20 | "obsidian-typings/implementations": [ 21 | "./node_modules/obsidian-typings/dist/implementations.d.ts", 22 | "./node_modules/obsidian-typings/dist/implementations.cjs" 23 | ] 24 | }, 25 | "types": ["obsidian-typings", "./src/libs/types"], 26 | "resolveJsonModule": true, 27 | "moduleResolution": "nodenext" 28 | // "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "baseUrl": "./", 25 | "paths": { 26 | "~/*": ["./src/*"] 27 | } 28 | }, 29 | "include": ["src"] 30 | } 31 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Title/index.ts: -------------------------------------------------------------------------------- 1 | import { TITLE } from "~/lib/constants"; 2 | import { CustomPropertyType, PropertyTypeSchema } from "../types"; 3 | import { registerListeners } from "./registerListeners"; 4 | import { renderSettings } from "./renderSettings"; 5 | import { renderWidget } from "./renderWidget"; 6 | import { text } from "~/i18next"; 7 | import * as v from "valibot"; 8 | 9 | export const titlePropertyType: CustomPropertyType = { 10 | type: "title", 11 | name: () => text("customPropertyTypes.title.name"), 12 | icon: "lucide-letter-text", 13 | validate: (v) => typeof v === "string", 14 | registerListeners, 15 | renderSettings, 16 | renderWidget, 17 | reservedKeys: [TITLE], 18 | }; 19 | 20 | export const titleSettingsSchema = v.optional( 21 | v.object({}) 22 | ) satisfies PropertyTypeSchema; 23 | -------------------------------------------------------------------------------- /docs/app/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | function Separator({ 7 | className, 8 | orientation = "horizontal", 9 | decorative = true, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 23 | ); 24 | } 25 | 26 | export { Separator }; 27 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Time/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "time" satisfies CustomTypeKey; 13 | 14 | export const timePropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.time.name"), 17 | icon: "lucide-clock", 18 | validate: (v) => typeof v === "string", 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const timeSettingsSchema = v.optional( 25 | v.object({}) 26 | ) satisfies PropertyTypeSchema; 27 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Toggle/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "toggle" satisfies CustomTypeKey; 13 | 14 | export const togglePropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.toggle.name"), 17 | icon: "lucide-toggle-left", 18 | validate: (v) => typeof v === "boolean", 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const toggleSettingsSchema = v.optional( 25 | v.object({}) 26 | ) satisfies PropertyTypeSchema; 27 | -------------------------------------------------------------------------------- /src/MetadataCache/index.ts: -------------------------------------------------------------------------------- 1 | import { around, dedupe } from "monkey-around"; 2 | import { MetadataCache } from "obsidian"; 3 | import { getPropertyTypeSettings } from "~/CustomPropertyTypes"; 4 | import { monkeyAroundKey } from "~/lib/constants"; 5 | import BetterProperties from "~/main"; 6 | 7 | export const patchMetadataCache = (plugin: BetterProperties) => { 8 | const removePatch = around(plugin.app.metadataCache, { 9 | getFrontmatterPropertyValuesForKey: (old) => 10 | dedupe(monkeyAroundKey, old, function (property) { 11 | // @ts-expect-error 12 | const that = this as MetadataCache; 13 | const { suggestions } = getPropertyTypeSettings({ 14 | plugin, 15 | property, 16 | type: "general", 17 | }); 18 | return suggestions ?? old.call(that, property); 19 | }), 20 | }); 21 | 22 | plugin.register(removePatch); 23 | }; 24 | -------------------------------------------------------------------------------- /src/classes/InputSuggest/IconSuggest/index.ts: -------------------------------------------------------------------------------- 1 | import { getIconIds, setIcon } from "obsidian"; 2 | import { InputSuggest, Suggestion } from ".."; 3 | 4 | export class IconSuggest extends InputSuggest { 5 | protected getSuggestions(query: string): string[] { 6 | const icons = getIconIds(); 7 | if (!query) return icons; 8 | const lower = query.toLowerCase(); 9 | return icons.filter((icon) => icon.toLowerCase().includes(lower)); 10 | } 11 | 12 | protected parseSuggestion(value: string): Suggestion { 13 | return { 14 | title: value, 15 | icon: " ", 16 | }; 17 | } 18 | 19 | renderSuggestion(value: string, el: HTMLElement): void { 20 | super.renderSuggestion.call(this, value, el); 21 | 22 | const iconEl = el.querySelector(".suggestion-flair"); 23 | if (!(iconEl instanceof HTMLElement)) return; 24 | setIcon(iconEl, value); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Object/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { CustomPropertyType, PropertyTypeSchema } from "../types"; 3 | import { registerListeners } from "./registerListeners"; 4 | import { renderSettings } from "./renderSettings"; 5 | import { renderWidget } from "./renderWidget"; 6 | import * as v from "valibot"; 7 | 8 | export const objectPropertyType: CustomPropertyType = { 9 | type: "object", 10 | name: () => text("customPropertyTypes.object.name"), 11 | icon: "lucide-braces", 12 | validate: (v) => 13 | v === null || 14 | v === undefined || 15 | (typeof v === "object" && !Array.isArray(v)), 16 | registerListeners, 17 | renderSettings, 18 | renderWidget, 19 | }; 20 | 21 | export const objectSettingsSchema = v.optional( 22 | v.object({ 23 | hideAddButton: v.optional(v.boolean()), 24 | }) 25 | ) satisfies PropertyTypeSchema; 26 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/route.tsx: -------------------------------------------------------------------------------- 1 | import { createMeta } from "~/lib/utils"; 2 | import type { Route } from "./+types/route"; 3 | import MdxArticle, { 4 | // @ts-expect-error TODO named imports from *.mdx not being recognized by TS 5 | toc, 6 | // @ts-expect-error 7 | filepath, 8 | } from "./article.mdx"; 9 | import { Article } from "~/components/common/Article"; 10 | 11 | export function meta({}: Route.MetaArgs) { 12 | return createMeta({ 13 | title: "bpjs", 14 | description: "Guide to using Better Properties JavaScript blocks.", 15 | }); 16 | } 17 | 18 | export default function Route({}: Route.ComponentProps) { 19 | return ( 20 |
28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /docs/app/routes/getting-started/usage/route.tsx: -------------------------------------------------------------------------------- 1 | import { createMeta } from "~/lib/utils"; 2 | import type { Route } from "./+types/route"; 3 | import MdxArticle, { 4 | // @ts-expect-error TODO named imports from *.mdx not being recognized by TS 5 | toc, 6 | // @ts-expect-error 7 | filepath, 8 | } from "./article.mdx"; 9 | import { Article } from "~/components/common/Article"; 10 | 11 | export function meta({}: Route.MetaArgs) { 12 | return createMeta({ 13 | title: "Usage", 14 | description: "Using Better Properties.", 15 | }); 16 | } 17 | 18 | export default function Route({}: Route.ComponentProps) { 19 | return ( 20 |
28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: "\U0001F41Etype/bug" 6 | assignees: unxok 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - Better Properties version: 28 | - OS: [e.g. iOS] 29 | - Obsidian app version: 30 | - Obsidian installer version: 31 | 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/getProperty/type.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | import Options from "./options.mdx"; 3 | 4 | #### Arguments 5 | 6 | | Name | Type | Default | 7 | | ------- | -------------------------------------------------------------------- | ------- | 8 | | options | {"object"}} content={} /> | - | 9 | 10 | #### Returns 11 | 12 | `unknown` 13 | 14 | #### Examples 15 | 16 | ```js 17 | api.getProperty({ property: "myProperty" }); 18 | 19 | api.getProperty({ property: "myProperty", path: "path/to/note.md" }); 20 | 21 | api.getProperty({ property: "myProperty", subscribe: false }); 22 | 23 | api.getProperty({ 24 | property: "myProperty", 25 | path: "path/to/note.md", 26 | subscribe: false, 27 | }); 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/markdown/options.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | 3 | #### options 4 | 5 | | Key | Type | Default | 6 | | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------- | 7 | | | `string` | - | 8 | | | [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement) | `api.el` | 9 | -------------------------------------------------------------------------------- /docs/app/routes/getting-started/installation/route.tsx: -------------------------------------------------------------------------------- 1 | import { createMeta } from "~/lib/utils"; 2 | import type { Route } from "./+types/route"; 3 | import MdxArticle, { 4 | // @ts-expect-error TODO named imports from *.mdx not being recognized by TS 5 | toc, 6 | // @ts-expect-error 7 | filepath, 8 | } from "./article.mdx"; 9 | import { Article } from "~/components/common/Article"; 10 | 11 | export function meta({}: Route.MetaArgs) { 12 | return createMeta({ 13 | title: "Installation", 14 | description: "How to install Better Properties.", 15 | }); 16 | } 17 | 18 | export default function Route({}: Route.ComponentProps) { 19 | return ( 20 |
28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/getMetadata/options.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | 3 | #### options 4 | 5 | | Key | Type | Default | 6 | | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- | --------------- | 7 | | | `string \| undefined` | `api.soucePath` | 8 | | | `boolean` | `true` | 9 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Relation/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "relation" satisfies CustomTypeKey; 13 | 14 | export const relationPropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.relation.name"), 17 | icon: "lucide-arrow-up-right", 18 | validate: (v) => Array.isArray(v), 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const relationSettingsSchema = v.optional( 25 | v.object({ 26 | relatedProperty: v.optional(v.string()), 27 | }) 28 | ) satisfies PropertyTypeSchema; 29 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/getMetadata/type.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | import Options from "./options.mdx"; 3 | 4 | #### Arguments 5 | 6 | | Name | Type | Default | 7 | | ------- | --------------------------------------------------------------------------------- | ----------- | 8 | | options | {"object \| undefined"}} content={} /> | `undefined` | 9 | 10 | #### Returns 11 | 12 | [CachedMetadata](https://docs.obsidian.md/Reference/TypeScript+API/CachedMetadata) 13 | 14 | #### Examples 15 | 16 | ```js 17 | api.getMetadata(); 18 | 19 | api.getMetadata({ path: "path/to/note.md" }); 20 | 21 | api.getMetadata({ subscribe: false }); 22 | 23 | api.getMetadata({ 24 | path: "path/to/note.md", 25 | subscribe: false, 26 | }); 27 | ``` 28 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Array/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "array" satisfies CustomTypeKey; 13 | 14 | export const arrayPropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.array.name"), 17 | icon: "lucide-brackets", 18 | validate: (v) => typeof v === "object" && Array.isArray(v), 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const arraySettingsSchema = v.optional( 25 | v.object({ 26 | hideAddButton: v.optional(v.boolean()), 27 | }) 28 | ) satisfies PropertyTypeSchema; 29 | -------------------------------------------------------------------------------- /docs/app/routes/features/property-types/route.tsx: -------------------------------------------------------------------------------- 1 | import MdxArticle, { 2 | // @ts-expect-error TODO named imports from *.mdx not being recognized by TS 3 | toc, 4 | // @ts-expect-error 5 | filepath, 6 | } from "./article.mdx"; 7 | import { Article } from "~/components/common/Article"; 8 | import type { Route } from "./+types/route"; 9 | import { createMeta } from "~/lib/utils"; 10 | 11 | export function meta({}: Route.MetaArgs) { 12 | return createMeta({ 13 | title: "Property types", 14 | description: "Descriptions and examples of newly added property types.", 15 | }); 16 | } 17 | 18 | export default function Route({ loaderData }: Route.ComponentProps) { 19 | return ( 20 |
28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /docs/app/routes/getting-started/introduction/route.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/route"; 2 | import MdxArticle, { 3 | // @ts-expect-error TODO named imports from *.mdx not being recognized by TS 4 | toc, 5 | // @ts-expect-error 6 | filepath, 7 | } from "./article.mdx"; 8 | import { Article } from "~/components/common/Article"; 9 | import { createMeta } from "~/lib/utils"; 10 | 11 | export function meta({}: Route.MetaArgs) { 12 | return createMeta({ 13 | title: "Introduction", 14 | description: "Getting started with Better Properties.", 15 | }); 16 | } 17 | 18 | export default function Route({}: Route.ComponentProps) { 19 | return ( 20 |
28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Created/index.ts: -------------------------------------------------------------------------------- 1 | import { CREATED } from "~/lib/constants"; 2 | import { CustomPropertyType, PropertyTypeSchema } from "../types"; 3 | import { registerListeners } from "./registerListeners"; 4 | import { renderSettings } from "./renderSettings"; 5 | import { renderWidget } from "./renderWidget"; 6 | import { moment } from "obsidian"; 7 | import { text } from "~/i18next"; 8 | import * as v from "valibot"; 9 | 10 | export const createdPropertyType: CustomPropertyType = { 11 | type: "created", 12 | name: () => text("customPropertyTypes.created.name"), 13 | icon: "lucide-clock-10", 14 | validate: (v) => moment(v?.toString()).isValid(), 15 | registerListeners, 16 | renderSettings, 17 | renderWidget, 18 | reservedKeys: [CREATED], 19 | }; 20 | 21 | export const createdSettingsSchema = v.optional( 22 | v.object({ 23 | format: v.optional(v.string()), 24 | }) 25 | ) satisfies PropertyTypeSchema; 26 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Rating/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "rating" satisfies CustomTypeKey; 13 | 14 | export const ratingPropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.rating.name"), 17 | icon: "lucide-star", 18 | validate: (v) => typeof v === "number", 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const ratingSettingsSchema = v.optional( 25 | v.object({ 26 | icon: v.optional(v.string()), 27 | count: v.optional(v.number()), 28 | }) 29 | ) satisfies PropertyTypeSchema; 30 | -------------------------------------------------------------------------------- /docs/app/routes/features/metadata-editor/route.tsx: -------------------------------------------------------------------------------- 1 | import MdxArticle, { 2 | // @ts-expect-error TODO named imports from *.mdx not being recognized by TS 3 | toc, 4 | // @ts-expect-error 5 | filepath, 6 | } from "./article.mdx"; 7 | import { Article } from "~/components/common/Article"; 8 | import type { Route } from "./+types/route"; 9 | import { createMeta } from "~/lib/utils"; 10 | 11 | export function meta({}: Route.MetaArgs) { 12 | return createMeta({ 13 | title: "Metadata Editor", 14 | description: "Features relating to the Metadata Editor.", 15 | }); 16 | } 17 | 18 | export default function Route({ loaderData }: Route.ComponentProps) { 19 | return ( 20 |
28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Numeric/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "numeric" satisfies CustomTypeKey; 13 | 14 | export const numericPropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.numeric.name"), 17 | icon: "lucide-circle-percent", 18 | validate: (v) => typeof v === "number" || typeof v === "string", 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const numericSettingsSchema = v.optional( 25 | v.object({ 26 | decimalPlaces: v.optional(v.number()), 27 | }) 28 | ) satisfies PropertyTypeSchema; 29 | -------------------------------------------------------------------------------- /src/MetadataTypeManager/patchMetadataTypeManager.ts: -------------------------------------------------------------------------------- 1 | import { around, dedupe } from "monkey-around"; 2 | import { getTrueProperty } from "~/CustomPropertyTypes/utils"; 3 | import { monkeyAroundKey } from "~/lib/constants"; 4 | import BetterProperties from "~/main"; 5 | 6 | export const patchMetadataTypeManager = (plugin: BetterProperties): void => { 7 | const removePatch = around(plugin.app.metadataTypeManager, { 8 | getAssignedWidget(old) { 9 | return dedupe(monkeyAroundKey, old, function (property) { 10 | // @ts-expect-error 11 | const that: MetadataTypeManager = this; 12 | return old.call(that, getTrueProperty(property)); 13 | }); 14 | }, 15 | setType(old) { 16 | return dedupe(monkeyAroundKey, old, function (property, type) { 17 | // @ts-expect-error 18 | const that: MetadataTypeManager = this; 19 | return old.call(that, getTrueProperty(property), type); 20 | }); 21 | }, 22 | }); 23 | 24 | plugin.register(removePatch); 25 | }; 26 | -------------------------------------------------------------------------------- /docs/app/routes/getting-started/introduction/article.mdx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; 2 | import { TriangleAlert } from "lucide-react"; 3 | 4 | ## Introduction 5 | 6 | Better Properties is an [Obsidian](https://obsidian.md) plugin which adds numerous features related to [Properties](https://help.obsidian.md/properties). 7 | 8 |
9 | 10 | 11 | 12 | Obsidian internal API usage 13 | 14 | This plugin makes heavy use of the internal Obsidian API, so there may be 15 | changes that break this plugin after a new Obsidian update is released. 16 | Please [open an 17 | issue](https://github.com/unxok/obsidian-better-properties/issues) and/or be 18 | patient in the event that this happens. 19 | 20 | 21 | 22 | ### Showcase 23 | 24 | ![property menu](/property-menu-showcase.png) 25 | ![new property types](/new-types-showcase.png) 26 | -------------------------------------------------------------------------------- /docs/app/components/common/InfoButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from "lucide-react"; 2 | import type { ReactNode } from "react"; 3 | import { Button } from "~/components/ui/button"; 4 | import { 5 | Popover, 6 | PopoverContent, 7 | PopoverTrigger, 8 | } from "~/components/ui/popover"; 9 | 10 | export const InfoButton = ({ 11 | label, 12 | content, 13 | }: { 14 | label: ReactNode; 15 | content: ReactNode; 16 | }) => { 17 | return ( 18 |
19 | {label} 20 | 21 | 22 | 25 | 26 | 27 | {content} 28 | 29 | 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Slider/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "slider" satisfies CustomTypeKey; 13 | 14 | export const sliderPropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.slider.name"), 17 | icon: "lucide-git-commit", 18 | validate: (v) => typeof v === "number", 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const sliderSettingsSchema = v.optional( 25 | v.object({ 26 | min: v.optional(v.number()), 27 | max: v.optional(v.number()), 28 | step: v.optional(v.number()), 29 | hideLimits: v.optional(v.boolean()), 30 | }) 31 | ) satisfies PropertyTypeSchema; 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "22.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Generate release notes from changelog 26 | id: notes 27 | run: | 28 | node ./getChangelogEntry.mjs > RELEASE_NOTES.md 29 | echo "file=RELEASE_NOTES.md" >> "$GITHUB_OUTPUT" 30 | 31 | - name: Create release 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: | 35 | tag="${GITHUB_REF#refs/tags/}" 36 | 37 | gh release create "$tag" \ 38 | --title="$tag" \ 39 | --notes-file "${{ steps.notes.outputs.file }}" \ 40 | main.js manifest.json styles.css 41 | -------------------------------------------------------------------------------- /docs/app/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "~/lib/utils"; 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ); 19 | } 20 | 21 | export { Input }; 22 | -------------------------------------------------------------------------------- /src/bpjs/createCodeBlockProcessor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MarkdownPostProcessorContext, 3 | MarkdownRenderChild, 4 | Component, 5 | Plugin, 6 | } from "obsidian"; 7 | import BetterProperties from "~/main"; 8 | import { BpJsApi } from "./api"; 9 | 10 | export const createCodeBlockProcessor = ( 11 | plugin: BetterProperties 12 | ): Parameters => { 13 | const cb = ( 14 | source: string, 15 | el: HTMLElement, 16 | ctx: MarkdownPostProcessorContext 17 | ) => { 18 | el.classList.add("better-properties-bpjs-codeblock"); 19 | const mdrc = new MarkdownRenderChild(el); 20 | ctx.addChild(mdrc); 21 | const component = new Component(); 22 | mdrc.addChild(component); 23 | plugin.addChild(component); 24 | const api = new BpJsApi(plugin, el, ctx.sourcePath, component, source); 25 | api.run(source); 26 | // api.monitorSubsribedPaths(); 27 | }; 28 | 29 | const BPJS = "bpjs"; 30 | 31 | window.CodeMirror.defineMode(BPJS, (config) => 32 | window.CodeMirror.getMode(config, "javascript") 33 | ); 34 | return [BPJS, cb]; 35 | }; 36 | -------------------------------------------------------------------------------- /src/classes/InputSuggest/PropertyTypeSuggest/index.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | import { InputSuggest, Suggestion } from ".."; 3 | 4 | type Value = { 5 | type: string; 6 | name: string; 7 | }; 8 | export class PropertyTypeSuggest extends InputSuggest { 9 | constructor(app: App, cmp: HTMLDivElement | HTMLInputElement) { 10 | super(app, cmp); 11 | } 12 | 13 | protected getSuggestions(query: string): Value[] { 14 | const { metadataTypeManager } = this.app; 15 | const arr = Object.values(metadataTypeManager.registeredTypeWidgets).map( 16 | (w) => ({ 17 | type: w.type, 18 | name: w.name(), 19 | }) 20 | ); 21 | 22 | if (query === "") return arr; 23 | const lower = query.toLowerCase(); 24 | return arr.filter( 25 | ({ type, name }) => 26 | (type.toLowerCase().includes(lower) || 27 | name.toLowerCase().includes(lower)) && 28 | this.setFilterCallback({ type, name }) 29 | ); 30 | } 31 | 32 | protected parseSuggestion({ name, type }: Value): Suggestion { 33 | return { 34 | title: name, 35 | note: type, 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Array/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 4 | import { text } from "~/i18next"; 5 | import { typeKey } from "."; 6 | 7 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 8 | modal, 9 | plugin, 10 | property, 11 | }) => { 12 | const { tabContentEl } = modal; 13 | 14 | const settings = getPropertyTypeSettings({ 15 | plugin, 16 | property, 17 | type: typeKey, 18 | }); 19 | 20 | modal.onTabChange(() => { 21 | setPropertyTypeSettings({ 22 | plugin, 23 | property, 24 | type: typeKey, 25 | typeSettings: settings, 26 | }); 27 | }); 28 | 29 | new Setting(tabContentEl) 30 | .setName(text("customPropertyTypes.object.settings.hideAddButton.title")) 31 | .setDesc(text("customPropertyTypes.object.settings.hideAddButton.desc")) 32 | .addToggle((cmp) => { 33 | cmp.setValue(!!settings.hideAddButton).onChange((b) => { 34 | settings.hideAddButton = b; 35 | }); 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/DateCustom/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "datecustom" satisfies CustomTypeKey; 13 | 14 | export const dateCustomPropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.datecustom.name"), 17 | icon: "lucide-calendar-plus", 18 | validate: (_v) => true, // TODO do we need to validate here? 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const dateCustomSettingsSchema = v.optional( 25 | v.object({ 26 | type: v.optional(v.union([v.literal("date"), v.literal("datetime-local")])), 27 | format: v.optional(v.string()), 28 | placeholder: v.optional(v.string()), 29 | icon: v.optional(v.string()), 30 | }) 31 | ) satisfies PropertyTypeSchema; 32 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/getProperty/options.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | 3 | #### options 4 | 5 | | Key | Type | Default | 6 | | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- | --------------- | 7 | | | `string` | - | 8 | | | `string \| undefined` | `api.soucePath` | 9 | | | `boolean` | `true` | 10 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Object/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 4 | import { typeKey } from "./renderWidget"; 5 | import { text } from "~/i18next"; 6 | 7 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 8 | modal, 9 | plugin, 10 | property, 11 | }) => { 12 | const { tabContentEl } = modal; 13 | 14 | const settings = getPropertyTypeSettings({ 15 | plugin, 16 | property, 17 | type: typeKey, 18 | }); 19 | 20 | modal.onTabChange(() => { 21 | setPropertyTypeSettings({ 22 | plugin, 23 | property, 24 | type: typeKey, 25 | typeSettings: settings, 26 | }); 27 | }); 28 | 29 | new Setting(tabContentEl) 30 | .setName(text("customPropertyTypes.object.settings.hideAddButton.title")) 31 | .setDesc(text("customPropertyTypes.object.settings.hideAddButton.desc")) 32 | .addToggle((cmp) => { 33 | cmp.setValue(!!settings.hideAddButton).onChange((b) => { 34 | settings.hideAddButton = b; 35 | }); 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /docs/app/routes/features/roadmap/article.mdx: -------------------------------------------------------------------------------- 1 | ## Roadmap, bugs, and more 2 | 3 | The following are all fetched automatically from GitHub. [Click here](https://github.com/unxok/obsidian-better-properties/issues) to view all issues. 4 | 5 | If you're interested in what has changed throughout different releases, check out the [Changelog](https://github.com/unxok/obsidian-better-properties/blob/main/changelog.md). 6 | 7 | ### Roadmap 8 | 9 | Features that are planned to be added or are currently being worked on. Upvotes and comments may influence the priority of a given feature being developed! 10 | 11 | 12 | 13 | ### Bugs 14 | 15 | Known bugs. Before opening a bug report, please check this list to see if it's already been reported. If there is an existing bug report for your problem, please leave an upvote and/or leave a comment. 16 | 17 | 18 | 19 | ### Feature requests 20 | 21 | Requested features that haven't been added to the roadmap and may or may not be developed. Please give an upvote or leave a comment on the issue if you would like to see it added! 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const customPropertyTypePrefix = "better-properties:"; 2 | export const monkeyAroundKey = "better-properties_monkey-around-key"; 3 | 4 | export const invalidFileNameRegex = /[*"\/\\<>:|?]/; 5 | 6 | export const TITLE = "title"; 7 | export const MODIFIED = "modified"; 8 | export const CREATED = "created"; 9 | 10 | export const selectColors = { 11 | red: "var(--better-properties-select-red)", 12 | orange: "var(--better-properties-select-orange)", 13 | yellow: "var(--better-properties-select-yellow)", 14 | green: "var(--better-properties-select-green)", 15 | blue: "var(--better-properties-select-blue)", 16 | cyan: "var(--better-properties-select-cyan)", 17 | purple: "var(--better-properties-select-purple)", 18 | pink: "var(--better-properties-select-pink)", 19 | gray: "var(--better-properties-select-gray)", 20 | transparent: "var(--better-properties-select-transparent)", 21 | }; 22 | 23 | export const selectBackgroundCssVar = "--better-properties-select-bg"; 24 | export const selectEmptyAttr = "data-better-properties-combobox-empty"; 25 | export const backgroundCssVar = "--better-properties-bg"; 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 unxok 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 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 unxok 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 | -------------------------------------------------------------------------------- /src/classes/InputSuggest/FileSuggest/index.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | import { compareFunc } from "~/lib/utils"; 3 | import { InputSuggest, Suggestion } from ".."; 4 | 5 | export class FileSuggest extends InputSuggest { 6 | constructor( 7 | app: App, 8 | component: HTMLDivElement | HTMLInputElement, 9 | public respectUserIgnored: boolean = true 10 | ) { 11 | super(app, component); 12 | } 13 | 14 | protected getSuggestions(query: string): TFile[] | Promise { 15 | const { app } = this; 16 | const allFiles = app.vault 17 | .getFiles() 18 | .toSorted((a, b) => compareFunc(a.path, b.path)); 19 | const notIgnored = this.respectUserIgnored 20 | ? allFiles.filter((f) => !this.app.metadataCache.isUserIgnored(f.path)) 21 | : allFiles; 22 | if (!query) return notIgnored.filter(this.setFilterCallback); 23 | const lower = query.toLowerCase(); 24 | return notIgnored.filter( 25 | (v) => v.path.toLowerCase().includes(lower) && this.setFilterCallback(v) 26 | ); 27 | } 28 | 29 | protected parseSuggestion({ path, name }: TFile): Suggestion { 30 | return { 31 | title: name, 32 | note: path, 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import { cloudflare } from "@cloudflare/vite-plugin"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import { defineConfig } from "vite"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | import mdx from "@mdx-js/rollup"; 7 | import path from "path"; 8 | import rehypeSlug from "rehype-slug"; 9 | import rehypeMdxToc from "rehype-mdx-toc"; 10 | import rehypeAutoLinkHeadings from "rehype-autolink-headings"; 11 | import recmaExportFilepath from "recma-export-filepath"; 12 | import remarkGfm from "remark-gfm"; 13 | import rehypeStarryNight from "rehype-starry-night"; 14 | 15 | export default defineConfig({ 16 | plugins: [ 17 | mdx({ 18 | remarkPlugins: [remarkGfm], 19 | rehypePlugins: [ 20 | rehypeSlug, 21 | rehypeMdxToc, 22 | rehypeAutoLinkHeadings, 23 | rehypeStarryNight, 24 | ], 25 | recmaPlugins: [recmaExportFilepath], 26 | }), 27 | cloudflare({ viteEnvironment: { name: "ssr" } }), 28 | tailwindcss(), 29 | reactRouter(), 30 | tsconfigPaths(), 31 | ], 32 | resolve: { 33 | alias: { 34 | "~": path.resolve(__dirname, "./app"), 35 | }, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Numeric/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | import { typeKey } from "."; 5 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 6 | 7 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 8 | modal, 9 | plugin, 10 | property, 11 | }) => { 12 | const { tabContentEl } = modal; 13 | 14 | const settings = getPropertyTypeSettings({ 15 | plugin, 16 | property: property, 17 | type: typeKey, 18 | }); 19 | 20 | modal.onTabChange(() => { 21 | setPropertyTypeSettings({ 22 | plugin, 23 | property: property, 24 | type: typeKey, 25 | typeSettings: { ...settings }, 26 | }); 27 | }); 28 | 29 | new Setting(tabContentEl) 30 | .setName(text("customPropertyTypes.numeric.settings.decimalPlaces.title")) 31 | .setDesc(text("customPropertyTypes.numeric.settings.decimalPlaces.desc")) 32 | .addText((cmp) => { 33 | cmp.inputEl.type = "number"; 34 | cmp.setValue(settings.decimalPlaces?.toString() ?? ""); 35 | cmp.onChange((v) => { 36 | settings.decimalPlaces = Number(v); 37 | }); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /src/classes/InputSuggest/FolderSuggest/index.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile, TFolder } from "obsidian"; 2 | import { InputSuggest, Suggestion } from ".."; 3 | import { compareFunc } from "~/lib/utils"; 4 | 5 | export class FolderSuggest extends InputSuggest { 6 | constructor( 7 | app: App, 8 | component: HTMLDivElement | HTMLInputElement, 9 | public options?: { 10 | showFileCountAux: boolean; 11 | } 12 | ) { 13 | super(app, component); 14 | } 15 | 16 | protected getSuggestions(query: string): TFolder[] | Promise { 17 | const { app } = this; 18 | const allFolders = app.vault 19 | .getAllFolders(true) 20 | .toSorted((a, b) => compareFunc(a.path, b.path)); 21 | if (!query) return allFolders.filter(this.setFilterCallback); 22 | const lower = query.toLowerCase(); 23 | return allFolders.filter( 24 | (v) => v.path.toLowerCase().includes(lower) && this.setFilterCallback(v) 25 | ); 26 | } 27 | 28 | protected parseSuggestion({ path, name, children }: TFolder): Suggestion { 29 | return { 30 | title: name, 31 | note: path, 32 | aux: this.options?.showFileCountAux 33 | ? children.filter((t) => t instanceof TFile).length.toString() 34 | : undefined, 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/renderProperty/type.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | import Options from "./options.mdx"; 3 | 4 | #### Arguments 5 | 6 | | Name | Type | Default | 7 | | ------- | -------------------------------------------------------------------- | ------- | 8 | | options | {"object"}} content={} /> | - | 9 | 10 | #### Returns 11 | 12 | [`PropertyWidgetComponentBase`](https://github.com/Fevol/obsidian-typings/blob/release/obsidian-catalyst/1.9.12/src/obsidian/internals/MetadataTypeManager/PropertyWidgetComponentBase.d.ts) 13 | 14 | #### Examples 15 | 16 | ```js 17 | api.renderProperty({ property: "myProperty" }); 18 | 19 | api.renderProperty({ property: "myProperty", hideKey: true }); 20 | 21 | api.renderProperty({ property: "myProperty", el: api.el.createDiv() }); 22 | 23 | api.renderProperty({ property: "myProperty", path: "path/to/note.md" }); 24 | 25 | api.renderProperty({ property: "myProperty", subscribe: false }); 26 | 27 | api.renderProperty({ 28 | property: "myProperty", 29 | path: "path/to/note.md", 30 | subscribe: false, 31 | }); 32 | ``` 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-better-properties", 3 | "version": "1.0.0", 4 | "description": "Upgrades Obsidian properties with new types, features, and more!", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "vite build --watch --mode=development", 8 | "tsc": "tsc", 9 | "build": "vite build", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json", 11 | "release": "node getChangelogEntry.mjs && git tag -a %npm_config_tag% -m '%npm_config_tag%' && git push origin %npm_config_tag%" 12 | }, 13 | "keywords": [], 14 | "author": "Unxok", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@codemirror/language": "6.10.3", 18 | "@types/luxon": "3.4.2", 19 | "@types/node": "22.7.4", 20 | "@typescript-eslint/eslint-plugin": "5.29.0", 21 | "@typescript-eslint/parser": "5.29.0", 22 | "builtin-modules": "3.3.0", 23 | "esbuild": "0.25.5", 24 | "luxon": "3.5.0", 25 | "obsidian": "latest", 26 | "obsidian-typings": "obsidian-public-latest", 27 | "tslib": "2.7.0", 28 | "typescript": "5.8.3", 29 | "vite": "6.3.6", 30 | "vite-bundle-analyzer": "1.2.1" 31 | }, 32 | "dependencies": { 33 | "math-expression-evaluator": "2.0.7", 34 | "monkey-around": "3.0.0", 35 | "valibot": "1.1.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/bpjs/createPostProcessor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MarkdownPostProcessor, 3 | MarkdownRenderChild, 4 | Component, 5 | } from "obsidian"; 6 | import BetterProperties from "~/main"; 7 | import { BpJsApi } from "./api"; 8 | 9 | export const createPostProcessor = ( 10 | plugin: BetterProperties 11 | ): MarkdownPostProcessor => { 12 | const processor: MarkdownPostProcessor = (el, ctx) => { 13 | if (el.tagName.toLowerCase() !== "code") { 14 | el.findAll("code").forEach((codeEl) => processor(codeEl, ctx)); 15 | return; 16 | } 17 | 18 | if (!el.textContent.startsWith(plugin.codePrefix)) return; 19 | 20 | const file = plugin.app.vault.getFileByPath(ctx.sourcePath); 21 | if (!file) { 22 | throw new Error(`File not found at path "${ctx.sourcePath}"`); 23 | } 24 | 25 | const code = el.textContent.slice(plugin.codePrefix.length); 26 | const spanEl = createSpan({ cls: "better-properties-bpjs-code" }); 27 | el.replaceWith(spanEl); 28 | const mdrc = new MarkdownRenderChild(spanEl); 29 | ctx.addChild(mdrc); 30 | const component = new Component(); 31 | mdrc.addChild(component); 32 | plugin.addChild(component); 33 | const api = new BpJsApi(plugin, spanEl, ctx.sourcePath, component, code); 34 | api.run(code); 35 | }; 36 | return processor; 37 | }; 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better Properties 2 | 3 | ![GitHub Release](https://img.shields.io/github/v/release/unxok/obsidian-better-properties) 4 | ![GitHub Downloads (all assets, latest release)](https://img.shields.io/github/downloads/unxok/obsidian-better-properties/latest/total) 5 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/unxok/obsidian-better-properties/total?label=total%20downloads) 6 | ![GitHub issue custom search in repo](https://img.shields.io/github/issues-search/unxok/obsidian-better-properties?query=is%3Aissue%20state%3Aopen%20label%3A%F0%9F%90%9Etype%2Fbug%20no%3Amilestone&label=open%20bugs&color=red) ![GitHub issue custom search in repo](https://img.shields.io/github/issues-search/unxok/obsidian-better-properties?query=is%3Aissue%20state%3Aopen%20label%3A%22%E2%9C%A8type%2Ffeature%20request%22%20no%3Amilestone&label=feature%20requests&color=gold) 7 | 8 | 9 | 10 | What if Properties in Obsidian were... better? 11 | 12 | This [Obsidian](https://obsidian.md) plugin adds many different properties-related features, such as new property types and per-property settings. 13 | 14 | For documentation, check out the [Better Properties Docs](https://better-properties.unxok.com) website. 15 | 16 | ![property menu](/demo-assets/property-menu.png) 17 | 18 | ![properties](/demo-assets/properties.png) 19 | -------------------------------------------------------------------------------- /src/MetadataEditor/index.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { View } from "obsidian"; 3 | import { patchMenu } from "./patchMenu"; 4 | import { onFilePropertyMenu } from "./propertyEditorMenu"; 5 | 6 | export { patchMetadataEditor } from "./patchMetadataEditor"; 7 | 8 | export const customizePropertyEditorMenu = (plugin: BetterProperties) => { 9 | patchMenu(plugin); 10 | 11 | plugin.registerEvent( 12 | plugin.app.workspace.on( 13 | "better-properties:file-property-menu", 14 | (menu, property) => { 15 | onFilePropertyMenu(plugin, menu, property); 16 | } 17 | ) 18 | ); 19 | }; 20 | 21 | export const refreshPropertyEditor = ( 22 | plugin: BetterProperties, 23 | property: string 24 | ) => { 25 | const lower = property.toLowerCase(); 26 | const withoutDots = lower.split(".")[0]; 27 | plugin.app.metadataTypeManager.trigger("changed", lower); 28 | plugin.app.workspace.iterateAllLeaves((leaf) => { 29 | if (!leaf.view.hasOwnProperty("metadataEditor")) return; 30 | const view = leaf.view as View & { 31 | metadataEditor: { 32 | onMetadataTypeChange: (propName: string) => void; 33 | }; 34 | }; 35 | 36 | // This is to force dropdowns to re-render with updated options 37 | // the easiest way I found was to emulate a type change 38 | view.metadataEditor.onMetadataTypeChange(withoutDots); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /docs/app/components/common/GithubIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps, ReactNode } from "react"; 2 | 3 | export const GithubIcon = ({ 4 | className, 5 | ...props 6 | }: ComponentProps<"svg">): ReactNode => ( 7 | 15 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /src/classes/InputSuggest/TagSuggest/index.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | import { InputSuggest, Suggestion } from ".."; 3 | import { getAllTags, iterateFileMetadata } from "~/lib/utils"; 4 | 5 | type Value = { 6 | tag: string; 7 | count: number; 8 | }; 9 | export class TagSuggest extends InputSuggest { 10 | constructor(app: App, cmp: HTMLDivElement | HTMLInputElement) { 11 | super(app, cmp); 12 | } 13 | 14 | protected getSuggestions(query: string): Value[] { 15 | const { vault, metadataCache } = this.app; 16 | const record: Record = {}; 17 | iterateFileMetadata({ 18 | vault, 19 | metadataCache, 20 | callback: ({ metadata }) => { 21 | if (!metadata) return; 22 | const tags = getAllTags(metadata, false); 23 | tags?.forEach((t) => { 24 | t in record ? record[t]++ : (record[t] = 1); 25 | }); 26 | }, 27 | }); 28 | const allTags = Object.entries(record).map( 29 | ([tag, count]) => ({ tag, count }), 30 | [] as Value[] 31 | ); 32 | 33 | if (!query) return allTags.filter(this.setFilterCallback); 34 | const lower = query.toLowerCase(); 35 | return allTags.filter( 36 | (v) => v.tag.toLowerCase().startsWith(lower) && this.setFilterCallback(v) 37 | ); 38 | } 39 | 40 | protected parseSuggestion({ tag, count }: Value): Suggestion { 41 | return { 42 | title: tag, 43 | aux: count.toString(), 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Toggle/renderWidget.ts: -------------------------------------------------------------------------------- 1 | import { ToggleComponent } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { PropertyWidgetComponentNew } from "../utils"; 4 | import { PropertyRenderContext } from "obsidian-typings"; 5 | import BetterProperties from "~/main"; 6 | 7 | export const renderWidget: CustomPropertyType["renderWidget"] = ({ 8 | plugin, 9 | el, 10 | ctx, 11 | value, 12 | }) => { 13 | return new ToggleTypeComponent(plugin, el, value, ctx); 14 | }; 15 | 16 | class ToggleTypeComponent extends PropertyWidgetComponentNew< 17 | "toggle", 18 | boolean 19 | > { 20 | type = "toggle" as const; 21 | parseValue = (v: unknown) => !!v; 22 | 23 | toggle: ToggleComponent; 24 | 25 | constructor( 26 | plugin: BetterProperties, 27 | el: HTMLElement, 28 | value: unknown, 29 | ctx: PropertyRenderContext 30 | ) { 31 | super(plugin, el, value, ctx); 32 | 33 | const parsed = this.parseValue(value); 34 | this.toggle = new ToggleComponent(el).setValue(parsed).onChange((b) => { 35 | this.setValue(b); 36 | }); 37 | 38 | this.onFocus = () => { 39 | this.toggle.toggleEl.focus(); 40 | }; 41 | } 42 | 43 | getValue(): boolean { 44 | return !!this.toggle?.getValue(); 45 | } 46 | 47 | setValue(value: unknown): void { 48 | if (this.toggle.getValue() !== value) { 49 | this.toggle.setValue(this.parseValue(value)); 50 | } 51 | super.setValue(value); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/MetadataEditor/propertyEditorMenu/delete.ts: -------------------------------------------------------------------------------- 1 | import { ConfirmationModal } from "~/classes/ConfirmationModal"; 2 | import { text } from "~/i18next"; 3 | import { obsidianText } from "~/i18next/obsidian"; 4 | import { deleteProperty } from "~/lib/utils"; 5 | import BetterProperties from "~/main"; 6 | 7 | export const openDeleteModal = ({ 8 | plugin, 9 | property, 10 | }: { 11 | plugin: BetterProperties; 12 | property: string; 13 | }) => { 14 | const modal = new ConfirmationModal(plugin.app) 15 | .setTitle(text("metadataEditor.propertyMenu.delete.modalTitle")) 16 | .setContent( 17 | text("metadataEditor.propertyMenu.delete.modalTitle", { property }) 18 | ) 19 | .setFooterCheckbox((checkbox) => { 20 | checkbox 21 | .setValue(false) 22 | .setLabel(text("common.dontAskAgain")) 23 | .onChange(async (b) => { 24 | await plugin.updateSettings((s) => { 25 | s.confirmPropertyDelete = b; 26 | return s; 27 | }); 28 | }); 29 | }) 30 | .addFooterButton((btn) => 31 | btn 32 | .setWarning() 33 | .setButtonText(obsidianText("interface.delete-action-short-name")) 34 | .onClick(async () => { 35 | await deleteProperty({ 36 | plugin, 37 | property, 38 | }); 39 | modal.close(); 40 | }) 41 | ) 42 | .addFooterButton((btn) => 43 | btn.setButtonText(obsidianText("dialogue.button-cancel")).onClick(() => { 44 | modal.close(); 45 | }) 46 | ); 47 | modal.open(); 48 | return; 49 | }; 50 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Color/renderWidget.ts: -------------------------------------------------------------------------------- 1 | import { CustomPropertyType } from "../types"; 2 | import { PropertyWidgetComponentNew } from "../utils"; 3 | import { ColorTextComponent } from "~/classes/ColorTextComponent"; 4 | import BetterProperties from "~/main"; 5 | import { PropertyRenderContext } from "obsidian-typings"; 6 | 7 | export const renderWidget: CustomPropertyType["renderWidget"] = ({ 8 | plugin, 9 | el, 10 | ctx, 11 | value, 12 | }) => { 13 | return new ColorTypeComponent(plugin, el, value, ctx); 14 | }; 15 | 16 | class ColorTypeComponent extends PropertyWidgetComponentNew<"color", string> { 17 | type = "color" as const; 18 | parseValue = (v: unknown) => v?.toString() ?? ""; 19 | 20 | colorText: ColorTextComponent; 21 | 22 | constructor( 23 | plugin: BetterProperties, 24 | el: HTMLElement, 25 | value: unknown, 26 | ctx: PropertyRenderContext 27 | ) { 28 | super(plugin, el, value, ctx); 29 | 30 | const parsed = this.parseValue(value); 31 | this.colorText = new ColorTextComponent(el) 32 | .setValue(parsed) 33 | .onChange((v) => { 34 | this.setValue(v); 35 | }); 36 | 37 | this.onFocus = () => { 38 | this.colorText.colorInputEl.focus(); 39 | }; 40 | } 41 | 42 | getValue(): string { 43 | return this.colorText.getValue() ?? ""; 44 | } 45 | 46 | setValue(value: unknown): void { 47 | if (this.colorText.getValue() !== value) { 48 | this.colorText.setValue(this.parseValue(value)); 49 | } 50 | super.setValue(value); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/MetadataEditor/patchMenu.ts: -------------------------------------------------------------------------------- 1 | import { around, dedupe } from "monkey-around"; 2 | import { Menu } from "obsidian"; 3 | import { monkeyAroundKey } from "~/lib/constants"; 4 | import { BetterProperties } from "~/Plugin/plugin"; 5 | 6 | export const patchMenu = (plugin: BetterProperties) => { 7 | const removePatch = around(Menu.prototype, { 8 | showAtMouseEvent(old) { 9 | return dedupe(monkeyAroundKey, old, function (e) { 10 | // @ts-ignore Doesn't look like there's a way to get this typed correctly 11 | const that = this as Menu; 12 | const exit = () => { 13 | return old.call(that, e); 14 | }; 15 | const { currentTarget } = e; 16 | const isMetadataPropertyIcon = 17 | currentTarget instanceof HTMLElement && 18 | currentTarget.tagName.toLowerCase() === "span" && 19 | currentTarget.classList.contains("metadata-property-icon"); 20 | 21 | if (!isMetadataPropertyIcon) return exit(); 22 | 23 | const container = currentTarget.closest( 24 | "div.metadata-property[data-property-key]" 25 | )!; 26 | const property = container.getAttribute("data-property-key") ?? ""; 27 | const toReturn = exit(); 28 | // trigger after running old() because "Property type" item only exists once menu opens 29 | plugin.app.workspace.trigger( 30 | "better-properties:file-property-menu", 31 | that, 32 | property 33 | ); 34 | 35 | return toReturn; 36 | }); 37 | }, 38 | }); 39 | 40 | plugin.register(removePatch); 41 | }; 42 | -------------------------------------------------------------------------------- /docs/app/components/common/ThemeSwitcher/switcher.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react"; 2 | import { Button } from "~/components/ui/button"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuRadioGroup, 7 | DropdownMenuRadioItem, 8 | DropdownMenuTrigger, 9 | } from "~/components/ui/dropdown-menu"; 10 | import { useTheme } from "./context"; 11 | import type { ReactNode } from "react"; 12 | 13 | export const ThemeSwitcher = (): ReactNode => { 14 | const { theme, setTheme } = useTheme(); 15 | 16 | return ( 17 | 18 | 19 | 24 | 25 | 26 | setTheme(t as typeof theme)} 29 | > 30 | Light 31 | Dark 32 | System 33 | 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /docs/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { isRouteErrorResponse, Outlet } from "react-router"; 2 | 3 | import type { Route } from "./+types/root"; 4 | import "./app.css"; 5 | 6 | export const links: Route.LinksFunction = () => [ 7 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 8 | { 9 | rel: "preconnect", 10 | href: "https://fonts.gstatic.com", 11 | crossOrigin: "anonymous", 12 | }, 13 | { 14 | rel: "stylesheet", 15 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 16 | }, 17 | ]; 18 | 19 | export default function App() { 20 | return ; 21 | } 22 | 23 | export { Layout } from "./layout"; 24 | 25 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 26 | let message = "Oops!"; 27 | let details = "An unexpected error occurred."; 28 | let stack: string | undefined; 29 | 30 | if (isRouteErrorResponse(error)) { 31 | message = error.status === 404 ? "404" : "Error"; 32 | details = 33 | error.status === 404 34 | ? "The requested page could not be found." 35 | : error.statusText || details; 36 | } else if (import.meta.env.DEV && error && error instanceof Error) { 37 | details = error.message; 38 | stack = error.stack; 39 | } 40 | 41 | return ( 42 |
43 |

{message}

44 |

{details}

45 | {stack && ( 46 |
47 |           {stack}
48 |         
49 | )} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /docs/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { AppLoadContext, EntryContext } from "react-router"; 2 | import { ServerRouter } from "react-router"; 3 | import { isbot } from "isbot"; 4 | import { renderToReadableStream } from "react-dom/server"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | routerContext: EntryContext, 11 | _loadContext: AppLoadContext, 12 | ) { 13 | let shellRendered = false; 14 | const userAgent = request.headers.get("user-agent"); 15 | 16 | const body = await renderToReadableStream( 17 | , 18 | { 19 | onError(error: unknown) { 20 | responseStatusCode = 500; 21 | // Log streaming rendering errors from inside the shell. Don't log 22 | // errors encountered during initial shell rendering since they'll 23 | // reject and get logged in handleDocumentRequest. 24 | if (shellRendered) { 25 | console.error(error); 26 | } 27 | }, 28 | }, 29 | ); 30 | shellRendered = true; 31 | 32 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 33 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 34 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 35 | await body.allReady; 36 | } 37 | 38 | responseHeaders.set("Content-Type", "text/html"); 39 | return new Response(body, { 40 | headers: responseHeaders, 41 | status: responseStatusCode, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Rating/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | import { typeKey } from "."; 5 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 6 | import { IconSuggest } from "~/classes/InputSuggest/IconSuggest"; 7 | 8 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 9 | modal, 10 | plugin, 11 | property, 12 | }) => { 13 | const { tabContentEl } = modal; 14 | 15 | const settings = getPropertyTypeSettings({ 16 | plugin, 17 | property: property, 18 | type: typeKey, 19 | }); 20 | 21 | modal.onTabChange(() => { 22 | setPropertyTypeSettings({ 23 | plugin, 24 | property: property, 25 | type: typeKey, 26 | typeSettings: { ...settings }, 27 | }); 28 | }); 29 | 30 | new Setting(tabContentEl) 31 | .setName(text("customPropertyTypes.rating.settings.icon.title")) 32 | .setDesc(text("customPropertyTypes.rating.settings.icon.desc")) 33 | .addSearch((cmp) => { 34 | cmp.setValue(settings.icon ?? "").onChange((v) => { 35 | settings.icon = v || undefined; 36 | }); 37 | new IconSuggest(plugin.app, cmp.inputEl).onSelect((v) => { 38 | cmp.setValue(v); 39 | cmp.onChanged(); 40 | }); 41 | }); 42 | 43 | new Setting(tabContentEl) 44 | .setName(text("customPropertyTypes.rating.settings.count.title")) 45 | .setDesc(text("customPropertyTypes.rating.settings.count.desc")) 46 | .addText((cmp) => { 47 | cmp.setValue(settings.count?.toString() ?? "").onChange((v) => { 48 | const n = Number(v); 49 | settings.count = !v || Number.isNaN(n) ? undefined : n; 50 | }); 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /docs/app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import type { href } from "react-router"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export type Path = Parameters[0]; 10 | 11 | type Meta = 12 | | { title: string } 13 | | { name: string; content: string } 14 | | { property: string; content: string }; 15 | export const createMeta = (props: { 16 | title: string; 17 | description?: string; 18 | image?: string; 19 | }): Meta[] => { 20 | const title: Meta[] = [ 21 | { 22 | title: props.title + " | Better Properties", 23 | }, 24 | { 25 | property: "og:title", 26 | content: props.title + " | Better Properties", 27 | }, 28 | ]; 29 | 30 | const description: Meta[] = props.description 31 | ? [ 32 | { 33 | name: "description", 34 | content: props.description, 35 | }, 36 | { 37 | property: "og:description", 38 | content: props.description, 39 | }, 40 | ] 41 | : []; 42 | 43 | return [ 44 | ...title, 45 | ...description, 46 | { 47 | property: "og:image", 48 | content: 49 | props.image ?? 50 | "https://better-properties.unxok.com/open-graph-image.png", 51 | }, 52 | { 53 | property: "og:site_name", 54 | content: "Better Properties", 55 | }, 56 | { 57 | property: "og:image:width", 58 | content: "1200", 59 | }, 60 | { 61 | property: "og:image:width", 62 | content: "600", 63 | }, 64 | { 65 | property: "og:type", 66 | content: "object", 67 | }, 68 | ]; 69 | }; 70 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { UserConfig, defineConfig } from "vite"; 2 | import path from "path"; 3 | import builtins from "builtin-modules"; 4 | // import { analyzer } from "vite-bundle-analyzer"; 5 | 6 | export default defineConfig(async ({ mode }) => { 7 | const { resolve } = path; 8 | const prod = mode === "production"; 9 | 10 | return { 11 | plugins: [ 12 | // analyzer() 13 | ], 14 | resolve: { 15 | alias: { 16 | "~": path.resolve(__dirname, "./src"), 17 | }, 18 | }, 19 | build: { 20 | lib: { 21 | entry: resolve(__dirname, "src/main.ts"), 22 | cssFileName: "styles", 23 | name: "main", 24 | fileName: () => "main.js", 25 | formats: ["cjs"], 26 | }, 27 | minify: prod, 28 | sourcemap: prod ? false : "inline", 29 | cssCodeSplit: false, 30 | // cssCodeSplit: true, 31 | emptyOutDir: false, 32 | outDir: "", 33 | rollupOptions: { 34 | input: { 35 | main: resolve(__dirname, "src/main.ts"), 36 | }, 37 | output: { 38 | entryFileNames: "main.js", 39 | assetFileNames: "styles.css", 40 | // assetFileNames: "[name].css",/ 41 | }, 42 | external: [ 43 | "obsidian", 44 | "electron", 45 | "@codemirror/autocomplete", 46 | "@codemirror/collab", 47 | "@codemirror/commands", 48 | "@codemirror/language", 49 | "@codemirror/lint", 50 | "@codemirror/search", 51 | "@codemirror/state", 52 | "@codemirror/view", 53 | "@lezer/common", 54 | "@lezer/highlight", 55 | "@lezer/lr", 56 | ...builtins, 57 | ], 58 | onwarn(warning, warn) { 59 | // suppress eval warnings 60 | if (warning.code === "EVAL") return; 61 | warn(warning); 62 | }, 63 | }, 64 | }, 65 | } as UserConfig; 66 | }); 67 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Time/renderWidget.ts: -------------------------------------------------------------------------------- 1 | import { obsidianText } from "~/i18next/obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { PropertyWidgetComponentNew } from "../utils"; 4 | import { PropertyRenderContext } from "obsidian-typings"; 5 | import BetterProperties from "~/main"; 6 | 7 | //TODO add option for hours, hours + minutes, hours + minutes + seconds 8 | 9 | export const renderWidget: CustomPropertyType["renderWidget"] = ({ 10 | plugin, 11 | el, 12 | ctx, 13 | value, 14 | }) => { 15 | return new TimeTypeComponent(plugin, el, value, ctx); 16 | }; 17 | 18 | class TimeTypeComponent extends PropertyWidgetComponentNew<"time", string> { 19 | type = "time" as const; 20 | parseValue = (v: unknown) => v?.toString() ?? ""; 21 | 22 | inputEl: HTMLInputElement; 23 | 24 | constructor( 25 | plugin: BetterProperties, 26 | el: HTMLElement, 27 | value: unknown, 28 | ctx: PropertyRenderContext 29 | ) { 30 | super(plugin, el, value, ctx); 31 | 32 | // el.classList.add(); 33 | 34 | const parsed = this.parseValue(value); 35 | this.inputEl = el.createEl("input", { 36 | type: "time", 37 | cls: ["metadata-input-text", "mod-date"], 38 | attr: { 39 | placeholder: obsidianText("interface.empty-state.empty"), 40 | }, 41 | }); 42 | 43 | this.inputEl.value = parsed; 44 | this.inputEl.addEventListener("change", () => { 45 | this.setValue(this.inputEl.value); 46 | }); 47 | 48 | this.onFocus = () => { 49 | this.inputEl.focus(); 50 | }; 51 | } 52 | 53 | getValue(): string { 54 | return this.inputEl.value; 55 | } 56 | 57 | setValue(value: unknown): void { 58 | if (this.inputEl.value !== value) { 59 | this.inputEl.value = this.parseValue(value); 60 | } 61 | super.setValue(value); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/classes/ConfirmationModal/index.ts: -------------------------------------------------------------------------------- 1 | import { App, ButtonComponent, Modal } from "obsidian"; 2 | 3 | export class ConfirmationModal extends Modal { 4 | buttonContainerEl: HTMLElement; 5 | checkbox: Checkbox | undefined = undefined; 6 | constructor(app: App) { 7 | super(app); 8 | 9 | this.buttonContainerEl = this.modalEl.createDiv({ 10 | cls: "modal-button-container", 11 | }); 12 | } 13 | 14 | setFooterCheckbox(cb: (checkbox: Checkbox) => void): this { 15 | this.checkbox = new Checkbox(this.buttonContainerEl); 16 | cb(this.checkbox); 17 | return this; 18 | } 19 | 20 | addFooterButton(cb: (btn: ButtonComponent) => void): this { 21 | const btn = new ButtonComponent(this.buttonContainerEl); 22 | cb(btn); 23 | return this; 24 | } 25 | } 26 | 27 | class Checkbox { 28 | inputEl: HTMLInputElement; 29 | labelEl: HTMLLabelElement; 30 | labelTextNode: Node; 31 | onChangeCallback: (value: boolean) => void = () => {}; 32 | constructor(public containerEl: HTMLElement) { 33 | this.labelEl = containerEl.createEl("label", { cls: "mod-checkbox" }); 34 | this.inputEl = this.labelEl.createEl("input", { 35 | attr: { 36 | tabindex: "-1", 37 | type: "checkbox", 38 | }, 39 | }); 40 | this.labelTextNode = document.createTextNode(""); 41 | this.labelEl.appendChild(this.labelTextNode); 42 | this.inputEl.addEventListener("change", () => 43 | this.onChangeCallback(this.inputEl.checked) 44 | ); 45 | } 46 | 47 | setLabel(label: string): this { 48 | this.labelTextNode.textContent = label; 49 | return this; 50 | } 51 | 52 | setValue(checked: boolean): this { 53 | this.inputEl.checked = checked; 54 | return this; 55 | } 56 | 57 | onChange(cb: (value: boolean) => void): this { 58 | this.onChangeCallback = cb; 59 | return this; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /docs/app/index.css: -------------------------------------------------------------------------------- 1 | @layer base { 2 | button:not(:disabled), 3 | [role="button"]:not(:disabled) { 4 | cursor: pointer; 5 | } 6 | } 7 | 8 | .icon.icon-link { 9 | padding-inline-start: 0.25rem; 10 | visibility: hidden; 11 | 12 | h1:hover &, 13 | h2:hover &, 14 | h3:hover &, 15 | h4:hover &, 16 | h5:hover &, 17 | h6:hover & { 18 | visibility: visible; 19 | } 20 | 21 | h1:has(&), 22 | h2:has(&), 23 | h3:has(&), 24 | h4:has(&), 25 | h5:has(&), 26 | h6:has(&) { 27 | display: flex; 28 | align-items: center; 29 | flex-direction: row-reverse; 30 | justify-content: start; 31 | } 32 | 33 | html.dark &::before { 34 | content: url("data:image/svg+xml;base64,PHN2ZwoJeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgoJd2lkdGg9IjE2IgoJaGVpZ2h0PSIxNiIKCXZpZXdCb3g9IjAgMCAyNCAyNCIKCWZpbGw9Im5vbmUiCglzdHJva2U9IndoaXRlIgoJc3Ryb2tlLXdpZHRoPSIyIgoJc3Ryb2tlLWxpbmVjYXA9InJvdW5kIgoJc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIKCWNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayIKPgoJPHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiIC8+Cgk8cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiIC8+Cjwvc3ZnPgo="); 35 | } 36 | html.light &::before { 37 | content: url("data:image/svg+xml;base64,PHN2ZwoJeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgoJd2lkdGg9IjE2IgoJaGVpZ2h0PSIxNiIKCXZpZXdCb3g9IjAgMCAyNCAyNCIKCWZpbGw9Im5vbmUiCglzdHJva2U9ImJsYWNrIgoJc3Ryb2tlLXdpZHRoPSIyIgoJc3Ryb2tlLWxpbmVjYXA9InJvdW5kIgoJc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIKCWNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayIKPgoJPHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiIC8+Cgk8cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiIC8+Cjwvc3ZnPgo="); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /docs/app/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | function Popover({ 7 | ...props 8 | }: React.ComponentProps) { 9 | return ; 10 | } 11 | 12 | function PopoverTrigger({ 13 | ...props 14 | }: React.ComponentProps) { 15 | return ; 16 | } 17 | 18 | function PopoverContent({ 19 | className, 20 | align = "center", 21 | sideOffset = 4, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 36 | 37 | ); 38 | } 39 | 40 | function PopoverAnchor({ 41 | ...props 42 | }: React.ComponentProps) { 43 | return ; 44 | } 45 | 46 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 47 | -------------------------------------------------------------------------------- /docs/app/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | }, 26 | ); 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span"; 36 | 37 | return ( 38 | 43 | ); 44 | } 45 | 46 | export { Badge, badgeVariants }; 47 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/renderProperty/options.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | 3 | #### options 4 | 5 | | Key | Type | Default | 6 | | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | --------------- | 7 | | | `string` | - | 8 | | | [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement) | `api.el` | 9 | | | `boolean` | `false` | 10 | | | `string \| undefined` | `api.soucePath` | 11 | | | `boolean` | `true` | 12 | -------------------------------------------------------------------------------- /docs/app/components/common/Article/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, type ReactNode } from "react"; 2 | import { EditOnGithub } from "../EditOnGithub"; 3 | import type { TocItem } from "rehype-mdx-toc"; 4 | import { useToc } from "../TocSidebar"; 5 | import { Link } from "react-router"; 6 | import { cn, type Path } from "~/lib/utils"; 7 | import { buttonVariants } from "~/components/ui/button"; 8 | import { ArrowRight } from "lucide-react"; 9 | 10 | export const Article = ({ 11 | children, 12 | path, 13 | toc, 14 | next, 15 | }: { 16 | children: ReactNode; 17 | path: string; 18 | toc: TocItem[]; 19 | next?: { 20 | path: Path; 21 | label: string; 22 | }; 23 | }): ReactNode => { 24 | const { setToc } = useToc(); 25 | 26 | useEffect(() => { 27 | if (!toc) return; 28 | setToc(() => [...toc]); 29 | }, [toc]); 30 | return ( 31 |
32 | {/* */} 33 |
34 | {children} 35 |
36 |
37 | 38 | {next && ( 39 | 46 | {next.label} 47 | 48 | 49 | )} 50 |
51 |
52 |
53 | ); 54 | }; 55 | 56 | // const DotsBackground = () => ( 57 | //
58 | // ); 59 | -------------------------------------------------------------------------------- /src/classes/InputSuggest/LinkSuggest/index.ts: -------------------------------------------------------------------------------- 1 | import { App, setIcon } from "obsidian"; 2 | import { InputSuggest, Suggestion } from ".."; 3 | import { LinkSuggestion } from "obsidian-typings"; 4 | import { Icon } from "~/lib/types/icons"; 5 | 6 | export class LinkSuggest extends InputSuggest { 7 | constructor( 8 | app: App, 9 | component: HTMLDivElement | HTMLInputElement, 10 | public respectUserIgnored: boolean = true 11 | ) { 12 | super(app, component); 13 | } 14 | 15 | protected getSuggestions( 16 | query: string 17 | ): LinkSuggestion[] | Promise { 18 | const { metadataCache } = this.app; 19 | const lowerQuery = query.toLowerCase(); 20 | return metadataCache.getLinkSuggestions().filter((s) => { 21 | const isValidExtension = s.file 22 | ? s.file?.extension.toLowerCase() === "md" 23 | : true; 24 | const isIgnored = metadataCache.isUserIgnored(s.path); 25 | const isMatch = 26 | !query || 27 | s.alias?.toLowerCase()?.includes(lowerQuery) || 28 | s.path.toLowerCase().includes(lowerQuery); 29 | return ( 30 | isValidExtension && !isIgnored && isMatch && this.setFilterCallback(s) 31 | ); 32 | }); 33 | } 34 | 35 | protected parseSuggestion({ path, file, alias }: LinkSuggestion): Suggestion { 36 | return { 37 | title: alias ?? file?.basename ?? path, 38 | note: alias 39 | ? path 40 | : file 41 | ? path.slice(0, -1 * file.basename.length) 42 | : undefined, 43 | aux: alias ? " " : undefined, 44 | }; 45 | } 46 | 47 | override renderSuggestion(value: LinkSuggestion, el: HTMLElement): void { 48 | super.renderSuggestion(value, el); 49 | if (!value.alias) return; 50 | const auxFlairEl: HTMLElement | null = el.querySelector( 51 | ".suggestion-aux > .suggestion-flair" 52 | ); 53 | if (!auxFlairEl) return; 54 | setIcon(auxFlairEl, "lucide-forward" satisfies Icon); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/app/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | }, 20 | ); 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ); 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ); 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ); 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription }; 67 | -------------------------------------------------------------------------------- /src/classes/InputSuggest/index.ts: -------------------------------------------------------------------------------- 1 | import { AbstractInputSuggest, setIcon } from "obsidian"; 2 | 3 | export type Suggestion = { 4 | title: string; 5 | note?: string; 6 | aux?: string; 7 | icon?: string; 8 | }; 9 | 10 | export abstract class InputSuggest extends AbstractInputSuggest { 11 | /** 12 | * Get the suggestions for the popover 13 | * @note Make sure to utilize `this.setFilterCallback` 14 | */ 15 | protected abstract getSuggestions(query: string): T[] | Promise; 16 | 17 | /** 18 | * Convert a suggestion value of type `T` to the `Suggestion` type 19 | */ 20 | protected abstract parseSuggestion(value: T): Suggestion; 21 | 22 | /** 23 | * An additional filter that must be true to render a given suggestion 24 | */ 25 | setFilterCallback: (suggestion: T) => boolean = () => true; 26 | 27 | /** 28 | * Sets `this.setFilterCallback` 29 | */ 30 | setFilter(cb: this["setFilterCallback"]): this { 31 | this.setFilterCallback = cb; 32 | return this; 33 | } 34 | 35 | /** 36 | * Renders suggestions 37 | */ 38 | renderSuggestion(value: T, el: HTMLElement): void { 39 | if (!this.setFilterCallback(value)) { 40 | el.remove(); 41 | return; 42 | } 43 | 44 | const { title, aux, note, icon } = this.parseSuggestion(value); 45 | el.classList.add("mod-complex"); 46 | if (icon) { 47 | const iconEl = el 48 | .createDiv({ cls: "suggestion-icon" }) 49 | .createSpan({ cls: "suggestion-flair" }); 50 | setIcon(iconEl, icon); 51 | } 52 | const contentEl = el.createDiv({ cls: "suggestion-content" }); 53 | contentEl.createDiv({ 54 | cls: "suggestion-title", 55 | text: title, 56 | }); 57 | if (note !== undefined) { 58 | contentEl.createDiv({ cls: "suggestion-note", text: note }); 59 | } 60 | if (aux !== undefined) { 61 | el.createDiv({ cls: "suggestion-aux" }).createSpan({ 62 | cls: "suggestion-flair", 63 | text: aux, 64 | }); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/MultiSelect/index.ts: -------------------------------------------------------------------------------- 1 | import { text } from "~/i18next"; 2 | import { 3 | CustomPropertyType, 4 | CustomTypeKey, 5 | PropertyTypeSchema, 6 | } from "../types"; 7 | import { registerListeners } from "./registerListeners"; 8 | import { renderSettings } from "./renderSettings"; 9 | import { renderWidget } from "./renderWidget"; 10 | import * as v from "valibot"; 11 | 12 | export const typeKey = "multiselect" satisfies CustomTypeKey; 13 | 14 | export const multiSelectPropertyType: CustomPropertyType = { 15 | type: typeKey, 16 | name: () => text("customPropertyTypes.multiSelect.name"), 17 | icon: "lucide-list-collapse", 18 | validate: (v) => Array.isArray(v), 19 | registerListeners, 20 | renderSettings, 21 | renderWidget, 22 | }; 23 | 24 | export const multiSelectSettingsSchema = v.optional( 25 | v.object({ 26 | optionsType: v.optional( 27 | v.union([v.literal("manual"), v.literal("dynamic")]), 28 | "manual" 29 | ), 30 | manualAllowCreate: v.optional(v.boolean()), 31 | manualOptions: v.optional( 32 | v.array( 33 | v.object({ 34 | value: v.string(), 35 | label: v.optional(v.string()), 36 | desc: v.optional(v.string()), 37 | bgColor: v.optional(v.string()), 38 | textColor: v.optional(v.string()), 39 | }) 40 | ) 41 | ), 42 | dynamicOptionsType: v.optional( 43 | v.union([ 44 | v.literal("filesInFolder"), 45 | v.literal("filesFromTag"), 46 | v.literal("filesFromInlineJs"), 47 | v.literal("filesFromJsFile"), 48 | ]) 49 | ), 50 | folderOptionsPaths: v.optional(v.array(v.string())), 51 | folderOptionsIsSubsIncluded: v.optional(v.boolean()), 52 | folderOptionsExcludeFolderNote: v.optional(v.boolean()), 53 | tagOptionsTags: v.optional(v.array(v.string())), 54 | tagOptionsIncludeNested: v.optional(v.boolean()), 55 | inlineJsOptionsCode: v.optional(v.string()), 56 | fileJsOptionsPath: v.optional(v.string()), 57 | }) 58 | ) satisfies PropertyTypeSchema; 59 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-better-properties-docs", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "react-router build", 7 | "cf-typegen": "wrangler types", 8 | "deploy": "npm run build && wrangler deploy", 9 | "dev": "react-router dev --host", 10 | "postinstall": "patch-package && npm run cf-typegen", 11 | "preview": "npm run build && vite preview", 12 | "typecheck": "npm run cf-typegen && react-router typegen && tsc -b" 13 | }, 14 | "dependencies": { 15 | "@mdx-js/rollup": "^3.1.1", 16 | "@radix-ui/react-dialog": "^1.1.15", 17 | "@radix-ui/react-dropdown-menu": "^2.1.16", 18 | "@radix-ui/react-popover": "^1.1.15", 19 | "@radix-ui/react-separator": "^1.1.7", 20 | "@radix-ui/react-slot": "^1.2.3", 21 | "@radix-ui/react-tooltip": "^1.2.8", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "isbot": "^5.1.31", 25 | "lucide-react": "^0.544.0", 26 | "react": "^19.1.1", 27 | "react-dom": "^19.1.1", 28 | "react-router": "^7.9.2", 29 | "recma-export-filepath": "^1.2.0", 30 | "rehype-autolink-headings": "^7.1.0", 31 | "rehype-mdx-toc": "^1.1.0", 32 | "rehype-slug": "^6.0.0", 33 | "rehype-starry-night": "^2.2.0", 34 | "remark-gfm": "^4.0.1", 35 | "tailwind-merge": "^3.3.1" 36 | }, 37 | "devDependencies": { 38 | "@cloudflare/vite-plugin": "^1.13.5", 39 | "@octokit/openapi-types": "^26.0.0", 40 | "@react-router/dev": "^7.9.2", 41 | "@tailwindcss/vite": "^4.1.13", 42 | "@types/node": "^22", 43 | "@types/react": "^19.1.13", 44 | "@types/react-dom": "^19.1.9", 45 | "patch-package": "^8.0.1", 46 | "prettier": "^3.6.2", 47 | "prettier-plugin-tailwindcss": "^0.6.14", 48 | "tailwindcss": "^4.1.13", 49 | "tw-animate-css": "^1.4.0", 50 | "typescript": "^5.9.2", 51 | "vite": "^7.1.7", 52 | "vite-tsconfig-paths": "^5.1.4", 53 | "wrangler": "^4.40.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Created/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting, moment } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | import { obsidianText } from "~/i18next/obsidian"; 5 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 6 | 7 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 8 | modal, 9 | plugin, 10 | property, 11 | }) => { 12 | const { tabContentEl } = modal; 13 | 14 | const settings = getPropertyTypeSettings({ 15 | plugin, 16 | property, 17 | type: "created", 18 | }); 19 | 20 | modal.onTabChange(() => { 21 | setPropertyTypeSettings({ 22 | plugin, 23 | property, 24 | type: "created", 25 | typeSettings: settings, 26 | }); 27 | }); 28 | 29 | let updateFormatSample = (_: string): void => { 30 | throw new Error("Not implemented"); 31 | }; 32 | 33 | new Setting(tabContentEl) 34 | .setName(text("customPropertyTypes.created.settings.format.title")) 35 | .setDesc( 36 | createFragment((el) => { 37 | el.appendText( 38 | text("customPropertyTypes.created.settings.format.desc") + " " 39 | ); 40 | el.createEl("a", { 41 | text: obsidianText("plugins.daily-notes.label-syntax-link"), 42 | href: "https://momentjs.com/docs/#/displaying/format/", 43 | }); 44 | 45 | el.createEl("br"); 46 | 47 | el.appendText( 48 | obsidianText("plugins.daily-notes.label-syntax-live-preview") + " " 49 | ); 50 | const formatSampleEl = el.createEl("b", { cls: "u-pop" }); 51 | updateFormatSample = (formatStr) => { 52 | const dateStr = formatStr === "" ? "" : moment().format(formatStr); 53 | formatSampleEl.textContent = dateStr; 54 | }; 55 | }) 56 | ) 57 | .addText((cmp) => { 58 | cmp 59 | .setPlaceholder("YYYY-MM-DD") 60 | .setValue(settings.format ?? "") 61 | .onChange((v) => { 62 | updateFormatSample(v); 63 | settings.format = v; 64 | }); 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /src/MetadataEditor/propertyEditorMenu/icon.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { ConfirmationModal } from "~/classes/ConfirmationModal"; 3 | import { IconSuggest } from "~/classes/InputSuggest/IconSuggest"; 4 | import { getPropertyTypeSettings } from "~/CustomPropertyTypes"; 5 | import { updatePropertyTypeSettings } from "~/CustomPropertyTypes/utils"; 6 | import BetterProperties from "~/main"; 7 | import { refreshPropertyEditor } from ".."; 8 | import { obsidianText } from "~/i18next/obsidian"; 9 | import { text } from "~/i18next"; 10 | 11 | export const openChangeIconModal = ({ 12 | plugin, 13 | property, 14 | }: { 15 | plugin: BetterProperties; 16 | property: string; 17 | }) => { 18 | const modal = new ConfirmationModal(plugin.app); 19 | let icon: string = 20 | getPropertyTypeSettings({ plugin, property, type: "general" })?.icon ?? ""; 21 | 22 | modal.setTitle( 23 | text("metadataEditor.propertyMenu.icon.modalTitle", { property }) 24 | ); 25 | 26 | new Setting(modal.contentEl) 27 | .setName(text("metadataEditor.propertyMenu.icon.iconSetting.title")) 28 | .setDesc(text("metadataEditor.propertyMenu.icon.iconSetting.desc")) 29 | .addSearch((cmp) => { 30 | cmp.setValue(icon).onChange((v) => { 31 | icon = v; 32 | }); 33 | new IconSuggest(plugin.app, cmp.inputEl).onSelect((v) => { 34 | cmp.setValue(v); 35 | cmp.onChanged(); 36 | }); 37 | }); 38 | 39 | modal 40 | .addFooterButton((btn) => 41 | btn.setButtonText(obsidianText("dialogue.button-cancel")).onClick(() => { 42 | modal.close(); 43 | }) 44 | ) 45 | .addFooterButton((btn) => 46 | btn 47 | .setButtonText(obsidianText("dialogue.button-update")) 48 | .setCta() 49 | .onClick(() => { 50 | updatePropertyTypeSettings({ 51 | plugin, 52 | property, 53 | type: "general", 54 | cb: (s) => ({ ...s, icon }), 55 | }); 56 | refreshPropertyEditor(plugin, property); 57 | modal.close(); 58 | }) 59 | ); 60 | 61 | modal.open(); 62 | }; 63 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Select/index.ts: -------------------------------------------------------------------------------- 1 | import { CustomPropertyType, CustomTypeKey } from ".."; 2 | import { renderWidget } from "./renderWidget"; 3 | import { renderSettings } from "./renderSettings"; 4 | import { registerListeners } from "./registerListeners"; 5 | import { text } from "~/i18next"; 6 | import { PropertyTypeSchema } from "../types"; 7 | import * as v from "valibot"; 8 | 9 | export const typeKey = "select" satisfies CustomTypeKey; 10 | 11 | export const selectPropertyType: CustomPropertyType = { 12 | type: typeKey, 13 | icon: "lucide-circle-chevron-down", 14 | name: () => text("customPropertyTypes.select.name"), 15 | validate: (v) => typeof v?.toString() === "string", 16 | renderWidget, 17 | registerListeners, 18 | renderSettings, 19 | }; 20 | 21 | export const selectSettingsSchema = v.optional( 22 | v.object({ 23 | useDefaultStyle: v.optional(v.boolean(), false), 24 | optionsType: v.optional( 25 | v.union([v.literal("manual"), v.literal("dynamic")]), 26 | "manual" 27 | ), 28 | manualAllowCreate: v.optional(v.boolean()), 29 | manualOptions: v.optional( 30 | v.array( 31 | v.object({ 32 | value: v.string(), 33 | label: v.optional(v.string()), 34 | desc: v.optional(v.string()), 35 | bgColor: v.optional(v.string()), 36 | textColor: v.optional(v.string()), 37 | }) 38 | ) 39 | ), 40 | dynamicOptionsType: v.optional( 41 | v.union([ 42 | v.literal("filesInFolder"), 43 | v.literal("filesFromTag"), 44 | v.literal("filesFromInlineJs"), 45 | v.literal("filesFromJsFile"), 46 | ]) 47 | ), 48 | dynamicEmptyLabel: v.optional(v.string()), 49 | folderOptionsPaths: v.optional(v.array(v.string())), 50 | folderOptionsIsSubsIncluded: v.optional(v.boolean()), 51 | folderOptionsExcludeFolderNote: v.optional(v.boolean()), 52 | tagOptionsTags: v.optional(v.array(v.string())), 53 | tagOptionsIncludeNested: v.optional(v.boolean()), 54 | inlineJsOptionsCode: v.optional(v.string()), 55 | fileJsOptionsPath: v.optional(v.string()), 56 | }) 57 | ) satisfies PropertyTypeSchema; 58 | -------------------------------------------------------------------------------- /src/lib/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Events, 3 | Workspace as BaseWorkspace, 4 | MetadataCache as BaseMetadataCache, 5 | } from "obsidian"; 6 | import { 7 | MetadataTypeManager as BaseMetadataTypeManager, 8 | MetadataEditor as BaseMetadataEditor, 9 | PropertyEntryData, 10 | PropertyWidget, 11 | } from "obsidian-typings"; 12 | 13 | declare module "obsidian" { 14 | interface Workspace extends BaseWorkspace { 15 | on( 16 | name: "better-properties:file-property-menu", 17 | callback: (menu: Menu, property: string) => void, 18 | ctx?: unknown 19 | ): EventRef; 20 | 21 | on( 22 | name: "better-properties:property-label-width-change", 23 | callback: (newWidth: number | undefined) => void 24 | ): EventRef; 25 | 26 | // trigger( 27 | // name: "better-properties:property-label-width-change", 28 | // width: number | undefined 29 | // ): void; 30 | } 31 | 32 | interface AbstractInputSuggest extends PopoverSuggest { 33 | showSuggestions: (suggestions: T[]) => void; 34 | } 35 | 36 | interface MarkdownPreviewRenderer { 37 | onHeadingCollapseClick(e: MouseEvent, el: HTMLElement): void; 38 | } 39 | 40 | interface PropertyValueComponent { 41 | containerEl: HTMLElement; 42 | private focus(_: unknown): void; 43 | onFocus(): void; 44 | } 45 | 46 | interface MetadataTypeManager extends BaseMetadataTypeManager { 47 | on(name: "changed", cb: (property: string) => void): EventRef; 48 | } 49 | 50 | interface MetadataCache extends BaseMetadataCache { 51 | on( 52 | name: "better-properties:relation-changed", 53 | cb: (data: { 54 | file: TFile; 55 | property: string; 56 | oldValue: string[]; 57 | value: string[]; 58 | relatedProperty: string; 59 | }) => void 60 | ): EventRef; 61 | trigger( 62 | name: "better-properties:relation-changed", 63 | data: { 64 | file: TFile; 65 | property: string; 66 | oldValue: string[]; 67 | value: string[]; 68 | relatedProperty: string; 69 | } 70 | ); 71 | } 72 | 73 | interface Menu { 74 | scrollEl: HTMLDivElement; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Select/registerListeners.ts: -------------------------------------------------------------------------------- 1 | import { debounce, EventRef } from "obsidian"; 2 | import BetterProperties from "~/main"; 3 | import { refreshPropertyEditor } from "~/MetadataEditor"; 4 | import { CustomPropertyType } from "../types"; 5 | import { typeKey } from "."; 6 | 7 | export const registerListeners: CustomPropertyType["registerListeners"] = ( 8 | plugin: BetterProperties 9 | ) => { 10 | const vaultEventHandler = vaultEventHandlerFactory(plugin); 11 | const metadataCacheHandler = metadataCacheEventHandlerFactory(plugin); 12 | const eventRefs: EventRef[] = [ 13 | plugin.app.vault.on("create", vaultEventHandler), 14 | plugin.app.vault.on("delete", vaultEventHandler), 15 | plugin.app.vault.on("rename", vaultEventHandler), 16 | plugin.app.metadataCache.on("changed", metadataCacheHandler), 17 | ]; 18 | eventRefs.forEach((ref) => plugin.registerEvent(ref)); 19 | }; 20 | 21 | const vaultEventHandlerFactory = (plugin: BetterProperties): (() => void) => { 22 | return debounce( 23 | () => { 24 | const { propertySettings } = plugin.settings; 25 | if (!propertySettings) return; 26 | 27 | Object.entries(propertySettings).forEach(([property, settings]) => { 28 | const { optionsType, dynamicOptionsType } = settings[typeKey] ?? {}; 29 | if (optionsType !== "dynamic") return; 30 | if (dynamicOptionsType !== "filesInFolder") return; 31 | refreshPropertyEditor(plugin, property); 32 | }); 33 | }, 34 | 250, 35 | true 36 | ); 37 | }; 38 | 39 | const metadataCacheEventHandlerFactory = ( 40 | plugin: BetterProperties 41 | ): (() => void) => { 42 | return debounce( 43 | () => { 44 | const { propertySettings } = plugin.settings; 45 | if (!propertySettings) return; 46 | 47 | Object.entries(propertySettings).forEach(([property, settings]) => { 48 | const { optionsType, dynamicOptionsType } = settings[typeKey] ?? {}; 49 | if (optionsType !== "dynamic") return; 50 | if (dynamicOptionsType !== "filesFromTag") return; 51 | refreshPropertyEditor(plugin, property); 52 | }); 53 | }, 54 | 250, 55 | true 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Relation/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { CustomPropertyType } from "../types"; 2 | import { typeKey } from "."; 3 | import { 4 | getPropertyTypeSettings, 5 | setPropertyTypeSettings, 6 | updatePropertyTypeSettings, 7 | } from "../utils"; 8 | import { Setting } from "obsidian"; 9 | import { PropertySuggest } from "~/classes/InputSuggest/PropertySuggest"; 10 | import { customPropertyTypePrefix } from "~/lib/constants"; 11 | 12 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 13 | modal, 14 | plugin, 15 | property, 16 | }) => { 17 | const { tabContentEl } = modal; 18 | 19 | const settings = getPropertyTypeSettings({ 20 | plugin, 21 | property: property, 22 | type: typeKey, 23 | }); 24 | 25 | const originalRelatedProperty = settings.relatedProperty; 26 | 27 | modal.onTabChange(() => { 28 | setPropertyTypeSettings({ 29 | plugin, 30 | property: property, 31 | type: typeKey, 32 | typeSettings: { ...settings }, 33 | }); 34 | 35 | const { relatedProperty } = settings; 36 | if (relatedProperty === originalRelatedProperty) return; 37 | if (!relatedProperty && originalRelatedProperty) { 38 | updatePropertyTypeSettings({ 39 | plugin, 40 | property: originalRelatedProperty, 41 | type: typeKey, 42 | cb: (prev) => ({ ...prev, relatedProperty: undefined }), 43 | }); 44 | } 45 | if (!relatedProperty) return; 46 | 47 | plugin.app.metadataTypeManager.setType( 48 | relatedProperty, 49 | customPropertyTypePrefix + typeKey 50 | ); 51 | updatePropertyTypeSettings({ 52 | plugin, 53 | property: relatedProperty, 54 | type: typeKey, 55 | cb: (prev) => ({ ...prev, relatedProperty: property }), 56 | }); 57 | }); 58 | 59 | new Setting(tabContentEl) 60 | .setName("Related property") 61 | .setDesc("") 62 | .addSearch((cmp) => { 63 | cmp.setValue(settings.relatedProperty ?? "").onChange((v) => { 64 | settings.relatedProperty = v; 65 | }); 66 | 67 | new PropertySuggest(plugin, cmp.inputEl).onSelect((v) => { 68 | cmp.setValue(v.name); 69 | cmp.onChanged(); 70 | }); 71 | }); 72 | }; 73 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/types.ts: -------------------------------------------------------------------------------- 1 | import { PropertyRenderContext } from "obsidian-typings"; 2 | import * as v from "valibot"; 3 | import { Prettify } from "~/lib/utils"; 4 | import { propertySettingsSchema } from "./schema"; 5 | import BetterProperties from "~/main"; 6 | import { PropertyValueComponent } from "obsidian"; 7 | import { PropertySettingsModal } from "./settings"; 8 | import { Icon } from "~/lib/types/icons"; 9 | 10 | export type PropertySettings = Prettify< 11 | v.InferOutput 12 | >; 13 | export type CustomTypeKey = keyof PropertySettings; 14 | 15 | export type ModifiedPropertyRenderContext = PropertyRenderContext & { 16 | index?: number; 17 | }; 18 | 19 | export type CustomPropertyType = { 20 | type: CustomTypeKey; 21 | icon: Icon; 22 | name(): string; 23 | validate(value: unknown): boolean; 24 | renderWidget(args: { 25 | plugin: BetterProperties; 26 | el: HTMLElement; 27 | value: unknown; 28 | ctx: ModifiedPropertyRenderContext; 29 | }): PropertyValueComponent; 30 | reservedKeys?: string[]; 31 | 32 | registerListeners: (plugin: BetterProperties) => void; 33 | 34 | renderSettings: (args: { 35 | plugin: BetterProperties; 36 | modal: PropertySettingsModal; 37 | property: string; 38 | }) => void; 39 | }; 40 | 41 | // export type CustomTypeWidget = { 42 | // type: CustomTypeKey; 43 | // icon: string; 44 | // default: () => Value; 45 | // name: () => string; 46 | // validate: (value: unknown) => boolean; 47 | // render: RenderCustomTypeWidget; 48 | // }; 49 | 50 | export type RenderCustomTypeSettings = (args: { 51 | plugin: BetterProperties; 52 | modal: PropertySettingsModal; 53 | type: CustomTypeKey; 54 | property: string; 55 | }) => void; 56 | 57 | export type RenderCustomTypeWidget = (args: { 58 | plugin: BetterProperties; 59 | el: HTMLElement; 60 | value: Value; 61 | ctx: PropertyRenderContext; 62 | }) => PropertyValueComponent; 63 | 64 | export type PropertyTypeSchema = v.OptionalSchema< 65 | v.ObjectSchema< 66 | Record>, 67 | undefined 68 | >, 69 | undefined 70 | >; 71 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Markdown/renderWidget.ts: -------------------------------------------------------------------------------- 1 | import { obsidianText } from "~/i18next/obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { PropertyWidgetComponentNew } from "../utils"; 4 | import { 5 | TEmbeddableMarkdownEditor, 6 | createEmbeddableMarkdownEditor, 7 | } from "~/classes/EmbeddableMarkdownEditor"; 8 | import { PropertyRenderContext } from "obsidian-typings"; 9 | import BetterProperties from "~/main"; 10 | 11 | export const renderWidget: CustomPropertyType["renderWidget"] = ({ 12 | plugin, 13 | el, 14 | ctx, 15 | value, 16 | }) => { 17 | return new MarkdownTypeComponent(plugin, el, value, ctx); 18 | }; 19 | 20 | class MarkdownTypeComponent extends PropertyWidgetComponentNew< 21 | "markdown", 22 | string 23 | > { 24 | type = "markdown" as const; 25 | parseValue = (v: unknown) => v?.toString() ?? ""; 26 | 27 | embeddedMarkdownEditor: TEmbeddableMarkdownEditor; 28 | 29 | constructor( 30 | plugin: BetterProperties, 31 | el: HTMLElement, 32 | value: unknown, 33 | ctx: PropertyRenderContext 34 | ) { 35 | super(plugin, el, value, ctx); 36 | 37 | const parsed = this.parseValue(value); 38 | this.embeddedMarkdownEditor = createEmbeddableMarkdownEditor({ 39 | app: plugin.app, 40 | container: el, 41 | options: { 42 | value: parsed, 43 | onBlur: (editor) => { 44 | const val = editor.editor?.getValue() ?? ""; 45 | this.setValue(val); 46 | }, 47 | placeholder: obsidianText("properties.label-no-value"), 48 | }, 49 | filePath: ctx.sourcePath, 50 | }); 51 | 52 | this.onFocus = () => { 53 | this.embeddedMarkdownEditor.focus(); 54 | }; 55 | 56 | if (!plugin.app.vault.getConfig("showLineNumber")) { 57 | el.setAttribute("data-better-properties-showLineNumber", "false"); 58 | } 59 | } 60 | 61 | getValue(): string { 62 | return this.embeddedMarkdownEditor.editor?.getValue() ?? ""; 63 | } 64 | 65 | setValue(value: unknown): void { 66 | if (this.embeddedMarkdownEditor.editor?.getValue() !== value) { 67 | this.embeddedMarkdownEditor.editor?.setValue(this.parseValue(value)); 68 | } 69 | super.setValue(value); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /docs/app/components/common/ThemeSwitcher/context.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useContext, 4 | useEffect, 5 | useLayoutEffect, 6 | useState, 7 | } from "react"; 8 | 9 | type Theme = "dark" | "light" | "system"; 10 | 11 | type ThemeProviderProps = { 12 | children: React.ReactNode; 13 | defaultTheme?: Theme; 14 | storageKey?: string; 15 | }; 16 | 17 | type ThemeProviderState = { 18 | theme: Theme; 19 | setTheme: (theme: Theme) => void; 20 | }; 21 | 22 | const initialState: ThemeProviderState = { 23 | theme: "system", 24 | setTheme: () => null, 25 | }; 26 | 27 | const ThemeProviderContext = createContext(initialState); 28 | 29 | export const ThemeProvider = ({ 30 | children, 31 | defaultTheme = "system", 32 | storageKey = "vite-ui-theme", 33 | ...props 34 | }: ThemeProviderProps) => { 35 | const [theme, setTheme] = useState(() => defaultTheme); 36 | 37 | useLayoutEffect(() => { 38 | const storedTheme = window.localStorage.getItem(storageKey) as Theme; 39 | if (storedTheme) { 40 | setTheme(storedTheme); 41 | } 42 | }, []); 43 | 44 | useEffect(() => { 45 | const root = window.document.documentElement; 46 | 47 | root.classList.remove("light", "dark"); 48 | 49 | if (theme === "system") { 50 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 51 | .matches 52 | ? "dark" 53 | : "light"; 54 | 55 | root.classList.add(systemTheme); 56 | return; 57 | } 58 | 59 | root.classList.add(theme); 60 | }, [theme]); 61 | 62 | const value = { 63 | theme, 64 | setTheme: (theme: Theme) => { 65 | localStorage.setItem(storageKey, theme); 66 | setTheme(theme); 67 | }, 68 | }; 69 | 70 | return ( 71 | 72 | {children} 73 | 74 | ); 75 | }; 76 | 77 | export const useTheme = () => { 78 | const context = useContext(ThemeProviderContext); 79 | 80 | if (context === undefined) 81 | throw new Error("useTheme must be used within a ThemeProvider"); 82 | 83 | return context; 84 | }; 85 | -------------------------------------------------------------------------------- /docs/app/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return ; 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 0, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 60 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Select/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { typeKey } from "."; 2 | import { CustomPropertyType } from "../types"; 3 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 4 | import { 5 | renderDefaultSettings, 6 | renderDynamicSettings, 7 | renderFilesFromFileJsSetings, 8 | renderFilesFromFolderSettings, 9 | renderFilesFromInlineJsSettings, 10 | renderFilesFromTagSettings, 11 | renderManualSettings, 12 | } from "./utils"; 13 | 14 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 15 | plugin, 16 | modal, 17 | property, 18 | }) => { 19 | const { tabContentEl: parentEl } = modal; 20 | const settings = getPropertyTypeSettings({ 21 | plugin, 22 | property, 23 | type: typeKey, 24 | }); 25 | 26 | modal.onTabChange(() => { 27 | setPropertyTypeSettings({ 28 | plugin, 29 | property, 30 | type: typeKey, 31 | typeSettings: settings, 32 | }); 33 | }); 34 | 35 | renderDefaultSettings({ 36 | plugin, 37 | parentEl, 38 | settings, 39 | modal, 40 | property, 41 | }); 42 | 43 | if (settings.optionsType === "manual") { 44 | renderManualSettings({ 45 | plugin, 46 | parentEl, 47 | settings, 48 | }); 49 | } 50 | 51 | if (settings.optionsType === "dynamic") { 52 | if (!settings.dynamicOptionsType) { 53 | settings.dynamicOptionsType = "filesInFolder"; 54 | } 55 | 56 | renderDynamicSettings({ 57 | plugin, 58 | parentEl, 59 | settings, 60 | modal, 61 | property, 62 | }); 63 | 64 | if (settings.dynamicOptionsType === "filesInFolder") { 65 | renderFilesFromFolderSettings({ 66 | plugin, 67 | parentEl, 68 | settings, 69 | }); 70 | return; 71 | } 72 | 73 | if (settings.dynamicOptionsType === "filesFromTag") { 74 | renderFilesFromTagSettings({ 75 | plugin, 76 | parentEl, 77 | settings, 78 | }); 79 | return; 80 | } 81 | 82 | if (settings.dynamicOptionsType === "filesFromInlineJs") { 83 | renderFilesFromInlineJsSettings({ plugin, parentEl, settings }); 84 | } 85 | 86 | if (settings.dynamicOptionsType === "filesFromJsFile") { 87 | renderFilesFromFileJsSetings({ plugin, parentEl, settings }); 88 | } 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /docs/app/routes/features/bpjs/(method types)/import/type.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | import DataContent from "./dataContent.mdx"; 3 | 4 | #### Arguments 5 | 6 | | Name | Type | Default | 7 | | --------------------------------------------------------------------- | --------------------- | --------- | 8 | | | `string` | - | 9 | | } /> | `string \| undefined` | undefined | 10 | 11 | #### Returns 12 | 13 | `Promise` - Depends on the file extension in the provided path: 14 | 15 | - `.js` - `unknown` - File contents will be executed as JavaScript. If the file exports a default, it will be returned. Default exports can be in ES6 syntax (`export default myVar`) or CommonJS syntax (`module.exports = myVar`) 16 | - `.css` - `void` - File contents will be read as CSS and inserted into `api.styleEl`. These styles will only affect elements within `api.el`. 17 | - `.json` - `object` - File contents will be parsed to JSON and returned. 18 | - `.yaml` - `object` - File contents will be parsed as YAML to JSON and returned. 19 | - `.csv` - `string[][]` - File contents will be parsed as a CSV to a two dimensional string array. 20 | - `.tsv` - `string[][]` - File contents will be parsed as a CSV to a two dimensional string array. 21 | - `.md` - `string` - File contents will be returned. 22 | - `.txt` - `string` - File contents will be returned. 23 | 24 | #### Examples 25 | 26 | ```js 27 | const exportedVar = await api.import("path/to/file.js"); 28 | 29 | await api.import("path/to/file.css"); 30 | 31 | const json = await api.import("path/to/file.json"); 32 | const jsonFromYaml = await api.import("path/to/file.json"); 33 | 34 | const twoDimensionalArray = await api.import("path/to/file.csv"); 35 | 36 | // with custom delimiter 37 | const twoDimensionalArray = await api.import("path/to/file.tsv", ";"); 38 | 39 | const noteContent = await api.import("path/to/note.md"); 40 | 41 | const txtContent = await api.import("path/to/file.txt"); 42 | ``` 43 | -------------------------------------------------------------------------------- /src/lib/types/oneDotNine.d.ts: -------------------------------------------------------------------------------- 1 | // All types in this file are for API changes in Obsidian ver 1.9+ 2 | 3 | import { 4 | PropertyWidget as BasePropertyWidget, 5 | MetadataTypeManager as BaseMetadataTypeManager, 6 | PropertyInfo as BasePropertyInfo, 7 | PropertyRenderContext as BasePropertyRenderContext, 8 | } from "obsidian-typings"; 9 | import { Scope as BaseScope } from "obsidian"; 10 | 11 | declare module "obsidian" { 12 | interface Scope extends BaseScope { 13 | /** 14 | * Not sure if it was actually 1.9 or earlier 15 | */ 16 | setTabFocusContainerEl(el: HTMLElement): void; 17 | 18 | /** 19 | * @deprecated 20 | */ 21 | setTabFocusContainer(el: HTMLElement): never; 22 | } 23 | } 24 | 25 | declare module "obsidian-typings" { 26 | // interface PropertyInfo extends BasePropertyInfo { 27 | // /** 28 | // * @deprecated 29 | // * @see occurrences 30 | // */ 31 | // count: never; 32 | // /** 33 | // * The number of notes that contain this property 34 | // * @remark used to be named `count` 35 | // */ 36 | // occurrences: number; 37 | // /** 38 | // * @deprecated 39 | // * @see widget 40 | // */ 41 | // type: never; 42 | // /** 43 | // * The type name of the corresponding type widget 44 | // * @remark used to be named `type` 45 | // */ 46 | // widget: string; 47 | // } 48 | // interface PropertyRenderContext extends BasePropertyRenderContext { 49 | // /** 50 | // * @deprecated 51 | // */ 52 | // metadataEditor: never; 53 | // } 54 | // interface PropertyWidget extends BasePropertyWidget { 55 | // render( 56 | // containerEl: HTMLElement, 57 | // /** 58 | // * 1.9+ changed to `T` 59 | // */ 60 | // value: unknown, 61 | // context: PropertyRenderContext 62 | // ): PropertyValueComponent; 63 | // } 64 | // interface App { 65 | // MetadataTypeManager: { 66 | // /** 67 | // * @deprecated 68 | // * @see getAssignedWidget 69 | // */ 70 | // getAssignedType(property: string): string | null; 71 | // /** 72 | // * Gets the type name of the currently assigned type widget for the given property 73 | // */ 74 | // getAssignedWidget(property: string): string | null; 75 | // }; 76 | // } 77 | } 78 | -------------------------------------------------------------------------------- /docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 41 | 55 | 56 | 63 | 66 | 70 | 74 | 75 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Slider/renderWidget.ts: -------------------------------------------------------------------------------- 1 | import { SliderComponent } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { PropertyWidgetComponentNew } from "../utils"; 4 | import { clampNumber } from "~/lib/utils"; 5 | import { PropertyRenderContext } from "obsidian-typings"; 6 | import BetterProperties from "~/main"; 7 | 8 | export const renderWidget: CustomPropertyType["renderWidget"] = ({ 9 | plugin, 10 | el, 11 | ctx, 12 | value, 13 | }) => { 14 | return new SliderTypeComponent(plugin, el, value, ctx); 15 | }; 16 | 17 | class SliderTypeComponent extends PropertyWidgetComponentNew<"slider", number> { 18 | type = "slider" as const; 19 | parseValue = (v: unknown) => { 20 | const n = Number(v); 21 | return Number.isNaN(n) ? 0 : n; 22 | }; 23 | 24 | slider: SliderComponent; 25 | 26 | constructor( 27 | plugin: BetterProperties, 28 | el: HTMLElement, 29 | value: unknown, 30 | ctx: PropertyRenderContext 31 | ) { 32 | super(plugin, el, value, ctx); 33 | 34 | const settings = this.getSettings(); 35 | const min = settings.min ?? 0; 36 | const max = settings.max ?? 100; 37 | const step = settings.step ?? 1; 38 | const clamp = (x: unknown) => clampNumber(Number(x), min, max); 39 | 40 | const parsed = clamp(this.parseValue(value)); 41 | 42 | const container = el.createDiv({ 43 | cls: "better-properties-slider-container", 44 | attr: { 45 | "data-better-properties-slider-hide-limit": settings.hideLimits ?? null, 46 | }, 47 | }); 48 | 49 | container.createDiv({ 50 | cls: "better-properties-slider-limit", 51 | text: min.toString(), 52 | }); 53 | 54 | this.slider = new SliderComponent(container) 55 | .setValue(parsed) 56 | .onChange((v) => { 57 | this.setValue(v); 58 | }) 59 | .setLimits(min, max, step) 60 | .setDynamicTooltip(); 61 | 62 | container.createDiv({ 63 | cls: "better-properties-slider-limit", 64 | text: max.toString(), 65 | }); 66 | 67 | this.onFocus = () => { 68 | this.slider.sliderEl.focus(); 69 | }; 70 | } 71 | 72 | getValue(): number { 73 | return this.slider.getValue(); 74 | } 75 | 76 | setValue(value: unknown): void { 77 | if (this.slider.getValue() !== value) { 78 | this.slider.setValue(this.parseValue(value)); 79 | } 80 | super.setValue(value); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Created/renderWidget.ts: -------------------------------------------------------------------------------- 1 | import { moment } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { 4 | getPropertyTypeSettings, 5 | // getPropertyTypeSettings, 6 | PropertyWidgetComponent, 7 | } from "../utils"; 8 | 9 | export const renderWidget: CustomPropertyType["renderWidget"] = ({ 10 | plugin, 11 | el, 12 | ctx, 13 | value, 14 | }) => { 15 | const settings = getPropertyTypeSettings({ 16 | plugin, 17 | property: ctx.key, 18 | type: "created", 19 | }); 20 | 21 | const container = el.createDiv({ 22 | cls: "better-properties-property-value-inner better-properties-mod-created", 23 | }); 24 | 25 | const cmp = new PropertyWidgetComponent( 26 | "created", 27 | container, 28 | () => {}, 29 | () => {} 30 | ); 31 | 32 | const file = plugin.app.vault.getFileByPath(ctx.sourcePath); 33 | if (!file) { 34 | throw new Error("File not found by ctx.sourcePath of: " + ctx.sourcePath); 35 | } 36 | 37 | const ctime = moment(file.stat.ctime).format("yyyy-MM-DDTHH:mm"); 38 | if (!value || ctime !== value) { 39 | // property is rendered with no value 40 | // so it's likely rendered for the first time 41 | 42 | // 0 timeout because without it, it will usually just remove all properties 43 | window.setTimeout(() => { 44 | ctx.onChange(ctime); 45 | }, 0); 46 | } 47 | 48 | if (!settings.format) { 49 | const dateInput = container.createEl("input", { 50 | type: "datetime-local", 51 | cls: "metadata-input metadata-input-text mod-datetime", 52 | attr: { 53 | // disabled: "true", 54 | "aria-disabled": "true", 55 | }, 56 | }); 57 | const createdTime = moment(ctime).format("yyyy-MM-DDTHH:mm"); 58 | 59 | dateInput.value = createdTime; 60 | dateInput.addEventListener("input", () => { 61 | dateInput.value = createdTime; 62 | }); 63 | 64 | cmp.onFocus = () => { 65 | dateInput.focus(); 66 | }; 67 | } 68 | 69 | if (settings.format) { 70 | const dateStr = moment(ctime).format(settings.format); 71 | const inputEl = container.createEl("input", { 72 | cls: "metadata-input metadata-input-number", 73 | value: dateStr, 74 | type: "text", 75 | }); 76 | inputEl.addEventListener("input", () => { 77 | inputEl.value = dateStr; 78 | }); 79 | cmp.onFocus = () => inputEl.focus(); 80 | } 81 | 82 | return cmp; 83 | }; 84 | -------------------------------------------------------------------------------- /docs/app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import { cn } from "~/lib/utils"; 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 14 | outline: 15 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: 19 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 24 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 25 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 26 | icon: "size-9", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | function Button({ 37 | className, 38 | variant, 39 | size, 40 | asChild = false, 41 | ...props 42 | }: React.ComponentProps<"button"> & 43 | VariantProps & { 44 | asChild?: boolean; 45 | }) { 46 | const Comp = asChild ? Slot : "button"; 47 | 48 | return ( 49 | 54 | ); 55 | } 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /src/MetadataEditor/patchMetadataEditor/patchMetadataEditorProperty.ts: -------------------------------------------------------------------------------- 1 | import BetterProperties from "~/main"; 2 | import { PatchedMetadataEditor } from "."; 3 | // import { around, dedupe } from "monkey-around"; 4 | import { Constructor, MarkdownView } from "obsidian"; 5 | // import { monkeyAroundKey } from "~/lib/constants"; 6 | // import { refreshPropertyEditor } from ".."; 7 | // import { MetadataEditorProperty } from "obsidian-typings"; 8 | 9 | export const patchMetadataEditorProperty = ( 10 | plugin: BetterProperties, 11 | metadataEditorPrototype: PatchedMetadataEditor 12 | ) => { 13 | const { app } = plugin; 14 | 15 | const proto = 16 | metadataEditorPrototype.constructor as Constructor; 17 | class ME extends proto { 18 | constructor() { 19 | super(); 20 | } 21 | } 22 | const metadataEditor = new ME(); 23 | 24 | metadataEditor._children = []; 25 | metadataEditor._events = []; 26 | metadataEditor.owner = { 27 | getFile: () => {}, 28 | } as MarkdownView; 29 | metadataEditor.addPropertyButtonEl; 30 | metadataEditor.propertyListEl = createDiv(); 31 | metadataEditor.containerEl = createDiv(); 32 | metadataEditor.app = app; 33 | metadataEditor.properties = []; 34 | metadataEditor.rendered = []; 35 | metadataEditor.headingEl = createDiv(); 36 | metadataEditor.addPropertyButtonEl = createEl("button"); 37 | // @ts-ignore TODO 38 | metadataEditor.errorEl = createDiv(); 39 | metadataEditor.owner.getHoverSource = () => "source"; 40 | metadataEditor.load(); 41 | metadataEditor.synchronize({ tags: "[]" }); 42 | const MetadataEditorPropertyPrototype = Object.getPrototypeOf( 43 | metadataEditor.rendered[0] 44 | ) as (typeof metadataEditor.rendered)[0]; 45 | 46 | MetadataEditorPropertyPrototype; // stops no-unused-variables rule 47 | 48 | // I don't this is actually needed. And it causes issues with focusing the valueEl when pressing Enter withint the keyInputEl. 49 | // const removePatch = around(MetadataEditorPropertyPrototype, { 50 | // handleUpdateKey(old) { 51 | // return dedupe(monkeyAroundKey, old, function (newKey) { 52 | // // @ts-ignore 53 | // const that = this as MetadataEditorProperty; 54 | 55 | // const returnValue = old.call(that, newKey); 56 | // refreshPropertyEditor(plugin, newKey); 57 | // return returnValue; 58 | // }); 59 | // }, 60 | // }); 61 | 62 | // plugin.register(removePatch); 63 | }; 64 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Title/renderWidget.ts: -------------------------------------------------------------------------------- 1 | import { displayTooltip, TextComponent } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { 4 | // getPropertyTypeSettings, 5 | PropertyWidgetComponent, 6 | } from "../utils"; 7 | import { tryCatch } from "~/lib/utils"; 8 | 9 | /** 10 | * TODO 11 | * Current implementation is wrong. Instead should behave like the "Frontmatter Title" plugin where it *displays* the value of this property, rather than actually renaming the file. 12 | * 13 | * Might not even include this type due to the other plugin existing 14 | */ 15 | 16 | export const renderWidget: CustomPropertyType["renderWidget"] = ({ 17 | plugin, 18 | el, 19 | ctx, 20 | value, 21 | }) => { 22 | // const settings = getPropertyTypeSettings({ 23 | // plugin, 24 | // property: ctx.key, 25 | // type: "toggle", 26 | // }); 27 | 28 | const container = el.createDiv({ 29 | cls: "better-properties-property-value-inner better-properties-mod-title", 30 | }); 31 | 32 | const file = plugin.app.vault.getFileByPath(ctx.sourcePath); 33 | if (!file) { 34 | throw new Error("File not found by ctx.sourcePath of: " + ctx.sourcePath); 35 | } 36 | 37 | const title = file.basename; 38 | const text = new TextComponent(container).setValue(title); 39 | 40 | const doRename = async () => { 41 | const newPath = 42 | (file.parent?.path ?? "") + "/" + text.getValue() + "." + file.extension; 43 | const { success, error } = await tryCatch( 44 | plugin.app.fileManager.renameFile(file, newPath) 45 | ); 46 | if (success) return; 47 | text.setValue(title); 48 | displayTooltip(text.inputEl, error, { 49 | placement: "bottom", 50 | classes: ["mod-error"], 51 | }); 52 | }; 53 | 54 | if (!value || title !== value) { 55 | // property is rendered with no value 56 | // so it's likely rendered for the first time 57 | 58 | // 0 timeout because without it, it will usually just remove all properties 59 | window.setTimeout(() => { 60 | ctx.onChange(title); 61 | }, 0); 62 | } 63 | 64 | text.inputEl.addEventListener("blur", doRename); 65 | text.inputEl.addEventListener("keydown", async (e) => { 66 | if (e.key === "Enter") { 67 | await doRename(); 68 | } 69 | if (e.key === "Escape") { 70 | text.setValue(title); 71 | } 72 | }); 73 | 74 | const cmp = new PropertyWidgetComponent( 75 | "title", 76 | container, 77 | (v) => { 78 | text.setValue(v?.toString() ?? ""); 79 | doRename(); 80 | }, 81 | () => { 82 | text.inputEl.focus(); 83 | } 84 | ); 85 | return cmp; 86 | }; 87 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/schema.ts: -------------------------------------------------------------------------------- 1 | import { NonNullishObject } from "~/lib/utils"; 2 | import { PropertySettings, PropertyTypeSchema } from "./types"; 3 | import * as v from "valibot"; 4 | import { selectSettingsSchema } from "./Select"; 5 | import { toggleSettingsSchema } from "./Toggle"; 6 | import { titleSettingsSchema } from "./Title"; 7 | import { markdownSettingsSchema } from "./Markdown"; 8 | import { createdSettingsSchema } from "./Created"; 9 | import { objectSettingsSchema } from "./Object"; 10 | import { colorSettingsSchema } from "./Color"; 11 | import { ratingSettingsSchema } from "./Rating"; 12 | import { dateCustomSettingsSchema } from "./DateCustom"; 13 | import { sliderSettingsSchema } from "./Slider"; 14 | import { timeSettingsSchema } from "./Time"; 15 | import { multiSelectSettingsSchema } from "./MultiSelect"; 16 | import { numericSettingsSchema } from "./Numeric"; 17 | import { arraySettingsSchema } from "./Array"; 18 | import { relationSettingsSchema } from "./Relation"; 19 | 20 | type SettingsBase = v.ObjectSchema< 21 | Record, 22 | undefined 23 | >; 24 | 25 | // NOTE type key names must be all lowercase, otherwise they will fail to register 26 | export const propertySettingsSchema = v.object({ 27 | general: v.optional( 28 | v.object({ 29 | icon: v.optional(v.string(), ""), 30 | hidden: v.optional(v.boolean(), false), 31 | defaultValue: v.optional(v.string()), 32 | // onloadScript: z.string(), 33 | alias: v.optional(v.string()), 34 | suggestions: v.optional(v.array(v.string())), 35 | collapsed: v.optional(v.boolean()), 36 | }) 37 | ), 38 | array: arraySettingsSchema, 39 | select: selectSettingsSchema, 40 | multiselect: multiSelectSettingsSchema, 41 | toggle: toggleSettingsSchema, 42 | title: titleSettingsSchema, 43 | markdown: markdownSettingsSchema, 44 | created: createdSettingsSchema, 45 | // modified: v.optional(v.object({})), 46 | object: objectSettingsSchema, 47 | color: colorSettingsSchema, 48 | rating: ratingSettingsSchema, 49 | relation: relationSettingsSchema, 50 | datecustom: dateCustomSettingsSchema, 51 | slider: sliderSettingsSchema, 52 | time: timeSettingsSchema, 53 | numeric: numericSettingsSchema, 54 | }) satisfies SettingsBase; 55 | 56 | export const getDefaultPropertySettings = 57 | (): NonNullishObject => { 58 | return Object.entries(propertySettingsSchema.entries).reduce( 59 | (acc, [k, schema]) => { 60 | const key = k as keyof typeof acc; 61 | // @ts-ignore TODO 62 | acc[key] = v.parse(schema, {}); 63 | return acc; 64 | }, 65 | {} as NonNullishObject 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /docs/public/bp-logo-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 41 | 55 | 56 | 63 | 67 | 74 | 77 | 81 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /docs/app/routes/getting-started/installation/article.mdx: -------------------------------------------------------------------------------- 1 | import { InfoButton } from "~/components/common/InfoButton"; 2 | 3 | ## Installation 4 | 5 | **Pre installation checklist** 6 | 7 | - ✅ Use consistent casing in property names 8 | - Most features within this plugin (and in Obsidian in general) will treat two property names to be the same if they only differ in the casing of their names, i.e. `myproperty` is equivalent to `MyProperty`. However, this is not 100% consistent across the app, so try to use consistent casing between uses of a property name. 9 | - ✅ **Don't** use a dot (`.`) in property names. 10 | - Better Properties and Obsidian itself (Bases for example) treat property names with dots in them as if they are a "nested property" (see [Object property type](/features/property-types#object)). Because of this, make sure your existing property names don't have a dot in them. 11 | - ✅ **Dont** use a hastag (`#`) as a sub-property name in object properties. 12 | - Better Properties uses the syntax `.#` to store the type and settings for the sub-properties of [Array type](/features/property-types#array) properties, so using a hashtag as a sub-property name in an object may cause unexpected behaviors. 13 | 14 | Ready to get started? Use one of the options below! 15 | 16 | If you enjoy this plugin, please consider giving it a [star on github](https://github.com/unxok/obsidian-better-properties) and/or [buying me a coffee](https://buymeacoffee.com/unxok)<3 17 | 18 | ### BRAT 19 | 20 | 1. Download the [Beta Reviewers Auto-update Tester (BRAT)](https://github.com/TfTHacker/obsidian42-brat) plugin from the [Obsidian community plugins directory](obsidian://show-plugin?id=obsidian42-brat) and enable it. 21 | 2. Choose one of the following: 22 | - automatic 23 | 1. Click [this link](obsidian://brat?plugin=unxok/obsidian-better-properties) then press the `Add plugin` button. 24 | - manual 25 | 1. Go to the BRAT plugin settings and select the `Add beta plugin` button. 26 | 2. Paste the following: https://github.com/unxok/obsidian-better-properties. 27 | 3. Click the `Add plugin` button. 28 | 29 | ### Manual 30 | 31 | 1. Open your vault's community plugins folder (default at `YOUR_VAULT/.obsidian/plugins`) and create a folder named `better-properties`. 32 | 2. Go to the Better Properties [releases page](https://github.com/unxok/obsidian-better-properties/releases) 33 | 3. Download `main.js`, `manifest.json`, and `styles.css`. Ensure they are placed in the folder you made in step one. 34 | 35 | ### Community plugins directory 36 | 37 | v1.0.0 has been submitted for review as of 2025-10-5 ([link](https://github.com/obsidianmd/obsidian-releases/pull/8049)). This page will be updated once it has been approved. 38 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/Slider/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 5 | import { typeKey } from "."; 6 | 7 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 8 | modal, 9 | plugin, 10 | property, 11 | }) => { 12 | const { tabContentEl } = modal; 13 | 14 | const settings = getPropertyTypeSettings({ 15 | plugin, 16 | property: property, 17 | type: typeKey, 18 | }); 19 | 20 | modal.onTabChange(() => { 21 | setPropertyTypeSettings({ 22 | plugin, 23 | property: property, 24 | type: typeKey, 25 | typeSettings: { ...settings }, 26 | }); 27 | }); 28 | 29 | new Setting(tabContentEl) 30 | .setName(text("customPropertyTypes.slider.settings.min.title")) 31 | .setDesc(text("customPropertyTypes.slider.settings.min.desc")) 32 | .addText((cmp) => { 33 | cmp.inputEl.setAttribute("type", "number"); 34 | cmp.inputEl.setAttribute("inputmode", "decimal"); 35 | cmp 36 | .setPlaceholder("0") 37 | .setValue(settings.min?.toString() ?? "") 38 | .onChange((v) => { 39 | if (!v) { 40 | settings.min = undefined; 41 | return; 42 | } 43 | settings.min = Number(v); 44 | }); 45 | }); 46 | 47 | new Setting(tabContentEl) 48 | .setName(text("customPropertyTypes.slider.settings.max.title")) 49 | .setDesc(text("customPropertyTypes.slider.settings.max.desc")) 50 | .addText((cmp) => { 51 | cmp.inputEl.setAttribute("type", "number"); 52 | cmp.inputEl.setAttribute("inputmode", "decimal"); 53 | cmp 54 | .setPlaceholder("100") 55 | .setValue(settings.max?.toString() ?? "") 56 | .onChange((v) => { 57 | if (!v) { 58 | settings.max = undefined; 59 | return; 60 | } 61 | settings.max = Number(v); 62 | }); 63 | }); 64 | 65 | new Setting(tabContentEl) 66 | .setName(text("customPropertyTypes.slider.settings.step.title")) 67 | .setDesc(text("customPropertyTypes.slider.settings.step.desc")) 68 | .addText((cmp) => { 69 | cmp.inputEl.setAttribute("type", "number"); 70 | cmp.inputEl.setAttribute("inputmode", "decimal"); 71 | cmp 72 | .setPlaceholder("1") 73 | .setValue(settings.step?.toString() ?? "") 74 | .onChange((v) => { 75 | if (!v) { 76 | settings.step = undefined; 77 | return; 78 | } 79 | settings.step = Number(v); 80 | }); 81 | }); 82 | 83 | new Setting(tabContentEl) 84 | .setName(text("customPropertyTypes.slider.settings.hideLimits.title")) 85 | .setDesc(text("customPropertyTypes.slider.settings.hideLimits.desc")) 86 | .addToggle((cmp) => { 87 | cmp.setValue(!!settings.hideLimits).onChange((b) => { 88 | settings.hideLimits = b; 89 | }); 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /src/classes/ColorTextComponent/index.ts: -------------------------------------------------------------------------------- 1 | import { ValueComponent, TextComponent, ColorComponent } from "obsidian"; 2 | import { obsidianText } from "~/i18next/obsidian"; 3 | 4 | export class ColorTextComponent extends ValueComponent { 5 | private value: string = ""; 6 | componentsContainerEl: HTMLElement; 7 | colorsContainerEl: HTMLElement; 8 | textComponent: TextComponent; 9 | colorComponent: ColorComponent; 10 | colorInputEl: HTMLDivElement; 11 | 12 | onChangeCallback: (value: string) => void = () => {}; 13 | 14 | constructor(containerEl: HTMLElement) { 15 | super(); 16 | 17 | this.componentsContainerEl = containerEl.createDiv({ 18 | cls: "better-properties-color-text-component-container", 19 | }); 20 | 21 | this.colorsContainerEl = this.componentsContainerEl.createDiv({ 22 | cls: "better-properties-color-text-component-colors-container", 23 | attr: { 24 | // tabindex: "-1", 25 | }, 26 | }); 27 | 28 | this.colorComponent = new ColorComponent(this.colorsContainerEl); 29 | this.colorComponent.colorPickerEl.setAttribute("aria-hidden", "true"); 30 | this.colorComponent.colorPickerEl.setAttribute("tabindex", "-1"); 31 | this.colorInputEl = this.colorsContainerEl.createDiv({ 32 | cls: "better-properties-swatch better-properties-color-text-component-color-input", 33 | attr: { 34 | tabindex: "0", 35 | role: "button", 36 | }, 37 | }); 38 | this.colorComponent.onChange((v) => { 39 | this.setColorCssVar(v); 40 | this.textComponent.inputEl.value = v; 41 | this.value = v; 42 | this.onChanged(); 43 | }); 44 | 45 | this.colorInputEl.addEventListener("click", () => { 46 | this.colorComponent.colorPickerEl.showPicker(); 47 | }); 48 | 49 | this.textComponent = new TextComponent( 50 | this.componentsContainerEl 51 | ).setPlaceholder(obsidianText("properties.label-no-value")); 52 | this.textComponent.onChange((v) => { 53 | this.setColorCssVar(v); 54 | if (isHex(v)) { 55 | this.colorComponent.colorPickerEl.value = v; 56 | } 57 | this.value = v; 58 | this.onChanged(); 59 | }); 60 | } 61 | 62 | getValue(): string { 63 | return this.value; 64 | } 65 | 66 | setValue(value: string): this { 67 | this.value = value; 68 | this.setColorCssVar(value); 69 | this.textComponent.inputEl.value = value; 70 | if (isHex(value)) { 71 | this.colorComponent.colorPickerEl.value = value; 72 | } 73 | return this; 74 | } 75 | 76 | onChanged() { 77 | this.onChangeCallback(this.getValue()); 78 | } 79 | 80 | onChange(cb: (value: string) => void): this { 81 | this.onChangeCallback = cb; 82 | return this; 83 | } 84 | 85 | setColorCssVar(value: string) { 86 | this.colorsContainerEl.setCssProps({ "--better-properties-bg": value }); 87 | } 88 | } 89 | 90 | const isHex = (str: string) => { 91 | return str.startsWith("#") && str.length === 7; 92 | }; 93 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/MultiSelect/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from "obsidian"; 2 | import { typeKey } from "."; 3 | import { 4 | renderDynamicSettings, 5 | renderFilesFromFileJsSetings, 6 | renderFilesFromFolderSettings, 7 | renderFilesFromInlineJsSettings, 8 | renderFilesFromTagSettings, 9 | renderManualSettings, 10 | SelectOptionsType, 11 | } from "./utils"; 12 | import { CustomPropertyType } from "../types"; 13 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 14 | import { text } from "~/i18next"; 15 | 16 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 17 | modal, 18 | plugin, 19 | property, 20 | }) => { 21 | const { tabContentEl: parentEl } = modal; 22 | 23 | const settings = getPropertyTypeSettings({ 24 | plugin, 25 | property: property, 26 | type: typeKey, 27 | }); 28 | 29 | modal.onTabChange(() => { 30 | setPropertyTypeSettings({ 31 | plugin, 32 | property: property, 33 | type: typeKey, 34 | typeSettings: { ...settings }, 35 | }); 36 | }); 37 | 38 | new Setting(parentEl) 39 | .setName(text("customPropertyTypes.select.settings.optionsType.title")) 40 | .setDesc(text("customPropertyTypes.select.settings.optionsType.desc")) 41 | .addDropdown((dropdown) => { 42 | dropdown 43 | .addOptions({ 44 | manual: text( 45 | "customPropertyTypes.select.settings.optionsType.selectLabelManual" 46 | ), 47 | dynamic: text( 48 | "customPropertyTypes.select.settings.optionsType.selectLabelDynamic" 49 | ), 50 | } satisfies Record, string>) 51 | .setValue( 52 | settings?.optionsType ?? ("manual" satisfies SelectOptionsType) 53 | ) 54 | .onChange((v) => { 55 | const opt = v as SelectOptionsType; 56 | // optionsTypeSettings.showGroup(opt); 57 | settings.optionsType = opt; 58 | parentEl.empty(); 59 | renderSettings({ plugin, modal, property }); 60 | }); 61 | }); 62 | 63 | if (settings.optionsType === "manual") { 64 | renderManualSettings({ 65 | plugin, 66 | parentEl, 67 | settings, 68 | }); 69 | } 70 | 71 | if (settings.optionsType === "dynamic") { 72 | if (!settings.dynamicOptionsType) { 73 | settings.dynamicOptionsType = "filesInFolder"; 74 | } 75 | 76 | renderDynamicSettings({ 77 | plugin, 78 | parentEl, 79 | settings, 80 | modal, 81 | property, 82 | }); 83 | 84 | if (settings.dynamicOptionsType === "filesInFolder") { 85 | renderFilesFromFolderSettings({ 86 | plugin, 87 | parentEl, 88 | settings, 89 | }); 90 | return; 91 | } 92 | 93 | if (settings.dynamicOptionsType === "filesFromTag") { 94 | renderFilesFromTagSettings({ 95 | plugin, 96 | parentEl, 97 | settings, 98 | }); 99 | return; 100 | } 101 | 102 | if (settings.dynamicOptionsType === "filesFromInlineJs") { 103 | renderFilesFromInlineJsSettings({ plugin, parentEl, settings }); 104 | } 105 | 106 | if (settings.dynamicOptionsType === "filesFromJsFile") { 107 | renderFilesFromFileJsSetings({ plugin, parentEl, settings }); 108 | } 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /docs/app/routes/features/metadata-editor/article.mdx: -------------------------------------------------------------------------------- 1 | ## Metadata Editor 2 | 3 | The Metadata Editor refers to the "container" for the rendered properties of a note. This by itself is a built-in to Obsidian. 4 | 5 | Below, a CSS snippet (among others) has been added to add an orange border around the Metadata Editor. 6 | 7 | ![In-note Metadata Editor](/metadata-editor.png) 8 | 9 | Better Properties augments the Metadata Editor to add the features below. 10 | 11 | ### More button 12 | 13 | Opens the menu shown below. Right now there's only these two options, but I have some ideas for more. If you think of something, let me know by [opening a feature request](https://github.com/unxok/obsidian-better-properties/issues/new?template=feature_request.md). 14 | 15 | ![More button menu](/metadata-editor-more-button.png) 16 | 17 | #### Show hidden 18 | 19 | Toggles the visibility of properties marked as "hidden" in their general property settings. 20 | 21 | ![Show hidden demo](/metadata-editor-more-button-show-hidden.gif) 22 | 23 | #### Sort 24 | 25 | Sorts the note's properties by the chosen sorting option. 26 | 27 | ![Sort options](/metadata-editor-more-button-sort-options.png) 28 | 29 | ### Resizable label width 30 | 31 | Select and drag the border (which is default invisible) between the name and value of a property to resize the width of all property labels in the Metadata Editor. Double click it to reset it back to the default. 32 | 33 | ![Resizable label width demo](/metadata-editor-resizable-label.gif) 34 | 35 | This customized width is saved to the plugin settings and applied to all notes. 36 | 37 | ### Collapsible properties 38 | 39 | When hovering over the name of a property, a collapse indicator will show which can be used to hide the value of that property. 40 | 41 | ![Collapsible properties demo](/metadata-editor-collapsible-properties.gif) 42 | 43 | ### Property Menu 44 | 45 | When selecting the icon for a given property, the following menu will be shown. This by itself is built-in to Obsidian. 46 | 47 | ![Property Menu](/metadata-editor-property-menu.png) 48 | 49 | Better Properties augments this menu by adding or changing the following items. 50 | 51 | #### Property type 52 | 53 | This built-in item has it's submenu changed such that the types are listed in alphabetical order and types added by the plugin have their icon in your accent color. 54 | 55 | #### Settings 56 | 57 | This new item opens the settings for the given property. This can also be opened by using the command `Bett Properties: Open property settings`. 58 | 59 | Switching to a different tab or closing the modal will save and apply the changed settings. 60 | 61 | ![Property settings modal](/property-settings-modal.png) 62 | 63 | #### Rename 64 | 65 | This new item allows you to mass-update the property name in every note it's used in. The property settings of the previous name will be transferred to the new name. 66 | 67 | #### Icon 68 | 69 | This new item allows you to override the type icon to shown for this property in the Metadata Editor. 70 | 71 | #### Delete 72 | 73 | This new item allows you to delete the given property from all notes. A confirmation modal will show before doing the deletion, but be careful because the _deletion is irreversible_ 74 | -------------------------------------------------------------------------------- /src/classes/InputSuggest/PropertySuggest/index.ts: -------------------------------------------------------------------------------- 1 | import { FuzzyMatch, FuzzySuggestModal, setIcon } from "obsidian"; 2 | import { InputSuggest, Suggestion } from ".."; 3 | import { getPropertyTypeSettings } from "~/CustomPropertyTypes"; 4 | import BetterProperties from "~/main"; 5 | import { Icon } from "~/lib/types/icons"; 6 | 7 | type Value = { 8 | name: string; 9 | icon: string; 10 | }; 11 | 12 | export class PropertySuggest extends InputSuggest { 13 | constructor( 14 | public plugin: BetterProperties, 15 | textInputEl: HTMLDivElement | HTMLInputElement 16 | ) { 17 | super(plugin.app, textInputEl); 18 | } 19 | protected getSuggestions(query: string): Value[] { 20 | const properties: Value[] = Object.values( 21 | this.app.metadataTypeManager.properties 22 | ) 23 | .map(({ name, widget }) => { 24 | const icon = 25 | (getPropertyTypeSettings({ 26 | plugin: this.plugin, 27 | property: name, 28 | type: "general", 29 | }).icon || 30 | this.app.metadataTypeManager.getWidget(widget)?.icon) ?? 31 | ("lucide-file-question" satisfies Icon); 32 | return { 33 | name, 34 | icon, 35 | }; 36 | }) 37 | .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); 38 | if (!query) return properties; 39 | const lower = query.toLowerCase(); 40 | return properties.filter((property) => 41 | property.name.toLowerCase().includes(lower) 42 | ); 43 | } 44 | 45 | protected parseSuggestion({ name }: Value): Suggestion { 46 | return { 47 | title: name, 48 | icon: " ", 49 | }; 50 | } 51 | 52 | renderSuggestion(value: Value, el: HTMLElement): void { 53 | super.renderSuggestion.call(this, value, el); 54 | 55 | const iconEl = el.querySelector(".suggestion-flair"); 56 | if (!(iconEl instanceof HTMLElement)) return; 57 | setIcon(iconEl, value.icon); 58 | } 59 | } 60 | 61 | export class PropertySuggestModal extends FuzzySuggestModal { 62 | constructor(public plugin: BetterProperties) { 63 | super(plugin.app); 64 | } 65 | 66 | getItems(): Value[] { 67 | return Object.values(this.app.metadataTypeManager.properties) 68 | .map(({ name, widget }) => { 69 | const icon = 70 | (getPropertyTypeSettings({ 71 | plugin: this.plugin, 72 | property: name, 73 | type: "general", 74 | }).icon || 75 | this.app.metadataTypeManager.getWidget(widget)?.icon) ?? 76 | ("lucide-file-question" satisfies Icon); 77 | return { 78 | name, 79 | icon, 80 | }; 81 | }) 82 | .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); 83 | } 84 | 85 | getItemText(item: Value): string { 86 | return item.name; 87 | } 88 | 89 | onChooseItem(_item: Value, _evt: MouseEvent | KeyboardEvent): void { 90 | throw new Error("Method not implemented"); 91 | } 92 | 93 | renderSuggestion(item: FuzzyMatch, el: HTMLElement): void { 94 | super.renderSuggestion.call(this, item, el); 95 | el.empty(); 96 | el.classList.add("mod-complex"); 97 | el.createDiv({ cls: "suggestion-title" }).createDiv({ 98 | cls: "suggestion-title", 99 | text: item.item.name, 100 | }); 101 | setIcon( 102 | el 103 | .createDiv({ cls: "suggestion-aux" }) 104 | .createSpan({ cls: "suggestion-flair" }), 105 | item.item.icon 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/MetadataEditor/propertyEditorMenu/rename.ts: -------------------------------------------------------------------------------- 1 | import { TextComponent, setIcon, ButtonComponent } from "obsidian"; 2 | import { ConfirmationModal } from "~/classes/ConfirmationModal"; 3 | import { text } from "~/i18next"; 4 | import { obsidianText } from "~/i18next/obsidian"; 5 | import { Icon } from "~/lib/types/icons"; 6 | import { renameProperty, findKey } from "~/lib/utils"; 7 | import BetterProperties from "~/main"; 8 | 9 | export const openRenameModal = ({ 10 | plugin, 11 | property, 12 | }: { 13 | plugin: BetterProperties; 14 | property: string; 15 | }) => { 16 | const modal = new ConfirmationModal(plugin.app); 17 | 18 | modal 19 | .setTitle(text("metadataEditor.propertyMenu.rename.modalTitle")) 20 | .setContent( 21 | createFragment((frag) => { 22 | frag.createEl("p", { 23 | text: text("metadataEditor.propertyMenu.rename.modalDesc", { 24 | property, 25 | }), 26 | }); 27 | const textCmp = new TextComponent(frag.createDiv()) 28 | .setValue(property) 29 | .setPlaceholder( 30 | text( 31 | "metadataEditor.propertyMenu.rename.newPropertyNamePlaceholder" 32 | ) 33 | ); 34 | // const warningEl = frag.createEl("p", { 35 | // cls: "better-properties-mod-warning", 36 | // }); 37 | const warningEl = createCallout({ 38 | parentEl: frag, 39 | type: "warning", 40 | icon: "lucide-alert-triangle", 41 | title: "Existing property name", 42 | desc: text("metadataEditor.propertyMenu.rename.nameExistsWarning", { 43 | property, 44 | }), 45 | }); 46 | let renameBtn: ButtonComponent | null = null; 47 | modal 48 | .addFooterButton((btn) => { 49 | renameBtn = btn 50 | .setButtonText(obsidianText("interface.menu.rename")) 51 | .setWarning() 52 | .onClick(async () => { 53 | await renameProperty({ 54 | plugin, 55 | property, 56 | newProperty: textCmp.getValue(), 57 | }); 58 | modal.close(); 59 | }); 60 | }) 61 | .addFooterButton((btn) => 62 | btn 63 | .setButtonText(obsidianText("dialogue.button-cancel")) 64 | .onClick(() => { 65 | modal.close(); 66 | }) 67 | ); 68 | 69 | textCmp.onChange((v) => { 70 | if (!renameBtn) return; 71 | if (v === property) { 72 | warningEl.classList.add("better-properties-mod-hidden"); 73 | renameBtn.setDisabled(true); 74 | return; 75 | } 76 | renameBtn.setDisabled(false); 77 | const existing = findKey( 78 | plugin.app.metadataTypeManager.properties, 79 | v 80 | ); 81 | if (!existing) { 82 | warningEl.classList.add("better-properties-mod-hidden"); 83 | return; 84 | } 85 | warningEl.classList.remove("better-properties-mod-hidden"); 86 | }); 87 | 88 | textCmp.onChanged(); 89 | }) 90 | ); 91 | 92 | modal.open(); 93 | }; 94 | 95 | const createCallout = ({ 96 | parentEl, 97 | type, 98 | icon, 99 | title, 100 | desc, 101 | }: { 102 | parentEl: HTMLElement | DocumentFragment; 103 | type: string; 104 | icon: Icon; 105 | title: string; 106 | desc: string; 107 | }) => { 108 | const calloutEl = parentEl.createDiv({ 109 | cls: "callout", 110 | attr: { 111 | "data-callout": type, 112 | }, 113 | }); 114 | const titleEl = calloutEl.createDiv({ 115 | cls: "callout-title", 116 | attr: { dir: "auto" }, 117 | }); 118 | setIcon(titleEl.createDiv({ cls: "callout-icon" }), icon); 119 | titleEl.createDiv({ cls: "callout-title-inner", text: title }); 120 | calloutEl 121 | .createDiv({ cls: "callout-content" }) 122 | .createEl("p", { text: desc, attr: { dir: "auto" } }); 123 | return calloutEl; 124 | }; 125 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/DateCustom/renderSettings.ts: -------------------------------------------------------------------------------- 1 | import { moment, Setting } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { text } from "~/i18next"; 4 | import { typeKey } from "."; 5 | import { getPropertyTypeSettings, setPropertyTypeSettings } from "../utils"; 6 | import { IconSuggest } from "~/classes/InputSuggest/IconSuggest"; 7 | import { obsidianText } from "~/i18next/obsidian"; 8 | 9 | export const renderSettings: CustomPropertyType["renderSettings"] = ({ 10 | modal, 11 | plugin, 12 | property, 13 | }) => { 14 | const { tabContentEl } = modal; 15 | 16 | const settings = getPropertyTypeSettings({ 17 | plugin, 18 | property: property, 19 | type: typeKey, 20 | }); 21 | 22 | modal.onTabChange(() => { 23 | setPropertyTypeSettings({ 24 | plugin, 25 | property: property, 26 | type: typeKey, 27 | typeSettings: { ...settings }, 28 | }); 29 | }); 30 | 31 | new Setting(tabContentEl) 32 | .setName(text("customPropertyTypes.datecustom.settings.type.title")) 33 | .setDesc(text("customPropertyTypes.datecustom.settings.type.desc")) 34 | .addDropdown((cmp) => { 35 | cmp 36 | .addOptions({ 37 | "date": text("datetime.date"), 38 | "datetime-local": text("datetime.dateAndTime"), 39 | } satisfies Record, string>) 40 | .setValue(settings.type ?? "date") 41 | .onChange((v) => { 42 | settings.type = v as NonNullable<(typeof settings)["type"]>; 43 | }); 44 | }); 45 | 46 | let renderFormatPreview = () => {}; 47 | new Setting(tabContentEl) 48 | .setName(text("customPropertyTypes.datecustom.settings.format.title")) 49 | .setDesc( 50 | createFragment((el) => { 51 | el.appendText( 52 | text("customPropertyTypes.datecustom.settings.format.desc") 53 | ); 54 | el.appendText(" "); 55 | el.createEl("a", { 56 | text: obsidianText("plugins.daily-notes.label-syntax-link"), 57 | href: "https://momentjs.com/docs/#/displaying/format/", 58 | attr: { 59 | target: "_blank", 60 | rel: "noopener", 61 | }, 62 | }); 63 | el.createEl("br"); 64 | el.appendText( 65 | obsidianText("plugins.daily-notes.label-syntax-live-preview") 66 | ); 67 | const formatPreviewEl = el.createEl("b", { 68 | cls: "u-pop", 69 | }); 70 | renderFormatPreview = () => { 71 | const formatted = moment().format(settings.format ?? "YYYY-MM-DD"); 72 | formatPreviewEl.textContent = formatted; 73 | }; 74 | renderFormatPreview(); 75 | }) 76 | ) 77 | .addText((cmp) => { 78 | cmp 79 | .setPlaceholder("YYYY-MM-DD") 80 | .setValue(settings.format ?? "") 81 | .onChange((v) => { 82 | settings.format = v || undefined; 83 | renderFormatPreview(); 84 | }); 85 | }); 86 | 87 | new Setting(tabContentEl) 88 | .setName(text("customPropertyTypes.datecustom.settings.placeholder.title")) 89 | .setDesc(text("customPropertyTypes.datecustom.settings.placeholder.desc")) 90 | .addText((cmp) => { 91 | cmp 92 | .setPlaceholder(obsidianText("interface.empty-state.empty")) 93 | .setValue(settings.placeholder ?? "") 94 | .onChange((v) => { 95 | settings.placeholder = v || undefined; 96 | }); 97 | }); 98 | 99 | new Setting(tabContentEl) 100 | .setName(text("customPropertyTypes.datecustom.settings.icon.title")) 101 | .setDesc(text("customPropertyTypes.datecustom.settings.icon.desc")) 102 | .addSearch((cmp) => { 103 | cmp.setValue(settings.icon ?? "").onChange((v) => { 104 | settings.icon = v || undefined; 105 | }); 106 | new IconSuggest(plugin.app, cmp.inputEl).onSelect((v) => { 107 | cmp.setValue(v); 108 | cmp.onChanged(); 109 | }); 110 | }); 111 | }; 112 | -------------------------------------------------------------------------------- /docs/app/components/common/TocSidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | type Dispatch, 4 | type SetStateAction, 5 | useContext, 6 | type ReactNode, 7 | useState, 8 | } from "react"; 9 | import { Link } from "react-router"; 10 | import type { TocItem } from "rehype-mdx-toc"; 11 | import { 12 | Sidebar, 13 | SidebarHeader, 14 | SidebarContent, 15 | SidebarGroup, 16 | SidebarGroupLabel, 17 | SidebarGroupContent, 18 | SidebarMenu, 19 | SidebarRail, 20 | SidebarMenuItem, 21 | SidebarMenuButton, 22 | SidebarMenuSub, 23 | SidebarFooter, 24 | } from "~/components/ui/sidebar"; 25 | import { Button } from "~/components/ui/button"; 26 | 27 | const TocContext = createContext<{ 28 | toc: TocItem[]; 29 | setToc: Dispatch>; 30 | }>({ 31 | toc: [], 32 | setToc: () => {}, 33 | }); 34 | 35 | export const useToc = () => { 36 | const ctx = useContext(TocContext); 37 | if (ctx === undefined) { 38 | throw new Error("useToc() must be used within a "); 39 | } 40 | return ctx; 41 | }; 42 | 43 | export const TocProvider = ({ children }: { children: ReactNode }) => { 44 | const [toc, setToc] = useState([]); 45 | return ( 46 | 47 | {children} 48 | 49 | ); 50 | }; 51 | 52 | export const RightSidebar = () => { 53 | const { toc } = useToc(); 54 | 55 | return ( 56 | 57 | {/*
*/} 58 | 59 | 60 | On this page 61 | 62 | 63 | {nestTocByDepth(toc ?? [])?.[0]?.children?.map((item) => 64 | renderNestedMenu(item), 65 | )} 66 | 67 | 68 | 69 | 70 | 71 |
72 |
73 | 74 | I have spent a lot of my free time and effort to work on this 75 | plugin, and because it will{" "} 76 | never have any paid features, 77 | coffee is a great way to show your support :) 78 | 79 | 80 |
81 |
82 | 83 | 84 | ); 85 | }; 86 | 87 | const renderNestedMenu = (item: TreeNode) => ( 88 | 89 | 90 | 95 | {item.value} 96 | 97 | 98 | {item.children.length ? ( 99 | 100 | {item.children.map((subItem) => renderNestedMenu(subItem))} 101 | 102 | ) : undefined} 103 | 104 | ); 105 | 106 | type TreeNode = TocItem & { children: TreeNode[] }; 107 | 108 | const nestTocByDepth = (toc: readonly TocItem[]): TreeNode[] => { 109 | const roots: TreeNode[] = []; 110 | const stack: TreeNode[] = []; 111 | 112 | for (const item of toc) { 113 | const { depth } = item; 114 | const node: TreeNode = { ...item, children: [] }; 115 | 116 | stack.length = Math.min(stack.length, depth - 1); 117 | 118 | if (stack.length === 0) { 119 | roots.push(node); 120 | } else { 121 | stack[stack.length - 1].children.push(node); 122 | } 123 | 124 | stack[depth - 1] = node; 125 | } 126 | 127 | return roots; 128 | }; 129 | -------------------------------------------------------------------------------- /src/CustomPropertyTypes/DateCustom/renderWidget.ts: -------------------------------------------------------------------------------- 1 | import { moment, setIcon } from "obsidian"; 2 | import { CustomPropertyType } from "../types"; 3 | import { PropertyWidgetComponentNew } from "../utils"; 4 | import { obsidianText } from "~/i18next/obsidian"; 5 | import { PropertyRenderContext } from "obsidian-typings"; 6 | import BetterProperties from "~/main"; 7 | 8 | export const renderWidget: CustomPropertyType["renderWidget"] = ({ 9 | plugin, 10 | el, 11 | ctx, 12 | value, 13 | }) => { 14 | return new DateTypeComponent(plugin, el, value, ctx); 15 | }; 16 | 17 | class DateTypeComponent extends PropertyWidgetComponentNew< 18 | "datecustom", 19 | string 20 | > { 21 | type = "datecustom" as const; 22 | parseValue = (v: unknown) => v?.toString() ?? ""; 23 | 24 | inputEl: HTMLInputElement | undefined; 25 | formatEl: HTMLDivElement | undefined; 26 | value: string; 27 | rawFormat: string = ""; 28 | 29 | constructor( 30 | plugin: BetterProperties, 31 | el: HTMLElement, 32 | value: unknown, 33 | ctx: PropertyRenderContext 34 | ) { 35 | super(plugin, el, value, ctx); 36 | 37 | this.value = this.parseValue(value); 38 | this.render(); 39 | 40 | this.onFocus = () => { 41 | this.inputEl?.focus(); 42 | }; 43 | } 44 | 45 | render(): void { 46 | this.containerEl.empty(); 47 | const settings = this.getSettings(); 48 | const isEmptyAttr = "data-better-properties-is-empty"; 49 | 50 | const format = settings.format ?? "YYYY-MM-DD"; 51 | const placeholder = 52 | settings.placeholder ?? obsidianText("interface.empty-state.empty"); 53 | const icon = settings.icon ?? "lucide-calendar"; 54 | const inputType = settings.type ?? "date"; 55 | this.rawFormat = inputType === "date" ? "YYYY-MM-DD" : "YYYY-MM-DDTHH:mm"; 56 | const max = inputType === "date" ? "9999-12-31" : "9999-12-31T23:59"; 57 | 58 | const parsed = !this.value 59 | ? undefined 60 | : moment(this.parseValue(this.value)); 61 | 62 | const buttonContainer = this.containerEl.createDiv({ 63 | cls: "better-properties-datecustom-button-container", 64 | }); 65 | 66 | this.inputEl = buttonContainer.createEl("input", { 67 | type: inputType, 68 | attr: { 69 | "max": max, 70 | "placeholder": obsidianText("interface.empty-state.empty"), 71 | "aria-hidden": "true", 72 | }, 73 | }); 74 | 75 | const buttonEl = buttonContainer.createDiv({ 76 | cls: "better-properties-datecustom-button clickable-icon", 77 | attr: { 78 | role: "button", 79 | tabindex: "0", 80 | }, 81 | }); 82 | setIcon(buttonEl, icon); 83 | buttonEl.addEventListener("click", () => { 84 | this.inputEl?.showPicker(); 85 | }); 86 | buttonEl.addEventListener("keydown", (e) => { 87 | if (e.key !== " ") return; 88 | this.inputEl?.showPicker(); 89 | }); 90 | 91 | this.formatEl = this.containerEl.createDiv({ 92 | cls: "better-properties-datecustom-format metadata-input-longtext", 93 | text: parsed?.format(format) ?? placeholder, 94 | attr: { 95 | [isEmptyAttr]: !this.value || null, 96 | }, 97 | }); 98 | 99 | this.inputEl.addEventListener("change", (e) => { 100 | const v = (e.target as HTMLInputElement).value; 101 | if (!v && this.formatEl) { 102 | this.formatEl.textContent = placeholder; 103 | this.formatEl.setAttribute(isEmptyAttr, "true"); 104 | this.setValue(null); 105 | return; 106 | } 107 | const date = moment(v?.toString()); 108 | if (date.isValid() && this.formatEl) { 109 | this.formatEl.removeAttribute(isEmptyAttr); 110 | this.formatEl.textContent = date.format(format); 111 | } 112 | this.setValue(date.format(this.rawFormat)); 113 | }); 114 | } 115 | 116 | getValue(): string { 117 | return this.inputEl?.value ?? ""; 118 | } 119 | 120 | setValue(value: unknown): void { 121 | super.setValue(value); 122 | 123 | const dateStr = moment(this.parseValue(value)).format(this.rawFormat); 124 | 125 | if (this.inputEl && this.inputEl?.value !== dateStr) { 126 | this.render(); 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Plugin/settings.ts: -------------------------------------------------------------------------------- 1 | import { Prettify } from "~/lib/utils"; 2 | import * as v from "valibot"; 3 | import { propertySettingsSchema } from "~/CustomPropertyTypes"; 4 | import { PluginSettingTab, Setting } from "obsidian"; 5 | import { BetterProperties } from "./plugin"; 6 | import { Icon } from "~/lib/types/icons"; 7 | import { obsidianText } from "~/i18next/obsidian"; 8 | import { MultiselectComponent } from "~/classes/MultiSelect"; 9 | import { PropertyTypeSuggest } from "~/classes/InputSuggest/PropertyTypeSuggest"; 10 | import { sortAndFilterRegisteredTypeWidgets } from "~/CustomPropertyTypes/register"; 11 | 12 | export const betterPropertiesSettingsSchema = v.object({ 13 | propertySettings: v.optional( 14 | v.record(v.string(), propertySettingsSchema), 15 | {} 16 | ), 17 | confirmPropertySettingsReset: v.optional(v.boolean(), true), 18 | confirmPropertyDelete: v.optional(v.boolean(), true), 19 | propertyLabelWidth: v.optional(v.number(), undefined), 20 | defaultLabelWidth: v.optional(v.string(), "9em"), // not UI facing 21 | hiddenPropertyTypes: v.optional(v.array(v.string()), [] satisfies string[]), 22 | }); 23 | 24 | export type BetterPropertiesSettings = Prettify< 25 | v.InferOutput 26 | >; 27 | 28 | export const getDefaultSettings = (): BetterPropertiesSettings => 29 | v.getDefaults(betterPropertiesSettingsSchema); 30 | 31 | export class BetterPropertiesSettingsTab extends PluginSettingTab { 32 | constructor(public plugin: BetterProperties) { 33 | super(plugin.app, plugin); 34 | } 35 | 36 | display(): void { 37 | const { plugin, containerEl } = this; 38 | containerEl.empty(); 39 | const { settings } = plugin; 40 | 41 | new Setting(containerEl) 42 | .setName("Property label width") 43 | .setDesc("The width of the label for each frontmatter property.") 44 | .addExtraButton((cmp) => { 45 | cmp 46 | .setIcon("lucide-rotate-ccw" satisfies Icon) 47 | .setTooltip( 48 | obsidianText("interface.tooltip-restore-default-settings") 49 | ) 50 | .onClick(() => { 51 | settings.propertyLabelWidth = undefined; 52 | plugin.app.workspace.trigger( 53 | "better-properties:property-label-width-change", 54 | undefined 55 | ); 56 | this.display(); 57 | }); 58 | }) 59 | .addSlider((cmp) => { 60 | cmp 61 | .setDynamicTooltip() 62 | .setLimits(0, 500, 1) 63 | .setValue(settings.propertyLabelWidth ?? 0) 64 | .onChange((n) => { 65 | settings.propertyLabelWidth = n; 66 | plugin.app.workspace.trigger( 67 | "better-properties:property-label-width-change", 68 | n 69 | ); 70 | }); 71 | }); 72 | 73 | new Setting(containerEl) 74 | .setName("Hidden property types") 75 | .setDesc("The following types will be disabled.") 76 | .then((s) => { 77 | const cmp = new MultiselectComponent(s); 78 | cmp 79 | .setValues(settings.hiddenPropertyTypes ?? []) 80 | .onChange((arr) => { 81 | settings.hiddenPropertyTypes = [...arr]; 82 | }) 83 | .addSuggest((inputEl) => { 84 | return new PropertyTypeSuggest(plugin.app, inputEl) 85 | .setFilter((item) => !cmp.values.contains(item.type)) 86 | .onSelect((t, e) => { 87 | e.preventDefault(); 88 | e.stopImmediatePropagation(); 89 | inputEl.textContent = t.type; 90 | cmp.inputEl.blur(); 91 | cmp.inputEl.focus(); 92 | }); 93 | }) 94 | .renderValues(); 95 | }); 96 | 97 | new Setting(containerEl).setHeading().setName("Warnings"); 98 | 99 | new Setting(containerEl) 100 | .setName("Confirm reset property settings") 101 | .setDesc("Prompt before resetting a property's settings.") 102 | .addToggle((cmp) => { 103 | cmp 104 | .setValue(settings.confirmPropertySettingsReset ?? true) 105 | .onChange((b) => { 106 | settings.confirmPropertySettingsReset = b; 107 | }); 108 | }); 109 | 110 | new Setting(containerEl) 111 | .setName("Confirm delete property") 112 | .setDesc("Prompt before deleting a property from all notes.") 113 | .addToggle((cmp) => { 114 | cmp.setValue(settings.confirmPropertyDelete ?? true).onChange((b) => { 115 | settings.confirmPropertyDelete = b; 116 | }); 117 | }); 118 | } 119 | 120 | hide(): void { 121 | super.hide.call(this); 122 | // TODO settings won't save it app is closed while the tab is still displayed. Might be better to just do a debounce to save after every change 123 | this.plugin.saveSettings(); 124 | 125 | const { plugin } = this; 126 | sortAndFilterRegisteredTypeWidgets(plugin); 127 | plugin.refreshPropertyEditors(); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /docs/app/routes/features/property-types/article.mdx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertTitle, AlertDescription } from "~/components/ui/alert"; 2 | import { CircleAlert } from "lucide-react"; 3 | 4 | ## Property types 5 | 6 | Better Properties add numerous additional property types. If you have an idea for a new type, let me know by [opening a feature request](https://github.com/unxok/obsidian-better-properties/issues/new?template=feature_request.md). 7 | 8 | _Note:_ as of now, specific built-in property types are left untouched. Generally, I would prefer to keep it that way. But, this could change if there's enough of a reason to justify doing it. 9 | 10 | ![Property types](/property-menu-showcase.png) 11 | 12 | ### Array 13 | 14 | A list of values where each "sub-property" share the same type and settings. Each sub-property is rendered as if it's a property named `.#` (i.e. `favorite quotes.#`) but without the property name being shown. 15 | 16 | ![Array example](/type-array-example.png) 17 | 18 | ```yaml 19 | --- 20 | favorite quotes: 21 | - Goodbye everyone, I'll remember you all in therapy. 22 | - No Patrick, mayonaise is not an instrument. 23 | --- 24 | ``` 25 | 26 | ### Color 27 | 28 | A color input combined with a text input. Using the color picker will choose a color as a hex value. Using the text input will allow any valid [CSS color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). 29 | 30 | ![Color example](/type-color-example.png) 31 | 32 | ```yaml 33 | --- 34 | eye color: "#0b4660" 35 | --- 36 | ``` 37 | 38 | ### Date custom 39 | 40 | A date input where the displayed date can be set to a custom format. 41 | 42 | The actual value written to the note's fronmatter will be `YYYY-MM-DD` or `YYYY-MM-DD[T]HH:MM:SS`. 43 | 44 | ![Date custom example](/type-datecustom-example.png) 45 | 46 | ```yaml 47 | --- 48 | start date: 2025-10-15 49 | --- 50 | ``` 51 | 52 | ### Markdown 53 | 54 | A live-preview markdown editor. It is the same editor that is used in the body of notes. 55 | 56 | ![Markdown example](/type-markdown-example.png) 57 | 58 | ```yaml 59 | --- 60 | pangram: A *quick* brown fox **jumps** over the ==lazy== dog 61 | --- 62 | ``` 63 | 64 | ### Multi-Select 65 | 66 | Similar to the built-in [list type](https://help.obsidian.md/properties#List), but it does not allow duplicates, has customizable dynamic or manually defined options, and more. 67 | 68 | ![Multi-Select example](/type-multiselect-example.png) 69 | 70 | ```yaml 71 | --- 72 | fruits: 73 | - bananas 74 | - grapes 75 | --- 76 | ``` 77 | 78 | ### Numeric 79 | 80 | An input which is evaulated as a mathematical expression and can be rounded to a configurable number of decimal places. The variable `x` can be used to represent the current value. 81 | 82 | ![Numeric example before](/type-numeric-example-before.png) 83 | ![Numeric example after](/type-numeric-example-after.png) 84 | 85 | ```yaml 86 | --- 87 | savings: "25807.82" 88 | --- 89 | ``` 90 | 91 | ### Object 92 | 93 | Renders an object of keys and values as sub-properties nested within the parent property. Each sub-property is rendered as if it's a property named `.`, i.e. `project.status`. 94 | 95 | ![Object example](/type-object-example.png) 96 | 97 | ```yaml 98 | --- 99 | project: 100 | status: in progress 101 | stakeholders: 102 | - Sandy 103 | - Patrick 104 | - Mr. Krabs 105 | --- 106 | ``` 107 | 108 | ### Rating 109 | 110 | A group of icons which act as an input where you select a value along a scale. The icon and count can be customized. 111 | 112 | ![Rating example](/type-rating-example.png) 113 | 114 | ```yaml 115 | --- 116 | quality: 3 117 | --- 118 | ``` 119 | 120 | ### Select 121 | 122 | A select input where dynamic or manually defined options are available to select for the value. Colors and labels for options can be customized. 123 | 124 | ![Select example](/type-select-example.png) 125 | 126 | ```yaml 127 | --- 128 | status: in progress 129 | --- 130 | ``` 131 | 132 | ### Slider 133 | 134 | A slider input. The lower and upper limits can be customized. 135 | 136 | ![Slider example](/type-slider-example.png) 137 | 138 | ```yaml 139 | --- 140 | current HP: 18 141 | --- 142 | ``` 143 | 144 | ### Time 145 | 146 | A time input. 147 | 148 | ![Time example](/type-time-example.png) 149 | 150 | ```yaml 151 | --- 152 | bedtime: 01:30 153 | --- 154 | ``` 155 | 156 | ### Toggle 157 | 158 | A toggle input. It has only two states (`true` or `false`), unlike the built-in [Checkbox type](https://help.obsidian.md/properties#Checkbox) which technically has three. 159 | 160 | ![Toggle example](/type-toggle-example.png) 161 | 162 | ```yaml 163 | --- 164 | reviewed: true 165 | --- 166 | ``` 167 | --------------------------------------------------------------------------------