mirror of
https://github.com/jackcarey/obsidian-rule-engine.git
synced 2026-07-22 14:00:24 +00:00
Initial commit – replace repo contents
This commit is contained in:
commit
2b45af1de1
21 changed files with 5065 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
3
.eslintignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
23
.eslintrc
Normal file
23
.eslintrc
Normal 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
23
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
251
AGENTS.md
Normal file
251
AGENTS.md
Normal 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
94
README.md
Normal 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
50
esbuild.config.mjs
Normal 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
11
manifest.json
Normal 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
2427
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal 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
187
src/filters.ts
Normal 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 => ``).join("\n");
|
||||
return ``;
|
||||
},
|
||||
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
261
src/main.ts
Normal 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
89
src/matcher.ts
Normal 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
160
src/renderer.ts
Normal 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
752
src/settings.ts
Normal 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
27
src/types.ts
Normal 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
625
styles.css
Normal 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
24
tsconfig.json
Normal 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
17
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue