Release v1.0.0 – initial plugin version

This commit is contained in:
marc-f 2025-04-11 18:51:10 +02:00
commit 60ca770a86
12 changed files with 825 additions and 0 deletions

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Compiled output
main.js
main.js.map
# TypeScript cache
*.tsbuildinfo
# Node.js dependencies
node_modules/
package-lock.json
# Build output (ZIPs and temporary folders)
build/
*.zip
# OS-specific files
.DS_Store
# PhpStorm project files
.idea/
# Optional: IDE-specific workspace files
*.iml
# Optional: Logs and misc
*.log

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Marc Feininger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

88
README.md Normal file
View file

@ -0,0 +1,88 @@
# FileName Styler
**FileName Styler** is a plugin for [Obsidian](https://obsidian.md) that allows you to hide, modify, and decorate parts of file names in the sidebar based on patterns or folder locations.
Originally created as simple “Zettelkasten ID Hider,” this plugin has evolved into a flexible tool for styling file names based on regex or timestamp-based IDs.
✨ **Special thanks to Simon for the original idea and inspiration behind this plugin.**
---
## Features
- Hide timestamp-based IDs (8-, 12-, or 14-digit) from file names
- Support for **custom regular expressions** with 3 capture groups
- Add custom **prefixes** and **suffixes** to matching file names
- Apply **custom colors** and **custom file icons** (e.g. 🧠 or 📖)
- Restrict styling to specific folders only
- Combine multiple styling **profiles** different styles for different folders or file types
- Automatically restore original file names and styles when unloading
---
## How it works
This plugin searches for matching file names based on selected ID formats:
- `yyyyMMddHHmmss` → 14-digit
- `yyMMddHHmmss` → 12-digit
- `yyyyMMdd` → 8-digit
- or a **custom regular expression**, such as:
```regex
^(\d{6})([-_ ]?)(.+)$
```
It then applies the configured styling to each match within the File Explorer (without changing the actual file name on disk).
---
## Settings
You can configure and combine **multiple profiles**. Each profile supports:
- **ID format** Choose from predefined timestamp formats or define your own regex
- **Move ID to end** Display the ID at the end of the file name instead of the start
- **Custom prefix** Text to prepend to the displayed name
- **Custom suffix** Text to append to the displayed name
- **Custom text color** Optional color override
- **Custom icon** Emoji or Unicode (e.g. `🧠`, `📖`)
- **Folder filter** Apply profile only in certain folders
All profiles can be toggled independently and are applied in order.
---
## Installation
1. Download or clone this repository.
2. Copy the following files into your Obsidian vault under:
```
.obsidian/plugins/filename-styler/
```
Required files:
- `main.js`
- `manifest.json`
- (optional) `styles.css`
3. Enable the plugin in Obsidian under
`Settings → Community Plugins`.
---
## Compatibility
Compatible with Obsidian v1.8.9 and higher.
Earlier versions might also work, but have not been tested.
---
## License
MIT License.
---
**Created with ❤️ by and for the Obsidian community.**

49
esbuild.config.mjs Normal file
View file

@ -0,0 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

29
eslint.config.js Normal file
View file

@ -0,0 +1,29 @@
import js from '@eslint/js';
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import globals from 'globals';
export default [
js.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node
}
},
plugins: {
'@typescript-eslint': ts
},
rules: {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { args: 'none' }],
'@typescript-eslint/ban-ts-comment': 'off',
'no-prototype-builtins': 'off',
'@typescript-eslint/no-empty-function': 'off'
}
}
];

535
main.ts Normal file
View file

@ -0,0 +1,535 @@
import { App, Plugin, PluginSettingTab, Setting, Modal } from "obsidian";
interface FileNameStylerProfileSettings {
idFormat: string;
customIdRegex?: string;
moveIdToEnd: boolean;
showCustomSuffix: boolean;
customSuffix: string;
customColor: string;
enableCustomColor: boolean;
onlyEnabledFolders: boolean;
enabledFolders: string[];
showCustomPrefix: boolean;
customPrefix: string;
showCustomFileIcon: boolean;
customFileIcon: string;
}
interface FileNameStylerGlobalSettings {
profiles: Record<string, Partial<FileNameStylerProfileSettings>>;
activeProfiles: string[];
}
const DEFAULT_PROFILE_SETTINGS: FileNameStylerProfileSettings = {
idFormat: "12",
moveIdToEnd: false,
showCustomSuffix: false,
customSuffix: " (z)",
customColor: "#4b0082",
enableCustomColor: false,
onlyEnabledFolders: false,
enabledFolders: [],
showCustomPrefix: false,
customPrefix: "[Z] ",
showCustomFileIcon: false,
customFileIcon: "🧠",
};
export default class FileNameStylerPlugin extends Plugin {
settings!: FileNameStylerGlobalSettings;
observer!: MutationObserver;
async onload() {
console.log("FileName Styler Plugin loaded");
await this.loadSettings();
this.addSettingTab(new FileNameStylerSettingTab(this.app, this));
this.observer = new MutationObserver((mutations) => {
if (mutations.some(m => m.target instanceof HTMLElement && m.target.closest(".nav-file-title"))) {
setTimeout(() => this.refreshAll(), 50);
}
});
this.refreshAll();
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
refreshAll() {
if (this.observer) this.observer.disconnect();
this.restoreOriginalFileTitles();
this.applyStylingToFileNames();
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
onunload() {
console.log("FileName Styler Plugin unloaded");
this.observer.disconnect();
this.restoreOriginalFileTitles();
}
async loadSettings() {
this.settings = Object.assign(
{ profiles: {}, activeProfiles: [] },
await this.loadData()
);
}
async saveSettings() {
await this.saveData(this.settings);
}
applyStylingToFileNames() {
const fileTitles = document.querySelectorAll(".nav-file-title-content");
fileTitles.forEach((el: Element) => {
const htmlEl = el as HTMLElement;
const parent = el.closest(".tree-item-self");
const path = parent?.getAttribute("data-path") || "";
const originalText = htmlEl.dataset.originalTitle || htmlEl.textContent || "";
for (const profileName of this.settings.activeProfiles) {
const profile = this.settings.profiles[profileName];
if (!profile) continue;
if (
profile.onlyEnabledFolders &&
!(profile.enabledFolders?.some(folder => path.startsWith(folder)))
) {
continue;
}
let regex: RegExp | null = null;
if (profile.idFormat === "custom" && profile.customIdRegex) {
try {
regex = new RegExp(profile.customIdRegex);
} catch (e) {
console.warn(`Invalid custom regex in profile '${profileName}':`, e);
continue;
}
} else if (profile.idFormat) {
regex = new RegExp(`^(\\d{${profile.idFormat}})([-_ ]?)(.+)`);
}
if (!regex) continue;
const match = originalText.match(regex);
if (!match) continue;
this.applyIdHiding(htmlEl, match, originalText, profile);
this.applyProfileStyling(htmlEl, profile);
break;
}
});
}
applyIdHiding(el: HTMLElement, match: RegExpMatchArray, originalText: string, profile: Partial<FileNameStylerProfileSettings>) {
if (el.dataset.originalTitle) return;
el.dataset.originalTitle = originalText;
const moveToEnd = profile.moveIdToEnd ?? false;
el.textContent = moveToEnd
? `${match[3]} ${match[1]}`
: match[3];
}
applyProfileStyling(el: HTMLElement, profile: Partial<FileNameStylerProfileSettings>) {
if (profile.showCustomPrefix && profile.customPrefix && !el.textContent?.startsWith(profile.customPrefix)) {
el.textContent = `${profile.customPrefix}${el.textContent}`;
}
if (profile.showCustomSuffix && profile.customSuffix && !el.textContent?.endsWith(profile.customSuffix)) {
el.textContent = `${el.textContent}${profile.customSuffix}`;
}
if (profile.enableCustomColor && profile.customColor) {
el.style.color = profile.customColor;
}
if (profile.showCustomFileIcon && profile.customFileIcon) {
el.style.setProperty("--file-icon", `"${profile.customFileIcon}"`);
}
}
restoreOriginalFileTitles() {
const fileTitles = document.querySelectorAll(".nav-file-title-content");
fileTitles.forEach((el: Element) => {
const htmlEl = el as HTMLElement;
if (htmlEl.dataset.originalTitle) {
htmlEl.textContent = htmlEl.dataset.originalTitle;
delete htmlEl.dataset.originalTitle;
}
htmlEl.style.color = "";
htmlEl.style.removeProperty("--file-icon");
});
}
}
class FileNameStylerSettingTab extends PluginSettingTab {
plugin: FileNameStylerPlugin;
newProfileName: string = "";
constructor(app: App, plugin: FileNameStylerPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
const profileHeading = containerEl.createDiv("setting-item setting-item-heading");
const profileInfo = profileHeading.createDiv("setting-item-info");
profileInfo.createDiv({cls: "setting-item-name", text: "Filename styling profiles"});
new Setting(containerEl)
.setName("New Profile")
.setDesc("Enter a profile name and add it")
.addText(text => {
text.setPlaceholder("e.g. Focus Mode")
.onChange(value => this.newProfileName = value);
})
.addExtraButton(button => {
button.setIcon("circle-plus")
.setTooltip("Add profile")
.onClick(async () => {
if (!this.newProfileName) return;
this.plugin.settings.profiles[this.newProfileName] = this.getCleanSettings();
await this.plugin.saveSettings();
this.newProfileName = "";
this.display();
});
});
Object.keys(this.plugin.settings.profiles).forEach(profileName => {
const isActive = this.plugin.settings.activeProfiles.includes(profileName);
new Setting(containerEl)
.setName(profileName)
.addToggle(toggle => toggle
.setValue(isActive)
.onChange(async (value) => {
const idx = this.plugin.settings.activeProfiles.indexOf(profileName);
if (value && idx === -1) {
this.plugin.settings.activeProfiles.push(profileName);
} else if (!value && idx > -1) {
this.plugin.settings.activeProfiles.splice(idx, 1);
}
await this.plugin.saveSettings();
this.plugin.refreshAll();
this.display();
}))
.addExtraButton(btn => {
btn.setIcon("settings")
.setTooltip("Show profile details")
.onClick(() => {
new ProfileModal(this.app, profileName, this.plugin.settings.profiles[profileName], this.plugin).open();
});
})
.addExtraButton(btn => {
btn.setIcon("x")
.setTooltip("Remove profile")
.onClick(async () => {
delete this.plugin.settings.profiles[profileName];
this.plugin.settings.activeProfiles = this.plugin.settings.activeProfiles.filter(p => p !== profileName);
await this.plugin.saveSettings();
this.display();
});
});
});
}
private getCleanSettings(): Partial<FileNameStylerProfileSettings> {
return { ...DEFAULT_PROFILE_SETTINGS };
}
}
class ProfileModal extends Modal {
constructor(
app: App,
private profileName: string,
private profileData: Partial<FileNameStylerProfileSettings>,
private plugin: FileNameStylerPlugin
) {
super(app);
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
this.modalEl.style.maxWidth = "70vw";
this.modalEl.style.width = "700px";
contentEl.createEl("h2", { text: `Profile: ${this.profileName}` });
const isActive = this.plugin.settings.activeProfiles.includes(this.profileName);
if (!isActive) {
contentEl.createEl("p", {
text: "This profile is currently inactive. Changes will take effect when it's active.",
cls: "mod-warning"
});
}
this.createSectionHeading(contentEl, "Filename ID format");
new Setting(contentEl)
.setName("ID Format")
.setDesc("Select the ID format used at the beginning of filenames.")
.addDropdown(drop => {
drop.addOption("14", "14-digit (yyyyMMddHHmmss)");
drop.addOption("12", "12-digit (yyMMddHHmmss)");
drop.addOption("8", "8-digit (yyyyMMdd)");
drop.addOption("custom", "Custom regex");
drop.setValue(this.profileData.idFormat ?? "12");
drop.onChange(async (value) => {
this.profileData.idFormat = value;
this.plugin.settings.profiles[this.profileName] = this.profileData;
await this.plugin.saveSettings();
this.plugin.refreshAll();
this.onOpen();
});
});
if (this.profileData.idFormat === "custom") {
new Setting(contentEl)
.setName("Custom ID Regex")
.setDesc(
"Enter a custom regular expression to extract the ID and filename\n" +
"The custom regex must contain exactly three capturing groups:\n" +
" (1) the ID\n" +
" (2) an optional separator (e.g. -, _, space)\n" +
" (3) the remaining file name\n" +
"Example: ^(\\d{6})([-_ ]?)(.+)$"
)
.addText(text => {
text.setPlaceholder("e.g. ^(\\d{6})([-_ ]?)(.+)$")
.setValue(this.profileData.customIdRegex ?? "")
.onChange(async (value) => {
this.profileData.customIdRegex = value;
this.plugin.settings.profiles[this.profileName] = this.profileData;
await this.plugin.saveSettings();
this.plugin.refreshAll();
});
});
}
this.createSectionHeading(contentEl, "ID position in filename");
this.addProfileToggleSetting(
contentEl,
"Move ID to end",
"Moves the ID to the end of the filename",
"moveIdToEnd"
);
this.createSectionHeading(contentEl, "Folder filter");
this.addProfileToggleSetting(
contentEl,
"Restrict to folders",
"Only apply formatting in specified folders",
"onlyEnabledFolders"
);
const folderList = contentEl.createDiv();
const renderFolderList = () => {
folderList.empty();
const folders = this.profileData.enabledFolders ?? [];
folders.forEach((folder, index) => {
new Setting(folderList)
.setName(folder)
.addExtraButton(button => {
button.setIcon("x")
.setTooltip("Remove folder")
.onClick(async () => {
folders.splice(index, 1);
this.profileData.enabledFolders = folders;
this.plugin.settings.profiles[this.profileName] = this.profileData;
await this.plugin.saveSettings();
this.plugin.refreshAll();
renderFolderList();
});
});
});
};
renderFolderList();
let newFolderPath = "";
new Setting(contentEl)
.setName("Add folder")
.setDesc("Add a folder prefix (e.g. 'projects/', 'notes/', or 'inbox'). Files in these folders will be styled.")
.addText(text => {
text.setPlaceholder("folder or prefix")
.onChange(value => {
newFolderPath = value;
});
})
.addButton(btn => {
btn.setButtonText("Add")
.setCta()
.onClick(async () => {
if (!newFolderPath) return;
const folders = this.profileData.enabledFolders ?? [];
if (!folders.includes(newFolderPath)) {
folders.push(newFolderPath);
this.profileData.enabledFolders = folders;
this.plugin.settings.profiles[this.profileName] = this.profileData;
await this.plugin.saveSettings();
this.plugin.refreshAll();
renderFolderList();
}
newFolderPath = "";
});
});
this.createSectionHeading(contentEl, "Text color styling");
this.addProfileToggleSetting(
contentEl,
"Enable custom text color",
"Turn on/off custom color for this profile",
"enableCustomColor"
);
new Setting(contentEl)
.setName("Custom text color")
.setDesc("Color used when color is enabled")
.addColorPicker(picker => {
picker.setValue(this.profileData.customColor || "#4b0082");
picker.onChange(async (value) => {
this.profileData.customColor = value;
this.plugin.settings.profiles[this.profileName] = this.profileData;
await this.plugin.saveSettings();
this.plugin.refreshAll();
});
});
this.createSectionHeading(contentEl, "Custom file icon options");
this.addProfileToggleSetting(
contentEl,
"Show custom file icon",
"Apply a custom file icon such as 🧠.",
"showCustomFileIcon"
);
this.addProfileTextSetting(
contentEl,
"Custom file icon",
"Enter an emoji (e.g. 🧠) or Unicode like \\1F4D6 for 📖.",
"customFileIcon",
);
this.createSectionHeading(contentEl, "Prefix styling");
this.addProfileToggleSetting(
contentEl,
"Show prefix styling",
"Add a prefix like '[Z]' at the start of file names.",
"showCustomPrefix"
);
this.addProfileTextSetting(
contentEl,
"Prefix",
"Text to prepend to file names, e.g. [Z]",
"customPrefix",
);
this.createSectionHeading(contentEl, "Suffix styling");
this.addProfileToggleSetting(
contentEl,
"Show suffix styling",
"Display a suffix like '(z)' at the end of file names.",
"showCustomSuffix"
);
this.addProfileTextSetting(
contentEl,
"Suffix",
"Text to append to file names, e.g. [Z]",
"customSuffix",
);
const closeBtn = contentEl.createEl("button", { text: "Close" });
closeBtn.addEventListener("click", () => this.close());
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
private createSectionHeading(container: HTMLElement, title: string, description?: string): void {
const heading = container.createDiv("setting-item setting-item-heading");
const info = heading.createDiv("setting-item-info");
info.createDiv({ cls: "setting-item-name", text: title });
if (description) {
info.createDiv({ cls: "setting-item-description", text: description });
}
}
private addProfileToggleSetting(
container: HTMLElement,
label: string,
description: string,
key: keyof FileNameStylerProfileSettings
): void {
new Setting(container)
.setName(label)
.setDesc(description)
.addToggle(toggle => {
toggle.setValue(this.profileData[key] as boolean ?? false);
toggle.onChange(async (value) => {
this.profileData[key] = value as any;
Object.assign(this.plugin.settings.profiles[this.profileName], this.profileData);
await this.plugin.saveSettings();
this.plugin.refreshAll();
});
});
}
private addProfileTextSetting(
container: HTMLElement,
label: string,
description: string,
key: keyof FileNameStylerProfileSettings
): void {
new Setting(container)
.setName(label)
.setDesc(description)
.addText(text => {
text.setPlaceholder("")
.setValue((this.profileData[key] as string) ?? DEFAULT_PROFILE_SETTINGS[key] as string)
.onChange(async (value) => {
this.profileData[key] = value as any;
Object.assign(this.plugin.settings.profiles[this.profileName], this.profileData);
await this.plugin.saveSettings();
this.plugin.refreshAll();
});
})
.addExtraButton(button => {
button.setIcon("reset")
.setTooltip("Reset to default")
.onClick(async () => {
this.profileData[key] = DEFAULT_PROFILE_SETTINGS[key] as any;
Object.assign(this.plugin.settings.profiles[this.profileName], this.profileData);
await this.plugin.saveSettings();
this.plugin.refreshAll();
this.onOpen();
});
});
}
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "filename-styler",
"name": "FileName Styler",
"version": "1.0.0",
"minAppVersion": "1.8.9",
"description": "Customize and style file names in the file explorer using patterns, prefixes, suffixes, colors, and icons.",
"author": "Marc Feininger",
"authorUrl": "https://github.com/marc-f/obsidian-file-name-styler",
"main": "main.js"
}

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "filename-styler",
"version": "1.0.0",
"type": "module",
"description": "An Obsidian plugin to style filenames based on ID patterns, colors, icons, and more.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint . --ext .ts"
},
"keywords": [
"obsidian",
"plugin",
"filename",
"styling",
"zettelkasten",
"customization"
],
"author": "Marc Feininger",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.24.0",
"@types/node": "^22.14.0",
"@typescript-eslint/eslint-plugin": "^8.29.1",
"@typescript-eslint/parser": "^8.29.1",
"builtin-modules": "^5.0.0",
"esbuild": "^0.25.2",
"eslint": "^9.24.0",
"globals": "^16.0.0",
"obsidian": "latest",
"typescript": "^5.3.3"
}
}

1
styles.css Normal file
View file

@ -0,0 +1 @@
/* Placeholder style sheet */

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node",
"outDir": ".",
"baseUrl": ".",
"types": ["obsidian", "node"]
},
"files": ["main.ts"]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "1.8.9"
}