Build tool

This commit is contained in:
2026-06-19 16:52:38 -04:00
parent 829e07ad19
commit c935b050ee
36 changed files with 1717 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
project.fragment.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
publish_release/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
#*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignoreable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
node_modules/
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
**/.DS_Store
**/.vs/
.DS_Store
publish_release/
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+304
View File
@@ -0,0 +1,304 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
const string DocsFolderName = "op.docs";
const string PluginSourceFolder = "OP_plugin";
const string PluginManifestFileName = "manifest.json";
try
{
var buildProjectDir = GetBuildProjectDirectory();
var workspaceRoot = Path.GetFullPath(Path.Combine(buildProjectDir, ".."));
var docsRoot = Path.GetFullPath(Path.Combine(workspaceRoot, "..", DocsFolderName));
var pluginSourceRoot = Path.GetFullPath(Path.Combine(workspaceRoot, "..", PluginSourceFolder));
Console.WriteLine($"Workspace root: {workspaceRoot}");
Console.WriteLine($"Docs root: {docsRoot}");
Console.WriteLine($"Plugin source: {pluginSourceRoot}");
EnsureDirectoryExists(docsRoot, "Docs folder not found. Make sure op.docs exists in the workspace root.");
EnsureDirectoryExists(pluginSourceRoot, "Plugin source folder not found. Make sure OP_plugin exists in the workspace root.");
var manifestPath = Path.Combine(pluginSourceRoot, PluginManifestFileName);
if (!File.Exists(manifestPath))
throw new InvalidOperationException($"Plugin manifest not found at {manifestPath}");
BuildPlugin(pluginSourceRoot);
var pluginId = ReadPluginId(manifestPath);
Console.WriteLine($"Detected plugin id: {pluginId}");
var userRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var docsTargets = ScanDocTargets(docsRoot);
if (!docsTargets.Any())
{
Console.WriteLine("No docs files with 'category: docs' were found.");
return;
}
var uniqueTargets = docsTargets.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
Console.WriteLine($"Found {uniqueTargets.Count} unique docs target(s):");
foreach (var target in uniqueTargets)
{
Console.WriteLine($" - {target}");
}
foreach (var targetPath in uniqueTargets)
{
var targetRoot = BuildAbsolutePathFromUserRoot(userRoot, targetPath);
var pluginsRoot = Path.Combine(targetRoot, ".obsidian", "plugins");
var pluginDestination = Path.Combine(pluginsRoot, pluginId);
Console.WriteLine();
Console.WriteLine($"Installing plugin to: {pluginDestination}");
if (Directory.Exists(pluginDestination))
{
Console.WriteLine("Existing plugin folder found. Deleting previous version...");
Directory.Delete(pluginDestination, recursive: true);
}
Directory.CreateDirectory(pluginsRoot);
CopyDirectory(pluginSourceRoot, pluginDestination, GetCopyExcludes());
Console.WriteLine("Plugin copied successfully.");
}
}
catch (Exception ex)
{
Console.Error.WriteLine("ERROR: " + ex.Message);
Environment.Exit(1);
}
static string GetBuildProjectDirectory()
{
var baseDir = AppContext.BaseDirectory;
// Current baseDir is Build/bin/Debug/net10.0/
// We want to go up to the project root (where Build/ and OP.sln are)
return Path.GetFullPath(Path.Combine(baseDir, "..", "..", ".."));
}
static void EnsureDirectoryExists(string path, string message)
{
if (!Directory.Exists(path))
throw new DirectoryNotFoundException(message);
}
static IReadOnlyCollection<string> ScanDocTargets(string docsRoot)
{
var docsTargetPaths = new List<string>();
var markdownFiles = Directory.EnumerateFiles(docsRoot, "*.md", SearchOption.AllDirectories);
foreach (var filePath in markdownFiles)
{
var content = File.ReadAllText(filePath);
var frontMatter = ExtractFrontMatter(content);
if (frontMatter is null)
continue;
if (!frontMatter.TryGetValue("category", out var categoryValue) ||
!string.Equals(categoryValue.Trim(), "docs", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!frontMatter.TryGetValue("path", out var pathValue) || string.IsNullOrWhiteSpace(pathValue))
{
Console.WriteLine($"WARNING: File '{filePath}' has category: docs but missing path.");
continue;
}
docsTargetPaths.Add(pathValue.Trim());
}
return docsTargetPaths;
}
static Dictionary<string, string>? ExtractFrontMatter(string content)
{
var match = Regex.Match(content, @"\A---\r?\n(.*?)\r?\n---", RegexOptions.Singleline);
if (!match.Success)
return null;
var frontMatter = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var body = match.Groups[1].Value;
foreach (var rawLine in body.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith("#"))
continue;
var separatorIndex = line.IndexOf(':');
if (separatorIndex < 0)
continue;
var key = line.Substring(0, separatorIndex).Trim();
var value = line.Substring(separatorIndex + 1).Trim().Trim('"').Trim();
frontMatter[key] = value;
}
return frontMatter;
}
static string BuildAbsolutePathFromUserRoot(string userRoot, string relativePath)
{
if (Path.IsPathRooted(relativePath))
{
return Path.GetFullPath(relativePath);
}
var segments = relativePath
.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries)
.Select(segment => segment.Trim())
.Where(segment => segment.Length > 0)
.ToArray();
if (segments.Length == 0)
throw new InvalidOperationException($"Invalid path value: '{relativePath}'");
return Path.GetFullPath(Path.Combine(new[] { userRoot }.Concat(segments).ToArray()));
}
static string ReadPluginId(string manifestPath)
{
using var stream = File.OpenRead(manifestPath);
using var doc = JsonDocument.Parse(stream);
if (!doc.RootElement.TryGetProperty("id", out var idElement))
throw new InvalidOperationException("Plugin manifest does not contain an 'id' property.");
var pluginId = idElement.GetString();
if (string.IsNullOrWhiteSpace(pluginId))
throw new InvalidOperationException("Plugin id is empty.");
return pluginId.Trim();
}
static void BuildPlugin(string pluginSourceRoot)
{
Console.WriteLine("Building plugin in OP_plugin...");
var npmExecutable = FindNpmExecutable();
if (npmExecutable is null)
{
throw new InvalidOperationException("Could not find npm on PATH. Install Node.js/npm or add npm to your PATH.");
}
var processStartInfo = new ProcessStartInfo(npmExecutable)
{
WorkingDirectory = pluginSourceRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
processStartInfo.FileName = "cmd";
processStartInfo.ArgumentList.Add("/c");
processStartInfo.ArgumentList.Add(npmExecutable);
processStartInfo.ArgumentList.Add("run");
processStartInfo.ArgumentList.Add("build");
using var process = Process.Start(processStartInfo) ?? throw new InvalidOperationException($"Failed to start 'cmd /c {npmExecutable}'.");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
Console.WriteLine(stdout);
if (!string.IsNullOrWhiteSpace(stderr))
Console.Error.WriteLine(stderr);
if (process.ExitCode != 0)
throw new InvalidOperationException($"Plugin build failed with exit code {process.ExitCode}.");
Console.WriteLine("Plugin build completed successfully.");
}
static string? FindNpmExecutable()
{
var candidates = new[] { "npm.cmd", "npm.exe", "npm" };
foreach (var candidate in candidates)
{
try
{
var processStartInfo = new ProcessStartInfo("cmd", $"/c {candidate} --version")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(processStartInfo);
if (process is null)
continue;
bool exited = process.WaitForExit(5000);
if (!exited)
{
process.Kill();
continue;
}
if (process.ExitCode == 0)
return candidate;
}
catch
{
continue;
}
}
return null;
}
static HashSet<string> GetCopyExcludes()
{
return new(StringComparer.OrdinalIgnoreCase)
{
"node_modules",
"src",
"obj",
"bin",
".git",
".idea",
".vscode",
".editorconfig",
".npmrc",
"package.json",
"package-lock.json",
"tsconfig.json",
"esbuild.config.mjs",
"eslint.config.mts",
"version-bump.mjs",
"versions.json",
"data.json",
"README.md",
"AGENTS.md",
"LICENSE"
};
}
static void CopyDirectory(string sourceDir, string destinationDir, HashSet<string> excludes)
{
Directory.CreateDirectory(destinationDir);
foreach (var filePath in Directory.GetFiles(sourceDir))
{
var fileName = Path.GetFileName(filePath);
if (excludes.Contains(fileName))
continue;
var destinationFile = Path.Combine(destinationDir, fileName);
File.Copy(filePath, destinationFile, overwrite: true);
}
foreach (var directoryPath in Directory.GetDirectories(sourceDir))
{
var dirName = Path.GetFileName(directoryPath);
if (excludes.Contains(dirName))
continue;
CopyDirectory(directoryPath, Path.Combine(destinationDir, dirName), excludes);
}
}
+16
View File
@@ -0,0 +1,16 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "Build\Build.csproj", "{57A2E434-08CD-47BF-9F3D-E1BF74C34EE7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{57A2E434-08CD-47BF-9F3D-E1BF74C34EE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{57A2E434-08CD-47BF-9F3D-E1BF74C34EE7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{57A2E434-08CD-47BF-9F3D-E1BF74C34EE7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{57A2E434-08CD-47BF-9F3D-E1BF74C34EE7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
View File
View File
+1
View File
@@ -0,0 +1 @@
{}
+3
View File
@@ -0,0 +1,3 @@
{
"theme": "obsidian"
}
+3
View File
@@ -0,0 +1,3 @@
[
"frontmatter-folder-organizer"
]
+33
View File
@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
@@ -0,0 +1,10 @@
{
"id": "frontmatter-folder-organizer",
"name": "Frontmatter Folder Organizer",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "Organize markdown files into folders based on frontmatter hierarchy.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"isDesktopOnly": false
}
@@ -0,0 +1,8 @@
/*
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/
+8
View File
@@ -0,0 +1,8 @@
{
"types": {
"aliases": "aliases",
"cssclasses": "multitext",
"tags": "tags",
"shouldInstallPlugins": "checkbox"
}
}
+194
View File
@@ -0,0 +1,194 @@
{
"main": {
"id": "eeb36d4e48f0ca25",
"type": "split",
"children": [
{
"id": "4fe61a13d63c2732",
"type": "tabs",
"children": [
{
"id": "362018eb264ab766",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "docs/op.docs.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "op.docs"
}
}
]
}
],
"direction": "vertical"
},
"left": {
"id": "b9bf87c6cad06498",
"type": "split",
"children": [
{
"id": "6685cdba50e4a0a7",
"type": "tabs",
"children": [
{
"id": "6ef95b00a28c418d",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
},
"icon": "lucide-folder-closed",
"title": "Files"
}
},
{
"id": "c5043b22e38e990c",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "Search"
}
},
{
"id": "b159963fcca61ebc",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "Bookmarks"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"right": {
"id": "c5e47ea76d041492",
"type": "split",
"children": [
{
"id": "897865c856161773",
"type": "tabs",
"children": [
{
"id": "379d0b312b9d30c9",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"file": "doc.zs.md",
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "Backlinks for doc.zs"
}
},
{
"id": "a8f559c72a82e929",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"file": "doc.zs.md",
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "Outgoing links from doc.zs"
}
},
{
"id": "e1552c44ca209983",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "Tags"
}
},
{
"id": "2d2defa7638fb5a3",
"type": "leaf",
"state": {
"type": "all-properties",
"state": {
"sortOrder": "frequency",
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-archive",
"title": "All properties"
}
},
{
"id": "82b06aefc5b6df13",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "doc.zs.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "Outline of doc.zs"
}
}
]
}
],
"direction": "horizontal",
"width": 300,
"collapsed": true
},
"left-ribbon": {
"hiddenItems": {
"switcher:Open quick switcher": false,
"graph:Open graph view": false,
"canvas:Create new canvas": false,
"daily-notes:Open today's daily note": false,
"templates:Insert template": false,
"command-palette:Open command palette": false,
"bases:Create new base": false
}
},
"active": "362018eb264ab766",
"lastOpenFiles": [
"docs/doc.zs.md",
"docs/op.docs.md",
"docs",
"os.docs.md",
"PLUGIN_SETTINGS_UI_GUIDE.md"
]
}
+604
View File
@@ -0,0 +1,604 @@
# Plugin Settings UI Guide
This guide explains how to structure and format plugin settings UI in Obsidian, with examples for building multiple plugins with distinct features.
## Table of Contents
1. [Core Concepts](#core-concepts)
2. [Setting Types](#setting-types)
3. [UI Organization Patterns](#ui-organization-patterns)
4. [Multi-Plugin Architecture](#multi-plugin-architecture)
5. [Plugin Examples](#plugin-examples)
## Core Concepts
### Settings Interface & Settings Tab
Every plugin has:
1. **Settings Interface** - TypeScript interface defining your data structure
2. **Settings Tab Class** - Extends `PluginSettingTab` and defines the UI
```typescript
// Define the data structure
export interface MyPluginSettings {
settingName: string;
settingValue: boolean;
}
// Default values
export const DEFAULT_SETTINGS: MyPluginSettings = {
settingName: 'default',
settingValue: false,
};
// UI definition
export class MySettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty(); // Clear previous UI
// Add UI elements here
}
}
```
### Container Structure
The `containerEl` is where you build your UI. Always start with `containerEl.empty()` to clear old content when the settings tab reopens.
```typescript
display(): void {
const { containerEl } = this;
containerEl.empty();
// Add heading
containerEl.createEl('h2', { text: 'Plugin Name' });
// Add settings
new Setting(containerEl)
.setName('Setting Name')
.setDesc('Description of what this does')
.addText((text) => { /* ... */ });
}
```
## Setting Types
### Text Input
Single-line text input:
```typescript
new Setting(containerEl)
.setName('Plugin Name')
.setDesc('The name of your plugin')
.addText((text) =>
text
.setPlaceholder('Enter name')
.setValue(this.plugin.settings.pluginName)
.onChange(async (value) => {
this.plugin.settings.pluginName = value;
await this.plugin.saveSettings();
})
);
```
### Text Area
Multi-line text input:
```typescript
new Setting(containerEl)
.setName('Configuration')
.setDesc('Multi-line configuration')
.addTextArea((text) =>
text
.setPlaceholder('Enter configuration')
.setValue(this.plugin.settings.config)
.onChange(async (value) => {
this.plugin.settings.config = value;
await this.plugin.saveSettings();
})
);
```
### Toggle
Boolean switch:
```typescript
new Setting(containerEl)
.setName('Enable Feature')
.setDesc('Turn this feature on or off')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.enabled)
.onChange(async (value) => {
this.plugin.settings.enabled = value;
await this.plugin.saveSettings();
})
);
```
### Dropdown
Select from predefined options:
```typescript
new Setting(containerEl)
.setName('Theme')
.setDesc('Choose a color theme')
.addDropdown((dropdown) =>
dropdown
.addOption('light', 'Light')
.addOption('dark', 'Dark')
.addOption('auto', 'Auto')
.setValue(this.plugin.settings.theme)
.onChange(async (value) => {
this.plugin.settings.theme = value;
await this.plugin.saveSettings();
})
);
```
### Slider
Numeric input with range:
```typescript
new Setting(containerEl)
.setName('Search Depth')
.setDesc('How deep to search in folders (1-10)')
.addSlider((slider) =>
slider
.setMin(1)
.setMax(10)
.setStep(1)
.setValue(this.plugin.settings.searchDepth)
.onChange(async (value) => {
this.plugin.settings.searchDepth = value;
await this.plugin.saveSettings();
})
);
```
### Button
Trigger an action:
```typescript
new Setting(containerEl)
.setName('Process Files')
.setDesc('Start processing all files')
.addButton((button) =>
button
.setButtonText('Process Now')
.onClick(async () => {
await this.plugin.processAllFiles();
new Notice('Processing complete!');
})
);
```
### Color Picker
Select a color:
```typescript
new Setting(containerEl)
.setName('Tag Color')
.setDesc('Color for tags')
.addColorPicker((color) =>
color
.setValue(this.plugin.settings.tagColor)
.onChange(async (value) => {
this.plugin.settings.tagColor = value;
await this.plugin.saveSettings();
})
);
```
## UI Organization Patterns
### Section Headers
Organize settings into logical sections:
```typescript
display(): void {
const { containerEl } = this;
containerEl.empty();
// Main heading
containerEl.createEl('h2', { text: 'Plugin Settings' });
// Section 1
containerEl.createEl('h3', { text: 'Basic Configuration' });
new Setting(containerEl).setName('Setting 1')...
new Setting(containerEl).setName('Setting 2')...
// Section 2
containerEl.createEl('h3', { text: 'Advanced Options' });
new Setting(containerEl).setName('Setting 3')...
new Setting(containerEl).setName('Setting 4')...
}
```
### Conditional Display
Show/hide settings based on other settings:
```typescript
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Enable Advanced')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.advancedMode)
.onChange(async (value) => {
this.plugin.settings.advancedMode = value;
await this.plugin.saveSettings();
this.display(); // Redraw to show/hide advanced settings
})
);
if (this.plugin.settings.advancedMode) {
new Setting(containerEl)
.setName('Advanced Option')
.setDesc('This only shows when advanced mode is on')
.addText((text) => { /* ... */ });
}
}
```
### Grouped Controls
Multiple controls in one setting:
```typescript
new Setting(containerEl)
.setName('Color')
.setDesc('Choose color and opacity')
.addColorPicker((color) =>
color
.setValue(this.plugin.settings.color)
.onChange(async (value) => {
this.plugin.settings.color = value;
await this.plugin.saveSettings();
})
)
.addSlider((slider) =>
slider
.setMin(0)
.setMax(100)
.setValue(this.plugin.settings.opacity)
.onChange(async (value) => {
this.plugin.settings.opacity = value;
await this.plugin.saveSettings();
})
);
```
## Multi-Plugin Architecture
When building multiple plugins within one codebase, consider this structure:
### Folder Structure
```
src/
main.ts (Plugin entry point)
settings.ts (Shared settings)
plugins/
folder/
folderPlugin.ts
folderSettings.ts
markdown/
markdownPlugin.ts
markdownSettings.ts
kanban/
kanbanPlugin.ts
kanbanSettings.ts
```
### Unified Settings Interface
```typescript
// settings.ts
export interface PluginConfig {
// Folder Plugin settings
folderPlugin: {
enabled: boolean;
organizationHierarchy: string;
};
// Markdown Plugin settings
markdownPlugin: {
enabled: boolean;
autoCreatePath: string;
template: string;
};
// Kanban Plugin settings
kanbanPlugin: {
enabled: boolean;
boardTemplate: string;
defaultLayout: string;
};
}
export const DEFAULT_SETTINGS: PluginConfig = {
folderPlugin: {
enabled: true,
organizationHierarchy: 'Category, Status',
},
markdownPlugin: {
enabled: true,
autoCreatePath: 'Notes',
template: 'default',
},
kanbanPlugin: {
enabled: false,
boardTemplate: 'default',
defaultLayout: 'columns',
},
};
```
### Unified Settings Tab
```typescript
// settings.ts
export class UnifiedSettingTab extends PluginSettingTab {
plugin: MultiPluginBase;
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Plugin Suite Settings' });
// Folder Plugin Section
this.displayFolderPluginSettings(containerEl);
// Markdown Plugin Section
this.displayMarkdownPluginSettings(containerEl);
// Kanban Plugin Section
this.displayKanbanPluginSettings(containerEl);
}
displayFolderPluginSettings(containerEl: HTMLElement): void {
containerEl.createEl('h3', { text: 'Folder Organization Plugin' });
new Setting(containerEl)
.setName('Enable Folder Plugin')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.folderPlugin.enabled)
.onChange(async (value) => {
this.plugin.settings.folderPlugin.enabled = value;
await this.plugin.saveSettings();
})
);
if (this.plugin.settings.folderPlugin.enabled) {
new Setting(containerEl)
.setName('Organization Hierarchy')
.setDesc('Comma-separated frontmatter fields')
.addTextArea((text) =>
text
.setValue(this.plugin.settings.folderPlugin.organizationHierarchy)
.onChange(async (value) => {
this.plugin.settings.folderPlugin.organizationHierarchy = value;
await this.plugin.saveSettings();
})
);
}
}
displayMarkdownPluginSettings(containerEl: HTMLElement): void {
containerEl.createEl('h3', { text: 'Markdown Auto-Create Plugin' });
new Setting(containerEl)
.setName('Enable Markdown Plugin')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.markdownPlugin.enabled)
.onChange(async (value) => {
this.plugin.settings.markdownPlugin.enabled = value;
await this.plugin.saveSettings();
})
);
if (this.plugin.settings.markdownPlugin.enabled) {
new Setting(containerEl)
.setName('Auto-Create Path')
.setDesc('Default folder for auto-created files')
.addText((text) =>
text
.setPlaceholder('Notes')
.setValue(this.plugin.settings.markdownPlugin.autoCreatePath)
.onChange(async (value) => {
this.plugin.settings.markdownPlugin.autoCreatePath = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Template')
.setDesc('Template to use for new files')
.addDropdown((dropdown) =>
dropdown
.addOption('default', 'Default')
.addOption('minimal', 'Minimal')
.addOption('detailed', 'Detailed')
.setValue(this.plugin.settings.markdownPlugin.template)
.onChange(async (value) => {
this.plugin.settings.markdownPlugin.template = value;
await this.plugin.saveSettings();
})
);
}
}
displayKanbanPluginSettings(containerEl: HTMLElement): void {
containerEl.createEl('h3', { text: 'Kanban Board Plugin' });
new Setting(containerEl)
.setName('Enable Kanban Plugin')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.kanbanPlugin.enabled)
.onChange(async (value) => {
this.plugin.settings.kanbanPlugin.enabled = value;
await this.plugin.saveSettings();
})
);
if (this.plugin.settings.kanbanPlugin.enabled) {
new Setting(containerEl)
.setName('Board Template')
.setDesc('Default board template')
.addDropdown((dropdown) =>
dropdown
.addOption('default', 'Default')
.addOption('agile', 'Agile')
.addOption('gtd', 'Getting Things Done')
.setValue(this.plugin.settings.kanbanPlugin.boardTemplate)
.onChange(async (value) => {
this.plugin.settings.kanbanPlugin.boardTemplate = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Default Layout')
.setDesc('How boards are displayed by default')
.addDropdown((dropdown) =>
dropdown
.addOption('columns', 'Columns')
.addOption('rows', 'Rows')
.addOption('grid', 'Grid')
.setValue(this.plugin.settings.kanbanPlugin.defaultLayout)
.onChange(async (value) => {
this.plugin.settings.kanbanPlugin.defaultLayout = value;
await this.plugin.saveSettings();
})
);
}
}
}
```
## Plugin Examples
### Example 1: Folder Organization Plugin
**Settings Interface:**
```typescript
export interface FolderOrganizerSettings {
enabled: boolean;
organizationHierarchy: string;
createMissingFolders: boolean;
overwriteExisting: boolean;
excludeFolders: string[];
}
```
**Key UI Elements:**
- Toggle to enable/disable
- Text area for hierarchy configuration
- Toggle for auto-create folders
- Toggle for overwrite confirmation
- Multi-select or list for excluded folders
- Action button to organize files
### Example 2: Markdown Auto-Create Plugin
**Settings Interface:**
```typescript
export interface MarkdownAutoCreateSettings {
enabled: boolean;
defaultPath: string;
template: 'default' | 'minimal' | 'detailed';
autoOpenNewFile: boolean;
defaultFrontmatter: string;
hotkey: string;
}
```
**Key UI Elements:**
- Toggle to enable/disable
- Text input for default path
- Dropdown for template selection
- Toggle for auto-open
- Text area for frontmatter template
- Display current hotkey (or hotkey picker if available)
### Example 3: Kanban Board Plugin
**Settings Interface:**
```typescript
export interface KanbanBoardSettings {
enabled: boolean;
boardTemplate: 'default' | 'agile' | 'gtd';
defaultLayout: 'columns' | 'rows' | 'grid';
cardWidth: number;
showDates: boolean;
showTags: boolean;
defaultColumns: string[];
darkMode: boolean;
}
```
**Key UI Elements:**
- Toggle to enable/disable
- Dropdown for board templates
- Dropdown for default layout
- Slider for card width
- Toggles for feature flags
- Text area for default columns (comma-separated)
- Color picker for theme customization
## Best Practices
1. **Always save settings** - Call `await this.plugin.saveSettings()` after any change
2. **Use descriptive names** - Users should understand what each setting does
3. **Group related settings** - Use section headers (h3) to organize
4. **Provide defaults** - Always have sensible defaults in `DEFAULT_SETTINGS`
5. **Validate input** - Check user input before saving (trim, validate paths, etc.)
6. **Use conditionals wisely** - Show/hide advanced options based on basic settings
7. **Provide feedback** - Use `new Notice()` to confirm actions
8. **Redraw on enable/disable** - Call `this.display()` to update UI when features toggle
9. **Document complex settings** - Use `.setDesc()` liberally
10. **Test multi-step workflows** - Ensure settings changes work with all plugins enabled
## Testing Your Settings UI
```typescript
// In your test file
const plugin = new MyPlugin(app, manifest);
plugin.loadSettings();
plugin.registerSettingTab(new UnifiedSettingTab(app, plugin));
// Settings should load without errors
// Check that all toggles/inputs work
// Verify settings persist after reload
// Test conditional displays
```
---
This guide provides a foundation for building clean, organized, and user-friendly plugin settings. Adapt these patterns to your specific needs!
+5
View File
@@ -0,0 +1,5 @@
---
category: docs
path: ZeroSpace\zs.docs
shouldInstallPlugins: true
---
+4
View File
@@ -0,0 +1,4 @@
---
category: docs
path: Obsidian-Plugins\op.docs
---
+1
View File
@@ -0,0 +1 @@
{}
+3
View File
@@ -0,0 +1,3 @@
{
"theme": "obsidian"
}
+33
View File
@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
+190
View File
@@ -0,0 +1,190 @@
{
"main": {
"id": "5aefe3eaa01298aa",
"type": "split",
"children": [
{
"id": "8e01d97cdc20d278",
"type": "tabs",
"children": [
{
"id": "46c2d403c5136d23",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Generate Build Script Project.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "Generate Build Script Project"
}
}
]
}
],
"direction": "vertical"
},
"left": {
"id": "97c9b935bff66d1d",
"type": "split",
"children": [
{
"id": "1ecf357cfe02325f",
"type": "tabs",
"children": [
{
"id": "19ac1b441316dcfc",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
},
"icon": "lucide-folder-closed",
"title": "Files"
}
},
{
"id": "99feed5ad5a90deb",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "Search"
}
},
{
"id": "de452041497e4cd8",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "Bookmarks"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"right": {
"id": "a984cdfdcdf28365",
"type": "split",
"children": [
{
"id": "a63d615d35bc9daf",
"type": "tabs",
"children": [
{
"id": "4b86dc95dc16032b",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"file": "Generate Build Script Project.md",
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "Backlinks for Generate Build Script Project"
}
},
{
"id": "4c3dbdb8f44e7955",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"file": "Generate Build Script Project.md",
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "Outgoing links from Generate Build Script Project"
}
},
{
"id": "da94bd2266074d5a",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "Tags"
}
},
{
"id": "46b74855b4b336d7",
"type": "leaf",
"state": {
"type": "all-properties",
"state": {
"sortOrder": "frequency",
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-archive",
"title": "All properties"
}
},
{
"id": "ae88324a14179265",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "Generate Build Script Project.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "Outline of Generate Build Script Project"
}
}
]
}
],
"direction": "horizontal",
"width": 300,
"collapsed": true
},
"left-ribbon": {
"hiddenItems": {
"switcher:Open quick switcher": false,
"graph:Open graph view": false,
"canvas:Create new canvas": false,
"daily-notes:Open today's daily note": false,
"templates:Insert template": false,
"command-palette:Open command palette": false,
"bases:Create new base": false
}
},
"active": "46c2d403c5136d23",
"lastOpenFiles": [
"Generate Build Script Project.md"
]
}
+17
View File
@@ -0,0 +1,17 @@
---
category: AI Task
---
In OP/Build project, create a program that scans the markdown files in op.docs.
It will grab files with the frontmatter of catergory: docs.
For this doc files, get the frontmatter path. This will tell you the location of the docs relative to the current user root. Such as user/Obsidian-Plugins/op.docs.
You will want to build the Obsidian plugin located in OP_plugin.
Once it's built, you copy the contents of OP_plugin and move it to the relative plugin folders, such as ZeroSpace\zs.docs\.obsidian\plugins\
Delete the previous plugin obviously, if the location is already occupied.