diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b6cf67b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +## obsidian-advanced-merger 1.3.0 +### Additions and Changes +- Added "Overwrite existing file" dialog + +## obsidian-advanced-merger 1.2.0 +### Additions and Changes +- Translations support added (starter: English, Ukranian, Finnish, French, Russian, German) +- Added support and configuration for merging files in nested folders +- Added configuration for sorting mode (default/alphabetically) + +## obsidian-advanced-merger 1.1.0 +### Additions and Changes +- Fix issue where the created file will still be collected and merged +- Remove not needed await Promise.All + +## obsidian-advanced-merger 1.0.2 +### Additions and Changes +- Added sorting alphabetically by file path + +## obsidian-advanced-merger 1.0.1 +### Additions and Changes +- Remove unused onunload event +- Remove redundant file check diff --git a/esbuild.config.mjs b/esbuild.config.mjs index b6f3904..cc53c2b 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -2,14 +2,13 @@ import esbuild from "esbuild"; import process from "process"; import builtins from "builtin-modules"; -const banner = -`/* +const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ `; -const prod = (process.argv[2] === "production"); +const prod = process.argv[2] === "production"; const context = await esbuild.context({ banner: { @@ -31,7 +30,8 @@ const context = await esbuild.context({ "@lezer/common", "@lezer/highlight", "@lezer/lr", - ...builtins], + ...builtins, + ], format: "cjs", target: "es2018", logLevel: "info", diff --git a/main.ts b/main.ts index edd3c12..73f0c5a 100644 --- a/main.ts +++ b/main.ts @@ -1,75 +1,141 @@ -import { App, Plugin, PluginSettingTab, Setting, TFile, TFolder } from "obsidian"; +import { + App, + Modal, + Plugin, + PluginSettingTab, + Setting, + TFile, + TFolder, + Vault, +} from "obsidian"; const TRANSLATIONS: { [name: string]: { [name: string]: string } } = { de: { MergeFolder: "Ordner zusammenführen", MergedFilesuffix: "zusammengeführt", + OverwriteFileQuestion: "Vorhandene Datei überschreiben", Settings: "Einstellungen", SettingSortAlphabetically: "Dateien alphabetisch sortieren", - SettingSortAlphabeticallyDescription: "Wenn aktiviert, werden die Dateien im ausgewählten Ordner alphabetisch nach ihrem vollständigen Pfad sortiert. Andernfalls werden die Dateien nach dem Erstellungsdatum sortiert (Standardverhalten).", + SettingSortAlphabeticallyDescription: + "Wenn aktiviert, werden die Dateien im ausgewählten Ordner alphabetisch nach ihrem vollständigen Pfad sortiert. Andernfalls werden die Dateien nach dem Erstellungsdatum sortiert (Standardverhalten).", 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)." + SettingIncludeNestedFoldersDescription: + "Wenn aktiviert, werden Dateien in verschachtelten Ordnern zusammengeführt. Andernfalls werden nur Dateien im ausgewählten Ordner zusammengeführt (Standardverhalten).", + Yes: "Ja", + No: "Nein", }, en: { MergeFolder: "Merge folder", MergedFilesuffix: "merged", + OverwriteFileQuestion: "Overwite exising file", Settings: "Settings", SettingSortAlphabetically: "Sort files alphabetically", - SettingSortAlphabeticallyDescription: "If enabled, files in selected folder will be sorted alphabetically, according to its full path. Otherwise, files will be sorted by creation date (default behaviour).", + SettingSortAlphabeticallyDescription: + "If enabled, files in selected folder will be sorted alphabetically, according to its full path. Otherwise, files will be sorted by creation date (default behaviour).", 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)." + SettingIncludeNestedFoldersDescription: + "If enabled, files in nested folders will be included in merge. Otherwise, only files in selected folder will be merged (default behaviour).", + Yes: "Yes", + No: "No", }, fi: { MergeFolder: "Yhdistä kansio", MergedFilesuffix: "yhdistetty", + OverwriteFileQuestion: "Korvaa olemassa oleva tiedosto", Settings: "Asetukset", SettingSortAlphabetically: "Lajittele tiedostot aakkosjärjestykseen", - SettingSortAlphabeticallyDescription: "Jos käytössä, valitun kansion tiedostot lajitellaan aakkosjärjestyksessä sen täyden polun mukaan. Muussa tapauksessa tiedostot lajitellaan luomispäivämäärän mukaan (oletuskäyttäytyminen).", + SettingSortAlphabeticallyDescription: + "Jos käytössä, valitun kansion tiedostot lajitellaan aakkosjärjestyksessä sen täyden polun mukaan. Muussa tapauksessa tiedostot lajitellaan luomispäivämäärän mukaan (oletuskäyttäytyminen).", 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)." + SettingIncludeNestedFoldersDescription: + "Jos käytössä, sisäkkäisten kansioiden tiedostot yhdistetään. Muussa tapauksessa vain valitun kansion tiedostot yhdistetään (oletustoiminto).", + Yes: "Kyllä", + No: "Ei", }, fr: { MergeFolder: "Fusionner le dossier", MergedFilesuffix: "fusionné", + OverwriteFileQuestion: "Remplacer le fichier existant", Settings: "Paramètres", SettingSortAlphabetically: "Trier les fichiers par ordre alphabétique", - SettingSortAlphabeticallyDescription: "Si activé, les fichiers du dossier sélectionné seront triés par ordre alphabétique, en fonction de son chemin complet. Sinon, les fichiers seront triés par date de création (comportement par défaut).", + SettingSortAlphabeticallyDescription: + "Si activé, les fichiers du dossier sélectionné seront triés par ordre alphabétique, en fonction de son chemin complet. Sinon, les fichiers seront triés par date de création (comportement par défaut).", 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)." + 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).", + Yes: "Oui", + No: "Non", }, ru: { MergeFolder: "Объединить папку", MergedFilesuffix: "совмещенный", + OverwriteFileQuestion: "Перезаписать существующий файл", Settings: "Настройки", SettingSortAlphabetically: "Сортировать файлы по алфавиту", - SettingSortAlphabeticallyDescription: "Если включено, файлы в выбранной папке будут отсортированы в алфавитном порядке в соответствии с полным путем. В противном случае, файлы будут отсортированы по дате создания (поведение по умолчанию)", + SettingSortAlphabeticallyDescription: + "Если включено, файлы в выбранной папке будут отсортированы в алфавитном порядке в соответствии с полным путём. В противном случае, файлы будут отсортированы по дате создания (поведение по умолчанию).", SettingIncludeNestedFolders: "Влючать вложенные папки", - SettingIncludeNestedFoldersDescription: "Если включено, файлы во вложенных папках будут включены в слияние. В противном случае будут объединены только файлы в выбранной папке (поведение по умолчанию)." + SettingIncludeNestedFoldersDescription: + "Если включено, файлы во вложенных папках будут включены в слияние. В противном случае будут объединены только файлы в выбранной папке (поведение по умолчанию).", + Yes: "Да", + No: "Нет", }, ua: { MergeFolder: "Об'єднати папку", MergedFilesuffix: "об'єднані", + OverwriteFileQuestion: "Перезаписати існуючий файл", Settings: "Налаштування", SettingSortAlphabetically: "Сортувати файли за алфавітом", - SettingSortAlphabeticallyDescription: "Якщо ввімкнено, файли у вибраній папці будуть відсортовані в алфавітному порядку відповідно до повного шляху. В іншому випадку файли будуть відсортовані за датою створення (поведінка за замовчуванням).", + SettingSortAlphabeticallyDescription: + "Якщо ввімкнено, файли у вибраній папці будуть відсортовані в алфавітному порядку відповідно до повного шляху. В іншому випадку файли будуть відсортовані за датою створення (поведінка за замовчуванням).", SettingIncludeNestedFolders: "Включити вкладені папки", - SettingIncludeNestedFoldersDescription: "Якщо ввімкнено, файли у вкладених папках будуть включені в об’єднання. В іншому випадку буде об’єднано лише файли у вибраній папці (поведінка за замовчуванням)." - } -} + SettingIncludeNestedFoldersDescription: + "Якщо ввімкнено, файли у вкладених папках будуть включені в об’єднання. В іншому випадку буде об’єднано лише файли у вибраній папці (поведінка за замовчуванням).", + Yes: "Так", + No: "Ні", + }, +}; interface AdvancedMergePluginSettings { sortAlphabetically: boolean; - includeNestedFolders: boolean + includeNestedFolders: boolean; } const DEFAULT_SETTINGS: AdvancedMergePluginSettings = { sortAlphabetically: false, - includeNestedFolders: false -} + includeNestedFolders: 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"; export default class AdvancedMerge extends Plugin { public language: string; @@ -77,7 +143,10 @@ export default class AdvancedMerge extends Plugin { public settings: AdvancedMergePluginSettings; public async onload(): Promise { - this.language = TRANSLATIONS[navigator.language] === null ? DEFAULT_LANGUAGE : navigator.language; + this.language = + TRANSLATIONS[navigator.language] === null + ? DEFAULT_LANGUAGE + : navigator.language; await this.loadSettings(); @@ -90,8 +159,11 @@ export default class AdvancedMerge extends Plugin { const folder = file; menu.addItem((item) => { item.setTitle(TRANSLATIONS[this.language].MergeFolder) - .setIcon("git-merge") - .onClick(async(evt) => await this.onClickCallback(folder, evt)); + .setIcon(ICON_NAME) + .onClick( + async (evt) => + await this.onClickCallback(folder, evt) + ); }); }) ); @@ -100,55 +172,122 @@ export default class AdvancedMerge extends Plugin { this.addSettingTab(new AdvancedMergeSettingTab(this.app, this)); } - private async onClickCallback(folder: TFolder, _evt: MouseEvent | KeyboardEvent): Promise { + private async onClickCallback( + folder: TFolder, + _evt: MouseEvent | KeyboardEvent + ): Promise { const { vault } = this.app; const files = this.fileSort( vault .getMarkdownFiles() - .filter((file) => this.fileFilter(folder, file))); - - const destination = await vault.create( - `${folder.path}-${TRANSLATIONS[this.language].MergedFilesuffix}.md`, - "" + .filter((file) => this.fileFilter(folder, file)) ); - files.forEach(async (file) => { + const mergedFileName = `${folder.path}-${ + TRANSLATIONS[this.language].MergedFilesuffix + }.md`; + const fileExists = await vault.adapter.exists(mergedFileName, false); + if (fileExists) { + new AdvancedMergeOverwriteFileModal( + this.app, + this.language, + mergedFileName, + async (deleteFile) => { + if (!deleteFile) { + console.info( + `file "${mergedFileName}" already exists, but user cancelled deleting..` + ); + return; + } + + console.info( + `file "${mergedFileName}" already exists, deleting..` + ); + await vault.adapter.remove(mergedFileName); + + await this.mergeFiles(vault, files, mergedFileName); + } + ).open(); + return; + } + await this.mergeFiles(vault, files, mergedFileName); + } + + /** + * Merges input files to single output file. + * @param {Vault} vault - Current vault. + * @param {Array} files - Files, to be included. + * @param {string} mergedFileName - Output file name. + */ + private async mergeFiles( + vault: Vault, + files: Array, + mergedFileName: string + ): Promise { + const destination = await vault.create(mergedFileName, ""); + + files.forEach(async (file: TFile, index: number) => { let contents = await vault.read(file); - contents = `\n# ${file.name.replace( - /\.md$/, - "" - )}\n${contents}\n`; + const fileSectionName = file.name.replace(/\.md$/, ""); + // For the first file in a row, we shouldnt add new line + contents = `${ + index === 0 ? "" : NEW_LINE_CHAR + }# ${fileSectionName}${DOUBLE_NEW_LINE_CHAR}${contents}${NEW_LINE_CHAR}`; + console.info( + `Adding file "${file.name}" as section "${fileSectionName}" into file "${mergedFileName}"..` + ); vault.append(destination, contents); }); } + /** + * 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 fileFilter(folder: TFolder, file: TFile): boolean { - return this.settings.includeNestedFolders ? - !!file.parent?.path.startsWith(folder.path) + return this.settings.includeNestedFolders + ? !!file.parent?.path.startsWith(folder.path) : file.parent?.path == folder.path; } + /** + * Applies sorting to input files array. + * @param {Array} files - The files array, to be sorted. + * @returns {Array} Sorted files array. + */ private fileSort(files: Array): Array { - return this.settings.sortAlphabetically ? - files.sort((a, b) => { - const x = a.path.toLowerCase(); - const y = b.path.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }) + return this.settings.sortAlphabetically + ? files.sort((a, b) => { + const x = a.path.toLowerCase(); + const y = b.path.toLowerCase(); + if (x < y) { + return -1; + } + if (x > y) { + return 1; + } + return 0; + }) : files.reverse(); } + /** + * De-serializes settings from persistent storage. + */ private async loadSettings(): Promise { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + await this.loadData() + ); } + /** + * Serializes settings into persistent storage. + */ public async saveSettings(): Promise { await this.saveData(this.settings); } @@ -157,36 +296,123 @@ export default class AdvancedMerge extends Plugin { class AdvancedMergeSettingTab extends PluginSettingTab { private plugin: AdvancedMerge; + /** + * 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; + const { containerEl } = this; containerEl.empty(); - containerEl.createEl('h2', {text: `${PLUGIN_NAME} - ${TRANSLATIONS[this.plugin.language].Settings}`}); + containerEl.createEl("h2", { + text: `${PLUGIN_NAME} - ${ + TRANSLATIONS[this.plugin.language].Settings + }`, + }); + // Add "sort alphabetically" toggle in settings new Setting(containerEl) - .setName(TRANSLATIONS[this.plugin.language].SettingSortAlphabetically) - .setDesc(TRANSLATIONS[this.plugin.language].SettingSortAlphabeticallyDescription) - .addToggle(toggle => toggle - .setValue(this.plugin.settings.sortAlphabetically) - .onChange(async (value) => { - this.plugin.settings.sortAlphabetically = value; - await this.plugin.saveSettings(); - })); + .setName( + TRANSLATIONS[this.plugin.language].SettingSortAlphabetically + ) + .setDesc( + TRANSLATIONS[this.plugin.language] + .SettingSortAlphabeticallyDescription + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.sortAlphabetically) + .onChange(async (value) => { + this.plugin.settings.sortAlphabetically = value; + await this.plugin.saveSettings(); + }) + ); + // Add "include nested folders" toggle in settings new Setting(containerEl) - .setName(TRANSLATIONS[this.plugin.language].SettingIncludeNestedFolders) - .setDesc(TRANSLATIONS[this.plugin.language].SettingIncludeNestedFoldersDescription) - .addToggle(toggle => toggle - .setValue(this.plugin.settings.includeNestedFolders) - .onChange(async (value) => { - this.plugin.settings.includeNestedFolders = value; - await this.plugin.saveSettings(); - })); + .setName( + TRANSLATIONS[this.plugin.language].SettingIncludeNestedFolders + ) + .setDesc( + TRANSLATIONS[this.plugin.language] + .SettingIncludeNestedFoldersDescription + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.includeNestedFolders) + .onChange(async (value) => { + this.plugin.settings.includeNestedFolders = value; + await this.plugin.saveSettings(); + }) + ); + } +} + +class AdvancedMergeOverwriteFileModal extends Modal { + private language: string; + private existingFileName: string; + private onSubmitHandler: (result: boolean) => void; + + /** + * Represents an "Overwrite file?" dialog. + * @constructor + * @param {App} app - The `Obsidian` application object. + * @param {string} language - Application language. + * @param {string} existingFileName - Existing file name. + * @param handler - Modal callback handler. + */ + constructor( + app: App, + language: string, + existingFileName: string, + handler: (result: boolean) => void + ) { + super(app); + this.language = language; + this.existingFileName = existingFileName; + this.onSubmitHandler = handler; + } + + public onOpen() { + const { contentEl } = this; + + contentEl.createEl("h3", { + text: `${TRANSLATIONS[this.language].OverwriteFileQuestion} "${ + this.existingFileName + }"?`, + }); + + new Setting(contentEl) + .addButton((btn) => + btn + .setButtonText(TRANSLATIONS[this.language].No) + .setCta() + .onClick(() => { + this.close(); + this.onSubmitHandler(false); + }) + ) + .addButton((btn) => + btn + .setButtonText(TRANSLATIONS[this.language].Yes) + .setCta() + .onClick(() => { + this.close(); + this.onSubmitHandler(true); + }) + ); + } + + public onClose() { + const { contentEl } = this; + contentEl.empty(); } } diff --git a/manifest.json b/manifest.json index aae4841..0932e58 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "advanced-merger", "name": "Advanced Merger", - "version": "1.2.0", + "version": "1.3.0", "minAppVersion": "0.15.0", "description": "Merge a folder of notes for easier export.", "author": "Anto Keinänen", diff --git a/package.json b/package.json index 3309536..e10af10 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,21 @@ { - "name": "obsidian-sample-plugin", - "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "name": "obsidian-advanced-merger", + "version": "1.3.0", + "description": "Merge a folder of notes for easier export. This plugin is designed for Obsidian (https://obsidian.md).", + "homepage": "https://github.com/antoKeinanen/obsidian-advanced-merger", + "contributors": [ + { + "name": "Dmitry Besedin", + "email": "endarvaluk@gmail.com", + "url": "https://github.com/EndarValuk/" + } + ], + "funding": [ + { + "type": "individual", + "url": "https://www.buymeacoffee.com/antokeinanen" + } + ], "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", @@ -10,7 +24,10 @@ "prepare": "husky install" }, "keywords": [], - "author": "", + "author": { + "name": "Anto Keinänen", + "url": "https://github.com/antoKeinanen/" + }, "license": "MIT", "devDependencies": { "@types/node": "^16.11.6",