Skip to content

Commit a315b3e

Browse files
authored
Merge pull request #11 from ongaeshi/feature/export-filtered-rivers-to-canvas
0.4.1
2 parents f517a52 + 4612ae5 commit a315b3e

7 files changed

Lines changed: 324 additions & 106 deletions

File tree

HISTORY.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# History
22

3+
## 0.4.1 (2026-04-26)
4+
- **Export filtered rivers to Canvas**: Add a command to export a customized subset of your note network to an Obsidian Canvas based on specific filters.
5+
36
## 0.4.0 (2026-04-24)
47
- **Export next notes to Canvas**: Add a command to export the entire tree structure of next notes derived from the current note to an Obsidian Canvas. Automatically detect loop structures and add a `🔄` icon to the originating note for visualization.
58
- **Export all previous links to Canvas**: Add a command to analyze all `previous` property connections across the entire vault and export the full network as a Canvas file.

lib/ExportFilterModal.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { App, Modal, Setting } from "obsidian";
2+
3+
export interface ExportFilterResult {
4+
directory: string;
5+
tag: string;
6+
link: string;
7+
property: string;
8+
width: string;
9+
height: string;
10+
maxColumns: string;
11+
exportAll: boolean;
12+
}
13+
14+
let lastExportFilterResult: ExportFilterResult | null = null;
15+
16+
export class ExportFilterModal extends Modal {
17+
directory: string;
18+
tag: string;
19+
link: string;
20+
property: string;
21+
width: string;
22+
height: string;
23+
maxColumns: string;
24+
exportAll: boolean;
25+
onSubmit: (result: ExportFilterResult) => void;
26+
27+
constructor(app: App, onSubmit: (result: ExportFilterResult) => void) {
28+
super(app);
29+
this.onSubmit = onSubmit;
30+
if (lastExportFilterResult) {
31+
this.directory = lastExportFilterResult.directory;
32+
this.tag = lastExportFilterResult.tag;
33+
this.link = lastExportFilterResult.link;
34+
this.property = lastExportFilterResult.property;
35+
this.width = lastExportFilterResult.width;
36+
this.height = lastExportFilterResult.height;
37+
this.maxColumns = lastExportFilterResult.maxColumns;
38+
this.exportAll = lastExportFilterResult.exportAll;
39+
} else {
40+
this.directory = '';
41+
this.tag = '';
42+
this.link = '';
43+
this.property = '';
44+
this.width = '400';
45+
this.height = '500';
46+
this.maxColumns = '5';
47+
this.exportAll = false;
48+
}
49+
}
50+
51+
onOpen() {
52+
const { contentEl } = this;
53+
contentEl.createEl("h2", { text: "Export Connected Notes by Filter" });
54+
55+
const dirSetting = new Setting(contentEl)
56+
.setName("Directory")
57+
.setDesc("Export notes under this directory (e.g., 01_Projects)")
58+
.addText(text => text
59+
.setValue(this.directory)
60+
.onChange(value => this.directory = value));
61+
62+
const propSetting = new Setting(contentEl)
63+
.setName("Property")
64+
.setDesc("Use with Link or Tag: Only search in this property")
65+
.addText(text => text
66+
.setValue(this.property)
67+
.onChange(value => this.property = value));
68+
69+
const linkSetting = new Setting(contentEl)
70+
.setName("Link")
71+
.setDesc("Export notes containing this link (e.g., Some Concept)")
72+
.addText(text => text
73+
.setValue(this.link)
74+
.onChange(value => this.link = value));
75+
76+
const tagSetting = new Setting(contentEl)
77+
.setName("Tag")
78+
.setDesc("Export notes containing this tag (e.g., #idea)")
79+
.addText(text => text
80+
.setValue(this.tag)
81+
.onChange(value => this.tag = value));
82+
83+
new Setting(contentEl)
84+
.setName("Search all elements")
85+
.setDesc("Ignore filters above and export all connected notes")
86+
.addToggle(toggle => toggle
87+
.setValue(this.exportAll)
88+
.onChange(value => {
89+
this.exportAll = value;
90+
dirSetting.setDisabled(value);
91+
tagSetting.setDisabled(value);
92+
linkSetting.setDisabled(value);
93+
propSetting.setDisabled(value);
94+
}));
95+
96+
// Apply initial disabled state based on this.exportAll
97+
if (this.exportAll) {
98+
dirSetting.setDisabled(true);
99+
tagSetting.setDisabled(true);
100+
linkSetting.setDisabled(true);
101+
propSetting.setDisabled(true);
102+
}
103+
104+
new Setting(contentEl)
105+
.setName("Width")
106+
.setDesc("Default: 400")
107+
.addText(text => text
108+
.setValue(this.width)
109+
.onChange(value => this.width = value));
110+
111+
new Setting(contentEl)
112+
.setName("Height")
113+
.setDesc("Default: 500")
114+
.addText(text => text
115+
.setValue(this.height)
116+
.onChange(value => this.height = value));
117+
118+
new Setting(contentEl)
119+
.setName("Max Columns")
120+
.setDesc("Default: 5")
121+
.addText(text => text
122+
.setValue(this.maxColumns)
123+
.onChange(value => this.maxColumns = value));
124+
125+
new Setting(contentEl)
126+
.addButton(btn => btn
127+
.setButtonText("Export")
128+
.setCta()
129+
.onClick(() => {
130+
this.close();
131+
const result: ExportFilterResult = {
132+
directory: this.directory,
133+
tag: this.tag,
134+
link: this.link,
135+
property: this.property,
136+
width: this.width,
137+
height: this.height,
138+
maxColumns: this.maxColumns,
139+
exportAll: this.exportAll
140+
};
141+
lastExportFilterResult = result;
142+
this.onSubmit(result);
143+
}));
144+
}
145+
146+
onClose() {
147+
const { contentEl } = this;
148+
contentEl.empty();
149+
}
150+
}

lib/canvas.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,22 @@ export function randomId(): string {
2828
return Math.random().toString(36).substring(2, 18);
2929
}
3030

31+
export interface CanvasGeneratorOptions {
32+
width?: number;
33+
height?: number;
34+
maxColumns?: number;
35+
}
36+
3137
export class CanvasGenerator {
3238
nodes: CanvasNode[] = [];
3339
edges: CanvasEdge[] = [];
3440
fileToNodeId = new Map<string, string>();
3541
maxUsedY = 0;
36-
MAX_COLUMNS = 5;
42+
MAX_COLUMNS: number;
3743

38-
constructor(private app: App, private reverseCache: Record<string, string[]>) { }
44+
constructor(private app: App, private reverseCache: Record<string, string[]>, private options?: CanvasGeneratorOptions) {
45+
this.MAX_COLUMNS = options?.maxColumns || 5;
46+
}
3947

4048
dfs(current: TFile, col: number, y: number, direction: number): string {
4149
const existingNodeId = this.fileToNodeId.get(current.path);
@@ -46,14 +54,19 @@ export class CanvasGenerator {
4654
const nodeId = randomId();
4755
this.fileToNodeId.set(current.path, nodeId);
4856

57+
const width = this.options?.width || 400;
58+
const height = this.options?.height || 500;
59+
const xStep = width + 100;
60+
const yStep = height + 100;
61+
4962
this.nodes.push({
5063
id: nodeId,
5164
type: "file",
5265
file: current.path,
53-
x: col * 500,
66+
x: col * xStep,
5467
y: y,
55-
width: 400,
56-
height: 500
68+
width: width,
69+
height: height
5770
});
5871

5972
if (y > this.maxUsedY) {
@@ -69,14 +82,14 @@ export class CanvasGenerator {
6982
if (first) {
7083
first = false;
7184
} else {
72-
this.maxUsedY += 600;
85+
this.maxUsedY += yStep;
7386
nextY = this.maxUsedY;
7487
}
7588

7689
let isWrapped = false;
7790
if (nextCol >= this.MAX_COLUMNS) {
7891
nextCol = 0;
79-
nextY += 600;
92+
nextY += yStep;
8093
if (nextY > this.maxUsedY) this.maxUsedY = nextY;
8194
isWrapped = true;
8295
}

lib/commands.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ConfirmModal } from "./ConfirmModal";
33
import { NextNoteSuggestModal } from "./NextNoteSuggestModal";
44
import { getActiveFile, getPreviousNote, getNextNotes, getNextNotesWithCache, buildReverseCache, detachNote, setPreviousProperty, findLastNote, findFirstNote, isOnSamePath } from "./obsidian";
55
import { CanvasGenerator, saveCanvasData } from "./canvas";
6+
import { ExportFilterModal } from "./ExportFilterModal";
67

78
export async function goToPreviousNoteCommand(app: App) {
89
const file = getActiveFile(app);
@@ -350,3 +351,144 @@ export function exportAllRiversToCanvasCommand(app: App) {
350351
}
351352
).open();
352353
}
354+
355+
export function exportFilteredRiversToCanvasCommand(app: App) {
356+
new ExportFilterModal(app, async (result) => {
357+
let { directory, tag, link, property, width, height, maxColumns, exportAll } = result;
358+
if (!exportAll && !directory && !tag && !link) {
359+
new Notice("Please provide at least one filter criterion or check 'Search all elements'.");
360+
return;
361+
}
362+
363+
tag = tag.trim();
364+
link = link.trim();
365+
directory = directory.trim();
366+
property = property?.trim();
367+
368+
if (tag && !tag.startsWith("#")) {
369+
tag = "#" + tag;
370+
}
371+
372+
const allFiles = app.vault.getMarkdownFiles();
373+
let matchedFiles: TFile[] = [];
374+
375+
if (exportAll) {
376+
matchedFiles = allFiles;
377+
} else {
378+
for (const file of allFiles) {
379+
let match = true;
380+
381+
if (directory && !file.path.includes(directory)) {
382+
match = false;
383+
}
384+
385+
if (match && (tag || link)) {
386+
const cache = app.metadataCache.getFileCache(file);
387+
388+
if (tag) {
389+
let allTags: string[] = [];
390+
391+
if (property) {
392+
const propertyValue = cache?.frontmatter?.[property];
393+
let propertyTags: string[] = [];
394+
if (Array.isArray(propertyValue)) {
395+
propertyTags = propertyValue.map(t => String(t).trim());
396+
} else if (typeof propertyValue === 'string') {
397+
propertyTags = propertyValue.split(",").map(t => t.trim());
398+
}
399+
allTags = propertyTags.map(t => t.startsWith("#") ? t : "#" + t);
400+
} else {
401+
const fileTags = cache?.tags?.map(t => t.tag) || [];
402+
const frontmatterTagsFromCache = cache?.frontmatter?.tags;
403+
404+
let fmTags: string[] = [];
405+
if (Array.isArray(frontmatterTagsFromCache)) {
406+
fmTags = frontmatterTagsFromCache;
407+
} else if (typeof frontmatterTagsFromCache === 'string') {
408+
fmTags = frontmatterTagsFromCache.split(",").map(t => t.trim());
409+
}
410+
411+
allTags = [...fileTags, ...fmTags.map(t => t.startsWith("#") ? t : "#" + t)];
412+
}
413+
414+
const hasTag = allTags.some(t => t === tag || t.startsWith(tag + "/"));
415+
if (!hasTag) match = false;
416+
}
417+
418+
if (match && link) {
419+
if (property) {
420+
const fileFrontmatterLinks = cache?.frontmatterLinks?.filter(l => l.key === property) || [];
421+
const hasLink = fileFrontmatterLinks.some(l => l.link.includes(link));
422+
if (!hasLink) match = false;
423+
} else {
424+
const fileLinks = cache?.links?.map(l => l.link) || [];
425+
const fileEmbeds = cache?.embeds?.map(e => e.link) || [];
426+
const fileFrontmatterLinks = cache?.frontmatterLinks?.map(l => l.link) || [];
427+
const allLinks = [...fileLinks, ...fileEmbeds, ...fileFrontmatterLinks];
428+
const hasLink = allLinks.some(l => l.includes(link));
429+
if (!hasLink) match = false;
430+
}
431+
}
432+
}
433+
434+
if (match) {
435+
matchedFiles.push(file);
436+
}
437+
}
438+
}
439+
440+
if (matchedFiles.length === 0) {
441+
new Notice("No files matched the given criteria.");
442+
return;
443+
}
444+
445+
const reverseCache = buildReverseCache(app);
446+
447+
const roots = new Set<TFile>();
448+
for (const file of matchedFiles) {
449+
const prev = getPreviousNote(app, file);
450+
const nexts = getNextNotesWithCache(app, file, reverseCache);
451+
452+
// Skip isolated notes that do not belong to any river
453+
if (!prev && nexts.length === 0) {
454+
continue;
455+
}
456+
457+
const root = findFirstNote(app, file);
458+
roots.add(root);
459+
}
460+
461+
const numWidth = parseInt(width);
462+
const numHeight = parseInt(height);
463+
const numMaxColumns = parseInt(maxColumns);
464+
465+
const generatorOptions = {
466+
width: isNaN(numWidth) ? 400 : numWidth,
467+
height: isNaN(numHeight) ? 500 : numHeight,
468+
maxColumns: isNaN(numMaxColumns) ? 5 : numMaxColumns
469+
};
470+
471+
const generator = new CanvasGenerator(app, reverseCache, generatorOptions);
472+
let currentY = 0;
473+
474+
for (const root of roots) {
475+
if (generator.fileToNodeId.has(root.path)) continue;
476+
477+
generator.dfs(root, 0, currentY, 1);
478+
currentY = generator.maxUsedY + 1000;
479+
}
480+
481+
if (generator.nodes.length === 0) {
482+
new Notice("No connected notes found for the matched files.");
483+
return;
484+
}
485+
486+
const activeFile = getActiveFile(app);
487+
const sourcePath = activeFile ? activeFile.path : "";
488+
const canvasName = "Filtered Connected Notes.canvas";
489+
const saveDir = app.fileManager.getNewFileParent(sourcePath, "Filtered Connected Notes.md").path;
490+
491+
await saveCanvasData(app, generator.nodes, generator.edges, canvasName, saveDir);
492+
493+
}).open();
494+
}

main.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
insertNoteToFirstCommand,
1111
copyNextNotesListCommand,
1212
exportNextNotesToCanvasCommand,
13-
exportAllRiversToCanvasCommand
13+
exportAllRiversToCanvasCommand,
14+
exportFilteredRiversToCanvasCommand
1415
} from "./lib/commands";
1516

1617
export default class PreviousRiverPlugin extends Plugin {
@@ -80,5 +81,11 @@ export default class PreviousRiverPlugin extends Plugin {
8081
name: "Export all rivers to canvas",
8182
callback: () => exportAllRiversToCanvasCommand(this.app),
8283
});
84+
85+
this.addCommand({
86+
id: "export-filtered-rivers-to-canvas",
87+
name: "Export filtered rivers to canvas",
88+
callback: () => exportFilteredRiversToCanvasCommand(this.app),
89+
});
8390
}
8491
}

0 commit comments

Comments
 (0)