mirror of
https://github.com/nn-ninja/n-brace.git
synced 2026-07-22 06:42:36 +00:00
working on setting
This commit is contained in:
parent
690e026ed8
commit
861ceeb5d4
22 changed files with 469 additions and 189 deletions
|
|
@ -11,38 +11,38 @@ if you want to view the source, please visit the github repository of this plugi
|
|||
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/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",
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
await context.watch();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { BasicSearchEngine } from "@/BasicSearchEngine";
|
||||
import { DvSearchEngine } from "@/DvSearchEngine";
|
||||
import { IFileManager, IPassiveSearchEngine, IActiveSearchEngine } from "@/Interfaces";
|
||||
import { PassiveSearchEngine } from "@/PassiveSearchEngine";
|
||||
import { SearchEngineType } from "@/SettingsSchemas";
|
||||
import Graph3dPlugin from "@/main";
|
||||
|
||||
/**
|
||||
|
|
@ -8,9 +12,21 @@ export class MyFileManager implements IFileManager {
|
|||
private plugin: Graph3dPlugin;
|
||||
public searchEngine: IActiveSearchEngine | IPassiveSearchEngine;
|
||||
|
||||
constructor(plugin: Graph3dPlugin, searchEngine: IActiveSearchEngine | IPassiveSearchEngine) {
|
||||
constructor(plugin: Graph3dPlugin) {
|
||||
this.plugin = plugin;
|
||||
this.searchEngine = searchEngine;
|
||||
this.setSearchEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
* this will set the search engine base on the setting
|
||||
*/
|
||||
setSearchEngine() {
|
||||
const searchEngine = this.plugin.settingManager.getSettings().pluginSetting.searchEngine;
|
||||
if (searchEngine === SearchEngineType.default)
|
||||
this.searchEngine = new BasicSearchEngine(this.plugin);
|
||||
else if (searchEngine === SearchEngineType.dataview)
|
||||
this.searchEngine = new DvSearchEngine(this.plugin);
|
||||
else this.searchEngine = new PassiveSearchEngine(this.plugin);
|
||||
}
|
||||
|
||||
getFiles() {
|
||||
|
|
@ -26,8 +42,7 @@ export class MyFileManager implements IFileManager {
|
|||
// check whether it is active search engine
|
||||
if (this.searchEngine instanceof IActiveSearchEngine) {
|
||||
return this.searchEngine.searchFiles(this.searchEngine.parseQueryToConfig(query));
|
||||
} else {
|
||||
return this.searchEngine.getFiles();
|
||||
}
|
||||
throw new Error("passive search engine cannot search files");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { TAbstractFile } from "obsidian";
|
||||
import { SearchView, TAbstractFile } from "obsidian";
|
||||
|
||||
/**
|
||||
* the config object of the search engine
|
||||
|
|
@ -53,15 +53,11 @@ export abstract class IActiveSearchEngine extends ISearchEngine {
|
|||
}
|
||||
|
||||
export abstract class IPassiveSearchEngine extends ISearchEngine {
|
||||
/**
|
||||
* when the result is change, this callback will be called.
|
||||
* This mutation should only be fired when the result is stable
|
||||
*/
|
||||
abstract mutationCallback: (files: TAbstractFile[]) => void;
|
||||
/**
|
||||
* an active way to call it
|
||||
*/
|
||||
abstract getFiles(): TAbstractFile[];
|
||||
abstract addMutationObserver(
|
||||
searchResultContainerEl: HTMLDivElement,
|
||||
view: SearchView,
|
||||
mutationCallback: (files: TAbstractFile[]) => void
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface IFileManager {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,54 @@
|
|||
import { IPassiveSearchEngine } from "@/Interfaces";
|
||||
import { TAbstractFile } from "obsidian";
|
||||
import Graph3dPlugin from "@/main";
|
||||
import { waitForStable } from "@/util/waitFor";
|
||||
import { SearchView, TAbstractFile, TFile } from "obsidian";
|
||||
|
||||
export type SearchResultFile = ReturnType<typeof getFilesFromSearchResult>[0];
|
||||
|
||||
/**
|
||||
* given the result from `getResultFromSearchView`, return the files
|
||||
*/
|
||||
const getFilesFromSearchResult = (rawSearchResult: unknown) => {
|
||||
// @ts-ignore
|
||||
return Array.from(rawSearchResult.keys()) as TFile[];
|
||||
};
|
||||
|
||||
const getResultFromSearchView = async (searchView: SearchView) => {
|
||||
await waitForStable(() => {
|
||||
return searchView.dom.resultDomLookup.size;
|
||||
}, {});
|
||||
return searchView.dom.resultDomLookup;
|
||||
};
|
||||
|
||||
/**
|
||||
* this is the built in search engine that uses the obsidian search engine
|
||||
*/
|
||||
export class PassiveSearchEngine implements IPassiveSearchEngine {
|
||||
useBuiltInSearchInput = true;
|
||||
mutationCallback: (files: TAbstractFile[]) => void;
|
||||
plugin: Graph3dPlugin;
|
||||
|
||||
constructor(mutationCallback: (files: TAbstractFile[]) => void) {
|
||||
this.mutationCallback = mutationCallback;
|
||||
|
||||
// init the listeners for the changes
|
||||
constructor(plugin: Graph3dPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
getFiles(): TAbstractFile[] {
|
||||
throw new Error("Method not implemented.");
|
||||
/**
|
||||
* given a search result container element, add a mutation observer to it
|
||||
*/
|
||||
addMutationObserver(
|
||||
searchResultContainerEl: HTMLDivElement,
|
||||
view: SearchView,
|
||||
mutationCallback: (files: TAbstractFile[]) => void
|
||||
) {
|
||||
const observer = new MutationObserver(async (mutations) => {
|
||||
// if the search result is loading or the cache is not ready, then we know that the search must not be ready yet
|
||||
if (searchResultContainerEl.classList.contains("is-loading") || !this.plugin.cacheIsReady)
|
||||
return;
|
||||
const files = getFilesFromSearchResult(await getResultFromSearchView(view));
|
||||
mutationCallback(files);
|
||||
});
|
||||
observer.observe(searchResultContainerEl, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ export class MySettingManager implements ISettingManager<Setting> {
|
|||
*/
|
||||
private isLoaded = false;
|
||||
|
||||
/**
|
||||
* @remarks don't forget to call `loadSettings` after creating this class
|
||||
*/
|
||||
constructor(plugin: Graph3dPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export enum GraphType {
|
|||
export enum SearchEngineType {
|
||||
dataview = "dataview",
|
||||
default = "default",
|
||||
builtIn = "built-in",
|
||||
builtIn = "builtIn",
|
||||
}
|
||||
|
||||
export enum DagOrientation {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link } from "@/graph/Link";
|
||||
import { Node } from "@/graph/Node";
|
||||
import { App } from "obsidian";
|
||||
import { App, TAbstractFile } from "obsidian";
|
||||
|
||||
export class Graph {
|
||||
public readonly nodes: Node[];
|
||||
|
|
@ -77,43 +77,62 @@ export class Graph {
|
|||
}
|
||||
|
||||
/**
|
||||
* This method retrieves the local graph of a node, which includes all the other nodes and links
|
||||
* that are connected to the given node recursively based on the specified depth and link type.
|
||||
*
|
||||
* @param nodeId
|
||||
* @returns the local graph of a node
|
||||
*/
|
||||
public getLocalGraph(nodeId: string): Graph {
|
||||
const node = this.getNodeById(nodeId);
|
||||
if (node) {
|
||||
const nodes = [node, ...node.neighbors];
|
||||
const links: Link[] = [];
|
||||
const nodeIndex = new Map<string, number>();
|
||||
public getLocalGraph(
|
||||
param: (
|
||||
| {
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
path: string;
|
||||
}
|
||||
) & {
|
||||
depth: number;
|
||||
linkType: "both" | "inlinks" | "outlinks";
|
||||
}
|
||||
): Graph {
|
||||
const node = "id" in param ? this.getNodeById(param.id) : this.getNodeByPath(param.path);
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
nodeIndex.set(node.id, index);
|
||||
});
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
const nodeIndex = new Map<string, number>();
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const filteredLinks = node.links
|
||||
.filter((link) => nodeIndex.has(link.target.id) && nodeIndex.has(link.source.id))
|
||||
.map((link) => {
|
||||
if (
|
||||
!links.includes(link) &&
|
||||
nodeIndex.has(link.target.id) &&
|
||||
nodeIndex.has(link.source.id)
|
||||
)
|
||||
links.push(link);
|
||||
return link;
|
||||
});
|
||||
|
||||
node.links.splice(0, node.links.length, ...filteredLinks);
|
||||
});
|
||||
|
||||
const linkIndex = Link.createLinkIndex(links);
|
||||
|
||||
return new Graph(nodes, links, nodeIndex, linkIndex);
|
||||
} else {
|
||||
if (!node) {
|
||||
return new Graph([], [], new Map(), new Map());
|
||||
}
|
||||
|
||||
const traverseNeighbors = (currentNode: Node, currentDepth: number) => {
|
||||
if (currentDepth > param.depth || nodeIndex.has(currentNode.id)) return; // Depth limit reached or node already traversed
|
||||
|
||||
nodes.push(currentNode);
|
||||
nodeIndex.set(currentNode.id, nodes.length - 1);
|
||||
|
||||
currentNode.links.forEach((link) => {
|
||||
if (
|
||||
param.linkType === "both" ||
|
||||
(param.linkType === "inlinks" && link.target.id === currentNode.id) ||
|
||||
(param.linkType === "outlinks" && link.source.id === currentNode.id)
|
||||
) {
|
||||
if (!links.includes(link)) {
|
||||
links.push(link);
|
||||
traverseNeighbors(
|
||||
link.target.id === currentNode.id ? link.source : link.target,
|
||||
currentDepth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverseNeighbors(node, 0);
|
||||
|
||||
const linkIndex = Link.createLinkIndex(links);
|
||||
|
||||
return new Graph(nodes, links, nodeIndex, linkIndex);
|
||||
}
|
||||
|
||||
// Clones the graph
|
||||
|
|
@ -126,6 +145,10 @@ export class Graph {
|
|||
);
|
||||
};
|
||||
|
||||
public static createEmpty = (): Graph => {
|
||||
return new Graph([], [], new Map(), new Map());
|
||||
};
|
||||
|
||||
// Creates a graph using the Obsidian API
|
||||
public static createFromApp = (app: App): Graph => {
|
||||
const [nodes, nodeIndex] = Node.createFromFiles(app.vault.getFiles()),
|
||||
|
|
@ -133,6 +156,16 @@ export class Graph {
|
|||
return new Graph(nodes, links, nodeIndex, linkIndex);
|
||||
};
|
||||
|
||||
public static createFromFiles = (files: TAbstractFile[], app: App): Graph => {
|
||||
const [nodes, nodeIndex] = Node.createFromFiles(files),
|
||||
[links, linkIndex] = Link.createFromCache(app.metadataCache.resolvedLinks, nodes, nodeIndex);
|
||||
const tempGraph = new Graph(nodes, links, nodeIndex, linkIndex);
|
||||
// const tempGraph = Graph.createFromApp(app);
|
||||
// since the nodes are from the files, they are already correct.
|
||||
// we just need to update the links, we can pass into Boolean as the predicate
|
||||
return this.createFromMask(Boolean, tempGraph);
|
||||
};
|
||||
|
||||
// updates this graph with new data from the Obsidian API
|
||||
public update = (app: App) => {
|
||||
const newGraph = Graph.createFromApp(app);
|
||||
|
|
@ -153,12 +186,13 @@ export class Graph {
|
|||
|
||||
/**
|
||||
* filter the nodes of the graph, the links will be filtered automatically.
|
||||
* @param predicate this
|
||||
* @param predicate what nodes to keep
|
||||
* @param graph the graph to filter
|
||||
* @returns a new graph
|
||||
*/
|
||||
public filter = (predicate: (node: Node) => boolean) => {
|
||||
const filteredNodes = this.nodes.filter(predicate);
|
||||
const filteredLinks = this.links.filter((link) => {
|
||||
public static createFromMask = (predicate: (node: Node) => boolean, graph: Graph) => {
|
||||
const filteredNodes = graph.nodes.filter(predicate);
|
||||
const filteredLinks = graph.links.filter((link) => {
|
||||
// the source and target nodes of a link must be in the filtered nodes
|
||||
return (
|
||||
filteredNodes.some((node) => link.source.id === node.id) &&
|
||||
|
|
@ -175,4 +209,44 @@ export class Graph {
|
|||
|
||||
return new Graph(filteredNodes, filteredLinks, nodeIndex, linkIndex);
|
||||
};
|
||||
|
||||
public static compare = (graph1: Graph, graph2: Graph): boolean => {
|
||||
if (graph1.nodes.length !== graph2.nodes.length) {
|
||||
return false;
|
||||
}
|
||||
if (graph1.links.length !== graph2.links.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const graph2NodeIds = new Set(graph2.nodes.map((node) => node.id));
|
||||
|
||||
// Check if all nodes in graph1 exist in graph2
|
||||
for (const node1 of graph1.nodes) {
|
||||
if (!graph2NodeIds.has(node1.id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getLinkId(link: Link): string {
|
||||
return `${link.source.path}-${link.target.path}`;
|
||||
}
|
||||
|
||||
const graph2LinkIds = new Set(graph2.links.map(getLinkId));
|
||||
|
||||
// Check if all links in graph1 exist in graph2
|
||||
for (const link1 of graph1.links) {
|
||||
if (!graph2LinkIds.has(getLinkId(link1))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* get the files from the graph
|
||||
*/
|
||||
public static getFiles(app: App, graph: Graph): TAbstractFile[] {
|
||||
return graph.nodes.map((node) => app.vault.getAbstractFileByPath(node.path)).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,4 +72,8 @@ export class Link {
|
|||
|
||||
return [links, Link.createLinkIndex(links)];
|
||||
}
|
||||
|
||||
public static compare = (a: Link, b: Link) => {
|
||||
return a.source.path === b.source.path && a.target.path === b.target.path;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Link } from "@/graph/Link";
|
||||
import { TFile } from "obsidian";
|
||||
import { TAbstractFile } from "obsidian";
|
||||
|
||||
export class Node {
|
||||
public readonly id: string;
|
||||
|
|
@ -20,7 +20,7 @@ export class Node {
|
|||
}
|
||||
|
||||
// Creates an array of nodes from an array of files (from the Obsidian API)
|
||||
static createFromFiles(files: TFile[]): [Node[], Map<string, number>] {
|
||||
static createFromFiles(files: TAbstractFile[]): [Node[], Map<string, number>] {
|
||||
const nodeMap = new Map<string, number>();
|
||||
return [
|
||||
files
|
||||
|
|
@ -64,4 +64,8 @@ export class Node {
|
|||
if (node instanceof Node) return this.neighbors.includes(node);
|
||||
else return this.neighbors.some((neighbor) => neighbor.id === node);
|
||||
}
|
||||
|
||||
public static compare = (a: Node, b: Node) => {
|
||||
return a.path === b.path;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
31
src/main.ts
31
src/main.ts
|
|
@ -10,16 +10,14 @@ import { eventBus } from "@/util/EventBus";
|
|||
import { SettingTab } from "@/settings/SettingTab";
|
||||
import { config } from "@/config";
|
||||
import { MyFileManager } from "@/FileManager";
|
||||
import { BasicSearchEngine } from "@/BasicSearchEngine";
|
||||
import { DvSearchEngine } from "@/DvSearchEngine";
|
||||
import { MySettingManager } from "@/SettingManager";
|
||||
import { GraphType, SearchEngineType } from "@/SettingsSchemas";
|
||||
import { GraphType } from "@/SettingsSchemas";
|
||||
import { SearchManager } from "@/SearchManager";
|
||||
import { NewGraph3dView } from "@/views/graph/NewGraph3dView";
|
||||
|
||||
export default class Graph3dPlugin extends Plugin {
|
||||
_resolvedCache: ResolvedLinkCache;
|
||||
private cacheIsReady: State<boolean> = new State(
|
||||
public readonly cacheIsReady: State<boolean> = new State(
|
||||
this.app.metadataCache.resolvedLinks !== undefined
|
||||
);
|
||||
/**
|
||||
|
|
@ -42,18 +40,13 @@ export default class Graph3dPlugin extends Plugin {
|
|||
this.settingManager = new MySettingManager(this);
|
||||
|
||||
// load the setting using setting manager
|
||||
const settings = await this.settingManager.loadSettings();
|
||||
await this.settingManager.loadSettings();
|
||||
|
||||
// get the setting from setting manager
|
||||
// const setting = this.settingManager.getSetting("test");
|
||||
|
||||
// initalise the file manager
|
||||
this.fileManager = new MyFileManager(
|
||||
this,
|
||||
settings.pluginSetting.searchEngine === SearchEngineType.dataview
|
||||
? new DvSearchEngine(this)
|
||||
: new BasicSearchEngine(this)
|
||||
);
|
||||
this.fileManager = new MyFileManager(this);
|
||||
|
||||
// init the theme
|
||||
this.theme = new ObsidianTheme(this.app.workspace.containerEl);
|
||||
|
|
@ -110,12 +103,19 @@ export default class Graph3dPlugin extends Plugin {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* this will be called the when the cache is ready.
|
||||
* And this will hit the else clause of the `onGraphCacheChanged` function
|
||||
*/
|
||||
private onGraphCacheReady = () => {
|
||||
console.log("Graph cache is ready");
|
||||
this.cacheIsReady.value = true;
|
||||
this.onGraphCacheChanged();
|
||||
};
|
||||
|
||||
/**
|
||||
* check if the cache is ready and if it is, update the global graph
|
||||
*/
|
||||
public onGraphCacheChanged = () => {
|
||||
// check if the cache actually updated
|
||||
// Obsidian API sends a lot of (for this plugin) unnecessary stuff
|
||||
|
|
@ -133,6 +133,15 @@ export default class Graph3dPlugin extends Plugin {
|
|||
" and ",
|
||||
deepCompare(this._resolvedCache, this.app.metadataCache.resolvedLinks)
|
||||
);
|
||||
|
||||
// update global graph view
|
||||
this.activeGraphViews.forEach((view) => {
|
||||
if (view.graphType === GraphType.global) {
|
||||
view.updateGraphData({
|
||||
files: Graph.getFiles(this.app, this.globalGraph),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import Graph3dPlugin from "@/main";
|
||||
import { SearchEngineType } from "@/SettingsSchemas";
|
||||
import { BasicSearchEngine } from "@/BasicSearchEngine";
|
||||
import { DvSearchEngine } from "@/DvSearchEngine";
|
||||
import { PassiveSearchEngine } from "@/PassiveSearchEngine";
|
||||
|
||||
const DEFAULT_NUMBER = 200;
|
||||
|
||||
|
|
@ -59,7 +56,9 @@ export class SettingTab extends PluginSettingTab {
|
|||
.setDesc("Search engine determine how to parse the query string and return results.")
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOptions(SearchEngineType)
|
||||
.addOptions({
|
||||
[SearchEngineType.builtIn]: SearchEngineType.builtIn,
|
||||
})
|
||||
// you need to add options before set value
|
||||
.setValue(pluginSetting.searchEngine)
|
||||
.onChange(async (value: SearchEngineType) => {
|
||||
|
|
@ -70,14 +69,7 @@ export class SettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
// update the plugin file manager
|
||||
this.plugin.fileManager.searchEngine =
|
||||
value === SearchEngineType.default
|
||||
? new BasicSearchEngine(this.plugin)
|
||||
: value === SearchEngineType.dataview
|
||||
? new DvSearchEngine(this.plugin)
|
||||
: new PassiveSearchEngine((files) => {
|
||||
console.log(files);
|
||||
});
|
||||
this.plugin.fileManager.setSearchEngine();
|
||||
|
||||
// force all the graph view to reset their settings
|
||||
this.plugin.activeGraphViews.forEach((view) => view.settingManager.resetSettings());
|
||||
|
|
|
|||
|
|
@ -67,7 +67,6 @@ export function waitForStable<T = unknown>(
|
|||
clearInterval(intervalId);
|
||||
resolve(currentValue);
|
||||
}
|
||||
console.log(previousValue, currentValue);
|
||||
previousValue = currentValue;
|
||||
}, interval);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import Graph3dPlugin from "@/main";
|
||||
import { PassiveSearchEngine } from "@/PassiveSearchEngine";
|
||||
import { NewGraph3dView } from "@/views/graph/NewGraph3dView";
|
||||
import { spawnLeafView } from "@/views/leafView";
|
||||
import { TextComponent } from "obsidian";
|
||||
import { SearchView, TAbstractFile, TextComponent } from "obsidian";
|
||||
|
||||
/**
|
||||
* depends on the search engine, this might be a normal text input or a built-in search input
|
||||
*
|
||||
* @remarks
|
||||
* the search input doesn't perform the search, it only display the input
|
||||
*/
|
||||
|
|
@ -21,12 +24,12 @@ export const addSearchInput = async (
|
|||
* @param files the files that match the query
|
||||
*/
|
||||
onChange: (value: string) => void,
|
||||
plugin: Graph3dPlugin
|
||||
view: NewGraph3dView
|
||||
) => {
|
||||
const searchEl = containerEl.createDiv({
|
||||
// cls :
|
||||
});
|
||||
if (!plugin.fileManager.searchEngine.useBuiltInSearchInput) {
|
||||
if (!view.plugin.fileManager.searchEngine.useBuiltInSearchInput) {
|
||||
const text = new TextComponent(searchEl).setValue(value).onChange((value) => {
|
||||
onChange(value);
|
||||
});
|
||||
|
|
@ -38,7 +41,7 @@ export const addSearchInput = async (
|
|||
return;
|
||||
}
|
||||
|
||||
const [searchLeaf] = spawnLeafView(plugin, searchEl);
|
||||
const [searchLeaf] = spawnLeafView(view.plugin, searchEl);
|
||||
|
||||
await searchLeaf.setViewState({
|
||||
type: "search",
|
||||
|
|
@ -56,7 +59,7 @@ export const addSearchInput = async (
|
|||
".search-result-container"
|
||||
) as HTMLDivElement;
|
||||
const searchResultInfoEl = searchElement.querySelector(".search-results-info") as HTMLDivElement;
|
||||
// const parentEl = searchResultContainerEl.parentElement!;
|
||||
|
||||
searchResultContainerEl.style.visibility = "hidden";
|
||||
searchResultContainerEl.style.height = "0px";
|
||||
searchResultContainerEl.style.position = "absolute";
|
||||
|
|
@ -77,10 +80,9 @@ export const addSearchInput = async (
|
|||
settingIconEl?.remove();
|
||||
matchCaseIconEl?.remove();
|
||||
inputEl.value = value;
|
||||
inputEl.onkeydown = async (e) => {
|
||||
// const query = inputEl.value;
|
||||
// const files = plugin.fileManager.searchFiles(query);
|
||||
onChange(inputEl.value);
|
||||
inputEl.oninput = async (e: Event<HTMLInputElement>) => {
|
||||
// @ts-ignore
|
||||
onChange(e.currentTarget?.value);
|
||||
};
|
||||
|
||||
clearButtonEl.onclick = () => {
|
||||
|
|
@ -89,5 +91,22 @@ export const addSearchInput = async (
|
|||
onChange("");
|
||||
};
|
||||
|
||||
return [searchRowEl] as const;
|
||||
// this make search that the search result container el is alaways visible
|
||||
view.containerEl.appendChild(searchResultContainerEl);
|
||||
|
||||
// if it is a passive engine, we need to enable mutation observer
|
||||
|
||||
const addMutationObserver = (callback: (files: TAbstractFile[]) => void) => {
|
||||
if (view.plugin.fileManager.searchEngine instanceof PassiveSearchEngine)
|
||||
view.plugin.fileManager.searchEngine.addMutationObserver(
|
||||
searchResultContainerEl,
|
||||
searchLeaf.view as SearchView,
|
||||
callback
|
||||
);
|
||||
else {
|
||||
throw new Error("cannot add mutation observer to active search engine");
|
||||
}
|
||||
};
|
||||
|
||||
return { searchRowEl, addMutationObserver };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as THREE from "three";
|
||||
import { origin } from "./NewForceGraph";
|
||||
import { origin } from "@/views/graph/NewForceGraph";
|
||||
|
||||
export class CenterCoordinates {
|
||||
public readonly arrowsGroup = new THREE.Group();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
|||
*/
|
||||
export const origin = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
type MyForceGraph3DInstance = Omit<ForceGraph3DInstance, "graphData"> & {
|
||||
graphData: {
|
||||
(): Graph; // When no argument is passed, it returns a Graph
|
||||
(graph: Graph): MyForceGraph3DInstance; // When a Graph is passed, it returns MyForceGraph3DInstance
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* this class control the config and graph of the force graph. The interaction is not control here.
|
||||
*/
|
||||
|
|
@ -20,15 +27,13 @@ export class NewForceGraph {
|
|||
private view: NewGraph3dView;
|
||||
private config: LocalGraphSettings | GlobalGraphSettings;
|
||||
|
||||
private graph: Graph;
|
||||
private instance: ForceGraph3DInstance;
|
||||
public readonly instance: MyForceGraph3DInstance;
|
||||
private centerCoordinates: CenterCoordinates;
|
||||
|
||||
/**
|
||||
*
|
||||
* this will create a new force graph instance and render it to the view
|
||||
* @param view
|
||||
* @param graph
|
||||
* @param config you have to provide the full config here!!
|
||||
*/
|
||||
constructor(
|
||||
|
|
@ -38,7 +43,7 @@ export class NewForceGraph {
|
|||
) {
|
||||
this.view = view;
|
||||
this.config = config;
|
||||
this.graph = graph;
|
||||
// test;
|
||||
|
||||
// get the content element of the item view
|
||||
const rootHtmlElement = view.contentEl as HTMLDivElement;
|
||||
|
|
@ -62,12 +67,13 @@ export class NewForceGraph {
|
|||
}),
|
||||
],
|
||||
})(rootHtmlElement)
|
||||
.graphData(graph)
|
||||
// the options here are auto
|
||||
.width(rootHtmlElement.innerWidth)
|
||||
.height(rootHtmlElement.innerHeight)
|
||||
.d3Force("collide", d3.forceCollide(5))
|
||||
// transparent
|
||||
.backgroundColor(hexToRGBA("#000000", 0));
|
||||
.backgroundColor(hexToRGBA("#000000", 0)) as unknown as MyForceGraph3DInstance;
|
||||
|
||||
const scene = this.instance.scene();
|
||||
// add others things
|
||||
|
|
@ -94,13 +100,16 @@ export class NewForceGraph {
|
|||
* given a new force Graph, the update the graph and the instance
|
||||
*/
|
||||
public updateGraph(graph: Graph) {
|
||||
this.updateInstance(graph, undefined);
|
||||
// some optimization here
|
||||
// if the graph is the same, then we don't need to update the graph
|
||||
const same = Graph.compare(this.instance.graphData(), graph);
|
||||
if (!same) this.updateInstance(graph, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* given the changed things, update the instance
|
||||
*/
|
||||
public updateInstance = (graph?: Graph, config?: Partial<SavedSetting["setting"]>) => {
|
||||
private updateInstance = (graph?: Graph, config?: Partial<SavedSetting["setting"]>) => {
|
||||
if (graph !== undefined) this.instance.graphData(graph);
|
||||
if (config?.display?.nodeSize !== undefined)
|
||||
this.instance.nodeRelSize(config.display?.nodeSize);
|
||||
|
|
@ -120,25 +129,12 @@ export class NewForceGraph {
|
|||
/**
|
||||
* derive the need to reheat the simulation
|
||||
*/
|
||||
const needReheat =
|
||||
config?.display?.nodeRepulsion !== undefined ||
|
||||
config?.display?.linkDistance !== undefined ||
|
||||
graph !== undefined;
|
||||
// const needReheat =
|
||||
// config?.display?.nodeRepulsion !== undefined || config?.display?.linkDistance !== undefined;
|
||||
|
||||
if (needReheat) {
|
||||
this.instance.numDimensions(3); // reheat simulation
|
||||
this.instance.refresh();
|
||||
}
|
||||
// if (needReheat) {
|
||||
// this.instance.numDimensions(3); // reheat simulation
|
||||
// this.instance.refresh();
|
||||
// }
|
||||
};
|
||||
|
||||
public getInstance() {
|
||||
/**
|
||||
* patch the graph data so that the type is right.
|
||||
*
|
||||
* this should be the only way to access graph data from a force graph instance
|
||||
*/
|
||||
return this.instance as unknown as Omit<ForceGraph3DInstance, "graphData"> & {
|
||||
graphData: () => Graph;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,14 @@ import { GraphType } from "@/SettingsSchemas";
|
|||
import { config } from "@/config";
|
||||
import { Graph } from "@/graph/Graph";
|
||||
import Graph3dPlugin from "@/main";
|
||||
import { createNotice } from "@/util/createNotice";
|
||||
import { NewForceGraph } from "@/views/graph/NewForceGraph";
|
||||
import { GraphSettingManager } from "@/views/settings/GraphSettingsManager";
|
||||
import { getGraphAfterProcessingConfig } from "@/views/settings/categories/getGraphAfterProcessingConfig";
|
||||
import { App, ItemView, TAbstractFile, WorkspaceLeaf } from "obsidian";
|
||||
|
||||
/**
|
||||
* This is the entry point for the graph view
|
||||
*/
|
||||
const getGraph = (app: App): Graph => {
|
||||
// TODO: implement this
|
||||
return Graph.createFromApp(app);
|
||||
const getGraphFromFiles = (app: App, files: TAbstractFile[]): Graph => {
|
||||
return Graph.createFromFiles(files, app);
|
||||
};
|
||||
|
||||
export class NewGraph3dView extends ItemView {
|
||||
|
|
@ -25,7 +23,7 @@ export class NewGraph3dView extends ItemView {
|
|||
/**
|
||||
* if this is the local graph, this will be the current file ;
|
||||
*/
|
||||
private currentFile: TAbstractFile;
|
||||
public currentFile: TAbstractFile | undefined;
|
||||
|
||||
public readonly settingManager: GraphSettingManager;
|
||||
|
||||
|
|
@ -43,13 +41,14 @@ export class NewGraph3dView extends ItemView {
|
|||
if (file) {
|
||||
this.currentFile = file;
|
||||
|
||||
// recreate the graph view
|
||||
const graph = getGraph(this.plugin.app);
|
||||
this.forceGraph = new NewForceGraph(
|
||||
this,
|
||||
graph,
|
||||
this.plugin.settingManager.getNewSetting(graphType)
|
||||
);
|
||||
const graph = getGraphAfterProcessingConfig(this.plugin, {
|
||||
files: [],
|
||||
graphType: GraphType.local,
|
||||
setting: this.settingManager.getCurrentSetting().filter,
|
||||
centerFile: file,
|
||||
});
|
||||
|
||||
this.updateGraphData({ graph });
|
||||
|
||||
// we need to append the setting so that setting will be in front of the graph
|
||||
this.contentEl.appendChild(this.settingManager.containerEl);
|
||||
|
|
@ -62,20 +61,29 @@ export class NewGraph3dView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
console.warn("temporarily disabled");
|
||||
// the view is already initialized, so we can create the graph
|
||||
// set up some UI stuff
|
||||
this.contentEl.classList.add("graph-3d-view");
|
||||
this.settingManager = new GraphSettingManager(this);
|
||||
|
||||
const graph =
|
||||
this.graphType === GraphType.local
|
||||
? getGraphAfterProcessingConfig(this.plugin, {
|
||||
files: [],
|
||||
graphType: GraphType.local,
|
||||
setting: this.settingManager.getCurrentSetting().filter,
|
||||
centerFile: this.currentFile,
|
||||
})
|
||||
: this.plugin.globalGraph;
|
||||
|
||||
// you need to set up the graph before setting so that the setting will be in front of the graph
|
||||
const graph = getGraph(this.plugin.app);
|
||||
this.forceGraph = new NewForceGraph(
|
||||
this,
|
||||
graph,
|
||||
this.plugin.settingManager.getNewSetting(graphType)
|
||||
);
|
||||
|
||||
// set up some UI stuff
|
||||
this.contentEl.classList.add("graph-3d-view");
|
||||
this.settingManager = new GraphSettingManager(this);
|
||||
// move the setting to the front of the graph
|
||||
this.contentEl.appendChild(this.settingManager.containerEl);
|
||||
}
|
||||
|
||||
onload(): void {
|
||||
|
|
@ -85,7 +93,7 @@ export class NewGraph3dView extends ItemView {
|
|||
|
||||
onunload(): void {
|
||||
super.onunload();
|
||||
this.forceGraph.getInstance()._destructor();
|
||||
this.forceGraph?.instance._destructor();
|
||||
this.plugin.activeGraphViews = this.plugin.activeGraphViews.filter((view) => view !== this);
|
||||
}
|
||||
|
||||
|
|
@ -106,15 +114,60 @@ export class NewGraph3dView extends ItemView {
|
|||
if (this.forceGraph) this.forceGraph.updateDimensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* get the current force graph object
|
||||
*/
|
||||
public getForceGraph() {
|
||||
return this.forceGraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* destroy the old graph, remove the old graph completely from the DOM.
|
||||
* reassign a new graph base on setting like the constructor,
|
||||
* then render it.
|
||||
*/
|
||||
public refreshGraph() {
|
||||
// destroy the old graph, remove the old graph completely from the DOM
|
||||
// reassign a new graph base on setting like the constructor
|
||||
// then render it
|
||||
const graph = this.forceGraph.instance.graphData();
|
||||
|
||||
throw new Error("not implemented yet");
|
||||
// get the first child of the content element
|
||||
const forceGraphEl = this.contentEl.firstChild;
|
||||
forceGraphEl?.remove();
|
||||
|
||||
// destroy the old graph, remove the old graph completely from the DOM
|
||||
this.forceGraph.instance._destructor();
|
||||
|
||||
// reassign a new graph base on setting like the constructor
|
||||
this.forceGraph = new NewForceGraph(this, graph, this.settingManager.getCurrentSetting());
|
||||
|
||||
// move the setting to the front of the graph
|
||||
this.contentEl.appendChild(this.settingManager.containerEl);
|
||||
|
||||
this.onResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* given some files and config, update the graph data.
|
||||
*/
|
||||
public updateGraphData(
|
||||
param:
|
||||
| {
|
||||
files: TAbstractFile[];
|
||||
}
|
||||
| {
|
||||
graph: Graph;
|
||||
}
|
||||
) {
|
||||
const graph = "files" in param ? getGraphFromFiles(this.app, param.files) : param.graph;
|
||||
const tooLarge =
|
||||
graph.nodes.length > this.plugin.settingManager.getSettings().pluginSetting.maxNodeNumber;
|
||||
if (tooLarge) {
|
||||
createNotice(`Graph is too large to be rendered. Have ${graph.nodes.length} nodes.`);
|
||||
}
|
||||
this.forceGraph.updateGraph(tooLarge ? Graph.createEmpty() : graph);
|
||||
}
|
||||
|
||||
/**
|
||||
* handle setting update
|
||||
*/
|
||||
public handleSettingUpdate = () => {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export class GraphSettingManager {
|
|||
public readonly containerEl: HTMLDivElement;
|
||||
private settingsButton: ExtraButtonComponent;
|
||||
private graphControlsEl: HTMLDivElement;
|
||||
|
||||
public filterSettingView: ReturnType<typeof FilterSettingsView>;
|
||||
public displaySettingView: ReturnType<typeof DisplaySettingsView>;
|
||||
|
||||
private currentSetting: State<SavedSetting["setting"]>;
|
||||
|
|
@ -59,7 +61,10 @@ export class GraphSettingManager {
|
|||
this.graphControlsEl,
|
||||
this.currentSetting.value.filter,
|
||||
"Filters",
|
||||
(...args) => FilterSettingsView(...args, this)
|
||||
(...args) => {
|
||||
this.filterSettingView = FilterSettingsView(...args, this);
|
||||
return this.filterSettingView;
|
||||
}
|
||||
);
|
||||
|
||||
// add the group settings
|
||||
|
|
@ -69,6 +74,7 @@ export class GraphSettingManager {
|
|||
"Groups",
|
||||
(...args) => GroupSettingsView(...args, this.graphView)
|
||||
);
|
||||
|
||||
this.appendSettingGroup(
|
||||
this.graphControlsEl,
|
||||
this.currentSetting.value,
|
||||
|
|
@ -173,6 +179,8 @@ export class GraphSettingManager {
|
|||
// pass the updated graph object or any other new config to update the graph
|
||||
}
|
||||
|
||||
this.graphView.handleSettingUpdate();
|
||||
|
||||
return this.currentSetting.value;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,11 +17,10 @@ export const AddNodeGroupItem = async (
|
|||
*/
|
||||
index: number
|
||||
) => {
|
||||
const plugin = view.plugin;
|
||||
// This group must exist
|
||||
const groupEl = containerEl.createDiv({ cls: "graph-color-group" });
|
||||
|
||||
await addSearchInput(
|
||||
const searchInput = await addSearchInput(
|
||||
groupEl,
|
||||
newGroup.query,
|
||||
(value) => {
|
||||
|
|
@ -31,9 +30,14 @@ export const AddNodeGroupItem = async (
|
|||
return setting;
|
||||
});
|
||||
},
|
||||
plugin
|
||||
view
|
||||
);
|
||||
|
||||
if (searchInput)
|
||||
searchInput.addMutationObserver((files) => {
|
||||
console.log("set the graph config", files);
|
||||
});
|
||||
|
||||
addColorPicker(groupEl, newGroup.color, (value) => {
|
||||
view.settingManager.updateCurrentSettings((setting) => {
|
||||
// This group must exist
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ export const addNodeGroupButton = (
|
|||
// add a group to group settings
|
||||
view.settingManager.updateCurrentSettings((setting) => {
|
||||
setting.groups.push(newGroup);
|
||||
// add a group to UI as well, add it in the containerEl before the button container el
|
||||
return setting;
|
||||
});
|
||||
|
||||
// add a group to UI as well, add it in the containerEl before the button container el
|
||||
const index = groupSettings.length - 1;
|
||||
// we need to get the latest current setting so that index will be correct
|
||||
const index = view.settingManager.getCurrentSetting().groups.length - 1;
|
||||
AddNodeGroupItem(newGroup, containerEl, view, index);
|
||||
containerEl.append(buttonContainerEl);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { addSearchInput } from "@/views/atomics/addSearchInput";
|
|||
import { NewGraph3dView } from "@/views/graph/NewGraph3dView";
|
||||
import { BaseFilterSettings, LocalFilterSetting, LocalGraphSettings } from "@/SettingManager";
|
||||
import { GraphType } from "@/SettingsSchemas";
|
||||
import { getGraphAfterProcessingConfig } from "@/views/settings/categories/getGraphAfterProcessingConfig";
|
||||
|
||||
export const FilterSettingsView = async (
|
||||
filterSettings: BaseFilterSettings | LocalFilterSetting,
|
||||
|
|
@ -11,8 +12,7 @@ export const FilterSettingsView = async (
|
|||
settingManager: NewGraph3dView["settingManager"]
|
||||
) => {
|
||||
const graphView = settingManager.getGraphView();
|
||||
const plugin = graphView.plugin;
|
||||
await addSearchInput(
|
||||
const searchInput = await addSearchInput(
|
||||
containerEl,
|
||||
filterSettings.searchQuery,
|
||||
(value) => {
|
||||
|
|
@ -22,9 +22,23 @@ export const FilterSettingsView = async (
|
|||
return setting;
|
||||
});
|
||||
},
|
||||
plugin
|
||||
graphView
|
||||
);
|
||||
|
||||
// if this is a built-in search input, then we need to add a mutation observer
|
||||
if (searchInput)
|
||||
searchInput.addMutationObserver((files) => {
|
||||
// the files is empty, by default, we will show all files
|
||||
graphView.updateGraphData({
|
||||
graph: getGraphAfterProcessingConfig(graphView.plugin, {
|
||||
files,
|
||||
graphType: graphView.graphType,
|
||||
setting: settingManager.getCurrentSetting().filter,
|
||||
centerFile: graphView.currentFile,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// add show attachments setting
|
||||
new Setting(containerEl).setName("Show Attachments").addToggle((toggle) => {
|
||||
toggle.setValue(filterSettings.showAttachments || false).onChange(async (value) => {
|
||||
|
|
@ -82,5 +96,9 @@ export const FilterSettingsView = async (
|
|||
settingManager.displaySettingView.showDagOrientationSetting();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
searchInput,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { TAbstractFile } from "obsidian";
|
||||
import { BaseFilterSettings, LocalFilterSetting } from "@/SettingManager";
|
||||
import { GraphType } from "@/SettingsSchemas";
|
||||
import { Graph } from "@/graph/Graph";
|
||||
import Graph3dPlugin from "@/main";
|
||||
|
||||
/**
|
||||
* the files from the search result or the files from the search engine cannot be used directly
|
||||
*/
|
||||
export const getGraphAfterProcessingConfig = (
|
||||
plugin: Graph3dPlugin,
|
||||
{
|
||||
files,
|
||||
graphType,
|
||||
setting,
|
||||
centerFile,
|
||||
}: {
|
||||
files: TAbstractFile[];
|
||||
graphType: GraphType;
|
||||
setting: BaseFilterSettings | LocalFilterSetting;
|
||||
centerFile?: TAbstractFile;
|
||||
}
|
||||
) => {
|
||||
if (graphType === GraphType.local && "depth" in setting) {
|
||||
// then it is a local graph
|
||||
return Graph.createFromMask(
|
||||
(node) => {
|
||||
// if node is an orphan and show orphan is false, then we will not show it
|
||||
if (node.links.length === 0 && !setting.showOrphans) return false;
|
||||
// if node is not a markdown and show attachment is false, then we will not show it
|
||||
if (!node.path.endsWith(".md") && !setting.showAttachments) return false;
|
||||
// if the node is not in the files, then we will not show it
|
||||
return files.length === 0 ? true : files.some((file) => file.path === node.path);
|
||||
},
|
||||
// active file must exist in local graph
|
||||
plugin.globalGraph.getLocalGraph({
|
||||
path: centerFile?.path ?? "",
|
||||
depth: setting.depth,
|
||||
linkType: setting.linkType,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// if it is global graph
|
||||
return Graph.createFromMask((node) => {
|
||||
// if node is an orphan and show orphan is false, then we will not show it
|
||||
if (node.links.length === 0 && !setting.showOrphans) return false;
|
||||
// if node is not a markdown and show attachment is false, then we will not show it
|
||||
if (!node.path.endsWith(".md") && !setting.showAttachments) return false;
|
||||
// if the node is not in the files, then we will not show it
|
||||
return files.length === 0 ? true : files.some((file) => file.path === node.path);
|
||||
}, plugin.globalGraph);
|
||||
}
|
||||
};
|
||||
|
|
@ -19,8 +19,7 @@ export const getMySwitcher = (view: NewGraph3dView) => {
|
|||
const suggestions = await super.getSuggestions(query);
|
||||
const allFilePaths = view
|
||||
.getForceGraph()
|
||||
.getInstance()
|
||||
.graphData()
|
||||
.instance.graphData()
|
||||
.nodes.map((n) => n.path);
|
||||
return suggestions.filter(Boolean).filter((s) => {
|
||||
// only show files in this view
|
||||
|
|
|
|||
Loading…
Reference in a new issue