Initial commit – replace repo contents

This commit is contained in:
Anup Chavan 2025-12-28 20:45:25 +05:30
commit 2b45af1de1
21 changed files with 5065 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"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"
}
}

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# vscode
.vscode
.cursor
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

251
AGENTS.md Normal file
View file

@ -0,0 +1,251 @@
# Obsidian community plugin
## Project overview
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
## Environment & tooling
- Node.js: use current LTS (Node 18+ recommended).
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
- Types: `obsidian` type definitions.
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
### Install
```bash
npm install
```
### Dev (watch)
```bash
npm run dev
```
### Production build
```bash
npm run build
```
## Linting
- To use eslint install eslint from terminal: `npm install -g eslint`
- To use eslint to analyze this project use this command: `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
## File & folder conventions
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
- **Example file structure**:
```
src/
main.ts # Plugin entry point, lifecycle management
settings.ts # Settings interface and defaults
commands/ # Command implementations
command1.ts
command2.ts
ui/ # UI components, modals, views
modal.ts
view.ts
utils/ # Utility functions, helpers
helpers.ts
constants.ts
types.ts # TypeScript interfaces and types
```
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
## Manifest rules (`manifest.json`)
- Must include (non-exhaustive):
- `id` (plugin ID; for local dev it should match the folder name)
- `name`
- `version` (Semantic Versioning `x.y.z`)
- `minAppVersion`
- `description`
- `isDesktopOnly` (boolean)
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
- Never change `id` after release. Treat it as stable API.
- Keep `minAppVersion` accurate when using newer APIs.
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
## Testing
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
```
<Vault>/.obsidian/plugins/<plugin-id>/
```
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
## Commands & settings
- Any user-facing commands should be added via `this.addCommand(...)`.
- If the plugin has configuration, provide a settings tab and sensible defaults.
- Persist settings using `this.loadData()` / `this.saveData()`.
- Use stable command IDs; avoid renaming once released.
## Versioning & releases
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
## Security, privacy, and compliance
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
- Default to local/offline operation. Only make network requests when essential to the feature.
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
- Clearly disclose any external services used, data sent, and risks.
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
- Avoid deceptive patterns, ads, or spammy notifications.
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
## UX & copy guidelines (for UI text, commands, settings)
- Prefer sentence case for headings, buttons, and titles.
- Use clear, action-oriented imperatives in step-by-step copy.
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
- Use arrow notation for navigation: **Settings → Community plugins**.
- Keep in-app strings short, consistent, and free of jargon.
## Performance
- Keep startup light. Defer heavy work until needed.
- Avoid long-running tasks during `onload`; use lazy initialization.
- Batch disk access and avoid excessive vault scans.
- Debounce/throttle expensive operations in response to file system events.
## Coding conventions
- TypeScript with `"strict": true` preferred.
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
- Bundle everything into `main.js` (no unbundled runtime deps).
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
- Prefer `async/await` over promise chains; handle errors gracefully.
## Mobile
- Where feasible, test on iOS and Android.
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
- Avoid large in-memory structures; be mindful of memory and storage constraints.
## Agent do/don't
**Do**
- Add commands with stable IDs (don't rename once released).
- Provide defaults and validation in settings.
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
- Use `this.register*` helpers for everything that needs cleanup.
**Don't**
- Introduce network calls without an obvious user-facing reason and documentation.
- Ship features that require cloud services without clear disclosure and explicit opt-in.
- Store or transmit vault contents unless essential and consented.
## Common tasks
### Organize code across multiple files
**main.ts** (minimal, lifecycle only):
```ts
import { Plugin } from "obsidian";
import { MySettings, DEFAULT_SETTINGS } from "./settings";
import { registerCommands } from "./commands";
export default class MyPlugin extends Plugin {
settings: MySettings;
async onload() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
registerCommands(this);
}
}
```
**settings.ts**:
```ts
export interface MySettings {
enabled: boolean;
apiKey: string;
}
export const DEFAULT_SETTINGS: MySettings = {
enabled: true,
apiKey: "",
};
```
**commands/index.ts**:
```ts
import { Plugin } from "obsidian";
import { doSomething } from "./my-command";
export function registerCommands(plugin: Plugin) {
plugin.addCommand({
id: "do-something",
name: "Do something",
callback: () => doSomething(plugin),
});
}
```
### Add a command
```ts
this.addCommand({
id: "your-command-id",
name: "Do the thing",
callback: () => this.doTheThing(),
});
```
### Persist settings
```ts
interface MySettings { enabled: boolean }
const DEFAULT_SETTINGS: MySettings = { enabled: true };
async onload() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
await this.saveData(this.settings);
}
```
### Register listeners safely
```ts
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
this.registerDomEvent(window, "resize", () => { /* ... */ });
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
```
## Troubleshooting
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
## References
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
- API documentation: https://docs.obsidian.md
- Developer policies: https://docs.obsidian.md/Developer+policies
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
- Style guide: https://help.obsidian.md/style-guide

94
README.md Normal file
View file

@ -0,0 +1,94 @@
# Obsidian Sample Plugin
This is a sample plugin for Obsidian (https://obsidian.md).
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
## First time developing plugins?
Quick starting guide for new plugin devs:
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Releasing new releases
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Adding your plugin to the community plugin list
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## How to use
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Improve code quality with eslint (optional)
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint ./src/`
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation
See https://github.com/obsidianmd/obsidian-api

50
esbuild.config.mjs Normal file
View file

@ -0,0 +1,50 @@
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: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/lang-html",
"@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();
}

11
manifest.json Normal file
View file

@ -0,0 +1,11 @@
{
"id": "obsidian-custom-views",
"name": "Custom Views",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "",
"author": "Anup Chavan",
"authorUrl": "https://anupchavan.com",
"fundingUrl": "",
"isDesktopOnly": false
}

2427
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "obsidian-custom-views",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"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"
},
"keywords": [],
"author": "Anup Chavan",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"nanoid": "^5.1.6"
}
}

187
src/filters.ts Normal file
View file

@ -0,0 +1,187 @@
import { moment } from "obsidian";
// Helper: Parse arguments like: "YYYY-MM-DD" or ("a", "b")
function parseArgs(argString: string): any[] {
if (!argString) return [];
// Remove outer parenthesis if they exist: ("a", "b") -> "a", "b"
const content = argString.trim().replace(/^\((.*)\)$/, '$1');
// Split by comma, respecting quotes
const args: string[] = [];
let current = '';
let inQuote = false;
for (let i = 0; i < content.length; i++) {
const char = content[i];
if (char === '"' || char === "'") {
inQuote = !inQuote;
} else if (char === ',' && !inQuote) {
args.push(cleanQuote(current));
current = '';
continue;
}
current += char;
}
if (current) args.push(cleanQuote(current));
return args;
}
function cleanQuote(str: string): any {
str = str.trim();
if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
return str.slice(1, -1);
}
// Check for numbers
if (!isNaN(Number(str))) return Number(str);
return str;
}
// =============================================================================
// FILTER IMPLEMENTATIONS
// =============================================================================
const filters: Record<string, (value: any, ...args: any[]) => any> = {
// --- Dates ---
date: (val: string | number, format?: string, inputFormat?: string) => {
const m = inputFormat ? moment(val, inputFormat) : moment(val);
return m.isValid() ? m.format(format || "YYYY-MM-DD") : val;
},
date_modify: (val: string, modification: string) => {
// Simple parser for "+1 year", "-2 months"
const parts = modification.trim().split(" ");
const amount = parseInt(parts[0]);
const unit = parts[1] as moment.unitOfTime.DurationConstructor;
const m = moment(val);
return m.isValid() ? m.add(amount, unit).format("YYYY-MM-DD") : val;
},
// --- Text Conversion ---
capitalize: (val: string) => String(val).charAt(0).toUpperCase() + String(val).slice(1).toLowerCase(),
upper: (val: string) => String(val).toUpperCase(),
lower: (val: string) => String(val).toLowerCase(),
title: (val: string) => String(val).replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()),
camel: (val: string) => String(val).toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase()),
kebab: (val: string) => String(val).match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)?.map(x => x.toLowerCase()).join('-') || val,
snake: (val: string) => String(val).match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)?.map(x => x.toLowerCase()).join('_') || val,
trim: (val: string) => String(val).trim(),
replace: (val: string, search: string, replaceWith: string = "") => {
// Handle Regex if string starts with /
if (search.startsWith("/")) {
const lastSlash = search.lastIndexOf("/");
const pattern = search.substring(1, lastSlash);
const flags = search.substring(lastSlash + 1);
return String(val).replace(new RegExp(pattern, flags), replaceWith);
}
return String(val).replaceAll(search, replaceWith);
},
// --- Formatting (Markdown) ---
wikilink: (val: any, alias?: string) => {
if (Array.isArray(val)) return val.map(v => `[[${v}${alias ? '|' + alias : ''}]]`).join(", ");
return `[[${val}${alias ? '|' + alias : ''}]]`;
},
link: (val: any, text?: string) => {
const label = text || "link";
if (Array.isArray(val)) return val.map(v => `[${label}](${v})`).join(", ");
return `[${label}](${val})`;
},
image: (val: any, alt?: string) => {
const txt = alt || "";
if (Array.isArray(val)) return val.map(v => `![${txt}](${v})`).join("\n");
return `![${txt}](${val})`;
},
blockquote: (val: string) => val.split('\n').map(line => `> ${line}`).join('\n'),
// --- HTML Processing ---
strip_tags: (val: string, keep?: string) => {
const doc = new DOMParser().parseFromString(val, 'text/html');
// A full implementation would need a complex sanitizer,
// simplistic text extraction:
return doc.body.textContent || "";
},
// --- Arrays ---
split: (val: string, separator: string = ",") => String(val).split(separator),
join: (val: any[], separator: string = ",") => Array.isArray(val) ? val.join(separator) : val,
first: (val: any[]) => Array.isArray(val) ? val[0] : val,
last: (val: any[]) => Array.isArray(val) ? val[val.length - 1] : val,
slice: (val: any[] | string, start: number, end?: number) => val.slice(start, end),
count: (val: any) => Array.isArray(val) ? val.length : String(val).length,
// Basic Arithmetic
calc: (val: number, opString: string) => {
// CAUTION: Simple eval-like safety check needed
// Format: "+10", "*2", "**3"
const op = opString.trim().charAt(0);
const num = parseFloat(opString.substring(1));
const base = parseFloat(String(val));
if (isNaN(base) || isNaN(num)) return val;
switch (op) {
case '+': return base + num;
case '-': return base - num;
case '*': return base * num;
case '/': return base / num;
case '^': case '*': return Math.pow(base, num); // Handles ** or ^
default: return val;
}
}
};
// =============================================================================
// MAIN EXECUTION
// =============================================================================
export function applyFilterChain(value: any, filterChain: string): any {
if (!filterChain) return value;
// Split by pipe '|', ignoring pipes inside quotes
const steps: string[] = [];
let current = '';
let inQuote = false;
for (let i = 0; i < filterChain.length; i++) {
const char = filterChain[i];
if (char === '"' || char === "'") inQuote = !inQuote;
if (char === '|' && !inQuote) {
steps.push(current.trim());
current = '';
} else {
current += char;
}
}
if (current) steps.push(current.trim());
// Process each filter
let result = value;
for (const step of steps) {
if (!step) continue;
// Separate filter name and args: name:arg1,arg2
const colonIndex = step.indexOf(':');
let name = step;
let args: any[] = [];
if (colonIndex > -1) {
name = step.substring(0, colonIndex);
const argString = step.substring(colonIndex + 1);
args = parseArgs(argString);
}
const fn = filters[name];
if (fn) {
try {
result = fn(result, ...args);
} catch (e) {
console.error(`[Custom Views] Filter error '${name}':`, e);
}
}
}
return result;
}

261
src/main.ts Normal file
View file

@ -0,0 +1,261 @@
import { Plugin, TFile, MarkdownView, MarkdownRenderer, Keymap, Notice } from "obsidian";
import { CustomViewsSettings, DEFAULT_SETTINGS, CustomViewsSettingTab } from "./settings";
import { checkRules } from "./matcher";
import { renderTemplate } from "./renderer";
const CUSTOM_VIEW_CLASS = "obsidian-custom-view-render";
const HIDE_MARKDOWN_CLASS = "obsidian-custom-view-hidden";
export default class CustomViewsPlugin extends Plugin {
settings: CustomViewsSettings;
async onload() {
console.log("[Custom Views] Loading...");
await this.loadSettings();
this.addSettingTab(new CustomViewsSettingTab(this.app, this));
// Command: Enable Custom Views
this.addCommand({
id: "enable-custom-views",
name: "Enable Custom Views",
checkCallback: (checking) => {
// Only show this command if plugin is currently DISABLED
if (checking) {
return !this.settings.enabled;
}
// Execute
this.setPluginState(true);
return true;
},
});
// Command: Disable Custom Views
this.addCommand({
id: "disable-custom-views",
name: "Disable Custom Views",
checkCallback: (checking) => {
// Only show this command if plugin is currently ENABLED
if (checking) {
return this.settings.enabled;
}
// Execute
this.setPluginState(false);
return true;
},
});
this.registerEvent(
this.app.workspace.on("file-open", (file) => this.processActiveView(file))
);
this.registerEvent(
this.app.workspace.on("layout-change", () => {
const file = this.app.workspace.getActiveFile();
this.processActiveView(file);
})
);
this.addStyle();
}
// Helper to switch state, save, and refresh
async setPluginState(enabled: boolean) {
this.settings.enabled = enabled;
await this.saveSettings();
new Notice(enabled ? "Custom Views Enabled" : "Custom Views Disabled");
// Refresh the current view immediately
const file = this.app.workspace.getActiveFile();
if (file) {
this.processActiveView(file);
}
}
addStyle() {
const css = `
.${HIDE_MARKDOWN_CLASS} .markdown-source-view,
.${HIDE_MARKDOWN_CLASS} .markdown-preview-view {
display: none !important;
}
.${CUSTOM_VIEW_CLASS} {
padding: 30px;
height: 100%;
overflow-y: auto;
width: 100%;
position: absolute;
top: 0;
left: 0;
background-color: var(--background-primary);
z-index: 10;
}
.${HIDE_MARKDOWN_CLASS} {
position: relative;
}
.${CUSTOM_VIEW_CLASS} .markdown-rendered-content {
margin-top: 20px;
}
/* Ensure proper reading mode styling for rendered content */
.${CUSTOM_VIEW_CLASS} .markdown-preview-section {
padding: 0;
}
/* Fix list indentation and spacing to match reading mode */
.${CUSTOM_VIEW_CLASS} .markdown-preview-section ul,
.${CUSTOM_VIEW_CLASS} .markdown-preview-section ol {
padding-left: 1.625em;
margin-block-start: 1em;
margin-block-end: 1em;
}
.${CUSTOM_VIEW_CLASS} .markdown-preview-section li {
margin-block-start: 0.3em;
margin-block-end: 0.3em;
}
.${CUSTOM_VIEW_CLASS} .markdown-preview-section li > ul,
.${CUSTOM_VIEW_CLASS} .markdown-preview-section li > ol {
margin-block-start: 0.3em;
margin-block-end: 0.3em;
}
/* Ensure proper paragraph spacing */
.${CUSTOM_VIEW_CLASS} .markdown-preview-section p {
margin-block-start: 1em;
margin-block-end: 1em;
}
.${CUSTOM_VIEW_CLASS} .markdown-preview-section p:first-child {
margin-block-start: 0;
}
.${CUSTOM_VIEW_CLASS} .markdown-preview-section p:last-child {
margin-block-end: 0;
}
`;
const styleEl = document.createElement("style");
styleEl.id = "custom-views-css";
styleEl.textContent = css;
document.head.appendChild(styleEl);
}
onunload() {
const styleEl = document.getElementById("custom-views-css");
if (styleEl) styleEl.remove();
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof MarkdownView) {
this.restoreDefaultView(leaf.view);
}
});
}
async processActiveView(file: TFile | null) {
if (!file) return;
const leaf = this.app.workspace.getLeaf(false);
if (!(leaf.view instanceof MarkdownView)) return;
const view = leaf.view;
// 1. Check Global Enabled State
if (!this.settings.enabled) {
this.restoreDefaultView(view);
return;
}
const cache = this.app.metadataCache.getFileCache(file);
let matchedTemplate = "";
for (const viewConfig of this.settings.views) {
const isMatch = checkRules(viewConfig.rules, file, cache?.frontmatter);
if (isMatch) {
matchedTemplate = viewConfig.template;
break;
}
}
if (!matchedTemplate) {
this.restoreDefaultView(view);
return;
}
const state = view.getState();
const isTrueSourceMode = state.mode === 'source' && state.source === true;
const isReadingMode = state.mode === 'preview';
const isLivePreviewMode = state.mode === 'source' && state.source === false;
// Skip true source mode (pure editor)
if (isTrueSourceMode) {
this.restoreDefaultView(view);
return;
}
// Check if we should work in this view mode based on setting
if (!this.settings.workInLivePreview) {
// If workInLivePreview is OFF, only work in reading mode
if (!isReadingMode) {
this.restoreDefaultView(view);
return;
}
} else {
// If workInLivePreview is ON, work in both reading mode and live preview mode
if (!isReadingMode && !isLivePreviewMode) {
this.restoreDefaultView(view);
return;
}
}
await this.injectCustomView(view, file, matchedTemplate);
}
async injectCustomView(view: MarkdownView, file: TFile, template: string) {
const container = view.contentEl;
let customEl = container.querySelector(`.${CUSTOM_VIEW_CLASS}`) as HTMLElement;
if (!customEl) {
customEl = document.createElement("div");
customEl.addClass(CUSTOM_VIEW_CLASS);
container.appendChild(customEl);
this.registerDomEvent(customEl, "click", (evt: MouseEvent) => {
const target = evt.target as HTMLElement;
const link = target.closest(".internal-link");
if (link && link instanceof HTMLAnchorElement) {
evt.preventDefault();
const href = link.getAttribute("data-href") || link.getAttribute("href");
if (href) {
const newLeaf = Keymap.isModEvent(evt);
this.app.workspace.openLinkText(href, file.path, newLeaf);
}
}
});
}
await renderTemplate(this.app, template, file, customEl, this);
container.addClass(HIDE_MARKDOWN_CLASS);
}
restoreDefaultView(view: MarkdownView) {
const container = view.contentEl;
container.removeClass(HIDE_MARKDOWN_CLASS);
const customEl = container.querySelector(`.${CUSTOM_VIEW_CLASS}`);
if (customEl) customEl.remove();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}

89
src/matcher.ts Normal file
View file

@ -0,0 +1,89 @@
// src/matcher.ts
import { TFile, FrontMatterCache } from "obsidian";
import { FilterGroup, Filter, FilterOperator } from "./types";
export function checkRules(group: FilterGroup, file: TFile, frontmatter?: FrontMatterCache): boolean {
if (!group || !group.conditions || group.conditions.length === 0) return true;
// Evaluate all conditions in this group
const results = group.conditions.map(condition => {
if (condition.type === "group") {
return checkRules(condition as FilterGroup, file, frontmatter);
} else {
return evaluateFilter(condition as Filter, file, frontmatter);
}
});
// Combine results based on AND (every) / OR (some)
if (group.operator === "AND") {
return results.every(r => r === true);
} else {
return results.some(r => r === true);
}
}
function evaluateFilter(filter: Filter, file: TFile, frontmatter?: FrontMatterCache): boolean {
let targetValue: any = null;
// 1. Resolve the value from File or Frontmatter
// Determine field type by checking if field starts with "file."
if (filter.field.startsWith("file.")) {
if (filter.field === "file.name") targetValue = file.name;
else if (filter.field === "file.basename") targetValue = file.basename;
else if (filter.field === "file.path") targetValue = file.path;
else if (filter.field === "file.folder") targetValue = file.parent?.path || "";
else if (filter.field === "file.size") targetValue = file.stat.size;
else if (filter.field === "file.ctime") targetValue = file.stat.ctime;
else if (filter.field === "file.mtime") targetValue = file.stat.mtime;
else if (filter.field === "file.extension") targetValue = file.extension;
} else if (frontmatter) {
// Frontmatter field
targetValue = frontmatter[filter.field];
}
// Handle null/undefined
if (targetValue === undefined || targetValue === null) targetValue = "";
// Normalize to string for comparison (or array if checking includes)
const normalize = (val: any) => String(val).toLowerCase();
const filterValue = normalize(filter.value || "");
// If target is an array (e.g. tags: [a, b]), we handle it differently
const isArray = Array.isArray(targetValue);
// 2. Perform the Check
switch (filter.operator) {
case "is empty":
return isArray ? targetValue.length === 0 : !targetValue;
case "is not empty":
return isArray ? targetValue.length > 0 : !!targetValue;
case "is":
case "is not": {
// Exact match
let match = false;
if (isArray) match = targetValue.some((v: any) => normalize(v) === filterValue);
else match = normalize(targetValue) === filterValue;
return filter.operator === "is" ? match : !match;
}
case "contains":
case "does not contain": {
let match = false;
if (isArray) match = targetValue.some((v: any) => normalize(v).includes(filterValue));
else match = normalize(targetValue).includes(filterValue);
return filter.operator === "contains" ? match : !match;
}
case "starts with":
if (isArray) return false; // Hard to strictly start with on an array
return normalize(targetValue).startsWith(filterValue);
case "ends with":
if (isArray) return false;
return normalize(targetValue).endsWith(filterValue);
default:
return false;
}
}

160
src/renderer.ts Normal file
View file

@ -0,0 +1,160 @@
import { App, TFile, MarkdownRenderer, Component } from "obsidian";
import { applyFilterChain } from "./filters";
export async function renderTemplate(
app: App,
template: string,
file: TFile,
container: HTMLElement,
component: Component
) {
const cache = app.metadataCache.getFileCache(file);
const frontmatter = cache?.frontmatter;
const rawContent = await app.vault.read(file);
// 1. Extract Body Content
let bodyContent = rawContent;
if (frontmatter && frontmatter.position) {
bodyContent = rawContent.substring(frontmatter.position.end.offset).trim();
}
// 2. Prepare Queues
const markdownQueue: { id: string, content: string }[] = [];
const contentPlaceholderId = `custom-view-content-${Date.now()}`;
// 3. Helper to resolve raw value
const resolveValue = (key: string, index?: string): any => {
let value: any;
if (key === "name") value = file.name;
else if (key === "basename") value = file.basename;
else if (key === "size") value = file.stat.size;
else if (key === "ctime") value = file.stat.ctime; // Timestamp for dates
else if (key === "mtime") value = file.stat.mtime;
else if (frontmatter && frontmatter[key] !== undefined) value = frontmatter[key];
else return null;
if (index !== undefined && Array.isArray(value)) {
const i = parseInt(index);
return i < value.length ? value[i] : "";
}
return value;
};
// 4. Process Template with Filters
// Regex breakdown:
// {{ file.KEY ( [INDEX] )? ( | FILTER_CHAIN )? }}
const regex = /\{\{file\.([a-zA-Z0-9_.-]+)(\[(\d+)\])?(?:\s*\|(.*?))?\}\}/g;
const filledTemplate = template.replace(
regex,
(match, key, _, index, filterChain, offset, fullString) => {
// SPECIAL CASE: The Content
if (key === "content") {
return `<div id="${contentPlaceholderId}" class="markdown-rendered-content"></div>`;
}
// 1. Get Base Value
let value = resolveValue(key, index);
if (value === null) return "";
// 2. Apply Filters (NEW)
if (filterChain) {
value = applyFilterChain(value, filterChain.trim());
}
// 3. Context Check (Inside Attribute or Body?)
const prefix = fullString.substring(0, offset);
const doubleQuotes = (prefix.match(/"/g) || []).length;
const singleQuotes = (prefix.match(/'/g) || []).length;
const isInsideAttribute = (doubleQuotes % 2 !== 0) || (singleQuotes % 2 !== 0);
// 4. Return Output
if (isInsideAttribute) {
// Return RAW string (for href="", src="")
return String(value);
} else {
// Return PLACEHOLDER (Render as Markdown)
// We assume if it's in the body, it might contain Markdown syntax (like [[Links]] generated by filters)
const placeholderId = `cv-md-${markdownQueue.length}-${Date.now()}`;
markdownQueue.push({ id: placeholderId, content: String(value) });
return `<span id="${placeholderId}"></span>`;
}
}
);
// 5. Inject HTML
container.innerHTML = filledTemplate;
// 6. Render Markdown Queue
for (const item of markdownQueue) {
const span = container.querySelector(`#${item.id}`) as HTMLElement;
if (span) {
await MarkdownRenderer.render(app, item.content, span, file.path, component);
span.removeAttribute("id");
// Cleanup wrapping <p> if strictly wrapping
const p = span.querySelector("p");
if (p && p.parentElement === span && span.children.length === 1) {
p.replaceWith(...Array.from(p.childNodes));
}
}
}
// 7. Render Body Content
const contentEl = container.querySelector(`#${contentPlaceholderId}`) as HTMLElement;
if (contentEl) {
// Create the proper reading mode structure
const sizer = document.createElement("div");
sizer.addClass("markdown-preview-sizer");
sizer.addClass("markdown-preview-section");
contentEl.appendChild(sizer);
await MarkdownRenderer.render(app, bodyContent, sizer, file.path, component);
contentEl.removeAttribute("id");
}
// 8. Execute Scripts
// Scripts in innerHTML are not executed automatically for security reasons.
// We manually extract and execute them to allow dynamic behavior in templates.
executeScripts(container);
}
/**
* Executes all script tags found in the container.
* Scripts are executed in order and can access the fully rendered DOM.
* This function handles both inline scripts and external scripts (with src attribute).
* Inline scripts are wrapped in an IIFE to prevent variable redeclaration errors on re-renders.
*/
function executeScripts(container: HTMLElement): void {
const scripts = Array.from(container.querySelectorAll('script'));
scripts.forEach((oldScript) => {
const newScript = document.createElement('script');
// Copy all attributes from the original script
Array.from(oldScript.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
// Handle inline scripts (textContent)
if (oldScript.textContent) {
const scriptContent = oldScript.textContent.trim();
if (scriptContent) {
// Wrap script content in IIFE to create a new scope
// This prevents "Identifier has already been declared" errors on re-renders
newScript.textContent = `(function() {\n${scriptContent}\n})();`;
}
}
// Handle external scripts (src attribute)
if (oldScript.src && !oldScript.textContent) {
newScript.src = oldScript.src;
}
// Insert the new script before the old one, then remove the old one
// This ensures scripts execute in order
oldScript.parentNode?.insertBefore(newScript, oldScript);
oldScript.remove();
});
}

752
src/settings.ts Normal file
View file

@ -0,0 +1,752 @@
import { App, PluginSettingTab, Setting, ButtonComponent, TextComponent, setIcon, Menu, TFile } from "obsidian";
import CustomViewsPlugin from "./main";
import { ViewConfig, FilterGroup, Filter, FilterOperator, FilterConjunction } from "./types";
// --- CONSTANTS & ICONS ---
type PropertyType = "text" | "number" | "date" | "datetime" | "list" | "checkbox" | "unknown";
const TYPE_ICONS: Record<PropertyType, string> = {
text: "text", // Text
number: "binary", // Number
date: "calendar", // Date
datetime: "clock", // Datetime
list: "list", // List/Tags
checkbox: "check-square", // Boolean
unknown: "help-circle"
};
// Operators specific to types
const OPERATORS: Record<string, string[]> = {
text: ["contains", "does not contain", "is", "is not", "starts with", "ends with", "is empty", "is not empty"],
list: ["contains", "does not contain", "is empty", "is not empty"],
number: ["=", "≠", "<", "≤", ">", "≥", "is empty", "is not empty"],
date: ["on", "not on", "before", "on or before", "after", "on or after", "is empty", "is not empty"],
checkbox: ["is"] // true/false
};
const DEFAULT_RULES: FilterGroup = {
type: "group",
operator: "AND",
conditions: [
{ type: "filter", field: "file.name", operator: "contains", value: "" }
]
};
// --- SETTINGS TAB ---
export interface CustomViewsSettings {
enabled: boolean;
workInLivePreview: boolean;
views: ViewConfig[];
}
export const DEFAULT_SETTINGS: CustomViewsSettings = {
enabled: true,
workInLivePreview: true,
views: [
{
id: 'default-1',
name: 'Movie Card',
rules: JSON.parse(JSON.stringify(DEFAULT_RULES)),
template: "<h1>{{file.basename}}</h1>"
}
]
};
export class CustomViewsSettingTab extends PluginSettingTab {
plugin: CustomViewsPlugin;
constructor(app: App, plugin: CustomViewsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Inject CSS for the custom popovers
containerEl.createEl("style", {
text: `
.cv-popover-search {
padding: 8px;
border-bottom: 1px solid var(--background-modifier-border);
}
.cv-popover-search input {
width: 100%;
}
.cv-popover-item:hover, .cv-popover-item.is-selected {
background-color: var(--background-modifier-hover);
}
.cv-popover-content {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
.cv-popover-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cv-popover-check {
margin-left: 8px;
opacity: 0.5;
flex-shrink: 0;
}
`
});
containerEl.createEl("h2", { text: "Settings" });
// Work in Live Preview Toggle
new Setting(containerEl)
.setName("Work in Live Preview")
.setDesc("If off: custom views only work in reading view. If on: custom views work in both live preview and reading view.")
.addToggle(toggle => toggle
.setValue(this.plugin.settings.workInLivePreview)
.onChange(async (value) => {
this.plugin.settings.workInLivePreview = value;
await this.plugin.saveSettings();
// Refresh the current view to apply the change
const file = this.app.workspace.getActiveFile();
if (file) {
this.plugin.processActiveView(file);
}
}));
containerEl.createEl("h2", { text: "Views Configuration" });
// Add New View
new Setting(containerEl)
.addButton(btn => btn
.setButtonText("Add New View")
.setCta()
.onClick(async () => {
this.plugin.settings.views.push({
id: `${Date.now()}`,
name: "New View",
rules: JSON.parse(JSON.stringify(DEFAULT_RULES)),
template: "<h1>{{file.basename}}</h1>"
});
await this.plugin.saveSettings();
this.display();
}));
this.plugin.settings.views.forEach((view, index) => {
this.renderViewConfig(containerEl, view, index);
});
}
renderViewConfig(container: HTMLElement, view: ViewConfig, index: number) {
const wrapper = container.createDiv({ cls: "cv-custom-view-box" });
// --- 1. Filter Builder (Pass Delete Callback) ---
const rulesContainer = wrapper.createDiv({ cls: "cv-bases-query-container" });
const builder = new FilterBuilder(this.plugin, view.rules,
async () => { await this.plugin.saveSettings(); },
() => { rulesContainer.empty(); builder.render(rulesContainer); },
// OnDeleteView Callback
async () => {
this.plugin.settings.views.splice(index, 1);
await this.plugin.saveSettings();
this.display();
}
);
builder.render(rulesContainer);
// --- 2. Template Editor ---
const htmlTemplateContainer = wrapper.createDiv({ cls: "cv-bases-template-container" });
htmlTemplateContainer.createEl("div", { cls: "cv-bases-section-header", text: "HTML Template" });
const ta = new TextComponent(htmlTemplateContainer);
const textarea = htmlTemplateContainer.createEl("textarea", {
cls: "cv-textarea",
text: view.template
});
ta.inputEl.replaceWith(textarea);
wrapper.querySelector("textarea")?.addEventListener("input", async (e: any) => {
view.template = e.target.value;
await this.plugin.saveSettings();
});
}
}
// ==========================================================
// FILTER BUILDER (Enhanced)
// ==========================================================
interface PropertyDef {
key: string;
type: PropertyType;
}
class FilterBuilder {
plugin: CustomViewsPlugin;
root: FilterGroup;
onSave: () => void;
onRefresh: () => void;
onDeleteView?: () => void; // New optional callback
availableProperties: PropertyDef[];
constructor(plugin: CustomViewsPlugin, root: FilterGroup, onSave: () => void, onRefresh: () => void, onDeleteView?: () => void) {
this.plugin = plugin;
this.root = root;
this.onSave = onSave;
this.onRefresh = onRefresh;
this.onDeleteView = onDeleteView;
this.availableProperties = this.scanVaultProperties();
}
/**
* Scans the vault to find properties and INFER their types.
*/
scanVaultProperties(): PropertyDef[] {
const app = this.plugin.app;
const propMap = new Map<string, PropertyType>();
// 1. System Properties
propMap.set("file.name", "text");
propMap.set("file.path", "text");
propMap.set("file.folder", "text");
propMap.set("file.size", "number");
propMap.set("file.ctime", "datetime");
propMap.set("file.mtime", "datetime");
propMap.set("tags", "list");
// 2. Scan Frontmatter
const files = app.vault.getMarkdownFiles();
for (const file of files) {
const cache = app.metadataCache.getFileCache(file);
if (cache?.frontmatter) {
for (const key of Object.keys(cache.frontmatter)) {
if (key === "position") continue;
if (propMap.has(key) && propMap.get(key) !== "unknown") continue;
const val = cache.frontmatter[key];
const type = this.inferType(val);
propMap.set(key, type);
}
}
}
return Array.from(propMap.entries())
.map(([key, type]) => ({ key, type }))
.sort((a, b) => a.key.localeCompare(b.key));
}
inferType(val: any): PropertyType {
if (val === null || val === undefined) return "unknown";
if (Array.isArray(val)) return "list";
if (typeof val === "number") return "number";
if (typeof val === "boolean") return "checkbox";
if (typeof val === "string") {
if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return "date";
if (/^\d{4}-\d{2}-\d{2}T/.test(val)) return "datetime";
}
return "text";
}
getPropertyType(key: string): PropertyType {
const def = this.availableProperties.find(p => p.key === key);
return def ? def.type : "text";
}
render(container: HTMLElement) {
this.renderGroup(container, this.root, true);
}
renderGroup(container: HTMLElement, group: FilterGroup, isRoot: boolean = false) {
const groupDiv = container.createDiv({ cls: "cv-filter-group" });
const header = groupDiv.createDiv({ cls: "cv-filter-group-header" });
// --- CONJUNCTION DROPDOWN (Native Select) ---
const labelMap: Record<string, string> = {
"AND": "All the following are true",
"OR": "Any of the following are true",
"NOR": "None of the following are true"
};
// Map internal values to select values
const valueMap: Record<string, string> = {
"AND": "and",
"OR": "or",
"NOR": "not"
};
const reverseValueMap: Record<string, FilterConjunction> = {
"and": "AND",
"or": "OR",
"not": "NOR"
};
const select = header.createEl("select", {
cls: "conjunction dropdown",
attr: { value: valueMap[group.operator] || "and" }
});
// Add options
select.createEl("option", {
attr: { value: "and" },
text: labelMap["AND"]
});
select.createEl("option", {
attr: { value: "or" },
text: labelMap["OR"]
});
select.createEl("option", {
attr: { value: "not" },
text: labelMap["NOR"]
});
// Set initial value
select.value = valueMap[group.operator] || "and";
// Handle change
select.onchange = () => {
group.operator = reverseValueMap[select.value];
this.onSave();
this.onRefresh();
};
// --- FILTER GROUP HEADER ACTIONS ---
const headerActionsDiv = header.createDiv({ cls: "cv-filter-group-header-actions" });
// --- INSERT VIEW HEADER HERE (If Root) ---
if (isRoot && this.onDeleteView) {
const viewHeader = headerActionsDiv.createDiv({ cls: "cv-custom-view-box-header" });
viewHeader.style.marginLeft = "auto"; // Push to right
const delBtn = new ButtonComponent(viewHeader)
.setIcon("trash")
.setTooltip("Delete View")
.onClick(() => {
if (this.onDeleteView) this.onDeleteView();
});
}
// ----------------------------------------
// Statements
const statementsContainer = groupDiv.createDiv({ cls: "cv-filter-group-statements" });
group.conditions.forEach((condition, index) => {
const rowWrapper = statementsContainer.createDiv({ cls: "cv-filter-row" });
const conjLabel = rowWrapper.createSpan({ cls: "cv-conjunction-text" });
if (index === 0) {
conjLabel.innerText = "where";
} else {
// Use "or" for OR and NOR, "and" for AND
conjLabel.innerText = (group.operator === "OR" || group.operator === "NOR") ? "or" : "and";
}
if (condition.type === "group") {
rowWrapper.addClass("mod-group");
this.renderGroup(rowWrapper, condition as FilterGroup);
// Group Delete Btn (Only for subgroups)
const h = rowWrapper.querySelector(".cv-filter-group-header");
if (h) {
// Subgroups don't have the main view delete button, so we add the group trash icon
const d = h.createDiv({ cls: "cv-clickable-icon" });
// Push to right if header is flex
d.style.marginLeft = "auto";
setIcon(d, "trash-2");
d.onclick = (e) => { e.stopPropagation(); group.conditions.splice(index, 1); this.onSave(); this.onRefresh(); };
}
} else {
this.renderFilterRow(rowWrapper, condition as Filter, group, index);
}
});
// Add Actions
const actionsDiv = groupDiv.createDiv({ cls: "cv-filter-group-actions" });
this.createSimpleBtn(actionsDiv, "plus", "Add filter", () => {
group.conditions.push({ type: "filter", field: "file.name", operator: "contains", value: "" });
this.onSave(); this.onRefresh();
});
this.createSimpleBtn(actionsDiv, "plus", "Add group", () => {
group.conditions.push({ type: "group", operator: "AND", conditions: [] });
this.onSave(); this.onRefresh();
});
}
renderFilterRow(row: HTMLElement, filter: Filter, parentGroup: FilterGroup, index: number) {
const statement = row.createDiv({ cls: "cv-filter-statement" });
// --- 1. PROPERTY SELECTOR ---
const currentType = this.getPropertyType(filter.field);
const propertyBtn = statement.createDiv({ cls: "cv-combobox-button", attr: { tabindex: "-1" } });
// Add icon
if (TYPE_ICONS[currentType]) {
const icon = propertyBtn.createDiv({ cls: "cv-combobox-button-icon" });
setIcon(icon, TYPE_ICONS[currentType] || "pilcrow");
}
// Add label
const lbl = propertyBtn.createDiv({ cls: "cv-combobox-button-label" });
lbl.innerText = filter.field;
setIcon(propertyBtn.createDiv({ cls: "cv-combobox-button-chevron" }), "chevrons-up-down");
propertyBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
// Prevent the button from stealing focus after dropdown opens
propertyBtn.blur();
// Make button unfocusable
propertyBtn.setAttribute("tabindex", "-1");
this.createSearchableDropdown(
propertyBtn,
this.availableProperties.map(p => ({
label: p.key,
value: p.key,
icon: TYPE_ICONS[p.type] || "pilcrow"
})),
filter.field,
(newVal) => {
filter.field = newVal;
const newType = this.getPropertyType(newVal);
const validOps = OPERATORS[newType === "datetime" ? "date" : newType] || OPERATORS["text"];
filter.operator = validOps[0] as FilterOperator;
filter.value = "";
this.onSave();
this.onRefresh();
}
);
};
// --- 2. OPERATOR SELECTOR ---
let opsKey = currentType;
if (currentType === "datetime") opsKey = "date";
if (currentType === "unknown") opsKey = "text";
if (!OPERATORS[opsKey]) opsKey = "text";
const validOps = OPERATORS[opsKey];
const operatorBtn = statement.createDiv({ cls: "cv-combobox-button", attr: { tabindex: "-1" } });
// Add label
const opLbl = operatorBtn.createDiv({ cls: "cv-combobox-button-label" });
opLbl.innerText = filter.operator;
setIcon(operatorBtn.createDiv({ cls: "cv-combobox-button-chevron" }), "chevrons-up-down");
operatorBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
// Prevent the button from stealing focus after dropdown opens
operatorBtn.blur();
// Make button unfocusable
operatorBtn.setAttribute("tabindex", "-1");
this.createSearchableDropdown(
operatorBtn,
validOps.map(op => ({ label: op, value: op })),
filter.operator,
(newVal) => {
filter.operator = newVal as FilterOperator;
this.onSave();
this.onRefresh();
}
);
};
// --- 3. VALUE INPUT ---
if (!["is empty", "is not empty"].includes(filter.operator)) {
const rhs = statement.createDiv({ cls: "cv-filter-rhs-container" });
if (currentType === "date" || currentType === "datetime") {
const input = rhs.createEl("input", {
type: currentType === "datetime" ? "datetime-local" : "date",
value: filter.value,
attr: {
max: currentType === "datetime" ? "9999-12-31T23:59" : "9999-12-31"
}
});
input.addClass("cv-multi-select-input");
input.oninput = () => { filter.value = input.value; this.onSave(); };
} else if (currentType === "number") {
const input = rhs.createEl("input", { type: "number", value: filter.value });
input.addClass("cv-multi-select-input");
input.oninput = () => { filter.value = input.value; this.onSave(); };
} else {
const input = rhs.createEl("input", { type: "text", value: filter.value });
input.addClass("cv-multi-select-input");
input.placeholder = "Value...";
input.oninput = () => { filter.value = input.value; this.onSave(); };
}
// --- DELETE BUTTON (inside the value input container) ---
const delBtn = rhs.createDiv({ cls: "cv-clickable-icon cv-filter-delete-inside" });
setIcon(delBtn, "trash-2");
delBtn.onclick = (e) => {
e.stopPropagation();
parentGroup.conditions.splice(index, 1);
this.onSave();
this.onRefresh();
};
} else {
// For "is empty" / "is not empty", delete button goes in actions
const actions = row.createDiv({ cls: "cv-filter-row-actions" });
const delBtn = actions.createDiv({ cls: "cv-clickable-icon" });
setIcon(delBtn, "trash-2");
delBtn.onclick = (e) => {
e.stopPropagation();
parentGroup.conditions.splice(index, 1);
this.onSave();
this.onRefresh();
};
}
}
/**
* Creates a searchable dropdown container below the anchor element
*/
createSearchableDropdown(
anchorEl: HTMLElement,
items: { label: string, value: string, icon?: string }[],
selectedValue: string,
onSelect: (val: string) => void
) {
// Remove any existing dropdowns
document.querySelectorAll('.cv-suggestion-container').forEach(el => el.remove());
// Create main container
const container = document.body.createDiv({ cls: "cv-suggestion-container cv-combobox" });
const rect = anchorEl.getBoundingClientRect();
container.style.left = `${rect.left}px`;
container.style.top = `${rect.bottom + 5}px`;
// Create search input container
const searchInputContainer = container.createDiv({ cls: "cv-search-input-container" });
const searchInput = searchInputContainer.createEl("input", {
type: "search",
placeholder: "Search...",
attr: { enterkeyhint: "search", spellcheck: "false" }
});
const clearButton = searchInputContainer.createDiv({ cls: "cv-search-input-clear-button" });
// Create suggestions container
const suggestionsContainer = container.createDiv({ cls: "cv-suggestion" });
const render = (list: typeof items) => {
suggestionsContainer.empty();
// Helper to remove cv-is-selected from all items
const clearSelected = () => {
suggestionsContainer.querySelectorAll('.cv-suggestion-item').forEach(el => {
el.removeClass('cv-is-selected');
});
};
list.forEach(item => {
const suggestionItem = suggestionsContainer.createDiv({ cls: "cv-suggestion-item cv-mod-complex cv-mod-toggle" });
if (item.value === selectedValue) {
suggestionItem.addClass("cv-is-selected");
}
// Check icon (for selected item)
if (item.value === selectedValue) {
const checkIcon = suggestionItem.createDiv({ cls: "cv-suggestion-icon cv-mod-checked" });
setIcon(checkIcon, "check");
}
// Main icon
const iconDiv = suggestionItem.createDiv({ cls: "cv-suggestion-icon" });
const flair = iconDiv.createSpan({ cls: "cv-suggestion-flair" });
if (item.icon) {
setIcon(flair, item.icon);
}
// Content
const content = suggestionItem.createDiv({ cls: "cv-suggestion-content" });
content.createDiv({ cls: "cv-suggestion-title", text: item.label });
// Aux (for additional info if needed)
const aux = suggestionItem.createDiv({ cls: "cv-suggestion-aux" });
// Hover handlers
suggestionItem.onmouseenter = () => {
clearSelected();
suggestionItem.addClass('cv-is-selected');
};
suggestionItem.onclick = (evt) => {
evt.stopPropagation();
onSelect(item.value);
container.remove();
};
});
};
// Search functionality
// Blur the anchor button and make it unfocusable to prevent it from stealing focus
if (anchorEl instanceof HTMLElement) {
anchorEl.blur();
anchorEl.setAttribute("tabindex", "-1");
// Prevent focus events on the button
anchorEl.addEventListener("focus", (e) => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:515', message: 'Preventing button focus', data: { target: (e.target as HTMLElement)?.tagName }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'post-fix', hypothesisId: 'E' }) }).catch(() => { });
// #endregion
e.preventDefault();
e.stopPropagation();
anchorEl.blur();
searchInput.focus();
}, { capture: true });
}
// Use setTimeout to ensure the button has lost focus before focusing the input
setTimeout(() => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:525', message: 'Focusing input after delay', data: { documentActiveElement: document.activeElement?.tagName, containerInDOM: container.isConnected }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'post-fix', hypothesisId: 'E' }) }).catch(() => { });
// #endregion
searchInput.focus();
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:527', message: 'After focus call', data: { documentActiveElement: document.activeElement?.tagName, isInput: document.activeElement === searchInput }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'post-fix', hypothesisId: 'E' }) }).catch(() => { });
// #endregion
}, 10);
container.addClass("cv-has-input-focus");
searchInput.addEventListener("input", (e: any) => {
const val = e.target.value.toLowerCase();
const filtered = items.filter(i => i.label.toLowerCase().includes(val) || i.value.toLowerCase().includes(val));
render(filtered);
});
searchInput.addEventListener("focus", (e) => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:513', message: 'Input focus event', data: { target: document.activeElement?.tagName, relatedTarget: (e as FocusEvent).relatedTarget?.tagName }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'E' }) }).catch(() => { });
// #endregion
container.addClass("cv-has-input-focus");
});
searchInput.addEventListener("blur", (e) => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:516', message: 'Input blur event', data: { target: document.activeElement?.tagName, relatedTarget: (e as FocusEvent).relatedTarget?.tagName, containerStillExists: container.isConnected }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'E' }) }).catch(() => { });
// #endregion
container.removeClass("cv-has-input-focus");
});
searchInput.addEventListener("click", (e) => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:520', message: 'Input click event', data: { target: (e.target as HTMLElement)?.tagName, containerContainsTarget: container.contains(e.target as Node) }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'A' }) }).catch(() => { });
// #endregion
e.stopPropagation();
});
searchInput.addEventListener("mousedown", (e) => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:524', message: 'Input mousedown event', data: { target: (e.target as HTMLElement)?.tagName }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'C' }) }).catch(() => { });
// #endregion
e.stopPropagation();
});
// Clear button functionality
clearButton.onclick = () => {
searchInput.value = "";
searchInput.focus();
render(items);
};
// Initial render
render(items);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:527', message: 'After render', data: { documentActiveElement: document.activeElement?.tagName, isInput: document.activeElement === searchInput }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'B' }) }).catch(() => { });
// #endregion
// Close handler
const closeHandler = (evt: MouseEvent) => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:531', message: 'closeHandler triggered', data: { targetTag: (evt.target as HTMLElement)?.tagName, targetClass: (evt.target as HTMLElement)?.className, isSearchInput: evt.target === searchInput, containerContains: container.contains(evt.target as Node), willClose: !container.contains(evt.target as Node) && evt.target !== anchorEl && !anchorEl.contains(evt.target as Node) }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'A' }) }).catch(() => { });
// #endregion
if (!container.contains(evt.target as Node) && evt.target !== anchorEl && !anchorEl.contains(evt.target as Node)) {
container.remove();
document.removeEventListener("click", closeHandler);
}
};
setTimeout(() => {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/449950d1-16ff-4fc2-beef-90db3e564ad1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'settings.ts:537', message: 'Registering closeHandler', data: { documentActiveElement: document.activeElement?.tagName }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'A' }) }).catch(() => { });
// #endregion
document.addEventListener("click", closeHandler);
}, 0);
}
/**
* Core method to spawn the floating menu (Shared by Conjunction and Dropdowns)
*/
createPopover(
anchorEl: HTMLElement,
items: { label: string, value: string, icon?: string }[],
onSelect: (val: string) => void,
selectedValue?: string,
enableSearch: boolean = false
) {
document.querySelectorAll('.cv-popover').forEach(el => el.remove());
const popover = document.body.createDiv({ cls: "cv-popover" });
const rect = anchorEl.getBoundingClientRect();
popover.style.top = `${rect.bottom + 5}px`;
popover.style.left = `${rect.left}px`;
const listContainer = popover.createDiv({ cls: "cv-popover-list" });
const render = (list: typeof items) => {
listContainer.empty();
list.forEach(opt => {
const item = listContainer.createDiv({ cls: "cv-popover-item" });
if (opt.value === selectedValue) item.addClass("is-selected");
// [FIXED] Created wrapper 'cv-popover-content' to hold Icon + Text
const contentWrapper = item.createDiv({ cls: "cv-popover-content" });
if (opt.icon) {
const iconDiv = contentWrapper.createDiv({ cls: "cv-popover-icon" });
setIcon(iconDiv, opt.icon);
}
contentWrapper.createDiv({ cls: "cv-popover-label", text: opt.label });
if (opt.value === selectedValue) {
const check = item.createDiv({ cls: "cv-popover-check" });
setIcon(check, "check");
}
item.onclick = (evt) => {
evt.stopPropagation();
onSelect(opt.value);
popover.remove();
};
});
};
if (enableSearch) {
const searchContainer = popover.createDiv({ cls: "cv-popover-search" });
const searchInput = searchContainer.createEl("input", { type: "text", placeholder: "Search..." });
searchContainer.prepend(searchInput);
searchInput.focus();
searchInput.addEventListener("input", (e: any) => {
const val = e.target.value.toLowerCase();
render(items.filter(i => i.label.toLowerCase().includes(val)));
});
}
render(items);
const closeHandler = (evt: MouseEvent) => {
if (!popover.contains(evt.target as Node) && evt.target !== anchorEl && !anchorEl.contains(evt.target as Node)) {
popover.remove();
document.removeEventListener("click", closeHandler);
}
};
setTimeout(() => document.addEventListener("click", closeHandler), 0);
}
createSimpleBtn(container: HTMLElement, icon: string, text: string, onClick: () => void) {
const btn = container.createDiv({ cls: "cv-text-icon-button", attr: { tabindex: "0" } });
setIcon(btn.createSpan({ cls: "cv-text-button-icon" }), icon);
btn.createSpan({ cls: "cv-text-button-label", text: text });
btn.onclick = (e) => { e.stopPropagation(); onClick(); };
}
}

27
src/types.ts Normal file
View file

@ -0,0 +1,27 @@
export type FilterOperator =
| "contains" | "does not contain"
| "is" | "is not"
| "starts with" | "ends with"
| "is empty" | "is not empty";
export type FilterConjunction = "AND" | "OR" | "NOR"; // All, Any, None
export interface Filter {
type: "filter";
field: string;
operator: FilterOperator;
value?: string;
}
export interface FilterGroup {
type: "group";
operator: FilterConjunction;
conditions: (Filter | FilterGroup)[];
}
export interface ViewConfig {
id: string;
name: string;
rules: FilterGroup;
template: string;
}

625
styles.css Normal file
View file

@ -0,0 +1,625 @@
.custom-view-wrapper {
padding: 20px;
height: 100%;
overflow-y: auto;
}
.movie-cover img {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.cv-custom-view-box {
display: flex
;
flex-direction: column;
border: var(--menu-border-width) solid var(--menu-border-color);
background-color: var(--menu-background);
backdrop-filter: var(--menu-backdrop-filter)
none
;
margin-top: var(--size-4-4);
border-radius: var(--menu-radius);
/* box-shadow: var(--menu-shadow); */
max-height: 100%;
/* position: fixed; */
z-index: var(--layer-menu);
user-select: none;
}
.cv-custom-view-box-header {
padding: 0;
}
.cv-custom-view-box-header button {
background-color: transparent;
box-shadow: none;
-webkit-app-region: no-drag;
background-color: transparent;
display: flex
;
align-items: center;
justify-content: center;
padding: var(--size-2-2) var(--size-2-3);
cursor: var(--cursor);
border-radius: var(--clickable-icon-radius);
color: var(--icon-color);
opacity: var(--icon-opacity);
transition: opacity var(--anim-duration-fast) ease-in-out;
height: auto;
-electron-corner-smoothing: var(--corner-smoothing);
}
.cv-custom-view-box-header button:hover {
box-shadow: none;
opacity: var(--icon-opacity-hover);
color: var(--icon-color-hover);
background-color: var(--background-modifier-hover);
}
.cv-custom-view-box-header input {
flex-grow: 1;
background-color: transparent;
border: none;
color: var(--text-normal);
font-weight: var(--font-medium);
font-size: var(--font-ui-normal);
}
.cv-textarea {
height: 250px;
width: 100%;
font-family: var(--font-monospace);
}
.cv-codemirror-container {
height: 250px;
width: 100%;
border: 1px solid var(--background-modifier-border);
border-radius: var(--input-radius);
overflow: hidden;
}
.cv-codemirror-container .cm-editor {
height: 100%;
}
.cv-codemirror-container .cm-scroller {
font-family: var(--font-monospace);
font-size: var(--font-text-size);
}
/* Container Reset */
.cv-bases-query-container, .cv-bases-template-container {
padding: var(--size-4-4);
}
.cv-bases-query-container {
padding: var(--size-4-4);
border-bottom: var(--border-width) solid var(--background-modifier-border);
}
/* Group Container */
.cv-filter-group {
gap: var(--size-2-3);
display: flex;
flex-direction: column;
width: 100%;
}
/* Top Level Group: No left border/padding */
.cv-bases-query-container > .cv-filter-group {
padding-left: 0;
border-left: none;
}
.cv-bases-query-container .cv-filter-group .cv-filter-group {
background-color: var(--background-primary-alt);
border: var(--table-border-width) solid var(--table-border-color);
padding: var(--size-2-3);
border-radius: var(--radius-m);
}
.cv-bases-section-header {
margin-bottom: var(--size-4-3);
color: var(--text-normal);
font-weight: var(--font-medium);
font-size: var(--font-ui-normal);
}
/* Group Header */
.cv-filter-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--size-2-1);
}
.cv-filter-group-statements {
width: 100%;
gap: var(--size-2-3);
display: flex;
flex-direction: column;
}
/* Conjunction Dropdown (All/Any/None) */
select.cv-conjunction {
padding-top: 0;
background-color: transparent;
font-size: var(--font-ui-small);
box-shadow: none;
text-align: start;
}
select.cv-conjunction:hover {
background-color: var(--background-modifier-hover);
box-shadow: none;
}
.cv-bases-query-container .cv-filter-group .cv-filter-group-header .cv-conjunction.cv-dropdown {
padding-top: 0;
background-color: transparent;
font-size: var(--font-ui-small);
box-shadow: none;
text-align: start;
}
/* Filter Row */
.cv-filter-row {
display: flex;
align-items: center;
gap: var(--size-4-2);
}
/* "Where" / "And" Label */
.cv-conjunction-text {
display: flex;
align-items: center;
height: 32px;
min-width: 45px;
justify-content: flex-end;
color: var(--text-muted);
font-size: 13px;
user-select: none;
}
/* The Main Filter Statement Box */
.cv-filter-statement {
--input-shadow: none;
--icon-size: var(--icon-s);
--icon-stroke: var(--icon-s-stroke-width);
--metadata-divider-width: 0px;
--metadata-label-width: 7em;
font-size: var(--font-ui-small);
box-shadow: 0 0 0 1px var(--background-modifier-border-hover);
border-radius: var(--menu-radius);
background-color: var(--interactive-normal);
width: 100%;
align-items: center;
display: flex;
overflow: hidden;
}
.cv-filter-statement:focus-within {
box-shadow: var(--metadata-property-box-shadow-focus);
border-radius: var(--metadata-property-radius-focus);
}
/* Combobox Buttons (Property & Operator) */
.cv-combobox-button {
display: flex;
align-items: center;
gap: var(--size-2-2);
min-height: var(--input-height);
padding-inline-start: var(--size-4-2);
padding-inline-end: var(--size-2-3);
overflow: hidden;
white-space: nowrap;
}
.cv-combobox-button:nth-of-type(2) {
border-width: 0 1px;
max-width: 33%;
align-self: stretch;
height: auto;
border-style: solid;
border-color: var(--background-modifier-border);
}
.cv-combobox-button:hover {
background: var(--background-modifier-hover);
}
.cv-combobox-button-label {
font-size: 13px;
}
.cv-combobox-button *, .cv-text-button-icon {
display: flex;
align-items: center;
}
.cv-combobox-button .cv-combobox-button-chevron {
--icon-size: var(--icon-xs);
--icon-stroke: var(--icon-xs-stroke-width);
align-items: center;
display: flex;
}
.cv-combobox-button-icon svg,
.cv-combobox-button-chevron svg,
.cv-combobox-clear-button svg {
height: var(--icon-size);
width: var(--icon-size);
stroke-width: var(--icon-stroke);
}
/* Value Input Container */
.cv-filter-rhs-container {
flex: 1;
display: flex;
align-items: center;
min-width: 100px;
padding: 0 4px;
position: relative;
}
.cv-filter-rhs-container input, .cv-filter-rhs-container input:active, .cv-filter-rhs-container input:focus {
border-radius: 0;
border: none ;
box-shadow: none ;
}
.cv-filter-delete-inside {
position: absolute;
right: 4px;
flex-shrink: 0;
z-index: 1;
}
.cv-multi-select-input {
width: 100%;
border: none;
background: transparent;
padding-right: 28px;
outline: none;
padding: 4px;
font-size: 13px;
color: var(--text-normal);
}
/* Row Actions (Delete/Advanced) */
.cv-filter-row-actions {
display: flex;
align-items: center;
gap: 2px;
opacity: 0; /* Hidden until hover */
transition: opacity 0.2s;
}
.cv-filter-row:hover .cv-filter-row-actions {
opacity: 1;
}
.cv-clickable-icon {
-webkit-app-region: no-drag;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
padding: var(--size-2-2) var(--size-2-3);
cursor: var(--cursor);
border-radius: var(--clickable-icon-radius);
color: var(--icon-color);
opacity: var(--icon-opacity);
transition: opacity var(--anim-duration-fast) ease-in-out;
height: auto;
-electron-corner-smoothing: var(--corner-smoothing);
}
@media (hover: hover) {
.cv-clickable-icon:hover {
box-shadow: none;
opacity: var(--icon-opacity-hover);
color: var(--icon-color-hover);
background-color: var(--background-modifier-hover);
}
}
/* Bottom Action Buttons (+ Add filter) */
.cv-filter-group-actions {
display: flex;
gap: 12px;
padding-left: 53px; /* Align with statements */
margin-top: 4px;
}
.cv-text-icon-button {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
padding: 4px 8px;
border-radius: 4px;
user-select: none;
}
.cv-text-icon-button:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.cv-text-button-icon svg {
width: 16px;
height: 16px;
}
.cv-popover-list {
padding: var(--size-2-2);
}
.cv-popover-icon {
margin-right: 8px;
color: var(--text-muted);
display: flex;
align-items: center;
flex-shrink: 0;
}
.cv-popover-icon svg {
height: var(--icon-size);
width: var(--icon-size);
stroke-width: var(--icon-stroke);
}
.cv-popover-item {
padding: var(--size-2-2) var(--size-4-2);
display: flex
;
align-items: center;
justify-content: space-between;
cursor: pointer;
font-size: 13px;
border-radius: var(--size-2-2);
}
.cv-popover, .cv-suggestion-container {
display: flex;
position: absolute;
z-index: 60;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
box-shadow: var(--shadow-s);
border-radius: var(--radius-m);
max-height: var(--popover-max-height);
}
/* Conjunction Text Styles */
.cv-conjunction-trigger {
display: flex;
align-items: center;
cursor: pointer;
font-weight: 500;
color: var(--text-normal);
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.cv-conjunction-trigger span {
font-size: var(--font-ui-small);
}
.cv-conjunction-trigger:hover {
background-color: var(--background-modifier-hover);
}
.cv-conjunction-icon {
margin-left: 6px;
color: var(--text-muted);
display: flex;
}
.cv-suggestion-container {
align-self: stretch;
overflow: hidden;
display: flex;
position: absolute;
flex-direction: column;
max-width: 500px;
max-height: 300px;
z-index: var(--layer-notice);
}
.cv-combobox {
}
.cv-has-input-focus {
}
.cv-combobox .cv-search-input-container {
--search-icon-color: var(--text-faint);
margin-top: 2px;
}
.cv-search-input-container {
position: relative;
}
.cv-search-input-container:before {
top: calc((var(--input-height) - var(--search-icon-size)) / 2);
inset-inline-start: var(--size-4-2);
position: absolute;
content: '';
height: var(--search-icon-size);
width: var(--search-icon-size);
display: block;
background-color: var(--search-icon-color);
-webkit-mask-image: url("data:image/svg+xml,<svg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 24 24%27 fill=%27none%27 stroke=%27currentColor%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27><circle cx=%2711%27 cy=%2711%27 r=%278%27></circle><line x1=%2721%27 y1=%2721%27 x2=%2716.65%27 y2=%2716.65%27></line></svg>");
-webkit-mask-repeat: no-repeat;
}
.cv-combobox .cv-search-input-container input[type=search] {
border: 0;
box-shadow: none;
background-color: transparent;
}
.cv-search-input-container input {
display: block;
width: 100%;
padding-inline-start: 36px;
}
.cv-search-input-clear-button {
}
.cv-suggestion {
overflow-y: auto;
padding: var(--size-2-3);
}
.cv-combobox .cv-suggestion-item {
font-size: var(--font-ui-small);
padding: var(--size-2-3) var(--size-4-2) var(--size-2-3) var(--size-4-2);
}
.cv-suggestion-item.cv-mod-complex {
align-items: baseline;
display: flex;
justify-content: space-between;
}
.combobox .suggestion-item {
font-size: var(--font-ui-small);
padding: var(--size-2-3) var(--size-4-2) var(--size-2-3) var(--size-4-2);
}
.cv-suggestion-item {
cursor: var(--cursor);
padding: var(--size-2-3) var(--size-4-3);
white-space: pre-wrap;
border-radius: var(--radius-s);
}
.cv-suggestion-item, .cv-suggestion-empty {
font-size: var(--font-ui-medium);
margin-bottom: 1px;
}
.cv-mod-complex {
}
.cv-mod-toggle {
}
.cv-suggestion-item.cv-is-selected {
background-color: var(--background-modifier-hover);
}
.cv-suggestion-item.cv-mod-toggle .cv-mod-checked {
--icon-size: var(--icon-xs);
--icon-stroke: var(--icon-xs-stroke-width);
order: 2;
}
.cv-suggestion-flair .svg-icon {
--icon-size: var(--icon-s);
--icon-stroke: var(--icon-s-stroke-width);
}
.cv-suggestion-item.cv-mod-complex .cv-suggestion-icon, .cv-suggestion-item.cv-mod-complex .cv-suggestion-aux {
display: flex;
align-items: center;
align-self: center;
flex-shrink: 0;
}
.cv-mod-checked {
}
.cv-suggestion-item.cv-mod-complex .cv-suggestion-icon .cv-suggestion-flair {
margin-top: 0;
margin-inline-start: var(--size-4-1);
margin-bottom: 0;
margin-inline-end: var(--size-4-3);
}
.cv-suggestion-item.cv-mod-complex .cv-suggestion-flair {
color: var(--text-muted);
opacity: var(--icon-opacity);
display: flex;
align-items: center;
}
.cv-suggestion-item.cv-mod-complex .cv-suggestion-content {
display: flex;
flex-direction: column;
overflow: hidden;
text-overflow: ellipsis;
margin-inline-end: auto;
}
.cv-suggestion-title {
}
.cv-suggestion-aux {
}
.cv-list-value-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
min-height: 32px;
padding: 4px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.cv-list-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background-color: var(--background-modifier-border);
border-radius: 4px;
font-size: 0.9em;
}
.cv-list-chip-remove {
cursor: pointer;
display: flex;
align-items: center;
opacity: 0.7;
}
.cv-list-chip-remove:hover {
opacity: 1;
}
.cv-list-input-container {
display: inline-flex;
flex: 1;
min-width: 120px;
}
.cv-list-input {
border: none;
outline: none;
background: transparent;
flex: 1;
min-width: 0;
}
.cv-text-input-wrapper {
position: relative;
}

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

17
version-bump.mjs Normal file
View file

@ -0,0 +1,17 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const 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
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
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": "0.15.0"
}