Compare commits

...

13 commits

Author SHA1 Message Date
caffae
3c83ab046e Prepare for Git Release 2025-06-12 23:53:10 +08:00
caffae
73834af345 1.7.15 2025-06-12 23:53:10 +08:00
caffae
1e111d57fb fixed leaf opening bug 2025-06-12 23:53:03 +08:00
caffae
7315f13a7d Prepare for Git Release 2025-05-17 19:29:17 +08:00
caffae
6e87618c43 1.7.14 2025-05-17 19:29:17 +08:00
caffae
67133774b7 Prepare for Git Release 2025-05-17 19:28:51 +08:00
caffae
d65d4e103e 1.7.13 2025-05-17 19:20:26 +08:00
caffae
11a4ae6dc7 Prepare for Git Release 2025-05-17 19:18:12 +08:00
caffae
3c58721fc4 fixing package json 2025-03-12 12:57:19 +08:00
caffae
cc3ec3236e Prepare for Git Release 2025-03-12 12:54:02 +08:00
caffae
9147568130 1.7.12 2025-03-12 12:54:02 +08:00
caffae
ba1ef2f69a optimizing the code 2025-03-12 12:53:52 +08:00
caffae
ecb0aaa97d Prepare for Git Release 2025-03-11 15:32:24 +08:00
7 changed files with 295 additions and 151 deletions

13
build.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/bash
# Build script for Current Folder Notes plugin
echo "Building Current Folder Notes plugin..."
# Run TypeScript compiler
npx tsc -noEmit false
# Use esbuild to bundle the project
node esbuild.config.mjs
echo "Build completed successfully!"

View file

@ -1,7 +1,7 @@
{ {
"id": "current-folder-notes-pamphlet", "id": "current-folder-notes-pamphlet",
"name": "Current Folder Notes", "name": "Current Folder Notes",
"version": "1.7.10", "version": "1.7.15",
"minAppVersion": "1.7.7", "minAppVersion": "1.7.7",
"description": "Shows a list of notes in the current folder, and allows you to filter the titles to include or exclude notes.", "description": "Shows a list of notes in the current folder, and allows you to filter the titles to include or exclude notes.",
"author": "Pamela Wang", "author": "Pamela Wang",

409
main.ts
View file

@ -10,11 +10,11 @@ interface CurrentFolderNotesDisplaySettings {
includeSubfolderNotes: boolean; includeSubfolderNotes: boolean;
includeCurrentFileOutline: boolean; includeCurrentFileOutline: boolean;
includeListFileOutlines: boolean; includeListFileOutlines: boolean;
displayMode: 'compact' | 'expanded';
styleMode: 'minimal' | 'fancy' | 'neobrutalist'; styleMode: 'minimal' | 'fancy' | 'neobrutalist';
showNavigation: boolean; showNavigation: boolean;
biggerText: boolean; // Add this line biggerText: boolean; // Add this line
biggerTextMobileOnly: boolean; // Add this new setting biggerTextMobileOnly: boolean; // Add this new setting
maxFilesDisplay: number; // Add new setting for max files to display
} }
const DEFAULT_SETTINGS: Partial<CurrentFolderNotesDisplaySettings> = { const DEFAULT_SETTINGS: Partial<CurrentFolderNotesDisplaySettings> = {
@ -24,11 +24,11 @@ const DEFAULT_SETTINGS: Partial<CurrentFolderNotesDisplaySettings> = {
includeSubfolderNotes: false, includeSubfolderNotes: false,
includeCurrentFileOutline: true, includeCurrentFileOutline: true,
includeListFileOutlines: false, includeListFileOutlines: false,
displayMode: 'expanded',
styleMode: 'fancy', styleMode: 'fancy',
showNavigation: false, showNavigation: false,
biggerText: false, biggerText: false,
biggerTextMobileOnly: false // Add default value biggerTextMobileOnly: false,
maxFilesDisplay: 100 // Default to showing 100 files
} }
export default class CurrentFolderNotesDisplay extends Plugin { export default class CurrentFolderNotesDisplay extends Plugin {
@ -67,25 +67,20 @@ export default class CurrentFolderNotesDisplay extends Plugin {
} }
}); });
// when file is changes (opened) in the editor, update the view // Add debouncing for refreshView
this.registerEvent(this.app.workspace.on('file-open', async (file) => { let refreshTimeout: NodeJS.Timeout | null = null;
this.refreshView(); const debouncedRefresh = () => {
})); if (refreshTimeout) clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => {
this.refreshView();
}, 300); // Wait 300ms before refreshing
};
// when a file is deleted, update the view // Use debounced refresh for all file events
this.registerEvent(this.app.vault.on('delete', async (file) => { this.registerEvent(this.app.workspace.on('file-open', debouncedRefresh));
this.refreshView(); this.registerEvent(this.app.vault.on('delete', debouncedRefresh));
})); this.registerEvent(this.app.vault.on('create', debouncedRefresh));
this.registerEvent(this.app.vault.on('rename', debouncedRefresh));
// when a file is created, update the view
this.registerEvent(this.app.vault.on('create', async (file) => {
this.refreshView();
}));
// when a file is renamed, update the view
this.registerEvent(this.app.vault.on('rename', async (file) => {
this.refreshView();
}));
} }
async onunload() { async onunload() {
@ -152,8 +147,18 @@ export default class CurrentFolderNotesDisplay extends Plugin {
if (leaves.length === 1) { if (leaves.length === 1) {
// console.log("[CFN] Updating existing view"); // console.log("[CFN] Updating existing view");
const view = leaves[0].view as CurrentFolderNotesDisplayView; const leaf = leaves[0];
await view.displayNotesInCurrentFolder(); const view = leaf.view;
// Check if view is an instance of CurrentFolderNotesDisplayView
if (view && view instanceof CurrentFolderNotesDisplayView) {
await view.displayNotesInCurrentFolder();
} else {
// If the view doesn't have the expected method, recreate it
console.log("[CFN] View is not a CurrentFolderNotesDisplayView, recreating view");
leaf.detach();
await this.activateView();
}
} else if (leaves.length === 0) { } else if (leaves.length === 0) {
// console.log("[CFN] No view found, creating new one"); // console.log("[CFN] No view found, creating new one");
this.activateView(); this.activateView();
@ -233,50 +238,81 @@ export class CurrentFolderNotesDisplayView extends ItemView {
// Function to create clickable headings // Function to create clickable headings
createClickableHeadings(container: HTMLElement, currentFileContent: string, currentFilePath: string, addExtraHeadingCSS: boolean): void { createClickableHeadings(container: HTMLElement, currentFileContent: string, currentFilePath: string, addExtraHeadingCSS: boolean): void {
const headings: RegExpMatchArray | null = currentFileContent.match(/^(#+)\s+(.*)$/gm); // Set a maximum limit on the file content to process for safety
if (headings) { const MAX_CONTENT_SIZE = 500000; // 500KB max processing size
headings.forEach((heading: string) => {
const headingLevelMatch: RegExpMatchArray | null = heading.match(/^(#+)/);
if (headingLevelMatch) {
const headingLevel: number = headingLevelMatch[0].length;
let headingText: string = heading.replace(/^(#+)\s+/, '');
// Use extractAlias to get the alias from the heading text if (currentFileContent.length > MAX_CONTENT_SIZE) {
headingText = this.extractAlias(headingText); currentFileContent = currentFileContent.substring(0, MAX_CONTENT_SIZE);
// Add a right arrow symbol to the heading text // Add a note that content was truncated
let headingLabel = '→ ' + headingText; container.createEl('p', {
cls: 'content-truncated-note',
text: 'Note is very large. Only showing headings from the first portion.'
});
}
const p: HTMLElement = container.createEl('p', { text: headingLabel }); // Use a more efficient regex approach for large files
p.classList.add('basic-heading'); const headingRegex = /^(#+)\s+(.*)$/gm;
p.classList.add(`heading-level-${headingLevel}`); let match;
let headingCount = 0;
const MAX_HEADINGS = 100; // Limit the number of headings displayed
if (addExtraHeadingCSS) { // Process headings in batches for very large files
p.classList.add('extra-heading-style'); try {
} while ((match = headingRegex.exec(currentFileContent)) !== null && headingCount < MAX_HEADINGS) {
const headingLevel = match[1].length;
let headingText = match[2];
p.addEventListener('click', async (event) => { // Use extractAlias to get the alias from the heading text
// Prevent default behavior headingText = this.extractAlias(headingText);
event.preventDefault(); // Add a right arrow symbol to the heading text
let headingLabel = '→ ' + headingText;
// Use the openLinkText method to navigate to the heading const p: HTMLElement = container.createEl('p', { text: headingLabel });
this.app.workspace.openLinkText('#' + headingText, currentFilePath); p.classList.add('basic-heading');
p.classList.add(`heading-level-${headingLevel}`);
// Clear any text selection if (addExtraHeadingCSS) {
const selection = window.getSelection(); p.classList.add('extra-heading-style');
if (selection) {
selection.removeAllRanges();
}
});
// Add hover effect
p.onmouseover = () => {
p.classList.add('hover-style-heading');
}
// Remove hover effect when not hovering
p.onmouseout = () => {
p.classList.remove('hover-style-heading');
}
} }
p.addEventListener('click', (event) => {
// Prevent default behavior
event.preventDefault();
// Use the openLinkText method to navigate to the heading
this.app.workspace.openLinkText('#' + headingText, currentFilePath);
// Clear any text selection
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
});
// Add hover effect
p.onmouseover = () => {
p.classList.add('hover-style-heading');
}
// Remove hover effect when not hovering
p.onmouseout = () => {
p.classList.remove('hover-style-heading');
}
headingCount++;
}
// Indicate if there are more headings not shown
if (headingCount >= MAX_HEADINGS) {
container.createEl('p', {
cls: 'more-headings-note',
text: '... more headings available (not shown)'
});
}
} catch (err) {
console.error("Error processing headings:", err);
container.createEl('p', {
cls: 'error-note',
text: 'Error processing headings'
}); });
} }
} }
@ -314,7 +350,7 @@ export class CurrentFolderNotesDisplayView extends ItemView {
container.empty(); container.empty();
// Get display settings // Get display settings
const displayMode = this.plugin.settings.displayMode; // const displayMode = this.plugin.settings.displayMode; // Removed
const styleMode = this.plugin.settings.styleMode; const styleMode = this.plugin.settings.styleMode;
const showNavigation = this.plugin.settings.showNavigation; const showNavigation = this.plugin.settings.showNavigation;
@ -422,7 +458,8 @@ export class CurrentFolderNotesDisplayView extends ItemView {
}); });
prevLink.setAttribute('aria-label', `Previous: ${prevNote.basename}`); prevLink.setAttribute('aria-label', `Previous: ${prevNote.basename}`);
prevLink.addEventListener('click', () => { prevLink.addEventListener('click', () => {
this.app.workspace.openLinkText(prevNote.basename, parentFolderPath); // Use the full path to handle duplicate file names
this.app.workspace.openLinkText(prevNote.path, '');
}); });
} }
@ -439,29 +476,46 @@ export class CurrentFolderNotesDisplayView extends ItemView {
}); });
nextLink.setAttribute('aria-label', `Next: ${nextNote.basename}`); nextLink.setAttribute('aria-label', `Next: ${nextNote.basename}`);
nextLink.addEventListener('click', () => { nextLink.addEventListener('click', () => {
this.app.workspace.openLinkText(nextNote.basename, parentFolderPath); // Use the full path to handle duplicate file names
this.app.workspace.openLinkText(nextNote.path, '');
}); });
} }
// Current note outline // Current note outline - LAZY LOAD
if (this.plugin.settings.includeCurrentFileOutline) { if (this.plugin.settings.includeCurrentFileOutline) {
const currentFile = files[currentFileIndex]; const currentFile = files[currentFileIndex];
const fileContent = await this.app.vault.read(currentFile); // Create the outline section first
if (fileContent) { const outlineSection = navigationSection.createDiv({ cls: 'outline-section' });
const outlineSection = navigationSection.createDiv({ cls: 'outline-section' });
// Add current note title // Add current note title
outlineSection.createEl('div', { outlineSection.createEl('div', {
cls: 'current-note-title', cls: 'current-note-title',
text: currentFile.basename text: currentFile.basename
}); });
outlineSection.createEl('div', { outlineSection.createEl('div', {
cls: 'folder-section-header', cls: 'folder-section-header',
text: 'CURRENT NOTE OUTLINE' text: 'CURRENT NOTE OUTLINE'
}); });
this.createClickableHeadings(outlineSection, fileContent, currentFile.path, true);
} // Add a loading indicator
const loadingEl = outlineSection.createEl('div', {
cls: 'loading-indicator',
text: 'Loading outline...'
});
// Load content asynchronously
setTimeout(async () => {
const fileContent = await this.app.vault.read(currentFile);
if (fileContent) {
// Remove loading indicator
loadingEl.remove();
// Create the headings
this.createClickableHeadings(outlineSection, fileContent, currentFile.path, true);
} else {
loadingEl.setText('No content found');
}
}, 10);
} }
// Add separator // Add separator
@ -475,25 +529,69 @@ export class CurrentFolderNotesDisplayView extends ItemView {
text: 'FOLDER NOTES' text: 'FOLDER NOTES'
}); });
for (const file of files) { // OPTIMIZE: Limit the number of files displayed at once to prevent memory issues
const isCurrentFile = file.path === currentFilePath; const MAX_FILES_DISPLAY = this.plugin.settings.maxFilesDisplay; // Use the setting value
const fileContainer = listContainer.createDiv({ const displayedFiles = files.slice(0, MAX_FILES_DISPLAY);
cls: isCurrentFile ? 'file-container current' : 'file-container' const hasMoreFiles = files.length > MAX_FILES_DISPLAY;
});
this.createFileLink(fileContainer, file, currentFilePath, parentFolderPath); // Process files in batches to prevent UI freezing
const BATCH_SIZE = 20;
const processBatch = async (startIdx: number) => {
const endIdx = Math.min(startIdx + BATCH_SIZE, displayedFiles.length);
if (this.plugin.settings.includeListFileOutlines) { for (let i = startIdx; i < endIdx; i++) {
const fileContent = await this.app.vault.read(file); const file = displayedFiles[i];
if (fileContent) { const isCurrentFile = file.path === currentFilePath;
this.createClickableHeadings(fileContainer, fileContent, file.path, isCurrentFile); const fileContainer = listContainer.createDiv({
cls: isCurrentFile ? 'file-container current' : 'file-container'
});
this.createFileLink(fileContainer, file, currentFilePath, parentFolderPath);
// Only load outlines if explicitly enabled
if (this.plugin.settings.includeListFileOutlines) {
// Add a placeholder initially
const outlinePlaceholder = fileContainer.createEl('div', {
cls: 'outline-placeholder',
text: 'Loading outline...'
});
// Load outline in the next animation frame
requestAnimationFrame(async () => {
try {
const fileContent = await this.app.vault.read(file);
outlinePlaceholder.remove();
if (fileContent) {
this.createClickableHeadings(fileContainer, fileContent, file.path, isCurrentFile);
}
} catch (err) {
console.error("Error loading outline:", err);
outlinePlaceholder.setText("Failed to load outline");
}
});
} }
} }
}
// Process next batch if needed
if (endIdx < displayedFiles.length) {
setTimeout(() => processBatch(endIdx), 50);
}
// Show message if we're not displaying all files
if (hasMoreFiles && endIdx === displayedFiles.length) {
listContainer.createEl('div', {
cls: 'more-files-message',
text: `Showing ${MAX_FILES_DISPLAY} of ${files.length} notes. Use filters to narrow results.`
});
}
};
// Start processing the first batch
processBatch(0);
// Apply display classes // Apply display classes
container.classList.toggle('compact-mode', displayMode === 'compact'); container.classList.add('compact-mode'); // Always use compact mode
container.classList.toggle('expanded-mode', displayMode === 'expanded'); // container.classList.toggle('expanded-mode', displayMode === 'expanded'); // Removed
container.classList.toggle('minimal-style', styleMode === 'minimal'); container.classList.toggle('minimal-style', styleMode === 'minimal');
container.classList.toggle('fancy-style', styleMode === 'fancy'); container.classList.toggle('fancy-style', styleMode === 'fancy');
container.classList.toggle('neobrutalist-style', styleMode === 'neobrutalist'); container.classList.toggle('neobrutalist-style', styleMode === 'neobrutalist');
@ -529,7 +627,8 @@ export class CurrentFolderNotesDisplayView extends ItemView {
// Add click handler to open the file // Add click handler to open the file
a.addEventListener('click', () => { a.addEventListener('click', () => {
this.app.workspace.openLinkText(file.basename, parentFolderPath); // Use the full path instead of just basename to handle duplicate file names
this.app.workspace.openLinkText(file.path, '');
}); });
} }
@ -563,52 +662,79 @@ export class CurrentFolderNotesDisplayView extends ItemView {
cls: 'empty-state-subtext' cls: 'empty-state-subtext'
}); });
this.app.vault.read(activeFile).then(fileContent => { // Add a loading indicator
if (fileContent) { const loadingEl = emptyStateDiv.createEl('div', {
this.createClickableHeadings(container as HTMLElement, fileContent, currentFilePath, true); cls: 'loading-indicator',
} text: 'Loading outline...'
}); });
// Load content asynchronously with timeout to prevent blocking UI
setTimeout(async () => {
try {
const fileContent = await this.app.vault.read(activeFile);
if (fileContent) {
// Remove loading indicator
loadingEl.remove();
// Create the headings with a limited subset of content if it's large
const contentSize = fileContent.length;
if (contentSize > 100000) { // If content is very large (>100KB)
const truncatedContent = fileContent.substring(0, 100000);
this.createClickableHeadings(container as HTMLElement, truncatedContent, currentFilePath, true);
container.createEl('p', {
text: 'Note is very large. Only showing first portion of headings.',
cls: 'empty-state-subtext'
});
} else {
this.createClickableHeadings(container as HTMLElement, fileContent, currentFilePath, true);
}
} else {
loadingEl.setText('No content found');
}
} catch (err) {
console.error("Error loading outline:", err);
loadingEl.setText("Failed to load outline");
}
}, 10);
} }
} }
private applyFilters(files: TFile[], parentFolderPath: string): TFile[] { private applyFilters(files: TFile[], parentFolderPath: string): TFile[] {
let filteredFiles = files; // Create a single-pass filter function to avoid multiple array iterations
// console.log("[CFN] Starting filter with files:", files.length); return files.filter(file => {
// Skip files in subfolders if that setting is disabled
// Apply subfolder filter if (!this.plugin.settings.includeSubfolderNotes &&
if (!this.plugin.settings.includeSubfolderNotes) { file.path.substring(parentFolderPath.length + 1).includes('/')) {
const beforeCount = filteredFiles.length; return false;
filteredFiles = filteredFiles.filter(file => !file.path.substring(parentFolderPath.length + 1).includes('/'));
// console.log("[CFN] After subfolder filter:", filteredFiles.length, "removed:", beforeCount - filteredFiles.length);
}
// Apply include filter
const includesFilter = this.plugin.settings.includeTitleFilter;
if (includesFilter && includesFilter.length > 0) {
const beforeCount = filteredFiles.length;
const includeWords = includesFilter.split(/[,\s]+/).map(word => word.trim().toLowerCase());
if (includeWords.length > 0) {
filteredFiles = filteredFiles.filter(file =>
includeWords.some(word => file.basename.toLowerCase().includes(word))
);
// console.log("[CFN] After include filter:", filteredFiles.length, "removed:", beforeCount - filteredFiles.length);
} }
}
// Apply exclude filter const lowerBasename = file.basename.toLowerCase();
const excludeFilter = this.plugin.settings.excludeTitlesFilter;
if (excludeFilter && excludeFilter.length > 0) { // Check exclude filter
const beforeCount = filteredFiles.length; const excludeFilter = this.plugin.settings.excludeTitlesFilter;
const excludeWords = excludeFilter.split(/[,\s]+/).map(word => word.trim().toLowerCase()); if (excludeFilter && excludeFilter.length > 0) {
if (excludeWords.length > 0) { const excludeWords = excludeFilter.split(/[,\s]+/).filter(word => word.trim().length > 0)
filteredFiles = filteredFiles.filter(file => .map(word => word.trim().toLowerCase());
!excludeWords.some(word => file.basename.toLowerCase().includes(word))
); if (excludeWords.length > 0 &&
// console.log("[CFN] After exclude filter:", filteredFiles.length, "removed:", beforeCount - filteredFiles.length); excludeWords.some(word => lowerBasename.includes(word))) {
return false;
}
} }
}
return filteredFiles; // Check include filter
const includesFilter = this.plugin.settings.includeTitleFilter;
if (includesFilter && includesFilter.length > 0) {
const includeWords = includesFilter.split(/[,\s]+/).filter(word => word.trim().length > 0)
.map(word => word.trim().toLowerCase());
if (includeWords.length > 0 &&
!includeWords.some(word => lowerBasename.includes(word))) {
return false;
}
}
return true;
});
} }
} }
@ -721,18 +847,6 @@ class CurrentFolderNotesDisplaySettingTab extends PluginSettingTab {
await this.plugin.saveSettings(); await this.plugin.saveSettings();
})); }));
new Setting(displayEl)
.setName('Display mode')
.setDesc('Choose between compact and expanded display modes.')
.addDropdown(dropdown => dropdown
.addOption('compact', 'Compact')
.addOption('expanded', 'Expanded')
.setValue(this.plugin.settings.displayMode)
.onChange(async (value) => {
this.plugin.settings.displayMode = value as 'compact' | 'expanded';
await this.plugin.saveSettings();
}));
new Setting(displayEl) new Setting(displayEl)
.setName('Style mode') .setName('Style mode')
.setDesc('Choose between minimal, fancy, and neobrutalist styles.') .setDesc('Choose between minimal, fancy, and neobrutalist styles.')
@ -808,6 +922,19 @@ class CurrentFolderNotesDisplaySettingTab extends PluginSettingTab {
await this.plugin.saveSettings(); await this.plugin.saveSettings();
})); }));
new Setting(contentEl)
.setName('Maximum files to display')
.setDesc('Limit the number of files shown in the folder notes list. Use a lower value for better performance in large folders.')
.addSlider(slider => slider
.setLimits(10, 500, 10)
.setValue(this.plugin.settings.maxFilesDisplay)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.maxFilesDisplay = value;
await this.plugin.saveSettings();
this.plugin.refreshView();
}));
// About section with help text // About section with help text
const aboutEl = containerEl.createDiv({ cls: 'settings-section' }); const aboutEl = containerEl.createDiv({ cls: 'settings-section' });
aboutEl.createEl('h3', { cls: 'settings-section-header', text: ' About' }); aboutEl.createEl('h3', { cls: 'settings-section-header', text: ' About' });

View file

@ -1,7 +1,7 @@
{ {
"id": "current-folder-notes-pamphlet", "id": "current-folder-notes-pamphlet",
"name": "Current Folder Notes", "name": "Current Folder Notes",
"version": "1.7.11", "version": "1.7.15",
"minAppVersion": "1.7.7", "minAppVersion": "1.7.7",
"description": "Shows a list of notes in the current folder, and allows you to filter the titles to include or exclude notes.", "description": "Shows a list of notes in the current folder, and allows you to filter the titles to include or exclude notes.",
"author": "Pamela Wang", "author": "Pamela Wang",

8
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "obsidian-sample-plugin", "name": "current-folder-notes-pamphlet",
"version": "1.7.11", "version": "1.7.15",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-sample-plugin", "name": "current-folder-notes-pamphlet",
"version": "1.7.11", "version": "1.7.15",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",

View file

@ -1,7 +1,7 @@
{ {
"name": "obsidian-sample-plugin", "name": "current-folder-notes-pamphlet",
"version": "1.7.11", "version": "1.7.15",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)", "description": "Shows a list of notes in the current folder, and allows you to filter the titles to include or exclude notes.",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",

View file

@ -24,5 +24,9 @@
"1.7.8": "1.7.7", "1.7.8": "1.7.7",
"1.7.9": "1.7.7", "1.7.9": "1.7.7",
"1.7.10": "1.7.7", "1.7.10": "1.7.7",
"1.7.11": "1.7.7" "1.7.11": "1.7.7",
"1.7.12": "1.7.7",
"1.7.13": "1.7.7",
"1.7.14": "1.7.7",
"1.7.15": "1.7.7"
} }