obsidian#Plugin TypeScript Examples

The following examples show how to use obsidian#Plugin. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: main.ts    From obsidian-emoji-shortcodes with MIT License 6 votes vote down vote up
export default class EmojiShortcodesPlugin extends Plugin {

	settings: EmojiPluginSettings;

	async onload() {
		await this.loadSettings();
		this.addSettingTab(new EmojiPluginSettingTab(this.app, this));
		this.registerEditorSuggest(new EmojiSuggester(this));

		this.registerMarkdownPostProcessor(EmojiMarkdownPostProcessor.emojiProcessor);
		//this.registerMarkdownPostProcessor(DefinitionListPostProcessor.definitionListProcessor);
	}

	async loadSettings() {
		this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
	}

	async saveSettings() {
		await this.saveData(this.settings);
	}
}
Example #2
Source File: main.ts    From obsidian-ReadItLater with MIT License 6 votes vote down vote up
export default class ReadItLaterPlugin extends Plugin {
    settings: ReadItLaterSettings;

    private parsers: Parser[];

    async onload(): Promise<void> {
        await this.loadSettings();
        this.parsers = [
            new YoutubeParser(this.app, this.settings),
            new TwitterParser(this.app, this.settings),
            new WebsiteParser(this.app, this.settings),
            new TextSnippetParser(this.app, this.settings),
        ];

        addIcon('read-it-later', clipboardIcon);

        this.addRibbonIcon('read-it-later', 'ReadItLater: Save clipboard', async () => {
            await this.processClipboard();
        });

        this.addCommand({
            id: 'save-clipboard-to-notice',
            name: 'Save clipboard',
            callback: async () => {
                await this.processClipboard();
            },
        });

        this.addSettingTab(new ReadItLaterSettingsTab(this.app, this));
    }

    async loadSettings(): Promise<void> {
        this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
    }

    async saveSettings(): Promise<void> {
        await this.saveData(this.settings);
    }

    async processClipboard(): Promise<void> {
        const clipboardContent = await navigator.clipboard.readText();

        for (const parser of this.parsers) {
            if (parser.test(clipboardContent)) {
                const note = await parser.prepareNote(clipboardContent);
                await this.writeFile(note.fileName, note.content);
                break;
            }
        }
    }

    async writeFile(fileName: string, content: string): Promise<void> {
        let filePath;
        fileName = normalizeFilename(fileName);
        await checkAndCreateFolder(this.app.vault, this.settings.inboxDir);

        if (this.settings.inboxDir) {
            filePath = normalizePath(`${this.settings.inboxDir}/${fileName}`);
        } else {
            filePath = normalizePath(`/${fileName}`);
        }

        if (await this.app.vault.adapter.exists(filePath)) {
            new Notice(`${fileName} already exists!`);
        } else {
            const newFile = await this.app.vault.create(filePath, content);
            if (this.settings.openNewNote) {
                this.app.workspace.getLeaf(false).openFile(newFile);
            }
            new Notice(`${fileName} created successful`);
        }
    }
}
Example #3
Source File: index.ts    From folder-note-core with MIT License 5 votes vote down vote up
isPluginEnabled = (plugin: Plugin) =>
  plugin.app.plugins.enabledPlugins.has("folder-note-core")
Example #4
Source File: main.ts    From obsidian-checklist-plugin with MIT License 5 votes vote down vote up
export default class TodoPlugin extends Plugin {
  private settings: TodoSettings

  get view() {
    return this.app.workspace.getLeavesOfType(TODO_VIEW_TYPE)[0]?.view as TodoListView
  }

  async onload() {
    await this.loadSettings()

    this.addSettingTab(new TodoSettingTab(this.app, this))
    this.addCommand({
      id: "show-checklist-view",
      name: "Open View",
      callback: () => {
        const views = this.app.workspace.getLeavesOfType(TODO_VIEW_TYPE)
        if (views.length === 0)
          this.app.workspace.getRightLeaf(false).setViewState({
            type: TODO_VIEW_TYPE,
            active: true,
          })
        else views[0].setViewState({ active: true, type: TODO_VIEW_TYPE })
      },
    })
    this.addCommand({
      id: "refresh-checklist-view",
      name: "Refresh List",
      callback: () => {
        this.view.refresh()
      },
    })
    this.registerView(TODO_VIEW_TYPE, (leaf) => {
      const newView = new TodoListView(leaf, this)
      return newView
    })

    if (this.app.workspace.layoutReady) this.initLeaf()
    else this.app.workspace.onLayoutReady(() => this.initLeaf())
  }

  initLeaf(): void {
    if (this.app.workspace.getLeavesOfType(TODO_VIEW_TYPE).length) return

    this.app.workspace.getRightLeaf(false).setViewState({
      type: TODO_VIEW_TYPE,
      active: true,
    })
  }

  async onunload() {
    this.app.workspace.getLeavesOfType(TODO_VIEW_TYPE)[0]?.detach()
  }

  async loadSettings() {
    const loadedData = await this.loadData()
    this.settings = { ...DEFAULT_SETTINGS, ...loadedData }
  }

  async updateSettings(updates: Partial<TodoSettings>) {
    Object.assign(this.settings, updates)
    await this.saveData(this.settings)
    const onlyRepaintWhenChanges = ["autoRefresh", "lookAndFeel", "_collapsedSections"]
    const onlyReGroupWhenChanges = [
      "subGroups",
      "groupBy",
      "sortDirectionGroups",
      "sortDirectionSubGroups",
      "sortDirectionItems",
    ]
    if (onlyRepaintWhenChanges.includes(Object.keys(updates)[0])) this.view.rerender()
    else this.view.refresh(!onlyReGroupWhenChanges.includes(Object.keys(updates)[0]))
  }

  getSettingValue<K extends keyof TodoSettings>(setting: K): TodoSettings[K] {
    return this.settings[setting]
  }
}
Example #5
Source File: main.ts    From obsidian-switcher-plus with GNU General Public License v3.0 5 votes vote down vote up
export default class SwitcherPlusPlugin extends Plugin {
  public options: SwitcherPlusSettings;

  async onload(): Promise<void> {
    const options = new SwitcherPlusSettings(this);
    await options.loadSettings();
    this.options = options;

    this.addSettingTab(new SwitcherPlusSettingTab(this.app, this, options));

    this.registerCommand('switcher-plus:open', 'Open', Mode.Standard);
    this.registerCommand(
      'switcher-plus:open-editors',
      'Open in Editor Mode',
      Mode.EditorList,
    );
    this.registerCommand(
      'switcher-plus:open-symbols',
      'Open in Symbol Mode',
      Mode.SymbolList,
    );
    this.registerCommand(
      'switcher-plus:open-workspaces',
      'Open in Workspaces Mode',
      Mode.WorkspaceList,
    );
    this.registerCommand(
      'switcher-plus:open-headings',
      'Open in Headings Mode',
      Mode.HeadingsList,
    );
    this.registerCommand(
      'switcher-plus:open-starred',
      'Open in Starred Mode',
      Mode.StarredList,
    );
    this.registerCommand(
      'switcher-plus:open-commands',
      'Open in Commands Mode',
      Mode.CommandList,
    );

    this.registerCommand(
      'switcher-plus:open-related-items',
      'Open in Related Items Mode',
      Mode.RelatedItemsList,
    );
  }

  registerCommand(id: string, name: string, mode: Mode): void {
    this.addCommand({
      id,
      name,
      hotkeys: [],
      checkCallback: (checking) => {
        // modal needs to be created dynamically (same as system switcher)
        // as system options are evaluated in the modal constructor
        const modal = createSwitcherPlus(this.app, this);
        if (modal) {
          if (!checking) {
            modal.openInMode(mode);
          }

          return true;
        }

        return false;
      },
    });
  }
}
Example #6
Source File: main.tsx    From obsidian-chartsview-plugin with MIT License 5 votes vote down vote up
export default class ChartsViewPlugin extends Plugin {
	settings: ChartsViewPluginSettings;

	async ChartsViewProcessor(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) {
		ReactDOM.unmountComponentAtNode(el);
		try {
			const chartProps = await parseConfig(source, this, ctx.sourcePath);
			ReactDOM.render(
				<Chart {...chartProps} />,
				el
			);
		} catch (e) {
			ReactDOM.render(
				<div style={{ color: 'var(--text-title-h1)' }}>{e.toString()}</div>,
				el
			);
		}
	}

	async onload() {
		try {
			await this.loadSettings();
			this.addSettingTab(new ChartsViewSettingTab(this.app, this));
			this.registerMarkdownCodeBlockProcessor("chartsview", this.ChartsViewProcessor.bind(this));
			
			this.addCommand({
				id: 'insert-chartsview-template',
				name: 'Insert Template',
				editorCallback: (editor) => {
					new ChartTemplateSuggestModal(this.app, editor).open();
				}
			});

			if (Platform.isDesktopApp) {
				this.addCommand({
					id: `import-chartsview-data-csv`,
					name: `Import data from external CSV file`,
					editorCallback: async (editor) => {
						const file = await fileDialog({ accept: '.csv', strict: true });
						const content = await file.text();
						const records = parseCsv(content);

						insertEditor(
							editor,
							yaml.dump(records, { quotingType: '"', noRefs: true })
								.replace(/\n/g, "\n" + " ".repeat(editor.getCursor().ch))
						);
					}
				});
			}
		} catch (error) {
			console.log(`Load error. ${error}`);
		}

		try {
			this.registerExtensions([CSV_FILE_EXTENSION], VIEW_TYPE_CSV);
		} catch (error) {
			console.log(`Existing file extension ${CSV_FILE_EXTENSION}`);
		}
		console.log('Loaded Charts View plugin');
	}

	onunload() {
		console.log('Unloading Charts View plugin');
	}

	async loadSettings() {
		this.settings = Object.assign(DEFAULT_SETTINGS, await this.loadData());
	}

	async saveSettings() {
		await this.saveData(this.settings);
	}
}
Example #7
Source File: main.ts    From obsidian-kroki with MIT License 5 votes vote down vote up
export default class KrokiPlugin extends Plugin {
    settings: KrokiSettings;

    svgProcessor = async (diagType: string, source: string, el: HTMLElement, _: MarkdownPostProcessorContext) => {
        const dest = document.createElement('div');
        const urlPrefix = this.settings.server_url + diagType + "/svg/";
        source = source.replace(/&nbsp;/gi, " ");

        // encode the source 
        // per: https://docs.kroki.io/kroki/setup/encode-diagram/#javascript
        const data = new TextEncoder().encode(source);
        const compressed = pako.deflate(data, { level: 9 });
        const encodedSource = Buffer.from(compressed)
            .toString('base64')
            .replace(/\+/g, '-').replace(/\//g, '_');

        const img = document.createElement("img");
        img.src = urlPrefix + encodedSource;
        img.useMap = "#" + encodedSource;

        const result = await fetch(urlPrefix + encodedSource, {
            method: "GET"
        });

        if (result.ok) {
            dest.innerHTML = await result.text();
            dest.children[0].setAttr("name", encodedSource);
        }
        el.appendChild(dest);
    };

    async onload(): Promise<void> {
        console.log('loading plugin kroki');
        await this.loadSettings();

        this.addSettingTab(new KrokiSettingsTab(this.app, this));

        // register a processor for each of the enabled diagram types
        for (let diagramType of this.settings.diagramTypes) {
            if (diagramType.enabled === true) {
                console.log("kroki is     enabling: " + diagramType.prettyName);
                this.registerMarkdownCodeBlockProcessor(diagramType.obsidianBlockName,
                    (source: string, el: HTMLElement, _: MarkdownPostProcessorContext) => {
                        this.svgProcessor(diagramType.krokiBlockName, source, el, _)  // this name is used to build the url, so it must be the kroki one
                    })
            } else {
                console.log("kroki is not enabling:", diagramType.prettyName);
            }
        }

    }

    onunload(): void {
        console.log('unloading plugin kroki');
    }

    async loadSettings(): Promise<void> {
        this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
    }

    async saveSettings(): Promise<void> {
        await this.saveData(this.settings);
    }
}
Example #8
Source File: index.ts    From folder-note-core with MIT License 5 votes vote down vote up
registerApi = (
  plugin: Plugin,
  callback: (api: FolderNoteAPI) => void,
): FolderNoteAPI | undefined => {
  plugin.app.vault.on("folder-note:api-ready", callback);
  return getApi(plugin);
}
Example #9
Source File: main.ts    From Templater with GNU Affero General Public License v3.0 5 votes vote down vote up
export default class TemplaterPlugin extends Plugin {
    public settings: Settings;
    public templater: Templater;
    public event_handler: EventHandler;
    public command_handler: CommandHandler;
    public fuzzy_suggester: FuzzySuggester;
    public editor_handler: Editor;

    async onload(): Promise<void> {
        await this.load_settings();

        this.templater = new Templater(this.app, this);
        await this.templater.setup();

        this.editor_handler = new Editor(this.app, this);
        await this.editor_handler.setup();

        this.fuzzy_suggester = new FuzzySuggester(this.app, this);

        this.event_handler = new EventHandler(
            this.app,
            this,
            this.templater,
            this.settings
        );
        this.event_handler.setup();

        this.command_handler = new CommandHandler(this.app, this);
        this.command_handler.setup();

        addIcon("templater-icon", ICON_DATA);
        this.addRibbonIcon("templater-icon", "Templater", async () => {
            this.fuzzy_suggester.insert_template();
        });

        this.addSettingTab(new TemplaterSettingTab(this.app, this));

        // Files might not be created yet
        this.app.workspace.onLayoutReady(() => {
            this.templater.execute_startup_scripts();
        });
    }

    async save_settings(): Promise<void> {
        await this.saveData(this.settings);
    }

    async load_settings(): Promise<void> {
        this.settings = Object.assign(
            {},
            DEFAULT_SETTINGS,
            await this.loadData()
        );
    }
}
Example #10
Source File: main.ts    From obsidian-ics with MIT License 5 votes vote down vote up
export default class ICSPlugin extends Plugin {
	data: ICSSettings;

	async addCalendar(calendar: Calendar): Promise<void> {
        this.data.calendars = {
            ...this.data.calendars,
			[calendar.icsName]: calendar 
        };
        await this.saveSettings();
    }

	async removeCalendar(calendar: Calendar) {
        if (this.data.calendars[calendar.icsName]) {
            delete this.data.calendars[calendar.icsName];
        }
        await this.saveSettings();
    }

	async onload() {
		console.log('loading ics plugin');
		await this.loadSettings();
		this.addSettingTab(new ICSSettingsTab(this.app, this));
		this.addCommand({
			id: "import_events",
			name: "import events",
			hotkeys: [{
				modifiers: ["Alt", "Shift"],
				key: 'T',
			}, ],
			callback: async () => {
				const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
				const fileDate = getDateFromFile(activeView.file, "day").format("YYYY-MM-DD");
				var mdArray: string[] = [];

				for (const calendar in this.data.calendars) {
					const calendarSetting = this.data.calendars[calendar];
					console.log(calendarSetting);
					var icsArray: any[] = [];
					var icsArray = parseIcs(await request({
						url: calendarSetting.icsUrl
					}));
					const todayEvents = filterMatchingEvents(icsArray, fileDate);
					console.log(todayEvents);
	
					todayEvents.forEach((e) => {
						mdArray.push(`- [ ] ${moment(e.start).format("HH:mm")} ${calendarSetting.icsName} ${e.summary} ${e.location}`.trim());
					});
				}

				activeView.editor.replaceRange(mdArray.sort().join("\n"), activeView.editor.getCursor());
			}
		});
	}

	onunload() {
		console.log('unloading ics plugin');
	}

	async loadSettings() {
		this.data = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
	}

	async saveSettings() {
		await this.saveData(this.data);
	}
}
Example #11
Source File: main.ts    From obsidian-system-dark-mode with MIT License 5 votes vote down vote up
export default class SystemDarkMode extends Plugin {

  async onload() {

    // Watch for system changes to color theme 

    let media = window.matchMedia('(prefers-color-scheme: dark)');

    let callback = () => {
      if (media.matches) {
        console.log('Dark mode active');
        this.updateDarkStyle()

      } else {
        console.log('Light mode active');
        this.updateLightStyle()
      }
    }
    media.addEventListener('change', callback);

    // Remove listener when we unload

    this.register(() => media.removeEventListener('change', callback));
    
    callback();
  }

  onunload() {
    console.log('System color scheme checking is turned off');
  }

  refreshSystemTheme() {
    const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches

    if(isDarkMode){
        console.log('Dark mode active');
        this.updateDarkStyle()

      } else {
        console.log('Light mode active');
        this.updateLightStyle()
      }
  }

  updateDarkStyle() {
    // @ts-ignore
    this.app.setTheme('obsidian');
    // @ts-ignore
    this.app.vault.setConfig('theme', 'obsidian');
    this.app.workspace.trigger('css-change');
  }

  updateLightStyle() {
    // @ts-ignore
    this.app.setTheme('moonstone');
    // @ts-ignore
    this.app.vault.setConfig('theme', 'moonstone');
    this.app.workspace.trigger('css-change');
  }

}
Example #12
Source File: main.ts    From obsidian-tracker with MIT License 4 votes vote down vote up
export default class Tracker extends Plugin {
    settings: TrackerSettings;

    async onload() {
        console.log("loading obsidian-tracker plugin");

        await this.loadSettings();

        this.addSettingTab(new TrackerSettingTab(this.app, this));

        this.registerMarkdownCodeBlockProcessor(
            "tracker",
            this.postprocessor.bind(this)
        );

        this.addCommand({
            id: "add-line-chart-tracker",
            name: "Add Line Chart Tracker",
            callback: () => this.addCodeBlock(GraphType.Line),
        });

        this.addCommand({
            id: "add-bar-chart-tracker",
            name: "Add Bar Chart Tracker",
            callback: () => this.addCodeBlock(GraphType.Bar),
        });

        this.addCommand({
            id: "add-summary-tracker",
            name: "Add Summary Tracker",
            callback: () => this.addCodeBlock(GraphType.Summary),
        });
    }

    async loadSettings() {
        this.settings = Object.assign(
            {},
            DEFAULT_SETTINGS,
            await this.loadData()
        );
    }

    async saveSettings() {
        await this.saveData(this.settings);
    }

    renderErrorMessage(message: string, canvas: HTMLElement, el: HTMLElement) {
        rendering.renderErrorMessage(canvas, message);
        el.appendChild(canvas);
        return;
    }

    onunload() {
        console.log("unloading obsidian-tracker plugin");
    }

    getFilesInFolder(
        folder: TFolder,
        includeSubFolders: boolean = true
    ): TFile[] {
        let files: TFile[] = [];

        for (let item of folder.children) {
            if (item instanceof TFile) {
                if (item.extension === "md") {
                    files.push(item);
                }
            } else {
                if (item instanceof TFolder && includeSubFolders) {
                    files = files.concat(this.getFilesInFolder(item));
                }
            }
        }

        return files;
    }

    async getFiles(
        files: TFile[],
        renderInfo: RenderInfo,
        includeSubFolders: boolean = true
    ) {
        if (!files) return;

        let folderToSearch = renderInfo.folder;
        let useSpecifiedFilesOnly = renderInfo.specifiedFilesOnly;
        let specifiedFiles = renderInfo.file;
        let filesContainsLinkedFiles = renderInfo.fileContainsLinkedFiles;
        let fileMultiplierAfterLink = renderInfo.fileMultiplierAfterLink;

        // Include files in folder
        // console.log(useSpecifiedFilesOnly);
        if (!useSpecifiedFilesOnly) {
            let folder = this.app.vault.getAbstractFileByPath(
                normalizePath(folderToSearch)
            );
            if (folder && folder instanceof TFolder) {
                let folderFiles = this.getFilesInFolder(folder);
                for (let file of folderFiles) {
                    files.push(file);
                }
            }
        }

        // Include specified file
        // console.log(specifiedFiles);
        for (let filePath of specifiedFiles) {
            let path = filePath;
            if (!path.endsWith(".md")) {
                path += ".md";
            }
            path = normalizePath(path);
            // console.log(path);

            let file = this.app.vault.getAbstractFileByPath(path);
            // console.log(file);
            if (file && file instanceof TFile) {
                files.push(file);
            }
        }
        // console.log(files);

        // Include files in pointed by links in file
        // console.log(filesContainsLinkedFiles);
        // console.log(fileMultiplierAfterLink);
        let linkedFileMultiplier = 1;
        let searchFileMultifpierAfterLink = true;
        if (fileMultiplierAfterLink === "") {
            searchFileMultifpierAfterLink = false;
        } else if (/^[0-9]+$/.test(fileMultiplierAfterLink)) {
            // integer
            linkedFileMultiplier = parseFloat(fileMultiplierAfterLink);
            searchFileMultifpierAfterLink = false;
        } else if (!/\?<value>/.test(fileMultiplierAfterLink)) {
            // no 'value' named group
            searchFileMultifpierAfterLink = false;
        }
        for (let filePath of filesContainsLinkedFiles) {
            if (!filePath.endsWith(".md")) {
                filePath += ".md";
            }
            let file = this.app.vault.getAbstractFileByPath(
                normalizePath(filePath)
            );
            if (file && file instanceof TFile) {
                // Get linked files
                let fileCache = this.app.metadataCache.getFileCache(file);
                let fileContent = await this.app.vault.adapter.read(file.path);
                let lines = fileContent.split(
                    /\r\n|[\n\v\f\r\x85\u2028\u2029]/
                );
                // console.log(lines);

                if (!fileCache?.links) continue;

                for (let link of fileCache.links) {
                    if (!link) continue;
                    let linkedFile =
                        this.app.metadataCache.getFirstLinkpathDest(
                            link.link,
                            filePath
                        );
                    if (linkedFile && linkedFile instanceof TFile) {
                        if (searchFileMultifpierAfterLink) {
                            // Get the line of link in file
                            let lineNumber = link.position.end.line;
                            // console.log(lineNumber);
                            if (lineNumber >= 0 && lineNumber < lines.length) {
                                let line = lines[lineNumber];
                                // console.log(line);

                                // Try extract multiplier
                                // if (link.position)
                                let splitted = line.split(link.original);
                                // console.log(splitted);
                                if (splitted.length === 2) {
                                    let toParse = splitted[1].trim();
                                    let strRegex = fileMultiplierAfterLink;
                                    let regex = new RegExp(strRegex, "gm");
                                    let match;
                                    while ((match = regex.exec(toParse))) {
                                        // console.log(match);
                                        if (
                                            typeof match.groups !==
                                                "undefined" &&
                                            typeof match.groups.value !==
                                                "undefined"
                                        ) {
                                            // must have group name 'value'
                                            let retParse =
                                                helper.parseFloatFromAny(
                                                    match.groups.value.trim(),
                                                    renderInfo.textValueMap
                                                );
                                            if (retParse.value !== null) {
                                                linkedFileMultiplier =
                                                    retParse.value;
                                                break;
                                            }
                                        }
                                    }
                                }
                            }
                        }

                        for (let i = 0; i < linkedFileMultiplier; i++) {
                            files.push(linkedFile);
                        }
                    }
                }
            }
        }

        // console.log(files);
    }

    async postprocessor(
        source: string,
        el: HTMLElement,
        ctx: MarkdownPostProcessorContext
    ) {
        // console.log("postprocess");
        const canvas = document.createElement("div");

        let yamlText = source.trim();

        // Replace all tabs by spaces
        let tabSize = this.app.vault.getConfig("tabSize");
        let spaces = Array(tabSize).fill(" ").join("");
        yamlText = yamlText.replace(/\t/gm, spaces);

        // Get render info
        let retRenderInfo = getRenderInfoFromYaml(yamlText, this);
        if (typeof retRenderInfo === "string") {
            return this.renderErrorMessage(retRenderInfo, canvas, el);
        }
        let renderInfo = retRenderInfo as RenderInfo;
        // console.log(renderInfo);

        // Get files
        let files: TFile[] = [];
        try {
            await this.getFiles(files, renderInfo);
        } catch (e) {
            return this.renderErrorMessage(e.message, canvas, el);
        }
        if (files.length === 0) {
            return this.renderErrorMessage(
                "No markdown files found in folder",
                canvas,
                el
            );
        }
        // console.log(files);

        // let dailyNotesSettings = getDailyNoteSettings();
        // console.log(dailyNotesSettings);
        // I always got YYYY-MM-DD from dailyNotesSettings.format
        // Use own settings panel for now

        // Collecting data to dataMap first
        let dataMap: DataMap = new Map(); // {strDate: [query: value, ...]}
        let processInfo = new CollectingProcessInfo();
        processInfo.fileTotal = files.length;

        // Collect data from files, each file has one data point for each query
        const loopFilePromises = files.map(async (file) => {
            // console.log(file.basename);
            // Get fileCache and content
            let fileCache: CachedMetadata = null;
            let needFileCache = renderInfo.queries.some((q) => {
                let type = q.getType();
                let target = q.getTarget();
                if (
                    type === SearchType.Frontmatter ||
                    type === SearchType.Tag ||
                    type === SearchType.Wiki ||
                    type === SearchType.WikiLink ||
                    type === SearchType.WikiDisplay
                ) {
                    return true;
                }
                return false;
            });
            if (needFileCache) {
                fileCache = this.app.metadataCache.getFileCache(file);
            }

            let content: string = null;
            let needContent = renderInfo.queries.some((q) => {
                let type = q.getType();
                let target = q.getTarget();
                if (
                    type === SearchType.Tag ||
                    type === SearchType.Text ||
                    type === SearchType.dvField ||
                    type === SearchType.Task ||
                    type === SearchType.TaskDone ||
                    type === SearchType.TaskNotDone
                ) {
                    return true;
                } else if (type === SearchType.FileMeta) {
                    if (
                        target === "numWords" ||
                        target === "numChars" ||
                        target === "numSentences"
                    ) {
                        return true;
                    }
                }
                return false;
            });
            if (needContent) {
                content = await this.app.vault.adapter.read(file.path);
            }

            // Get xValue and add it into xValueMap for later use
            let xValueMap: XValueMap = new Map(); // queryId: xValue for this file
            let skipThisFile = false;
            // console.log(renderInfo.xDataset);
            for (let xDatasetId of renderInfo.xDataset) {
                // console.log(`xDatasetId: ${xDatasetId}`);
                if (!xValueMap.has(xDatasetId)) {
                    let xDate = window.moment("");
                    if (xDatasetId === -1) {
                        // Default using date in filename as xValue
                        xDate = collecting.getDateFromFilename(
                            file,
                            renderInfo
                        );
                        // console.log(xDate);
                    } else {
                        let xDatasetQuery = renderInfo.queries[xDatasetId];
                        // console.log(xDatasetQuery);
                        switch (xDatasetQuery.getType()) {
                            case SearchType.Frontmatter:
                                xDate = collecting.getDateFromFrontmatter(
                                    fileCache,
                                    xDatasetQuery,
                                    renderInfo
                                );
                                break;
                            case SearchType.Tag:
                                xDate = collecting.getDateFromTag(
                                    content,
                                    xDatasetQuery,
                                    renderInfo
                                );
                                break;
                            case SearchType.Text:
                                xDate = collecting.getDateFromText(
                                    content,
                                    xDatasetQuery,
                                    renderInfo
                                );
                                break;
                            case SearchType.dvField:
                                xDate = collecting.getDateFromDvField(
                                    content,
                                    xDatasetQuery,
                                    renderInfo
                                );
                                break;
                            case SearchType.FileMeta:
                                xDate = collecting.getDateFromFileMeta(
                                    file,
                                    xDatasetQuery,
                                    renderInfo
                                );
                                break;
                            case SearchType.Task:
                            case SearchType.TaskDone:
                            case SearchType.TaskNotDone:
                                xDate = collecting.getDateFromTask(
                                    content,
                                    xDatasetQuery,
                                    renderInfo
                                );
                                break;
                        }
                    }

                    if (!xDate.isValid()) {
                        // console.log("Invalid xDate");
                        skipThisFile = true;
                        processInfo.fileNotInFormat++;
                    } else {
                        // console.log("file " + file.basename + " accepted");
                        if (renderInfo.startDate !== null) {
                            if (xDate < renderInfo.startDate) {
                                skipThisFile = true;
                                processInfo.fileOutOfDateRange++;
                            }
                        }
                        if (renderInfo.endDate !== null) {
                            if (xDate > renderInfo.endDate) {
                                skipThisFile = true;
                                processInfo.fileOutOfDateRange++;
                            }
                        }
                    }

                    if (!skipThisFile) {
                        processInfo.gotAnyValidXValue ||= true;
                        xValueMap.set(
                            xDatasetId,
                            helper.dateToStr(xDate, renderInfo.dateFormat)
                        );
                        processInfo.fileAvailable++;

                        // Get min/max date
                        if (processInfo.fileAvailable == 1) {
                            processInfo.minDate = xDate.clone();
                            processInfo.maxDate = xDate.clone();
                        } else {
                            if (xDate < processInfo.minDate) {
                                processInfo.minDate = xDate.clone();
                            }
                            if (xDate > processInfo.maxDate) {
                                processInfo.maxDate = xDate.clone();
                            }
                        }
                    }
                }
            }
            if (skipThisFile) return;
            // console.log(xValueMap);
            // console.log(`minDate: ${minDate}`);
            // console.log(`maxDate: ${maxDate}`);

            // Loop over queries
            let yDatasetQueries = renderInfo.queries.filter((q) => {
                return q.getType() !== SearchType.Table && !q.usedAsXDataset;
            });
            // console.log(yDatasetQueries);

            const loopQueryPromises = yDatasetQueries.map(async (query) => {
                // Get xValue from file if xDataset assigned
                // if (renderInfo.xDataset !== null)
                // let xDatasetId = renderInfo.xDataset;
                // console.log(query);

                // console.log("Search frontmatter tags");
                if (fileCache && query.getType() === SearchType.Tag) {
                    // Add frontmatter tags, allow simple tag only
                    let gotAnyValue = collecting.collectDataFromFrontmatterTag(
                        fileCache,
                        query,
                        renderInfo,
                        dataMap,
                        xValueMap
                    );
                    processInfo.gotAnyValidYValue ||= gotAnyValue;
                } // Search frontmatter tags

                // console.log("Search frontmatter keys");
                if (
                    fileCache &&
                    query.getType() === SearchType.Frontmatter &&
                    query.getTarget() !== "tags"
                ) {
                    let gotAnyValue = collecting.collectDataFromFrontmatterKey(
                        fileCache,
                        query,
                        renderInfo,
                        dataMap,
                        xValueMap
                    );
                    processInfo.gotAnyValidYValue ||= gotAnyValue;
                } // console.log("Search frontmatter keys");

                // console.log("Search wiki links");
                if (
                    fileCache &&
                    (query.getType() === SearchType.Wiki ||
                        query.getType() === SearchType.WikiLink ||
                        query.getType() === SearchType.WikiDisplay)
                ) {
                    let gotAnyValue = collecting.collectDataFromWiki(
                        fileCache,
                        query,
                        renderInfo,
                        dataMap,
                        xValueMap
                    );
                    processInfo.gotAnyValidYValue ||= gotAnyValue;
                }

                // console.log("Search inline tags");
                if (content && query.getType() === SearchType.Tag) {
                    let gotAnyValue = collecting.collectDataFromInlineTag(
                        content,
                        query,
                        renderInfo,
                        dataMap,
                        xValueMap
                    );
                    processInfo.gotAnyValidYValue ||= gotAnyValue;
                } // Search inline tags

                // console.log("Search Text");
                if (content && query.getType() === SearchType.Text) {
                    let gotAnyValue = collecting.collectDataFromText(
                        content,
                        query,
                        renderInfo,
                        dataMap,
                        xValueMap
                    );
                    processInfo.gotAnyValidYValue ||= gotAnyValue;
                } // Search text

                // console.log("Search FileMeta");
                if (query.getType() === SearchType.FileMeta) {
                    let gotAnyValue = collecting.collectDataFromFileMeta(
                        file,
                        content,
                        query,
                        renderInfo,
                        dataMap,
                        xValueMap
                    );
                    processInfo.gotAnyValidYValue ||= gotAnyValue;
                } // Search FileMeta

                // console.log("Search dvField");
                if (content && query.getType() === SearchType.dvField) {
                    let gotAnyValue = collecting.collectDataFromDvField(
                        content,
                        query,
                        renderInfo,
                        dataMap,
                        xValueMap
                    );
                    processInfo.gotAnyValidYValue ||= gotAnyValue;
                } // search dvField

                // console.log("Search Task");
                if (
                    content &&
                    (query.getType() === SearchType.Task ||
                        query.getType() === SearchType.TaskDone ||
                        query.getType() === SearchType.TaskNotDone)
                ) {
                    let gotAnyValue = collecting.collectDataFromTask(
                        content,
                        query,
                        renderInfo,
                        dataMap,
                        xValueMap
                    );
                    processInfo.gotAnyValidYValue ||= gotAnyValue;
                } // search Task
            });
            await Promise.all(loopQueryPromises);
        });
        await Promise.all(loopFilePromises);
        // console.log(dataMap);

        // Collect data from a file, one file contains full dataset
        await this.collectDataFromTable(dataMap, renderInfo, processInfo);
        if (processInfo.errorMessage) {
            return this.renderErrorMessage(
                processInfo.errorMessage,
                canvas,
                el
            );
        }
        // console.log(minDate);
        // console.log(maxDate);
        // console.log(dataMap);

        // Check date range
        // minDate and maxDate are collected without knowing startDate and endDate
        // console.log(`fileTotal: ${processInfo.fileTotal}`);
        // console.log(`fileAvailable: ${processInfo.fileAvailable}`);
        // console.log(`fileNotInFormat: ${processInfo.fileNotInFormat}`);
        // console.log(`fileOutOfDateRange: ${processInfo.fileOutOfDateRange}`);
        let dateErrorMessage = "";
        if (
            !processInfo.minDate.isValid() ||
            !processInfo.maxDate.isValid() ||
            processInfo.fileAvailable === 0 ||
            !processInfo.gotAnyValidXValue
        ) {
            dateErrorMessage = `No valid date as X value found in notes`;
            if (processInfo.fileOutOfDateRange > 0) {
                dateErrorMessage += `\n${processInfo.fileOutOfDateRange} files are out of the date range.`;
            }
            if (processInfo.fileNotInFormat) {
                dateErrorMessage += `\n${processInfo.fileNotInFormat} files are not in the right format.`;
            }
        }
        if (renderInfo.startDate === null && renderInfo.endDate === null) {
            // No date arguments
            renderInfo.startDate = processInfo.minDate.clone();
            renderInfo.endDate = processInfo.maxDate.clone();
        } else if (
            renderInfo.startDate !== null &&
            renderInfo.endDate === null
        ) {
            if (renderInfo.startDate < processInfo.maxDate) {
                renderInfo.endDate = processInfo.maxDate.clone();
            } else {
                dateErrorMessage = "Invalid date range";
            }
        } else if (
            renderInfo.endDate !== null &&
            renderInfo.startDate === null
        ) {
            if (renderInfo.endDate > processInfo.minDate) {
                renderInfo.startDate = processInfo.minDate.clone();
            } else {
                dateErrorMessage = "Invalid date range";
            }
        } else {
            // startDate and endDate are valid
            if (
                (renderInfo.startDate < processInfo.minDate &&
                    renderInfo.endDate < processInfo.minDate) ||
                (renderInfo.startDate > processInfo.maxDate &&
                    renderInfo.endDate > processInfo.maxDate)
            ) {
                dateErrorMessage = "Invalid date range";
            }
        }
        if (dateErrorMessage) {
            return this.renderErrorMessage(dateErrorMessage, canvas, el);
        }
        // console.log(renderInfo.startDate);
        // console.log(renderInfo.endDate);

        if (!processInfo.gotAnyValidYValue) {
            return this.renderErrorMessage(
                "No valid Y value found in notes",
                canvas,
                el
            );
        }

        // Reshape data for rendering
        let datasets = new Datasets(renderInfo.startDate, renderInfo.endDate);
        for (let query of renderInfo.queries) {
            // We still create a dataset for xDataset,
            // to keep the sequence and order of targets
            let dataset = datasets.createDataset(query, renderInfo);
            // Add number of targets to the dataset
            // Number of targets has been accumulated while collecting data
            dataset.addNumTargets(query.getNumTargets());
            for (
                let curDate = renderInfo.startDate.clone();
                curDate <= renderInfo.endDate;
                curDate.add(1, "days")
            ) {
                // console.log(curDate);

                // dataMap --> {date: [query: value, ...]}
                if (
                    dataMap.has(
                        helper.dateToStr(curDate, renderInfo.dateFormat)
                    )
                ) {
                    let queryValuePairs = dataMap
                        .get(helper.dateToStr(curDate, renderInfo.dateFormat))
                        .filter(function (pair) {
                            return pair.query.equalTo(query);
                        });
                    if (queryValuePairs.length > 0) {
                        // Merge values of the same day same query
                        let value = null;
                        for (
                            let indPair = 0;
                            indPair < queryValuePairs.length;
                            indPair++
                        ) {
                            let collected = queryValuePairs[indPair].value;
                            if (
                                Number.isNumber(collected) &&
                                !Number.isNaN(collected)
                            ) {
                                if (value === null) {
                                    value = collected;
                                } else {
                                    value += collected;
                                }
                            }
                        }
                        // console.log(hasValue);
                        // console.log(value);
                        if (value !== null) {
                            dataset.setValue(curDate, value);
                        }
                    }
                }
            }
        }
        renderInfo.datasets = datasets;
        // console.log(renderInfo.datasets);

        let retRender = rendering.render(canvas, renderInfo);
        if (typeof retRender === "string") {
            return this.renderErrorMessage(retRender, canvas, el);
        }

        el.appendChild(canvas);
    }

    // TODO: remove this.app and move to collecting.ts
    async collectDataFromTable(
        dataMap: DataMap,
        renderInfo: RenderInfo,
        processInfo: CollectingProcessInfo
    ) {
        // console.log("collectDataFromTable");

        let tableQueries = renderInfo.queries.filter(
            (q) => q.getType() === SearchType.Table
        );
        // console.log(tableQueries);
        // Separate queries by tables and xDatasets/yDatasets
        let tables: Array<TableData> = [];
        let tableFileNotFound = false;
        for (let query of tableQueries) {
            let filePath = query.getParentTarget();
            let file = this.app.vault.getAbstractFileByPath(
                normalizePath(filePath + ".md")
            );
            if (!file || !(file instanceof TFile)) {
                tableFileNotFound = true;
                break;
            }

            let tableIndex = query.getAccessor();
            let isX = query.usedAsXDataset;

            let table = tables.find(
                (t) => t.filePath === filePath && t.tableIndex === tableIndex
            );
            if (table) {
                if (isX) {
                    table.xDataset = query;
                } else {
                    table.yDatasets.push(query);
                }
            } else {
                let tableData = new TableData(filePath, tableIndex);
                if (isX) {
                    tableData.xDataset = query;
                } else {
                    tableData.yDatasets.push(query);
                }
                tables.push(tableData);
            }
        }
        // console.log(tables);

        if (tableFileNotFound) {
            processInfo.errorMessage = "File containing tables not found";
            return;
        }

        for (let tableData of tables) {
            //extract xDataset from query
            let xDatasetQuery = tableData.xDataset;
            if (!xDatasetQuery) {
                // missing xDataset
                continue;
            }
            let yDatasetQueries = tableData.yDatasets;
            let filePath = xDatasetQuery.getParentTarget();
            let tableIndex = xDatasetQuery.getAccessor();

            // Get table text
            let textTable = "";
            filePath = filePath + ".md";
            let file = this.app.vault.getAbstractFileByPath(
                normalizePath(filePath)
            );
            if (file && file instanceof TFile) {
                processInfo.fileAvailable++;
                let content = await this.app.vault.adapter.read(file.path);
                // console.log(content);

                // Test this in Regex101
                // This is a not-so-strict table selector
                // ((\r?\n){2}|^)([^\r\n]*\|[^\r\n]*(\r?\n)?)+(?=(\r?\n){2}|$)
                let strMDTableRegex =
                    "((\\r?\\n){2}|^)([^\\r\\n]*\\|[^\\r\\n]*(\\r?\\n)?)+(?=(\\r?\\n){2}|$)";
                // console.log(strMDTableRegex);
                let mdTableRegex = new RegExp(strMDTableRegex, "gm");
                let match;
                let indTable = 0;

                while ((match = mdTableRegex.exec(content))) {
                    // console.log(match);
                    if (indTable === tableIndex) {
                        textTable = match[0];
                        break;
                    }
                    indTable++;
                }
            } else {
                // file not exists
                continue;
            }
            // console.log(textTable);

            let tableLines = textTable.split(/\r?\n/);
            tableLines = tableLines.filter((line) => {
                return line !== "";
            });
            let numColumns = 0;
            let numDataRows = 0;
            // console.log(tableLines);

            // Make sure it is a valid table first
            if (tableLines.length >= 2) {
                // Must have header and separator line
                let headerLine = tableLines.shift().trim();
                headerLine = helper.trimByChar(headerLine, "|");
                let headerSplitted = headerLine.split("|");
                numColumns = headerSplitted.length;

                let sepLine = tableLines.shift().trim();
                sepLine = helper.trimByChar(sepLine, "|");
                let spepLineSplitted = sepLine.split("|");
                for (let col of spepLineSplitted) {
                    if (!col.includes("-")) {
                        break; // Not a valid sep
                    }
                }

                numDataRows = tableLines.length;
            }

            if (numDataRows == 0) continue;

            // get x data
            let columnXDataset = xDatasetQuery.getAccessor(1);
            if (columnXDataset >= numColumns) continue;
            let xValues = [];

            let indLine = 0;
            for (let tableLine of tableLines) {
                let dataRow = helper.trimByChar(tableLine.trim(), "|");
                let dataRowSplitted = dataRow.split("|");
                if (columnXDataset < dataRowSplitted.length) {
                    let data = dataRowSplitted[columnXDataset].trim();
                    let date = helper.strToDate(data, renderInfo.dateFormat);

                    if (date.isValid()) {
                        xValues.push(date);

                        if (
                            !processInfo.minDate.isValid() &&
                            !processInfo.maxDate.isValid()
                        ) {
                            processInfo.minDate = date.clone();
                            processInfo.maxDate = date.clone();
                        } else {
                            if (date < processInfo.minDate) {
                                processInfo.minDate = date.clone();
                            }
                            if (date > processInfo.maxDate) {
                                processInfo.maxDate = date.clone();
                            }
                        }
                    } else {
                        xValues.push(null);
                    }
                } else {
                    xValues.push(null);
                }
                indLine++;
            }
            // console.log(xValues);

            if (
                xValues.every((v) => {
                    return v === null;
                })
            ) {
                processInfo.errorMessage =
                    "No valid date as X value found in table";
                return;
            } else {
                processInfo.gotAnyValidXValue ||= true;
            }

            // get y data
            for (let yDatasetQuery of yDatasetQueries) {
                let columnOfInterest = yDatasetQuery.getAccessor(1);
                // console.log(`columnOfInterest: ${columnOfInterest}, numColumns: ${numColumns}`);
                if (columnOfInterest >= numColumns) continue;

                let indLine = 0;
                for (let tableLine of tableLines) {
                    let dataRow = helper.trimByChar(tableLine.trim(), "|");
                    let dataRowSplitted = dataRow.split("|");
                    if (columnOfInterest < dataRowSplitted.length) {
                        let data = dataRowSplitted[columnOfInterest].trim();
                        let splitted = data.split(yDatasetQuery.getSeparator());
                        // console.log(splitted);
                        if (!splitted) continue;
                        if (splitted.length === 1) {
                            let retParse = helper.parseFloatFromAny(
                                splitted[0],
                                renderInfo.textValueMap
                            );
                            // console.log(retParse);
                            if (retParse.value !== null) {
                                if (retParse.type === ValueType.Time) {
                                    yDatasetQuery.valueType = ValueType.Time;
                                }
                                let value = retParse.value;
                                if (
                                    indLine < xValues.length &&
                                    xValues[indLine]
                                ) {
                                    processInfo.gotAnyValidYValue ||= true;
                                    collecting.addToDataMap(
                                        dataMap,
                                        helper.dateToStr(
                                            xValues[indLine],
                                            renderInfo.dateFormat
                                        ),
                                        yDatasetQuery,
                                        value
                                    );
                                }
                            }
                        } else if (
                            splitted.length > yDatasetQuery.getAccessor(2) &&
                            yDatasetQuery.getAccessor(2) >= 0
                        ) {
                            let value = null;
                            let splittedPart =
                                splitted[yDatasetQuery.getAccessor(2)].trim();
                            // console.log(splittedPart);
                            let retParse = helper.parseFloatFromAny(
                                splittedPart,
                                renderInfo.textValueMap
                            );
                            // console.log(retParse);
                            if (retParse.value !== null) {
                                if (retParse.type === ValueType.Time) {
                                    yDatasetQuery.valueType = ValueType.Time;
                                }
                                value = retParse.value;
                                if (
                                    indLine < xValues.length &&
                                    xValues[indLine]
                                ) {
                                    processInfo.gotAnyValidYValue ||= true;
                                    collecting.addToDataMap(
                                        dataMap,
                                        helper.dateToStr(
                                            xValues[indLine],
                                            renderInfo.dateFormat
                                        ),
                                        yDatasetQuery,
                                        value
                                    );
                                }
                            }
                        }
                    }

                    indLine++;
                } // Loop over tableLines
            }
        }
    }

    getEditor(): Editor {
        return this.app.workspace.getActiveViewOfType(MarkdownView).editor;
    }

    addCodeBlock(outputType: GraphType): void {
        const currentView = this.app.workspace.activeLeaf.view;

        if (!(currentView instanceof MarkdownView)) {
            return;
        }

        let codeblockToInsert = "";
        switch (outputType) {
            case GraphType.Line:
                codeblockToInsert = `\`\`\` tracker
searchType: tag
searchTarget: tagName
folder: /
startDate:
endDate:
line:
    title: "Line Chart"
    xAxisLabel: Date
    yAxisLabel: Value
\`\`\``;
                break;
            case GraphType.Bar:
                codeblockToInsert = `\`\`\` tracker
searchType: tag
searchTarget: tagName
folder: /
startDate:
endDate:
bar:
    title: "Bar Chart"
    xAxisLabel: Date
    yAxisLabel: Value
\`\`\``;
                break;
            case GraphType.Summary:
                codeblockToInsert = `\`\`\` tracker
searchType: tag
searchTarget: tagName
folder: /
startDate:
endDate:
summary:
    template: "Average value of tagName is {{average}}"
    style: "color:white;"
\`\`\``;
                break;
            default:
                break;
        }

        if (codeblockToInsert !== "") {
            let textInserted = this.insertToNextLine(codeblockToInsert);
            if (!textInserted) {
            }
        }
    }

    insertToNextLine(text: string): boolean {
        let editor = this.getEditor();

        if (editor) {
            let cursor = editor.getCursor();
            let lineNumber = cursor.line;
            let line = editor.getLine(lineNumber);

            cursor.ch = line.length;
            editor.setSelection(cursor);
            editor.replaceSelection("\n" + text);

            return true;
        }

        return false;
    }
}
Example #13
Source File: main.ts    From obsidian-banners with MIT License 4 votes vote down vote up
export default class BannersPlugin extends Plugin {
  settings: ISettingsOptions;
  workspace: Workspace;
  vault: Vault;
  metadataCache: MetadataCache;
  extensions: Extension[];
  metaManager: MetaManager;

  holdingDragModKey: boolean

  async onload() {
    console.log('Loading Banners...');

    this.settings = Object.assign({}, INITIAL_SETTINGS, await this.loadData());
    this.workspace = this.app.workspace;
    this.vault = this.app.vault;
    this.metadataCache = this.app.metadataCache;
    this.metaManager = new MetaManager(this);
    this.holdingDragModKey = false;

    this.loadProcessor();
    this.loadExtension();
    this.loadCommands();
    this.loadStyles();
    this.loadListeners();
    this.loadPrecheck();

    this.addSettingTab(new SettingsTab(this));

    this.refreshViews();
  }

  async onunload() {
    console.log('Unloading Banners...');

    this.unloadListeners();
    this.unloadBanners();
    this.unloadStyles();
  }

  loadListeners() {
    // Banner cursor toggling
    window.addEventListener('keydown', this.isDragModHeld);
    window.addEventListener('keyup', this.isDragModHeld);
  }

  loadProcessor() {
    const processor = getPostProcessor(this);
    this.registerMarkdownPostProcessor(processor);
  }

  loadExtension() {
    this.extensions = [
      bannerDecorFacet.of(this.settings),
      iconDecorFacet.of(this.settings),
      getViewPlugin(this)
    ];
    this.registerEditorExtension(this.extensions);
  }

  loadCommands() {
    this.addCommand({
      id: 'banners:addBanner',
      name: 'Add/Change banner with local image',
      checkCallback: (checking) => {
        const file = this.workspace.getActiveFile();
        if (checking) { return !!file }
        new LocalImageModal(this, file).open();
      }
    });

    this.addCommand({
      id: 'banners:addIcon',
      name: 'Add/Change emoji icon',
      checkCallback: (checking) => {
        const file = this.workspace.getActiveFile();
        if (checking) { return !!file }
        new IconModal(this, file).open();
      }
    });

    this.addCommand({
      id: 'banners:pasteBanner',
      name: 'Paste banner from clipboard',
      checkCallback: (checking) => {
        const file = this.workspace.getActiveFile();
        if (checking) { return !!file }
        this.pasteBanner(file);
      }
    });

    this.addCommand({
      id: 'banners:lockBanner',
      name: 'Lock/Unlock banner position',
      checkCallback: (checking) => {
        const file = this.workspace.getActiveFile();
        if (checking) { return !!file }
        this.toggleBannerLock(file);
      }
    })

    this.addCommand({
      id: 'banners:removeBanner',
      name: 'Remove banner',
      checkCallback: (checking) => {
        const file = this.workspace.getActiveFile();
        if (checking) {
          if (!file) { return false }
          return !!this.metaManager.getBannerDataFromFile(file)?.src;
        }
        this.removeBanner(file);
      }
    });

    this.addCommand({
      id: 'banners:removeIcon',
      name: 'Remove icon',
      checkCallback: (checking) => {
        const file = this.workspace.getActiveFile();
        if (checking) {
          if (!file) { return false }
          return !!this.metaManager.getBannerDataFromFile(file)?.icon;
        }
        this.removeIcon(file);
      }
    });
  }

  loadStyles() {
    document.documentElement.style.setProperty('--banner-height', `${this.getSettingValue('height')}px`);
    document.documentElement.style.setProperty('--banner-internal-embed-height', `${this.getSettingValue('internalEmbedHeight')}px`);
    document.documentElement.style.setProperty('--banner-preview-embed-height', `${this.getSettingValue('previewEmbedHeight')}px`);
  }

  loadPrecheck() {
    // Wrap banner source in quotes to prevent errors later in CM6 extension
    const files = this.workspace.getLeavesOfType('markdown').map((leaf) => (leaf.view as MarkdownView).file);
    const uniqueFiles = [...new Set(files)];
    uniqueFiles.forEach((file) => this.lintBannerSource(file));
    this.registerEvent(this.workspace.on('file-open', (file) => this.lintBannerSource(file)));
  }

  unloadListeners() {
    window.removeEventListener('keydown', this.isDragModHeld);
    window.removeEventListener('keyup', this.isDragModHeld);
  }

  unloadBanners() {
    this.workspace.containerEl
      .querySelectorAll('.obsidian-banner-wrapper')
      .forEach((wrapper) => {
        wrapper.querySelector('.obsidian-banner')?.remove();
        wrapper.querySelector('.obsidian-banner-icon')?.remove();
        wrapper.removeClasses(['obsidian-banner-wrapper', 'has-banner-icon']);
      });
  }

  unloadStyles() {
    document.documentElement.style.removeProperty('--banner-height');
    document.documentElement.style.removeProperty('--banner-internal-embed-height');
    document.documentElement.style.removeProperty('--banner-preview-embed-height');
  }

  // Helper to check if the drag modifier key is being held down or not, if specified
  isDragModHeld = (e?: KeyboardEvent) => {
    let ret: boolean;
    if (e) {
      switch (this.settings.bannerDragModifier) {
        case 'alt': ret = e.altKey; break;
        case 'ctrl': ret = e.ctrlKey; break;
        case 'meta': ret = e.metaKey; break;
        case 'shift': ret = e.shiftKey; break;
        default: ret = true;
      }
    } else {
      ret = (this.settings.bannerDragModifier === 'none');
    }
    this.holdingDragModKey = ret;
    this.toggleBannerCursor(ret);
  }

  // Helper to refresh views
  refreshViews() {
    // Reconfigure CM6 extensions and update Live Preview views
    this.extensions[0] = bannerDecorFacet.of(this.settings);
    this.extensions[1] = iconDecorFacet.of(this.settings);
    this.workspace.updateOptions();

    // Rerender Reading views
    this.workspace.getLeavesOfType('markdown').forEach((leaf) => {
      if (leaf.getViewState().state.mode.includes('preview')) {
        (leaf.view as MarkdownView).previewMode.rerender(true);
      }
    });

    // Refresh banners' drag state
    this.isDragModHeld();
  }

  // Helper to use clipboard for banner
  async pasteBanner(file: TFile) {
    const clipboard = await navigator.clipboard.readText();
    if (!isURL(clipboard)) {
      new Notice('Your clipboard didn\'t had a valid URL! Please try again (and check the console if you wanna debug).');
      console.error({ clipboard });
    } else {
      await this.metaManager.upsertBannerData(file, { src: `"${clipboard}"` });
      new Notice('Pasted a new banner!');
    }
  }

  // Helper to apply grab cursor for banner images
  // TODO: This feels fragile, perhaps look for a better way
  toggleBannerCursor = (val: boolean) => {
    document.querySelectorAll('.banner-image').forEach((el) => el.toggleClass('draggable', val));
  }

  // Helper to toggle banner position locking
  async toggleBannerLock(file: TFile) {
    const { lock = false } = this.metaManager.getBannerDataFromFile(file);
    if (lock) {
      await this.metaManager.removeBannerData(file, 'lock');
      new Notice(`Unlocked banner position for ${file.name}!`);
    } else {
      await this.metaManager.upsertBannerData(file, { lock: true });
      new Notice(`Locked banner position for ${file.name}!`);
    }
  }

  // Helper to remove banner
  async removeBanner(file: TFile) {
    await this.metaManager.removeBannerData(file, ['src', 'x', 'y', 'lock']);
    new Notice(`Removed banner for ${file.name}!`);
  }

  // Helper to remove banner icon
  async removeIcon(file: TFile) {
    await this.metaManager.removeBannerData(file, 'icon');
    new Notice(`Removed banner icon for ${file.name}!`);
  }

  // Helper to wrap banner source in quotes if not already (Patch for previous versions)
  async lintBannerSource(file: TFile) {
    if (!file) { return }
    const { src } = this.metaManager.getBannerDataFromFile(file) ?? {};
    if (src && typeof src === 'string') {
      await this.metaManager.upsertBannerData(file, { src: `"${src}"` });
    }
  }

  // Helper to get setting value (or the default setting value if not set)
  getSettingValue<K extends keyof ISettingsOptions>(key: K): PartialSettings[K] {
    return this.settings[key] ?? DEFAULT_VALUES[key];
  }
}
Example #14
Source File: main.ts    From obsidian-charts with GNU Affero General Public License v3.0 4 votes vote down vote up
export default class ChartPlugin extends Plugin {

	settings: ChartPluginSettings;
	renderer: Renderer;

	postprocessor = async (content: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {

		let data;
		try {
			data = await parseYaml(content.replace(/	/g, '    '));
		} catch (error) {
			renderError(error, el);
			return;
		}

		if(!data.id) {
			if (!data || !data.type || !data.labels || !data.series) {
				renderError("Missing type, labels or series", el)
				return;
			}
		}

		await this.renderer.renderFromYaml(data, el, ctx);
	}

	async loadSettings() {
		this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
	}

	async saveSettings() {
		await this.saveData(this.settings);
	}

	async onload() {
		console.log('loading plugin: Obsidian Charts');

		await this.loadSettings()

		addIcons();

		this.renderer = new Renderer(this);

		//@ts-ignore
		window.renderChart = this.renderer.renderRaw;

		this.addSettingTab(new ChartSettingTab(this.app, this));

		this.addCommand({
			id: 'creation-helper',
			name: 'Insert new Chart',
			checkCallback: (checking: boolean) => {
				let leaf = this.app.workspace.activeLeaf;
				if (leaf.view instanceof MarkdownView) {
					if (!checking) {
						new CreationHelperModal(this.app, leaf.view, this.settings, this.renderer).open();
					}
					return true;
				}
				return false;
			}
		});

		this.addCommand({
			id: 'chart-from-table-column',
			name: 'Create Chart from Table (Column oriented Layout)',
			editorCheckCallback: (checking: boolean, editor: Editor, view: View) => {
				let selection = editor.getSelection();
				if (view instanceof MarkdownView && selection.split('\n').length >= 3 && selection.split('|').length >= 2) {
					if (!checking) {
						chartFromTable(editor, 'columns');
					}
					return true;
				}
				return false;
			}
		});

		this.addCommand({
			id: 'chart-from-table-row',
			name: 'Create Chart from Table (Row oriented Layout)',
			editorCheckCallback: (checking: boolean, editor: Editor, view: View) => {
				if (view instanceof MarkdownView && editor.getSelection().split('\n').length >= 3 && editor.getSelection().split('|').length >= 2) {
					if (!checking) {
						chartFromTable(editor, 'rows');
					}
					return true;
				}
				return false;
			}
		});

		this.addCommand({
			id: 'chart-to-svg',
			name: 'Create Image from Chart',
			editorCheckCallback: (checking: boolean, editor: Editor, view: View) => {
				if (view instanceof MarkdownView && editor.getSelection().startsWith("```chart") && editor.getSelection().endsWith("```")) {
					if (!checking) {
						new Notice("Rendering Chart...")
						saveImageToVaultAndPaste(editor, this.app, this.renderer, view.file, this.settings);
					}
					return true;
				}
				return false;
			}
		});

		this.registerMarkdownCodeBlockProcessor('chart', this.postprocessor);
		this.registerMarkdownCodeBlockProcessor('advanced-chart', async (data, el) => this.renderer.renderRaw(await JSON.parse(data), el));

		// Remove this ignore when the obsidian package is updated on npm
		// Editor mode
		// @ts-ignore
		this.registerEvent(this.app.workspace.on('editor-menu',
			(menu: Menu, _: Editor, view: MarkdownView) => {
				if (view && this.settings.contextMenu) {
					menu.addItem((item) => {
						item.setTitle("Insert Chart")
							.setIcon("chart")
							.onClick((_) => {
								new CreationHelperModal(this.app, view, this.settings, this.renderer).open();
							});
					});
				}
			}));
	}

	onunload() {
		console.log('unloading plugin: Obsidian Charts');
	}

}
Example #15
Source File: main.ts    From obsidian-customizable-sidebar with MIT License 4 votes vote down vote up
export default class CustomSidebarPlugin extends Plugin {
	settings: CustomSidebarSettings;
	pinSidebarSupport?: PinSidebarSupport;
	iconList: string[] = ["any-key", "audio-file", "blocks", "bold-glyph", "bracket-glyph", "broken-link", "bullet-list", "bullet-list-glyph", "calendar-with-checkmark", "check-in-circle", "check-small", "checkbox-glyph", "checkmark", "clock", "cloud", "code-glyph", "create-new", "cross", "cross-in-box", "crossed-star", "csv", "deleteColumn", "deleteRow", "dice", "document", "documents", "dot-network", "double-down-arrow-glyph", "double-up-arrow-glyph", "down-arrow-with-tail", "down-chevron-glyph", "enter", "exit-fullscreen", "expand-vertically", "filled-pin", "folder", "formula", "forward-arrow", "fullscreen", "gear", "go-to-file", "hashtag", "heading-glyph", "help", "highlight-glyph", "horizontal-split", "image-file", "image-glyph", "indent-glyph", "info", "insertColumn", "insertRow", "install", "italic-glyph", "keyboard-glyph", "languages", "left-arrow", "left-arrow-with-tail", "left-chevron-glyph", "lines-of-text", "link", "link-glyph", "logo-crystal", "magnifying-glass", "microphone", "microphone-filled", "minus-with-circle", "moveColumnLeft", "moveColumnRight", "moveRowDown", "moveRowUp", "note-glyph", "number-list-glyph", "open-vault", "pane-layout", "paper-plane", "paused", "pdf-file", "pencil", "percent-sign-glyph", "pin", "plus-with-circle", "popup-open", "presentation", "price-tag-glyph", "quote-glyph", "redo-glyph", "reset", "right-arrow", "right-arrow-with-tail", "right-chevron-glyph", "right-triangle", "run-command", "search", "sheets-in-box", "sortAsc", "sortDesc", "spreadsheet", "stacked-levels", "star", "star-list", "strikethrough-glyph", "switch", "sync", "sync-small", "tag-glyph", "three-horizontal-bars", "trash", "undo-glyph", "unindent-glyph", "up-and-down-arrows", "up-arrow-with-tail", "up-chevron-glyph", "uppercase-lowercase-a", "vault", "vertical-split", "vertical-three-dots", "wrench-screwdriver-glyph"];

	async onload() {
		console.log('loading CustomSidebarPlugin %s', this.manifest.version);

		await this.loadSettings();

		this.addSettingTab(new CustomSidebarSettingsTab(this.app, this));

		addFeatherIcons(this.iconList);

		this.settings.sidebarCommands.forEach(c => {
			this.addRibbonIcon(c.icon, c.name, () => {
				//@ts-ignore
				this.app.commands.executeCommandById(c.id);
			});
		});

		this.addExtraCommands();

		this.app.workspace.onLayoutReady(() => {
			this.hideCommands();
			this.sidebarPins();
		});
	}

	onunload() {
		console.log('unloading plugin');
	}

	async loadSettings() {
		this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
	}

	async saveSettings() {
		await this.saveData(this.settings);
	}

	addExtraCommands(){
		this.addCommand({
			id: "theme-toggle",
			name: "Toggle Dark/Light Mode",
			//@ts-ignore
			icon: "feather-eye",
			callback: () => {
				//@ts-ignore
				this.app.getTheme() === "obsidian"
				//@ts-ignore
				? this.app.commands.executeCommandById("theme:use-light")
				//@ts-ignore
				: this.app.commands.executeCommandById("theme:use-dark");
			}
		});

		this.addCommand({
			id: "close-left-sidebar",
			name: "Close left Sidebar",
			icon: "feather-minimize",
			callback: () => {
				//@ts-ignore
				if(!this.app.workspace.leftRibbon.containerEl.hasClass("is-collapsed")){
					//@ts-ignore
					this.app.workspace.leftRibbon.collapseButtonEl.click();
				}
			}
		});

		this.addCommand({
			id: "open-left-sidebar",
			name: "Open left Sidebar",
			icon: "feather-maximize",
			callback: () => {
				//@ts-ignore
				if(this.app.workspace.leftRibbon.containerEl.hasClass("is-collapsed")){
					//@ts-ignore
					this.app.workspace.leftRibbon.collapseButtonEl.click();
				}
			}
		});

		this.addCommand({
			id: "close-right-sidebar",
			name: "Close right Sidebar",
			icon: "feather-minimize",
			callback: () => {
				//@ts-ignore
				if(!this.app.workspace.rightRibbon.containerEl.hasClass("is-collapsed")){
					//@ts-ignore
					this.app.workspace.rightRibbon.collapseButtonEl.click();
				}
			}
		});

		this.addCommand({
			id: "open-right-sidebar",
			name: "Open right Sidebar",
			icon: "feather-maximize",
			callback: () => {
				//@ts-ignore
				if(this.app.workspace.rightRibbon.containerEl.hasClass("is-collapsed")){
					//@ts-ignore
					this.app.workspace.rightRibbon.collapseButtonEl.click();
				}
			}
		});
	}

	hideCommands() {
		//@ts-ignore
        const children: HTMLCollection = this.app.workspace.leftRibbon.ribbonActionsEl.children;
		for (let i = 0; i < children.length; i++) {
			if(this.settings.hiddenCommands.contains(children.item(i).getAttribute("aria-label"))) {
				(children.item(i) as HTMLElement).style.display = "none";
			}
		}
	}

	sidebarPins() {
		if ( Platform.isMobile) {
			// don't do any of this if we're on mobile
			return;
		}
		if (this.settings.pinSidebar === undefined && this.pinSidebarSupport) {
			// clean up any sidebar pins & related classes if present

			const leftParent = this.pinSidebarSupport.pinLeft.parentElement;
			leftParent.removeChild(this.pinSidebarSupport.pinLeft);
			delete this.pinSidebarSupport.pinLeft;

			const rightParent = this.pinSidebarSupport.pinRight.parentElement;
			rightParent.removeChild(this.pinSidebarSupport.pinRight);
			delete this.pinSidebarSupport.pinRight;

			this.getSidebarContainer(true).removeClass("is-pinned");
			this.getSidebarContainer(true).removeClass("is-floating");

			this.getSidebarContainer(false).removeClass("is-pinned");
			this.getSidebarContainer(false).removeClass("is-floating");

		} else if (this.settings.pinSidebar) {
			// create sidebar pin buttons & related classes

			if ( this.pinSidebarSupport === undefined ) {
				// Commands are added once (not removed?)
				this.addCommand({
					id: "pin-left-sidebar",
					name: "Toggle pin left Sidebar",
					icon: "pin",
					callback: async () => this.toggleLeftSidebar()
				});

				this.addCommand({
					id: "pin-right-sidebar",
					name: "Toggle pin right Sidebar",
					icon: "pin",
					callback: async () => this.toggleRightSidebar()
				});
			}

			//@ts-ignore
			const leftParent: HTMLElement = this.app.workspace.leftRibbon.containerEl;
			let pinLeft = leftParent.children.namedItem("left-pin-sidebar") as HTMLElement;
			if (pinLeft == null) {
				pinLeft = leftParent.createDiv({
					cls: "pin-sidebar pin-left-sidebar",
					attr: {
						name: "left-pin-sidebar"
					}});
				pinLeft.addEventListener("click", async (e) => {
					this.toggleLeftSidebar();
					return e.preventDefault();
				});
				leftParent.insertBefore(pinLeft, leftParent.firstChild);
			}

			//@ts-ignore
			const rightParent: HTMLElement = this.app.workspace.rightRibbon.containerEl;
			let pinRight = rightParent.children.namedItem("right-pin-sidebar") as HTMLElement;
			if ( pinRight == null ) {
				pinRight = rightParent.createDiv({
					cls: "pin-sidebar pin-right-sidebar",
					attr: {
						name: "right-pin-sidebar",
					}});
				pinRight.addEventListener("click", async (e) => {
					this.toggleRightSidebar();
					return e.preventDefault();
				});
				rightParent.insertBefore(pinRight, rightParent.firstChild);
			}

			// Save elements for reference/toggle
			this.pinSidebarSupport = {
				pinLeft,
				pinRight
			};

			this.setAttributes(pinLeft,
					this.getSidebarContainer(true),
					this.settings.pinSidebar.left === undefined ? true : this.settings.pinSidebar.left);

			this.setAttributes(pinRight,
					this.getSidebarContainer(false),
					this.settings.pinSidebar.right === undefined ? true : this.settings.pinSidebar.right);
		}
	}

	async toggleLeftSidebar() {
		if ( this.settings.pinSidebar && this.pinSidebarSupport ) {
			this.settings.pinSidebar.left = ! this.settings.pinSidebar.left;
			this.setAttributes(this.pinSidebarSupport.pinLeft,
				this.getSidebarContainer(true),
				this.settings.pinSidebar.left);
			await this.saveSettings();
		}
	}

	async toggleRightSidebar() {
		if ( this.settings.pinSidebar && this.pinSidebarSupport ) {
			this.settings.pinSidebar.right = ! this.settings.pinSidebar.right;
			this.setAttributes(this.pinSidebarSupport.pinRight,
				this.getSidebarContainer(false),
				this.settings.pinSidebar.right);
			await this.saveSettings();
		}
	}

	setAttributes(pin: HTMLElement, sidebar: HTMLElement, value: boolean) {
		if ( value ) {
			setIcon(pin, "filled-pin");

			pin.addClass("is-pinned");
			sidebar.addClass("is-pinned");

			pin.removeClass("is-floating");
			sidebar.removeClass("is-floating");
		} else {
			setIcon(pin, "pin");

			pin.removeClass("is-pinned");
			sidebar.removeClass("is-pinned");

			pin.addClass("is-floating");
			sidebar.addClass("is-floating");
		}
	}

	getSidebarContainer(leftSplit: boolean) {
		if ( leftSplit ) {
			//@ts-ignore
			return this.app.workspace.leftSplit.containerEl;
		}
		//@ts-ignore
		return this.app.workspace.rightSplit.containerEl;
	}
}
Example #16
Source File: main.ts    From obsidian_supercharged_links with MIT License 4 votes vote down vote up
export default class SuperchargedLinks extends Plugin {
	settings: SuperchargedLinksSettings;
	initialProperties: Array<Field> = []
	settingTab: SuperchargedLinksSettingTab
	private observers: [MutationObserver, string, string][];
	private modalObserver: MutationObserver;

	async onload(): Promise<void> {
		console.log('Supercharged links loaded');
		await this.loadSettings();


		this.settings.presetFields.forEach(prop => {
			const property = new Field()
			Object.assign(property, prop)
			this.initialProperties.push(property)
		})
		this.addSettingTab(new SuperchargedLinksSettingTab(this.app, this));
		this.registerMarkdownPostProcessor((el, ctx) => {
			updateElLinks(this.app, this, el, ctx)
		});

		// Plugins watching
		this.registerEvent(this.app.metadataCache.on('changed', debounce((_file) => {
			updateVisibleLinks(this.app, this);
			this.observers.forEach(([observer, type, own_class ]) => {
				const leaves = this.app.workspace.getLeavesOfType(type);
				leaves.forEach(leaf => {
					this.updateContainer(leaf.view.containerEl, this, own_class);
				})
			});
			// Debounced to prevent lag when writing
		}, 4500, true)));


		// Live preview
		const ext = Prec.lowest(buildCMViewPlugin(this.app, this.settings));
		this.registerEditorExtension(ext);

		this.observers = [];

		this.app.workspace.onLayoutReady(() => {
			this.initViewObservers(this);
			this.initModalObservers(this);
		});
		this.registerEvent(this.app.workspace.on("layout-change", () => this.initViewObservers(this)));

		this.addCommand({
			id: "field_options",
			name: "field options",
			hotkeys: [
				{
					modifiers: ["Alt"],
					key: 'O',
				},
			],
			callback: () => {
				const leaf = this.app.workspace.activeLeaf
				if (leaf.view instanceof MarkdownView && leaf.view.file) {
					const fieldsOptionsModal = new NoteFieldsCommandsModal(this.app, this, leaf.view.file)
					fieldsOptionsModal.open()
				}
			},
		});

		/* TODO : add a context menu for fileClass files to show the same options as in FileClassAttributeSelectModal*/
		this.addCommand({
			id: "fileClassAttr_options",
			name: "fileClass attributes options",
			hotkeys: [
				{
					modifiers: ["Alt"],
					key: 'P',
				},
			],
			callback: () => {
				const leaf = this.app.workspace.activeLeaf
				if (leaf.view instanceof MarkdownView && leaf.view.file && `${leaf.view.file.parent.path}/` == this.settings.classFilesPath) {
					const modal = new FileClassAttributeSelectModal(this, leaf.view.file)
					modal.open()
				} else {
					const notice = new Notice("This is not a fileClass", 2500)
				}
			},
		});

		new linkContextMenu(this)
	}

	initViewObservers(plugin: SuperchargedLinks) {
		// Reset observers
		plugin.observers.forEach(([observer, type ]) => {
			observer.disconnect();
		});
		plugin.observers = [];

		// Register new observers
		plugin.registerViewType('backlink', plugin, ".tree-item-inner", true);
		plugin.registerViewType('outgoing-link', plugin, ".tree-item-inner", true);
		plugin.registerViewType('search', plugin, ".tree-item-inner");
		plugin.registerViewType('BC-matrix', plugin, '.BC-Link');
		plugin.registerViewType('BC-ducks', plugin, '.internal-link');
		plugin.registerViewType('BC-tree', plugin, 'a.internal-link');
		plugin.registerViewType('graph-analysis', plugin, '.internal-link');
		plugin.registerViewType('starred', plugin, '.nav-file-title-content');
		plugin.registerViewType('file-explorer', plugin, '.nav-file-title-content');
		plugin.registerViewType('recent-files', plugin, '.nav-file-title-content');
		// If backlinks in editor is on
		// @ts-ignore
		if (plugin.app?.internalPlugins?.plugins?.backlink?.instance?.options?.backlinkInDocument) {
			plugin.registerViewType('markdown', plugin, '.tree-item-inner', true);
		}
	}

	initModalObservers(plugin: SuperchargedLinks) {
		const config = {
			subtree: false,
			childList: true,
			attributes: false
		};

		this.modalObserver = new MutationObserver(records => {
			records.forEach((mutation) => {
				if (mutation.type === 'childList') {
					mutation.addedNodes.forEach(n => {
						if ('className' in n &&
							// @ts-ignore
							(n.className.includes('modal-container') && plugin.settings.enableQuickSwitcher
								// @ts-ignore
								|| n.className.includes('suggestion-container') && plugin.settings.enableSuggestor)) {
							let selector = ".suggestion-item, .suggestion-note, .another-quick-switcher__item__title";
							// @ts-ignore
							if (n.className.includes('suggestion-container')) {
								selector = ".suggestion-content, .suggestion-note";
							}
							plugin.updateContainer(n as HTMLElement, plugin, selector);
							plugin._watchContainer(null, n as HTMLElement, plugin, selector);
						}
					});
				}
			});
		});
		this.modalObserver.observe(document.body, config);
	}

	registerViewType(viewTypeName: string, plugin: SuperchargedLinks, selector: string, updateDynamic = false ){
		const leaves = this.app.workspace.getLeavesOfType(viewTypeName);
		if (leaves.length > 1) {
			for (let i=0; i < leaves.length; i++) {
				const container = leaves[i].view.containerEl;
				if (updateDynamic) {
					plugin._watchContainerDynamic(viewTypeName + i, container, plugin, selector)
				}
				else {
					plugin._watchContainer(viewTypeName + i, container, plugin, selector);
				}
			}
		}
		else if (leaves.length < 1) return;
		else {
			const container = leaves[0].view.containerEl;
			this.updateContainer(container, plugin, selector);
			if (updateDynamic) {
				plugin._watchContainerDynamic(viewTypeName, container, plugin, selector)
			}
			else {
				plugin._watchContainer(viewTypeName, container, plugin, selector);
			}
		}
	}

	updateContainer(container: HTMLElement, plugin: SuperchargedLinks, selector: string) {
		if (!plugin.settings.enableBacklinks) return;
		const nodes = container.findAll(selector);
		for (let i = 0; i < nodes.length; ++i)  {
			const el = nodes[i] as HTMLElement;
			updateDivExtraAttributes(plugin.app, plugin.settings, el, "");
		}
	}

	removeFromContainer(container: HTMLElement, selector: string) {
		const nodes = container.findAll(selector);
		for (let i = 0; i < nodes.length; ++i)  {
		    const el = nodes[i] as HTMLElement;
			clearExtraAttributes(el);
		}
	}

	_watchContainer(viewType: string, container: HTMLElement, plugin: SuperchargedLinks, selector: string) {
		let observer = new MutationObserver((records, _) => {
			 plugin.updateContainer(container, plugin, selector);
		});
		observer.observe(container, { subtree: true, childList: true, attributes: false });
		if (viewType) {
			plugin.observers.push([observer, viewType, selector]);
		}
	}

	_watchContainerDynamic(viewType: string, container: HTMLElement, plugin: SuperchargedLinks, selector: string, own_class='tree-item-inner', parent_class='tree-item') {
		// Used for efficient updating of the backlinks panel
		// Only loops through newly added DOM nodes instead of changing all of them
		let observer = new MutationObserver((records, _) => {
			records.forEach((mutation) => {
				if (mutation.type === 'childList') {
					mutation.addedNodes.forEach((n) => {
						if ('className' in n) {
							// @ts-ignore
							if (n.className.includes && typeof n.className.includes === 'function' && n.className.includes(parent_class)) {
								const fileDivs = (n as HTMLElement).getElementsByClassName(own_class);
								for (let i = 0; i < fileDivs.length; ++i) {
									const link = fileDivs[i] as HTMLElement;
									updateDivExtraAttributes(plugin.app, plugin.settings, link, "");
								}
							}
						}
					});
				}
			});
		});
		observer.observe(container, { subtree: true, childList: true, attributes: false });
		plugin.observers.push([observer, viewType, selector]);
	}


	onunload() {
		this.observers.forEach(([observer, type, own_class ]) => {
			observer.disconnect();
			const leaves = this.app.workspace.getLeavesOfType(type);
			leaves.forEach(leaf => {
				this.removeFromContainer(leaf.view.containerEl, own_class);
			})
		});
		this.modalObserver.disconnect();
		console.log('Supercharged links unloaded');
	}

	async loadSettings() {
		this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
	}

	async saveSettings() {
		this.settings.presetFields = this.initialProperties
		await this.saveData(this.settings);
	}
}
Example #17
Source File: main.ts    From better-word-count with MIT License 4 votes vote down vote up
export default class BetterWordCount extends Plugin {
  public static statusBar: StatusBar;
  // public currentFile: TFile;
  // public settings: BetterWordCountSettings;
  // public view: StatsView;
  // public dataManager: DataManager;
  // public barManager: BarManager;

  // onunload(): void {
  //   this.app.workspace
  //     .getLeavesOfType(VIEW_TYPE_STATS)
  //     .forEach((leaf) => leaf.detach());
  // }

  onload() {
    let statusBarEl = this.addStatusBarItem();
    BetterWordCount.statusBar = new StatusBar(statusBarEl);

    this.createCMExtension();

    // this.settings = Object.assign(DEFAULT_SETTINGS, await this.loadData());
    // this.addSettingTab(new BetterWordCountSettingsTab(this.app, this));

    // let statusBarElTest = this.addStatusBarItem();
    // statusBarElTest.setText("§l§aTest§r");
    // let statusBarEl = this.addStatusBarItem();
    // this.statusBar = new StatusBar(statusBarEl);
    // this.statusBar.displayText("Awesome");
    // this.barManager = new BarManager(
    //   this.statusBar,
    //   this.settings,
    //   this.app.vault,
    //   this.app.metadataCache
    // );

    // if (this.settings.collectStats) {
    //   this.dataManager = new DataManager(
    //     this.app.vault,
    //     this.app.metadataCache
    //   );
    // }

    // this.registerEvent(
    //   this.app.workspace.on("active-leaf-change", this.activeLeafChange, this)
    // );

    // this.registerCodeMirror((cm: CodeMirror.Editor) => {
    //   cm.on("cursorActivity", (cm: CodeMirror.Editor) =>
    //     this.barManager.cursorActivity(cm)
    //   );
    // });

    
    

    // if (this.settings.collectStats) {
    //   this.registerEvent(
    //     this.app.workspace.on(
    //       "quick-preview",
    //       this.dataManager.debounceChange,
    //       this.dataManager
    //     )
    //   );

    //   this.registerInterval(
    //     window.setInterval(() => {
    //       this.dataManager.setTotalStats();
    //     }, 1000 * 60)
    //   );
    // }

    // addIcon(STATS_ICON_NAME, STATS_ICON);

    // this.addCommand({
    //   id: "show-vault-stats-view",
    //   name: "Open Statistics",
    //   checkCallback: (checking: boolean) => {
    //     if (checking) {
    //       return this.app.workspace.getLeavesOfType("vault-stats").length === 0;
    //     }
    //     this.initLeaf();
    //   },
    // });

    // this.registerView(
    //   VIEW_TYPE_STATS,
    //   (leaf: WorkspaceLeaf) => (this.view = new StatsView(leaf))
    // );

    // if (this.app.workspace.layoutReady) {
    //   this.initLeaf();
    // } else {
    //   this.app.workspace.onLayoutReady(() => this.initLeaf());
    // }
  }

  // activeLeafChange(leaf: WorkspaceLeaf) {
  //   if (!(leaf.view.getViewType() === "markdown")) {
  //     this.barManager.updateAltStatusBar();
  //   }
  // }

  // async saveSettings(): Promise<void> {
  //   await this.saveData(this.settings);
  // }

  // initLeaf(): void {
  //   if (this.app.workspace.getLeavesOfType(VIEW_TYPE_STATS).length) {
  //     return;
  //   }
  //   this.app.workspace.getRightLeaf(false).setViewState({
  //     type: VIEW_TYPE_STATS,
  //   });
  // }


  createCMExtension() {

    const cmStateField = StateField.define<DecorationSet>({
      create(state: EditorState) {
        return  Decoration.none;
      },
      update(effects: DecorationSet, tr: Transaction) {
        let text = "";
        const selection = tr.newSelection.main;
        if (selection.empty) {
          const textIter = tr.newDoc.iter();
          while (!textIter.done) {
            text = text + textIter.next().value;
          }
        } else {
          const textIter = tr.newDoc.iterRange(selection.from, selection.to);
          while (!textIter.done) {
            text = text + textIter.next().value;
          }
        }

        BetterWordCount.updateStatusBar(text);
        
        return effects;
      },

      provide: (f: any) => EditorView.decorations.from(f),
    });
    
    this.registerEditorExtension(cmStateField);
  }

  static updateStatusBar(text: string) {
    const words = this.getWordCount(text);
    const chars = this.getCharacterCount(text);

    this.statusBar.displayText(`${words} words ${chars} characters`);
  }

  static getWordCount(text: string): number {
    const spaceDelimitedChars =
      /A-Za-z\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AD\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC/
        .source;
    const nonSpaceDelimitedWords =
      /\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u4E00-\u9FD5/.source;
  
    const nonSpaceDelimitedWordsOther =
      /[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u4E00-\u9FD5]{1}/
        .source;
  
    const pattern = new RegExp(
      [
        `(?:[0-9]+(?:(?:,|\\.)[0-9]+)*|[\\-${spaceDelimitedChars}])+`,
        nonSpaceDelimitedWords,
        nonSpaceDelimitedWordsOther,
      ].join("|"),
      "g"
    );
    return (text.match(pattern) || []).length;
  }
  
  static getCharacterCount(text: string): number {
    return text.length;
  }

  
}
Example #18
Source File: main.ts    From obsidian-dictionary with GNU Affero General Public License v3.0 4 votes vote down vote up
export default class DictionaryPlugin extends Plugin {
    settings: DictionarySettings;
    manager: APIManager;
    localDictionary: LocalDictionaryBuilder;
    synonymPopover: SynonymPopover | null = null;
    cache: DictionaryCache;

    async onload(): Promise<void> {
        console.log('loading dictionary');

        await Promise.all([this.loadSettings(), this.loadCache()]);

        addIcons();

        this.addSettingTab(new SettingsTab(this.app, this));

        this.manager = new APIManager(this);

        this.registerView(VIEW_TYPE, (leaf) => {
            return new DictionaryView(leaf, this);
        });

        this.addCommand({
            id: 'dictionary-open-view',
            name: t('Open Dictionary View'),
            callback: async () => {
                if (this.app.workspace.getLeavesOfType(VIEW_TYPE).length == 0) {
                    await this.app.workspace.getRightLeaf(false).setViewState({
                        type: VIEW_TYPE,
                    });
                }
                this.app.workspace.revealLeaf(this.app.workspace.getLeavesOfType(VIEW_TYPE).first());
                dispatchEvent(new Event("dictionary-focus-on-search"));
            },
        });

        this.addCommand({
            id: 'dictionary-open-language-switcher',
            name: t('Open Language Switcher'),
            callback: () => {
                new LanguageChooser(this.app, this).open();
            },
        });

        this.registerDomEvent(document.body, "pointerup", () => {
            if (!this.settings.shouldShowSynonymPopover) {
                return;
            }
            this.handlePointerUp();
        });
        this.registerDomEvent(window, "keydown", () => {
            // Destroy the popover if it's open
            if (this.synonymPopover) {
                this.synonymPopover.destroy();
                this.synonymPopover = null;
            }
        });

        this.registerDomEvent(document.body, "contextmenu", (event) => {
            //@ts-ignore
            if (this.settings.shouldShowCustomContextMenu && event.path.find((el: HTMLElement) => {
                try {
                    return el.hasClass("markdown-preview-view");
                } catch (error) {
                    return false;
                }
            })) {
                const selection = window.getSelection().toString();
                if (selection && this.app.workspace.activeLeaf?.getViewState()?.state.mode === "preview") {
                    event.preventDefault();

                    const fileMenu = new Menu(this.app);

                    fileMenu.addItem((item) => {
                        item.setTitle(t('Copy'))
                            .setIcon('copy')
                            .onClick((_) => {
                                copy(selection);
                            });
                    });

                    if (selection.trim().split(" ").length === 1) {
                        fileMenu.addItem((item) => {
                            item.setTitle(t('Look up'))
                                .setIcon('quote-glyph')
                                .onClick(async (_) => {
                                    let leaf: WorkspaceLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE).first();
                                    if (!leaf) {
                                        leaf = this.app.workspace.getRightLeaf(false);
                                        await leaf.setViewState({
                                            type: VIEW_TYPE,
                                        });
                                    }
                                    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                                    //@ts-ignore
                                    leaf.view.query(selection.trim());
                                    this.app.workspace.revealLeaf(leaf);
                                });
                        });
                    }

                    fileMenu.showAtPosition({ x: event.clientX, y: event.clientY });
                }
            }
        });

        this.localDictionary = new LocalDictionaryBuilder(this);
        
        this.registerEvent(this.app.workspace.on('editor-menu', this.handleContextMenuHelper));

        this.registerEvent(this.app.workspace.on('file-open', (file) => {
            if (file && this.settings.getLangFromFile) {
                let lang = this.app.metadataCache.getFileCache(file).frontmatter?.lang ?? null;
                if (!lang) {
                    lang = this.app.metadataCache.getFileCache(file).frontmatter?.language ?? null;
                }
                if (lang && Object.values(RFC).contains(lang)) {
                    this.settings.defaultLanguage = Object.keys(RFC)[Object.values(RFC).indexOf(lang)] as keyof APISettings;
                } else {
                    this.settings.defaultLanguage = this.settings.normalLang;
                }
                this.saveSettings();
            }
        }));
    }

    onunload(): void {
        console.log('unloading dictionary');
    }

    handleContextMenuHelper = (menu: Menu, editor: Editor, _: MarkdownView): void => {
        handleContextMenu(menu, editor, this);
    };

    // Open the synonym popover if a word is selected
    // This is debounced to handle double clicks
    handlePointerUp = debounce(
        () => {

            const activeLeaf = this.app.workspace.activeLeaf;

            if (activeLeaf?.view instanceof MarkdownView) {
                const view = activeLeaf.view;

                if (view.getMode() === 'source') {
                    const editor = view.editor;
                    const selection = editor.getSelection();

                    // Return early if we don't have anything selected, or if
                    // multiple words are selected
                    if (!selection || /\s/.test(selection)) return;

                    const cursor = editor.getCursor('from');
                    const line = editor.getLine(cursor.line);

                    let coords: Coords;

                    // Get the cursor position using the appropriate CM5 or CM6 interface
                    if ((editor as any).cursorCoords) {
                        coords = (editor as any).cursorCoords(true, 'window');
                    } else if ((editor as any).coordsAtPos) {
                        const offset = editor.posToOffset(cursor);
                        coords = (editor as any).cm.coordsAtPos?.(offset) ?? (editor as any).coordsAtPos(offset);
                    } else {
                        return;
                    }

                    this.synonymPopover = new SynonymPopover({
                        apiManager: this.manager,
                        advancedPoS: this.settings.advancedSynonymAnalysis,
                        coords,
                        cursor,
                        line,
                        selection,
                        onSelect: (replacement) => {
                            editor.replaceSelection(matchCasing(replacement, selection));
                        }
                    });
                }
            }
        },
        300,
        true
    );

    async loadSettings(): Promise<void> {
        this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
    }

    async loadCache(): Promise<void> {
        this.cache = Object.assign({}, DEFAULT_CACHE, await this.loadCacheFromDisk());
    }

    async loadCacheFromDisk(): Promise<DictionaryCache> {
        const path = normalizePath(`${this.manifest.dir}/cache.json`);
        if (!(await this.app.vault.adapter.exists(path))) {
            await this.app.vault.adapter.write(path, "{}");
        }
        return JSON.parse(await this.app.vault.adapter.read(path)) as DictionaryCache;
    }

    async saveCache(): Promise<void> {
        await this.app.vault.adapter.write(normalizePath(`${this.manifest.dir}/cache.json`), JSON.stringify(this.cache));
    }

    async saveSettings(): Promise<void> {
        await this.saveData(this.settings);
    }
}
Example #19
Source File: main.ts    From obsidian-linter with MIT License 4 votes vote down vote up
export default class LinterPlugin extends Plugin {
    settings: LinterSettings;
    private eventRef: EventRef;

    async onload() {
      console.log('Loading Linter plugin');
      await this.loadSettings();

      this.addCommand({
        id: 'lint-file',
        name: 'Lint the current file',
        editorCallback: (editor) => this.runLinterEditor(editor),
        hotkeys: [
          {
            modifiers: ['Mod', 'Alt'],
            key: 'l',
          },
        ],
      });

      this.addCommand({
        id: 'lint-all-files',
        name: 'Lint all files in the vault',
        callback: () => {
          const startMessage = 'This will edit all of your files and may introduce errors.';
          const submitBtnText = 'Lint All';
          const submitBtnNoticeText = 'Linting all files...';
          new LintConfirmationModal(this.app, startMessage, submitBtnText, submitBtnNoticeText, () =>{
            return this.runLinterAllFiles(this.app);
          }).open();
        },
      });

      this.addCommand({
        id: 'lint-all-files-in-folder',
        name: 'Lint all files in the current folder',
        editorCallback: (_) => {
          this.createFolderLintModal(this.app.workspace.getActiveFile().parent);
        },
      });

      // https://github.com/mgmeyers/obsidian-kanban/blob/main/src/main.ts#L239-L251
      this.registerEvent(
          this.app.workspace.on('file-menu', (menu, file: TFile) => {
            // Add a menu item to the folder context menu to create a board
            if (file instanceof TFolder) {
              menu.addItem((item) => {
                item
                    .setTitle('Lint folder')
                    .setIcon('wrench-screwdriver-glyph')
                    .onClick(() => this.createFolderLintModal(file));
              });
            }
          }),
      );

      this.eventRef = this.app.workspace.on('file-menu',
          (menu, file, source) => this.onMenuOpenCallback(menu, file, source));
      this.registerEvent(this.eventRef);

      // Source for save setting
      // https://github.com/hipstersmoothie/obsidian-plugin-prettier/blob/main/src/main.ts
      const saveCommandDefinition = (this.app as any).commands?.commands?.[
        'editor:save-file'
      ];
      const save = saveCommandDefinition?.callback;

      if (typeof save === 'function') {
        saveCommandDefinition.callback = () => {
          if (this.settings.lintOnSave) {
            const editor = this.app.workspace.getActiveViewOfType(MarkdownView).editor;
            const file = this.app.workspace.getActiveFile();

            if (!this.shouldIgnoreFile(file)) {
              this.runLinterEditor(editor);
            }
          }
        };
      }

      this.addSettingTab(new SettingTab(this.app, this));
    }

    async onunload() {
      console.log('Unloading Linter plugin');
      this.app.workspace.offref(this.eventRef);
    }

    async loadSettings() {
      this.settings = {
        ruleConfigs: {},
        lintOnSave: false,
        displayChanged: true,
        foldersToIgnore: [],
      };
      const data = await this.loadData();
      const storedSettings = data || {};

      for (const rule of rules) {
        this.settings.ruleConfigs[rule.name] = rule.getDefaultOptions();
        if (storedSettings?.ruleConfigs && storedSettings?.ruleConfigs[rule.name]) {
          Object.assign(this.settings.ruleConfigs[rule.name], storedSettings.ruleConfigs[rule.name]);

          // For backwards compatibility, if enabled is set, copy it to the new option and remove it
          if (storedSettings.ruleConfigs[rule.name].Enabled !== undefined) {
            const newEnabledOptionName = rule.enabledOptionName();
            this.settings.ruleConfigs[rule.name][newEnabledOptionName] = storedSettings.ruleConfigs[rule.name].Enabled;
            delete this.settings.ruleConfigs[rule.name].Enabled;
          }
        }
      }

      if (Object.prototype.hasOwnProperty.call(storedSettings, 'lintOnSave')) {
        this.settings.lintOnSave = storedSettings.lintOnSave;
      }
      if (Object.prototype.hasOwnProperty.call(storedSettings, 'displayChanged')) {
        this.settings.displayChanged = storedSettings.displayChanged;
      }
      if (Object.prototype.hasOwnProperty.call(storedSettings, 'foldersToIgnore')) {
        this.settings.foldersToIgnore = storedSettings.foldersToIgnore;
      }
    }
    async saveSettings() {
      await this.saveData(this.settings);
    }

    onMenuOpenCallback(menu: Menu, file: TAbstractFile, source: string) {
      if (file instanceof TFile && file.extension === 'md') {
        menu.addItem((item) => {
          item.setIcon('wrench-screwdriver-glyph');
          item.setTitle('Lint file');
          item.onClick(async (evt) => {
            this.runLinterFile(file);
          });
        });
      }
    }

    lintText(oldText: string, file: TFile) {
      let newText = oldText;

      // remove hashtags from tags before parsing yaml
      const tag_rule = rules.find((rule) => rule.name === 'Format Tags in YAML');
      const options = tag_rule.getOptions(this.settings);
      if (options[tag_rule.enabledOptionName()]) {
        newText = tag_rule.apply(newText, options);
      }

      const disabledRules = getDisabledRules(newText);

      for (const rule of rules) {
        if (disabledRules.includes(rule.alias())) {
          continue;
        }

        const options: Options =
          Object.assign({
            'metadata: file created time': moment(file.stat.ctime).format(),
            'metadata: file modified time': moment(file.stat.mtime).format(),
            'metadata: file name': file.basename,
          }, rule.getOptions(this.settings));

        if (options[rule.enabledOptionName()]) {
          // console.log(`Running ${rule.name}`);
          newText = rule.apply(newText, options);
        }
      }

      return newText;
    }

    shouldIgnoreFile(file: TFile) {
      for (const folder of this.settings.foldersToIgnore) {
        if (folder.length > 0 && file.path.startsWith(folder)) {
          return true;
        }
      }
      return false;
    }

    async runLinterFile(file: TFile) {
      const oldText = stripCr(await this.app.vault.read(file));

      try {
        const newText = this.lintText(oldText, file);
        await this.app.vault.modify(file, newText);
      } catch (error) {
        new Notice('An error occured during linting. See console for details');
        console.log(`Linting error in file: ${file.path}`);
        console.error(error);
      }
    }

    async runLinterAllFiles(app: App) {
      await Promise.all(app.vault.getMarkdownFiles().map(async (file) => {
        if (!this.shouldIgnoreFile(file)) {
          await this.runLinterFile(file);
        }
      }));
      new Notice('Linted all files');
    }

    async runLinterAllFilesInFolder(folder: TFolder) {
      console.log('Linting folder ' + folder.name);

      let lintedFiles = 0;
      await Promise.all(this.app.vault.getMarkdownFiles().map(async (file) => {
        if (this.convertPathToNormalizedString(file.path).startsWith(this.convertPathToNormalizedString(folder.path) + '|') && !this.shouldIgnoreFile(file)) {
          await this.runLinterFile(file);
          lintedFiles++;
        }
      }));
      new Notice('Linted all ' + lintedFiles + ' files in ' + folder.name);
    }

    // convert the path separators to | in order to "normalize" the path for better comparisons
    convertPathToNormalizedString(path: string): string {
      return path.replace('\\', '|').replace('/', '|');
    }

    // handles the creation of the folder linting modal since this happens in multiple places and it should be consistent
    createFolderLintModal(folder: TFolder) {
      const startMessage = 'This will edit all of your files in ' + folder.name + ' including files in its subfolders which may introduce errors.';
      const submitBtnText = 'Lint All Files in ' + folder.name;
      const submitBtnNoticeText = 'Linting all files in ' + folder.name + '...';
      new LintConfirmationModal(this.app, startMessage, submitBtnText, submitBtnNoticeText, () => this.runLinterAllFilesInFolder(folder)).open();
    }

    runLinterEditor(editor: Editor) {
      console.log('running linter');

      const file = this.app.workspace.getActiveFile();
      const oldText = editor.getValue();
      const newText = this.lintText(oldText, file);

      // Replace changed lines
      const dmp = new DiffMatchPatch.diff_match_patch(); // eslint-disable-line new-cap
      const changes = dmp.diff_main(oldText, newText);
      let curText = '';
      changes.forEach((change) => {
        function endOfDocument(doc: string) {
          const lines = doc.split('\n');
          return {line: lines.length - 1, ch: lines[lines.length - 1].length};
        }

        const [type, value] = change;

        if (type == DiffMatchPatch.DIFF_INSERT) {
          editor.replaceRange(value, endOfDocument(curText));
          curText += value;
        } else if (type == DiffMatchPatch.DIFF_DELETE) {
          const start = endOfDocument(curText);
          let tempText = curText;
          tempText += value;
          const end = endOfDocument(tempText);
          editor.replaceRange('', start, end);
        } else {
          curText += value;
        }
      });

      const charsAdded = changes.map((change) => change[0] == DiffMatchPatch.DIFF_INSERT ? change[1].length : 0).reduce((a, b) => a + b, 0);
      const charsRemoved = changes.map((change) => change[0] == DiffMatchPatch.DIFF_DELETE ? change[1].length : 0).reduce((a, b) => a + b, 0);
      this.displayChangedMessage(charsAdded, charsRemoved);
    }

    private displayChangedMessage(charsAdded: number, charsRemoved: number) {
      if (this.settings.displayChanged) {
        const message = dedent`
        ${charsAdded} characters added
        ${charsRemoved} characters removed
      `;
        new Notice(message);
      }
    }
}
Example #20
Source File: main.ts    From obsidian-citation-plugin with MIT License 4 votes vote down vote up
export default class CitationPlugin extends Plugin {
  settings: CitationsPluginSettings;
  library: Library;

  // Template compilation options
  private templateSettings = {
    noEscape: true,
  };

  private loadWorker = new WorkerManager(new LoadWorker(), {
    blockingChannel: true,
  });

  loadErrorNotifier = new Notifier(
    'Unable to load citations. Please update Citations plugin settings.',
  );
  literatureNoteErrorNotifier = new Notifier(
    'Unable to access literature note. Please check that the literature note folder exists, or update the Citations plugin settings.',
  );

  get editor(): CodeMirror.Editor {
    const view = this.app.workspace.activeLeaf.view;
    if (!(view instanceof MarkdownView)) return null;

    const sourceView = view.sourceMode;
    return (sourceView as MarkdownSourceView).cmEditor;
  }

  async loadSettings(): Promise<void> {
    this.settings = new CitationsPluginSettings();

    const loadedSettings = await this.loadData();
    if (!loadedSettings) return;

    const toLoad = [
      'citationExportPath',
      'citationExportFormat',
      'literatureNoteTitleTemplate',
      'literatureNoteFolder',
      'literatureNoteContentTemplate',
      'markdownCitationTemplate',
      'alternativeMarkdownCitationTemplate',
    ];
    toLoad.forEach((setting) => {
      if (setting in loadedSettings) {
        (this.settings as IIndexable)[setting] = loadedSettings[setting];
      }
    });
  }

  async saveSettings(): Promise<void> {
    await this.saveData(this.settings);
  }

  onload(): void {
    this.loadSettings().then(() => this.init());
  }

  async init(): Promise<void> {
    if (this.settings.citationExportPath) {
      // Load library for the first time
      this.loadLibrary();

      // Set up a watcher to refresh whenever the export is updated
      try {
        // Wait until files are finished being written before going ahead with
        // the refresh -- here, we request that `change` events be accumulated
        // until nothing shows up for 500 ms
        // TODO magic number
        const watchOptions = {
          awaitWriteFinish: {
            stabilityThreshold: 500,
          },
        };

        chokidar
          .watch(
            this.resolveLibraryPath(this.settings.citationExportPath),
            watchOptions,
          )
          .on('change', () => {
            this.loadLibrary();
          });
      } catch {
        this.loadErrorNotifier.show();
      }
    } else {
      // TODO show warning?
    }

    this.addCommand({
      id: 'open-literature-note',
      name: 'Open literature note',
      hotkeys: [{ modifiers: ['Ctrl', 'Shift'], key: 'o' }],
      callback: () => {
        const modal = new OpenNoteModal(this.app, this);
        modal.open();
      },
    });

    this.addCommand({
      id: 'update-bib-data',
      name: 'Refresh citation database',
      hotkeys: [{ modifiers: ['Ctrl', 'Shift'], key: 'r' }],
      callback: () => {
        this.loadLibrary();
      },
    });

    this.addCommand({
      id: 'insert-citation',
      name: 'Insert literature note link',
      hotkeys: [{ modifiers: ['Ctrl', 'Shift'], key: 'e' }],
      callback: () => {
        const modal = new InsertNoteLinkModal(this.app, this);
        modal.open();
      },
    });

    this.addCommand({
      id: 'insert-literature-note-content',
      name: 'Insert literature note content in the current pane',
      callback: () => {
        const modal = new InsertNoteContentModal(this.app, this);
        modal.open();
      },
    });

    this.addCommand({
      id: 'insert-markdown-citation',
      name: 'Insert Markdown citation',
      callback: () => {
        const modal = new InsertCitationModal(this.app, this);
        modal.open();
      },
    });

    this.addSettingTab(new CitationSettingTab(this.app, this));
  }

  /**
   * Resolve a provided library path, allowing for relative paths rooted at
   * the vault directory.
   */
  resolveLibraryPath(rawPath: string): string {
    const vaultRoot =
      this.app.vault.adapter instanceof FileSystemAdapter
        ? this.app.vault.adapter.getBasePath()
        : '/';
    return path.resolve(vaultRoot, rawPath);
  }

  async loadLibrary(): Promise<Library> {
    console.debug('Citation plugin: Reloading library');
    if (this.settings.citationExportPath) {
      const filePath = this.resolveLibraryPath(
        this.settings.citationExportPath,
      );

      // Unload current library.
      this.library = null;

      return FileSystemAdapter.readLocalFile(filePath)
        .then((buffer) => {
          // If there is a remaining error message, hide it
          this.loadErrorNotifier.hide();

          // Decode file as UTF-8.
          const dataView = new DataView(buffer);
          const decoder = new TextDecoder('utf8');
          const value = decoder.decode(dataView);

          return this.loadWorker.post({
            databaseRaw: value,
            databaseType: this.settings.citationExportFormat,
          });
        })
        .then((entries: EntryData[]) => {
          let adapter: new (data: EntryData) => Entry;
          let idKey: string;

          switch (this.settings.citationExportFormat) {
            case 'biblatex':
              adapter = EntryBibLaTeXAdapter;
              idKey = 'key';
              break;
            case 'csl-json':
              adapter = EntryCSLAdapter;
              idKey = 'id';
              break;
          }

          this.library = new Library(
            Object.fromEntries(
              entries.map((e) => [(e as IIndexable)[idKey], new adapter(e)]),
            ),
          );
          console.debug(
            `Citation plugin: successfully loaded library with ${this.library.size} entries.`,
          );

          return this.library;
        })
        .catch((e) => {
          if (e instanceof WorkerManagerBlocked) {
            // Silently catch WorkerManager error, which will be thrown if the
            // library is already being loaded
            return;
          }

          console.error(e);
          this.loadErrorNotifier.show();

          return null;
        });
    } else {
      console.warn(
        'Citations plugin: citation export path is not set. Please update plugin settings.',
      );
    }
  }

  /**
   * Returns true iff the library is currently being loaded on the worker thread.
   */
  get isLibraryLoading(): boolean {
    return this.loadWorker.blocked;
  }

  get literatureNoteTitleTemplate(): Template {
    return compileTemplate(
      this.settings.literatureNoteTitleTemplate,
      this.templateSettings,
    );
  }

  get literatureNoteContentTemplate(): Template {
    return compileTemplate(
      this.settings.literatureNoteContentTemplate,
      this.templateSettings,
    );
  }

  get markdownCitationTemplate(): Template {
    return compileTemplate(
      this.settings.markdownCitationTemplate,
      this.templateSettings,
    );
  }

  get alternativeMarkdownCitationTemplate(): Template {
    return compileTemplate(
      this.settings.alternativeMarkdownCitationTemplate,
      this.templateSettings,
    );
  }

  getTitleForCitekey(citekey: string): string {
    const unsafeTitle = this.literatureNoteTitleTemplate(
      this.library.getTemplateVariablesForCitekey(citekey),
    );
    return unsafeTitle.replace(DISALLOWED_FILENAME_CHARACTERS_RE, '_');
  }

  getPathForCitekey(citekey: string): string {
    const title = this.getTitleForCitekey(citekey);
    // TODO escape note title
    return path.join(this.settings.literatureNoteFolder, `${title}.md`);
  }

  getInitialContentForCitekey(citekey: string): string {
    return this.literatureNoteContentTemplate(
      this.library.getTemplateVariablesForCitekey(citekey),
    );
  }

  getMarkdownCitationForCitekey(citekey: string): string {
    return this.markdownCitationTemplate(
      this.library.getTemplateVariablesForCitekey(citekey),
    );
  }

  getAlternativeMarkdownCitationForCitekey(citekey: string): string {
    return this.alternativeMarkdownCitationTemplate(
      this.library.getTemplateVariablesForCitekey(citekey),
    );
  }

  /**
   * Run a case-insensitive search for the literature note file corresponding to
   * the given citekey. If no corresponding file is found, create one.
   */
  async getOrCreateLiteratureNoteFile(citekey: string): Promise<TFile> {
    const path = this.getPathForCitekey(citekey);
    const normalizedPath = normalizePath(path);

    let file = this.app.vault.getAbstractFileByPath(normalizedPath);
    if (file == null) {
      // First try a case-insensitive lookup.
      const matches = this.app.vault
        .getMarkdownFiles()
        .filter((f) => f.path.toLowerCase() == normalizedPath.toLowerCase());
      if (matches.length > 0) {
        file = matches[0];
      } else {
        try {
          file = await this.app.vault.create(
            path,
            this.getInitialContentForCitekey(citekey),
          );
        } catch (exc) {
          this.literatureNoteErrorNotifier.show();
          throw exc;
        }
      }
    }

    return file as TFile;
  }

  async openLiteratureNote(citekey: string, newPane: boolean): Promise<void> {
    this.getOrCreateLiteratureNoteFile(citekey)
      .then((file: TFile) => {
        this.app.workspace.getLeaf(newPane).openFile(file);
      })
      .catch(console.error);
  }

  async insertLiteratureNoteLink(citekey: string): Promise<void> {
    this.getOrCreateLiteratureNoteFile(citekey)
      .then((file: TFile) => {
        const useMarkdown: boolean = (<VaultExt>this.app.vault).getConfig(
          'useMarkdownLinks',
        );
        const title = this.getTitleForCitekey(citekey);

        let linkText: string;
        if (useMarkdown) {
          const uri = encodeURI(
            this.app.metadataCache.fileToLinktext(file, '', false),
          );
          linkText = `[${title}](${uri})`;
        } else {
          linkText = `[[${title}]]`;
        }

        this.editor.replaceRange(linkText, this.editor.getCursor());
      })
      .catch(console.error);
  }

  /**
   * Format literature note content for a given reference and insert in the
   * currently active pane.
   */
  async insertLiteratureNoteContent(citekey: string): Promise<void> {
    const content = this.getInitialContentForCitekey(citekey);
    this.editor.replaceRange(content, this.editor.getCursor());
  }

  async insertMarkdownCitation(
    citekey: string,
    alternative = false,
  ): Promise<void> {
    const func = alternative
      ? this.getAlternativeMarkdownCitationForCitekey
      : this.getMarkdownCitationForCitekey;
    const citation = func.bind(this)(citekey);

    this.editor.replaceRange(citation, this.editor.getCursor());
  }
}
Example #21
Source File: main.ts    From obsidian-readwise with GNU General Public License v3.0 4 votes vote down vote up
export default class ReadwisePlugin extends Plugin {
  settings: ReadwisePluginSettings;
  fs: DataAdapter;
  vault: Vault;
  scheduleInterval: null | number = null;
  statusBar: StatusBar;

  getErrorMessageFromResponse(response: Response) {
    if (response && response.status === 409) {
      return "Sync in progress initiated by different client";
    }
    if (response && response.status === 417) {
      return "Obsidian export is locked. Wait for an hour.";
    }
    return `${response ? response.statusText : "Can't connect to server"}`;
  }

  handleSyncError(buttonContext: ButtonComponent, msg: string) {
    this.clearSettingsAfterRun();
    this.settings.lastSyncFailed = true;
    this.saveSettings();
    if (buttonContext) {
      this.showInfoStatus(buttonContext.buttonEl.parentElement, msg, "rw-error");
      buttonContext.buttonEl.setText("Run sync");
    } else {
      this.notice(msg, true, 4, true);
    }
  }

  clearSettingsAfterRun() {
    this.settings.isSyncing = false;
    this.settings.currentSyncStatusID = 0;
  }

  handleSyncSuccess(buttonContext: ButtonComponent, msg: string = "Synced", exportID: number = null) {
    this.clearSettingsAfterRun();
    this.settings.lastSyncFailed = false;
    this.settings.currentSyncStatusID = 0;
    if (exportID) {
      this.settings.lastSavedStatusID = exportID;
    }
    this.saveSettings();
    // if we have a button context, update the text on it
    // this is the case if we fired on a "Run sync" click (the button)
    if (buttonContext) {
      this.showInfoStatus(buttonContext.buttonEl.parentNode.parentElement, msg, "rw-success");
      buttonContext.buttonEl.setText("Run sync");
    }
  }

  async getExportStatus(statusID?: number, buttonContext?: ButtonComponent) {
    const statusId = statusID || this.settings.currentSyncStatusID;
    let url = `${baseURL}/api/get_export_status?exportStatusId=${statusId}`;
    let response, data: ExportStatusResponse;
    try {
      response = await fetch(
        url,
        {
          headers: this.getAuthHeaders()
        }
      );
    } catch (e) {
      console.log("Readwise Official plugin: fetch failed in getExportStatus: ", e);
    }
    if (response && response.ok) {
      data = await response.json();
    } else {
      console.log("Readwise Official plugin: bad response in getExportStatus: ", response);
      this.handleSyncError(buttonContext, this.getErrorMessageFromResponse(response));
      return;
    }
    const WAITING_STATUSES = ['PENDING', 'RECEIVED', 'STARTED', 'RETRY'];
    const SUCCESS_STATUSES = ['SUCCESS'];
    if (WAITING_STATUSES.includes(data.taskStatus)) {
      if (data.booksExported) {
        const progressMsg = `Exporting Readwise data (${data.booksExported} / ${data.totalBooks}) ...`;
        this.notice(progressMsg);
      } else {
        this.notice("Building export...");
      }

      // re-try in 1 second
      await new Promise(resolve => setTimeout(resolve, 1000));
      await this.getExportStatus(statusId, buttonContext);
    } else if (SUCCESS_STATUSES.includes(data.taskStatus)) {
      return this.downloadArchive(statusId, buttonContext);
    } else {
      this.handleSyncError(buttonContext, "Sync failed");
    }
  }

  async requestArchive(buttonContext?: ButtonComponent, statusId?: number, auto?: boolean) {

    const parentDeleted = !await this.app.vault.adapter.exists(this.settings.readwiseDir);

    let url = `${baseURL}/api/obsidian/init?parentPageDeleted=${parentDeleted}`;
    if (statusId) {
      url += `&statusID=${statusId}`;
    }
    if (auto) {
      url += `&auto=${auto}`;
    }
    let response, data: ExportRequestResponse;
    try {
      response = await fetch(
        url,
        {
          headers: this.getAuthHeaders()
        }
      );
    } catch (e) {
      console.log("Readwise Official plugin: fetch failed in requestArchive: ", e);
    }
    if (response && response.ok) {
      data = await response.json();
      if (data.latest_id <= this.settings.lastSavedStatusID) {
        this.handleSyncSuccess(buttonContext);
        this.notice("Readwise data is already up to date", false, 4, true);
        return;
      }
      this.settings.currentSyncStatusID = data.latest_id;
      await this.saveSettings();
      if (response.status === 201) {
        this.notice("Syncing Readwise data");
        return this.getExportStatus(data.latest_id, buttonContext);
      } else {
        this.handleSyncSuccess(buttonContext, "Synced", data.latest_id); // we pass the export id to update lastSavedStatusID
        this.notice("Latest Readwise sync already happened on your other device. Data should be up to date", false, 4, true);
      }
    } else {
      console.log("Readwise Official plugin: bad response in requestArchive: ", response);
      this.handleSyncError(buttonContext, this.getErrorMessageFromResponse(response));
      return;
    }
  }

  notice(msg: string, show = false, timeout = 0, forcing: boolean = false) {
    if (show) {
      new Notice(msg);
    }
    // @ts-ignore
    if (!this.app.isMobile) {
      this.statusBar.displayMessage(msg.toLowerCase(), timeout, forcing);
    } else {
      if (!show) {
        new Notice(msg);
      }
    }
  }

  showInfoStatus(container: HTMLElement, msg: string, className = "") {
    let info = container.find('.rw-info-container');
    info.setText(msg);
    info.addClass(className);
  }

  clearInfoStatus(container: HTMLElement) {
    let info = container.find('.rw-info-container');
    info.empty();
  }

  getAuthHeaders() {
    return {
      'AUTHORIZATION': `Token ${this.settings.token}`,
      'Obsidian-Client': `${this.getObsidianClientID()}`,
    };
  }

  async downloadArchive(exportID: number, buttonContext: ButtonComponent): Promise<void> {
    let artifactURL = `${baseURL}/api/download_artifact/${exportID}`;
    if (exportID <= this.settings.lastSavedStatusID) {
      console.log(`Readwise Official plugin: Already saved data from export ${exportID}`);
      this.handleSyncSuccess(buttonContext);
      this.notice("Readwise data is already up to date", false, 4);
      return;
    }

    let response, blob;
    try {
      response = await fetch(
        artifactURL, {headers: this.getAuthHeaders()}
      );
    } catch (e) {
      console.log("Readwise Official plugin: fetch failed in downloadArchive: ", e);
    }
    if (response && response.ok) {
      blob = await response.blob();
    } else {
      console.log("Readwise Official plugin: bad response in downloadArchive: ", response);
      this.handleSyncError(buttonContext, this.getErrorMessageFromResponse(response));
      return;
    }

    this.fs = this.app.vault.adapter;

    const blobReader = new zip.BlobReader(blob);
    const zipReader = new zip.ZipReader(blobReader);
    const entries = await zipReader.getEntries();
    this.notice("Saving files...", false, 30);
    if (entries.length) {
      for (const entry of entries) {
        let bookID: string;
        const processedFileName = normalizePath(entry.filename.replace(/^Readwise/, this.settings.readwiseDir));
        try {
          // ensure the directory exists
          let dirPath = processedFileName.replace(/\/*$/, '').replace(/^(.+)\/[^\/]*?$/, '$1');
          const exists = await this.fs.exists(dirPath);
          if (!exists) {
            await this.fs.mkdir(dirPath);
          }
          // write the actual files
          const contents = await entry.getData(new zip.TextWriter());
          let contentToSave = contents;

          let originalName = processedFileName;
          // extracting book ID from file name
          let split = processedFileName.split("--");
          if (split.length > 1) {
            originalName = split.slice(0, -1).join("--") + ".md";
            bookID = split.last().match(/\d+/g)[0];
            this.settings.booksIDsMap[originalName] = bookID;
          }
          if (await this.fs.exists(originalName)) {
            // if the file already exists we need to append content to existing one
            const existingContent = await this.fs.read(originalName);
            contentToSave = existingContent + contents;
          }
          await this.fs.write(originalName, contentToSave);
          await this.saveSettings();
        } catch (e) {
          console.log(`Readwise Official plugin: error writing ${processedFileName}:`, e);
          this.notice(`Readwise: error while writing ${processedFileName}: ${e}`, true, 4, true);
          if (bookID) {
            this.settings.booksToRefresh.push(bookID);
            await this.saveSettings();
          }
          // communicate with readwise?
        }
      }
    }
    // close the ZipReader
    await zipReader.close();
    await this.acknowledgeSyncCompleted(buttonContext);
    this.handleSyncSuccess(buttonContext, "Synced!", exportID);
    this.notice("Readwise sync completed", true, 1, true);
    // @ts-ignore
    if (this.app.isMobile) {
      this.notice("If you don't see all of your readwise files reload obsidian app", true,);
    }
  }

  async acknowledgeSyncCompleted(buttonContext: ButtonComponent) {
    let response;
    try {
      response = await fetch(
        `${baseURL}/api/obsidian/sync_ack`,
        {
          headers: {...this.getAuthHeaders(), 'Content-Type': 'application/json'},
          method: "POST",
        });
    } catch (e) {
      console.log("Readwise Official plugin: fetch failed to acknowledged sync: ", e);
    }
    if (response && response.ok) {
      return;
    } else {
      console.log("Readwise Official plugin: bad response in acknowledge sync: ", response);
      this.handleSyncError(buttonContext, this.getErrorMessageFromResponse(response));
      return;
    }
  }

  async configureSchedule() {
    const minutes = parseInt(this.settings.frequency);
    let milliseconds = minutes * 60 * 1000; // minutes * seconds * milliseconds
    console.log('Readwise Official plugin: setting interval to ', milliseconds, 'milliseconds');
    window.clearInterval(this.scheduleInterval);
    this.scheduleInterval = null;
    if (!milliseconds) {
      // we got manual option
      return;
    }
    this.scheduleInterval = window.setInterval(() => this.requestArchive(null, null, true), milliseconds);
    this.registerInterval(this.scheduleInterval);
  }

  refreshBookExport(bookIds?: Array<string>) {
    bookIds = bookIds || this.settings.booksToRefresh;
    if (!bookIds.length || !this.settings.refreshBooks) {
      return;
    }
    try {
      fetch(
        `${baseURL}/api/refresh_book_export`,
        {
          headers: {...this.getAuthHeaders(), 'Content-Type': 'application/json'},
          method: "POST",
          body: JSON.stringify({exportTarget: 'obsidian', books: bookIds})
        }
      ).then(response => {
        if (response && response.ok) {
          let booksToRefresh = this.settings.booksToRefresh;
          this.settings.booksToRefresh = booksToRefresh.filter(n => !bookIds.includes(n));
          this.saveSettings();
          return;
        } else {
          console.log(`Readwise Official plugin: saving book id ${bookIds} to refresh later`);
          let booksToRefresh = this.settings.booksToRefresh;
          booksToRefresh.concat(bookIds);
          this.settings.booksToRefresh = booksToRefresh;
          this.saveSettings();
          return;
        }
      });
    } catch (e) {
      console.log("Readwise Official plugin: fetch failed in refreshBookExport: ", e);
    }
  }

  async addBookToRefresh(bookId: string) {
    let booksToRefresh = this.settings.booksToRefresh;
    booksToRefresh.push(bookId);
    this.settings.booksToRefresh = booksToRefresh;
    await this.saveSettings();
  }

  reimportFile(vault: Vault, fileName: string) {
    const bookId = this.settings.booksIDsMap[fileName];
    try {
      fetch(
        `${baseURL}/api/refresh_book_export`,
        {
          headers: {...this.getAuthHeaders(), 'Content-Type': 'application/json'},
          method: "POST",
          body: JSON.stringify({exportTarget: 'obsidian', books: [bookId]})
        }
      ).then(response => {
        if (response && response.ok) {
          let booksToRefresh = this.settings.booksToRefresh;
          this.settings.booksToRefresh = booksToRefresh.filter(n => ![bookId].includes(n));
          this.saveSettings();
          vault.delete(vault.getAbstractFileByPath(fileName));
          this.startSync();
        } else {
          this.notice("Failed to reimport. Please try again", true);
        }
      });
    } catch (e) {
      console.log("Readwise Official plugin: fetch failed in Reimport current file: ", e);
    }
  }

  startSync() {
    if (this.settings.isSyncing) {
      this.notice("Readwise sync already in progress", true);
    } else {
      this.settings.isSyncing = true;
      this.saveSettings();
      this.requestArchive();
    }
    console.log("started sync");
  }

  async onload() {
    // @ts-ignore
    if (!this.app.isMobile) {
      this.statusBar = new StatusBar(this.addStatusBarItem());
      this.registerInterval(
        window.setInterval(() => this.statusBar.display(), 1000)
      );
    }
    await this.loadSettings();
    this.refreshBookExport = debounce(
      this.refreshBookExport.bind(this),
      800,
      true
    );

    this.refreshBookExport(this.settings.booksToRefresh);
    this.app.vault.on("delete", async (file) => {
      const bookId = this.settings.booksIDsMap[file.path];
      if (bookId) {
        await this.addBookToRefresh(bookId);
      }
      this.refreshBookExport();
      delete this.settings.booksIDsMap[file.path];
      this.saveSettings();
    });
    this.app.vault.on("rename", (file, oldPath) => {
      const bookId = this.settings.booksIDsMap[oldPath];
      if (!bookId) {
        return;
      }
      this.settings.booksIDsMap[file.path] = bookId;
      delete this.settings.booksIDsMap[oldPath];
      this.saveSettings();
    });
    if (this.settings.isSyncing) {
      if (this.settings.currentSyncStatusID) {
        await this.getExportStatus();
      } else {
        // we probably got some unhandled error...
        this.settings.isSyncing = false;
        await this.saveSettings();
      }
    }
    this.addCommand({
      id: 'readwise-official-sync',
      name: 'Sync your data now',
      callback: () => {
        this.startSync();
      }
    });
    this.addCommand({
      id: 'readwise-official-format',
      name: 'Customize formatting',
      callback: () => window.open(`${baseURL}/export/obsidian/preferences`)
    });
    this.addCommand({
      id: 'readwise-official-reimport-file',
      name: 'Delete and reimport this document',
      editorCheckCallback: (checking: boolean, editor: Editor, view: MarkdownView) => {
        const activeFilePath = view.file.path;
        const isRWfile = activeFilePath in this.settings.booksIDsMap;
        if (checking) {
          return isRWfile;
        }
        if (this.settings.reimportShowConfirmation) {
          const modal = new Modal(view.app);
          modal.contentEl.createEl(
            'p',
            {
              'text': 'Warning: Proceeding will delete this file entirely (including any changes you made) ' +
                'and then reimport a new copy of your highlights from Readwise.'
            });
          const buttonsContainer = modal.contentEl.createEl('div', {"cls": "rw-modal-btns"});
          const cancelBtn = buttonsContainer.createEl("button", {"text": "Cancel"});
          const confirmBtn = buttonsContainer.createEl("button", {"text": "Proceed", 'cls': 'mod-warning'});
          const showConfContainer = modal.contentEl.createEl('div', {'cls': 'rw-modal-confirmation'});
          showConfContainer.createEl("label", {"attr": {"for": "rw-ask-nl"}, "text": "Don't ask me in the future"});
          const showConf = showConfContainer.createEl("input", {"type": "checkbox", "attr": {"name": "rw-ask-nl"}});
          showConf.addEventListener('change', (ev) => {
            // @ts-ignore
            this.settings.reimportShowConfirmation = !ev.target.checked;
            this.saveSettings();
          });
          cancelBtn.onClickEvent(() => {
            modal.close();
          });
          confirmBtn.onClickEvent(() => {
            this.reimportFile(view.app.vault, activeFilePath);
            modal.close();
          });
          modal.open();
        } else {
          this.reimportFile(view.app.vault, activeFilePath);
        }
      }
    });
    this.registerMarkdownPostProcessor((el, ctx) => {
      if (!ctx.sourcePath.startsWith(this.settings.readwiseDir)) {
        return;
      }
      let matches: string[];
      try {
        // @ts-ignore
        matches = [...ctx.getSectionInfo(el).text.matchAll(/__(.+)__/g)].map((a) => a[1]);
      } catch (TypeError) {
        // failed interaction with a Dataview element
        return;
      }
      const hypers = el.findAll("strong").filter(e => matches.contains(e.textContent));
      hypers.forEach(strongEl => {
        const replacement = el.createEl('span');
        while (strongEl.firstChild) {
          replacement.appendChild(strongEl.firstChild);
        }
        replacement.addClass("rw-hyper-highlight");
        strongEl.replaceWith(replacement);
      });
    });
    this.addSettingTab(new ReadwiseSettingTab(this.app, this));
    await this.configureSchedule();
    if (this.settings.token && this.settings.triggerOnLoad && !this.settings.isSyncing) {
      await this.saveSettings();
      await this.requestArchive(null, null, true);
    }
  }

  onunload() {
    // we're not doing anything here for now...
    return;
  }

  async loadSettings() {
    this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
  }

  async saveSettings() {
    await this.saveData(this.settings);
  }

  getObsidianClientID() {
    let obsidianClientId = window.localStorage.getItem('rw-ObsidianClientId');
    if (obsidianClientId) {
      return obsidianClientId;
    } else {
      obsidianClientId = Math.random().toString(36).substring(2, 15);
      window.localStorage.setItem('rw-ObsidianClientId', obsidianClientId);
      return obsidianClientId;
    }
  }

  async getUserAuthToken(button: HTMLElement, attempt = 0) {
    let uuid = this.getObsidianClientID();

    if (attempt === 0) {
      window.open(`${baseURL}/api_auth?token=${uuid}&service=obsidian`);
    }

    let response, data: ReadwiseAuthResponse;
    try {
      response = await fetch(
        `${baseURL}/api/auth?token=${uuid}`
      );
    } catch (e) {
      console.log("Readwise Official plugin: fetch failed in getUserAuthToken: ", e);
    }
    if (response && response.ok) {
      data = await response.json();
    } else {
      console.log("Readwise Official plugin: bad response in getUserAuthToken: ", response);
      this.showInfoStatus(button.parentElement, "Authorization failed. Try again", "rw-error");
      return;
    }
    if (data.userAccessToken) {
      this.settings.token = data.userAccessToken;
    } else {
      if (attempt > 20) {
        console.log('Readwise Official plugin: reached attempt limit in getUserAuthToken');
        return;
      }
      console.log(`Readwise Official plugin: didn't get token data, retrying (attempt ${attempt + 1})`);
      await new Promise(resolve => setTimeout(resolve, 1000));
      await this.getUserAuthToken(button, attempt + 1);
    }
    await this.saveSettings();
    return true;
  }
}
Example #22
Source File: main.ts    From remotely-save with Apache License 2.0 4 votes vote down vote up
export default class RemotelySavePlugin extends Plugin {
  settings: RemotelySavePluginSettings;
  db: InternalDBs;
  syncStatus: SyncStatusType;
  oauth2Info: OAuth2Info;
  currLogLevel: string;
  currSyncMsg?: string;
  syncRibbon?: HTMLElement;
  autoRunIntervalID?: number;
  i18n: I18n;
  vaultRandomID: string;

  async syncRun(triggerSource: SyncTriggerSourceType = "manual") {
    const t = (x: TransItemType, vars?: any) => {
      return this.i18n.t(x, vars);
    };

    const getNotice = (x: string, timeout?: number) => {
      // only show notices in manual mode
      // no notice in auto mode
      if (triggerSource === "manual" || triggerSource === "dry") {
        new Notice(x, timeout);
      }
    };
    if (this.syncStatus !== "idle") {
      // here the notice is shown regardless of triggerSource
      new Notice(
        t("syncrun_alreadyrunning", {
          pluginName: this.manifest.name,
          syncStatus: this.syncStatus,
        })
      );
      if (this.currSyncMsg !== undefined && this.currSyncMsg !== "") {
        new Notice(this.currSyncMsg);
      }
      return;
    }

    let originLabel = `${this.manifest.name}`;
    if (this.syncRibbon !== undefined) {
      originLabel = this.syncRibbon.getAttribute("aria-label");
    }

    try {
      log.info(
        `${
          this.manifest.id
        }-${Date.now()}: start sync, triggerSource=${triggerSource}`
      );

      if (this.syncRibbon !== undefined) {
        setIcon(this.syncRibbon, iconNameSyncRunning);
        this.syncRibbon.setAttribute(
          "aria-label",
          t("syncrun_syncingribbon", {
            pluginName: this.manifest.name,
            triggerSource: triggerSource,
          })
        );
      }

      const MAX_STEPS = 8;

      if (triggerSource === "dry") {
        getNotice(
          t("syncrun_step0", {
            maxSteps: `${MAX_STEPS}`,
          })
        );
      }

      //log.info(`huh ${this.settings.password}`)
      getNotice(
        t("syncrun_step1", {
          maxSteps: `${MAX_STEPS}`,
          serviceType: this.settings.serviceType,
        })
      );
      this.syncStatus = "preparing";

      getNotice(
        t("syncrun_step2", {
          maxSteps: `${MAX_STEPS}`,
        })
      );
      this.syncStatus = "getting_remote_files_list";
      const self = this;
      const client = new RemoteClient(
        this.settings.serviceType,
        this.settings.s3,
        this.settings.webdav,
        this.settings.dropbox,
        this.settings.onedrive,
        this.app.vault.getName(),
        () => self.saveSettings()
      );
      const remoteRsp = await client.listFromRemote();
      // log.debug(remoteRsp);

      getNotice(
        t("syncrun_step3", {
          maxSteps: `${MAX_STEPS}`,
        })
      );
      this.syncStatus = "checking_password";
      const passwordCheckResult = await isPasswordOk(
        remoteRsp.Contents,
        this.settings.password
      );
      if (!passwordCheckResult.ok) {
        getNotice(t("syncrun_passworderr"));
        throw Error(passwordCheckResult.reason);
      }

      getNotice(
        t("syncrun_step4", {
          maxSteps: `${MAX_STEPS}`,
        })
      );
      this.syncStatus = "getting_remote_extra_meta";
      const { remoteStates, metadataFile } = await parseRemoteItems(
        remoteRsp.Contents,
        this.db,
        this.vaultRandomID,
        client.serviceType,
        this.settings.password
      );
      const origMetadataOnRemote = await fetchMetadataFile(
        metadataFile,
        client,
        this.app.vault,
        this.settings.password
      );

      getNotice(
        t("syncrun_step5", {
          maxSteps: `${MAX_STEPS}`,
        })
      );
      this.syncStatus = "getting_local_meta";
      const local = this.app.vault.getAllLoadedFiles();
      const localHistory = await loadFileHistoryTableByVault(
        this.db,
        this.vaultRandomID
      );
      let localConfigDirContents: ObsConfigDirFileType[] = undefined;
      if (this.settings.syncConfigDir) {
        localConfigDirContents = await listFilesInObsFolder(
          this.app.vault.configDir,
          this.app.vault,
          this.manifest.id
        );
      }
      // log.info(local);
      // log.info(localHistory);

      getNotice(
        t("syncrun_step6", {
          maxSteps: `${MAX_STEPS}`,
        })
      );
      this.syncStatus = "generating_plan";
      const { plan, sortedKeys, deletions, sizesGoWrong } = await getSyncPlan(
        remoteStates,
        local,
        localConfigDirContents,
        origMetadataOnRemote.deletions,
        localHistory,
        client.serviceType,
        triggerSource,
        this.app.vault,
        this.settings.syncConfigDir,
        this.app.vault.configDir,
        this.settings.syncUnderscoreItems,
        this.settings.skipSizeLargerThan,
        this.settings.password
      );
      log.info(plan.mixedStates); // for debugging
      await insertSyncPlanRecordByVault(this.db, plan, this.vaultRandomID);

      // The operations above are almost read only and kind of safe.
      // The operations below begins to write or delete (!!!) something.

      if (triggerSource !== "dry") {
        getNotice(
          t("syncrun_step7", {
            maxSteps: `${MAX_STEPS}`,
          })
        );

        this.syncStatus = "syncing";
        await doActualSync(
          client,
          this.db,
          this.vaultRandomID,
          this.app.vault,
          plan,
          sortedKeys,
          metadataFile,
          origMetadataOnRemote,
          sizesGoWrong,
          deletions,
          (key: string) => self.trash(key),
          this.settings.password,
          this.settings.concurrency,
          (ss: FileOrFolderMixedState[]) => {
            new SizesConflictModal(
              self.app,
              self,
              this.settings.skipSizeLargerThan,
              ss,
              this.settings.password !== ""
            ).open();
          },
          (i: number, totalCount: number, pathName: string, decision: string) =>
            self.setCurrSyncMsg(i, totalCount, pathName, decision)
        );
      } else {
        this.syncStatus = "syncing";
        getNotice(
          t("syncrun_step7skip", {
            maxSteps: `${MAX_STEPS}`,
          })
        );
      }

      getNotice(
        t("syncrun_step8", {
          maxSteps: `${MAX_STEPS}`,
        })
      );
      this.syncStatus = "finish";
      this.syncStatus = "idle";

      if (this.syncRibbon !== undefined) {
        setIcon(this.syncRibbon, iconNameSyncWait);
        this.syncRibbon.setAttribute("aria-label", originLabel);
      }

      log.info(
        `${
          this.manifest.id
        }-${Date.now()}: finish sync, triggerSource=${triggerSource}`
      );
    } catch (error) {
      const msg = t("syncrun_abort", {
        manifestID: this.manifest.id,
        theDate: `${Date.now()}`,
        triggerSource: triggerSource,
        syncStatus: this.syncStatus,
      });
      log.error(msg);
      log.error(error);
      getNotice(msg, 10 * 1000);
      if (error instanceof AggregateError) {
        for (const e of error.errors) {
          getNotice(e.message, 10 * 1000);
        }
      } else {
        getNotice(error.message, 10 * 1000);
      }
      this.syncStatus = "idle";
      if (this.syncRibbon !== undefined) {
        setIcon(this.syncRibbon, iconNameSyncWait);
        this.syncRibbon.setAttribute("aria-label", originLabel);
      }
    }
  }

  async onload() {
    log.info(`loading plugin ${this.manifest.id}`);

    const { iconSvgSyncWait, iconSvgSyncRunning, iconSvgLogs } = getIconSvg();

    addIcon(iconNameSyncWait, iconSvgSyncWait);
    addIcon(iconNameSyncRunning, iconSvgSyncRunning);
    addIcon(iconNameLogs, iconSvgLogs);

    this.oauth2Info = {
      verifier: "",
      helperModal: undefined,
      authDiv: undefined,
      revokeDiv: undefined,
      revokeAuthSetting: undefined,
    }; // init

    this.currSyncMsg = "";

    await this.loadSettings();
    await this.checkIfPresetRulesFollowed();

    // lang should be load early, but after settings
    this.i18n = new I18n(this.settings.lang, async (lang: LangTypeAndAuto) => {
      this.settings.lang = lang;
      await this.saveSettings();
    });
    const t = (x: TransItemType, vars?: any) => {
      return this.i18n.t(x, vars);
    };

    if (this.settings.currLogLevel !== undefined) {
      log.setLevel(this.settings.currLogLevel as any);
    }

    await this.checkIfOauthExpires();

    // MUST before prepareDB()
    // And, it's also possible to be an empty string,
    // which means the vaultRandomID is read from db later!
    const vaultRandomIDFromOldConfigFile =
      await this.getVaultRandomIDFromOldConfigFile();

    // no need to await this
    this.tryToAddIgnoreFile();

    const vaultBasePath = this.getVaultBasePath();

    try {
      await this.prepareDBAndVaultRandomID(
        vaultBasePath,
        vaultRandomIDFromOldConfigFile
      );
    } catch (err) {
      new Notice(err.message, 10 * 1000);
      throw err;
    }

    // must AFTER preparing DB
    this.addOutputToDBIfSet();
    this.enableAutoClearOutputToDBHistIfSet();

    // must AFTER preparing DB
    this.enableAutoClearSyncPlanHist();

    this.syncStatus = "idle";

    this.registerEvent(
      this.app.vault.on("delete", async (fileOrFolder) => {
        await insertDeleteRecordByVault(
          this.db,
          fileOrFolder,
          this.vaultRandomID
        );
      })
    );

    this.registerEvent(
      this.app.vault.on("rename", async (fileOrFolder, oldPath) => {
        await insertRenameRecordByVault(
          this.db,
          fileOrFolder,
          oldPath,
          this.vaultRandomID
        );
      })
    );

    this.registerObsidianProtocolHandler(COMMAND_URI, async (inputParams) => {
      const parsed = importQrCodeUri(inputParams, this.app.vault.getName());
      if (parsed.status === "error") {
        new Notice(parsed.message);
      } else {
        const copied = cloneDeep(parsed.result);
        // new Notice(JSON.stringify(copied))
        this.settings = Object.assign({}, this.settings, copied);
        this.saveSettings();
        new Notice(
          t("protocol_saveqr", {
            manifestName: this.manifest.name,
          })
        );
      }
    });

    this.registerObsidianProtocolHandler(
      COMMAND_CALLBACK,
      async (inputParams) => {
        new Notice(
          t("protocol_callbacknotsupported", {
            params: JSON.stringify(inputParams),
          })
        );
      }
    );

    this.registerObsidianProtocolHandler(
      COMMAND_CALLBACK_DROPBOX,
      async (inputParams) => {
        if (inputParams.code !== undefined) {
          if (this.oauth2Info.helperModal !== undefined) {
            this.oauth2Info.helperModal.contentEl.empty();

            t("protocol_dropbox_connecting")
              .split("\n")
              .forEach((val) => {
                this.oauth2Info.helperModal.contentEl.createEl("p", {
                  text: val,
                });
              });
          }

          let authRes = await sendAuthReqDropbox(
            this.settings.dropbox.clientID,
            this.oauth2Info.verifier,
            inputParams.code
          );

          const self = this;
          setConfigBySuccessfullAuthInplaceDropbox(
            this.settings.dropbox,
            authRes,
            () => self.saveSettings()
          );

          const client = new RemoteClient(
            "dropbox",
            undefined,
            undefined,
            this.settings.dropbox,
            undefined,
            this.app.vault.getName(),
            () => self.saveSettings()
          );

          const username = await client.getUser();
          this.settings.dropbox.username = username;
          await this.saveSettings();

          new Notice(
            t("protocol_dropbox_connect_succ", {
              username: username,
            })
          );

          this.oauth2Info.verifier = ""; // reset it
          this.oauth2Info.helperModal?.close(); // close it
          this.oauth2Info.helperModal = undefined;

          this.oauth2Info.authDiv?.toggleClass(
            "dropbox-auth-button-hide",
            this.settings.dropbox.username !== ""
          );
          this.oauth2Info.authDiv = undefined;

          this.oauth2Info.revokeAuthSetting?.setDesc(
            t("protocol_dropbox_connect_succ_revoke", {
              username: this.settings.dropbox.username,
            })
          );
          this.oauth2Info.revokeAuthSetting = undefined;
          this.oauth2Info.revokeDiv?.toggleClass(
            "dropbox-revoke-auth-button-hide",
            this.settings.dropbox.username === ""
          );
          this.oauth2Info.revokeDiv = undefined;
        } else {
          new Notice(t("protocol_dropbox_connect_fail"));
          throw Error(
            t("protocol_dropbox_connect_unknown", {
              params: JSON.stringify(inputParams),
            })
          );
        }
      }
    );

    this.registerObsidianProtocolHandler(
      COMMAND_CALLBACK_ONEDRIVE,
      async (inputParams) => {
        if (inputParams.code !== undefined) {
          if (this.oauth2Info.helperModal !== undefined) {
            this.oauth2Info.helperModal.contentEl.empty();

            t("protocol_onedrive_connecting")
              .split("\n")
              .forEach((val) => {
                this.oauth2Info.helperModal.contentEl.createEl("p", {
                  text: val,
                });
              });
          }

          let rsp = await sendAuthReqOnedrive(
            this.settings.onedrive.clientID,
            this.settings.onedrive.authority,
            inputParams.code,
            this.oauth2Info.verifier
          );

          if ((rsp as any).error !== undefined) {
            throw Error(`${JSON.stringify(rsp)}`);
          }

          const self = this;
          setConfigBySuccessfullAuthInplaceOnedrive(
            this.settings.onedrive,
            rsp as AccessCodeResponseSuccessfulType,
            () => self.saveSettings()
          );

          const client = new RemoteClient(
            "onedrive",
            undefined,
            undefined,
            undefined,
            this.settings.onedrive,
            this.app.vault.getName(),
            () => self.saveSettings()
          );
          this.settings.onedrive.username = await client.getUser();
          await this.saveSettings();

          this.oauth2Info.verifier = ""; // reset it
          this.oauth2Info.helperModal?.close(); // close it
          this.oauth2Info.helperModal = undefined;

          this.oauth2Info.authDiv?.toggleClass(
            "onedrive-auth-button-hide",
            this.settings.onedrive.username !== ""
          );
          this.oauth2Info.authDiv = undefined;

          this.oauth2Info.revokeAuthSetting?.setDesc(
            t("protocol_onedrive_connect_succ_revoke", {
              username: this.settings.onedrive.username,
            })
          );
          this.oauth2Info.revokeAuthSetting = undefined;
          this.oauth2Info.revokeDiv?.toggleClass(
            "onedrive-revoke-auth-button-hide",
            this.settings.onedrive.username === ""
          );
          this.oauth2Info.revokeDiv = undefined;
        } else {
          new Notice(t("protocol_onedrive_connect_fail"));
          throw Error(
            t("protocol_onedrive_connect_unknown", {
              params: JSON.stringify(inputParams),
            })
          );
        }
      }
    );

    this.syncRibbon = this.addRibbonIcon(
      iconNameSyncWait,
      `${this.manifest.name}`,
      async () => this.syncRun("manual")
    );

    this.addCommand({
      id: "start-sync",
      name: t("command_startsync"),
      icon: iconNameSyncWait,
      callback: async () => {
        this.syncRun("manual");
      },
    });

    this.addCommand({
      id: "start-sync-dry-run",
      name: t("command_drynrun"),
      icon: iconNameSyncWait,
      callback: async () => {
        this.syncRun("dry");
      },
    });

    this.addCommand({
      id: "export-sync-plans-json",
      name: t("command_exportsyncplans_json"),
      icon: iconNameLogs,
      callback: async () => {
        await exportVaultSyncPlansToFiles(
          this.db,
          this.app.vault,
          this.vaultRandomID,
          "json"
        );
        new Notice(t("settings_syncplans_notice"));
      },
    });

    this.addCommand({
      id: "export-sync-plans-table",
      name: t("command_exportsyncplans_table"),
      icon: iconNameLogs,
      callback: async () => {
        await exportVaultSyncPlansToFiles(
          this.db,
          this.app.vault,
          this.vaultRandomID,
          "table"
        );
        new Notice(t("settings_syncplans_notice"));
      },
    });

    this.addCommand({
      id: "export-logs-in-db",
      name: t("command_exportlogsindb"),
      icon: iconNameLogs,
      callback: async () => {
        await exportVaultLoggerOutputToFiles(
          this.db,
          this.app.vault,
          this.vaultRandomID
        );
        new Notice(t("settings_logtodbexport_notice"));
      },
    });

    this.addSettingTab(new RemotelySaveSettingTab(this.app, this));

    // this.registerDomEvent(document, "click", (evt: MouseEvent) => {
    //   log.info("click", evt);
    // });

    if (!this.settings.agreeToUploadExtraMetadata) {
      const syncAlgoV2Modal = new SyncAlgoV2Modal(this.app, this);
      syncAlgoV2Modal.open();
    } else {
      this.enableAutoSyncIfSet();
      this.enableInitSyncIfSet();
    }
  }

  async onunload() {
    log.info(`unloading plugin ${this.manifest.id}`);
    this.syncRibbon = undefined;
    if (this.oauth2Info !== undefined) {
      this.oauth2Info.helperModal = undefined;
      this.oauth2Info = undefined;
    }
  }

  async loadSettings() {
    this.settings = Object.assign(
      {},
      cloneDeep(DEFAULT_SETTINGS),
      messyConfigToNormal(await this.loadData())
    );
    if (this.settings.dropbox.clientID === "") {
      this.settings.dropbox.clientID = DEFAULT_SETTINGS.dropbox.clientID;
    }
    if (this.settings.dropbox.remoteBaseDir === undefined) {
      this.settings.dropbox.remoteBaseDir = "";
    }
    if (this.settings.onedrive.clientID === "") {
      this.settings.onedrive.clientID = DEFAULT_SETTINGS.onedrive.clientID;
    }
    if (this.settings.onedrive.authority === "") {
      this.settings.onedrive.authority = DEFAULT_SETTINGS.onedrive.authority;
    }
    if (this.settings.onedrive.remoteBaseDir === undefined) {
      this.settings.onedrive.remoteBaseDir = "";
    }
    if (this.settings.webdav.manualRecursive === undefined) {
      this.settings.webdav.manualRecursive = false;
    }
    if (this.settings.webdav.depth === undefined) {
      this.settings.webdav.depth = "auto_unknown";
    }
    if (this.settings.webdav.remoteBaseDir === undefined) {
      this.settings.webdav.remoteBaseDir = "";
    }
    if (this.settings.s3.partsConcurrency === undefined) {
      this.settings.s3.partsConcurrency = 20;
    }
    if (this.settings.s3.forcePathStyle === undefined) {
      this.settings.s3.forcePathStyle = false;
    }
  }

  async checkIfPresetRulesFollowed() {
    const res = applyPresetRulesInplace(this.settings);
    if (res.changed) {
      await this.saveSettings();
    }
  }

  async saveSettings() {
    await this.saveData(normalConfigToMessy(this.settings));
  }

  async checkIfOauthExpires() {
    let needSave: boolean = false;
    const current = Date.now();

    // fullfill old version settings
    if (
      this.settings.dropbox.refreshToken !== "" &&
      this.settings.dropbox.credentialsShouldBeDeletedAtTime === undefined
    ) {
      // It has a refreshToken, but not expire time.
      // Likely to be a setting from old version.
      // we set it to a month.
      this.settings.dropbox.credentialsShouldBeDeletedAtTime =
        current + 1000 * 60 * 60 * 24 * 30;
      needSave = true;
    }
    if (
      this.settings.onedrive.refreshToken !== "" &&
      this.settings.onedrive.credentialsShouldBeDeletedAtTime === undefined
    ) {
      this.settings.onedrive.credentialsShouldBeDeletedAtTime =
        current + 1000 * 60 * 60 * 24 * 30;
      needSave = true;
    }

    // check expired or not
    let dropboxExpired = false;
    if (
      this.settings.dropbox.refreshToken !== "" &&
      current >= this.settings.dropbox.credentialsShouldBeDeletedAtTime
    ) {
      dropboxExpired = true;
      this.settings.dropbox = cloneDeep(DEFAULT_DROPBOX_CONFIG);
      needSave = true;
    }

    let onedriveExpired = false;
    if (
      this.settings.onedrive.refreshToken !== "" &&
      current >= this.settings.onedrive.credentialsShouldBeDeletedAtTime
    ) {
      onedriveExpired = true;
      this.settings.onedrive = cloneDeep(DEFAULT_ONEDRIVE_CONFIG);
      needSave = true;
    }

    // save back
    if (needSave) {
      await this.saveSettings();
    }

    // send notice
    if (dropboxExpired && onedriveExpired) {
      new Notice(
        `${this.manifest.name}: You haven't manually auth Dropbox and OneDrive for a while, you need to re-auth them again.`,
        6000
      );
    } else if (dropboxExpired) {
      new Notice(
        `${this.manifest.name}: You haven't manually auth Dropbox for a while, you need to re-auth it again.`,
        6000
      );
    } else if (onedriveExpired) {
      new Notice(
        `${this.manifest.name}: You haven't manually auth OneDrive for a while, you need to re-auth it again.`,
        6000
      );
    }
  }

  async getVaultRandomIDFromOldConfigFile() {
    let vaultRandomID = "";
    if (this.settings.vaultRandomID !== undefined) {
      // In old version, the vault id is saved in data.json
      // But we want to store it in localForage later
      if (this.settings.vaultRandomID !== "") {
        // a real string was assigned before
        vaultRandomID = this.settings.vaultRandomID;
      }
      log.debug("vaultRandomID is no longer saved in data.json");
      delete this.settings.vaultRandomID;
      await this.saveSettings();
    }
    return vaultRandomID;
  }

  async trash(x: string) {
    if (!(await this.app.vault.adapter.trashSystem(x))) {
      await this.app.vault.adapter.trashLocal(x);
    }
  }

  getVaultBasePath() {
    if (this.app.vault.adapter instanceof FileSystemAdapter) {
      // in desktop
      return this.app.vault.adapter.getBasePath().split("?")[0];
    } else {
      // in mobile
      return this.app.vault.adapter.getResourcePath("").split("?")[0];
    }
  }

  async prepareDBAndVaultRandomID(
    vaultBasePath: string,
    vaultRandomIDFromOldConfigFile: string
  ) {
    const { db, vaultRandomID } = await prepareDBs(
      vaultBasePath,
      vaultRandomIDFromOldConfigFile
    );
    this.db = db;
    this.vaultRandomID = vaultRandomID;
  }

  enableAutoSyncIfSet() {
    if (
      this.settings.autoRunEveryMilliseconds !== undefined &&
      this.settings.autoRunEveryMilliseconds !== null &&
      this.settings.autoRunEveryMilliseconds > 0
    ) {
      this.app.workspace.onLayoutReady(() => {
        const intervalID = window.setInterval(() => {
          this.syncRun("auto");
        }, this.settings.autoRunEveryMilliseconds);
        this.autoRunIntervalID = intervalID;
        this.registerInterval(intervalID);
      });
    }
  }

  enableInitSyncIfSet() {
    if (
      this.settings.initRunAfterMilliseconds !== undefined &&
      this.settings.initRunAfterMilliseconds !== null &&
      this.settings.initRunAfterMilliseconds > 0
    ) {
      this.app.workspace.onLayoutReady(() => {
        window.setTimeout(() => {
          this.syncRun("autoOnceInit");
        }, this.settings.initRunAfterMilliseconds);
      });
    }
  }

  async saveAgreeToUseNewSyncAlgorithm() {
    this.settings.agreeToUploadExtraMetadata = true;
    await this.saveSettings();
  }

  async setCurrSyncMsg(
    i: number,
    totalCount: number,
    pathName: string,
    decision: string
  ) {
    const msg = `syncing progress=${i}/${totalCount},decision=${decision},path=${pathName}`;
    this.currSyncMsg = msg;
  }

  /**
   * Because data.json contains sensitive information,
   * We usually want to ignore it in the version control.
   * However, if there's already a an ignore file (even empty),
   * we respect the existing configure and not add any modifications.
   * @returns
   */
  async tryToAddIgnoreFile() {
    const pluginConfigDir =
      this.manifest.dir ||
      `${this.app.vault.configDir}/plugins/${this.manifest.dir}`;
    const pluginConfigDirExists = await this.app.vault.adapter.exists(
      pluginConfigDir
    );
    if (!pluginConfigDirExists) {
      // what happened?
      return;
    }
    const ignoreFile = `${pluginConfigDir}/.gitignore`;
    const ignoreFileExists = await this.app.vault.adapter.exists(ignoreFile);

    const contentText = "data.json\n";

    try {
      if (!ignoreFileExists) {
        // not exists, directly create
        // no need to await
        this.app.vault.adapter.write(ignoreFile, contentText);
      }
    } catch (error) {
      // just skip
    }
  }

  addOutputToDBIfSet() {
    if (this.settings.logToDB) {
      applyLogWriterInplace((...msg: any[]) => {
        insertLoggerOutputByVault(this.db, this.vaultRandomID, ...msg);
      });
    }
  }

  enableAutoClearOutputToDBHistIfSet() {
    const initClearOutputToDBHistAfterMilliseconds = 1000 * 45;
    const autoClearOutputToDBHistAfterMilliseconds = 1000 * 60 * 5;

    this.app.workspace.onLayoutReady(() => {
      // init run
      window.setTimeout(() => {
        if (this.settings.logToDB) {
          clearExpiredLoggerOutputRecords(this.db);
        }
      }, initClearOutputToDBHistAfterMilliseconds);

      // scheduled run
      const intervalID = window.setInterval(() => {
        if (this.settings.logToDB) {
          clearExpiredLoggerOutputRecords(this.db);
        }
      }, autoClearOutputToDBHistAfterMilliseconds);
      this.registerInterval(intervalID);
    });
  }

  enableAutoClearSyncPlanHist() {
    const initClearSyncPlanHistAfterMilliseconds = 1000 * 45;
    const autoClearSyncPlanHistAfterMilliseconds = 1000 * 60 * 5;

    this.app.workspace.onLayoutReady(() => {
      // init run
      window.setTimeout(() => {
        clearExpiredSyncPlanRecords(this.db);
      }, initClearSyncPlanHistAfterMilliseconds);

      // scheduled run
      const intervalID = window.setInterval(() => {
        clearExpiredSyncPlanRecords(this.db);
      }, autoClearSyncPlanHistAfterMilliseconds);
      this.registerInterval(intervalID);
    });
  }
}
Example #23
Source File: main.ts    From obsidian-spaced-repetition with MIT License 4 votes vote down vote up
export default class SRPlugin extends Plugin {
    private statusBar: HTMLElement;
    private reviewQueueView: ReviewQueueListView;
    public data: PluginData;
    public syncLock = false;

    public reviewDecks: { [deckKey: string]: ReviewDeck } = {};
    public lastSelectedReviewDeck: string;

    public newNotes: TFile[] = [];
    public scheduledNotes: SchedNote[] = [];
    public easeByPath: Record<string, number> = {};
    private incomingLinks: Record<string, LinkStat[]> = {};
    private pageranks: Record<string, number> = {};
    private dueNotesCount = 0;
    public dueDatesNotes: Record<number, number> = {}; // Record<# of days in future, due count>

    public deckTree: Deck = new Deck("root", null);
    public dueDatesFlashcards: Record<number, number> = {}; // Record<# of days in future, due count>
    public cardStats: Stats;

    async onload(): Promise<void> {
        await this.loadPluginData();

        appIcon();

        this.statusBar = this.addStatusBarItem();
        this.statusBar.classList.add("mod-clickable");
        this.statusBar.setAttribute("aria-label", t("OPEN_NOTE_FOR_REVIEW"));
        this.statusBar.setAttribute("aria-label-position", "top");
        this.statusBar.addEventListener("click", async () => {
            if (!this.syncLock) {
                await this.sync();
                this.reviewNextNoteModal();
            }
        });

        this.addRibbonIcon("SpacedRepIcon", t("REVIEW_CARDS"), async () => {
            if (!this.syncLock) {
                await this.sync();
                new FlashcardModal(this.app, this).open();
            }
        });

        this.registerView(
            REVIEW_QUEUE_VIEW_TYPE,
            (leaf) => (this.reviewQueueView = new ReviewQueueListView(leaf, this))
        );

        if (!this.data.settings.disableFileMenuReviewOptions) {
            this.registerEvent(
                this.app.workspace.on("file-menu", (menu, fileish: TAbstractFile) => {
                    if (fileish instanceof TFile && fileish.extension === "md") {
                        menu.addItem((item) => {
                            item.setTitle(t("REVIEW_EASY_FILE_MENU"))
                                .setIcon("SpacedRepIcon")
                                .onClick(() => {
                                    this.saveReviewResponse(fileish, ReviewResponse.Easy);
                                });
                        });

                        menu.addItem((item) => {
                            item.setTitle(t("REVIEW_GOOD_FILE_MENU"))
                                .setIcon("SpacedRepIcon")
                                .onClick(() => {
                                    this.saveReviewResponse(fileish, ReviewResponse.Good);
                                });
                        });

                        menu.addItem((item) => {
                            item.setTitle(t("REVIEW_HARD_FILE_MENU"))
                                .setIcon("SpacedRepIcon")
                                .onClick(() => {
                                    this.saveReviewResponse(fileish, ReviewResponse.Hard);
                                });
                        });
                    }
                })
            );
        }

        this.addCommand({
            id: "srs-note-review-open-note",
            name: t("OPEN_NOTE_FOR_REVIEW"),
            callback: async () => {
                if (!this.syncLock) {
                    await this.sync();
                    this.reviewNextNoteModal();
                }
            },
        });

        this.addCommand({
            id: "srs-note-review-easy",
            name: t("REVIEW_NOTE_EASY_CMD"),
            callback: () => {
                const openFile: TFile | null = this.app.workspace.getActiveFile();
                if (openFile && openFile.extension === "md") {
                    this.saveReviewResponse(openFile, ReviewResponse.Easy);
                }
            },
        });

        this.addCommand({
            id: "srs-note-review-good",
            name: t("REVIEW_NOTE_GOOD_CMD"),
            callback: () => {
                const openFile: TFile | null = this.app.workspace.getActiveFile();
                if (openFile && openFile.extension === "md") {
                    this.saveReviewResponse(openFile, ReviewResponse.Good);
                }
            },
        });

        this.addCommand({
            id: "srs-note-review-hard",
            name: t("REVIEW_NOTE_HARD_CMD"),
            callback: () => {
                const openFile: TFile | null = this.app.workspace.getActiveFile();
                if (openFile && openFile.extension === "md") {
                    this.saveReviewResponse(openFile, ReviewResponse.Hard);
                }
            },
        });

        this.addCommand({
            id: "srs-review-flashcards",
            name: t("REVIEW_ALL_CARDS"),
            callback: async () => {
                if (!this.syncLock) {
                    await this.sync();
                    new FlashcardModal(this.app, this).open();
                }
            },
        });

        this.addCommand({
            id: "srs-review-flashcards-in-note",
            name: t("REVIEW_CARDS_IN_NOTE"),
            callback: async () => {
                const openFile: TFile | null = this.app.workspace.getActiveFile();
                if (openFile && openFile.extension === "md") {
                    this.deckTree = new Deck("root", null);
                    const deckPath: string[] = this.findDeckPath(openFile);
                    await this.findFlashcardsInNote(openFile, deckPath);
                    new FlashcardModal(this.app, this).open();
                }
            },
        });

        this.addCommand({
            id: "srs-cram-flashcards-in-note",
            name: t("CRAM_CARDS_IN_NOTE"),
            callback: async () => {
                const openFile: TFile | null = this.app.workspace.getActiveFile();
                if (openFile && openFile.extension === "md") {
                    this.deckTree = new Deck("root", null);
                    const deckPath: string[] = this.findDeckPath(openFile);
                    await this.findFlashcardsInNote(openFile, deckPath, false, true);
                    new FlashcardModal(this.app, this, true).open();
                }
            },
        });

        this.addCommand({
            id: "srs-view-stats",
            name: t("VIEW_STATS"),
            callback: async () => {
                if (!this.syncLock) {
                    await this.sync();
                    new StatsModal(this.app, this).open();
                }
            },
        });

        this.addSettingTab(new SRSettingTab(this.app, this));

        this.app.workspace.onLayoutReady(() => {
            this.initView();
            setTimeout(async () => {
                if (!this.syncLock) {
                    await this.sync();
                }
            }, 2000);
        });
    }

    onunload(): void {
        this.app.workspace.getLeavesOfType(REVIEW_QUEUE_VIEW_TYPE).forEach((leaf) => leaf.detach());
    }

    async sync(): Promise<void> {
        if (this.syncLock) {
            return;
        }
        this.syncLock = true;

        // reset notes stuff
        graph.reset();
        this.easeByPath = {};
        this.incomingLinks = {};
        this.pageranks = {};
        this.dueNotesCount = 0;
        this.dueDatesNotes = {};
        this.reviewDecks = {};

        // reset flashcards stuff
        this.deckTree = new Deck("root", null);
        this.dueDatesFlashcards = {};
        this.cardStats = {
            eases: {},
            intervals: {},
            newCount: 0,
            youngCount: 0,
            matureCount: 0,
        };

        const now = window.moment(Date.now());
        const todayDate: string = now.format("YYYY-MM-DD");
        // clear bury list if we've changed dates
        if (todayDate !== this.data.buryDate) {
            this.data.buryDate = todayDate;
            this.data.buryList = [];
        }

        const notes: TFile[] = this.app.vault.getMarkdownFiles();
        for (const note of notes) {
            if (
                this.data.settings.noteFoldersToIgnore.some((folder) =>
                    note.path.startsWith(folder)
                )
            ) {
                continue;
            }

            if (this.incomingLinks[note.path] === undefined) {
                this.incomingLinks[note.path] = [];
            }

            const links = this.app.metadataCache.resolvedLinks[note.path] || {};
            for (const targetPath in links) {
                if (this.incomingLinks[targetPath] === undefined)
                    this.incomingLinks[targetPath] = [];

                // markdown files only
                if (targetPath.split(".").pop().toLowerCase() === "md") {
                    this.incomingLinks[targetPath].push({
                        sourcePath: note.path,
                        linkCount: links[targetPath],
                    });

                    graph.link(note.path, targetPath, links[targetPath]);
                }
            }

            const deckPath: string[] = this.findDeckPath(note);
            if (deckPath.length !== 0) {
                const flashcardsInNoteAvgEase: number = await this.findFlashcardsInNote(
                    note,
                    deckPath
                );

                if (flashcardsInNoteAvgEase > 0) {
                    this.easeByPath[note.path] = flashcardsInNoteAvgEase;
                }
            }

            const fileCachedData = this.app.metadataCache.getFileCache(note) || {};

            const frontmatter: FrontMatterCache | Record<string, unknown> =
                fileCachedData.frontmatter || {};
            const tags = getAllTags(fileCachedData) || [];

            let shouldIgnore = true;
            const matchedNoteTags = [];

            for (const tagToReview of this.data.settings.tagsToReview) {
                if (tags.some((tag) => tag === tagToReview || tag.startsWith(tagToReview + "/"))) {
                    if (!Object.prototype.hasOwnProperty.call(this.reviewDecks, tagToReview)) {
                        this.reviewDecks[tagToReview] = new ReviewDeck(tagToReview);
                    }
                    matchedNoteTags.push(tagToReview);
                    shouldIgnore = false;
                    break;
                }
            }
            if (shouldIgnore) {
                continue;
            }

            // file has no scheduling information
            if (
                !(
                    Object.prototype.hasOwnProperty.call(frontmatter, "sr-due") &&
                    Object.prototype.hasOwnProperty.call(frontmatter, "sr-interval") &&
                    Object.prototype.hasOwnProperty.call(frontmatter, "sr-ease")
                )
            ) {
                for (const matchedNoteTag of matchedNoteTags) {
                    this.reviewDecks[matchedNoteTag].newNotes.push(note);
                }
                continue;
            }

            const dueUnix: number = window
                .moment(frontmatter["sr-due"], ["YYYY-MM-DD", "DD-MM-YYYY", "ddd MMM DD YYYY"])
                .valueOf();

            for (const matchedNoteTag of matchedNoteTags) {
                this.reviewDecks[matchedNoteTag].scheduledNotes.push({ note, dueUnix });
                if (dueUnix <= now.valueOf()) {
                    this.reviewDecks[matchedNoteTag].dueNotesCount++;
                }
            }

            if (Object.prototype.hasOwnProperty.call(this.easeByPath, note.path)) {
                this.easeByPath[note.path] =
                    (this.easeByPath[note.path] + frontmatter["sr-ease"]) / 2;
            } else {
                this.easeByPath[note.path] = frontmatter["sr-ease"];
            }

            if (dueUnix <= now.valueOf()) {
                this.dueNotesCount++;
            }

            const nDays: number = Math.ceil((dueUnix - now.valueOf()) / (24 * 3600 * 1000));
            if (!Object.prototype.hasOwnProperty.call(this.dueDatesNotes, nDays)) {
                this.dueDatesNotes[nDays] = 0;
            }
            this.dueDatesNotes[nDays]++;
        }

        graph.rank(0.85, 0.000001, (node: string, rank: number) => {
            this.pageranks[node] = rank * 10000;
        });

        // sort the deck names
        this.deckTree.sortSubdecksList();
        if (this.data.settings.showDebugMessages) {
            console.log(`SR: ${t("EASES")}`, this.easeByPath);
            console.log(`SR: ${t("DECKS")}`, this.deckTree);
        }

        for (const deckKey in this.reviewDecks) {
            this.reviewDecks[deckKey].sortNotes(this.pageranks);
        }

        if (this.data.settings.showDebugMessages) {
            console.log(
                "SR: " +
                    t("SYNC_TIME_TAKEN", {
                        t: Date.now() - now.valueOf(),
                    })
            );
        }

        this.statusBar.setText(
            t("STATUS_BAR", {
                dueNotesCount: this.dueNotesCount,
                dueFlashcardsCount: this.deckTree.dueFlashcardsCount,
            })
        );
        this.reviewQueueView.redraw();

        this.syncLock = false;
    }

    async saveReviewResponse(note: TFile, response: ReviewResponse): Promise<void> {
        const fileCachedData = this.app.metadataCache.getFileCache(note) || {};
        const frontmatter: FrontMatterCache | Record<string, unknown> =
            fileCachedData.frontmatter || {};

        const tags = getAllTags(fileCachedData) || [];
        if (this.data.settings.noteFoldersToIgnore.some((folder) => note.path.startsWith(folder))) {
            new Notice(t("NOTE_IN_IGNORED_FOLDER"));
            return;
        }

        let shouldIgnore = true;
        for (const tag of tags) {
            if (
                this.data.settings.tagsToReview.some(
                    (tagToReview) => tag === tagToReview || tag.startsWith(tagToReview + "/")
                )
            ) {
                shouldIgnore = false;
                break;
            }
        }

        if (shouldIgnore) {
            new Notice(t("PLEASE_TAG_NOTE"));
            return;
        }

        let fileText: string = await this.app.vault.read(note);
        let ease: number, interval: number, delayBeforeReview: number;
        const now: number = Date.now();
        // new note
        if (
            !(
                Object.prototype.hasOwnProperty.call(frontmatter, "sr-due") &&
                Object.prototype.hasOwnProperty.call(frontmatter, "sr-interval") &&
                Object.prototype.hasOwnProperty.call(frontmatter, "sr-ease")
            )
        ) {
            let linkTotal = 0,
                linkPGTotal = 0,
                totalLinkCount = 0;

            for (const statObj of this.incomingLinks[note.path] || []) {
                const ease: number = this.easeByPath[statObj.sourcePath];
                if (ease) {
                    linkTotal += statObj.linkCount * this.pageranks[statObj.sourcePath] * ease;
                    linkPGTotal += this.pageranks[statObj.sourcePath] * statObj.linkCount;
                    totalLinkCount += statObj.linkCount;
                }
            }

            const outgoingLinks = this.app.metadataCache.resolvedLinks[note.path] || {};
            for (const linkedFilePath in outgoingLinks) {
                const ease: number = this.easeByPath[linkedFilePath];
                if (ease) {
                    linkTotal +=
                        outgoingLinks[linkedFilePath] * this.pageranks[linkedFilePath] * ease;
                    linkPGTotal += this.pageranks[linkedFilePath] * outgoingLinks[linkedFilePath];
                    totalLinkCount += outgoingLinks[linkedFilePath];
                }
            }

            const linkContribution: number =
                this.data.settings.maxLinkFactor *
                Math.min(1.0, Math.log(totalLinkCount + 0.5) / Math.log(64));
            ease =
                (1.0 - linkContribution) * this.data.settings.baseEase +
                (totalLinkCount > 0
                    ? (linkContribution * linkTotal) / linkPGTotal
                    : linkContribution * this.data.settings.baseEase);
            // add note's average flashcard ease if available
            if (Object.prototype.hasOwnProperty.call(this.easeByPath, note.path)) {
                ease = (ease + this.easeByPath[note.path]) / 2;
            }
            ease = Math.round(ease);
            interval = 1.0;
            delayBeforeReview = 0;
        } else {
            interval = frontmatter["sr-interval"];
            ease = frontmatter["sr-ease"];
            delayBeforeReview =
                now -
                window
                    .moment(frontmatter["sr-due"], ["YYYY-MM-DD", "DD-MM-YYYY", "ddd MMM DD YYYY"])
                    .valueOf();
        }

        const schedObj: Record<string, number> = schedule(
            response,
            interval,
            ease,
            delayBeforeReview,
            this.data.settings,
            this.dueDatesNotes
        );
        interval = schedObj.interval;
        ease = schedObj.ease;

        const due = window.moment(now + interval * 24 * 3600 * 1000);
        const dueString: string = due.format("YYYY-MM-DD");

        // check if scheduling info exists
        if (SCHEDULING_INFO_REGEX.test(fileText)) {
            const schedulingInfo = SCHEDULING_INFO_REGEX.exec(fileText);
            fileText = fileText.replace(
                SCHEDULING_INFO_REGEX,
                `---\n${schedulingInfo[1]}sr-due: ${dueString}\n` +
                    `sr-interval: ${interval}\nsr-ease: ${ease}\n` +
                    `${schedulingInfo[5]}---`
            );
        } else if (YAML_FRONT_MATTER_REGEX.test(fileText)) {
            // new note with existing YAML front matter
            const existingYaml = YAML_FRONT_MATTER_REGEX.exec(fileText);
            fileText = fileText.replace(
                YAML_FRONT_MATTER_REGEX,
                `---\n${existingYaml[1]}sr-due: ${dueString}\n` +
                    `sr-interval: ${interval}\nsr-ease: ${ease}\n---`
            );
        } else {
            fileText =
                `---\nsr-due: ${dueString}\nsr-interval: ${interval}\n` +
                `sr-ease: ${ease}\n---\n\n${fileText}`;
        }

        if (this.data.settings.burySiblingCards) {
            await this.findFlashcardsInNote(note, [], true); // bury all cards in current note
            await this.savePluginData();
        }
        await this.app.vault.modify(note, fileText);

        new Notice(t("RESPONSE_RECEIVED"));

        await this.sync();
        if (this.data.settings.autoNextNote) {
            this.reviewNextNote(this.lastSelectedReviewDeck);
        }
    }

    async reviewNextNoteModal(): Promise<void> {
        const reviewDeckNames: string[] = Object.keys(this.reviewDecks);
        if (reviewDeckNames.length === 1) {
            this.reviewNextNote(reviewDeckNames[0]);
        } else {
            const deckSelectionModal = new ReviewDeckSelectionModal(this.app, reviewDeckNames);
            deckSelectionModal.submitCallback = (deckKey: string) => this.reviewNextNote(deckKey);
            deckSelectionModal.open();
        }
    }

    async reviewNextNote(deckKey: string): Promise<void> {
        if (!Object.prototype.hasOwnProperty.call(this.reviewDecks, deckKey)) {
            new Notice(t("NO_DECK_EXISTS", { deckName: deckKey }));
            return;
        }

        this.lastSelectedReviewDeck = deckKey;
        const deck = this.reviewDecks[deckKey];

        if (deck.dueNotesCount > 0) {
            const index = this.data.settings.openRandomNote
                ? Math.floor(Math.random() * deck.dueNotesCount)
                : 0;
            this.app.workspace.activeLeaf.openFile(deck.scheduledNotes[index].note);
            return;
        }

        if (deck.newNotes.length > 0) {
            const index = this.data.settings.openRandomNote
                ? Math.floor(Math.random() * deck.newNotes.length)
                : 0;
            this.app.workspace.activeLeaf.openFile(deck.newNotes[index]);
            return;
        }

        new Notice(t("ALL_CAUGHT_UP"));
    }

    findDeckPath(note: TFile): string[] {
        let deckPath: string[] = [];
        if (this.data.settings.convertFoldersToDecks) {
            deckPath = note.path.split("/");
            deckPath.pop(); // remove filename
            if (deckPath.length === 0) {
                deckPath = ["/"];
            }
        } else {
            const fileCachedData = this.app.metadataCache.getFileCache(note) || {};
            const tags = getAllTags(fileCachedData) || [];

            outer: for (const tagToReview of this.data.settings.flashcardTags) {
                for (const tag of tags) {
                    if (tag === tagToReview || tag.startsWith(tagToReview + "/")) {
                        deckPath = tag.substring(1).split("/");
                        break outer;
                    }
                }
            }
        }

        return deckPath;
    }

    async findFlashcardsInNote(
        note: TFile,
        deckPath: string[],
        buryOnly = false,
        ignoreStats = false
    ): Promise<number> {
        let fileText: string = await this.app.vault.read(note);
        const fileCachedData = this.app.metadataCache.getFileCache(note) || {};
        const headings: HeadingCache[] = fileCachedData.headings || [];
        let fileChanged = false,
            totalNoteEase = 0,
            scheduledCount = 0;
        const settings: SRSettings = this.data.settings;
        const noteDeckPath = deckPath;

        const now: number = Date.now();
        const parsedCards: [CardType, string, number][] = parse(
            fileText,
            settings.singlelineCardSeparator,
            settings.singlelineReversedCardSeparator,
            settings.multilineCardSeparator,
            settings.multilineReversedCardSeparator,
            settings.convertHighlightsToClozes,
            settings.convertBoldTextToClozes
        );
        for (const parsedCard of parsedCards) {
            deckPath = noteDeckPath;
            const cardType: CardType = parsedCard[0],
                lineNo: number = parsedCard[2];
            let cardText: string = parsedCard[1];

            if (!settings.convertFoldersToDecks) {
                const tagInCardRegEx = /^#[^\s#]+/gi;
                const cardDeckPath = cardText
                    .match(tagInCardRegEx)
                    ?.slice(-1)[0]
                    .replace("#", "")
                    .split("/");
                if (cardDeckPath) {
                    deckPath = cardDeckPath;
                    cardText = cardText.replaceAll(tagInCardRegEx, "");
                }
            }

            this.deckTree.createDeck([...deckPath]);

            const cardTextHash: string = cyrb53(cardText);

            if (buryOnly) {
                this.data.buryList.push(cardTextHash);
                continue;
            }

            const siblingMatches: [string, string][] = [];
            if (cardType === CardType.Cloze) {
                const siblings: RegExpMatchArray[] = [];
                if (settings.convertHighlightsToClozes) {
                    siblings.push(...cardText.matchAll(/==(.*?)==/gm));
                }
                if (settings.convertBoldTextToClozes) {
                    siblings.push(...cardText.matchAll(/\*\*(.*?)\*\*/gm));
                }
                siblings.sort((a, b) => {
                    if (a.index < b.index) {
                        return -1;
                    }
                    if (a.index > b.index) {
                        return 1;
                    }
                    return 0;
                });

                let front: string, back: string;
                for (const m of siblings) {
                    const deletionStart: number = m.index,
                        deletionEnd: number = deletionStart + m[0].length;
                    front =
                        cardText.substring(0, deletionStart) +
                        "<span style='color:#2196f3'>[...]</span>" +
                        cardText.substring(deletionEnd);
                    front = front.replace(/==/gm, "").replace(/\*\*/gm, "");
                    back =
                        cardText.substring(0, deletionStart) +
                        "<span style='color:#2196f3'>" +
                        cardText.substring(deletionStart, deletionEnd) +
                        "</span>" +
                        cardText.substring(deletionEnd);
                    back = back.replace(/==/gm, "").replace(/\*\*/gm, "");
                    siblingMatches.push([front, back]);
                }
            } else {
                let idx: number;
                if (cardType === CardType.SingleLineBasic) {
                    idx = cardText.indexOf(settings.singlelineCardSeparator);
                    siblingMatches.push([
                        cardText.substring(0, idx),
                        cardText.substring(idx + settings.singlelineCardSeparator.length),
                    ]);
                } else if (cardType === CardType.SingleLineReversed) {
                    idx = cardText.indexOf(settings.singlelineReversedCardSeparator);
                    const side1: string = cardText.substring(0, idx),
                        side2: string = cardText.substring(
                            idx + settings.singlelineReversedCardSeparator.length
                        );
                    siblingMatches.push([side1, side2]);
                    siblingMatches.push([side2, side1]);
                } else if (cardType === CardType.MultiLineBasic) {
                    idx = cardText.indexOf("\n" + settings.multilineCardSeparator + "\n");
                    siblingMatches.push([
                        cardText.substring(0, idx),
                        cardText.substring(idx + 2 + settings.multilineCardSeparator.length),
                    ]);
                } else if (cardType === CardType.MultiLineReversed) {
                    idx = cardText.indexOf("\n" + settings.multilineReversedCardSeparator + "\n");
                    const side1: string = cardText.substring(0, idx),
                        side2: string = cardText.substring(
                            idx + 2 + settings.multilineReversedCardSeparator.length
                        );
                    siblingMatches.push([side1, side2]);
                    siblingMatches.push([side2, side1]);
                }
            }

            let scheduling: RegExpMatchArray[] = [...cardText.matchAll(MULTI_SCHEDULING_EXTRACTOR)];
            if (scheduling.length === 0)
                scheduling = [...cardText.matchAll(LEGACY_SCHEDULING_EXTRACTOR)];

            // we have some extra scheduling dates to delete
            if (scheduling.length > siblingMatches.length) {
                const idxSched: number = cardText.lastIndexOf("<!--SR:") + 7;
                let newCardText: string = cardText.substring(0, idxSched);
                for (let i = 0; i < siblingMatches.length; i++)
                    newCardText += `!${scheduling[i][1]},${scheduling[i][2]},${scheduling[i][3]}`;
                newCardText += "-->";

                const replacementRegex = new RegExp(escapeRegexString(cardText), "gm");
                fileText = fileText.replace(replacementRegex, () => newCardText);
                fileChanged = true;
            }

            const context: string = settings.showContextInCards
                ? getCardContext(lineNo, headings)
                : "";
            const siblings: Card[] = [];
            for (let i = 0; i < siblingMatches.length; i++) {
                const front: string = siblingMatches[i][0].trim(),
                    back: string = siblingMatches[i][1].trim();

                const cardObj: Card = {
                    isDue: i < scheduling.length,
                    note,
                    lineNo,
                    front,
                    back,
                    cardText,
                    context,
                    cardType,
                    siblingIdx: i,
                    siblings,
                };

                // card scheduled
                if (ignoreStats) {
                    this.cardStats.newCount++;
                    cardObj.isDue = true;
                    this.deckTree.insertFlashcard([...deckPath], cardObj);
                } else if (i < scheduling.length) {
                    const dueUnix: number = window
                        .moment(scheduling[i][1], ["YYYY-MM-DD", "DD-MM-YYYY"])
                        .valueOf();
                    const nDays: number = Math.ceil((dueUnix - now) / (24 * 3600 * 1000));
                    if (!Object.prototype.hasOwnProperty.call(this.dueDatesFlashcards, nDays)) {
                        this.dueDatesFlashcards[nDays] = 0;
                    }
                    this.dueDatesFlashcards[nDays]++;

                    const interval: number = parseInt(scheduling[i][2]),
                        ease: number = parseInt(scheduling[i][3]);
                    if (!Object.prototype.hasOwnProperty.call(this.cardStats.intervals, interval)) {
                        this.cardStats.intervals[interval] = 0;
                    }
                    this.cardStats.intervals[interval]++;
                    if (!Object.prototype.hasOwnProperty.call(this.cardStats.eases, ease)) {
                        this.cardStats.eases[ease] = 0;
                    }
                    this.cardStats.eases[ease]++;
                    totalNoteEase += ease;
                    scheduledCount++;

                    if (interval >= 32) {
                        this.cardStats.matureCount++;
                    } else {
                        this.cardStats.youngCount++;
                    }

                    if (this.data.buryList.includes(cardTextHash)) {
                        this.deckTree.countFlashcard([...deckPath]);
                        continue;
                    }

                    if (dueUnix <= now) {
                        cardObj.interval = interval;
                        cardObj.ease = ease;
                        cardObj.delayBeforeReview = now - dueUnix;
                        this.deckTree.insertFlashcard([...deckPath], cardObj);
                    } else {
                        this.deckTree.countFlashcard([...deckPath]);
                        continue;
                    }
                } else {
                    this.cardStats.newCount++;
                    if (this.data.buryList.includes(cyrb53(cardText))) {
                        this.deckTree.countFlashcard([...deckPath]);
                        continue;
                    }
                    this.deckTree.insertFlashcard([...deckPath], cardObj);
                }

                siblings.push(cardObj);
            }
        }

        if (fileChanged) {
            await this.app.vault.modify(note, fileText);
        }

        if (scheduledCount > 0) {
            const flashcardsInNoteAvgEase: number = totalNoteEase / scheduledCount;
            const flashcardContribution: number = Math.min(
                1.0,
                Math.log(scheduledCount + 0.5) / Math.log(64)
            );
            return (
                flashcardsInNoteAvgEase * flashcardContribution +
                settings.baseEase * (1.0 - flashcardContribution)
            );
        }

        return 0;
    }

    async loadPluginData(): Promise<void> {
        this.data = Object.assign({}, DEFAULT_DATA, await this.loadData());
        this.data.settings = Object.assign({}, DEFAULT_SETTINGS, this.data.settings);
    }

    async savePluginData(): Promise<void> {
        await this.saveData(this.data);
    }

    initView(): void {
        if (this.app.workspace.getLeavesOfType(REVIEW_QUEUE_VIEW_TYPE).length) {
            return;
        }

        this.app.workspace.getRightLeaf(false).setViewState({
            type: REVIEW_QUEUE_VIEW_TYPE,
            active: true,
        });
    }
}
Example #24
Source File: main.ts    From obsidian-jupyter with MIT License 4 votes vote down vote up
export default class JupyterPlugin extends Plugin {
	settings: JupyterPluginSettings;
	clients: Map<string, JupyterClient>;

	async postprocessor(src: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) {
		// Render the code using the default renderer for python.
		await MarkdownRenderer.renderMarkdown('```python\n' + src + '```', el, '',
											  this.app.workspace.activeLeaf.view);

		// Needed for positioning of the button and hiding Jupyter prompts.
		el.classList.add('obsidian-jupyter');
		// Add a button to run the code.
		let button = el.querySelector('pre').createEl('button', {
			type: 'button',
			text: 'Run',
			cls: 'copy-code-button',
		});
		button.setAttribute('style', `right: 32pt`);
		button.addEventListener('click', () => {
			button.innerText = 'Running...';
			this.getJupyterClient(ctx).request({
				command: 'execute',
				source: `${this.settings.setupScript}\n${src}`,
			}).then(response => {
				// Find the div to paste the output into or create it if necessary.
				let output = el.querySelector('div.obsidian-jupyter-output');
				if (output == null) {
					output = el.createEl('div');
					output.classList.add('obsidian-jupyter-output');
				}
				// Paste the output and reset the button.
				setInnerHTML(output, response);
				button.innerText = 'Run';
			});
		});
	}

	getJupyterClient(ctx: MarkdownPostProcessorContext): JupyterClient {
		let client = this.clients.get(ctx.docId);
		// Construct the interpeter path.
		let cache = this.app.metadataCache.getCache(ctx.sourcePath);
		let frontmatter: any = (cache ? cache.frontmatter : {}) || {};
		let interpreter = (frontmatter['obsidian-jupyter'] || {})['interpreter'] || this.settings.pythonInterpreter;
		// If we have a client, check that the interpreter path is right and stop it if not.
		if (client && client.interpreter != interpreter) {
			console.log(`interpreter path (${client.interpreter}) for the client for doc ` +
						`${ctx.docId} does not match the desired path (${interpreter})`);
			client.stop();
			client = undefined;
		}

		// Create a new interpreter if required.
		if (client === undefined) {
			let options = {cwd: this.getBasePath()};
			let path = this.getRelativeScriptPath();
			client = new JupyterClient(interpreter, [path, ctx.docId], options);
			this.clients.set(ctx.docId, client);
			console.log(`created new client for doc ${ctx.docId} using interpreter ${interpreter}`);
		}
		return client;
	}

	createJupyterPreview(leaf: WorkspaceLeaf) {
		return new JupterPreview(leaf, this.settings.pythonInterpreter);
	}

	async onload() {
		console.log('loading jupyter plugin');
		this.clients = new Map();

		await this.loadSettings();
		await this.downloadPythonScript();

		this.addSettingTab(new JupyterSettingTab(this.app, this));
		this.registerMarkdownCodeBlockProcessor('jupyter', this.postprocessor.bind(this));
		this.registerView("ipynb", this.createJupyterPreview.bind(this));
		this.registerExtensions(["ipynb"], "ipynb");
	}

	async downloadPythonScript() {
		let path = this.getAbsoluteScriptPath();
		try {
			let stats = statSync(path);
			if (!stats.isFile()) {
				throw new Error('python script is missing');
			}
			console.log(`python script exists at ${path}`);
		} catch {
			console.log('downloading missing python script...');
			let client = new HttpClient('obsidian-jupyter');
			let url = `https://github.com/tillahoffmann/obsidian-jupyter/releases/download/${this.manifest.version}/obsidian-jupyter.py`;
			let response = await client.get(url);
			if (response.message.statusCode != 200) {
				throw new Error(`could not download missing python script: ${response.message.statusMessage}`);
			}
			let content = await response.readBody();
			writeFileSync(path, content);
			console.log('obtained missing python script');
		}
	}

	getRelativeScriptPath(): string {
		return `${this.app.vault.configDir}/plugins/obsidian-jupyter/obsidian-jupyter.py`;
	}

	getAbsoluteScriptPath(): string {
		return `${this.getBasePath()}/${this.getRelativeScriptPath()}`;
	}

	getBasePath(): string {
		if (this.app.vault.adapter instanceof FileSystemAdapter) {
			return (this.app.vault.adapter as FileSystemAdapter).getBasePath();
		}
		throw new Error('cannot determine base path');
	}

	onunload() {
		console.log('unloading jupyter plugin');
		this.clients.forEach((client, docId) => {
			console.log(`stopping client for doc ${docId}...`);
			client.stop();
		});
	}

	async loadSettings() {
		this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
	}

	async saveSettings() {
		await this.saveData(this.settings);
	}
}
Example #25
Source File: main.ts    From obsidian-editor-shortcuts with MIT License 4 votes vote down vote up
export default class CodeEditorShortcuts extends Plugin {
  onload() {
    this.addCommand({
      id: 'insertLineAbove',
      name: 'Insert line above',
      hotkeys: [
        {
          modifiers: ['Mod', 'Shift'],
          key: 'Enter',
        },
      ],
      editorCallback: (editor) =>
        withMultipleSelections(editor, insertLineAbove),
    });

    this.addCommand({
      id: 'insertLineBelow',
      name: 'Insert line below',
      hotkeys: [
        {
          modifiers: ['Mod'],
          key: 'Enter',
        },
      ],
      editorCallback: (editor) =>
        withMultipleSelections(editor, insertLineBelow, {
          ...defaultMultipleSelectionOptions,
          customSelectionHandler: insertLineBelowHandler,
        }),
    });

    this.addCommand({
      id: 'deleteLine',
      name: 'Delete line',
      hotkeys: [
        {
          modifiers: ['Mod', 'Shift'],
          key: 'K',
        },
      ],
      editorCallback: (editor) =>
        withMultipleSelections(editor, deleteSelectedLines),
    });

    this.addCommand({
      id: 'deleteToStartOfLine',
      name: 'Delete to start of line',
      editorCallback: (editor) =>
        withMultipleSelections(editor, deleteToStartOfLine),
    });

    this.addCommand({
      id: 'deleteToEndOfLine',
      name: 'Delete to end of line',
      editorCallback: (editor) =>
        withMultipleSelections(editor, deleteToEndOfLine),
    });

    this.addCommand({
      id: 'joinLines',
      name: 'Join lines',
      hotkeys: [
        {
          modifiers: ['Mod'],
          key: 'J',
        },
      ],
      editorCallback: (editor) =>
        withMultipleSelections(editor, joinLines, {
          ...defaultMultipleSelectionOptions,
          repeatSameLineActions: false,
        }),
    });

    this.addCommand({
      id: 'duplicateLine',
      name: 'Duplicate line',
      hotkeys: [
        {
          modifiers: ['Mod', 'Shift'],
          key: 'D',
        },
      ],
      editorCallback: (editor) =>
        withMultipleSelections(editor, copyLine, {
          ...defaultMultipleSelectionOptions,
          args: 'down',
        }),
    });

    this.addCommand({
      id: 'copyLineUp',
      name: 'Copy line up',
      hotkeys: [
        {
          modifiers: ['Alt', 'Shift'],
          key: 'ArrowUp',
        },
      ],
      editorCallback: (editor) =>
        withMultipleSelections(editor, copyLine, {
          ...defaultMultipleSelectionOptions,
          args: 'up',
        }),
    });

    this.addCommand({
      id: 'copyLineDown',
      name: 'Copy line down',
      hotkeys: [
        {
          modifiers: ['Alt', 'Shift'],
          key: 'ArrowDown',
        },
      ],
      editorCallback: (editor) =>
        withMultipleSelections(editor, copyLine, {
          ...defaultMultipleSelectionOptions,
          args: 'down',
        }),
    });

    this.addCommand({
      id: 'selectWordOrNextOccurrence',
      name: 'Select word or next occurrence',
      hotkeys: [
        {
          modifiers: ['Mod'],
          key: 'D',
        },
      ],
      editorCallback: (editor) => selectWordOrNextOccurrence(editor),
    });

    this.addCommand({
      id: 'selectAllOccurrences',
      name: 'Select all occurrences',
      hotkeys: [
        {
          modifiers: ['Mod', 'Shift'],
          key: 'L',
        },
      ],
      editorCallback: (editor) => selectAllOccurrences(editor),
    });

    this.addCommand({
      id: 'selectLine',
      name: 'Select line',
      hotkeys: [
        {
          modifiers: ['Mod'],
          key: 'L',
        },
      ],
      editorCallback: (editor) => withMultipleSelections(editor, selectLine),
    });

    this.addCommand({
      id: 'addCursorsToSelectionEnds',
      name: 'Add cursors to selection ends',
      hotkeys: [
        {
          modifiers: ['Alt', 'Shift'],
          key: 'I',
        },
      ],
      editorCallback: (editor) => addCursorsToSelectionEnds(editor),
    });

    this.addCommand({
      id: 'goToLineStart',
      name: 'Go to start of line',
      editorCallback: (editor) =>
        withMultipleSelections(editor, goToLineBoundary, {
          ...defaultMultipleSelectionOptions,
          args: 'start',
        }),
    });

    this.addCommand({
      id: 'goToLineEnd',
      name: 'Go to end of line',
      editorCallback: (editor) =>
        withMultipleSelections(editor, goToLineBoundary, {
          ...defaultMultipleSelectionOptions,
          args: 'end',
        }),
    });

    this.addCommand({
      id: 'goToNextLine',
      name: 'Go to next line',
      editorCallback: (editor) =>
        withMultipleSelections(editor, navigateLine, {
          ...defaultMultipleSelectionOptions,
          args: 'down',
        }),
    });

    this.addCommand({
      id: 'goToPrevLine',
      name: 'Go to previous line',
      editorCallback: (editor) =>
        withMultipleSelections(editor, navigateLine, {
          ...defaultMultipleSelectionOptions,
          args: 'up',
        }),
    });

    this.addCommand({
      id: 'goToNextChar',
      name: 'Move cursor forward',
      editorCallback: (editor) =>
        withMultipleSelections(editor, moveCursor, {
          ...defaultMultipleSelectionOptions,
          args: DIRECTION.FORWARD,
        }),
    });

    this.addCommand({
      id: 'goToPrevChar',
      name: 'Move cursor backward',
      editorCallback: (editor) =>
        withMultipleSelections(editor, moveCursor, {
          ...defaultMultipleSelectionOptions,
          args: DIRECTION.BACKWARD,
        }),
    });

    this.addCommand({
      id: 'transformToUppercase',
      name: 'Transform selection to uppercase',
      editorCallback: (editor) =>
        withMultipleSelections(editor, transformCase, {
          ...defaultMultipleSelectionOptions,
          args: CASE.UPPER,
        }),
    });

    this.addCommand({
      id: 'transformToLowercase',
      name: 'Transform selection to lowercase',
      editorCallback: (editor) =>
        withMultipleSelections(editor, transformCase, {
          ...defaultMultipleSelectionOptions,
          args: CASE.LOWER,
        }),
    });

    this.addCommand({
      id: 'transformToTitlecase',
      name: 'Transform selection to title case',
      editorCallback: (editor) =>
        withMultipleSelections(editor, transformCase, {
          ...defaultMultipleSelectionOptions,
          args: CASE.TITLE,
        }),
    });

    this.addCommand({
      id: 'expandSelectionToBrackets',
      name: 'Expand selection to brackets',
      editorCallback: (editor) =>
        withMultipleSelections(editor, expandSelectionToBrackets),
    });

    this.addCommand({
      id: 'expandSelectionToQuotes',
      name: 'Expand selection to quotes',
      editorCallback: (editor) =>
        withMultipleSelections(editor, expandSelectionToQuotes),
    });

    this.addCommand({
      id: 'expandSelectionToQuotesOrBrackets',
      name: 'Expand selection to quotes or brackets',
      editorCallback: (editor) => expandSelectionToQuotesOrBrackets(editor),
    });

    this.addCommand({
      id: 'goToNextHeading',
      name: 'Go to next heading',
      editorCallback: (editor) => goToHeading(this.app, editor, 'next'),
    });

    this.addCommand({
      id: 'goToPrevHeading',
      name: 'Go to previous heading',
      editorCallback: (editor) => goToHeading(this.app, editor, 'prev'),
    });

    this.registerSelectionChangeListeners();
  }

  private registerSelectionChangeListeners() {
    this.app.workspace.onLayoutReady(() => {
      // Change handler for selectWordOrNextOccurrence
      const handleSelectionChange = (evt: Event) => {
        if (evt instanceof KeyboardEvent && MODIFIER_KEYS.includes(evt.key)) {
          return;
        }
        if (!isProgrammaticSelectionChange) {
          setIsManualSelection(true);
        }
        setIsProgrammaticSelectionChange(false);
      };
      iterateCodeMirrorDivs((cm: HTMLElement) => {
        this.registerDomEvent(cm, 'keydown', handleSelectionChange);
        this.registerDomEvent(cm, 'click', handleSelectionChange);
        this.registerDomEvent(cm, 'dblclick', handleSelectionChange);
      });
    });
  }
}
Example #26
Source File: main.ts    From obsidian-admonition with MIT License 4 votes vote down vote up
export default class ObsidianAdmonition extends Plugin {
    data: AdmonitionSettings;

    postprocessors: Map<string, MarkdownPostProcessor> = new Map();

    iconManager = new IconManager(this);
    calloutManager: CalloutManager;

    get types() {
        return Object.keys(this.admonitions);
    }
    get admonitionArray() {
        return Object.keys(this.admonitions).map((key) => {
            return {
                ...this.admonitions[key],
                type: key
            };
        });
    }

    async onload(): Promise<void> {
        console.log("Obsidian Admonition loaded");

        this.postprocessors = new Map();

        await this.loadSettings();
        await this.iconManager.load();
        this.app.workspace.onLayoutReady(async () => {
            this.addChild((this.calloutManager = new CalloutManager(this)));

            this.registerEditorSuggest(new AdmonitionSuggest(this));

            Object.keys(this.admonitions).forEach((type) => {
                this.registerType(type);
            });

            this.addSettingTab(new AdmonitionSetting(this.app, this));

            addIcon(ADD_COMMAND_NAME, ADD_ADMONITION_COMMAND_ICON);
            addIcon(REMOVE_COMMAND_NAME, REMOVE_ADMONITION_COMMAND_ICON);
            addIcon(WARNING_ICON_NAME, WARNING_ICON);
            addIcon(SPIN_ICON_NAME, SPIN_ICON);

            /** Add generic commands. */
            this.addCommand({
                id: "collapse-admonitions",
                name: "Collapse Admonitions in Note",
                checkCallback: (checking) => {
                    // checking if the command should appear in the Command Palette
                    if (checking) {
                        // make sure the active view is a MarkdownView.
                        return !!this.app.workspace.getActiveViewOfType(
                            MarkdownView
                        );
                    }
                    let view =
                        this.app.workspace.getActiveViewOfType(MarkdownView);
                    if (!view || !(view instanceof MarkdownView)) return;

                    let admonitions = view.contentEl.querySelectorAll(
                        "details[open].admonition-plugin"
                    );
                    for (let i = 0; i < admonitions.length; i++) {
                        let admonition = admonitions[i];
                        admonition.removeAttribute("open");
                    }
                }
            });
            this.addCommand({
                id: "open-admonitions",
                name: "Open Admonitions in Note",
                checkCallback: (checking) => {
                    // checking if the command should appear in the Command Palette
                    if (checking) {
                        // make sure the active view is a MarkdownView.
                        return !!this.app.workspace.getActiveViewOfType(
                            MarkdownView
                        );
                    }
                    let view =
                        this.app.workspace.getActiveViewOfType(MarkdownView);
                    if (!view || !(view instanceof MarkdownView)) return;

                    let admonitions = view.contentEl.querySelectorAll(
                        "details:not([open]).admonition-plugin"
                    );
                    for (let i = 0; i < admonitions.length; i++) {
                        let admonition = admonitions[i];
                        admonition.setAttribute("open", "open");
                    }
                }
            });
            this.addCommand({
                id: "insert-admonition",
                name: "Insert Admonition",
                editorCallback: (editor, view) => {
                    let suggestor = new InsertAdmonitionModal(this);
                    suggestor.onClose = () => {
                        if (!suggestor.insert) return;
                        let titleLine = "",
                            collapseLine = "";
                        if (
                            suggestor.title.length &&
                            suggestor.title.toLowerCase() !=
                                suggestor.type.toLowerCase()
                        ) {
                            titleLine = `title: ${suggestor.title}\n`;
                        }
                        if (
                            (this.data.autoCollapse &&
                                suggestor.collapse !=
                                    this.data.defaultCollapseType) ||
                            (!this.data.autoCollapse &&
                                suggestor.collapse != "none")
                        ) {
                            collapseLine = `collapse: ${suggestor.collapse}\n`;
                        }
                        editor.getDoc().replaceSelection(
                            `\`\`\`ad-${
                                suggestor.type
                            }\n${titleLine}${collapseLine}
${editor.getDoc().getSelection()}
\`\`\`\n`
                        );
                        const cursor = editor.getCursor();
                        editor.setCursor(cursor.line - 3);
                    };
                    suggestor.open();
                }
            });
            this.addCommand({
                id: "insert-callout",
                name: "Insert Callout",
                editorCallback: (editor, view) => {
                    let suggestor = new InsertAdmonitionModal(this);
                    suggestor.onClose = () => {
                        if (!suggestor.insert) return;
                        let title = "",
                            collapse = "";
                        if (
                            (this.data.autoCollapse &&
                                suggestor.collapse !=
                                    this.data.defaultCollapseType) ||
                            (!this.data.autoCollapse &&
                                suggestor.collapse != "none")
                        ) {
                            switch (suggestor.collapse) {
                                case "open": {
                                    collapse = "+";
                                    break;
                                }
                                case "closed": {
                                    collapse = "-";
                                    break;
                                }
                            }
                        }
                        if (
                            suggestor.title.length &&
                            suggestor.title.toLowerCase() !=
                                suggestor.type.toLowerCase()
                        ) {
                            title = ` ${suggestor.title}`;
                        }
                        const selection = editor.getDoc().getSelection();
                        editor.getDoc().replaceSelection(
                            `> [!${suggestor.type}]${collapse}${title}
> ${selection.split("\n").join("\n> ")}
`
                        );
                    };
                    suggestor.open();
                }
            });
        });
    }
    async downloadIcon(pack: DownloadableIconPack) {
        this.iconManager.downloadIcon(pack);
    }

    async removeIcon(pack: DownloadableIconPack) {
        this.iconManager.removeIcon(pack);
    }

    async postprocessor(
        type: string,
        src: string,
        el: HTMLElement,
        ctx?: MarkdownPostProcessorContext
    ) {
        if (!this.admonitions[type]) {
            return;
        }
        try {
            const sourcePath =
                typeof ctx == "string"
                    ? ctx
                    : ctx?.sourcePath ??
                      this.app.workspace.getActiveFile()?.path ??
                      "";
            let { title, collapse, content, icon, color } =
                getParametersFromSource(type, src, this.admonitions[type]);

            if (this.data.autoCollapse && !collapse) {
                collapse = this.data.defaultCollapseType ?? "open";
            } else if (collapse && collapse.trim() === "none") {
                collapse = "";
            }

            /* const iconNode = icon ? this.admonitions[type].icon; */
            const admonition = this.admonitions[type];
            let admonitionElement = this.getAdmonitionElement(
                type,
                title,
                this.iconManager.iconDefinitions.find(
                    ({ name }) => icon === name
                ) ?? admonition.icon,
                color ??
                    (admonition.injectColor ?? this.data.injectColor
                        ? admonition.color
                        : null),
                collapse
            );
            this.renderAdmonitionContent(
                admonitionElement,
                type,
                content,
                ctx,
                sourcePath,
                src
            );
            if (collapse && collapse != "none") {
                this.calloutManager.setCollapsible(admonitionElement);
            }
            /**
             * Replace the <pre> tag with the new admonition.
             */
            const parent = el.parentElement;
            if (parent) {
                parent.addClass(
                    "admonition-parent",
                    `admonition-${type}-parent`
                );
            }
            el.replaceWith(admonitionElement);
            return admonitionElement;
        } catch (e) {
            console.error(e);
            const pre = createEl("pre");

            pre.createEl("code", {
                attr: {
                    style: `color: var(--text-error) !important`
                }
            }).createSpan({
                text:
                    "There was an error rendering the admonition:" +
                    "\n\n" +
                    src
            });

            el.replaceWith(pre);
        }
    }

    /**
     *  .callout.admonition.is-collapsible.is-collapsed
     *      .callout-title
     *          .callout-icon
     *          .callout-title-inner
     *          .callout-fold
     *      .callout-content
     */
    getAdmonitionElement(
        type: string,
        title: string,
        icon: AdmonitionIconDefinition,
        color?: string,
        collapse?: string
    ): HTMLElement {
        const admonition = createDiv({
            cls: `callout admonition admonition-${type} admonition-plugin ${
                !title?.trim().length ? "no-title" : ""
            }`,
            attr: {
                style: color ? `--callout-color: ${color};` : '',
                "data-callout": type,
                "data-callout-fold": ""
            }
        });
        const titleEl = admonition.createDiv({
            cls: `callout-title admonition-title ${
                !title?.trim().length ? "no-title" : ""
            }`
        });

        if (title && title.trim().length) {
            //build icon element
            const iconEl = titleEl.createDiv(
                "callout-icon admonition-title-icon"
            );
            if (icon && icon.name && icon.type) {
                iconEl.appendChild(
                    this.iconManager.getIconNode(icon) ?? createDiv()
                );
            }

            //get markdown
            const titleInnerEl = titleEl.createDiv(
                "callout-title-inner admonition-title-content"
            );
            MarkdownRenderer.renderMarkdown(title, titleInnerEl, "", null);
            if (
                titleInnerEl.firstElementChild &&
                titleInnerEl.firstElementChild instanceof HTMLParagraphElement
            ) {
                titleInnerEl.setChildrenInPlace(
                    Array.from(titleInnerEl.firstElementChild.childNodes)
                );
            }
        }

        //add them to title element

        if (collapse) {
            admonition.addClass("is-collapsible");
            if (collapse == "closed") {
                admonition.addClass("is-collapsed");
            }
        }
        if (!this.data.dropShadow) {
            admonition.addClass("no-drop");
        }
        return admonition;
    }

    renderAdmonitionContent(
        admonitionElement: HTMLElement,
        type: string,
        content: string,
        ctx: MarkdownPostProcessorContext,
        sourcePath: string,
        src: string
    ) {
        let markdownRenderChild = new MarkdownRenderChild(admonitionElement);
        markdownRenderChild.containerEl = admonitionElement;
        if (ctx && !(typeof ctx == "string")) {
            ctx.addChild(markdownRenderChild);
        }

        if (content && content?.trim().length) {
            /**
             * Render the content as markdown and append it to the admonition.
             */
            const contentEl = this.getAdmonitionContentElement(
                type,
                admonitionElement,
                content
            );
            if (/^`{3,}mermaid/m.test(content)) {
                const wasCollapsed = !admonitionElement.hasAttribute("open");
                if (admonitionElement instanceof HTMLDetailsElement) {
                    admonitionElement.setAttribute("open", "open");
                }
                setImmediate(() => {
                    MarkdownRenderer.renderMarkdown(
                        content,
                        contentEl,
                        sourcePath,
                        markdownRenderChild
                    );
                    if (
                        admonitionElement instanceof HTMLDetailsElement &&
                        wasCollapsed
                    ) {
                        admonitionElement.removeAttribute("open");
                    }
                });
            } else {
                MarkdownRenderer.renderMarkdown(
                    content,
                    contentEl,
                    sourcePath,
                    markdownRenderChild
                );
            }

            if (
                (!content.length || contentEl.textContent.trim() == "") &&
                this.data.hideEmpty
            )
                admonitionElement.addClass("no-content");

            const taskLists = contentEl.querySelectorAll<HTMLInputElement>(
                ".task-list-item-checkbox"
            );
            if (taskLists?.length) {
                const split = src.split("\n");
                let slicer = 0;
                taskLists.forEach((task) => {
                    const line = split
                        .slice(slicer)
                        .findIndex((l) => /^[ \t>]*\- \[.\]/.test(l));

                    if (line == -1) return;
                    task.dataset.line = `${line + slicer + 1}`;
                    slicer = line + slicer + 1;
                });
            }
        }
    }

    getAdmonitionContentElement(
        type: string,
        admonitionElement: HTMLElement,
        content: string
    ) {
        const contentEl = admonitionElement.createDiv(
            "callout-content admonition-content"
        );
        if (this.admonitions[type].copy ?? this.data.copyButton) {
            let copy = contentEl.createDiv("admonition-content-copy");
            setIcon(copy, "copy");
            copy.addEventListener("click", () => {
                navigator.clipboard.writeText(content.trim()).then(async () => {
                    new Notice("Admonition content copied to clipboard.");
                });
            });
        }
        return contentEl;
    }

    registerType(type: string) {
        /** Turn on CodeMirror syntax highlighting for this "language" */
        if (this.data.syntaxHighlight) {
            this.turnOnSyntaxHighlighting([type]);
        }

        /** Register an admonition code-block post processor for legacy support. */
        if (this.postprocessors.has(type)) {
            MarkdownPreviewRenderer.unregisterCodeBlockPostProcessor(
                `ad-${type}`
            );
        }
        this.postprocessors.set(
            type,
            this.registerMarkdownCodeBlockProcessor(
                `ad-${type}`,
                (src, el, ctx) => this.postprocessor(type, src, el, ctx)
            )
        );
        const admonition = this.admonitions[type];
        if (admonition.command) {
            this.registerCommandsFor(admonition);
        }
    }
    get admonitions() {
        return { ...ADMONITION_MAP, ...this.data.userAdmonitions };
    }
    async addAdmonition(admonition: Admonition): Promise<void> {
        this.data.userAdmonitions = {
            ...this.data.userAdmonitions,
            [admonition.type]: admonition
        };

        this.registerType(admonition.type);

        /** Create the admonition type in CSS */
        this.calloutManager.addAdmonition(admonition);

        await this.saveSettings();
    }
    registerCommandsFor(admonition: Admonition) {
        admonition.command = true;
        this.addCommand({
            id: `insert-${admonition.type}-callout`,
            name: `Insert ${admonition.type} Callout`,
            editorCheckCallback: (checking, editor, view) => {
                if (checking) return admonition.command;
                if (admonition.command) {
                    try {
                        const selection = editor.getDoc().getSelection();
                        editor.getDoc().replaceSelection(
                            `> [!${admonition.type}]
> ${selection.split("\n").join("\n> ")}
`
                        );
                        const cursor = editor.getCursor();
                        editor.setCursor(cursor.line - 2);
                    } catch (e) {
                        new Notice(
                            "There was an issue inserting the admonition."
                        );
                    }
                }
            }
        });
        this.addCommand({
            id: `insert-${admonition.type}`,
            name: `Insert ${admonition.type}`,
            editorCheckCallback: (checking, editor, view) => {
                if (checking) return admonition.command;
                if (admonition.command) {
                    try {
                        editor.getDoc().replaceSelection(
                            `\`\`\`ad-${admonition.type}

${editor.getDoc().getSelection()}

\`\`\`\n`
                        );
                        const cursor = editor.getCursor();
                        editor.setCursor(cursor.line - 2);
                    } catch (e) {
                        new Notice(
                            "There was an issue inserting the admonition."
                        );
                    }
                }
            }
        });
        this.addCommand({
            id: `insert-${admonition.type}-with-title`,
            name: `Insert ${admonition.type} With Title`,
            editorCheckCallback: (checking, editor, view) => {
                if (checking) return admonition.command;
                if (admonition.command) {
                    try {
                        const title = admonition.title ?? "";
                        editor.getDoc().replaceSelection(
                            `\`\`\`ad-${admonition.type}
title: ${title}

${editor.getDoc().getSelection()}

\`\`\`\n`
                        );
                        const cursor = editor.getCursor();
                        editor.setCursor(cursor.line - 3);
                    } catch (e) {
                        new Notice(
                            "There was an issue inserting the admonition."
                        );
                    }
                }
            }
        });
    }
    unregisterType(admonition: Admonition) {
        if (this.data.syntaxHighlight) {
            this.turnOffSyntaxHighlighting([admonition.type]);
        }

        if (admonition.command) {
            this.unregisterCommandsFor(admonition);
        }

        if (this.postprocessors.has(admonition.type)) {
            MarkdownPreviewRenderer.unregisterPostProcessor(
                this.postprocessors.get(admonition.type)
            );
            MarkdownPreviewRenderer.unregisterCodeBlockPostProcessor(
                `ad-${admonition.type}`
            );
            this.postprocessors.delete(admonition.type);
        }
    }
    async removeAdmonition(admonition: Admonition) {
        if (this.data.userAdmonitions[admonition.type]) {
            delete this.data.userAdmonitions[admonition.type];
        }

        this.unregisterType(admonition);

        /** Remove the admonition type in CSS */
        this.calloutManager.removeAdmonition(admonition);

        await this.saveSettings();
    }
    unregisterCommandsFor(admonition: Admonition) {
        admonition.command = false;

        if (
            this.app.commands.findCommand(
                `obsidian-admonition:insert-${admonition.type}`
            )
        ) {
            delete this.app.commands.editorCommands[
                `obsidian-admonition:insert-${admonition.type}`
            ];
            delete this.app.commands.editorCommands[
                `obsidian-admonition:insert-${admonition.type}-with-title`
            ];
            delete this.app.commands.commands[
                `obsidian-admonition:insert-${admonition.type}`
            ];
            delete this.app.commands.commands[
                `obsidian-admonition:insert-${admonition.type}-with-title`
            ];
        }
    }

    async saveSettings() {
        this.data.version = this.manifest.version;

        await this.saveData(this.data);
    }
    async loadSettings() {
        const loaded: AdmonitionSettings = await this.loadData();
        this.data = Object.assign({}, DEFAULT_APP_SETTINGS, loaded);

        if (this.data.userAdmonitions) {
            if (
                !this.data.version ||
                Number(this.data.version.split(".")[0]) < 5
            ) {
                for (let admonition in this.data.userAdmonitions) {
                    if (
                        Object.prototype.hasOwnProperty.call(
                            this.data.userAdmonitions[admonition],
                            "type"
                        )
                    )
                        continue;
                    this.data.userAdmonitions[admonition] = {
                        ...this.data.userAdmonitions[admonition],
                        icon: {
                            type: "font-awesome",
                            name: this.data.userAdmonitions[admonition]
                                .icon as unknown as IconName
                        }
                    };
                }
            }

            if (
                !this.data.version ||
                Number(this.data.version.split(".")[0]) < 8
            ) {
                new Notice(
                    createFragment((e) => {
                        e.createSpan({
                            text: "Admonitions: Obsidian now has native support for callouts! Check out the "
                        });

                        e.createEl("a", {
                            text: "Admonitions ReadMe",
                            href: "obsidian://show-plugin?id=obsidian-admonition"
                        });

                        e.createSpan({
                            text: " for what that means for Admonitions going forward."
                        });
                    }),
                    0
                );
            }
        }

        if (
            !this.data.rpgDownloadedOnce &&
            this.data.userAdmonitions &&
            Object.values(this.data.userAdmonitions).some((admonition) => {
                if (admonition.icon.type == "rpg") return true;
            }) &&
            !this.data.icons.includes("rpg")
        ) {
            try {
                await this.downloadIcon("rpg");
                this.data.rpgDownloadedOnce = true;
            } catch (e) {}
        }

        await this.saveSettings();
    }

    turnOnSyntaxHighlighting(types: string[] = Object.keys(this.admonitions)) {
        if (!this.data.syntaxHighlight) return;
        types.forEach((type) => {
            if (this.data.syntaxHighlight) {
                /** Process from @deathau's syntax highlight plugin */
                const [, cmPatchedType] = `${type}`.match(
                    /^([\w+#-]*)[^\n`]*$/
                );
                window.CodeMirror.defineMode(
                    `ad-${cmPatchedType}`,
                    (config, options) => {
                        return window.CodeMirror.getMode({}, "hypermd");
                    }
                );
            }
        });

        this.app.workspace.onLayoutReady(() =>
            this.app.workspace.iterateCodeMirrors((cm) =>
                cm.setOption("mode", cm.getOption("mode"))
            )
        );
    }
    turnOffSyntaxHighlighting(types: string[] = Object.keys(this.admonitions)) {
        types.forEach((type) => {
            if (window.CodeMirror.modes.hasOwnProperty(`ad-${type}`)) {
                delete window.CodeMirror.modes[`ad-${type}`];
            }
        });
        this.app.workspace.onLayoutReady(() =>
            this.app.workspace.iterateCodeMirrors((cm) =>
                cm.setOption("mode", cm.getOption("mode"))
            )
        );
    }

    async onunload() {
        console.log("Obsidian Admonition unloaded");
        this.postprocessors = null;
        this.turnOffSyntaxHighlighting();
    }
}
Example #27
Source File: main.ts    From obsidian-initiative-tracker with GNU General Public License v3.0 4 votes vote down vote up
export default class InitiativeTracker extends Plugin {
    public data: InitiativeTrackerData;
    playerCreatures: Map<string, Creature> = new Map();
    homebrewCreatures: Map<string, Creature> = new Map();
    watchers: Map<TFile, HomebrewCreature> = new Map();
    async parseDice(text: string) {
        if (!this.canUseDiceRoller) return null;

        return await this.app.plugins
            .getPlugin("obsidian-dice-roller")
            .parseDice(text, "initiative-tracker");
    }
    getRoller(str: string) {
        if (!this.canUseDiceRoller) return;
        const roller = this.app.plugins
            .getPlugin("obsidian-dice-roller")
            .getRoller(str, "statblock", true);
        return roller;
    }
    get canUseDiceRoller() {
        return this.app.plugins.getPlugin("obsidian-dice-roller") != null;
    }

    async getInitiativeValue(modifier: number = 0): Promise<number> {
        let initiative = Math.floor(Math.random() * 19 + 1) + modifier;
        if (this.canUseDiceRoller) {
            const num = await this.app.plugins
                .getPlugin("obsidian-dice-roller")
                .parseDice(
                    this.data.initiative.replace(/%mod%/g, `(${modifier})`),
                    "initiative-tracker"
                );

            initiative = num.result;
        }
        return initiative;
    }

    get canUseStatBlocks() {
        return this.app.plugins.getPlugin("obsidian-5e-statblocks") != null;
    }
    get statblocks() {
        return this.app.plugins.getPlugin("obsidian-5e-statblocks");
    }
    get statblockVersion() {
        return this.statblocks?.settings?.version ?? { major: 0 };
    }
    get canUseLeaflet() {
        return (
            this.app.plugins.getPlugin("obsidian-leaflet-plugin") != null &&
            Number(
                this.app.plugins.getPlugin("obsidian-leaflet-plugin").data
                    ?.version?.major >= 4
            )
        );
    }

    get leaflet() {
        if (this.canUseLeaflet) {
            return this.app.plugins.getPlugin("obsidian-leaflet-plugin");
        }
    }

    get statblock_creatures() {
        if (!this.data.sync) return [];
        if (!this.app.plugins.getPlugin("obsidian-5e-statblocks")) return [];
        return [
            ...Array.from(
                this.app.plugins
                    .getPlugin("obsidian-5e-statblocks")
                    .data?.values() ?? []
            )
        ] as SRDMonster[];
    }

    get homebrew() {
        return [...this.statblock_creatures, ...this.data.homebrew];
    }

    get bestiary() {
        return [...BESTIARY, ...this.homebrew];
    }

    get view() {
        const leaves = this.app.workspace.getLeavesOfType(
            INTIATIVE_TRACKER_VIEW
        );
        const leaf = leaves?.length ? leaves[0] : null;
        if (leaf && leaf.view && leaf.view instanceof TrackerView)
            return leaf.view;
    }
    get combatant() {
        const leaves = this.app.workspace.getLeavesOfType(
            CREATURE_TRACKER_VIEW
        );
        const leaf = leaves?.length ? leaves[0] : null;
        if (leaf && leaf.view && leaf.view instanceof CreatureView)
            return leaf.view;
    }

    get defaultParty() {
        return this.data.parties.find((p) => p.name == this.data.defaultParty);
    }

    async onload() {
        registerIcons();

        await this.loadSettings();

        this.addSettingTab(new InitiativeTrackerSettings(this));

        this.registerView(
            INTIATIVE_TRACKER_VIEW,
            (leaf: WorkspaceLeaf) => new TrackerView(leaf, this)
        );
        this.registerView(
            CREATURE_TRACKER_VIEW,
            (leaf: WorkspaceLeaf) => new CreatureView(leaf, this)
        );

        this.addCommands();

        this.registerMarkdownCodeBlockProcessor("encounter", (src, el, ctx) => {
            const handler = new EncounterBlock(this, src, el);
            ctx.addChild(handler);
        });
        this.registerMarkdownCodeBlockProcessor(
            "encounter-table",
            (src, el, ctx) => {
                const handler = new EncounterBlock(this, src, el, true);
                ctx.addChild(handler);
            }
        );

        this.registerMarkdownPostProcessor(async (el, ctx) => {
            if (!el || !el.firstElementChild) return;

            const codeEls = el.querySelectorAll<HTMLElement>("code");
            if (!codeEls || !codeEls.length) return;

            const codes = Array.from(codeEls).filter((code) =>
                /^encounter:\s/.test(code.innerText)
            );
            if (!codes.length) return;

            for (const code of codes) {
                const creatures = code.innerText
                    .replace(`encounter:`, "")
                    .trim()
                    .split(",")
                    .map((s) => parseYaml(s.trim()));
                const parser = new EncounterParser(this);
                const parsed = await parser.parse({ creatures });

                if (!parsed || !parsed.creatures || !parsed.creatures.size)
                    continue;

                const target = createSpan("initiative-tracker-encounter-line");
                new EncounterLine({
                    target,
                    props: {
                        ...parsed,
                        plugin: this
                    }
                });

                code.replaceWith(target);
            }
        });

        this.playerCreatures = new Map(
            this.data.players.map((p) => [p.name, Creature.from(p)])
        );
        this.homebrewCreatures = new Map(
            this.bestiary.map((p) => [p.name, Creature.from(p)])
        );

        this.app.workspace.onLayoutReady(async () => {
            this.addTrackerView();
            //Update players from < 7.2
            for (const player of this.data.players) {
                if (player.path) continue;
                if (!player.note) continue;
                const file = await this.app.metadataCache.getFirstLinkpathDest(
                    player.note,
                    ""
                );
                if (
                    !file ||
                    !this.app.metadataCache.getFileCache(file)?.frontmatter
                ) {
                    new Notice(
                        `Initiative Tracker: There was an issue with the linked note for ${player.name}.\n\nPlease re-link it in settings.`
                    );
                    continue;
                }
            }
            this.registerEvent(
                this.app.metadataCache.on("changed", (file) => {
                    if (!(file instanceof TFile)) return;
                    const players = this.data.players.filter(
                        (p) => p.path == file.path
                    );
                    if (!players.length) return;
                    const frontmatter: FrontMatterCache =
                        this.app.metadataCache.getFileCache(file)?.frontmatter;
                    if (!frontmatter) return;
                    for (let player of players) {
                        const { ac, hp, modifier, level } = frontmatter;
                        player.ac = ac;
                        player.hp = hp;
                        player.modifier = modifier;
                        player.level = level;

                        this.playerCreatures.set(
                            player.name,
                            Creature.from(player)
                        );
                        if (this.view) {
                            const creature = this.view.ordered.find(
                                (c) => c.name == player.name
                            );
                            if (creature) {
                                this.view.updateCreature(creature, {
                                    max: player.hp,
                                    ac: player.ac
                                });
                            }
                        }
                    }
                })
            );
            this.registerEvent(
                this.app.vault.on("rename", (file, old) => {
                    if (!(file instanceof TFile)) return;
                    const players = this.data.players.filter(
                        (p) => p.path == old
                    );
                    if (!players.length) return;
                    for (const player of players) {
                        player.path = file.path;
                        player.note = file.basename;
                    }
                })
            );
            this.registerEvent(
                this.app.vault.on("delete", (file) => {
                    if (!(file instanceof TFile)) return;
                    const players = this.data.players.filter(
                        (p) => p.path == file.path
                    );
                    if (!players.length) return;
                    for (const player of players) {
                        player.path = null;
                        player.note = null;
                    }
                })
            );
        });

        console.log("Initiative Tracker v" + this.manifest.version + " loaded");
    }

    addCommands() {
        this.addCommand({
            id: "open-tracker",
            name: "Open Initiative Tracker",
            checkCallback: (checking) => {
                if (!this.view) {
                    if (!checking) {
                        this.addTrackerView();
                    }
                    return true;
                }
            }
        });

        this.addCommand({
            id: "toggle-encounter",
            name: "Toggle Encounter",
            checkCallback: (checking) => {
                const view = this.view;
                if (view) {
                    if (!checking) {
                        view.toggleState();
                    }
                    return true;
                }
            }
        });

        this.addCommand({
            id: "next-combatant",
            name: "Next Combatant",
            checkCallback: (checking) => {
                const view = this.view;
                if (view && view.state) {
                    if (!checking) {
                        view.goToNext();
                    }
                    return true;
                }
            }
        });

        this.addCommand({
            id: "prev-combatant",
            name: "Previous Combatant",
            checkCallback: (checking) => {
                const view = this.view;
                if (view && view.state) {
                    if (!checking) {
                        view.goToPrevious();
                    }
                    return true;
                }
            }
        });
    }

    addEvents() {
        this.registerEvent(
            this.app.workspace.on(
                "initiative-tracker:should-save",
                async () => await this.saveSettings()
            )
        );
        this.registerEvent(
            this.app.workspace.on(
                "initiative-tracker:start-encounter",
                async (homebrews: HomebrewCreature[]) => {
                    try {
                        const creatures = homebrews.map((h) =>
                            Creature.from(h)
                        );

                        const view = this.view;
                        if (!view) {
                            await this.addTrackerView();
                        }
                        if (view) {
                            view?.newEncounter({
                                creatures
                            });
                            this.app.workspace.revealLeaf(view.leaf);
                        } else {
                            new Notice(
                                "Could not find the Initiative Tracker. Try reloading the note!"
                            );
                        }
                    } catch (e) {
                        new Notice(
                            "There was an issue launching the encounter.\n\n" +
                                e.message
                        );
                        console.error(e);
                        return;
                    }
                }
            )
        );
    }

    async onunload() {
        await this.saveSettings();
        this.app.workspace.trigger("initiative-tracker:unload");
        this.app.workspace
            .getLeavesOfType(INTIATIVE_TRACKER_VIEW)
            .forEach((leaf) => leaf.detach());
        this.app.workspace
            .getLeavesOfType(CREATURE_TRACKER_VIEW)
            .forEach((leaf) => leaf.detach());
        console.log("Initiative Tracker unloaded");
    }

    async addTrackerView() {
        if (
            this.app.workspace.getLeavesOfType(INTIATIVE_TRACKER_VIEW)?.length
        ) {
            return;
        }
        await this.app.workspace.getRightLeaf(false).setViewState({
            type: INTIATIVE_TRACKER_VIEW
        });
    }

    async saveMonsters(importedMonsters: HomebrewCreature[]) {
        this.data.homebrew.push(...importedMonsters);

        for (let monster of importedMonsters) {
            this.homebrewCreatures.set(monster.name, Creature.from(monster));
        }

        await this.saveSettings();
    }
    async saveMonster(monster: HomebrewCreature) {
        this.data.homebrew.push(monster);
        this.homebrewCreatures.set(monster.name, Creature.from(monster));
        await this.saveSettings();
    }
    async updatePlayer(existing: HomebrewCreature, player: HomebrewCreature) {
        if (!this.playerCreatures.has(existing.name)) {
            await this.savePlayer(player);
            return;
        }

        const creature = this.playerCreatures.get(existing.name);
        creature.update(player);

        this.data.players.splice(
            this.data.players.indexOf(existing),
            1,
            player
        );

        this.playerCreatures.set(player.name, creature);
        this.playerCreatures.delete(existing.name);

        const view = this.view;
        if (view) {
            view.updateState();
        }

        await this.saveSettings();
    }
    async updateMonster(existing: HomebrewCreature, monster: HomebrewCreature) {
        if (!this.homebrewCreatures.has(existing.name)) {
            await this.saveMonster(monster);
            return;
        }

        const creature = this.homebrewCreatures.get(existing.name);
        creature.update(monster);

        this.data.homebrew.splice(
            this.data.homebrew.indexOf(existing),
            1,
            monster
        );

        this.homebrewCreatures.set(monster.name, creature);
        this.homebrewCreatures.delete(existing.name);

        const view = this.view;
        if (view) {
            view.updateState();
        }

        await this.saveSettings();
    }
    async deleteMonster(monster: HomebrewCreature) {
        this.data.homebrew = this.data.homebrew.filter((m) => m != monster);
        this.homebrewCreatures.delete(monster.name);

        await this.saveSettings();
    }

    async savePlayer(player: HomebrewCreature) {
        this.data.players.push(player);
        this.playerCreatures.set(player.name, Creature.from(player));
        await this.saveSettings();
    }
    async savePlayers(...players: HomebrewCreature[]) {
        for (let monster of players) {
            this.data.players.push(monster);
            this.playerCreatures.set(monster.name, Creature.from(monster));
        }
        await this.saveSettings();
    }

    async deletePlayer(player: HomebrewCreature) {
        this.data.players = this.data.players.filter((p) => p != player);
        this.playerCreatures.delete(player.name);
        await this.saveSettings();
    }

    async loadSettings() {
        const data = Object.assign(
            {},
            { ...DEFAULT_SETTINGS },
            await this.loadData()
        );

        this.data = data;
        if (
            this.data.leafletIntegration &&
            !this.data.players.every((p) => p.marker)
        ) {
            this.data.players = this.data.players.map((p) => {
                p.marker = p.marker ?? this.data.playerMarker;
                return p;
            });
        }
    }

    async saveSettings() {
        if (
            this.data.leafletIntegration &&
            !this.data.players.every((p) => p.marker)
        ) {
            this.data.players = this.data.players.map((p) => {
                p.marker = p.marker ?? this.data.playerMarker;
                return p;
            });
        }

        await this.saveData(this.data);
    }
}
Example #28
Source File: main.ts    From obsidian-hypothesis-plugin with MIT License 4 votes vote down vote up
export default class HypothesisPlugin extends Plugin {
	private syncHypothesis!: SyncHypothesis;
	private timeoutIDAutoSync: number;

	async onload(): Promise<void> {
		console.log('loading plugin', new Date().toLocaleString());

		await initialise(this);

		const fileManager = new FileManager(this.app.vault, this.app.metadataCache);

		this.syncHypothesis = new SyncHypothesis(fileManager);

		this.addRibbonIcon('hypothesisIcon', 'Sync your hypothesis highlights', () => {
			if (!get(settingsStore).isConnected) {
				new Notice('Please configure Hypothesis API token in the plugin setting');
			} else {
				this.startSync();
			}
		});

		// this.addStatusBarItem().setText('Status Bar Text');

		this.addCommand({
			id: 'hypothesis-sync',
			name: 'Sync highlights',
			callback: () => {
				if (!get(settingsStore).isConnected) {
					new Notice('Please configure Hypothesis API token in the plugin setting');
				} else {
					this.startSync();
				}
			},
		});

		this.addCommand({
			id: 'hypothesis-resync-deleted',
			name: 'Resync deleted file(s)',
			callback: () => {
				if (!get(settingsStore).isConnected) {
					new Notice('Please configure Hypothesis API token in the plugin setting');
				} else {
					this.showResyncModal();
				}
			},
		});

		this.addSettingTab(new SettingsTab(this.app, this));

		if (get(settingsStore).syncOnBoot) {
			if (get(settingsStore).isConnected) {
				await this.startSync();
			} else{
				console.info('Sync disabled. API Token not configured');
			}
		}

		if (get(settingsStore).autoSyncInterval) {
			this.startAutoSync();
		}
	}

	async showResyncModal(): Promise<void> {
		const resyncDelFileModal = new ResyncDelFileModal(this.app);
        await resyncDelFileModal.waitForClose;
	}

	async onunload() : Promise<void> {
		console.log('unloading plugin', new Date().toLocaleString());
		this.clearAutoSync();
	}

	async startSync(): Promise<void> {
		console.log('Start syncing...')
		await this.syncHypothesis.startSync();
	}

	async clearAutoSync(): Promise<void> {
		if (this.timeoutIDAutoSync) {
            window.clearTimeout(this.timeoutIDAutoSync);
            this.timeoutIDAutoSync = undefined;
        }
		console.log('Clearing auto sync...');
	}

	async startAutoSync(minutes?: number): Promise<void> {
		const minutesToSync = minutes ?? Number(get(settingsStore).autoSyncInterval);
		if (minutesToSync > 0) {
			this.timeoutIDAutoSync = window.setTimeout(
				() => {
					this.startSync();
					this.startAutoSync();
				},
				minutesToSync * 60000
			);
		}
		console.log(`StartAutoSync: this.timeoutIDAutoSync ${this.timeoutIDAutoSync} with ${minutesToSync} minutes`);
	}
}
Example #29
Source File: main.ts    From obsidian-consistent-attachments-and-links with MIT License 4 votes vote down vote up
export default class ConsistentAttachmentsAndLinks extends Plugin {
	settings: PluginSettings;
	lh: LinksHandler;
	fh: FilesHandler;

	recentlyRenamedFiles: PathChangeInfo[] = [];
	currentlyRenamingFiles: PathChangeInfo[] = [];
	timerId: NodeJS.Timeout;
	renamingIsActive = false;

	async onload() {
		await this.loadSettings();

		this.addSettingTab(new SettingTab(this.app, this));

		this.registerEvent(
			this.app.vault.on('delete', (file) => this.handleDeletedFile(file)),
		);

		this.registerEvent(
			this.app.vault.on('rename', (file, oldPath) => this.handleRenamedFile(file, oldPath)),
		);

		this.addCommand({
			id: 'collect-all-attachments',
			name: 'Collect all attachments',
			callback: () => this.collectAllAttachments()
		});

		this.addCommand({
			id: 'collect-attachments-current-note',
			name: 'Collect attachments in current note',
			editorCallback: (editor: Editor, view: MarkdownView) => this.collectAttachmentsCurrentNote(editor, view)
		});

		this.addCommand({
			id: 'delete-empty-folders',
			name: 'Delete empty folders',
			callback: () => this.deleteEmptyFolders()
		});

		this.addCommand({
			id: 'convert-all-link-paths-to-relative',
			name: 'Convert all link paths to relative',
			callback: () => this.convertAllLinkPathsToRelative()
		});

		this.addCommand({
			id: 'convert-all-embed-paths-to-relative',
			name: 'Convert all embed paths to relative',
			callback: () => this.convertAllEmbedsPathsToRelative()
		});

		this.addCommand({
			id: 'replace-all-wikilinks-with-markdown-links',
			name: 'Replace all wikilinks with markdown links',
			callback: () => this.replaceAllWikilinksWithMarkdownLinks()
		});

		this.addCommand({
			id: 'reorganize-vault',
			name: 'Reorganize vault',
			callback: () => this.reorganizeVault()
		});

		this.addCommand({
			id: 'check-consistent',
			name: 'Check vault consistent',
			callback: () => this.checkConsistent()
		});

		this.lh = new LinksHandler(
			this.app,
			"Consistent attachments and links: ",
			this.settings.ignoreFolders,
			this.settings.ignoreFiles
		);

		this.fh = new FilesHandler(
			this.app,
			this.lh,
			"Consistent attachments and links: ",
			this.settings.ignoreFolders,
			this.settings.ignoreFiles
		);
	}

	isPathIgnored(path: string): boolean {
		if (path.startsWith("./"))
			path = path.substring(2);

		for (let folder of this.settings.ignoreFolders) {
			if (path.startsWith(folder)) {
				return true;
			}
		}

		for (let file of this.settings.ignoreFiles) {
			if (path == file) {
				return true;
			}
		}
	}


	async handleDeletedFile(file: TAbstractFile) {
		if (this.isPathIgnored(file.path))
			return;

		let fileExt = file.path.substring(file.path.lastIndexOf("."));
		if (fileExt == ".md") {
			if (this.settings.deleteAttachmentsWithNote) {
				await this.fh.deleteUnusedAttachmentsForCachedNote(file.path);
			}

			//delete child folders (do not delete parent)
			if (this.settings.deleteEmptyFolders) {
				if (await this.app.vault.adapter.exists(path.dirname(file.path))) {
					let list = await this.app.vault.adapter.list(path.dirname(file.path));
					for (let folder of list.folders) {
						await this.fh.deleteEmptyFolders(folder);
					}
				}
			}
		}
	}

	async handleRenamedFile(file: TAbstractFile, oldPath: string) {
		this.recentlyRenamedFiles.push({ oldPath: oldPath, newPath: file.path });

		clearTimeout(this.timerId);
		this.timerId = setTimeout(() => { this.HandleRecentlyRenamedFiles() }, 3000);
	}

	async HandleRecentlyRenamedFiles() {
		if (!this.recentlyRenamedFiles || this.recentlyRenamedFiles.length == 0) //nothing to rename
			return;

		if (this.renamingIsActive) //already started
			return;

		this.renamingIsActive = true;

		this.currentlyRenamingFiles = this.recentlyRenamedFiles; //clear array for pushing new files async
		this.recentlyRenamedFiles = [];

		new Notice("Fixing consistent for " + this.currentlyRenamingFiles.length + " renamed files" + "...");
		console.log("Consistent attachments and links:\nFixing consistent for " + this.currentlyRenamingFiles.length + " renamed files" + "...");

		try {
			for (let file of this.currentlyRenamingFiles) {
				if (this.isPathIgnored(file.newPath) || this.isPathIgnored(file.oldPath))
					return;

				// await Utils.delay(10); //waiting for update vault

				let result: MovedAttachmentResult;

				let fileExt = file.oldPath.substring(file.oldPath.lastIndexOf("."));

				if (fileExt == ".md") {
					// await Utils.delay(500);//waiting for update metadataCache

					if ((path.dirname(file.oldPath) != path.dirname(file.newPath)) || (this.settings.attachmentsSubfolder.contains("${filename}"))) {
						if (this.settings.moveAttachmentsWithNote) {
							result = await this.fh.moveCachedNoteAttachments(
								file.oldPath,
								file.newPath,
								this.settings.deleteExistFilesWhenMoveNote,
								this.settings.attachmentsSubfolder
							)

							if (this.settings.updateLinks && result) {
								let changedFiles = result.renamedFiles.concat(result.movedAttachments);
								if (changedFiles.length > 0) {
									await this.lh.updateChangedPathsInNote(file.newPath, changedFiles)
								}
							}
						}

						if (this.settings.updateLinks) {
							await this.lh.updateInternalLinksInMovedNote(file.oldPath, file.newPath, this.settings.moveAttachmentsWithNote)
						}

						//delete child folders (do not delete parent)
						if (this.settings.deleteEmptyFolders) {
							if (await this.app.vault.adapter.exists(path.dirname(file.oldPath))) {
								let list = await this.app.vault.adapter.list(path.dirname(file.oldPath));
								for (let folder of list.folders) {
									await this.fh.deleteEmptyFolders(folder);
								}
							}
						}
					}
				}

				let updateAlts = this.settings.changeNoteBacklinksAlt && fileExt == ".md";
				if (this.settings.updateLinks) {
					await this.lh.updateLinksToRenamedFile(file.oldPath, file.newPath, updateAlts, this.settings.useBuiltInObsidianLinkCaching);
				}

				if (result && result.movedAttachments && result.movedAttachments.length > 0) {
					new Notice("Moved " + result.movedAttachments.length + " attachment" + (result.movedAttachments.length > 1 ? "s" : ""));
				}
			}
		} catch (e) {
			console.error("Consistent attachments and links: \n" + e);
		}

		new Notice("Fixing consistent complete");
		console.log("Consistent attachments and links:\nFixing consistent complete");

		this.renamingIsActive = false;

		if (this.recentlyRenamedFiles && this.recentlyRenamedFiles.length > 0) {
			clearTimeout(this.timerId);
			this.timerId = setTimeout(() => { this.HandleRecentlyRenamedFiles() }, 500);
		}
	}


	async collectAttachmentsCurrentNote(editor: Editor, view: MarkdownView) {
		let note = view.file;
		if (this.isPathIgnored(note.path)) {
			new Notice("Note path is ignored");
			return;
		}

		let result = await this.fh.collectAttachmentsForCachedNote(
			note.path,
			this.settings.attachmentsSubfolder,
			this.settings.deleteExistFilesWhenMoveNote);

		if (result && result.movedAttachments && result.movedAttachments.length > 0) {
			await this.lh.updateChangedPathsInNote(note.path, result.movedAttachments)
		}

		if (result.movedAttachments.length == 0)
			new Notice("No files found that need to be moved");
		else
			new Notice("Moved " + result.movedAttachments.length + " attachment" + (result.movedAttachments.length > 1 ? "s" : ""));
	}


	async collectAllAttachments() {
		let movedAttachmentsCount = 0;
		let processedNotesCount = 0;

		let notes = this.app.vault.getMarkdownFiles();

		if (notes) {
			for (let note of notes) {
				if (this.isPathIgnored(note.path))
					continue;

				let result = await this.fh.collectAttachmentsForCachedNote(
					note.path,
					this.settings.attachmentsSubfolder,
					this.settings.deleteExistFilesWhenMoveNote);


				if (result && result.movedAttachments && result.movedAttachments.length > 0) {
					await this.lh.updateChangedPathsInNote(note.path, result.movedAttachments)
					movedAttachmentsCount += result.movedAttachments.length;
					processedNotesCount++;
				}
			}
		}

		if (movedAttachmentsCount == 0)
			new Notice("No files found that need to be moved");
		else
			new Notice("Moved " + movedAttachmentsCount + " attachment" + (movedAttachmentsCount > 1 ? "s" : "")
				+ " from " + processedNotesCount + " note" + (processedNotesCount > 1 ? "s" : ""));
	}


	async convertAllEmbedsPathsToRelative() {
		let changedEmbedCount = 0;
		let processedNotesCount = 0;

		let notes = this.app.vault.getMarkdownFiles();

		if (notes) {
			for (let note of notes) {
				if (this.isPathIgnored(note.path))
					continue;

				let result = await this.lh.convertAllNoteEmbedsPathsToRelative(note.path);

				if (result && result.length > 0) {
					changedEmbedCount += result.length;
					processedNotesCount++;
				}
			}
		}

		if (changedEmbedCount == 0)
			new Notice("No embeds found that need to be converted");
		else
			new Notice("Converted " + changedEmbedCount + " embed" + (changedEmbedCount > 1 ? "s" : "")
				+ " from " + processedNotesCount + " note" + (processedNotesCount > 1 ? "s" : ""));
	}


	async convertAllLinkPathsToRelative() {
		let changedLinksCount = 0;
		let processedNotesCount = 0;

		let notes = this.app.vault.getMarkdownFiles();

		if (notes) {
			for (let note of notes) {
				if (this.isPathIgnored(note.path))
					continue;

				let result = await this.lh.convertAllNoteLinksPathsToRelative(note.path);

				if (result && result.length > 0) {
					changedLinksCount += result.length;
					processedNotesCount++;
				}
			}
		}

		if (changedLinksCount == 0)
			new Notice("No links found that need to be converted");
		else
			new Notice("Converted " + changedLinksCount + " link" + (changedLinksCount > 1 ? "s" : "")
				+ " from " + processedNotesCount + " note" + (processedNotesCount > 1 ? "s" : ""));
	}

	async replaceAllWikilinksWithMarkdownLinks() {
		let changedLinksCount = 0;
		let processedNotesCount = 0;

		let notes = this.app.vault.getMarkdownFiles();

		if (notes) {
			for (let note of notes) {
				if (this.isPathIgnored(note.path))
					continue;

				let result = await this.lh.replaceAllNoteWikilinksWithMarkdownLinks(note.path);

				if (result && (result.links.length > 0 || result.embeds.length > 0)) {
					changedLinksCount += result.links.length;
					changedLinksCount += result.embeds.length;
					processedNotesCount++;
				}
			}
		}

		if (changedLinksCount == 0)
			new Notice("No wikilinks found that need to be replaced");
		else
			new Notice("Replaced " + changedLinksCount + " wikilink" + (changedLinksCount > 1 ? "s" : "")
				+ " from " + processedNotesCount + " note" + (processedNotesCount > 1 ? "s" : ""));
	}

	deleteEmptyFolders() {
		this.fh.deleteEmptyFolders("/")
	}

	async checkConsistent() {
		let badLinks = this.lh.getAllBadLinks();
		let badSectionLinks = await this.lh.getAllBadSectionLinks();
		let badEmbeds = this.lh.getAllBadEmbeds();
		let wikiLinks = this.lh.getAllWikiLinks();
		let wikiEmbeds = this.lh.getAllWikiEmbeds();

		let text = "";

		let badLinksCount = Object.keys(badLinks).length;
		let badEmbedsCount = Object.keys(badEmbeds).length;
		let badSectionLinksCount = Object.keys(badSectionLinks).length;
		let wikiLinksCount = Object.keys(wikiLinks).length;
		let wikiEmbedsCount = Object.keys(wikiEmbeds).length;

		if (badLinksCount > 0) {
			text += "# Bad links (" + badLinksCount + " files)\n";
			for (let note in badLinks) {
				text += "[" + note + "](" + Utils.normalizePathForLink(note) + "): " + "\n"
				for (let link of badLinks[note]) {
					text += "- (line " + link.position.start.line + "): `" + link.link + "`\n";
				}
				text += "\n\n"
			}
		} else {
			text += "# Bad links \n";
			text += "No problems found\n\n"
		}


		if (badSectionLinksCount > 0) {
			text += "\n\n# Bad note link sections (" + badSectionLinksCount + " files)\n";
			for (let note in badSectionLinks) {
				text += "[" + note + "](" + Utils.normalizePathForLink(note) + "): " + "\n"
				for (let link of badSectionLinks[note]) {
					let li = this.lh.splitLinkToPathAndSection(link.link);
					let section = Utils.normalizeLinkSection(li.section);
					text += "- (line " + link.position.start.line + "): `" + li.link + "#" + section + "`\n";
				}
				text += "\n\n"
			}
		} else {
			text += "\n\n# Bad note link sections\n"
			text += "No problems found\n\n"
		}


		if (badEmbedsCount > 0) {
			text += "\n\n# Bad embeds (" + badEmbedsCount + " files)\n";
			for (let note in badEmbeds) {
				text += "[" + note + "](" + Utils.normalizePathForLink(note) + "): " + "\n"
				for (let link of badEmbeds[note]) {
					text += "- (line " + link.position.start.line + "): `" + link.link + "`\n";
				}
				text += "\n\n"
			}
		} else {
			text += "\n\n# Bad embeds \n";
			text += "No problems found\n\n"
		}


		if (wikiLinksCount > 0) {
			text += "# Wiki links (" + wikiLinksCount + " files)\n";
			for (let note in wikiLinks) {
				text += "[" + note + "](" + Utils.normalizePathForLink(note) + "): " + "\n"
				for (let link of wikiLinks[note]) {
					text += "- (line " + link.position.start.line + "): `" + link.original + "`\n";
				}
				text += "\n\n"
			}
		} else {
			text += "# Wiki links \n";
			text += "No problems found\n\n"
		}

		if (wikiEmbedsCount > 0) {
			text += "\n\n# Wiki embeds (" + wikiEmbedsCount + " files)\n";
			for (let note in wikiEmbeds) {
				text += "[" + note + "](" + Utils.normalizePathForLink(note) + "): " + "\n"
				for (let link of wikiEmbeds[note]) {
					text += "- (line " + link.position.start.line + "): `" + link.original + "`\n";
				}
				text += "\n\n"
			}
		} else {
			text += "\n\n# Wiki embeds \n";
			text += "No problems found\n\n"
		}



		let notePath = this.settings.consistentReportFile;
		await this.app.vault.adapter.write(notePath, text);

		let fileOpened = false;
		this.app.workspace.iterateAllLeaves(leaf => {
			if (leaf.getDisplayText() != "" && notePath.startsWith(leaf.getDisplayText())) {
				fileOpened = true;
			}
		});

		if (!fileOpened)
			this.app.workspace.openLinkText(notePath, "/", false);
	}

	async reorganizeVault() {
		await this.replaceAllWikilinksWithMarkdownLinks()
		await this.convertAllEmbedsPathsToRelative()
		await this.convertAllLinkPathsToRelative()
		//- Rename all attachments (using Unique attachments, optional)
		await this.collectAllAttachments()
		await this.deleteEmptyFolders()
		new Notice("Reorganization of the vault completed");
	}


	async loadSettings() {
		this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
	}

	async saveSettings() {
		await this.saveData(this.settings);

		this.lh = new LinksHandler(
			this.app,
			"Consistent attachments and links: ",
			this.settings.ignoreFolders,
			this.settings.ignoreFiles
		);

		this.fh = new FilesHandler(
			this.app,
			this.lh,
			"Consistent attachments and links: ",
			this.settings.ignoreFolders,
			this.settings.ignoreFiles
		);
	}


}