Dataview: Update/append metadata between existing notes from template files

If you’re exploring the new core plugin “Bases” and have notes missing metadata from your current template, this might help.

It’s a quick solution using Dataview (dataviewjs) to identify and append missing metadata and tags across multiple notes. Specific plugins might exist for this, but this approach is simple and flexible and only require you to have dataview installed and paste the provided code into an empty note in Obsidian.

Config, search and updating is controlled directly in the note:

Let me know if you decide to try it out :blush:

Requirements: Dataview (Community Plugin) with Javascript queries enabled in the configuration.

Edit with improvements:
-Code updated with additional modes for searching based on folder, tags or properties.
-Better handling of autocomplete and saving of paths to YAML only after selction of criteria
-Summary of search results in addition to the table view of missing keys and tags.
-Reduced and cleaned up the ammount of code lines

```dataviewjs
// =======================
// 🛠 Constants & Helpers
// =======================

const STYLES = {
    input: "width: 100%; padding: 6px 8px; margin-bottom: 1em; border-radius: 4px; border: 1px solid var(--interactive-accent);",
    button: "padding: 8px 12px; border: 1px solid var(--interactive-accent); border-radius: 6px; cursor: pointer; margin-top: 1em;",
    primaryButton: "background-color: var(--interactive-accent); color: var(--text-on-accent);",
    secondaryButton: "background-color: var(--background-secondary); color: var(--text-normal); margin-left: 1em;",
    label: "font-weight: bold;",
    summaryBox: "background-color: var(--background-secondary); padding: 12px; border-radius: 6px; margin: 1em 0; border-left: 4px solid var(--interactive-accent);",
    successMessage: "color: var(--color-green); font-weight: bold; padding: 12px; background-color: var(--background-secondary); border-radius: 6px; margin: 1em 0;",
    table: "width: 100%; border-collapse: collapse; margin: 1em 0;",
    tableHeader: "border: 1px solid var(--background-modifier-border); padding: 8px; text-align: left; background-color: var(--background-secondary);",
    tableCell: "border: 1px solid var(--background-modifier-border); padding: 8px;",
};

// Cache data
let cachedData = null;
const getData = () => cachedData || (cachedData = {
    folders: app.vault.getAllLoadedFiles().filter(f => f instanceof obsidian.TFolder).map(f => f.path).sort(),
    mdFiles: app.vault.getMarkdownFiles().map(f => f.path).sort(),
    allTags: [...new Set(dv.pages().flatMap(p => p.tags || []))].map(t => t.replace(/^#/, "")).sort(),
    allProperties: [...new Set(dv.pages().flatMap(p => Object.keys(p).filter(k => !k.startsWith("file") && k !== "tags")))].sort()
});

// Path helpers
const normalizePath = (name) => {
    if (!name) return "";
    if (name.endsWith('.md')) return name;
    return getData().mdFiles.find(f => f.replace(/\.md$/, '') === name) || name + '.md';
};

const stripExt = (path) => path.replace(/\.md$/, '');

// Storage
const KEYS = { mode: "selected-mode", folder: "selected-folder", template: "selected-template", tag: "selected-tag", property: "selected-property", includeSubfolders: "include-subfolders" };
const defaults = { mode: "folder", folder: "", template: "", tag: "", property: "", includeSubfolders: true };

const withFrontMatter = async (fn) => {
    const file = app.vault.getAbstractFileByPath(dv.current().file.path);
    if (file) await app.fileManager.processFrontMatter(file, fn);
};

const load = async () => {
    const selections = {...defaults};
    await withFrontMatter(fm => {
        Object.entries(KEYS).forEach(([k, v]) => {
            if (fm[v] !== undefined) selections[k] = k === 'includeSubfolders' ? fm[v] !== false : fm[v];
        });
    });
    return selections;
};

const save = async (sel) => withFrontMatter(fm => Object.entries(KEYS).forEach(([k, v]) => fm[v] = sel[k]));
const clear = async () => withFrontMatter(fm => Object.values(KEYS).forEach(k => delete fm[k]));

// UI Builder with save on blur/change
const createInput = (container, {label, placeholder, id, options, visible = true, onUpdate}) => {
    const wrapper = container.createDiv();
    wrapper.style.display = visible ? "block" : "none";
    
    wrapper.createEl("div", { text: label, attr: { style: STYLES.label } });
    const input = wrapper.createEl("input", { attr: { placeholder, list: id } });
    input.style.cssText = STYLES.input;
    
    const datalist = wrapper.createEl("datalist", { attr: { id } });
    options.forEach(opt => datalist.createEl("option", { value: opt }));
    
    // Filter options on input
    input.addEventListener('input', e => {
        const val = e.target.value.toLowerCase();
        [...datalist.children].forEach(o => o.style.display = o.value.toLowerCase().includes(val) ? '' : 'none');
        if (onUpdate) onUpdate(e.target.value.trim());
    });
    
    // Save on blur (when clicking outside)
    input.addEventListener('blur', () => save(state));
    
    // Save on selecting from datalist
    input.addEventListener('change', () => save(state));
    
    return { wrapper, input };
};

const btn = (container, text, primary = true) => {
    const b = container.createEl("button", { text });
    b.style.cssText = STYLES.button + (primary ? STYLES.primaryButton : STYLES.secondaryButton);
    return b;
};

// =======================
// 🧩 Build UI
// =======================

const container = this.container;
container.empty();
dv.header(2, "🧩 Template metadata/property checker");

const data = getData();
let state = {...defaults};
let results = [];

// Mode selector
container.createEl("div", { text: "Mode:", attr: { style: STYLES.label } });
const modeSelect = container.createEl("select");
["folder", "tag", "property"].forEach(m => modeSelect.createEl("option", { value: m, text: m[0].toUpperCase() + m.slice(1) }));
modeSelect.style.marginBottom = "1em";

// Mode inputs
const inputs = {
    folder: createInput(container, { 
        label: "Folder to check:", 
        placeholder: "Type folder path...", 
        id: "folder-list", 
        options: data.folders,
        onUpdate: v => state.folder = v
    }),
    tag: createInput(container, { 
        label: "Tag to check:", 
        placeholder: "Type tag (without #)", 
        id: "tag-list", 
        options: data.allTags, 
        visible: false,
        onUpdate: v => state.tag = v
    }),
    property: createInput(container, { 
        label: "Property to check:", 
        placeholder: "Type property name...", 
        id: "property-list", 
        options: data.allProperties, 
        visible: false,
        onUpdate: v => state.property = v
    })
};

// Subfolder checkbox
const sfWrap = inputs.folder.wrapper.createDiv();
sfWrap.style.cssText = "margin-bottom: 1em; display: flex; align-items: center; gap: 8px;";
const subfoldersCheck = sfWrap.createEl("input", { attr: { type: "checkbox", checked: true } });
sfWrap.createEl("label", { text: "Include subfolders", attr: { style: "cursor: pointer;" } });

container.createEl("hr");

// Template input
const templateInput = createInput(container, { 
    label: "Template file:", 
    placeholder: "Type template filename...", 
    id: "template-list", 
    options: data.mdFiles.map(stripExt),
    onUpdate: v => state.template = normalizePath(v)
}).input;

const status = container.createDiv({ attr: { style: "margin: 0.5em 0 1em 0;" } });

// Buttons
const searchBtn = btn(container, "🔍 Search");
const updateBtn = btn(container, "🔁 Append missing metadata and tags");
const resetBtn = btn(container, "🧹 Reset selections", false);

container.createEl("hr");
const resultsDiv = container.createDiv();

// =======================
// 🔁 Logic
// =======================

const updateUI = () => Object.entries(inputs).forEach(([m, {wrapper}]) => wrapper.style.display = state.mode === m ? "block" : "none");

// Mode change saves immediately
modeSelect.onchange = async () => { 
    state.mode = modeSelect.value; 
    updateUI(); 
    await save(state);
};

// Checkbox saves immediately
subfoldersCheck.onchange = async e => {
    state.includeSubfolders = e.target.checked;
    await save(state);
};

// Search - no saving, just search
const search = () => {
    const template = dv.page(normalizePath(state.template));
    if (!state.template || !template) return status.setText("❌ Please select a valid template file.");
    if (!state[state.mode]) return status.setText(`❌ Please specify a ${state.mode}.`);
    
    status.setText("🔄 Scanning notes...");
    
    const reqKeys = Object.keys(template).filter(k => !k.startsWith("file") && k !== "tags");
    const reqTags = template.tags || [];
    
    const getPages = {
        folder: () => {
            const f = state.folder;
            return state.includeSubfolders 
                ? dv.pages().where(p => p.file.path.startsWith(f))
                : dv.pages().where(p => {
                    const fp = f.endsWith('/') ? f : f + '/';
                    const rel = p.file.path.startsWith(fp) ? p.file.path.substring(fp.length) : null;
                    return rel && !rel.includes('/');
                });
        },
        tag: () => dv.pages().where(p => (p.tags || []).includes(state.tag)),
        property: () => dv.pages().where(p => state.property in p)
    };
    
    const pages = getPages[state.mode]();
    results = [];
    let complete = 0;
    
    for (const p of pages) {
        const mKeys = reqKeys.filter(k => !(k in p));
        const mTags = reqTags.filter(t => !(p.tags || []).includes(t));
        
        if (mKeys.length || mTags.length) results.push({ note: p.file, mKeys, mTags });
        else complete++;
    }
    
    // Clear and render
    resultsDiv.innerHTML = '';
    resultsDiv.createEl("h3", { text: "📋 Search Results" });
    
    const summary = resultsDiv.createDiv();
    summary.style.cssText = STYLES.summaryBox;
    summary.innerHTML = `
        <div style="font-weight: bold; margin-bottom: 8px;">Summary:</div>
        <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px;">
            <div>📝 Total: ${pages.length}</div>
            <div style="color: var(--color-green);">✅ Complete: ${complete}</div>
            <div style="color: var(--color-orange);">⚠️ Incomplete: ${results.length}</div>
        </div>`;
    
    if (!results.length) {
        resultsDiv.createEl("p", { text: "All notes have complete metadata and tags!", attr: { style: STYLES.successMessage } });
    } else {
        resultsDiv.createEl("h4", { text: `📋 Incomplete notes (${results.length})`, attr: { style: "margin-top: 1.5em; color: var(--color-orange);" } });
        
        const table = resultsDiv.createEl("table", { attr: { style: STYLES.table } });
        const header = table.createEl("tr");
        ["Note", "Missing keys", "Missing tags"].forEach(h => header.createEl("th", { text: h, attr: { style: STYLES.tableHeader } }));
        
        results.forEach(({note, mKeys, mTags}) => {
            const row = table.createEl("tr");
            
            const cell = row.createEl("td", { attr: { style: STYLES.tableCell } });
            cell.createEl("a", { text: stripExt(note.name), attr: { href: note.path, class: "internal-link" } })
                .onclick = e => { e.preventDefault(); app.workspace.openLinkText(note.path, '', false); };
            
            row.createEl("td", { text: mKeys.length ? mKeys.join(", ") : "—", attr: { style: STYLES.tableCell } });
            row.createEl("td", { text: mTags.length ? mTags.join(", ") : "—", attr: { style: STYLES.tableCell } });
        });
    }
    
    status.setText("✅ Search completed.");
};

// Buttons
searchBtn.onclick = search;  // Just search, no saving

updateBtn.onclick = async () => {
    if (!results.length) return status.setText("❌ No notes to update. Please search first.");
    
    status.setText("🔄 Updating notes...");
    const template = dv.page(normalizePath(state.template));
    let count = 0;
    
    for (const {note, mKeys, mTags} of results) {
        const file = app.vault.getAbstractFileByPath(note.path);
        if (!file) continue;
        
        await app.fileManager.processFrontMatter(file, fm => {
            mKeys.forEach(k => fm[k] = "");
            if (mTags.length) fm.tags = [...new Set([...(fm.tags || []), ...mTags])];
        });
        count++;
    }
    
    status.setText(`✅ Updated ${count} notes.`);
    results = [];
    resultsDiv.innerHTML = '';
};

resetBtn.onclick = async () => {
    await clear();
    state = {...defaults};
    
    Object.values(inputs).forEach(({input}) => input.value = "");
    templateInput.value = "";
    modeSelect.value = "folder";
    subfoldersCheck.checked = true;
    updateUI();
    
    resultsDiv.innerHTML = '';
    results = [];
    status.setText("✅ Selections cleared.");
};

// Init
(async () => {
    state = await load();
    
    modeSelect.value = state.mode;
    Object.entries(inputs).forEach(([m, {input}]) => input.value = state[m]);
    templateInput.value = stripExt(state.template);
    subfoldersCheck.checked = state.includeSubfolders;
    
    updateUI();
})();
1 Like

This fits in so perfectly with my use-case. Thanks - works right out of the box.
I love it for the DataviewJS alone to have an example how to incorporate utilities into my notes with it. Thats fantastic for making clean-up tasks more streamlined.

One thing that i might try to add, based on your example - is having more filtering options (currently its only the folder). I have sets of notes with vastly different meta-data setups, that still live in the same folder.
But looking at your script, this should be easy to achieve (i hope).

1 Like

I played a bit with it - and boy am i happy =)
I added some utility that i need for my use-case, namely the ability to also filter based on an yaml property (i.e. a tag or a custom one).
Folders and subfolders can be searched recursively (can be toggled).
I also added an “update” button, and changed the behaviour so that it doenst auto-update when you make changes to the settings (this leads to “flickering” and bad UI behavior once you have decently enough files and dataview queries take a couple seconds).

Maybe its useful for someone, for me it saves me the need to write some custom python scripts for basically the same functionality. As this is directly integrated into my workflow inside Obsidian, its a fantastic solution. Again, thanks for this!

Edit:

  • changed the save behavior for the settings (as this was constantly triggering auto-updates). Now its only saving the settings to the frontmatter when “Update Selection” is clicked.

Edit2:

  • updated the property handling for lists (like tags) to perform like expected for the filthering.

Bug:
If loading the note for the first time, the dataview block only gets rendered once i scroll past it in Obsidian. After that everything works as expected. Still not figured out what causes this behaviour.

// =======================
// 🛠 Helper Functions
// =======================

function debounce(fn, delay) {
    let timer;
    return function (...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), delay);
    };
}

async function getStoredSelections() {
    const file = app.vault.getAbstractFileByPath(dv.current().file.path);
    let folder = "";
    let template = "";
    let filterKey = "";
    let filterValue = "";
    let recursiveScan = false;

    await app.fileManager.processFrontMatter(file, (fm) => {
        folder = fm["selected-folder"] || "";
        template = fm["selected-template"] || "";
        filterKey = fm["filter-key"] || "";
        filterValue = fm["filter-value"] || "";
        recursiveScan = fm["recursive-scan"] || false;
    });

    return { folder, template, filterKey, filterValue, recursiveScan };
}

// Modified storeSelections to be called only on button click
async function storeSelections(folder, template, filterKey, filterValue, recursiveScan) {
    const file = app.vault.getAbstractFileByPath(dv.current().file.path);
    await app.fileManager.processFrontMatter(file, (fm) => {
        fm["selected-folder"] = folder;
        fm["selected-template"] = template;
        fm["filter-key"] = filterKey;
        fm["filter-value"] = filterValue;
        fm["recursive-scan"] = recursiveScan;
    });
}

async function clearSelections() {
    const file = app.vault.getAbstractFileByPath(dv.current().file.path);

    await app.fileManager.processFrontMatter(file, (fm) => {
        delete fm["selected-folder"];
        delete fm["selected-template"];
        delete fm["filter-key"];
        delete fm["filter-value"];
        delete fm["recursive-scan"];
    });
}

// =======================
// 🧩 Build UI Elements
// =======================

const container = this.container;
container.empty();

dv.header(2, "🧩 Template metadata/property checker");

// Folder Selection
container.createEl("div", { text: "Folder to check:" });
const folderInputWrapper = container.createDiv();
folderInputWrapper.style.position = "relative";
folderInputWrapper.style.marginBottom = "1em";
const folderInput = folderInputWrapper.createEl("input", { attr: { placeholder: "Type folder path..." } });
folderInput.style.cssText = "width: 100%; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--interactive-accent); box-sizing: border-box;";
const folderDropdown = folderInputWrapper.createDiv();
folderDropdown.style.cssText = "position: absolute; top: 100%; left: 0; right: 0; border: 1px solid var(--interactive-accent); border-top: none; background-color: var(--background-primary); max-height: 150px; overflow-y: auto; z-index: 10; display: none;";

// Recursive Scan Toggle
const recursiveScanWrapper = container.createDiv();
recursiveScanWrapper.style.marginBottom = "1em";
const recursiveScanToggle = recursiveScanWrapper.createEl("input", { type: "checkbox" });
const recursiveScanLabel = recursiveScanWrapper.createEl("label", { text: "Scan subdirectories recursively" });
recursiveScanLabel.style.marginLeft = "0.5em";

// Template Selection
container.createEl("div", { text: "Template file:" });
const templateInputWrapper = container.createDiv();
templateInputWrapper.style.position = "relative";
templateInputWrapper.style.marginBottom = "1em";
const templateInput = templateInputWrapper.createEl("input", { attr: { placeholder: "Type template filename..." } });
templateInput.style.cssText = "width: 100%; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--interactive-accent); box-sizing: border-box;";
const templateDropdown = templateInputWrapper.createDiv();
templateDropdown.style.cssText = "position: absolute; top: 100%; left: 0; right: 0; border: 1px solid var(--interactive-accent); border-top: none; background-color: var(--background-primary); max-height: 150px; overflow-y: auto; z-index: 10; display: none;";

// YAML Property Filter
container.createEl("div", { text: "Filter by YAML property (optional):" });
const filterInputWrapper = container.createDiv();
filterInputWrapper.style.display = "flex";
filterInputWrapper.style.gap = "1em";
filterInputWrapper.style.marginBottom = "1em";

const filterKeyInput = filterInputWrapper.createEl("input", { attr: { placeholder: "Property key..." } });
filterKeyInput.style.cssText = "flex-grow: 1; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--interactive-accent); box-sizing: border-box;";

const filterValueInput = filterInputWrapper.createEl("input", { attr: { placeholder: "Property value..." } });
filterValueInput.style.cssText = "flex-grow: 1; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--interactive-accent); box-sizing: border-box;";


const statusDiv = container.createDiv();
statusDiv.style.margin = "0.5em 0 1em 0";

// New "Update Selection" button
const updateButton = container.createEl("button", { text: "🔄 Update Selection" });
updateButton.style.cssText = "padding: 8px 12px; border: 1px solid var(--interactive-accent); border-radius: 6px; background-color: var(--interactive-accent); color: var(--text-on-accent); cursor: pointer; margin-top: 1em;";

const fixButton = container.createEl("button", { text: "🔁 Append missing metadata and tags" });
fixButton.style.cssText = "padding: 8px 12px; border: 1px solid var(--interactive-accent); border-radius: 6px; background-color: var(--interactive-accent); color: var(--text-on-accent); cursor: pointer; margin-top: 1em; margin-left: 1em;";


const resetButton = container.createEl("button", { text: "🧹 Reset selections" });
resetButton.style.cssText = "padding: 8px 12px; border: 1px solid var(--interactive-accent); border-radius: 6px; background-color: var(--background-secondary); color: var(--text-normal); cursor: pointer; margin-top: 1em; margin-left: 1em;";

container.appendChild(updateButton);
container.appendChild(fixButton);
container.appendChild(resetButton);
container.appendChild(statusDiv);

// State variables used by the Dataview query (only updated on button click)
let selectedFolder = "";
let selectedTemplate = "";
let filterKey = "";
let filterValue = "";
let recursiveScan = false;

let resultRows = [];

// Removed the shouldRenderOnLoad flag

// =======================
// 🌳 Data sources
// =======================

const folders = app.vault.getAllLoadedFiles()
    .filter(f => f instanceof obsidian.TFolder)
    .map(f => f.path)
    .sort();

const mdFiles = app.vault.getMarkdownFiles().map(f => f.path).sort();

// =======================
// 🔎 Folder Input Logic
// =======================

function updateFolderDropdown(filter) {
    folderDropdown.empty();
    if (!filter) {
        folderDropdown.style.display = "none";
        return;
    }

    const matches = folders.filter(f => f.toLowerCase().includes(filter.toLowerCase()));
    if (matches.length === 0) {
        folderDropdown.style.display = "none";
        return;
    }

    for (const match of matches) {
        const optionDiv = folderDropdown.createDiv({ text: match });
        optionDiv.style.padding = "6px 8px";
        optionDiv.style.cursor = "pointer";
        optionDiv.style.borderBottom = "1px solid var(--interactive-accent)";
        optionDiv.onmouseover = () => optionDiv.style.backgroundColor = "var(--interactive-accent)";
        optionDiv.onmouseout = () => optionDiv.style.backgroundColor = "transparent";

        optionDiv.onclick = async () => {
            folderInput.value = match;
            folderDropdown.style.display = "none";
        };
    }

    folderDropdown.style.display = "block";
}

// =======================
// 🔎 Template Input Logic
// =======================

function updateTemplateDropdown(filter) {
    templateDropdown.empty();
    if (!filter) {
        templateDropdown.style.display = "none";
        return;
    }

    const matches = mdFiles.filter(f => f.toLowerCase().includes(filter.toLowerCase()));
    if (matches.length === 0) {
        templateDropdown.style.display = "none";
        return;
    }

    for (const match of matches) {
        const optionDiv = templateDropdown.createDiv({ text: match });
        optionDiv.style.padding = "6px 8px";
        optionDiv.style.cursor = "pointer";
        optionDiv.style.borderBottom = "1px solid var(--interactive-accent)";
        optionDiv.onmouseover = () => optionDiv.style.backgroundColor = "var(--interactive-accent)";
        optionDiv.onmouseout = () => optionDiv.style.backgroundColor = "transparent";

        optionDiv.onclick = async () => {
            templateInput.value = match;
            templateDropdown.style.display = "none";
        };
    }

    templateDropdown.style.display = "block";
}

// =======================
// 🔁 Event Bindings
// =======================

document.addEventListener("click", (e) => {
    if (!folderInputWrapper.contains(e.target)) folderDropdown.style.display = "none";
    if (!templateInputWrapper.contains(e.target)) templateDropdown.style.display = "none";
});

// Debounced input handlers to update dropdowns, NO frontmatter storage
folderInput.addEventListener("input", debounce(async (e) => {
    updateFolderDropdown(e.target.value);
}, 200));

templateInput.addEventListener("input", debounce(async (e) => {
    updateTemplateDropdown(e.target.value);
}, 200));

// Filter and toggle handlers also do NOT store to frontmatter
filterKeyInput.addEventListener("input", debounce(async (e) => {
    // No action needed here other than allowing input
}, 300));

filterValueInput.addEventListener("input", debounce(async (e) => {
    // No action needed here other than allowing input
}, 300));

recursiveScanToggle.addEventListener("change", async (e) => {
    // No action needed here other than allowing toggle
});

// Event listener for the "Update Selection" button
updateButton.onclick = async () => {
    // Update the state variables used by the Dataview query from input values
    selectedFolder = folderInput.value.trim();
    selectedTemplate = templateInput.value.trim();
    filterKey = filterKeyInput.value.trim();
    filterValue = filterValueInput.value.trim();
    recursiveScan = recursiveScanToggle.checked;

    // Store the now-finalized selections to frontmatter
    // This frontmatter change will trigger Dataview's re-render
    await storeSelections(selectedFolder, selectedTemplate, filterKey, filterValue, recursiveScan);

    // Removed the explicit call to scanAndRender() here!
    // Dataview's reactivity will handle the re-render after frontmatter update.

    // Optionally, provide immediate feedback to the user
    statusDiv.setText("Selections updated. Re-rendering...");
};


resetButton.onclick = async () => {
    await clearSelections();
    // Reset state variables
    selectedFolder = "";
    selectedTemplate = "";
    filterKey = "";
    filterValue = "";
    recursiveScan = false;

    folderInput.value = "";
    templateInput.value = "";
    filterKeyInput.value = "";
    filterValueInput.value = "";
    recursiveScanToggle.checked = false;

    // No need to reset shouldRenderOnLoad flag

    dv.paragraph("Selections cleared. Please choose folder and template again.");
    scanAndRender(); // Re-render to show empty state (will be empty due to cleared state)
};

// =======================
// 🔎 Scan and Render Logic
// =======================

async function scanAndRender() {
    // Removed the conditional check based on shouldRenderOnLoad

    statusDiv.setText("Scanning notes...");

    // Clear previous results by clearing the container element
    // This ensures that the new output replaces the old one.
    container.findAll(".dataview.table-view").forEach(el => el.remove());
    container.findAll("p").forEach(el => {
        // Only remove paragraphs that are part of the Dataview output,
        // not the initial instruction paragraph.
        if (!el.textContent.startsWith("Start typing folder and template names above") &&
            !el.textContent.startsWith("Selections loaded. Click 'Update Selection'")) { // Also exclude the loaded message
             el.remove();
        }
    });
    container.findAll("h3").forEach(el => el.remove());


    if (!selectedFolder) {
        statusDiv.setText("Please select a folder from the suggestions.");
        dv.paragraph("No data to show.");
        return;
    }
    if (!selectedTemplate) {
        statusDiv.setText("Please select a template from the suggestions.");
        dv.paragraph("No data to show.");
        return;
    }

    const template = dv.page(selectedTemplate);
    if (!template) {
        statusDiv.setText(`Template not found: ${selectedTemplate}`);
        return;
    }

    const requiredKeys = Object.keys(template).filter(k => !k.startsWith("file") && k !== "tags");
    const requiredTags = Array.isArray(template.tags) ? template.tags : [];

    let pages;
    if (recursiveScan) {
        // Exclude the current file from the recursive scan
        pages = dv.pages(`"${selectedFolder}"`).where(p => p.file.path !== dv.current().file.path);
    } else {
        // Exclude the current file from the non-recursive scan
        pages = dv.pages().where(p => p.file.folder === selectedFolder && p.file.path !== dv.current().file.path);
    }

    // Apply YAML property filter if key and value are provided
    if (filterKey) {
        if (filterValue) {
            // Filter for pages where the property exists
            pages = pages.where(p => p[filterKey] !== undefined);

            // Now, refine the filter based on the property type and value
            pages = pages.where(p => {
                const propertyValue = p[filterKey];
                if (Array.isArray(propertyValue)) {
                    // If the property is a list, check if it includes the filterValue
                    // Case-insensitive comparison for list items
                    return propertyValue.some(item =>
                        typeof item === 'string' && item.toLowerCase() === filterValue.toLowerCase()
                    );
                } else if (typeof propertyValue === 'string') {
                    // If it's a string, perform case-insensitive comparison
                    return propertyValue.toLowerCase() === filterValue.toLowerCase();
                }
                 else {
                    // For other types (numbers, booleans), check for direct equality
                    return propertyValue === filterValue;
                }
            });
        } else {
             // If only key is provided, filter for notes that have the key
             pages = pages.where(p => p[filterKey] !== undefined);
        }
    }

    resultRows = [];

    for (const page of pages) {
        const missingKeys = requiredKeys.filter(k => page[k] === undefined);
        const currentTags = Array.isArray(page.tags) ? page.tags : [];
        const missingTags = requiredTags.filter(t => !currentTags.includes(t));

        if (missingKeys.length > 0 || missingTags.length > 0) {
            resultRows.push({
                note: page.file.link,
                path: page.file.path,
                missingKeys,
                missingTags
            });
        }
    }

    dv.header(3, "📋 Notes missing metadata or tags");
    if (resultRows.length === 0) {
        dv.paragraph("✅ All notes have complete metadata and tags.");
    } else {
        dv.table(
            ["Note", "Missing keys", "Missing tags"],
            resultRows.map(r => [
                r.note,
                r.missingKeys.length ? r.missingKeys.join(", ") : "—",
                r.missingTags.length ? r.missingTags.join(", ") : "—"
            ])
        );
        dv.paragraph(`🔍 Number of notes with missing metadata or tags: **${resultRows.length}**`);
    }

    statusDiv.setText("");
}

// =======================
// 🔘 Button click to fix metadata/tags
// =======================

fixButton.onclick = async () => {
    if (resultRows.length === 0) {
        statusDiv.setText("No notes to update.");
        return;
    }

    statusDiv.setText("🔄 Updating metadata and tags...");

    const template = dv.page(selectedTemplate);
    if (!template) {
        statusDiv.setText(`Template not found: ${selectedTemplate}`);
        return;
    }

    let updatedCount = 0;

    for (const { path, missingKeys, missingTags } of resultRows) {
        const file = app.vault.getAbstractFileByPath(path);
        if (!file || !file.path) continue;

        await app.fileManager.processFrontMatter(file, (frontmatter) => {
            for (const key of missingKeys) {
                if (frontmatter[key] === undefined) {
                     frontmatter[key] = template[key] !== undefined ? template[key] : "";
                }
            }

            if (!Array.isArray(frontmatter.tags)) {
                frontmatter.tags = [...(Array.isArray(template.tags) ? template.tags : [])];
            } else {
                for (const tag of missingTags) {
                    if (!frontmatter.tags.includes(tag)) {
                        frontmatter.tags.push(tag);
                    }
                }
            }
        });

        updatedCount++;
    }

    statusDiv.setText(`✅ Done! Updated **${updatedCount}** notes. Refreshing results...`);
    await scanAndRender(); // Explicitly re-render after fixing
};

// =======================
// 🏁 Load Saved Values on Start
// =======================

(async () => {
    const stored = await getStoredSelections();
    // Load stored values directly into input fields and toggle
    folderInput.value = stored.folder;
    templateInput.value = stored.template;
    filterKeyInput.value = stored.filterKey;
    filterValueInput.value = stored.filterValue;
    recursiveScanToggle.checked = stored.recursiveScan;

    // If stored selections exist, update the state variables and perform initial scan
    if (folderInput.value.trim() && templateInput.value.trim()) {
        // Update the state variables used by the Dataview query from input values
        selectedFolder = folderInput.value.trim();
        selectedTemplate = templateInput.value.trim();
        filterKey = filterKeyInput.value.trim();
        filterValue = filterValueInput.value.trim();
        recursiveScan = recursiveScanToggle.checked;

        // Explicitly call scanAndRender() on initial load if selections exist
        await scanAndRender();
    } else {
        // If no selections are loaded, the initial message is shown
        dv.paragraph("Start typing folder and template names above, then click 'Update Selection' to scan notes.");
    }
})();

Thats so cool to hear, I was really hoping it could be useful for others. I will definitely try out your version tommorow.

Thanks for all your further enhancements!

Incase you copied the script already, i made some tweaks to the save-logic, as this was triggering auto-updates constantly. Now it only saves when using the “update settings” buttons (and also triggers a re-render).
I updated the script in the original comment, so there are no different versions floating around, wich might get confusing =)

1 Like

I updated my initial code (first post in this thread) with alot of similar functionality to yours and made some improvements in handling of the search, this includes better autocomplete when typing (for tags, folders and properties that already exist in your vault) and two additional search modes (tags and properties).

Original post is edited with the newest code, @Abhuva thanks alot for your ideas and feedback :raising_hands: it really made me happy!

Should be much more flexibile solution now and easier to work with for different setups.

1 Like