Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/utils/FieldValueCollector.issue671.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "obsidian";
import { FieldSuggestionCache } from "./FieldSuggestionCache";
import { collectFieldValuesProcessed } from "./FieldValueCollector";

vi.mock("obsidian-dataview", () => ({
getAPI: () => null,
}));

describe("Issue #671 - {{FIELD:tags}} suggestions", () => {
beforeEach(() => {
FieldSuggestionCache.getInstance().clear();
});

it("includes tags from the vault tag index", async () => {
const app = new App();

// @ts-expect-error - getTags exists in Obsidian but is not typed
app.metadataCache.getTags = () => ({
"#ai/technology": 1,
"#cook/hoven": 1,
});

app.vault.getMarkdownFiles = () => [];

const values = await collectFieldValuesProcessed(app, "tags", {});

expect(values).toEqual(
expect.arrayContaining(["ai/technology", "cook/hoven"]),
);
});
});
113 changes: 113 additions & 0 deletions src/utils/FieldValueCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export async function collectFieldValuesRaw(
fieldName: string,
filters: FieldFilter,
): Promise<Set<string>> {
const normalizedFieldName = fieldName.trim().toLowerCase();
if (normalizedFieldName === "tags" || normalizedFieldName === "tag") {
const tagValues = await collectTagValues(app, filters);
if (tagValues.size > 0) return tagValues;
}

// Try Dataview when allowed; fall back to manual collection
try {
if (!filters.inline && DataviewIntegration.isAvailable(app)) {
Expand All @@ -86,6 +92,113 @@ export async function collectFieldValuesRaw(
return await collectFieldValuesManually(app, fieldName, filters);
}

async function collectTagValues(app: App, filters: FieldFilter): Promise<Set<string>> {
const hasFileFilters =
Boolean(filters.folder) ||
Boolean(filters.tags?.length) ||
Boolean(filters.excludeFolders?.length) ||
Boolean(filters.excludeTags?.length) ||
Boolean(filters.excludeFiles?.length);

if (!hasFileFilters) {
const fromIndex = collectAllVaultTags(app);
if (fromIndex.size > 0) return fromIndex;
}

return await collectTagValuesFromFiles(app, filters);
}

function collectAllVaultTags(app: App): Set<string> {
const values = new Set<string>();

try {
// @ts-expect-error - getTags exists in Obsidian but is not typed
const tagObj = app.metadataCache.getTags?.() as
| Record<string, number>
| undefined;

if (!tagObj) return values;

for (const rawTag of Object.keys(tagObj)) {
const cleaned = rawTag.startsWith("#") ? rawTag.substring(1) : rawTag;
const tag = cleaned.trim();
if (tag) values.add(tag);
}
} catch {
// ignore and fall back to file-based collection
}

return values;
}

async function collectTagValuesFromFiles(
app: App,
filters: FieldFilter,
): Promise<Set<string>> {
const rawValues = new Set<string>();

let files = app.vault.getMarkdownFiles();
files = EnhancedFieldSuggestionFileFilter.filterFiles(
files,
filters,
(file: TFile) => app.metadataCache.getFileCache(file),
);

const batchSize = 50;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const promises = batch.map(async (file) => {
const values = new Set<string>();
try {
const metadataCache = app.metadataCache.getFileCache(file);

// Frontmatter tags
const frontmatterTags: unknown = metadataCache?.frontmatter?.tags;
if (frontmatterTags !== undefined && frontmatterTags !== null) {
const tags = Array.isArray(frontmatterTags)
? frontmatterTags
: [frontmatterTags];

for (const tag of tags) {
const s = String(tag).trim();
if (s) values.add(s);
}
}

// Frontmatter tag (singular)
const frontmatterTag: unknown = metadataCache?.frontmatter?.tag;
if (frontmatterTag !== undefined && frontmatterTag !== null) {
const tags = Array.isArray(frontmatterTag)
? frontmatterTag
: [frontmatterTag];

for (const tag of tags) {
const s = String(tag).trim();
if (s) values.add(s);
}
}

// Inline tags
if (metadataCache?.tags) {
for (const t of metadataCache.tags) {
const raw = String(t.tag ?? "").trim();
const tag = raw.startsWith("#") ? raw.substring(1) : raw;
if (tag) values.add(tag);
}
}
} catch {}
return values;
});

const batchResults = await Promise.all(promises);
for (const set of batchResults) {
for (const v of set) rawValues.add(v);
}
}

return rawValues;
}

async function collectFieldValuesManually(
app: App,
fieldName: string,
Expand Down