diff --git a/.gitignore b/.gitignore index b7a989f..4ade5e6 100644 --- a/.gitignore +++ b/.gitignore @@ -26,13 +26,13 @@ package-lock.json bun.lockb yarn.lock -src/content/.obsidian +# src/content/.obsidian .playwright-mcp .serena .claude -.obsidian +# .obsidian .cache build.log \ No newline at end of file diff --git a/.obsidian/app.json b/.obsidian/app.json new file mode 100644 index 0000000..6848d12 --- /dev/null +++ b/.obsidian/app.json @@ -0,0 +1,8 @@ +{ + "newLinkFormat": "absolute", + "newFileLocation": "folder", + "newFileFolderPath": "src/content/posts", + "alwaysUpdateLinks": true, + "attachmentFolderPath": "/public/pic", + "useMarkdownLinks": true +} \ No newline at end of file diff --git a/.obsidian/appearance.json b/.obsidian/appearance.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.obsidian/appearance.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.obsidian/community-plugins.json b/.obsidian/community-plugins.json new file mode 100644 index 0000000..2a4769a --- /dev/null +++ b/.obsidian/community-plugins.json @@ -0,0 +1,5 @@ +[ + "fix-public-links", + "obsidian-auto-link-title", + "obsidian-paste-image-rename" +] \ No newline at end of file diff --git a/.obsidian/core-plugins.json b/.obsidian/core-plugins.json new file mode 100644 index 0000000..639b90d --- /dev/null +++ b/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": true, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-auto-link-title/data.json b/.obsidian/plugins/obsidian-auto-link-title/data.json new file mode 100644 index 0000000..5fe250c --- /dev/null +++ b/.obsidian/plugins/obsidian-auto-link-title/data.json @@ -0,0 +1,15 @@ +{ + "regex": {}, + "lineRegex": {}, + "linkRegex": {}, + "linkLineRegex": {}, + "imageRegex": {}, + "enhanceDefaultPaste": true, + "shouldPreserveSelectionAsTitle": false, + "enhanceDropEvents": true, + "websiteBlacklist": "", + "maximumTitleLength": 0, + "useNewScraper": false, + "linkPreviewApiKey": "", + "useBetterPasteId": true +} \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-auto-link-title/main.js b/.obsidian/plugins/obsidian-auto-link-title/main.js new file mode 100644 index 0000000..72ffd51 --- /dev/null +++ b/.obsidian/plugins/obsidian-auto-link-title/main.js @@ -0,0 +1,771 @@ +/* +THIS IS A GENERATED/BUNDLED FILE BY ROLLUP +if you want to view the source visit the plugins github repository +*/ + +'use strict'; + +var obsidian = require('obsidian'); + +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + +function __awaiter(thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +} + +typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { + var e = new Error(message); + return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; +}; + +const DEFAULT_SETTINGS = { + regex: /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/i, + lineRegex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi, + linkRegex: /^\[([^\[\]]*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)$/i, + linkLineRegex: /\[([^\[\]]*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)/gi, + imageRegex: /\.(gif|jpe?g|tiff?|png|webp|bmp|tga|psd|ai)$/i, + enhanceDefaultPaste: true, + shouldPreserveSelectionAsTitle: false, + enhanceDropEvents: true, + websiteBlacklist: "", + maximumTitleLength: 0, + useNewScraper: false, + linkPreviewApiKey: "", + useBetterPasteId: false, +}; +class AutoLinkTitleSettingTab extends obsidian.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + let { containerEl } = this; + containerEl.empty(); + new obsidian.Setting(containerEl) + .setName("Enhance Default Paste") + .setDesc("Fetch the link title when pasting a link in the editor with the default paste command") + .addToggle((val) => val + .setValue(this.plugin.settings.enhanceDefaultPaste) + .onChange((value) => __awaiter(this, void 0, void 0, function* () { + console.log(value); + this.plugin.settings.enhanceDefaultPaste = value; + yield this.plugin.saveSettings(); + }))); + new obsidian.Setting(containerEl) + .setName("Enhance Drop Events") + .setDesc("Fetch the link title when drag and dropping a link from another program") + .addToggle((val) => val + .setValue(this.plugin.settings.enhanceDropEvents) + .onChange((value) => __awaiter(this, void 0, void 0, function* () { + console.log(value); + this.plugin.settings.enhanceDropEvents = value; + yield this.plugin.saveSettings(); + }))); + new obsidian.Setting(containerEl) + .setName("Maximum title length") + .setDesc("Set the maximum length of the title. Set to 0 to disable.") + .addText((val) => val + .setValue(this.plugin.settings.maximumTitleLength.toString(10)) + .onChange((value) => __awaiter(this, void 0, void 0, function* () { + const titleLength = Number(value); + this.plugin.settings.maximumTitleLength = + isNaN(titleLength) || titleLength < 0 ? 0 : titleLength; + yield this.plugin.saveSettings(); + }))); + new obsidian.Setting(containerEl) + .setName("Preserve selection as title") + .setDesc("Whether to prefer selected text as title over fetched title when pasting") + .addToggle((val) => val + .setValue(this.plugin.settings.shouldPreserveSelectionAsTitle) + .onChange((value) => __awaiter(this, void 0, void 0, function* () { + console.log(value); + this.plugin.settings.shouldPreserveSelectionAsTitle = value; + yield this.plugin.saveSettings(); + }))); + new obsidian.Setting(containerEl) + .setName("Website Blacklist") + .setDesc("List of strings (comma separated) that disable autocompleting website titles. Can be URLs or arbitrary text.") + .addTextArea((val) => val + .setValue(this.plugin.settings.websiteBlacklist) + .setPlaceholder("localhost, tiktok.com") + .onChange((value) => __awaiter(this, void 0, void 0, function* () { + this.plugin.settings.websiteBlacklist = value; + yield this.plugin.saveSettings(); + }))); + new obsidian.Setting(containerEl) + .setName("Use New Scraper") + .setDesc("Use experimental new scraper, seems to work well on desktop but not mobile.") + .addToggle((val) => val + .setValue(this.plugin.settings.useNewScraper) + .onChange((value) => __awaiter(this, void 0, void 0, function* () { + console.log(value); + this.plugin.settings.useNewScraper = value; + yield this.plugin.saveSettings(); + }))); + new obsidian.Setting(containerEl) + .setName("Use Better Fetching Placeholder") + .setDesc("Use a more readable placeholder when fetching the title of a link.") + .addToggle((val) => val + .setValue(this.plugin.settings.useBetterPasteId) + .onChange((value) => __awaiter(this, void 0, void 0, function* () { + console.log(value); + this.plugin.settings.useBetterPasteId = value; + yield this.plugin.saveSettings(); + }))); + new obsidian.Setting(containerEl) + .setName("LinkPreview API Key") + .setDesc("API key for the LinkPreview.net service. Get one at https://my.linkpreview.net/access_keys") + .addText((text) => text + .setValue(this.plugin.settings.linkPreviewApiKey || "") + .onChange((value) => __awaiter(this, void 0, void 0, function* () { + const trimmedValue = value.trim(); + if (trimmedValue.length > 0 && trimmedValue.length !== 32) { + new obsidian.Notice("LinkPreview API key must be 32 characters long"); + this.plugin.settings.linkPreviewApiKey = ""; + } + else { + this.plugin.settings.linkPreviewApiKey = trimmedValue; + } + yield this.plugin.saveSettings(); + }))); + } +} + +class CheckIf { + static isMarkdownLinkAlready(editor) { + let cursor = editor.getCursor(); + // Check if the characters before the url are ]( to indicate a markdown link + var titleEnd = editor.getRange({ ch: cursor.ch - 2, line: cursor.line }, { ch: cursor.ch, line: cursor.line }); + return titleEnd == "]("; + } + static isAfterQuote(editor) { + let cursor = editor.getCursor(); + // Check if the characters before the url are " or ' to indicate we want the url directly + // This is common in elements like + var beforeChar = editor.getRange({ ch: cursor.ch - 1, line: cursor.line }, { ch: cursor.ch, line: cursor.line }); + return beforeChar == "\"" || beforeChar == "'"; + } + static isUrl(text) { + let urlRegex = new RegExp(DEFAULT_SETTINGS.regex); + return urlRegex.test(text); + } + static isImage(text) { + let imageRegex = new RegExp(DEFAULT_SETTINGS.imageRegex); + return imageRegex.test(text); + } + static isLinkedUrl(text) { + let urlRegex = new RegExp(DEFAULT_SETTINGS.linkRegex); + return urlRegex.test(text); + } +} + +class EditorExtensions { + static getSelectedText(editor) { + if (!editor.somethingSelected()) { + let wordBoundaries = this.getWordBoundaries(editor); + editor.setSelection(wordBoundaries.start, wordBoundaries.end); + } + return editor.getSelection(); + } + static cursorWithinBoundaries(cursor, match) { + let startIndex = match.index; + let endIndex = match.index + match[0].length; + return startIndex <= cursor.ch && cursor.ch <= endIndex; + } + static getWordBoundaries(editor) { + let cursor = editor.getCursor(); + // If its a normal URL token this is not a markdown link + // In this case we can simply overwrite the link boundaries as-is + let lineText = editor.getLine(cursor.line); + // First check if we're in a link + let linksInLine = lineText.matchAll(DEFAULT_SETTINGS.linkLineRegex); + for (let match of linksInLine) { + if (this.cursorWithinBoundaries(cursor, match)) { + return { + start: { line: cursor.line, ch: match.index }, + end: { line: cursor.line, ch: match.index + match[0].length }, + }; + } + } + // If not, check if we're in just a standard ol' URL. + let urlsInLine = lineText.matchAll(DEFAULT_SETTINGS.lineRegex); + for (let match of urlsInLine) { + if (this.cursorWithinBoundaries(cursor, match)) { + return { + start: { line: cursor.line, ch: match.index }, + end: { line: cursor.line, ch: match.index + match[0].length }, + }; + } + } + return { + start: cursor, + end: cursor, + }; + } + static getEditorPositionFromIndex(content, index) { + let substr = content.substr(0, index); + let l = 0; + let offset = -1; + let r = -1; + for (; (r = substr.indexOf("\n", r + 1)) !== -1; l++, offset = r) + ; + offset += 1; + let ch = content.substr(offset, index - offset).length; + return { line: l, ch: ch }; + } +} + +function blank$1(text) { + return text === undefined || text === null || text === ''; +} +function notBlank$1(text) { + return !blank$1(text); +} +function scrape(url) { + return __awaiter(this, void 0, void 0, function* () { + try { + const response = yield obsidian.requestUrl(url); + if (!response.headers['content-type'].includes('text/html')) + return getUrlFinalSegment$1(url); + const html = response.text; + const doc = new DOMParser().parseFromString(html, 'text/html'); + const title = doc.querySelector('title'); + if (blank$1(title === null || title === void 0 ? void 0 : title.innerText)) { + // If site is javascript based and has a no-title attribute when unloaded, use it. + var noTitle = title === null || title === void 0 ? void 0 : title.getAttr('no-title'); + if (notBlank$1(noTitle)) { + return noTitle; + } + // Otherwise if the site has no title/requires javascript simply return Title Unknown + return url; + } + return title.innerText; + } + catch (ex) { + console.error(ex); + return ''; + } + }); +} +function getUrlFinalSegment$1(url) { + try { + const segments = new URL(url).pathname.split('/'); + const last = segments.pop() || segments.pop(); // Handle potential trailing slash + return last; + } + catch (_) { + return 'File'; + } +} +function getPageTitle$1(url) { + return __awaiter(this, void 0, void 0, function* () { + if (!(url.startsWith('http') || url.startsWith('https'))) { + url = 'https://' + url; + } + return scrape(url); + }); +} + +const electronPkg = require("electron"); +function blank(text) { + return text === undefined || text === null || text === ""; +} +function notBlank(text) { + return !blank(text); +} +// async wrapper to load a url and settle on load finish or fail +function load(window, url) { + return __awaiter(this, void 0, void 0, function* () { + return new Promise((resolve, reject) => { + window.webContents.on("did-finish-load", (event) => resolve(event)); + window.webContents.on("did-fail-load", (event) => reject(event)); + window.loadURL(url); + }); + }); +} +function electronGetPageTitle(url) { + return __awaiter(this, void 0, void 0, function* () { + const { remote } = electronPkg; + const { BrowserWindow } = remote; + try { + const window = new BrowserWindow({ + width: 1000, + height: 600, + webPreferences: { + webSecurity: false, + nodeIntegration: true, + images: false, + }, + show: false, + }); + window.webContents.setAudioMuted(true); + window.webContents.on("will-navigate", (event, newUrl) => { + event.preventDefault(); + window.loadURL(newUrl); + }); + yield load(window, url); + try { + const title = window.webContents.getTitle(); + window.destroy(); + if (notBlank(title)) { + return title; + } + else { + return url; + } + } + catch (ex) { + window.destroy(); + return url; + } + } + catch (ex) { + console.error(ex); + return ""; + } + }); +} +function nonElectronGetPageTitle(url) { + return __awaiter(this, void 0, void 0, function* () { + try { + const html = yield obsidian.request({ url }); + const doc = new DOMParser().parseFromString(html, "text/html"); + const title = doc.querySelectorAll("title")[0]; + if (title == null || blank(title === null || title === void 0 ? void 0 : title.innerText)) { + // If site is javascript based and has a no-title attribute when unloaded, use it. + var noTitle = title === null || title === void 0 ? void 0 : title.getAttr("no-title"); + if (notBlank(noTitle)) { + return noTitle; + } + // Otherwise if the site has no title/requires javascript simply return Title Unknown + return url; + } + return title.innerText; + } + catch (ex) { + console.error(ex); + return ""; + } + }); +} +function getUrlFinalSegment(url) { + try { + const segments = new URL(url).pathname.split('/'); + const last = segments.pop() || segments.pop(); // Handle potential trailing slash + return last; + } + catch (_) { + return "File"; + } +} +function tryGetFileType(url) { + return __awaiter(this, void 0, void 0, function* () { + try { + const response = yield fetch(url, { method: "HEAD" }); + // Ensure site returns an ok status code before scraping + if (!response.ok) { + return "Site Unreachable"; + } + // Ensure site is an actual HTML page and not a pdf or 3 gigabyte video file. + let contentType = response.headers.get("content-type"); + if (!contentType.includes("text/html")) { + return getUrlFinalSegment(url); + } + return null; + } + catch (err) { + return null; + } + }); +} +function getPageTitle(url) { + return __awaiter(this, void 0, void 0, function* () { + // If we're on Desktop use the Electron scraper + if (!(url.startsWith("http") || url.startsWith("https"))) { + url = "https://" + url; + } + // Try to do a HEAD request to see if the site is reachable and if it's an HTML page + // If we error out due to CORS, we'll just try to scrape the page anyway. + let fileType = yield tryGetFileType(url); + if (fileType) { + return fileType; + } + if (electronPkg != null) { + return electronGetPageTitle(url); + } + else { + return nonElectronGetPageTitle(url); + } + }); +} + +class AutoLinkTitle extends obsidian.Plugin { + constructor() { + super(...arguments); + this.shortTitle = (title) => { + if (this.settings.maximumTitleLength === 0) { + return title; + } + if (title.length < this.settings.maximumTitleLength + 3) { + return title; + } + const shortenedTitle = `${title.slice(0, this.settings.maximumTitleLength)}...`; + return shortenedTitle; + }; + } + onload() { + return __awaiter(this, void 0, void 0, function* () { + console.log("loading obsidian-auto-link-title"); + yield this.loadSettings(); + this.blacklist = this.settings.websiteBlacklist + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + // Listen to paste event + this.pasteFunction = this.pasteUrlWithTitle.bind(this); + // Listen to drop event + this.dropFunction = this.dropUrlWithTitle.bind(this); + this.addCommand({ + id: "auto-link-title-paste", + name: "Paste URL and auto fetch title", + editorCallback: (editor) => this.manualPasteUrlWithTitle(editor), + hotkeys: [], + }); + this.addCommand({ + id: "auto-link-title-normal-paste", + name: "Normal paste (no fetching behavior)", + editorCallback: (editor) => this.normalPaste(editor), + hotkeys: [ + { + modifiers: ["Mod", "Shift"], + key: "v", + }, + ], + }); + this.registerEvent(this.app.workspace.on("editor-paste", this.pasteFunction)); + this.registerEvent(this.app.workspace.on("editor-drop", this.dropFunction)); + this.addCommand({ + id: "enhance-url-with-title", + name: "Enhance existing URL with link and title", + editorCallback: (editor) => this.addTitleToLink(editor), + hotkeys: [ + { + modifiers: ["Mod", "Shift"], + key: "e", + }, + ], + }); + this.addSettingTab(new AutoLinkTitleSettingTab(this.app, this)); + }); + } + addTitleToLink(editor) { + // Only attempt fetch if online + if (!navigator.onLine) + return; + let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim(); + // If the cursor is on a raw html link, convert to a markdown link and fetch title + if (CheckIf.isUrl(selectedText)) { + this.convertUrlToTitledLink(editor, selectedText); + } + // If the cursor is on the URL part of a markdown link, fetch title and replace existing link title + else if (CheckIf.isLinkedUrl(selectedText)) { + const link = this.getUrlFromLink(selectedText); + this.convertUrlToTitledLink(editor, link); + } + } + normalPaste(editor) { + return __awaiter(this, void 0, void 0, function* () { + let clipboardText = yield navigator.clipboard.readText(); + if (clipboardText === null || clipboardText === "") + return; + editor.replaceSelection(clipboardText); + }); + } + // Simulate standard paste but using editor.replaceSelection with clipboard text since we can't seem to dispatch a paste event. + manualPasteUrlWithTitle(editor) { + return __awaiter(this, void 0, void 0, function* () { + const clipboardText = yield navigator.clipboard.readText(); + // Only attempt fetch if online + if (!navigator.onLine) { + editor.replaceSelection(clipboardText); + return; + } + if (clipboardText == null || clipboardText == "") + return; + // If its not a URL, we return false to allow the default paste handler to take care of it. + // Similarly, image urls don't have a meaningful attribute so downloading it + // to fetch the title is a waste of bandwidth. + if (!CheckIf.isUrl(clipboardText) || CheckIf.isImage(clipboardText)) { + editor.replaceSelection(clipboardText); + return; + } + // If it looks like we're pasting the url into a markdown link already, don't fetch title + // as the user has already probably put a meaningful title, also it would lead to the title + // being inside the link. + if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) { + editor.replaceSelection(clipboardText); + return; + } + // If url is pasted over selected text and setting is enabled, no need to fetch title, + // just insert a link + let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim(); + if (selectedText && this.settings.shouldPreserveSelectionAsTitle) { + editor.replaceSelection(`[${selectedText}](${clipboardText})`); + return; + } + // At this point we're just pasting a link in a normal fashion, fetch its title. + this.convertUrlToTitledLink(editor, clipboardText); + return; + }); + } + pasteUrlWithTitle(clipboard, editor) { + return __awaiter(this, void 0, void 0, function* () { + if (!this.settings.enhanceDefaultPaste) { + return; + } + if (clipboard.defaultPrevented) + return; + // Only attempt fetch if online + if (!navigator.onLine) + return; + let clipboardText = clipboard.clipboardData.getData("text/plain"); + if (clipboardText === null || clipboardText === "") + return; + // If its not a URL, we return false to allow the default paste handler to take care of it. + // Similarly, image urls don't have a meaningful <title> attribute so downloading it + // to fetch the title is a waste of bandwidth. + if (!CheckIf.isUrl(clipboardText) || CheckIf.isImage(clipboardText)) { + return; + } + // We've decided to handle the paste, stop propagation to the default handler. + clipboard.stopPropagation(); + clipboard.preventDefault(); + // If it looks like we're pasting the url into a markdown link already, don't fetch title + // as the user has already probably put a meaningful title, also it would lead to the title + // being inside the link. + if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) { + editor.replaceSelection(clipboardText); + return; + } + // If url is pasted over selected text and setting is enabled, no need to fetch title, + // just insert a link + let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim(); + if (selectedText && this.settings.shouldPreserveSelectionAsTitle) { + editor.replaceSelection(`[${selectedText}](${clipboardText})`); + return; + } + // At this point we're just pasting a link in a normal fashion, fetch its title. + this.convertUrlToTitledLink(editor, clipboardText); + return; + }); + } + dropUrlWithTitle(dropEvent, editor) { + return __awaiter(this, void 0, void 0, function* () { + if (!this.settings.enhanceDropEvents) { + return; + } + if (dropEvent.defaultPrevented) + return; + // Only attempt fetch if online + if (!navigator.onLine) + return; + let dropText = dropEvent.dataTransfer.getData("text/plain"); + if (dropText === null || dropText === "") + return; + // If its not a URL, we return false to allow the default paste handler to take care of it. + // Similarly, image urls don't have a meaningful <title> attribute so downloading it + // to fetch the title is a waste of bandwidth. + if (!CheckIf.isUrl(dropText) || CheckIf.isImage(dropText)) { + return; + } + // We've decided to handle the paste, stop propagation to the default handler. + dropEvent.stopPropagation(); + dropEvent.preventDefault(); + // If it looks like we're pasting the url into a markdown link already, don't fetch title + // as the user has already probably put a meaningful title, also it would lead to the title + // being inside the link. + if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) { + editor.replaceSelection(dropText); + return; + } + // If url is pasted over selected text and setting is enabled, no need to fetch title, + // just insert a link + let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim(); + if (selectedText && this.settings.shouldPreserveSelectionAsTitle) { + editor.replaceSelection(`[${selectedText}](${dropText})`); + return; + } + // At this point we're just pasting a link in a normal fashion, fetch its title. + this.convertUrlToTitledLink(editor, dropText); + return; + }); + } + isBlacklisted(url) { + return __awaiter(this, void 0, void 0, function* () { + yield this.loadSettings(); + this.blacklist = this.settings.websiteBlacklist + .split(/,|\n/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return this.blacklist.some((site) => url.includes(site)); + }); + } + convertUrlToTitledLink(editor, url) { + return __awaiter(this, void 0, void 0, function* () { + if (yield this.isBlacklisted(url)) { + let domain = new URL(url).hostname; + editor.replaceSelection(`[${domain}](${url})`); + return; + } + // Generate a unique id for find/replace operations for the title. + const pasteId = this.getPasteId(); + // Instantly paste so you don't wonder if paste is broken + editor.replaceSelection(`[${pasteId}](${url})`); + // Fetch title from site, replace Fetching Title with actual title + const title = yield this.fetchUrlTitle(url); + const escapedTitle = this.escapeMarkdown(title); + const shortenedTitle = this.shortTitle(escapedTitle); + const text = editor.getValue(); + const start = text.indexOf(pasteId); + if (start < 0) { + console.log(`Unable to find text "${pasteId}" in current editor, bailing out; link ${url}`); + } + else { + const end = start + pasteId.length; + const startPos = EditorExtensions.getEditorPositionFromIndex(text, start); + const endPos = EditorExtensions.getEditorPositionFromIndex(text, end); + editor.replaceRange(shortenedTitle, startPos, endPos); + } + }); + } + escapeMarkdown(text) { + var unescaped = text.replace(/\\(\*|_|`|~|\\|\[|\])/g, "$1"); // unescape any "backslashed" character + var escaped = unescaped.replace(/(\*|_|`|<|>|~|\\|\[|\])/g, "\\$1"); // escape *, _, `, ~, \, [, ], <, and > + var escaped = unescaped.replace(/(\*|_|`|\||<|>|~|\\|\[|\])/g, "\\$1"); // escape *, _, `, ~, \, |, [, ], <, and > + return escaped; + } + fetchUrlTitleViaLinkPreview(url) { + return __awaiter(this, void 0, void 0, function* () { + if (this.settings.linkPreviewApiKey.length !== 32) { + console.error("LinkPreview API key is not 32 characters long, please check your settings"); + return ""; + } + try { + const apiEndpoint = `https://api.linkpreview.net/?q=${encodeURIComponent(url)}`; + const response = yield fetch(apiEndpoint, { + headers: { + "X-Linkpreview-Api-Key": this.settings.linkPreviewApiKey, + }, + }); + const data = yield response.json(); + return data.title; + } + catch (error) { + console.error(error); + return ""; + } + }); + } + fetchUrlTitle(url) { + return __awaiter(this, void 0, void 0, function* () { + try { + let title = ""; + title = yield this.fetchUrlTitleViaLinkPreview(url); + console.log(`Title via Link Preview: ${title}`); + if (title === "") { + console.log("Title via Link Preview failed, falling back to scraper"); + if (this.settings.useNewScraper) { + console.log("Using new scraper"); + title = yield getPageTitle$1(url); + } + else { + console.log("Using old scraper"); + title = yield getPageTitle(url); + } + } + console.log(`Title: ${title}`); + title = + title.replace(/(\r\n|\n|\r)/gm, "").trim() || + "Title Unavailable | Site Unreachable"; + return title; + } + catch (error) { + console.error(error); + return "Error fetching title"; + } + }); + } + getUrlFromLink(link) { + let urlRegex = new RegExp(DEFAULT_SETTINGS.linkRegex); + return urlRegex.exec(link)[2]; + } + getPasteId() { + var base = "Fetching Title"; + if (this.settings.useBetterPasteId) { + return this.getBetterPasteId(base); + } + else { + return `${base}#${this.createBlockHash()}`; + } + } + getBetterPasteId(base) { + // After every character, add 0, 1 or 2 invisible characters + // so that to the user it looks just like the base string. + // The number of combinations is 3^14 = 4782969 + let result = ""; + var invisibleCharacter = "\u200B"; + var maxInvisibleCharacters = 2; + for (var i = 0; i < base.length; i++) { + var count = Math.floor(Math.random() * (maxInvisibleCharacters + 1)); + result += base.charAt(i) + invisibleCharacter.repeat(count); + } + return result; + } + // Custom hashid by @shabegom + createBlockHash() { + let result = ""; + var characters = "abcdefghijklmnopqrstuvwxyz0123456789"; + var charactersLength = characters.length; + for (var i = 0; i < 4; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; + } + onunload() { + console.log("unloading obsidian-auto-link-title"); + } + loadSettings() { + return __awaiter(this, void 0, void 0, function* () { + this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData()); + }); + } + saveSettings() { + return __awaiter(this, void 0, void 0, function* () { + yield this.saveData(this.settings); + }); + } +} + +module.exports = AutoLinkTitle; + + +/* nosourcemap */ \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-auto-link-title/manifest.json b/.obsidian/plugins/obsidian-auto-link-title/manifest.json new file mode 100644 index 0000000..e3f4db2 --- /dev/null +++ b/.obsidian/plugins/obsidian-auto-link-title/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "obsidian-auto-link-title", + "name": "Auto Link Title", + "version": "1.5.5", + "minAppVersion": "0.12.17", + "description": "This plugin automatically fetches the titles of links from the web", + "author": "Matt Furden", + "authorUrl": "https://github.com/zolrath", + "isDesktopOnly": false +} diff --git a/.obsidian/plugins/obsidian-auto-link-title/styles.css b/.obsidian/plugins/obsidian-auto-link-title/styles.css new file mode 100644 index 0000000..ad3bb8f --- /dev/null +++ b/.obsidian/plugins/obsidian-auto-link-title/styles.css @@ -0,0 +1 @@ +/* no styles */ \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-paste-image-rename/data.json b/.obsidian/plugins/obsidian-paste-image-rename/data.json new file mode 100644 index 0000000..2ea8a7a --- /dev/null +++ b/.obsidian/plugins/obsidian-paste-image-rename/data.json @@ -0,0 +1,11 @@ +{ + "imageNamePattern": "{{fileHash}}", + "dupNumberAtStart": false, + "dupNumberDelimiter": "-", + "dupNumberAlways": false, + "autoRename": true, + "handleAllAttachments": true, + "excludeExtensionPattern": "", + "disableRenameNotice": false, + "useFileHashName": true +} \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-paste-image-rename/main.js b/.obsidian/plugins/obsidian-paste-image-rename/main.js new file mode 100644 index 0000000..2d4ffb9 --- /dev/null +++ b/.obsidian/plugins/obsidian-paste-image-rename/main.js @@ -0,0 +1,965 @@ +/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD */ +var __defProp = Object.defineProperty; +var __defProps = Object.defineProperties; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropDescs = Object.getOwnPropertyDescriptors; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSym ) + __defNormalProp(a, prop, b[prop]); + return a; +}; +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + var fulfilled = (value) => { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + }; + var rejected = (value) => { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + }; + var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); + step((generator = generator.apply(__this, __arguments)).next()); + }); +}; + +// package.json +var require_package = __commonJS({ + "package.json"(exports, module2) { + module2.exports = { + name: "obsidian-paste-image-rename", + version: "1.6.1", + main: "main.js", + scripts: { + start: "node esbuild.config.mjs", + build: "tsc -noEmit -skipLibCheck && BUILD_ENV=production node esbuild.config.mjs && cp manifest.json build", + version: "node version-bump.mjs && git add manifest.json versions.json", + release: "npm run build && gh release create ${npm_package_version} build/*" + }, + keywords: [], + author: "Reorx", + license: "MIT", + devDependencies: { + "@types/node": "^18.11.18", + "@typescript-eslint/eslint-plugin": "^5.49.0", + "@typescript-eslint/parser": "^5.49.0", + "builtin-modules": "^3.3.0", + esbuild: "0.16.17", + obsidian: "^1.1.1", + tslib: "2.5.0", + typescript: "4.9.4" + }, + dependencies: { + "cash-dom": "^8.1.2" + } + }; + } +}); + +// src/main.ts +var main_exports = {}; +__export(main_exports, { + default: () => PasteImageRenamePlugin +}); +module.exports = __toCommonJS(main_exports); +var import_obsidian2 = require("obsidian"); + +// src/batch.ts +var import_obsidian = require("obsidian"); + +// src/utils.ts +var DEBUG = false; +if (DEBUG) + console.log("DEBUG is enabled"); +function debugLog(...args) { + if (DEBUG) { + console.log(new Date().toISOString().slice(11, 23), ...args); + } +} +function createElementTree(rootEl, opts) { + const result = { + el: rootEl.createEl(opts.tag, opts), + children: [] + }; + const children = opts.children || []; + for (const child of children) { + result.children.push(createElementTree(result.el, child)); + } + return result; +} +var path = { + // Credit: @creationix/path.js + join(...partSegments) { + let parts = []; + for (let i = 0, l = partSegments.length; i < l; i++) { + parts = parts.concat(partSegments[i].split("/")); + } + const newParts = []; + for (let i = 0, l = parts.length; i < l; i++) { + const part = parts[i]; + if (!part || part === ".") + continue; + else + newParts.push(part); + } + if (parts[0] === "") + newParts.unshift(""); + return newParts.join("/"); + }, + // returns the last part of a path, e.g. 'foo.jpg' + basename(fullpath) { + const sp = fullpath.split("/"); + return sp[sp.length - 1]; + }, + // return extension without dot, e.g. 'jpg' + extension(fullpath) { + const positions = [...fullpath.matchAll(new RegExp("\\.", "gi"))].map((a) => a.index); + return fullpath.slice(positions[positions.length - 1] + 1); + } +}; +var filenameNotAllowedChars = /[^\p{L}0-9~`!@$&*()\-_=+{};'",<.>? ]/ug; +var sanitizer = { + filename(s) { + return s.replace(filenameNotAllowedChars, "").trim(); + }, + delimiter(s) { + s = this.filename(s); + if (!s) + s = "-"; + return s; + } +}; +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function lockInputMethodComposition(el) { + const state = { + lock: false + }; + el.addEventListener("compositionstart", () => { + state.lock = true; + }); + el.addEventListener("compositionend", () => { + state.lock = false; + }); + return state; +} +function toHex(buffer) { + return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} +function sha256(buffer) { + return __async(this, null, function* () { + const digest = yield crypto.subtle.digest("SHA-256", buffer); + return toHex(digest); + }); +} + +// src/batch.ts +var ImageBatchRenameModal = class extends import_obsidian.Modal { + constructor(app, activeFile, renameFunc, onClose) { + super(app); + this.activeFile = activeFile; + this.renameFunc = renameFunc; + this.onCloseExtra = onClose; + this.state = { + namePattern: "", + extPattern: "", + nameReplace: "", + renameTasks: [] + }; + } + onOpen() { + this.containerEl.addClass("image-rename-modal"); + const { contentEl, titleEl } = this; + titleEl.setText("Batch rename embeded files"); + const namePatternSetting = new import_obsidian.Setting(contentEl).setName("Name pattern").setDesc("Please input the name pattern to match files (regex)").addText((text) => text.setValue(this.state.namePattern).onChange( + (value) => __async(this, null, function* () { + this.state.namePattern = value; + }) + )); + const npInputEl = namePatternSetting.controlEl.children[0]; + npInputEl.focus(); + const npInputState = lockInputMethodComposition(npInputEl); + npInputEl.addEventListener("keydown", (e) => __async(this, null, function* () { + if (e.key === "Enter" && !npInputState.lock) { + e.preventDefault(); + if (!this.state.namePattern) { + errorEl.innerText = 'Error: "Name pattern" could not be empty'; + errorEl.style.display = "block"; + return; + } + this.matchImageNames(tbodyEl); + } + })); + const extPatternSetting = new import_obsidian.Setting(contentEl).setName("Extension pattern").setDesc("Please input the extension pattern to match files (regex)").addText((text) => text.setValue(this.state.extPattern).onChange( + (value) => __async(this, null, function* () { + this.state.extPattern = value; + }) + )); + const extInputEl = extPatternSetting.controlEl.children[0]; + extInputEl.addEventListener("keydown", (e) => __async(this, null, function* () { + if (e.key === "Enter") { + e.preventDefault(); + this.matchImageNames(tbodyEl); + } + })); + const nameReplaceSetting = new import_obsidian.Setting(contentEl).setName("Name replace").setDesc("Please input the string to replace the matched name (use $1, $2 for regex groups)").addText((text) => text.setValue(this.state.nameReplace).onChange( + (value) => __async(this, null, function* () { + this.state.nameReplace = value; + }) + )); + const nrInputEl = nameReplaceSetting.controlEl.children[0]; + const nrInputState = lockInputMethodComposition(nrInputEl); + nrInputEl.addEventListener("keydown", (e) => __async(this, null, function* () { + if (e.key === "Enter" && !nrInputState.lock) { + e.preventDefault(); + this.matchImageNames(tbodyEl); + } + })); + const matchedContainer = contentEl.createDiv({ + cls: "matched-container" + }); + const tableET = createElementTree(matchedContainer, { + tag: "table", + children: [ + { + tag: "thead", + children: [ + { + tag: "tr", + children: [ + { + tag: "td", + text: "Original path" + }, + { + tag: "td", + text: "Renamed Name" + } + ] + } + ] + }, + { + tag: "tbody" + } + ] + }); + const tbodyEl = tableET.children[1].el; + const errorEl = contentEl.createDiv({ + cls: "error", + attr: { + style: "display: none;" + } + }); + new import_obsidian.Setting(contentEl).addButton((button) => { + button.setButtonText("Rename all").setClass("mod-cta").onClick(() => { + new ConfirmModal( + this.app, + "Confirm rename all", + `Are you sure? This will rename all the ${this.state.renameTasks.length} images matched the pattern.`, + () => { + this.renameAll(); + this.close(); + } + ).open(); + }); + }).addButton((button) => { + button.setButtonText("Cancel").onClick(() => { + this.close(); + }); + }); + } + onClose() { + const { contentEl } = this; + contentEl.empty(); + this.onCloseExtra(); + } + renameAll() { + return __async(this, null, function* () { + debugLog("renameAll", this.state); + for (const task of this.state.renameTasks) { + yield this.renameFunc(task.file, task.name); + } + }); + } + matchImageNames(tbodyEl) { + const { state } = this; + const renameTasks = []; + tbodyEl.empty(); + const fileCache = this.app.metadataCache.getFileCache(this.activeFile); + if (!fileCache || !fileCache.embeds) + return; + const namePatternRegex = new RegExp(state.namePattern, "g"); + const extPatternRegex = new RegExp(state.extPattern); + fileCache.embeds.forEach((embed) => { + const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, this.activeFile.path); + if (!file) { + console.warn("file not found", embed.link); + return; + } + if (state.extPattern) { + const m0 = extPatternRegex.exec(file.extension); + if (!m0) + return; + } + const stem = file.basename; + namePatternRegex.lastIndex = 0; + const m1 = namePatternRegex.exec(stem); + if (!m1) + return; + let renamedName = file.name; + if (state.nameReplace) { + namePatternRegex.lastIndex = 0; + renamedName = stem.replace(namePatternRegex, state.nameReplace); + renamedName = `${renamedName}.${file.extension}`; + } + renameTasks.push({ + file, + name: renamedName + }); + createElementTree(tbodyEl, { + tag: "tr", + children: [ + { + tag: "td", + children: [ + { + tag: "span", + text: file.name + }, + { + tag: "div", + text: file.path, + attr: { + class: "file-path" + } + } + ] + }, + { + tag: "td", + children: [ + { + tag: "span", + text: renamedName + }, + { + tag: "div", + text: path.join(file.parent.path, renamedName), + attr: { + class: "file-path" + } + } + ] + } + ] + }); + }); + debugLog("new renameTasks", renameTasks); + state.renameTasks = renameTasks; + } +}; +var ConfirmModal = class extends import_obsidian.Modal { + constructor(app, title, message, onConfirm) { + super(app); + this.title = title; + this.message = message; + this.onConfirm = onConfirm; + } + onOpen() { + const { contentEl, titleEl } = this; + titleEl.setText(this.title); + contentEl.createEl("p", { + text: this.message + }); + new import_obsidian.Setting(contentEl).addButton((button) => { + button.setButtonText("Yes").setClass("mod-warning").onClick(() => { + this.onConfirm(); + this.close(); + }); + }).addButton((button) => { + button.setButtonText("No").onClick(() => { + this.close(); + }); + }); + } +}; + +// src/template.ts +var dateTmplRegex = /{{DATE:([^}]+)}}/gm; +var frontmatterTmplRegex = /{{frontmatter:([^}]+)}}/gm; +var replaceDateVar = (s, date) => { + const m = dateTmplRegex.exec(s); + if (!m) + return s; + return s.replace(m[0], date.format(m[1])); +}; +var replaceFrontmatterVar = (s, frontmatter) => { + if (!frontmatter) + return s; + const m = frontmatterTmplRegex.exec(s); + if (!m) + return s; + return s.replace(m[0], frontmatter[m[1]] || ""); +}; +var renderTemplate = (tmpl, data, frontmatter) => { + const now = window.moment(); + let text = tmpl; + let newtext; + while ((newtext = replaceDateVar(text, now)) != text) { + text = newtext; + } + while ((newtext = replaceFrontmatterVar(text, frontmatter)) != text) { + text = newtext; + } + text = text.replace(/{{imageNameKey}}/gm, data.imageNameKey).replace(/{{fileName}}/gm, data.fileName).replace(/{{dirName}}/gm, data.dirName).replace(/{{firstHeading}}/gm, data.firstHeading).replace(/{{fileHash}}/gm, data.fileHash); + return text; +}; + +// src/main.ts +var DEFAULT_SETTINGS = { + imageNamePattern: "{{fileName}}", + dupNumberAtStart: false, + dupNumberDelimiter: "-", + dupNumberAlways: false, + autoRename: false, + handleAllAttachments: false, + excludeExtensionPattern: "", + disableRenameNotice: false +}; +var PASTED_IMAGE_PREFIX = "Pasted image "; +var PasteImageRenamePlugin = class extends import_obsidian2.Plugin { + constructor() { + super(...arguments); + this.modals = []; + } + onload() { + return __async(this, null, function* () { + const pkg = require_package(); + console.log(`Plugin loading: ${pkg.name} ${pkg.version} BUILD_ENV=${"production"}`); + yield this.loadSettings(); + this.registerEvent( + this.app.vault.on("create", (file) => { + if (!(file instanceof import_obsidian2.TFile)) + return; + const timeGapMs = new Date().getTime() - file.stat.ctime; + if (timeGapMs > 1e3) + return; + if (isMarkdownFile(file)) + return; + if (isPastedImage(file)) { + debugLog("pasted image created", file); + this.startRenameProcess(file, this.settings.autoRename); + } else { + if (this.settings.handleAllAttachments) { + debugLog("handleAllAttachments for file", file); + if (this.testExcludeExtension(file)) { + debugLog("excluded file by ext", file); + return; + } + this.startRenameProcess(file, this.settings.autoRename); + } + } + }) + ); + const startBatchRenameProcess = () => { + this.openBatchRenameModal(); + }; + this.addCommand({ + id: "batch-rename-embeded-files", + name: "Batch rename embeded files (in the current file)", + callback: startBatchRenameProcess + }); + if (DEBUG) { + this.addRibbonIcon("wand-glyph", "Batch rename embeded files", startBatchRenameProcess); + } + const batchRenameAllImages = () => { + this.batchRenameAllImages(); + }; + this.addCommand({ + id: "batch-rename-all-images", + name: "Batch rename all images instantly (in the current file)", + callback: batchRenameAllImages + }); + if (DEBUG) { + this.addRibbonIcon("wand-glyph", "Batch rename all images instantly (in the current file)", batchRenameAllImages); + } + this.addSettingTab(new SettingTab(this.app, this)); + }); + } + startRenameProcess(file, autoRename = false) { + return __async(this, null, function* () { + const activeFile = this.getActiveFile(); + if (!activeFile) { + new import_obsidian2.Notice("Error: No active file found."); + return; + } + const { stem, newName, isMeaningful } = yield this.generateNewName(file, activeFile); + debugLog("generated newName:", newName, isMeaningful); + if (!isMeaningful || !autoRename) { + this.openRenameModal(file, isMeaningful ? stem : "", activeFile.path); + return; + } + this.renameFile(file, newName, activeFile.path, true); + }); + } + renameFile(file, inputNewName, sourcePath, replaceCurrentLine) { + return __async(this, null, function* () { + const { name: newName } = yield this.deduplicateNewName(inputNewName, file); + debugLog("deduplicated newName:", newName); + const originName = file.name; + const linkText = this.app.fileManager.generateMarkdownLink(file, sourcePath); + const newPath = path.join(file.parent.path, newName); + try { + yield this.app.fileManager.renameFile(file, newPath); + } catch (err) { + new import_obsidian2.Notice(`Failed to rename ${newName}: ${err}`); + throw err; + } + if (!replaceCurrentLine) { + return; + } + const newLinkText = this.app.fileManager.generateMarkdownLink(file, sourcePath); + debugLog("replace text", linkText, newLinkText); + const editor = this.getActiveEditor(); + if (!editor) { + new import_obsidian2.Notice(`Failed to rename ${newName}: no active editor`); + return; + } + const changes = []; + for (let lineNumber = 0; lineNumber < editor.lineCount(); lineNumber++) { + const line = editor.getLine(lineNumber); + const replacedLine = line.split(linkText).join(newLinkText); + if (line === replacedLine) { + continue; + } + debugLog("replace line", lineNumber, line, replacedLine); + changes.push({ + from: { line: lineNumber, ch: 0 }, + to: { line: lineNumber, ch: line.length }, + text: replacedLine + }); + } + if (changes.length > 0) { + editor.transaction({ changes }); + } + if (!this.settings.disableRenameNotice) { + new import_obsidian2.Notice(`Renamed ${originName} to ${newName}`); + } + }); + } + openRenameModal(file, newName, sourcePath) { + const modal = new ImageRenameModal( + this.app, + file, + newName, + (confirmedName) => { + debugLog("confirmedName:", confirmedName); + this.renameFile(file, confirmedName, sourcePath, true); + }, + () => { + this.modals.splice(this.modals.indexOf(modal), 1); + } + ); + this.modals.push(modal); + modal.open(); + debugLog("modals count", this.modals.length); + } + openBatchRenameModal() { + const activeFile = this.getActiveFile(); + const modal = new ImageBatchRenameModal( + this.app, + activeFile, + (file, name) => __async(this, null, function* () { + yield this.renameFile(file, name, activeFile.path); + }), + () => { + this.modals.splice(this.modals.indexOf(modal), 1); + } + ); + this.modals.push(modal); + modal.open(); + } + batchRenameAllImages() { + return __async(this, null, function* () { + const activeFile = this.getActiveFile(); + const fileCache = this.app.metadataCache.getFileCache(activeFile); + if (!fileCache || !fileCache.embeds) + return; + const extPatternRegex = /jpe?g|png|gif|tiff|webp/i; + for (const embed of fileCache.embeds) { + const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, activeFile.path); + if (!file) { + console.warn("file not found", embed.link); + return; + } + const m0 = extPatternRegex.exec(file.extension); + if (!m0) + return; + const { newName, isMeaningful } = yield this.generateNewName(file, activeFile); + debugLog("generated newName:", newName, isMeaningful); + if (!isMeaningful) { + new import_obsidian2.Notice("Failed to batch rename images: the generated name is not meaningful"); + break; + } + yield this.renameFile(file, newName, activeFile.path, false); + } + }); + } + // returns a new name for the input file, with extension + generateNewName(file, activeFile) { + return __async(this, null, function* () { + let imageNameKey = ""; + let firstHeading = ""; + let frontmatter; + const fileCache = this.app.metadataCache.getFileCache(activeFile); + if (fileCache) { + debugLog("frontmatter", fileCache.frontmatter); + frontmatter = fileCache.frontmatter; + imageNameKey = (frontmatter == null ? void 0 : frontmatter.imageNameKey) || ""; + firstHeading = getFirstHeading(fileCache.headings); + } else { + console.warn("could not get file cache from active file", activeFile.name); + } + let fileHash = ""; + if (this.settings.imageNamePattern.includes("{{fileHash}}")) { + const buffer = yield this.app.vault.readBinary(file); + fileHash = yield sha256(buffer); + } + const stem = renderTemplate( + this.settings.imageNamePattern, + { + imageNameKey, + fileName: activeFile.basename, + dirName: activeFile.parent.name, + firstHeading, + fileHash + }, + frontmatter + ); + const meaninglessRegex = new RegExp(`[${this.settings.dupNumberDelimiter}\\s]`, "gm"); + return { + stem, + newName: stem + "." + file.extension, + isMeaningful: stem.replace(meaninglessRegex, "") !== "" + }; + }); + } + // newName: foo.ext + deduplicateNewName(newName, file) { + return __async(this, null, function* () { + const dir = file.parent.path; + const listed = yield this.app.vault.adapter.list(dir); + debugLog("sibling files", listed); + const newNameExt = path.extension(newName), newNameStem = newName.slice(0, newName.length - newNameExt.length - 1), newNameStemEscaped = escapeRegExp(newNameStem), delimiter = this.settings.dupNumberDelimiter, delimiterEscaped = escapeRegExp(delimiter); + let dupNameRegex; + if (this.settings.dupNumberAtStart) { + dupNameRegex = new RegExp( + `^(?<number>\\d+)${delimiterEscaped}(?<name>${newNameStemEscaped})\\.${newNameExt}$` + ); + } else { + dupNameRegex = new RegExp( + `^(?<name>${newNameStemEscaped})${delimiterEscaped}(?<number>\\d+)\\.${newNameExt}$` + ); + } + debugLog("dupNameRegex", dupNameRegex); + const dupNameNumbers = []; + let isNewNameExist = false; + for (let sibling of listed.files) { + sibling = path.basename(sibling); + if (sibling == newName) { + isNewNameExist = true; + continue; + } + const m = dupNameRegex.exec(sibling); + if (!m) + continue; + dupNameNumbers.push(parseInt(m.groups.number)); + } + if (isNewNameExist || this.settings.dupNumberAlways) { + const newNumber = dupNameNumbers.length > 0 ? Math.max(...dupNameNumbers) + 1 : 1; + if (this.settings.dupNumberAtStart) { + newName = `${newNumber}${delimiter}${newNameStem}.${newNameExt}`; + } else { + newName = `${newNameStem}${delimiter}${newNumber}.${newNameExt}`; + } + } + return { + name: newName, + stem: newName.slice(0, newName.length - newNameExt.length - 1), + extension: newNameExt + }; + }); + } + getActiveFile() { + const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView); + const file = view == null ? void 0 : view.file; + debugLog("active file", file == null ? void 0 : file.path); + return file; + } + getActiveEditor() { + const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView); + return view == null ? void 0 : view.editor; + } + onunload() { + this.modals.map((modal) => modal.close()); + } + testExcludeExtension(file) { + const pattern = this.settings.excludeExtensionPattern; + if (!pattern) + return false; + return new RegExp(pattern).test(file.extension); + } + loadSettings() { + return __async(this, null, function* () { + this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData()); + }); + } + saveSettings() { + return __async(this, null, function* () { + yield this.saveData(this.settings); + }); + } +}; +function getFirstHeading(headings) { + if (headings && headings.length > 0) { + for (const heading of headings) { + if (heading.level === 1) { + return heading.heading; + } + } + } + return ""; +} +function isPastedImage(file) { + if (file instanceof import_obsidian2.TFile) { + if (file.name.startsWith(PASTED_IMAGE_PREFIX)) { + return true; + } + } + return false; +} +function isMarkdownFile(file) { + if (file instanceof import_obsidian2.TFile) { + if (file.extension === "md") { + return true; + } + } + return false; +} +var ImageRenameModal = class extends import_obsidian2.Modal { + constructor(app, src, stem, renameFunc, onClose) { + super(app); + this.src = src; + this.stem = stem; + this.renameFunc = renameFunc; + this.onCloseExtra = onClose; + } + onOpen() { + this.containerEl.addClass("image-rename-modal"); + const { contentEl, titleEl } = this; + titleEl.setText("Rename image"); + const imageContainer = contentEl.createDiv({ + cls: "image-container" + }); + imageContainer.createEl("img", { + attr: { + src: this.app.vault.getResourcePath(this.src) + } + }); + let stem = this.stem; + const ext = this.src.extension; + const getNewName = (stem2) => stem2 + "." + ext; + const getNewPath = (stem2) => path.join(this.src.parent.path, getNewName(stem2)); + const infoET = createElementTree(contentEl, { + tag: "ul", + cls: "info", + children: [ + { + tag: "li", + children: [ + { + tag: "span", + text: "Origin path" + }, + { + tag: "span", + text: this.src.path + } + ] + }, + { + tag: "li", + children: [ + { + tag: "span", + text: "New path" + }, + { + tag: "span", + text: getNewPath(stem) + } + ] + } + ] + }); + const doRename = () => __async(this, null, function* () { + debugLog("doRename", `stem=${stem}`); + this.renameFunc(getNewName(stem)); + }); + const nameSetting = new import_obsidian2.Setting(contentEl).setName("New name").setDesc("Please input the new name for the image (without extension)").addText((text) => text.setValue(stem).onChange( + (value) => __async(this, null, function* () { + stem = sanitizer.filename(value); + infoET.children[1].children[1].el.innerText = getNewPath(stem); + }) + )); + const nameInputEl = nameSetting.controlEl.children[0]; + nameInputEl.focus(); + const nameInputState = lockInputMethodComposition(nameInputEl); + nameInputEl.addEventListener("keydown", (e) => __async(this, null, function* () { + if (e.key === "Enter" && !nameInputState.lock) { + e.preventDefault(); + if (!stem) { + errorEl.innerText = 'Error: "New name" could not be empty'; + errorEl.style.display = "block"; + return; + } + doRename(); + this.close(); + } + })); + const errorEl = contentEl.createDiv({ + cls: "error", + attr: { + style: "display: none;" + } + }); + new import_obsidian2.Setting(contentEl).addButton((button) => { + button.setButtonText("Rename").onClick(() => { + doRename(); + this.close(); + }); + }).addButton((button) => { + button.setButtonText("Cancel").onClick(() => { + this.close(); + }); + }); + } + onClose() { + const { contentEl } = this; + contentEl.empty(); + this.onCloseExtra(); + } +}; +var imageNamePatternDesc = ` +The pattern indicates how the new name should be generated. + +Available variables: +- {{fileName}}: name of the active file, without ".md" extension. +- {{imageNameKey}}: this variable is read from the markdown file's frontmatter, from the same key "imageNameKey". +- {{fileHash}}: SHA-256 hash of the image file. +- {{DATE:$FORMAT}}: use "$FORMAT" to format the current date, "$FORMAT" must be a Moment.js format string, e.g. {{DATE:YYYY-MM-DD}}. + +Here are some examples from pattern to image names (repeat in sequence), variables: fileName = "My note", imageNameKey = "foo": +- {{fileName}}: My note, My note-1, My note-2 +- {{imageNameKey}}: foo, foo-1, foo-2 +- {{fileHash}}: <sha256>, <sha256>-1, <sha256>-2 +- {{imageNameKey}}-{{DATE:YYYYMMDD}}: foo-20220408, foo-20220408-1, foo-20220408-2 +`; +var SettingTab = class extends import_obsidian2.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + const { containerEl } = this; + containerEl.empty(); + new import_obsidian2.Setting(containerEl).setName("Image name pattern").setDesc(imageNamePatternDesc).setClass("long-description-setting-item").addText((text) => text.setPlaceholder("{{imageNameKey}}").setValue(this.plugin.settings.imageNamePattern).onChange( + (value) => __async(this, null, function* () { + this.plugin.settings.imageNamePattern = value; + yield this.plugin.saveSettings(); + }) + )); + new import_obsidian2.Setting(containerEl).setName("Duplicate number at start (or end)").setDesc(`If enabled, duplicate number will be added at the start as prefix for the image name, otherwise it will be added at the end as suffix for the image name.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAtStart).onChange( + (value) => __async(this, null, function* () { + this.plugin.settings.dupNumberAtStart = value; + yield this.plugin.saveSettings(); + }) + )); + new import_obsidian2.Setting(containerEl).setName("Duplicate number delimiter").setDesc(`The delimiter to generate the number prefix/suffix for duplicated names. For example, if the value is "-", the suffix will be like "-1", "-2", "-3", and the prefix will be like "1-", "2-", "3-". Only characters that are valid in file names are allowed.`).addText((text) => text.setValue(this.plugin.settings.dupNumberDelimiter).onChange( + (value) => __async(this, null, function* () { + this.plugin.settings.dupNumberDelimiter = sanitizer.delimiter(value); + yield this.plugin.saveSettings(); + }) + )); + new import_obsidian2.Setting(containerEl).setName("Always add duplicate number").setDesc(`If enabled, duplicate number will always be added to the image name. Otherwise, it will only be added when the name is duplicated.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAlways).onChange( + (value) => __async(this, null, function* () { + this.plugin.settings.dupNumberAlways = value; + yield this.plugin.saveSettings(); + }) + )); + new import_obsidian2.Setting(containerEl).setName("Auto rename").setDesc(`By default, the rename modal will always be shown to confirm before renaming, if this option is set, the image will be auto renamed after pasting.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.autoRename).onChange( + (value) => __async(this, null, function* () { + this.plugin.settings.autoRename = value; + yield this.plugin.saveSettings(); + }) + )); + new import_obsidian2.Setting(containerEl).setName("Handle all attachments").setDesc(`By default, the plugin only handles images that starts with "Pasted image " in name, + which is the prefix Obsidian uses to create images from pasted content. + If this option is set, the plugin will handle all attachments that are created in the vault.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.handleAllAttachments).onChange( + (value) => __async(this, null, function* () { + this.plugin.settings.handleAllAttachments = value; + yield this.plugin.saveSettings(); + }) + )); + new import_obsidian2.Setting(containerEl).setName("Exclude extension pattern").setDesc(`This option is only useful when "Handle all attachments" is enabled. + Write a Regex pattern to exclude certain extensions from being handled. Only the first line will be used.`).setClass("single-line-textarea").addTextArea((text) => text.setPlaceholder("docx?|xlsx?|pptx?|zip|rar").setValue(this.plugin.settings.excludeExtensionPattern).onChange( + (value) => __async(this, null, function* () { + this.plugin.settings.excludeExtensionPattern = value; + yield this.plugin.saveSettings(); + }) + )); + new import_obsidian2.Setting(containerEl).setName("Disable rename notice").setDesc(`Turn off this option if you don't want to see the notice when renaming images. + Note that Obsidian may display a notice when a link has changed, this option cannot disable that.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.disableRenameNotice).onChange( + (value) => __async(this, null, function* () { + this.plugin.settings.disableRenameNotice = value; + yield this.plugin.saveSettings(); + }) + )); + } +}; + +/* nosourcemap */ \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-paste-image-rename/manifest.json b/.obsidian/plugins/obsidian-paste-image-rename/manifest.json new file mode 100644 index 0000000..0415e39 --- /dev/null +++ b/.obsidian/plugins/obsidian-paste-image-rename/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "obsidian-paste-image-rename", + "name": "Paste image rename (Edited)", + "version": "1.6.1", + "minAppVersion": "0.12.0", + "description": "Rename pasted images and all the other attchments added to the vault", + "author": "Reorx", + "authorUrl": "https://github.com/reorx", + "isDesktopOnly": false +} \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-paste-image-rename/styles.css b/.obsidian/plugins/obsidian-paste-image-rename/styles.css new file mode 100644 index 0000000..d542d56 --- /dev/null +++ b/.obsidian/plugins/obsidian-paste-image-rename/styles.css @@ -0,0 +1,79 @@ +/* src/styles.css */ +:root { + --shadow-color: 0deg 0% 0%; + --shadow-elevation-medium: + 0.5px 0.5px 0.7px hsl(var(--shadow-color) / 0.14), + 1.1px 1.1px 1.5px -0.9px hsl(var(--shadow-color) / 0.12), + 2.4px 2.5px 3.3px -1.8px hsl(var(--shadow-color) / 0.1), + 5.3px 5.6px 7.3px -2.7px hsl(var(--shadow-color) / 0.09), + 11px 11.4px 15.1px -3.6px hsl(var(--shadow-color) / 0.07); +} +.image-rename-modal .modal { + width: 65%; + min-width: 600px; +} +.image-rename-modal .modal-content { + padding: 10px 5px; +} +.image-rename-modal .image-container { + display: flex; + justify-content: center; +} +.image-rename-modal .info { + padding: 10px 0; + color: var(--text-muted); + user-select: text; +} +.image-rename-modal .info li > span:nth-of-type(1) { + display: inline-block; + width: 6em; + margin-right: .5em; +} +.image-rename-modal .info li > span:nth-of-type(1):after { + content: ":"; + float: right; +} +.image-rename-modal .image-container img { + display: block; + max-height: 300px; + box-shadow: var(--shadow-elevation-medium); +} +.image-rename-modal .setting-item-control input { + min-width: 300px; +} +.image-rename-modal .error { + border: 1px solid rgb(201, 90, 90); + color: rgb(134, 22, 22); + padding: 10px; +} +.image-rename-modal table { + font-size: .9em; + line-height: 1.8; + margin-bottom: 1.5em; + user-select: text; +} +.image-rename-modal table td { + padding-right: 1em; +} +.image-rename-modal table thead td { + font-weight: 700; +} +.image-rename-modal table tbody td .file-path { + font-size: .8em; + color: var(--text-faint); + line-height: 1; +} +.long-description-setting-item { + flex-wrap: wrap; +} +.long-description-setting-item .setting-item-description { + white-space: pre-wrap; + line-height: 1.3em; +} +.long-description-setting-item .setting-item-control { + padding-top: 10px; +} +.long-description-setting-item .setting-item-control input { + min-width: 300px; + width: 50%; +} diff --git a/.obsidian/plugins/slash/main.js b/.obsidian/plugins/slash/main.js new file mode 100644 index 0000000..cd8128c --- /dev/null +++ b/.obsidian/plugins/slash/main.js @@ -0,0 +1,65 @@ +const { Plugin } = require('obsidian'); + +module.exports = class FixPublicLinksPlugin extends Plugin { + async onload() { + console.log('Loading Fix Public Links plugin'); + + // 监听文件创建事件(粘贴图片时触发) + this.registerEvent( + this.app.vault.on('create', (file) => { + // 延迟执行,确保 Obsidian 已经插入了链接 + setTimeout(() => { + this.fixPublicLinksInActiveFile(); + }, 100); + }) + ); + + // 添加命令:手动修复当前文件的所有链接 + this.addCommand({ + id: 'fix-public-links', + name: 'Fix public/ links in current file', + editorCallback: (editor) => { + this.fixPublicLinksInEditor(editor); + } + }); + } + + fixPublicLinksInActiveFile() { + const activeView = this.app.workspace.getActiveViewOfType(require('obsidian').MarkdownView); + if (!activeView) return; + + const editor = activeView.editor; + this.fixPublicLinksInEditor(editor); + } + + fixPublicLinksInEditor(editor) { + const cursor = editor.getCursor(); + const lineCount = editor.lineCount(); + let fixed = false; + + // 遍历所有行 + for (let i = 0; i < lineCount; i++) { + const line = editor.getLine(i); + + // 匹配 Markdown 图片语法:![...](public/...) + const fixedLine = line.replace(/\]\(public\//g, '](/public/'); + + if (fixedLine !== line) { + editor.replaceRange( + fixedLine, + { line: i, ch: 0 }, + { line: i, ch: line.length } + ); + fixed = true; + } + } + + if (fixed) { + console.log('Fixed public/ links in current file'); + } + } + + onunload() { + console.log('Unloading Fix Public Links plugin'); + } +}; diff --git a/.obsidian/plugins/slash/manifest.json b/.obsidian/plugins/slash/manifest.json new file mode 100644 index 0000000..58822af --- /dev/null +++ b/.obsidian/plugins/slash/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "fix-public-links", + "name": "Fix Public Links", + "version": "1.0.0", + "minAppVersion": "0.15.0", + "description": "Automatically fix image links that start with public/ to /public/", + "author": "AcoFork", + "authorUrl": "https://blog.acofork.com", + "isDesktopOnly": false +} \ No newline at end of file diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json new file mode 100644 index 0000000..9b87221 --- /dev/null +++ b/.obsidian/workspace.json @@ -0,0 +1,214 @@ +{ + "main": { + "id": "16a573e2ddb25847", + "type": "split", + "children": [ + { + "id": "ead2c6543d1842b7", + "type": "tabs", + "children": [ + { + "id": "e32607a73f296aa4", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "src/content/posts/codex-warp.md", + "mode": "source", + "source": false + }, + "icon": "lucide-file", + "title": "codex-warp" + } + } + ] + } + ], + "direction": "vertical" + }, + "left": { + "id": "b3b818b3595a168f", + "type": "split", + "children": [ + { + "id": "f871c6ee02fda945", + "type": "tabs", + "children": [ + { + "id": "6719a883e9f34850", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "alphabetical", + "autoReveal": false + }, + "icon": "lucide-folder-closed", + "title": "文件列表" + } + }, + { + "id": "8f2d88709f3efdd0", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + }, + "icon": "lucide-search", + "title": "搜索" + } + }, + { + "id": "e4e31602ca6ef73f", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {}, + "icon": "lucide-bookmark", + "title": "书签" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300 + }, + "right": { + "id": "0e5ea46bbde4f663", + "type": "split", + "children": [ + { + "id": "9a2e329e85417358", + "type": "tabs", + "children": [ + { + "id": "cf180cd264a5895e", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "file": "src/content/posts/codex-warp.md", + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-coming-in", + "title": "codex-warp 的反向链接列表" + } + }, + { + "id": "20df57f8cd76fbac", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "file": "src/content/posts/codex-warp.md", + "linksCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-going-out", + "title": "codex-warp 的出链列表" + } + }, + { + "id": "fc6075104c1b1a76", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-tags", + "title": "标签" + } + }, + { + "id": "f9fd6fcf70c8f5c5", + "type": "leaf", + "state": { + "type": "all-properties", + "state": { + "sortOrder": "frequency", + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-archive", + "title": "添加笔记属性" + } + }, + { + "id": "41cb4f8448efc28f", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "file": "src/content/posts/codex-warp.md", + "followCursor": false, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-list", + "title": "codex-warp 的大纲" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300, + "collapsed": true + }, + "left-ribbon": { + "hiddenItems": { + "switcher:打开快速切换": false, + "graph:查看关系图谱": false, + "canvas:新建白板": false, + "daily-notes:打开/创建今天的日记": false, + "templates:插入模板": false, + "command-palette:打开命令面板": false, + "bases:新建数据库": false + } + }, + "active": "e32607a73f296aa4", + "lastOpenFiles": [ + "public/pic/f1d08603a003c00e8664b4cb80b20358e9b9638bcedce5a307c91bb853b10eb7.png", + "public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-1.png", + "public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc.png", + "public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-4.png", + "public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-3.png", + "public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-2.png", + "public/pic/122.png", + "public/pic/codex-warp-2.png", + "public/pic/codex-warp.png", + "public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-1.png", + "public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc.png", + "public/pic", + "src/content/assets/images", + "src/content/assets", + "dist/pagefind/index/zh-cn_6473869.pf_index", + "dist/pagefind/index/zh-cn_52f3f92.pf_index", + "dist/pagefind/fragment/zh-cn_f061267.pf_fragment", + "dist/pagefind/fragment/zh-cn_de70424.pf_fragment", + "dist/pagefind/fragment/zh-cn_ddd5448.pf_fragment", + "dist/pagefind/fragment/zh-cn_dd6dfac.pf_fragment", + "dist/pagefind/fragment/zh-cn_c5d7eb4.pf_fragment", + "src/content/posts/codex-warp.md", + "src/content/posts/new-domain.md", + "dist/pic/Pasted image 20260420023402.png.md", + "public/pic/Pasted image 20260420023402.png.md" + ] +} \ No newline at end of file