Skip to content

Commit f157dee

Browse files
committed
2026/3/18
1 parent 096eae7 commit f157dee

9 files changed

Lines changed: 260 additions & 14 deletions

File tree

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "opentext-hub",
33
"name": "OpenText Hub",
4-
"version": "1.0.2",
4+
"version": "1.0.3",
55
"minAppVersion": "1.12.0",
66
"description": "用于管理 OpenText 文档的工具箱",
77
"author": "insile",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opentext-hub",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "用于管理 OpenText 文档的工具箱",
55
"main": "main.js",
66
"type": "module",

src/core/backlinkIndex.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { App, EventRef, TFile } from "obsidian";
2+
3+
4+
export class BacklinkIndex {
5+
// 核心索引数据结构
6+
backlinkMap: Map<string, Set<string>> = new Map();
7+
forwardMap: Map<string, Set<string>> = new Map();
8+
app: App;
9+
isInitialized: boolean = false;
10+
fileWatcherRefs: EventRef[] = []; // 存储文件监听器的 EventRef 以便注销
11+
12+
constructor(app: App) {
13+
this.app = app;
14+
}
15+
16+
// 全量初始化索引
17+
public init() {
18+
if (this.isInitialized) return;
19+
this.backlinkMap.clear();
20+
this.forwardMap.clear();
21+
this.isInitialized = true;
22+
23+
const resolvedLinks = this.app.metadataCache.resolvedLinks;
24+
for (const sourcePath in resolvedLinks) {
25+
const targets = Object.keys(resolvedLinks[sourcePath] || {});
26+
for (const targetPath of targets) {
27+
this.addLink(sourcePath, targetPath);
28+
}
29+
}
30+
31+
const resolveRef = this.app.metadataCache.on('resolve', (file) => {
32+
if (file instanceof TFile && file.path.endsWith(".md")) {
33+
this.updateFileIndex(file.path);
34+
}
35+
});
36+
const renameRef = this.app.vault.on("rename", (file, oldPath) => {
37+
if (file instanceof TFile && file.path.endsWith(".md")) {
38+
this.removeSourceFromIndex(oldPath);
39+
this.updateFileIndex(file.path);
40+
}
41+
});
42+
const deleteRef = this.app.vault.on("delete", (file) => {
43+
if (file instanceof TFile && file.path.endsWith(".md")) {
44+
this.removeSourceFromIndex(file.path);
45+
// 同时也确保作为 Target 的索引被抹除
46+
this['backlinkMap'].delete(file.path);
47+
}
48+
});
49+
this.fileWatcherRefs.push(resolveRef);
50+
this.fileWatcherRefs.push(renameRef);
51+
this.fileWatcherRefs.push(deleteRef);
52+
}
53+
54+
// 清理索引和监听器
55+
public close() {
56+
this.backlinkMap.clear();
57+
this.forwardMap.clear();
58+
this.isInitialized = false;
59+
this.fileWatcherRefs.forEach(ref => this.app.workspace.offref(ref));
60+
this.fileWatcherRefs = [];
61+
}
62+
63+
// 更新单个文件的索引(增量更新)
64+
public updateFileIndex(sourcePath: string) {
65+
// 1. 先移除该文件之前所有的旧链接关系
66+
this.removeSourceFromIndex(sourcePath);
67+
68+
// 2. 获取 MetadataCache 中最新的链接数据并重新添加
69+
const newLinks = this.app.metadataCache.resolvedLinks[sourcePath];
70+
if (newLinks) {
71+
for (const targetPath in newLinks) {
72+
this.addLink(sourcePath, targetPath);
73+
}
74+
}
75+
}
76+
77+
// 获取反向链接列表
78+
public getBacklinks(targetPath: string): string[] {
79+
const sources = this.backlinkMap.get(targetPath);
80+
return sources ? Array.from(sources) : [];
81+
}
82+
83+
// 建立单向连接关系
84+
private addLink(source: string, target: string) {
85+
// 更新反向索引
86+
if (!this.backlinkMap.has(target)) this.backlinkMap.set(target, new Set());
87+
this.backlinkMap.get(target)!.add(source);
88+
89+
// 更新正向索引(用于辅助增量更新)
90+
if (!this.forwardMap.has(source)) this.forwardMap.set(source, new Set());
91+
this.forwardMap.get(source)!.add(target);
92+
}
93+
94+
// 移除一个源文件的所有索引轨迹
95+
public removeSourceFromIndex(sourcePath: string) {
96+
const targets = this.forwardMap.get(sourcePath);
97+
if (!targets) return;
98+
99+
// 遍历该源文件之前指向的所有目标,从它们的 Backlink 集合中删掉自己
100+
for (const targetPath of targets) {
101+
const backlinkSet = this.backlinkMap.get(targetPath);
102+
if (backlinkSet) {
103+
backlinkSet.delete(sourcePath);
104+
// 如果该目标已经没有任何反向链接,清理 Map 空间
105+
if (backlinkSet.size === 0) this.backlinkMap.delete(targetPath);
106+
}
107+
}
108+
109+
// 最后清理正向索引
110+
this.forwardMap.delete(sourcePath);
111+
}
112+
}

src/core/classifyByLink.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import OpenTextHub from "main";
2+
import { App, EventRef, TFile } from "obsidian";
3+
import { BacklinkIndex } from "./backlinkIndex";
4+
5+
6+
export class ClassifyByLink {
7+
app: App;
8+
plugin: OpenTextHub; // 替换为你的插件类名
9+
statusBarItem: HTMLElement;
10+
fileWatcherRefs: EventRef[] = []; // 存储文件监听器的 EventRef 以便注销
11+
backlinkIndex: BacklinkIndex;
12+
13+
constructor(app: App, plugin: OpenTextHub) {
14+
this.app = app;
15+
this.plugin = plugin;
16+
this.backlinkIndex = new BacklinkIndex(this.app);
17+
this.statusBarItem = this.plugin.addStatusBarItem().createEl("span");
18+
if (this.plugin.settings.classifyByLinkEnabled) { this.init(); }
19+
}
20+
21+
init() {
22+
const isMetadataReady = Object.keys(this.app.metadataCache.resolvedLinks).length > 0;
23+
if (isMetadataReady) {
24+
// 如果已经准备好了(手动启用场景),直接初始化
25+
this.backlinkIndex.init();
26+
} else {
27+
const resolveRef = this.app.metadataCache.on('resolve', (file) => {
28+
this.backlinkIndex.init();
29+
this.app.metadataCache.offref(resolveRef);
30+
});
31+
}
32+
const changedRef = this.app.workspace.on("file-open", (file) => {
33+
if (file instanceof TFile && file.path.endsWith(".md")) {
34+
this.statusBarItem.setText(this.findNearestType(file.path)?.join(", ") ? `${this.findNearestType(file.path)?.join(", ")}` : "未找到类型");
35+
// console.debug(this.backlinkIndex.getBacklinks(file.path)); // 输出文件的反向链接信息
36+
}
37+
if (!file) {
38+
this.statusBarItem.setText("");
39+
return;
40+
}
41+
});
42+
this.fileWatcherRefs.push(changedRef);
43+
}
44+
45+
close() {
46+
this.fileWatcherRefs.forEach(ref => this.app.workspace.offref(ref));
47+
this.fileWatcherRefs = [];
48+
this.statusBarItem.setText("");
49+
this.backlinkIndex.close();
50+
}
51+
52+
private findNearestType(startPath: string): string[] | null {
53+
const queue: string[] = [startPath];
54+
const visited = new Set<string>([startPath]);
55+
const results: string[] = [];
56+
let foundAtLevel = false;
57+
58+
while (queue.length > 0) {
59+
// 获取当前层的节点数量
60+
const levelSize = queue.length;
61+
62+
// 遍历当前层的所有节点
63+
for (let i = 0; i < levelSize; i++) {
64+
const currentPath = queue.shift()!;
65+
66+
const currentFile = this.app.vault.getAbstractFileByPath(currentPath);
67+
if (currentFile instanceof TFile) {
68+
const cache = this.app.metadataCache.getFileCache(currentFile);
69+
if (cache?.frontmatter?.页面类型 === 0) {
70+
results.push(currentFile.basename);
71+
foundAtLevel = true; // 标记在这一层找到了目标
72+
}
73+
}
74+
75+
// 如果这一层已经找到了,就不再往更深层探索(但要把当前层扫完)
76+
if (!foundAtLevel) {
77+
const parents = this.backlinkIndex.getBacklinks(currentPath) || [];
78+
for (const parent of parents) {
79+
if (!visited.has(parent)) {
80+
visited.add(parent);
81+
queue.push(parent);
82+
}
83+
}
84+
}
85+
}
86+
87+
// 扫完这一层后,如果有了结果,直接返回所有收集到的分类
88+
if (foundAtLevel) {
89+
return results.length > 0 ? results : null;
90+
}
91+
}
92+
93+
return null;
94+
}
95+
}

src/main.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@ import { MetadataManagerView, OPENTEXT_METADATA_MANAGER_VIEW } from "./ui/metada
33
import { OpenTextSettingTab } from 'settings/settingsTab';
44
import { MetadataManager } from 'core/metadataManager';
55
import { Plugin } from 'obsidian';
6+
import { ClassifyByLink } from "core/classifyByLink";
67

78

89
// 主插件类
910
export default class OpenTextHub extends Plugin {
1011
settings: OpenTextSettings;
1112
metadataManager: MetadataManager;
13+
classifyByLink: ClassifyByLink;
1214

1315
// 插件加载
1416
async onload() {
1517
await this.loadSettings();
1618
this.addSettingTab(new OpenTextSettingTab(this.app, this));
1719
this.metadataManager = new MetadataManager(this.app, this);
20+
this.classifyByLink = new ClassifyByLink(this.app, this);
1821
this.registerView(OPENTEXT_METADATA_MANAGER_VIEW, (leaf) => new MetadataManagerView(leaf, this));
1922
this.addRibbonIcon('cog', '元数据管理', () => this.metadataManager.openManagerView());
2023

@@ -27,7 +30,9 @@ export default class OpenTextHub extends Plugin {
2730

2831
// 插件卸载
2932
onunload() {
30-
33+
if (this.settings.classifyByLinkEnabled) {
34+
this.classifyByLink.close();
35+
}
3136
}
3237

3338
// 加载设置

src/settings/settingsData.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ export interface OpenTextSettings {
33
apiKey: string;
44
actions: Action[];
55
commands: Command[];
6+
classifyByLinkEnabled?: boolean;
67
}
78

89
// 默认设置
910
export const DEFAULT_SETTINGS: OpenTextSettings = {
1011
apiKey: '',
1112
actions: [],
12-
commands: []
13+
commands: [],
14+
classifyByLinkEnabled: false
1315
};
1416

1517
// 元数据更新动作

src/settings/settingsTab.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { App, PluginSettingTab, SecretComponent, Setting } from "obsidian";
1+
import { App, Notice, PluginSettingTab, SecretComponent, Setting } from "obsidian";
22
import OpenTextHub from "../main";
33

44

@@ -25,6 +25,23 @@ export class OpenTextSettingTab extends PluginSettingTab {
2525
this.plugin.settings.apiKey = value;
2626
await this.plugin.saveSettings();
2727
}));
28+
29+
new Setting(containerEl)
30+
.setName('基于最短反向链接的分类')
31+
.setDesc('启用后,插件将根据文件的反向链接数据,自动将其分类到路径最短的索引文件上。')
32+
.addToggle(toggle => toggle
33+
.setValue(this.plugin.settings.classifyByLinkEnabled || false)
34+
.onChange(async (value) => {
35+
this.plugin.settings.classifyByLinkEnabled = value;
36+
await this.plugin.saveSettings();
37+
if (value) {
38+
this.plugin.classifyByLink.init();
39+
this.plugin.classifyByLink.backlinkIndex.init();
40+
new Notice("基于最短反向链接的分类已启用");
41+
} else {
42+
this.plugin.classifyByLink.close();
43+
}
44+
}));
2845
}
2946
}
3047

src/utils/dataUtils.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { TFile, TFolder } from "obsidian";
1+
import { App, TFile, TFolder } from "obsidian";
22

33

44
// 递归获取文件夹中的所有 Markdown 文件
55
export function getFilesInFolder(folder: TFolder): TFile[] {
6-
return folder.children.flatMap(child =>
7-
child instanceof TFile ? [child] :
8-
child instanceof TFolder ? getFilesInFolder(child) : []
6+
return folder.children.flatMap(child =>
7+
child instanceof TFile ? [child] :
8+
child instanceof TFolder ? getFilesInFolder(child) : []
99
);
1010
}
1111

@@ -33,10 +33,10 @@ export const NUMBER_OPERATIONS = {
3333

3434
// 时间周期单位对应的毫秒数
3535
export const PERIOD_MULTIPLIERS = {
36-
d: 24*60*60*1000,
37-
w: 7*24*60*60*1000,
38-
m: 30*24*60*60*1000,
39-
y: 365*24*60*60*1000
36+
d: 24 * 60 * 60 * 1000,
37+
w: 7 * 24 * 60 * 60 * 1000,
38+
m: 30 * 24 * 60 * 60 * 1000,
39+
y: 365 * 24 * 60 * 60 * 1000
4040
} as const;
4141

4242
// 解析时间周期字符串为毫秒数
@@ -63,3 +63,18 @@ export function parseJsonResponse(response: string): Record<string, unknown> | n
6363
}
6464
}
6565

66+
67+
// 获取文件的反向链接信息
68+
export function getBacklinks(app: App, targetPath: string) {
69+
const backlinks: Record<string, number> = {};
70+
const resolvedLinks = app.metadataCache.resolvedLinks;
71+
72+
for (const source in resolvedLinks) {
73+
const links = resolvedLinks[source] || {};
74+
if (links[targetPath]) {
75+
backlinks[source] = links[targetPath];
76+
}
77+
}
78+
79+
return backlinks;
80+
}

versions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"1.0.2": "1.12.0"
2+
"1.0.3": "1.12.0"
33
}

0 commit comments

Comments
 (0)