View Master Plugin

Name

View Master

Description

Populate a note with information about other notes in a formatted manner.

Icon

preview

Instructions




This plugin will create a view based on data contained in other notes, pulling in images and data from pages tagged with a certain tag grouped by a list of other tags.. This can be used to create a media gallery page, a list of projects, etc.

BREAKING CHANGE:
Starting in v1.1.0 the list separator for Group By Tags and Include Fields was changed from a comma to an ampersand to allow tag grouping to include multiple tags and allow exclusions. Please adjust your page options accordingly if you have pages generated using <1.1.0.

This is done by creating a note with at least the following two rows which define the tag to query on as well as a list of ampersand-separated tags define the groups. For example, to display a wallboard based on TV Shows and group them by watching and backlog tags, create a note with a table containing the following two fields:
- Query By : tv-series
- Group By Tags : watching&backlog

Once the table exists on the note, run the Wallboard plugin from the note menu and it will collect data from the referenced notes and create a section for each Group By Tag under which a view will be created containing information from the note.

The image used in the table will be the first image in the referenced note, resized to a certain width (set in order: the width specified on the page, the global app setting, or 125).

Fields
The data fields pulled into the table are defined on the referenced notes using the inline field syntax from the Dataview plugin for Obsidian. e.g. Field::Value. Right now the plugin pulls in all fields with this syntax, but this will at some point be configurable to only include fields specified using a setting.

Computed Fields
There is support for computed fields. These fields are denoted with a + as the first character and there is currently one option: +Progress.

- +Progress : This will display a progress bar showing the progress on tasks in the referenced note, in 10% increments. The characters will default to ★ and ☆ but there are plugin settings to set them to something else.

Page Options
There are optional page settings that can be defined in the table as well.

Include Fields is a ampersand-separated list of fields that will be included. If it is not there than all fields on the referenced note will be included. For example, this will include only Genre and Service in the view:
- Include Fields : Genre&Service

Sort By will define a field to use to order the notes. This field must be an integer value and sorting is done in ascending order (so 1 is first).
- Sort By : Priority

Sort Order will define the order to sort by. The options are either asc or desc and it defaults to asc

Display As defines the view to use for the output, currently either gallery for a card view or table for a more traditional table. For gallery, the number of columns created will be, in order, the amount specified on the page, the global app setting, or 3.
- Display As : table

Show Image can turn off image output; valid values are true or false and it defaults to true.
- Show Image : false

Page specific settings can also be included here which will override the global setting
- Image Width : 200
- Table Columns : 4

An example page options table with some options set. This will query for pages with the foo tag grouping by two different sets, bar and not baz and other. These are the only two required options. For Include Fields only field1 and field2 will be shown with the notes and for Sort By the notes will be in order based on the value of field1. This also overrides the number of columns to be 4 and set the image width to 200.

Setting

Default image width

Setting

Default table columns

Setting

Progress completion character

Setting

Progress remaining character


Github Repo

linkVersion History

1.0.0

Initial public release

1.0.1

Include webp in image extensions and made field value regex way less restrictive

1.1.0

Added Sort By page option to sort notes in a group by a field. NOTE: For now this field must be included in the Include Fields list.

Group By Tags can now have multiple tags defined for a single group, using the normal comma separated list that Amplenote supports. Tag groups are now separated by an ampersand (&) character. This also mean tag exclusions are now supported, so a group with tag foo and not bar would be foo,^bar

Include Fields is now also separated by an ampersand (&) character to maintain consistency with Group By Tags

1.1.1

Fix an issue with loading page options when running the plugin multiple times

2.0.0

Added Display As as an optional page option with two valid values: gallery and table. This control how the notes are output on the page; gallery is the format that has been output previously while table will output in a more traditional table format (but still includes the image if it exists).

gallery is the default value to maintain consistency with previous generated data

2.1.0

Sort By field no longer needs to be the Include Fields list

Added support for computed fields, of which Progress is the first one (using +Progress in the Include Fields list. This will display a progress bar for tasks on the referenced note. Characters to use for completed and remaining percentage can be set in the settings but will default to a closed and open star, respectively.

Added Show Image page option to be able to turn off images in view

Fixed an issue when Include Fields is not set.

Fixed an issue with Display As not defaulting to gallery

2.2.0

Sort By now supports alphabetical sorting as well by the {today} date format.

It will attempt to sort by number, then date, then finally alphabetically.

Add Sort Order page option that can be either asc or desc that determines which direction to sort

2.2.1

Fix issue when Query Tag has multiple tags or negations

2.2.2

Fixed variable name in log message that stopped processing

{
Note: class {
constructor(uuid, title, content, inlineFields, image, totalTasks = 0, completedTasks = 0) {
this.uuid = uuid;
this.title = title;
this.content = content;
this.inlineFields = inlineFields;
this.image = image;
this.totalTasks = totalTasks;
this.completedTasks = completedTasks;
}
 
copy(uuid = this.uuid, title = this.title, content = this.content, inlineFields = this.inlineFields) {
return new Note(uuid, title, content, inlineFields);
}
},
 
InlineField: class {
constructor(name, value) {
this.name = name;
this.value = value;
}
 
copy(name = this.name, value = this.value) {
return new InlineField(name, value);
}
},
 
Image: class {
constructor(image, url) {
this.image = image;
this.url = url;
}
 
copy(image = this.image, url = this.url) {
return new Image(image, url);
}
},
 
Settings: class {
constructor(
queryTag,
groupTags,
imageWidth,
tableColumns,
includeFields,
sortBy,
displayAs,
progressCompletion,
progressRemaining,
showImage,
sortOrder,
) {
this.queryTag = queryTag;
this.groupTags = groupTags;
this.imageWidth = imageWidth;
this.tableColumns = tableColumns;
this.includeFields = includeFields;
this.sortBy = sortBy;
this.displayAs = displayAs;
this.progressCompletion = progressCompletion;
this.progressRemaining = progressRemaining;
this.showImage = showImage;
this.sortOrder = sortOrder;
}
 
copy(
queryTag = this.queryTag,
groupTags = this.groupTags,
imageWidth = this.imageWidth,
tableColumns = this.tableColumns,
includeFields = this.includeFields,
sortBy = this.sortBy,
displayAs = this.displayAs,
progressCompletion = this.progressCompletion,
progressRemaining = this.progressRemaining,
showImage = this.showImage,
sortOrder = this.sortOrder,
) {
return new Settings(
queryTag,
groupTags,
imageWidth,
tableColumns,
includeFields,
sortBy,
displayAs,
progressCompletion,
progressRemaining,
showImage,
sortOrder,
);
}
},
 
// --------------------------------------------------------------------------------------
constants: {
version: "2.2.2",
settingImageWidthName: "Default image width",
settingTableColumnsName: "Default table columns",
settingProgressCompletionEmojiName: "Progress completion character",
settingProgressRemainingEmojiName: "Progress remaining character",
queryTagRegexp: /^\|Query Tag\|([a-z\-\/,^]+)\|\s*$/gmi,
groupByRegexp: /^\|Group By Tags\|([a-z/,\&\s\^\-]+)\|\s*$/gmi,
imageWidthRegexp: /^\|Image Width\|([a-z0-9\-\/,\s]+)\|\s*$/gmi,
tableColumnsRegexp: /^\|Table Columns\|([a-z0-9\-\/,\s]+)\|\s*$/gmi,
includeFieldsRegexp: /^\|Include Fields\|([a-z0-9\-\/,\s\&+]+)\|\s*$/gmi,
sortByRegexp: /^\|Sort By\|([a-z0-9\-\/,\s]+)\|\s*$/gmi,
displayAsRegexp: /^\|Display As\|(gallery|table|list)\|\s*$/gmi,
showImageRegexp: /^\|Show Image\|(true|false)\|\s*$/gmi,
sortOrderRegexp: /^\|Sort Order\|(asc|desc)\|\s*$/gmi,
inlineFieldRegexp: /[ -]?([a-zA-Z]*) ?:: ?(.*)$/gm,
imageRegexp: /(http[a-zA-Z0-9\.:/-]*(jpg|png|webp))/gm,
defaultImageWidth: 125,
defaultTableColumns: 3,
monthNameToNumber: {
"January": "01",
"February": "02",
"March": "03",
"April": "04",
"May": "05",
"June": "06",
"July": "07",
"August": "08",
"September": "09",
"October": "10",
"November": "11",
"December": "12"
},
},
 
// --------------------------------------------------------------------------
// https://www.amplenote.com/help/developing_amplenote_plugins#noteOption
noteOption: async function(app) {
const note = await app.notes.find(app.context.noteUUID);
const content = await note.content();
console.log(`content: ${content}`);
 
this._resetRegExps();
const settings = this._getSettings(content, app.settings);
console.log(`settings: ${JSON.stringify(settings)}`);
if (!settings.queryTag || !settings.groupTags) {
app.alert("Query Tag and Group By Tags settings must exist");
return;
};
 
const groupContents = this._getNotes(app, settings, settings.queryTag, settings.groupTags.split('&'))
.then(notes => {
const updatedContent = `${this._getFullGroupsContent(settings, notes)}\n\n---\n${this._createSettingsTable(settings, this._getImageWidthSetting(app.settings), this._getTableColumnsSetting(app.settings))}\n`;
app.replaceNoteContent({ uuid: app.context.noteUUID }, updatedContent);
});
},
 
// --------------------------------------------------------------------------
// Impure functions
_resetRegExps() {
this.constants.queryTagRegexp.lastIndex = 0;
this.constants.groupByRegexp.lastIndex = 0;
this.constants.imageWidthRegexp.lastIndex = 0;
this.constants.tableColumnsRegexp.lastIndex = 0;
this.constants.includeFieldsRegexp.lastIndex = 0;
this.constants.sortByRegexp.lastIndex = 0;
this.constants.inlineFieldRegexp.lastIndex = 0;
this.constants.imageRegexp.lastIndex = 0;
this.constants.displayAsRegexp.lastIndex = 0;
this.constants.showImageRegexp.lastIndex = 0;
this.constants.sortOrderRegexp.lastIndex = 0;
},
 
async _getNotes(app, settings, queryTag, groupTags) {
return await Promise.all(groupTags
.map(async tag => {
return await this._getNotesForGroup(app, settings, queryTag, tag);
})
).then( notes => {
return Object.assign({}, ...notes);
});
},
 
// Returns an array of Notes in a dict keyed by `groupTag`
async _getNotesForGroup(app, settings, queryTag, groupTag) {
const notes = await Promise.all(
await app
.filterNotes({ tag: `${queryTag},${groupTag}` })
.then(handles => {
return handles.map(async noteHandle => {
const note = await app.notes.find(noteHandle.uuid);
const noteContent = await note.content();
const inlineFields = this._getInlineFields(settings, noteContent);
const image = this._getImage(noteContent);
let tasks = []
if (settings.includeFields && settings.includeFields.find(key => key.toLowerCase() === "+progress") != undefined) {
// Only pull in tasks if something requires the data
tasks = await app.getNoteTasks({ uuid: noteHandle.uuid }, { includeDone: true });
}
return new this.Note(noteHandle.uuid, noteHandle.name, noteContent, inlineFields, image, tasks.length, this._completedTaskCount(tasks));
});
})
);
return { [groupTag]: this._sortNotes(settings, notes) };
},
 
// --------------------------------------------------------------------------
// Pure functions
_getImageWidthSetting(appSettings) {
return appSettings[this.constants.settingImageWidthName] || this.constants.defaultImageWidth;
},
 
_getTableColumnsSetting(appSettings) {
return appSettings[this.constants.settingTableColumnsName] || this.constants.defaultTableColumns;
},
 
_getProgressCompletionCharacterSetting(appSettings) {
return appSettings[this.constants.settingProgressCompletionEmojiName] || "★";
},
 
_getProgressRemainingCharacterSetting(appSettings) {
return appSettings[this.constants.settingProgressRemainingEmojiName] || "☆";
},
 
_getSettings(content, appSettings) {
const queryTagMatches = this.constants.queryTagRegexp.exec(content);
const groupByMatches = this.constants.groupByRegexp.exec(content);
const imageWidthMatches = this.constants.imageWidthRegexp.exec(content);
const tableColumnsMatches = this.constants.tableColumnsRegexp.exec(content);
const includeFieldsMatches = this.constants.includeFieldsRegexp.exec(content);
const sortByMatches = this.constants.sortByRegexp.exec(content);
const displayAsMatches = this.constants.displayAsRegexp.exec(content);
const showImageMatches = this.constants.showImageRegexp.exec(content);
const sortOrderMatches = this.constants.sortOrderRegexp.exec(content);
 
return new this.Settings(
(queryTagMatches) ? queryTagMatches[1] : undefined,
(groupByMatches) ? groupByMatches[1] : undefined,
(imageWidthMatches) ? imageWidthMatches[1] : this._getImageWidthSetting(appSettings),
(tableColumnsMatches) ? tableColumnsMatches[1] : this._getTableColumnsSetting(appSettings),
(includeFieldsMatches) ? includeFieldsMatches[1].split("&") : undefined,
(sortByMatches) ? sortByMatches[1] : undefined,
(displayAsMatches) ? displayAsMatches[1] : "gallery",
this._getProgressCompletionCharacterSetting(appSettings),
this._getProgressRemainingCharacterSetting(appSettings),
(showImageMatches) ? showImageMatches[1] === "true" : true, // Default to true if option doesn't exist
(sortOrderMatches) ? sortOrderMatches[1] : "asc", // Default order to ascending
);
},
 
_createSettingsTable(settings, defaultImageWidth, defaultTableColumns) {
const imageCell = ((settings.imageWidth == defaultImageWidth) ? "" : `|Image Width|${settings.imageWidth}|\n`);
const columnsCell = ((settings.tableColumns == defaultTableColumns) ? "" : `|Table Columns|${settings.tableColumns}|\n`);
const includeFieldsCell = ((!settings.includeFields) ? "" : `|Include Fields|${settings.includeFields.join("&")}|\n`);
const sortByCell = ((!settings.sortBy) ? "" : `|Sort By|${settings.sortBy}|\n`);
const displayAsCell = ((!settings.displayAs) ? "" : `|Display As|${settings.displayAs}|\n`);
const showImageCell = ((settings.showImage) ? "" : `|Show Image|false|\n`);
const sortOrderCell = ((!settings.sortOrder) ? "" : `|Sort Order|${settings.sortOrder}|\n`);
return `| | |\n|-|-|\n|Query Tag|${settings.queryTag}|\n|Group By Tags|${settings.groupTags}|\n${imageCell}${columnsCell}${includeFieldsCell}${sortByCell}${displayAsCell}${showImageCell}${sortOrderCell}`
},
 
_getInlineFields(settings, noteContent) {
var fields = new Map();
var match;
while(match = this.constants.inlineFieldRegexp.exec(noteContent)) {
fields.set(match[1], new this.InlineField(match[1], match[2]));
}
 
return fields;
},
 
// Takes a date string similar to `August 1st, 2023` into an epoch milliseconds value
// Returns NaN if `today` is not a parseable value
_parseJotDate(jotDate) {
const jotDateSplit = jotDate.split(" ");
if (jotDateSplit.length !== 3) {
console.log(`_parseToday cannot parse given string: ${jotDate}`);
return NaN;
}
const monthNumber = this.constants.monthNameToNumber[jotDateSplit[0]];
const dayOfMonth = jotDateSplit[1].replace(/(st,|rd,|th,|nd,)/, "");
const jotDateString = `${jotDateSplit[2]}-${monthNumber}-${dayOfMonth.padStart(2, "0")}`
return Date.parse(jotDateString);
},
 
// Returns an Array of unique fields in the given notes
_getUniqueFields(notes) {
const fieldSet = new Set();
notes.forEach(note => {
Array.from(note.inlineFields.keys()).forEach(fieldName => {
fieldSet.add(fieldName);
});
});
return Array.from(fieldSet);
},
 
_sortNotes(settings, notes) {
if (settings.sortBy) {
return notes.toSorted((current, next) => {
let sortValue = 0;
const currentSortValue = current.inlineFields.get(settings.sortBy)?.value ?? "99";
const nextSortValue = next.inlineFields.get(settings.sortBy)?.value ?? "99";
const currentSortBy = parseInt(currentSortValue);
const nextSortBy = parseInt(nextSortValue);
if (Number.isNaN(currentSortBy) || Number.isNaN(nextSortBy)) {
// If sort values are not numbers, try to parse the {today} format
const currentDateValue = this._parseJotDate(currentSortValue);
const nextDateValue = this._parseJotDate(nextSortValue);
 
if (Number.isNaN(currentDateValue) || Number.isNaN(nextDateValue)) {
// If sort values are not parseable date values, default to string sorting
sortValue = currentSortValue > nextSortValue ? 1 : -1;
} else {
sortValue = currentDateValue - nextDateValue;
}
} else {
sortValue = currentSortBy - nextSortBy;
}
// Default sortValue will be for ascending values, reverse sign if we want descending
return settings.sortOrder === "asc" ? sortValue : (sortValue * -1);
});
} else {
return notes;
}
},
 
_getImage(noteContents) {
this.constants.imageRegexp.lastIndex = 0; // Reset the lastIndex as this is a new exec
const match = this.constants.imageRegexp.exec(noteContents);
if (match) {
const urlSplit = match[1].split("/");
const image = new this.Image(urlSplit[urlSplit.length - 1], match[1]);
return image;
}
},
 
_completedTaskCount(tasks) {
return tasks.reduce((current, next) => {
return current + (next.hasOwnProperty('completedAt') ? 1 : 0)
}, 0);
},
 
// Computed Fields
// Returns the computed value of field
_computeField(settings, note, fieldName) {
switch(fieldName.toLowerCase()) {
case '+progress':
return this._computeProgress(settings, note);
default:
console.error(`${fieldName} is not a valid computed field`);
return "";
}
},
 
// Computes an emoji progress bar in 10 steps based on the competion percentage of tasks in the referenced note.
// If no tasks are in the note or there are no completed tasks, an empty progress bar is returned.
_computeProgress(settings, note) {
if (note.totalTasks == 0 || note.completedTasks == 0) {
return settings.progressRemaining.repeat(10);
} else {
const completedPercent = Math.floor(note.completedTasks / note.totalTasks * 10);
return settings.progressCompletion.repeat(completedPercent) + settings.progressRemaining.repeat(10 - completedPercent);
}
},
 
// `groupTags` is an object keyed by groupTags with values being a list of Notes
// Returns a string being the full note content
_getFullGroupsContent(settings, groupTags) {
return Object.keys(groupTags)
.map(groupTag => {
switch(settings.displayAs) {
case 'gallery':
console.log("output gallery");
return this._getGroupContentForGallery(settings, groupTag, groupTags[groupTag]);
case 'table':
console.log("output table");
return this._getGroupContentForTable(settings, groupTag, groupTags[groupTag]);
default:
console.error(`${settings.displayAs} is not a valid Display As value`);
}
})
.join("\n");
},
 
// Gallery output functions
_getCellContentForGallery(settings, fields, note) {
const image = settings.showImage ? (note.image ? `![${note.image.image}\\|${settings.imageWidth}](${note.image.url})
`
: "") : "";
return image +
`[${note.title}](https://www.amplenote.com/notes/${note.uuid})

`
+
fields
.map(field => {
if (field.startsWith("+")) {
return `**${field}**: ${this._computeField(settings, note, field)}`;
} else {
return `**${field}**: ${note.inlineFields.get(field).value}`;
}
})
.join("
"
);
},
 
_getGroupContentForGallery(settings, groupTag, notes) {
const reducer = (groupContent, cell, index) => {
return groupContent +
"|" + // Preface | to start a new table cell
cell + // Cell contents
((index + 1) % settings.tableColumns === 0 ? "|\n" : ""); // If we have filled the columns start a new row
};
 
// Table header as initial value
const initialValue = "| ".repeat(settings.tableColumns) + "|\n" + "|-".repeat(settings.tableColumns) + "|\n";
 
const notesString = notes
.map(note => {
const fields = settings.includeFields || Array.from(note.inlineFields.keys());
return this._getCellContentForGallery(settings, fields, note);
})
.reduce(reducer, initialValue);
 
return `# ${groupTag}\n` + notesString;
},
 
// Table output functions
_rowImage(settings, note) {
return `${(note.image ? `![${note.image.image}\\|${settings.imageWidth}](${note.image.url})` : "")}`;
},
 
_rowTitle(note) {
return `[${note.title}](https://www.amplenote.com/notes/${note.uuid})`;
},
 
_rowFields(settings, fields, note) {
return `${fields.map(field => {
if (field.startsWith("+")) {
return this._computeField(settings, note, field);
} else {
return note.inlineFields.get(field)?.value ?? ""
}
}).join("|")}`;
},
 
_getRowContentForTable(settings, fields, note) {
return `${settings.showImage ? `|${this._rowImage(settings, note)}` : ""}|${this._rowTitle(note)}|${this._rowFields(settings, fields, note)}|`;
},
 
_getGroupContentForTable(settings, groupTag, notes) {
// If no includeFields is set get a list of all unique fields from the notes
const fields = settings.includeFields || this._getUniqueFields(notes);
 
const reducer = (groupContent, row) => {
return groupContent + row + "\n"
};
 
// Initial value is the table header row with columns for Image, Note and all then included fields in the order they are defined
const initialValue = `${settings.showImage ? "|" : ""}|**Note**|${fields.join("|")}|\n|-|-${"|-".repeat(fields.length)}|\n`;
 
const notesString = notes
.map(note => {
return this._getRowContentForTable(settings, fields, note);
})
.reduce(reducer, initialValue);
 
return `# ${groupTag}\n` + notesString;
}
}