This commit is contained in:
Oleg 2022-08-27 10:12:04 +03:00
parent 866d3b8f0d
commit 2eef8f00b1
12 changed files with 4239 additions and 163 deletions

View File

@ -2,9 +2,9 @@
root = true root = true
[*] [*]
charset = utf-8 indent_style = space
indent_size = 2
end_of_line = lf end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

15
.prettierrc.json Normal file
View File

@ -0,0 +1,15 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "auto",
"htmlWhitespaceSensitivity": "css",
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 80,
"proseWrap": "never",
"quoteProps": "preserve",
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all"
}

35
errors/errors.ts Normal file
View File

@ -0,0 +1,35 @@
import { Notice } from 'obsidian';
export class TemplaterError extends Error {
constructor(msg: string, public console_msg?: string) {
super(msg);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export function log_error(e: Error | TemplaterError): void {
const notice = new Notice('', 8000);
if (e instanceof TemplaterError && e.console_msg) {
// TODO: Find a better way for this
// @ts-ignore
notice.noticeEl.innerHTML = `<b>Templater Error</b>:<br/>${e.message}<br/>Check console for more information`;
console.error(`Templater Error:`, e.message, '\n', e.console_msg);
} else {
// @ts-ignore
notice.noticeEl.innerHTML = `<b>Templater Error</b>:<br/>${e.message}`;
}
}
export function errorWrapperSync<T>(fn: () => T, msg: string): T {
try {
return fn();
} catch (e) {
log_error(new TemplaterError(msg, e.message));
return null;
}
}
export enum FileSuggestMode {
TemplateFiles,
ScriptFiles,
}

43
files/files.ts Normal file
View File

@ -0,0 +1,43 @@
import {
App,
normalizePath,
TAbstractFile,
TFile,
TFolder,
Vault,
} from 'obsidian';
import { TemplaterError } from '../errors/errors';
export function resolveFolder(app: App, folder_str: string): TFolder {
folder_str = normalizePath(folder_str);
const folder = app.vault.getAbstractFileByPath(folder_str);
if (!folder) {
throw new TemplaterError(`Folder "${folder_str}" doesn't exist`);
}
if (!(folder instanceof TFolder)) {
throw new TemplaterError(`${folder_str} is a file, not a folder`);
}
return folder;
}
export function getFilesFromTheFolder(
app: App,
folder_str: string,
): Array<TFile> {
const folder = resolveFolder(app, folder_str);
const files: Array<TFile> = [];
Vault.recurseChildren(folder, (file: TAbstractFile) => {
if (file instanceof TFile) {
files.push(file);
}
});
files.sort((a, b) => {
return a.basename.localeCompare(b.basename);
});
return files;
}

307
main.ts
View File

@ -1,137 +1,236 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; import {
App,
// Remember to rename these classes and interfaces! FileManager,
Plugin,
PluginSettingTab,
Setting,
TAbstractFile,
TFile,
} from 'obsidian';
import { FolderSuggest } from './suggest/folderSuggest';
interface MyPluginSettings { interface MyPluginSettings {
mySetting: string; folderName: string;
fileNames: TFile[];
existingSymbol: string;
replacePattern: string;
} }
const DEFAULT_SETTINGS: MyPluginSettings = { const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default' folderName: '',
} fileNames: [],
existingSymbol: '',
replacePattern: '',
};
export default class MyPlugin extends Plugin { export default class MyPlugin extends Plugin {
settings: MyPluginSettings; settings: MyPluginSettings;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
this.addSettingTab(new BulkRenameSettingsTab(this.app, this));
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
} }
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
onunload() {
}
async loadSettings() { async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
} }
async saveSettings() { async saveSettings() {
await this.saveData(this.settings); await this.saveData(this.settings);
} }
} }
class SampleModal extends Modal { type State = {
constructor(app: App) { previewScroll: number;
super(app); filesScroll: number;
} };
onOpen() { class BulkRenameSettingsTab extends PluginSettingTab {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin; plugin: MyPlugin;
state: State;
constructor(app: App, plugin: MyPlugin) { constructor(app: App, plugin: MyPlugin) {
super(app, plugin); super(app, plugin);
this.state = {
previewScroll: 0,
filesScroll: 0,
};
this.plugin = plugin; this.plugin = plugin;
} }
display(): void { display() {
const {containerEl} = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
containerEl.createEl('h2', { text: 'General Settings' });
this.renderFileLocation();
this.renderReplaceSymbol();
this.renderFilesAndPreview();
this.renderRenameFiles();
}
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'}); renderReplaceSymbol() {
const { settings } = this.plugin;
new Setting(this.containerEl)
.setName('Replace pattern')
.setDesc('Files in this folder will be available renamed.')
.addText((textComponent) => {
textComponent.setValue(settings.existingSymbol);
textComponent.setPlaceholder('existing symbols');
textComponent.onChange((newValue) => {
settings.existingSymbol = newValue;
this.plugin.saveSettings();
});
})
.addText((textComponent) => {
textComponent.setValue(settings.replacePattern);
textComponent.setPlaceholder('replace with');
textComponent.onChange((newValue) => {
settings.replacePattern = newValue;
this.plugin.saveSettings();
});
})
.addButton((button) => {
button.setButtonText('Preview');
button.onClick(() => {
this.display();
});
});
}
renderFileLocation() {
const { settings } = this.plugin;
new Setting(this.containerEl)
.setName('Folder location')
.setDesc('Files in this folder will be available renamed.')
.addSearch((cb) => {
new FolderSuggest(this.app, cb.inputEl);
cb.setPlaceholder('Example: folder1/')
.setValue(settings.folderName)
.onChange((newFolder) => {
settings.folderName = newFolder;
settings.fileNames = [...getObsidianFiles(this.app, newFolder)];
this.plugin.saveSettings();
});
// @ts-ignore
cb.containerEl.addClass('templater_search');
})
.addButton((button) => {
button.setButtonText('Refresh');
button.onClick(() => {
this.display();
});
});
}
renderFilesAndPreview() {
const { settings } = this.plugin;
let existingFilesTextArea: HTMLTextAreaElement;
let replacedPreviewTextArea: HTMLTextAreaElement;
new Setting(this.containerEl)
.setName('files within the folder')
.setDesc(`Total Files: ${settings.fileNames.length}`)
.addTextArea((text) => {
existingFilesTextArea = text.inputEl;
const value = getRenderedFileNames(this.plugin);
text.setValue(value);
text.setDisabled(true);
const previewLabel = createPreviewElement();
text.inputEl.insertAdjacentElement('afterend', previewLabel);
})
.addTextArea((text) => {
replacedPreviewTextArea = text.inputEl;
const value = getRenderedFileNamesReplaced(this.plugin);
text.setValue(value);
text.setDisabled(true);
})
.then((setting) => {
syncScrolls(existingFilesTextArea, replacedPreviewTextArea, this.state);
});
}
new Setting(containerEl) renderRenameFiles() {
.setName('Setting #1') const { settings } = this.plugin;
.setDesc('It\'s a secret') new Setting(this.containerEl)
.addText(text => text .setName('Replace pattern')
.setPlaceholder('Enter your secret') .setDesc('Files in this folder will be available renamed.')
.setValue(this.plugin.settings.mySetting) .addButton((button) => {
.onChange(async (value) => { button.setButtonText('Rename');
console.log('Secret: ' + value); button.onClick(() => {
this.plugin.settings.mySetting = value; const { replacePattern, existingSymbol } = this.plugin.settings;
await this.plugin.saveSettings(); const firstFile = this.plugin.settings.fileNames[0];
})); console.log(firstFile);
});
})
.addText((cb) => {});
const fileManager = new FileManager();
this.plugin.settings.fileNames;
} }
} }
const getObsidianFiles = (app: App, folderName: string) => {
const abstractFiles = app.vault.getAllLoadedFiles();
const files = [] as TFile[];
abstractFiles.forEach((file) => {
if (file instanceof TFile && file.parent.name.includes(folderName)) {
files.push(file);
}
});
return sortByname(files);
};
const sortByname = (files: TFile[]) => {
return files.sort((a, b) => a.name.localeCompare(b.name));
};
const getRenderedFileNames = (plugin: MyPlugin) => {
return prepareFileNameString(plugin.settings.fileNames);
};
const prepareFileNameString = (filesNames: TFile[]) => {
let value = '';
filesNames.forEach((fileName, index) => {
const isLast = index + 1 === filesNames.length;
if (isLast) {
return (value += fileName.name);
}
value += fileName.name + '\r\n';
});
return value;
};
const getRenderedFileNamesReplaced = (plugin: MyPlugin) => {
const { fileNames, replacePattern, existingSymbol } = plugin.settings;
const newFiles = fileNames.map((file) => {
return {
...file,
name: file.name.replaceAll(existingSymbol, replacePattern),
};
});
return prepareFileNameString(newFiles);
};
const createPreviewElement = () => {
const previewLabel = window.document.createElement('span');
previewLabel.className = 'previewLabel';
previewLabel.textContent = 'Preview';
previewLabel.style.margin = '0 20px';
return previewLabel;
};
const syncScrolls = (
existingFilesArea: HTMLTextAreaElement,
previewArea: HTMLTextAreaElement,
state: State,
) => {
existingFilesArea.addEventListener('scroll', (event) => {
const target = event.target;
if (target.scrollTop !== state.previewScroll) {
previewArea.scrollTop = target.scrollTop;
state.previewScroll = target.scrollTop;
}
});
previewArea.addEventListener('scroll', (event) => {
const target = event.target;
if (target.scrollTop !== state.filesScroll) {
existingFilesArea.scrollTop = target.scrollTop;
state.filesScroll = target.scrollTop;
}
});
};

View File

@ -1,9 +1,9 @@
{ {
"id": "obsidian-sample-plugin", "id": "obsidian-bulk-rename-plugin",
"name": "Sample Plugin", "name": "Bulk Rename",
"version": "1.0.0", "version": "1.0.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.", "description": "Purpose of this plugin rename files based on pattern",
"author": "Obsidian", "author": "Obsidian",
"authorUrl": "https://obsidian.md", "authorUrl": "https://obsidian.md",
"isDesktopOnly": false "isDesktopOnly": false

3597
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "obsidian-sample-plugin", "name": "obsidian-bulk-rename",
"version": "1.0.0", "version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)", "description": "Purpose of this plugin rename files based on pattern",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
@ -11,6 +11,9 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": {
"@popperjs/core": "^2.11.2"
},
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/eslint-plugin": "5.29.0",
@ -19,6 +22,7 @@
"esbuild": "0.14.47", "esbuild": "0.14.47",
"obsidian": "latest", "obsidian": "latest",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "typescript": "4.7.4",
"prettier": "2.7.1"
} }
} }

60
suggest/fileSuggest.ts Normal file
View File

@ -0,0 +1,60 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { App, TAbstractFile, TextAreaComponent, TFile } from 'obsidian';
import { TextInputSuggest } from './suggest';
import TemplaterPlugin from 'main';
import { errorWrapperSync } from '../errors/errors';
import { getFilesFromTheFolder } from '../files/files';
export class FileSuggest extends TextInputSuggest<TFile> {
constructor(
public app: App,
public inputEl: any,
private plugin: TemplaterPlugin,
) {
super(app, inputEl);
}
getFolder(): string {
return this.plugin.settings.mySetting;
}
get_error_msg(): string {
return `Templates folder doesn't exist`;
}
getSuggestions(input_str: string): TFile[] {
const all_files = errorWrapperSync(
() => getFilesFromTheFolder(this.app, this.getFolder()),
this.get_error_msg(),
);
if (!all_files) {
return [];
}
const files: TFile[] = [];
const lower_input_str = input_str.toLowerCase();
all_files.forEach((file: TAbstractFile) => {
if (
file instanceof TFile &&
file.extension === 'md' &&
file.path.toLowerCase().contains(lower_input_str)
) {
files.push(file);
}
});
return files;
}
renderSuggestion(file: TFile, el: HTMLElement): void {
el.setText(file.path);
}
selectSuggestion(file: TFile): void {
this.inputEl.value = file.path;
this.inputEl.trigger('input');
this.close();
}
}

33
suggest/folderSuggest.ts Normal file
View File

@ -0,0 +1,33 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { TAbstractFile, TFolder } from 'obsidian';
import { TextInputSuggest } from './suggest';
export class FolderSuggest extends TextInputSuggest<TFolder> {
getSuggestions(inputStr: string): TFolder[] {
const abstractFiles = this.app.vault.getAllLoadedFiles();
const folders: TFolder[] = [];
const lowerCaseInputStr = inputStr.toLowerCase();
abstractFiles.forEach((folder: TAbstractFile) => {
if (
folder instanceof TFolder &&
folder.path.toLowerCase().contains(lowerCaseInputStr)
) {
folders.push(folder);
}
});
return folders;
}
renderSuggestion(file: TFolder, el: HTMLElement): void {
el.setText(file.path);
}
selectSuggestion(file: TFolder): void {
this.inputEl.value = file.path;
this.inputEl.trigger('input');
this.close();
}
}

197
suggest/suggest.ts Normal file
View File

@ -0,0 +1,197 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { App, ISuggestOwner, Scope } from 'obsidian';
import { createPopper, Instance as PopperInstance } from '@popperjs/core';
const wrapAround = (value: number, size: number): number => {
return ((value % size) + size) % size;
};
class Suggest<T> {
private owner: ISuggestOwner<T>;
private values: T[];
private suggestions: HTMLDivElement[];
private selectedItem: number;
private containerEl: HTMLElement;
constructor(owner: ISuggestOwner<T>, containerEl: HTMLElement, scope: Scope) {
this.owner = owner;
this.containerEl = containerEl;
containerEl.on(
'click',
'.suggestion-item',
this.onSuggestionClick.bind(this),
);
containerEl.on(
'mousemove',
'.suggestion-item',
this.onSuggestionMouseover.bind(this),
);
scope.register([], 'ArrowUp', (event) => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem - 1, true);
return false;
}
});
scope.register([], 'ArrowDown', (event) => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem + 1, true);
return false;
}
});
scope.register([], 'Enter', (event) => {
if (!event.isComposing) {
this.useSelectedItem(event);
return false;
}
});
}
onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void {
event.preventDefault();
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
this.useSelectedItem(event);
}
onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void {
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
}
setSuggestions(values: T[]) {
this.containerEl.empty();
const suggestionEls: HTMLDivElement[] = [];
values.forEach((value) => {
const suggestionEl = this.containerEl.createDiv('suggestion-item');
this.owner.renderSuggestion(value, suggestionEl);
suggestionEls.push(suggestionEl);
});
this.values = values;
this.suggestions = suggestionEls;
this.setSelectedItem(0, false);
}
useSelectedItem(event: MouseEvent | KeyboardEvent) {
const currentValue = this.values[this.selectedItem];
if (currentValue) {
this.owner.selectSuggestion(currentValue, event);
}
}
setSelectedItem(selectedIndex: number, scrollIntoView: boolean) {
const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length);
const prevSelectedSuggestion = this.suggestions[this.selectedItem];
const selectedSuggestion = this.suggestions[normalizedIndex];
prevSelectedSuggestion?.removeClass('is-selected');
selectedSuggestion?.addClass('is-selected');
this.selectedItem = normalizedIndex;
if (scrollIntoView) {
selectedSuggestion.scrollIntoView(false);
}
}
}
export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
protected app: App;
protected inputEl: HTMLInputElement | HTMLTextAreaElement;
private popper: PopperInstance;
private scope: Scope;
private suggestEl: HTMLElement;
private suggest: Suggest<T>;
constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) {
this.app = app;
this.inputEl = inputEl;
this.scope = new Scope();
this.suggestEl = createDiv('suggestion-container');
const suggestion = this.suggestEl.createDiv('suggestion');
this.suggest = new Suggest(this, suggestion, this.scope);
this.scope.register([], 'Escape', this.close.bind(this));
this.inputEl.addEventListener('input', this.onInputChanged.bind(this));
this.inputEl.addEventListener('focus', this.onInputChanged.bind(this));
this.inputEl.addEventListener('blur', this.close.bind(this));
this.suggestEl.on(
'mousedown',
'.suggestion-container',
(event: MouseEvent) => {
event.preventDefault();
},
);
}
onInputChanged(): void {
const inputStr = this.inputEl.value;
const suggestions = this.getSuggestions(inputStr);
if (!suggestions) {
this.close();
return;
}
if (suggestions.length > 0) {
this.suggest.setSuggestions(suggestions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.open((<any>this.app).dom.appContainerEl, this.inputEl);
} else {
this.close();
}
}
open(container: HTMLElement, inputEl: HTMLElement): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any>this.app).keymap.pushScope(this.scope);
container.appendChild(this.suggestEl);
this.popper = createPopper(inputEl, this.suggestEl, {
placement: 'bottom-start',
modifiers: [
{
name: 'sameWidth',
enabled: true,
fn: ({ state, instance }) => {
// Note: positioning needs to be calculated twice -
// first pass - positioning it according to the width of the popper
// second pass - position it with the width bound to the reference element
// we need to early exit to avoid an infinite loop
const targetWidth = `${state.rects.reference.width}px`;
if (state.styles.popper.width === targetWidth) {
return;
}
state.styles.popper.width = targetWidth;
instance.update();
},
phase: 'beforeWrite',
requires: ['computeStyles'],
},
],
});
}
close(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any>this.app).keymap.popScope(this.scope);
this.suggest.setSuggestions([]);
if (this.popper) this.popper.destroy();
this.suggestEl.detach();
}
abstract getSuggestions(inputStr: string): T[];
abstract renderSuggestion(item: T, el: HTMLElement): void;
abstract selectSuggestion(item: T): void;
}

View File

@ -11,14 +11,7 @@
"importHelpers": true, "importHelpers": true,
"isolatedModules": true, "isolatedModules": true,
"strictNullChecks": true, "strictNullChecks": true,
"lib": [ "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021"]
"DOM",
"ES5",
"ES6",
"ES7"
]
}, },
"include": [ "include": ["**/*.ts"]
"**/*.ts"
]
} }