Automatically update property

What I’m trying to do

One of my uses for Obsidian is as a tracker for the media. I have notes for each of the comic series and its subsequent issues (all in a folder with the series title) and I am looking to display the percentage of issue I have read in a base. I currently have a property that lists the total amount of issues and want to add the amount of issues currently read to my series note.

Things I have tried

Ideally I wouldn’t want to update the issues read manually, and I’ve created a templater that does this.


<%*
const dv = app.plugins.plugins.dataview.api
const path = tp.file.folder(true)
const readCh = dv.pages(`"${path}" and #🍿/💭/issue`).where(p => p.readTimes != null).length

tp.hooks.on_all_templates_executed(async () => {
  const file = tp.file.find_tfile(tp.file.path(true));
  await tp.app.fileManager.processFrontMatter(file, (frontmatter) => {
    frontmatter["readChapters"] = readCh;
  });
});
-%>

I’m assuming I can’t access the number of issues through a formula in the base, so I was wondering if there’s any way to run this template in a more automated way. TIA :slight_smile:

The reason I am responding is to showcase the method, not the actual solution.

Method:
Codescript Toolkit plugin > create startup folder in a folder in your vault:
e.g. on Windows:
C:\Users<User>\Documents\Obsidian\My_Vault\My_Scripts\codescript_tkit\startup
In it, create:
main.ts file with:

import { invoke as seriesreadstatuspdater } from './series_read_status_updater.ts';


export async function invoke(app: App): Promise<void> {
  await seriesreadstatuspdater(app);
}

and in the same codescript_tkit\startup folder (you can rename it as long as you point the plugin setting at the new path):
series_read_status_updater.ts file with:

import { App, TFile, Notice, getAllTags, MarkdownView } from 'obsidian';

// ================================
// CONFIGURATION
// ================================
const CONFIG = {
    // Tag that identifies an issue note
    issueTag: '#🍿/💭/issue',
    // Property in issue note that indicates it has been read
    readProperty: 'readTimes',
    // Property in series note to update with count
    seriesProperty: 'readChapters',
};

// Check if a file is an "Issue Note" (has the specific tag)
const isIssueNote = (app: App, file: TFile): boolean => {
    const cache = app.metadataCache.getFileCache(file);
    if (!cache) return false;
    const tags = getAllTags(cache);
    if (!tags) return false;
    return tags.some(tag => tag === CONFIG.issueTag || tag.startsWith(CONFIG.issueTag + '/'));
};

// Check if a file is a "Series Note" (has the seriesProperty in frontmatter)
const isSeriesNote = (app: App, file: TFile): boolean => {
    const cache = app.metadataCache.getFileCache(file);
    return !!(cache?.frontmatter && Object.prototype.hasOwnProperty.call(cache.frontmatter, CONFIG.seriesProperty));
};

// Check if an issue is considered "read"
const isIssueRead = (app: App, file: TFile): boolean => {
    const cache = app.metadataCache.getFileCache(file);
    return !!(
        cache?.frontmatter &&
        Object.prototype.hasOwnProperty.call(cache.frontmatter, CONFIG.readProperty) &&
        cache.frontmatter[CONFIG.readProperty] !== null
    );
};

// Update the Series Note with the count of read issues
const updateSeriesNote = async (app: App, seriesFile: TFile) => {
    if (!seriesFile.parent) return;

    const siblings = seriesFile.parent.children.filter(
        f => f instanceof TFile && (f as TFile).extension === 'md'
    ) as TFile[];

    const readCount = siblings.filter(f => isIssueNote(app, f) && isIssueRead(app, f)).length;

    let updated = false;
    await app.fileManager.processFrontMatter(seriesFile, (frontmatter) => {
        if (frontmatter[CONFIG.seriesProperty] !== readCount) {
            frontmatter[CONFIG.seriesProperty] = readCount;
            updated = true;
            console.log(`📚 Updated ${seriesFile.basename}: ${CONFIG.seriesProperty} = ${readCount}`);
        }
    });

    if (updated) {
        new Notice(`📚 Updated read count for ${seriesFile.basename} to ${readCount}`, 2000);
    }
};

// Main processing function — runs on the file the user just LEFT
const processFile = async (app: App, file: TFile) => {
    if (!file || file.extension !== 'md') return;

    try {
        // If the file is an Issue Note, find sibling Series Notes and update them
        if (isIssueNote(app, file)) {
            const parent = file.parent;
            if (!parent) return;

            const siblings = parent.children.filter(
                f => f instanceof TFile && (f as TFile).extension === 'md'
            ) as TFile[];

            const seriesNotes = siblings.filter(f => isSeriesNote(app, f));
            for (const seriesNote of seriesNotes) {
                await updateSeriesNote(app, seriesNote);
            }
        }
    } catch (error) {
        console.error(`Error processing file ${file.path}:`, error);
    }
};

export async function invoke(app: App): Promise<void> {
    let lastActiveFile: TFile | null = null;

    // Deregister any previously registered hooks to prevent duplicates on re-run
    if ((app as any)._seriesUpdateHooks) {
        for (const ref of (app as any)._seriesUpdateHooks) {
            app.workspace.offref(ref);
        }
    }
    (app as any)._seriesUpdateHooks = [];

    // Handle tab switches: process the file the user just LEFT
    const activeLeafChangeRef = app.workspace.on('active-leaf-change', async (leaf) => {
        const currentFile = (leaf?.view instanceof MarkdownView ? leaf.view.file : null) as TFile | null;

        if (lastActiveFile && currentFile?.path !== lastActiveFile.path) {
            await processFile(app, lastActiveFile);
        }

        lastActiveFile = currentFile;
    });

    // Handle file opens: process the file the user just LEFT
    const fileOpenRef = app.workspace.on('file-open', async (file: TFile) => {
        if (lastActiveFile && file?.path !== lastActiveFile.path) {
            await processFile(app, lastActiveFile);
        }
        lastActiveFile = file ?? null;
    });

    // Store refs for cleanup on next run
    (app as any)._seriesUpdateHooks.push(activeLeafChangeRef, fileOpenRef);

    console.log('📚 Series Read Status Updater enabled');
    new Notice('📚 Series Read Status Updater enabled', 3000);
}

I am sharing the Claude.ai chat because you can download actual test files as well here and read what is expected of the user to do or happen:
https://claude.ai/share/828c6a7e-53d3-4a61-a07f-93cc82f9d04c

Basically, we set up a script that lives in Obsidian from startup and has an event listener: leaving the issue (by closing tab or going to another tab) will update the property ‘readChapters’

I do not have a workflow like this (but had a similar auto fire script in a vault) and for my taste there are too many different kind of terms like “chapter”, “issue”, etc. but if that’s how you set it up, then try this.

Any “issues”, (ahem) shout here or feed the script and your ideas to Claude and be done with it in 2-3 mins.

Script uses plain Obsidian API functions so no Templater or Dataview dependecy.