Table of Contents (Headers) #888
Replies: 12 comments 20 replies
-
Just wanted to say thanks, this works great. |
Beta Was this translation helpful? Give feedback.
-
You could also use the metadataCache to avoid regex matching and related edge cases. |
Beta Was this translation helpful? Give feedback.
-
Thanks for your contribution! Concise syntax! But when I linted the md, the menu splited up. I suppose the sentence it returns doesn't conform to the standard markdown syntax. I just added literally two spaces. > [!SUMMARY]+ Table of Contents
<%*
let headers = await tp.file.content
.split('\n') // split file into lines
.filter(t => t.match(/^[#]+\s+/gi)) // only get headers
.map(h => {
let header_level = h.split(' ')[0].match(/#/g).length;
// get header text without special characters like '[' and ']'
let header_text = h.substring(h.indexOf(' ') + 1).replace(/[\[\]]+/g, '');
let header_link = `[[${tp.file.title}#${header_text}|${header_text}]]`
// prepend block-quote (>), indentation and bullet-point (-)
return `> ${' '. repeat (header_level - 1) + '- ' + header_link}`;
})
.join('\n')
%><% headers %> |
Beta Was this translation helpful? Give feedback.
-
Great template!
I'm still planning to make the following updates but need time to work on it some more:
My updates: > [!SUMMARY]+ Table of Contents
<%*
// Get input from user - specify maximum header level to be displayed (default = 3)
let header_limit = await tp.system.prompt("Show Contents Down to Which Header Level (1-6)?", "3");
let headers = await tp.file.content
.split('\n') // split file into lines
.filter(t => t.match(/^[#]+\s+/gi)) // only get headers
.map(h => {
let header_level = h.split(' ')[0].match(/#/g).length;
// get header text without special characters like '[' and ']'
//let header_text = h.substring(h.indexOf(' ') + 1).replace(/[\[\]]+/g, '');
// get header text without removing any special characters
let header_text = h.substring(h.indexOf(' ') + 1);
// get header text URL as it is in the file (DON'T REMOVE SPECIAL CHARS OR LINK TO HEADER WON'T WORK)
let header_url = h.substring(h.indexOf(' ') + 1);
// Wikilinks style output:
//let header_link = `[[${tp.file.title}#${header_url}|${header_text}]]`
// Non-wikilinks style output:
let file_title= tp.file.title.replace(/ /g, '%20'); // Replace spaces in file names with '%20'
header_url = header_url.replace(/ /g, '%20'); // Replace spaces in urls with '%20'
let header_link = `[${header_text}](${file_title}.md#${header_url})`
// Output ALL header levels
// prepend block-quote (>), indentation and bullet-point (-)
//return `>${' '.repeat(header_level - 1) + '- ' + header_link}`;
// Output headers up to specified level
if ( header_level <= header_limit) {
return `>${' '.repeat(header_level - 1) + '- ' + header_link}`;
}
})
.join('\n')
// If not using all headers, empty lines are inserted where non-displayed headers should be.
// This removes any blank lines
while (headers.includes("\n\n")) { headers= headers.replace(/\n\n/g,'\n'); }
%><% headers %> |
Beta Was this translation helpful? Give feedback.
-
Version 2 of my updated code below. Managed to sort out the issue with Key changes (from original author's code):
Still to resolve:
---
>[!SUMMARY]+ Table of Contents
<%*
// Amended from: https://github.com/SilentVoid13/Templater/discussions/888
// Another alternative method of getting headers from file (uses different way to get current file reference):
//const outlineArray = await app.metadataCache.getFileCache(await tp.file.find_tfile(await tp.file.title)).headings;
// Change to 1 to output useful debugging info in console (Ctrl + Shift + i)
// Most often outputs a breakdown of system commands to check they are working.
var deBug = 0;
// Get header level where TOC should stop from user
let header_limit = await tp.system.prompt("Show Contents Down to Which Header Level (1-6)?", "3");
// Get headers from file
if ( deBug == 1) { console.log("tp.config.active_file \n\n", tp.config.active_file) };
if ( deBug == 1) { console.log("tp.config.active_file.name \n\n", tp.config.active_file.name) };
if ( deBug == 1) { console.log("app.workspace.activeLeaf.view.file \n\n",app.workspace.activeLeaf.view.file) };
if ( deBug == 1) { console.log("this.app.workspace.getActiveFile \n\n",await this.app.workspace.getActiveFile()) };
const activeFile = await this.app.workspace.getActiveFile();
if ( deBug == 1) { console.log("this.app.metadataCache.getFileCache(activeFile)) \n\n",await this.app.metadataCache.getFileCache(activeFile)) };
const mdCache = await this.app.metadataCache.getFileCache(activeFile);
if ( deBug == 1) { console.log("mdCache.headings \n\n",mdCache.headings) };
const mdCacheListItems = mdCache.headings;
// Build TOC from headers
let TOC = "";
mdCache.headings.forEach( item => {
var header_text = item.heading;
var header_level = item.level;
if ( deBug == 1) { console.log("array item header info \n\n",header_text, " (level:", header_level,")") };
// Wikilinks style output:
//let file_title= tp.file.title;
//let header_url = header_text;
//let header_link = `[[${file_title}#${header_url}|${header_text}]]`
// Non-wikilinks style output:
let file_title= tp.file.title.replace(/ /g, '%20'); // Replace spaces in file names with '%20'
let header_url = header_text.replace(/ /g, '%20'); // Replace spaces in urls with '%20'
let header_link = `[${header_text}](${file_title}.md#${header_url})`;
// Only output headers if level below specified limit
if ( header_level <= header_limit) {
TOC += `>${' '.repeat(header_level - 1) + '- ' + header_link + "\n"}`;
};
})
if ( deBug == 1) { console.log("TOC \n\n",TOC) };
%><% TOC %>
--- |
Beta Was this translation helpful? Give feedback.
-
Hiya, another update from me ... not a new version of the TOC script but a second script which automatically refreshes the TOC in a file, or adds one directly below the first header in the file. I have this triggered by a hotkey to quickly update/insert TOC. The script below does the following:
If no existing TOC is found, the script will find the first header in the file (doesn't matter what level of header) and position cursor following this header before generating/inserting new TOC. [Note: there are a number of <%*
// CHECK THAT THESE TOP THREE VARIABLES MATCH YOUR REQUIREMENTS
// TOC template name
// Set this to match your main TOC template name, NOT THE NAME OF THIS TEMPLATE.
const tocTemplate = "table-of-contents-callout-v2";
// String marking start and end of TOC - unique string at the start of your TOC to allow script to find it.
// TOC end marker does not need to be unique, it will find the first occurrence of this string following the start of the TOC and assume that it marks the end of the TOC.
// These values will work if you are using my v2 template. Otherwise update to match your TOC format.
const markerTOCstart = '>[!SUMMARY]+ Table of Contents';
const markerTOCend = '---';
// VARIABLES BELOW DO NOT NEED TO BE AMENDED ==========================
// Variables to hold line numbers for start/end of any existing TOC
// These will get updated by the script, no need to change these manually.
let TOCstart = 0;
let TOCend = 0;
// Variable to hold name of file that is being processed.
const file = tp.file.find_tfile(tp.file.title);
// Split file contents into array, each array item is one line
const fileContentSplit = await tp.file.content.split('\n');
// Find start of any existing TOC
TOCstart = fileContentSplit.indexOf(markerTOCstart);
// Find end of TOC - second value specifies start position of search
TOCend = fileContentSplit.indexOf(markerTOCend, TOCstart);
console.log("TOC line start \n\n", TOCstart);
console.log("TOC line end \n\n", TOCend);
// If TOC found then 'Splice' out TOC section - creates new var containing spliced array elements and removes them from original array
if ( TOCstart > 0 ) {
// Adjusted to remove '---' from before & after TOC but may need tweaked depending on how your TOC template is specifically formatted.
let splicedTOC = fileContentSplit.splice(TOCstart - 2, TOCend - TOCstart + 4);
console.log("splicedTOC \n\n", splicedTOC.join('\n'));
console.log("fileContentSplit \n\n", fileContentSplit.join('\n'));
}
// ------------------------------------------------------------------------------------------------------
// Write new content into file
await app.vault.modify( file, fileContentSplit.join('\n') );
// If existing TOC found, place cursor at it's original position before inserting new TOC
// If no TOC found, place cursor at end of header line before inserting new TOC
if ( TOCstart > 0 ) {
// '-4' accounts for removed lines etc but may need tweaked depending on how your TOC template is specifically formatted
this.app.workspace.getActiveViewOfType(tp.obsidian.MarkdownView).editor.setCursor(TOCstart - 4);
}
else {
// We have no existing TOC so find first header and position cursor after header line
// Extract first header info from file metadata
const headerFirstHeading = await app.metadataCache.getFileCache(await tp.file.find_tfile(await tp.file.title)).headings[0].heading
const headerFirstLevel = await app.metadataCache.getFileCache(await tp.file.find_tfile(await tp.file.title)).headings[0].level
const headerFirst = '#'.repeat(headerFirstLevel) + ' ' + headerFirstHeading;
console.log("first header \n\n", headerFirst);
// Find header line in file content
const headerLine = await tp.file.content.split('\n').indexOf(headerFirst);
console.log("first header Line # \n\n", headerLine);
// Position cursor at end of header line
this.app.workspace.getActiveViewOfType(tp.obsidian.MarkdownView).editor.setCursor(headerLine);
}
%><% '\n\n' + await tp.file.include(`[[${tocTemplate}]]`) %> |
Beta Was this translation helpful? Give feedback.
-
@danteali Great addition! I have just been manually deleting the old TOC and creating a new one whenever I needed to update one. This is a very helpful time saver. |
Beta Was this translation helpful? Give feedback.
-
Hello guys, I have made a new version of the script to generate a Table of Contents for any Obsidian notes. This way, you can create a Table of Contents for any note in Obsidian. Please note If you are having issues and want to troubleshoot, press "Ctrl + Shift + I" to open the Obsidian console and type "deBug = 1" before using the hotkey. What is different from the previous scripts?
*Thanks to @danteali for clarifying this in the comments below. Known Issues
Please refer to the following comment if you want to generate TOCs and use YAML frontmatter (Properties). *Thanks to @danteali for clarifying this in the comments below. DemoFor this demonstration, the template folder will be "Demo/Hotkeys" and the name of the page containing the script will be "Generate TOC". obsidian-templater-generate-toc.mp4InstructionsPrepare the script
Setup the template hotkey
Script<%*
// Constants for TOC markers
const markerTOCstart = '>[!SUMMARY]+ Table of Contents';
const markerTOCend = '%%ENDTOC%%';
// Get the active file and its metadata
const activeFile = await this.app.workspace.getActiveFile();
const mdCache = await this.app.metadataCache.getFileCache(activeFile);
// Get the current file content and split it into lines
const fileContent = await tp.file.content;
const fileContentSplit = fileContent.split('\n');
const deBug = 0;
// Utility function for debugging
const debugLog = (label, data) => {
if (typeof deBug !== 'undefined' && deBug === 1) console.log(`${label} \n\n`, data);
};
// Get the active file and its metadata cache
debugLog("tp.config.active_file", tp.config.active_file); debugLog("tp.config.active_file.name", tp.config.active_file.name); debugLog("app.workspace.activeLeaf.view.file", app.workspace.activeLeaf.view.file);
// Find existing TOC start and end positions
let TOCstart = fileContentSplit.indexOf(markerTOCstart);
let TOCend = fileContentSplit.indexOf(markerTOCend, TOCstart);
// Remove existing TOC if it exists
if (TOCstart !== -1 && TOCend !== -1) {
fileContentSplit.splice(TOCstart, TOCend - TOCstart + 1);
}
// Initialize an empty array to hold new TOC lines
let newTOC = [];
// Get header limit from user
let header_limit = await tp.system.prompt("Show Contents Down to Which Header Level (1-6)?", "3");
const mdCacheListItems = mdCache.headings;
debugLog("this.app.metadataCache.getFileCache(activeFile)", mdCacheListItems);
let parentLevel = 0; // Initialize parent level
let parentIndent = 0; // Initialize parent indent
// If there are headings, generate the TOC
if (mdCacheListItems && mdCacheListItems.length > 0) {
mdCacheListItems.forEach(item => {
var header_text = item.heading;
var header_level = item.level;
if (header_level <= header_limit) {
if (header_level > parentLevel) {
parentIndent += 4;
} else if (header_level < parentLevel) {
parentIndent -= 4;
}
parentLevel = header_level; // Update parent level for the next iteration
let file_title = tp.file.title.replace(/ /g, '%20');
let header_url = header_text.replace(/ /g, '%20');
let header_link = `[${header_text}](${file_title}.md#${header_url})`;
newTOC.push(`>${' '.repeat(parentIndent) + '- ' + header_link}`);
}
});
}
// Determine where to insert the new TOC
let insertPosition = (TOCstart !== -1) ? TOCstart : 0;
// Only add or update the TOC if it has at least one header; otherwise, remove it
if (newTOC.length > 0) {
fileContentSplit.splice(insertPosition, 0, markerTOCstart, ...newTOC, "", markerTOCend);
}
else if (TOCstart !== -1) {
fileContentSplit.splice(TOCstart, TOCend - TOCstart + 1);
}
debugLog("TOC:", newTOC);
// Update the file with the new content
if (insertPosition !== -1) {
await app.vault.modify(activeFile, fileContentSplit.join('\n'));
}
%> |
Beta Was this translation helpful? Give feedback.
-
Eventually got round to updating my script - although updating is probably the wrong word. @cr0Kz's latest version is excellent so I didn't bother to update my own and just used cr0kz's version as the starting point. This isn't a massive update, just some tweaks to make it fit my needs better and thought it might be useful for others too. Changes:
<%*
// =====================================================================================================================================================================
// FURTHER UPDATES TO PREVIOUS DANTEALI VERSIONS
// Templater discussion: https://github.com/SilentVoid13/Templater/discussions/888
// After my V2 update (https://github.com/SilentVoid13/Templater/discussions/888#discussioncomment-5523668) cr0Kz merged the two scripts and heavily refactored the code.
// Cr0Kz latest version (as of 20231005): https://github.com/SilentVoid13/Templater/discussions/888#discussioncomment-7024450
// This version: https://github.com/SilentVoid13/Templater/discussions/888#discussioncomment-7204381
//
// Additional changes in this version:
// - Added additional code commenting - for my own understanding later when I inevitably come back and forget it all 😉.
// Not as clean and concise as cr0Kz any longer - sorry!
// - Added empty line at top of TOC, otherwise there was no separation between file content and TOC callout.
// - Added debugging (enabled/disabled by constant/switch at top of file) which cr0Kz had in his first version but removed in latest.
// Added additional debug messages to help me with development. Debug console accessible with Ctrl+Shift+I.
// - If a TOC already exists it will always be updated in the current location. If no existing TOC, default location is top of file (below
// any frontmatter).
// Added switches to allow default location to be changed to: below the first header, or at cursor position.
// - Added switch (at top of file) to change from default Markdown-style links used in TOC to Wiki-style links.
// - Added switch to disable header level depth prompt and const to define default depth. If you know what level you will always want then easy to disable prompt.
//
// =====================================================================================================================================================================
// FUNCTIONALITY
// =============
//
// DEBUG MESSAGES
// Write useful debugging messages to console (Ctrl+Shiift+i)
const deBug = 0;
//
// TOC LOCATION
// If a TOC already exists it will be updated in the same location.
// If no existing TOC then default location is top of file (below any frontmatter). OR...
// - Insert TOC below first header instead of top of file
const insertBelowHeader = 0;
// - Insert TOC at cursor position instead of top of file or below first header (overrides insertBelowHeader=1)
const insertAtCursor = 1;
//
// TOC LINKS STYLE
// Swap to using Wiki-style syntax for TOC links. By default links in TOC use the Markdown syntax.
const useWikilinks = 0;
//
// DISABLE LEVEL PROMPT
// Disable prompt for user to select level depth to use in TOC.
// Set default level depth too (1-6).
const levelDepthPromptDisable = 1;
const levelDepthDefault = "6";
//
// =====================================================================================================================================================================
// Utility function for debugging
const debugLog = (label, data) => {
if (typeof deBug !== 'undefined' && deBug === 1) console.log(`${label} \n\n`, data);
};
// Otherwise place in default location at top of file (below frontmatter) or below first header.
let curPosition = this.app.workspace.activeLeaf.view.editor.getCursor().line;
debugLog("Cursor position", curPosition);
// Constants for TOC markers
const markerTOCstart = '>[!SUMMARY]+ Table of Contents';
const markerTOCend = '%%ENDTOC%%';
// Get the active file info and its metadata
const activeFile = await this.app.workspace.getActiveFile();
const mdCache = await this.app.metadataCache.getFileCache(activeFile);
// Show file info
debugLog("File Info - activeFile:", activeFile);
//debugLog("File Info - tp.config.active_file", tp.config.active_file); // Matches 'activeFile' above
//debugLog("File Info - app.workspace.activeLeaf.view.file", app.workspace.activeLeaf.view.file); // Matches 'activeFile' above
debugLog("File Info - tp.file.find_tfile", tp.file.find_tfile(tp.file.title)); // Matches 'activeFile' above
debugLog("File Cache - mdCache:", mdCache); // Metadata
debugLog("Filename - tp.config.active_file.name", tp.config.active_file.name); // Filename
// Get the current file content and split it into lines
const fileContent = await tp.file.content;
const fileContentSplit = fileContent.split('\n');
// Check if the file starts with a YAML frontmatter block
// hasYAML is a bool matching evaluation of the two conditionals i.e. will be true if
// (the first line in file = '---') AND (if the next occurrence of '---' is on line > 0, start looking from line 1)
let hasYAML = fileContentSplit[0] === '---' && fileContentSplit.indexOf('---', 1) > 0;
// yamlEndLine equals the left hand side of ':' expression if hasYAML is true, and right hand side if hasYAML is false.
// if hasYAML is true then yamlEndLine = first occurrence of '---' in array holding file lines start looking at index 1
// if hasYAML is false then yamlEndLine = -1
let yamlEndLine = hasYAML ? fileContentSplit.indexOf('---', 1) : -1;
debugLog("First line in file", fileContentSplit[0]); // First line in file
debugLog("First line = '---' in file line array", fileContentSplit.indexOf('---')); // Find first occurrence of '---' in array holding file lines
debugLog("First line = '---' in file line array, start line 2", fileContentSplit.indexOf('---', 1)); // Find first occurrence of '---' in array holding file lines start looking at index 1
debugLog("hasYAML", hasYAML);
debugLog("yamlEndLine", yamlEndLine);
// Find existing TOC start and end positions
let TOCstart = fileContentSplit.indexOf(markerTOCstart);
let TOCend = fileContentSplit.indexOf(markerTOCend, TOCstart); // Start looking for occurrence after the array element holding start marker.
debugLog("TOCstart", TOCstart);
debugLog("TOCend", TOCend);
// Remove existing TOC if it exists
// Removes the TOC section from array of file lines
// array.splice(X,Y) removes Y elements starting at position X.
if (TOCstart !== -1 && TOCend !== -1) {
//fileContentSplit.splice(TOCstart, TOCend - TOCstart + 1);
// Updated to also remove empty line at start/end of TOC which we added
fileContentSplit.splice(TOCstart - 1, TOCend - TOCstart + 2);
}
// Initialize an empty array to hold new TOC lines
let newTOC = [];
// Get header limit from user
let header_limit = levelDepthDefault;
if (levelDepthPromptDisable == 0) header_limit = await tp.system.prompt("Show Contents Down to Which Header Level (1-6)?", levelDepthDefault);
const mdCacheListItems = mdCache.headings;
debugLog("Headers", mdCacheListItems);
// Parse headings and create new TOC
if (mdCacheListItems && mdCacheListItems.length > 0) {
// Generate new TOC
mdCacheListItems.forEach(item => {
var header_text = item.heading;
var header_level = item.level;
if (header_level <= header_limit) {
if (useWikilinks == 1) {
// Wiki-style
let file_title = tp.file.title;
let header_url = header_text;
let header_link = `[[${file_title}#${header_url}|${header_text}]]`
newTOC.push(`>${' '.repeat(header_level - 1) + '- ' + header_link}`);
} else {
// Markdown-style
let file_title = tp.file.title.replace(/ /g, '%20'); // Replace spaces with '%20'
let header_url = header_text.replace(/:/g, '').replace(/ /g, '%20'); // Remove ':', Replace spaces in urls with '%20'
let header_link = `[${header_text}](${file_title}.md#${header_url})`;
newTOC.push(`>${' '.repeat(header_level - 1) + '- ' + header_link}`);
}
}
});
}
// Determine where to insert the new TOC
// Order of definitions below ensures that TOC is placed in priority order of:
// - Directly below frontmatter, or top of file if no frontmatter
// - Directly below first header
// - At cursor position if no existing TOC (if feature enabled at top of file)
// - ALWAYS update existing TOC location if it exists
// Insert below frontmatter or top of file.
let insertPosition = hasYAML ? yamlEndLine + 2 : 0; // Use +1 if not adding additional empty line to TOC before start marker
debugLog("insertPosition - frontmatter", insertPosition);
// Insert below first header
if (insertBelowHeader == 1) {
let firstHeaderFind = '#'.repeat(mdCacheListItems[0].level) + ' ' + mdCacheListItems[0].heading;
debugLog("First Header String", firstHeaderFind);
insertPosition = fileContentSplit.indexOf(firstHeaderFind) + 2; // Use +1 if not adding additional empty line to TOC before start marker
debugLog("insertPosition - header", insertPosition);
}
// Insert at cursor position if feature enabled
if (insertAtCursor == 1) insertPosition = curPosition;
debugLog("insertPosition - cursor", insertPosition);
// Insert at existing TOC location
if (TOCstart !== -1) insertPosition = TOCstart;
debugLog("insertPosition - existing", insertPosition);
// Add or remove TOC based on newTOC's content
// Note the '...newTOC' used to insert TOC. This is called the 'spread operator' and expands
// the item to individual values.
if (newTOC.length > 0) {
// Insert the new TOC into the file content
//fileContentSplit.splice(insertPosition, 0, markerTOCstart, ...newTOC, "", markerTOCend);
// Updated with an empty line before start marker, we need to adjust insert location to compensate
fileContentSplit.splice(insertPosition-1, 0, "", markerTOCstart, ...newTOC, "", markerTOCend);
} else if (TOCstart !== -1 && TOCend !== -1) {
// Remove the markers when there are no headers
fileContentSplit.splice(TOCstart, TOCend - TOCstart + 1);
}
debugLog("TOC:", newTOC);
// Update the file with the new content
await app.vault.modify(activeFile, fileContentSplit.join('\n'));
%> |
Beta Was this translation helpful? Give feedback.
-
I guess it's my turn to make some changes to this script lol So first off, thank you all so very much for making this and contributing to it, honestly for me at least it's the best way to make tables of contents in Obsidian. There were a couple things that I wanted to do that the plugin didn't support, namely using a header style as opposed to a callout, and excluding H1's from the table of contents. I added settings for these new abilities and fixed a couple issues regarding wikilinks in the headings. I made myself a repo to track these changes in, and also just put the script in a js file so I could more easily use syntax highlighting and formatting in my editor. You can find the full list of changes, as well as some features that I might add someday if I'm motivated enough, in the readme. Anyways, hope you all like the additions, and perhaps somebody can get some use out of this like I have. |
Beta Was this translation helpful? Give feedback.
-
Excellent work. I've 'starred' your repo so that I can see any future changes (might do a pull request if I make any updates too). Interesting that you've added the minimum header variable, I also use H1 for the note title and for some reason just realised that the TOC template I've been using already places the TOC below the first H1 header - can't remember explicitly doing that lol. But I like your explicit variable definition better. If you manage to find a way to incorporate headers which contain markdown links that would be amazing. I've never managed to work that one out. Not sure it's possible. (Prefer markdown to wikilinks for cross compatibility reasons but see the advantages of both). |
Beta Was this translation helpful? Give feedback.
-
I LOVE this template and have a request: I often have a colon in my headers and this breaks the links in the summary: Is there a workaround for this special character ? Thanks in advance |
Beta Was this translation helpful? Give feedback.
-
Creates a table of contents with links to each header in your current note.
Input:
Callout Version
Script:
Result:
Header Version
For those wanting the table of contents not in a callout, here is an alternative script:
Script:
Result:
Edit (08/11/22): template now properly handles headers with characters like
[
and]
Beta Was this translation helpful? Give feedback.
All reactions