antokeinanen_obsidian-advan.../main.ts
2023-10-04 15:53:12 +03:00

579 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
App,
Modal,
Plugin,
PluginSettingTab,
Setting,
TAbstractFile,
TFile,
TFolder,
Vault,
} from "obsidian";
interface Translation {
MergeFolder: string;
MergedFilesuffix: string;
OverwriteFileQuestion: string;
Settings: string;
SettingSortMode: string;
SettingSortModeDescription: string;
SettingSortAlphabetically: string;
SettingSortDateCreated: string;
SettingSortLogical: string;
SettingIncludeNestedFolders: string;
SettingIncludeNestedFoldersDescription: string;
SettingIncludeFoldersAsSections: string;
SettingIncludeFoldersAsSectionsDescription: string;
Yes: string;
No: string;
}
const TRANSLATIONS: { [name: string]: Translation } = {
de: {
MergeFolder: "Ordner zusammenführen",
MergedFilesuffix: "zusammengeführt",
OverwriteFileQuestion: "Vorhandene Datei überschreiben",
Settings: "Einstellungen",
SettingIncludeNestedFolders: "Verschachtelte Ordner einbeziehen",
SettingIncludeNestedFoldersDescription:
"Wenn aktiviert, werden Dateien in verschachtelten Ordnern zusammengeführt. Andernfalls werden nur Dateien im ausgewählten Ordner zusammengeführt (Standardverhalten).",
SettingIncludeFoldersAsSections: "Ordner als Abschnitte einbeziehen",
SettingIncludeFoldersAsSectionsDescription:
"Ordner werden als benannte Abschnitte in die Ausgabedatei eingefügt.",
Yes: "Ja",
No: "Nein",
SettingSortMode: "Sortiermodus",
SettingSortModeDescription:
"Wählen Sie den Sortiermodus für das Zusammenführen von Notizen.",
SettingSortDateCreated: "Erstellungsdatum",
SettingSortLogical: "Logisch",
SettingSortAlphabetically: "Alphabetisch",
},
en: {
MergeFolder: "Merge folder",
MergedFilesuffix: "merged",
OverwriteFileQuestion: "Overwite exising file",
Settings: "Settings",
SettingIncludeNestedFolders: "Include nested folders",
SettingIncludeNestedFoldersDescription:
"If enabled, files in nested folders will be included in merge. Otherwise, only files in selected folder will be merged (default behaviour).",
SettingIncludeFoldersAsSections: "Include folders as sections",
SettingIncludeFoldersAsSectionsDescription:
"Folders will be included as named sections into output file.",
Yes: "Yes",
No: "No",
SettingSortMode: "Sort mode",
SettingSortModeDescription: "Pick the sorting mode for merging notes.",
SettingSortDateCreated: "Date created",
SettingSortLogical: "Logical",
SettingSortAlphabetically: "Alphabetical",
},
fi: {
MergeFolder: "Yhdistä kansio",
MergedFilesuffix: "yhdistetty",
OverwriteFileQuestion: "Korvaa olemassa oleva tiedosto",
Settings: "Asetukset",
SettingIncludeNestedFolders: "Sisällytä sisäkkäiset kansiot",
SettingIncludeNestedFoldersDescription:
"Jos käytössä, sisäkkäisten kansioiden tiedostot yhdistetään. Muussa tapauksessa vain valitun kansion tiedostot yhdistetään (oletustoiminto).",
SettingIncludeFoldersAsSections: "Sisällytä kansiot osioihin",
SettingIncludeFoldersAsSectionsDescription:
"Kansiot sisällytetään nimettyinä osina tulostiedostoon.",
Yes: "Kyllä",
No: "Ei",
SettingSortMode: "Lajittelu järjestys",
SettingSortModeDescription:
"Valitse lajittelutapa muistiinpanojen yhdistämistä varten.",
SettingSortDateCreated: "Luontipäivämäärä",
SettingSortLogical: "Looginen",
SettingSortAlphabetically: "Aakkosjärjestys",
},
fr: {
MergeFolder: "Fusionner le dossier",
MergedFilesuffix: "fusionné",
OverwriteFileQuestion: "Remplacer le fichier existant",
Settings: "Paramètres",
SettingIncludeNestedFolders: "Inclure les dossiers imbriqués",
SettingIncludeNestedFoldersDescription:
"Si activé, les fichiers des dossiers imbriqués seront inclus dans la fusion. Sinon, seuls les fichiers du dossier sélectionné seront fusionnés (comportement par défaut).",
SettingIncludeFoldersAsSections:
"Inclure les dossiers en tant que sections",
SettingIncludeFoldersAsSectionsDescription:
"Les dossiers seront inclus en tant que sections nommées dans le fichier de sortie.",
Yes: "Oui",
No: "Non",
SettingSortMode: "mode de tri",
SettingSortModeDescription:
"Choisissez le mode de tri pour la fusion des notes.",
SettingSortDateCreated: "Date de création",
SettingSortLogical: "Logique",
SettingSortAlphabetically: "Alphabétique",
},
ru: {
MergeFolder: "Объединить папку",
MergedFilesuffix: "совмещенный",
OverwriteFileQuestion: "Перезаписать существующий файл",
Settings: "Настройки",
SettingIncludeNestedFolders: "Влючать вложенные папки",
SettingIncludeNestedFoldersDescription:
"Если включено, файлы во вложенных папках будут включены в слияние. В противном случае будут объединены только файлы в выбранной папке (поведение по умолчанию).",
SettingIncludeFoldersAsSections: "Включать папки как разделы",
SettingIncludeFoldersAsSectionsDescription:
"Папки будут включены в выходной файл в качестве разделов.",
Yes: "Да",
No: "Нет",
SettingSortMode: "режим сортировки",
SettingSortModeDescription:
"Выберите режим сортировки при объединении заметок.",
SettingSortDateCreated: "Дата создания",
SettingSortLogical: "Логический",
SettingSortAlphabetically: "Алфавитный указатель",
},
ua: {
MergeFolder: "Об'єднати папку",
MergedFilesuffix: "об'єднані",
OverwriteFileQuestion: "Перезаписати існуючий файл",
Settings: "Налаштування",
SettingIncludeNestedFolders: "Включити вкладені папки",
SettingIncludeNestedFoldersDescription:
"Якщо ввімкнено, файли у вкладених папках будуть включені в об’єднання. В іншому випадку буде об’єднано лише файли у вибраній папці (поведінка за замовчуванням).",
SettingIncludeFoldersAsSections: "Включити папки як розділи",
SettingIncludeFoldersAsSectionsDescription:
"Папки будуть включені як іменовані розділи у вихідний файл.",
Yes: "Так",
No: "Ні",
SettingSortMode: "Логический",
SettingSortModeDescription:
"Виберіть режим сортування для об'єднання нотаток.",
SettingSortDateCreated: "Дата створення",
SettingSortLogical: "Логічний",
SettingSortAlphabetically: "За алфавітом",
},
};
/**
* Enum representing the available sorting options for merging notes.
* If you add a new option, make sure to add it to the settings in 'AdvancedMergeSettingTab.display'.
*/
enum SortOptions {
Alphabetically,
CreationDate,
Logical,
}
interface AdvancedMergePluginSettings {
sortMode: SortOptions;
includeNestedFolders: boolean;
includeFoldersAsSections: boolean;
}
const DEFAULT_SETTINGS: AdvancedMergePluginSettings = {
sortMode: SortOptions.Logical,
includeNestedFolders: false,
includeFoldersAsSections: false,
};
/** @constant
@type {string}
@default
*/
const ICON_NAME = "git-merge";
/** @constant
@type {string}
@default
*/
const PLUGIN_NAME = "Advanced Merger";
/** @constant
@type {string}
@default
*/
const DEFAULT_LANGUAGE = "en";
/** @constant
@type {string}
@default
*/
const HANDLER_LOCATION = "file-menu";
/** @constant
@type {string}
@default
*/
const NEW_LINE_CHAR = "\n";
/** @constant
@type {string}
@default
*/
const DOUBLE_NEW_LINE_CHAR = "\n\n";
/** @constant
@type {string}
@default
*/
const SECTION_CHAR = "#";
/** @constant
@type {string}
@default
*/
const MARKDOWN_FILE_EXTENSION = "md";
export default class AdvancedMerge extends Plugin {
public translation: AdvancedMergeTranslation;
public settings: AdvancedMergePluginSettings;
public async onload(): Promise<void> {
this.translation = new AdvancedMergeTranslation();
await this.loadSettings();
this.registerEvent(
this.app.workspace.on(HANDLER_LOCATION, (menu, file) => {
if (!(file instanceof TFolder)) {
return;
}
const folder = file;
menu.addItem((item) => {
item.setTitle(this.translation.get().MergeFolder)
.setIcon(ICON_NAME)
.onClick(
async (evt) =>
await this.onClickCallback(folder, evt),
);
});
}),
);
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new AdvancedMergeSettingTab(this.app, this));
}
private async onClickCallback(
folder: TFolder,
_evt: MouseEvent | KeyboardEvent,
): Promise<void> {
const { vault } = this.app;
const documentEntries: Array<TFile> = [];
Vault.recurseChildren(folder, (folderOrFile: TFile) => {
// For merging we are including only *.md files
if (
folderOrFile instanceof TFile &&
folderOrFile.extension == MARKDOWN_FILE_EXTENSION
) {
console.log(`Found a file: ${folderOrFile.name}`);
documentEntries.push(folderOrFile);
} else if (
folderOrFile instanceof TFolder &&
this.settings.includeFoldersAsSections
) {
console.log(`Found a folder: ${folderOrFile.name}`);
documentEntries.push(folderOrFile);
}
});
const entries = this.sortNotes(
documentEntries.filter((entry) => this.filterNotes(folder, entry)),
);
const outputFileName = `${folder.path}-${
this.translation.get().MergedFilesuffix
}.md`;
const fileExists = await vault.adapter.exists(outputFileName, false);
if (fileExists) {
new AdvancedMergeOverwriteFileModal(
this.app,
this.translation,
outputFileName,
async (deleteFile) => {
if (!deleteFile) {
console.info(
`file "${outputFileName}" already exists, but user cancelled deleting..`,
);
return;
}
console.info(
`file "${outputFileName}" already exists, deleting..`,
);
await vault.adapter.remove(outputFileName);
await this.mergeNotes(vault, entries, outputFileName);
},
).open();
return;
}
await this.mergeNotes(vault, entries, outputFileName);
}
/**
* Merges input notes to single output file.
* @param {Vault} vault - Current vault.
* @param {Array<TAbstractFile>} entries - Files and folders, to be included.
* @param {string} outputFileName - Output file name.
*/
private async mergeNotes(
vault: Vault,
entries: Array<TAbstractFile>,
outputFileName: string,
): Promise<void> {
const outputFile = await vault.create(outputFileName, "");
for (let index = 0; index < entries.length; index++) {
const folderOrFile: TAbstractFile = entries[index];
const sectionLevel = (folderOrFile.path.match(/\//g) || []).length;
let sectionContents = `${index === 0 ? "" : NEW_LINE_CHAR}`;
const lastEntry = index === entries.length - 1;
if (folderOrFile instanceof TFile) {
sectionContents += await vault.cachedRead(folderOrFile);
const fileSectionName = folderOrFile.name.replace(/\.md$/, "");
// For the first file in a row, we shouldnt add new line
sectionContents = `${SECTION_CHAR.repeat(
sectionLevel,
)} ${fileSectionName}${DOUBLE_NEW_LINE_CHAR}${sectionContents}${
lastEntry ? "" : DOUBLE_NEW_LINE_CHAR
}`;
console.info(
`Adding file "${folderOrFile.name}" as section "${fileSectionName}" into file "${outputFileName}"..`,
);
} else if (folderOrFile instanceof TFolder) {
sectionContents += `${SECTION_CHAR.repeat(sectionLevel)} ${
folderOrFile.name
}${DOUBLE_NEW_LINE_CHAR}`;
console.info(
`Adding folder "${folderOrFile.name}" as section "${sectionContents}" into file "${outputFileName}"..`,
);
}
vault.append(outputFile, sectionContents);
}
}
/**
* Applies filtering to input file.
* @param {TFolder} folder - Selected folder.
* @param {TFile} file - Files, to be included.
* @returns {boolean} Provided file passses folder check.
*/
private filterNotes(folder: TFolder, file: TAbstractFile): boolean {
return this.settings.includeNestedFolders
? !!file.parent?.path.startsWith(folder.path)
: file.parent?.path == folder.path;
}
/**
* Applies sorting to input files array.
* @param {Array<TFile>} files - The files array, to be sorted.
* @returns {Array<TFile>} Sorted files array.
*/
private sortNotes(files: Array<TFile>): Array<TFile> {
switch (this.settings.sortMode) {
case SortOptions.Alphabetically:
return files.sort((a, b) =>
a.path.localeCompare(b.path, undefined, {
numeric: true,
sensitivity: "base",
usage: "sort",
}),
);
case SortOptions.CreationDate:
return files.sort((a, b) => a.stat.ctime - b.stat.ctime);
case SortOptions.Logical:
default:
return files.reverse();
}
}
/**
* De-serializes settings from persistent storage.
*/
private async loadSettings(): Promise<void> {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
);
}
/**
* Serializes settings into persistent storage.
*/
public async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
}
class AdvancedMergeSettingTab extends PluginSettingTab {
private plugin: AdvancedMerge;
private includeFolderAsSectionSetting: Setting;
/**
* Represents a settings tab.
* @constructor
* @param {App} app - The `Obsidian` application object.
* @param {AdvancedMerge} plugin - The `AdvancedMerge` plugin object.
*/
constructor(app: App, plugin: AdvancedMerge) {
super(app, plugin);
this.plugin = plugin;
}
public display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", {
text: `${PLUGIN_NAME} - ${this.plugin.translation.get().Settings}`,
});
// Add "sort mode" dropdown in settings
new Setting(containerEl)
.setName(this.plugin.translation.get().SettingSortMode)
.setDesc(this.plugin.translation.get().SettingSortModeDescription)
.addDropdown((dropdown) =>
dropdown.addOptions({
[SortOptions.Alphabetically]:
this.plugin.translation.get().SettingSortAlphabetically,
[SortOptions.CreationDate]:
this.plugin.translation.get().SettingSortDateCreated,
[SortOptions.Logical]:
this.plugin.translation.get().SettingSortLogical,
}),
);
// Add "include nested folders" toggle in settings
new Setting(containerEl)
.setName(this.plugin.translation.get().SettingIncludeNestedFolders)
.setDesc(
this.plugin.translation.get()
.SettingIncludeNestedFoldersDescription,
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.includeNestedFolders)
.onChange(async (value) => {
this.plugin.settings.includeNestedFolders = value;
this.plugin.settings.includeFoldersAsSections =
value === false
? value
: this.plugin.settings.includeFoldersAsSections;
await this.plugin.saveSettings();
this.showIncludeFolderAsSectionSetting(containerEl);
}),
);
this.showIncludeFolderAsSectionSetting(containerEl);
}
private showIncludeFolderAsSectionSetting(containerEl: HTMLElement): void {
if (!this.plugin.settings.includeNestedFolders) {
this.includeFolderAsSectionSetting?.settingEl?.remove();
return;
}
// Add "include folders as sections" toggle in settings
this.includeFolderAsSectionSetting = new Setting(containerEl)
.setName(
this.plugin.translation.get().SettingIncludeFoldersAsSections,
)
.setDesc(
this.plugin.translation.get()
.SettingIncludeFoldersAsSectionsDescription,
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.includeFoldersAsSections)
.onChange(async (value) => {
this.plugin.settings.includeFoldersAsSections = value;
await this.plugin.saveSettings();
}),
);
}
}
class AdvancedMergeOverwriteFileModal extends Modal {
private tranlation: AdvancedMergeTranslation;
private existingFileName: string;
private onSubmitHandler: (result: boolean) => void;
/**
* Represents an "Overwrite file?" dialog.
* @constructor
* @param {App} app - The `Obsidian` application object.
* @param {AdvancedMergeTranslation} translation - Plugin translation.
* @param {string} existingFileName - Existing file name.
* @param handler - Modal callback handler.
*/
constructor(
app: App,
translation: AdvancedMergeTranslation,
existingFileName: string,
handler: (result: boolean) => void,
) {
super(app);
this.tranlation = translation;
this.existingFileName = existingFileName;
this.onSubmitHandler = handler;
}
/**
* @inheritdoc
*/
public onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h3", {
text: `${this.tranlation.get().OverwriteFileQuestion} "${
this.existingFileName
}"?`,
});
new Setting(contentEl)
.addButton((btn) =>
btn
.setButtonText(this.tranlation.get().No)
.setCta()
.onClick(() => {
this.close();
this.onSubmitHandler(false);
}),
)
.addButton((btn) =>
btn
.setButtonText(this.tranlation.get().Yes)
.setCta()
.onClick(() => {
this.close();
this.onSubmitHandler(true);
}),
);
}
/**
* @inheritdoc
*/
public onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}
class AdvancedMergeTranslation {
private language: string;
/**
* Represents a plugin translation.
* @constructor
*/
constructor() {
this.language = !Object.keys(TRANSLATIONS).contains(navigator.language)
? DEFAULT_LANGUAGE
: navigator.language;
}
/**
* Gets translation object for current language.
* @returns {Translation} Current translation object.
*/
public get(): Translation {
return TRANSLATIONS[this.language];
}
}