mirror of
https://github.com/antokeinanen/obsidian-advanced-merger.git
synced 2026-07-22 07:40:24 +00:00
561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
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];
|
||
}
|
||
}
|