QuickAdd 脚本 - 基于关联性图形文件来定位笔记的脚本

QuickAdd 脚本 - 基于关联性图形文件来定位笔记的脚本

有些时候,你只隐约记得笔记中的某张图片,但对具体的笔记或内容却不太清楚,不知道从何入手时,这个脚本可以帮助你通过图片轻松定位到相关笔记。

QuickAdd 脚本 - 基于关联性图形文件来定位笔记的脚本--

目前,只通过 QuickAdd 的 Macro 脚本来实现,功能较为有限,期望有大佬能够开发成插件,实现更多功能,例如图片的排序和文件类型筛选。理想状态是借鉴 Eagle 素材管理界面的设计,提供更为灵活、直观的图片管理体验。

功能

通过图片定位笔记:通过直观的图片可视化展示,便能更快速、方便地定位到相关笔记。

QuickAdd 脚本 - 基于关联性图形文件来定位笔记的脚本--功能

  • 基于文件夹中笔记的检索:
    • 脚本会搜索文件夹内的所有笔记与之关联的图形文件。
    • 特别适配了 Excalidraw,可以直接在可视化界面查看嵌入的图形文件
    • 不支持 Canvas 文件。
    • 支持的图形文件类型:svg, gif, png, jpeg, jpg, webp, mp4
  • 图片的搜索定位
    • 图片右下角的🔍按钮被单击时会激活 Obsidian 的搜索功能,以便快速定位使用该图片的笔记。
    • 图片可单击放大。
    • 每页图片最多有 50 个,大于 50 个则出现换页器。

QuickAdd Macro


module.exports = async () => {
  const quickAddApi = app.plugins.plugins.quickadd.api;
  const path = require('path');
  const attachmentTypes = ['svg', 'gif', 'png', 'jpeg', 'jpg', 'webp', 'mp4'];
  const itemsPerPage = 50;

  // 获取ob的目录路径
  const listPaths = await app.vault.getAllFolders().map(f => f.path);
  // listPaths.unshift("./"); // 获取全部笔记的图片,全部加载比较卡

  let choicePath = "";
  // 获取笔记的基本路径
  try {
    const activeFilePath = await app.workspace.getActiveFile().path;
    listPaths.unshift("当前Index笔记");
    const fileName = path.basename(activeFilePath);
    const isFolderNote = path.basename(path.dirname(activeFilePath)) === fileName.replace(".md", "").replace(".canvas", "");
    // if (isFolderNote) {
    //   choicePath = path.dirname(activeFilePath);
    // } else {
    //   listPaths.unshift(path.dirname(activeFilePath));
    // }
    listPaths.unshift(path.dirname(activeFilePath));
  } catch (error) {
    console.error("获取活动文件路径时出错:", error);
  }

  // 判断是否是文件夹笔记,如果为FolderNote则直接使用当前路径,否则弹出文件夹选择器
  if (!choicePath) {
    choicePath = await quickAddApi.suggester(listPaths, listPaths);
  }
  if (!choicePath) return;

  console.log(`选择路径: ${choicePath}`);

  // 记录开始时间
  const startTime = performance.now();

  // 获取文件数据
  const files = await app.vault.getFiles();
  let fileData = [];
  if (choicePath === "当前Index笔记") {
    fileData = getMediaPathsbyMarkdwonPath(files, attachmentTypes);
  } else {
    fileData = getMediaPathsbyFolderPath(files, choicePath, attachmentTypes);
  }
  console.log(`获取了${fileData.length}个媒体文件`);
  await displayMedia({ fileData, attachmentTypes, itemsPerPage });

  // 创建一个 <style> 元素
  const style = document.createElement('style');
  // 定义 CSS 样式
  const css = `
.card-container {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: flex-start;
  align-items: stretch;
  align-content: flex-start;
  gap: 10px 10px;
  width: 100%;


  .file-card {
    border: 1px solid var(--background-modifier-border);
    border-radius: 5px;
    padding: 10px;
    margin-bottom: 10px;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
    background-color: var(--background-primary);
    flex: 0 1 auto;
    height: 200px;
    width: 300px;
    box-sizing: border-box;

    .media-element, .image-element {
      display: block;
      width: 100%;
      cursor: pointer;
      object-fit: contain;
      max-width: 100%;
    }
  }
}

.pagination-container {
  display: flex;
  width: 100%;
  justify-content: center;
  position: absolute;
  bottom: 20px;

  .pagination-list {
    display: flex;
    justify-content: center;
  }
}

.media-modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.8);
  z-index: 999;

  display: flex;
  justify-content: center;
  align-items: center;

  img, video {
    max-width: 80%;
    max-height: 80%;
  }
}
  `;

  // 将 CSS 样式添加到 <style> 元素中
  style.appendChild(document.createTextNode(css));

  // 将 <style> 元素添加到文档的 <head> 中
  document.head.appendChild(style);

  // !计算加载时间
  const endTime = performance.now();
  const loadTime = ((endTime - startTime) / 1000).toFixed(2);

  // !显示加载时间
  new Notice(`✔ ${fileData.length}个文件已加载完毕! 加载时间: ${loadTime}秒`);

  async function displayMedia({ fileData, attachmentTypes, itemsPerPage = 10, page: currentPage = 1 }) {
    // !计算总页数
    const totalPages = Math.ceil(fileData.length / itemsPerPage);
    const startIndex = (currentPage - 1) * itemsPerPage;
    const endIndex = startIndex + itemsPerPage;
    const paginatedData = fileData.slice(startIndex, endIndex);

    // ! 新建tab页
    const isFile = await app.workspace.getActiveFile();
    const leaf = await app.workspace.getLeaf(Boolean(isFile));
    await app.workspace.setActiveLeaf(leaf);
    const container = leaf.view.containerEl.children[1];
    container.innerHTML = '';

    // 创建卡片容器
    const cardContainer = document.createElement("div");
    cardContainer.className = "card-container";

    // 使用 Promise.all 并行处理文件卡片创建
    const cardPromises = paginatedData.map(async ({ imgPath }) => {
      const file = await app.vault.getFileByPath(imgPath);
      return createFileCard(file, attachmentTypes);
    });

    const cards = await Promise.all(cardPromises);
    cards.forEach(card => cardContainer.appendChild(card));

    container.appendChild(cardContainer);


    // 添加分页控件
    if (fileData.length > itemsPerPage) {
      createPaginationControls({ fileData, totalPages, currentPage, itemsPerPage, cardContainer, attachmentTypes });
    }

  };

  function createFileCard(file, attachmentTypes) {
    const card = document.createElement("div");
    card.className = "file-card";
    card.style.position = "relative";

    let mediaElement = createMediaElement(file, attachmentTypes);
    if (mediaElement) {
      card.appendChild(mediaElement);
      const searchButton = createSearchButton(file);
      card.appendChild(searchButton);
    }

    return card;
  }

  function createPaginationControls({ fileData, totalPages, currentPage, itemsPerPage, cardContainer, attachmentTypes }) {
    const paginationContainer = document.createElement("div");
    paginationContainer.className = "pagination-container";
    paginationContainer.style.display = "flex";
    paginationContainer.style.flexWrap = "wrap";

    const prevButton = document.createElement("button");
    prevButton.className = "pagination-button";
    prevButton.textContent = "上一页";
    prevButton.disabled = currentPage === 1;
    prevButton.addEventListener("click", () => {
      if (currentPage > 1) {
        displayMedia({ fileData, attachmentTypes, itemsPerPage, page: currentPage - 1 });
      }
    });
    paginationContainer.appendChild(prevButton);

    const paginationList = document.createElement("div");
    paginationList.className = "pagination-list";
    paginationList.style.display = "flex";
    paginationList.style.flexWrap = "wrap";
    paginationList.style.margin = "0 10px";
    for (let i = 1; i <= totalPages; i++) {
      const pageButton = document.createElement("button");
      pageButton.className = "pagination-button";
      pageButton.textContent = i;
      pageButton.style.margin = "2px";
      pageButton.style.padding = "5px 10px";
      pageButton.style.border = "none";
      pageButton.style.borderRadius = "3px";
      pageButton.style.cursor = "pointer";
      pageButton.style.backgroundColor = i === currentPage ? "#0033cc" : "#e0e0e0";
      pageButton.style.color = i === currentPage ? "#ffffff" : "#000000";

      pageButton.addEventListener("click", () => {
        displayMedia({ fileData, attachmentTypes, itemsPerPage, page: i });
      });

      paginationList.appendChild(pageButton);
    }
    paginationContainer.appendChild(paginationList);

    const nextButton = document.createElement("button");
    nextButton.className = "pagination-button";
    nextButton.textContent = "下一页";
    nextButton.disabled = currentPage === totalPages;
    nextButton.addEventListener("click", () => {
      if (currentPage < totalPages) {
        displayMedia({ fileData, attachmentTypes, itemsPerPage, page: currentPage + 1 });
      }
    });
    paginationContainer.appendChild(nextButton);

    // 将分页控件作为兄弟元素添加到现有的 container 之后
    cardContainer.insertAdjacentElement('afterend', paginationContainer);
    console.log('分页控件添加到 DOM 中');
  }

  function createMediaElement(file, attachmentTypes) {
    let mediaElement;
    const fileExtension = path.extname(file.path).split('.').pop().toLowerCase();
    if (attachmentTypes.includes(fileExtension)) {
      if (fileExtension === "mp4") {
        mediaElement = document.createElement("video");
        mediaElement.src = app.vault.getResourcePath(file);
        mediaElement.className = "media-element";
        mediaElement.controls = true;
      } else {
        mediaElement = document.createElement("img");
        mediaElement.className = "image-element";
        mediaElement.src = app.vault.getResourcePath(file);
        // 实现懒加载
        mediaElement.loading = 'lazy';
        mediaElement.addEventListener("click", () => {
          openMediaInModal(mediaElement.src);
        });
      }
    }
    return mediaElement;
  }

  function createSearchButton(file) {
    const searchButton = document.createElement("button");
    searchButton.innerHTML = "🔍";
    searchButton.className = "search-button";
    searchButton.style.position = "absolute";
    searchButton.style.bottom = "10px";
    searchButton.style.right = "10px";
    searchButton.addEventListener("click", () => {
      const searchQuery = encodeURIComponent(file.name);
      const searchUrl = `obsidian://search?vault=${encodeURIComponent(app.vault.getName())}&query="${searchQuery}"`;
      window.open(searchUrl, '_blank');
    });
    return searchButton;
  }

  function openMediaInModal(src) {
    const modalOverlay = document.createElement("div");
    modalOverlay.className = "media-modal-overlay";

    let mediaElement;
    if (src.endsWith(".mp4")) {
      mediaElement = document.createElement("video");
      mediaElement.controls = true;
    } else {
      mediaElement = document.createElement("img");
    }
    mediaElement.src = src;
    modalOverlay.appendChild(mediaElement);

    modalOverlay.addEventListener("click", (event) => {
      if (event.target === modalOverlay) {
        document.body.removeChild(modalOverlay);
        window.isModalOpen = false;
      }
    });

    document.body.appendChild(modalOverlay);
  }

  function getFilePath(files, baseName) {
    let files2 = files.filter(f => path.basename(f.path).replace(".md", "") === path.basename(baseName).replace(".md", ""));
    let filePath = files2.map((f) => f.path);
    return filePath[0];
  }

  function getMediaPathsbyFolderPath(files, folderPath, attachmentTypes, isolatedFile = true) {
    const selectFiles = folderPath === "./"
      ? files
      : files.filter(file => file.path.startsWith(`${folderPath}`));
    let allImgs = [];

    for (const file of selectFiles) {
      const cache = app.metadataCache.getFileCache(file);
      if (!cache) continue;

      let embeds = [];
      let links = [];

      const noteName = path.basename(file.path, path.extname(file.path));
      if (cache.embeds) {
        embeds = cache.embeds.map(e => ({
          link: e.link,
          position: e.position,
          noteName: noteName
        }));
      }
      if (cache.links) {
        links = cache.links.map(l => ({
          link: l.link,
          position: l.position,
          noteName: noteName
        }));
      }

      const allLinks = [...embeds, ...links];

      const media = allLinks.filter(link => {
        const fileExtension = path.extname(link.link).split('.').pop();
        return attachmentTypes.includes(fileExtension);
      });
      // console.log(`媒体文件: ${media}`);
      media.forEach(i => {
        const imgPath = getFilePath(files, i.link);
        if (imgPath) {
          allImgs.push({
            imgPath,
            notePath: file.path,
            position: i.position,
            noteName: i.noteName
          });
        }
      });

      // 检查文件本身是否是图片文件
      const fileExtension = path.extname(file.path).split('.').pop();
      if (attachmentTypes.includes(fileExtension) && isolatedFile) {
        allImgs.push({
          imgPath: file.path,
          notePath: file.path,
          position: null,
          noteName: noteName
        });
      }
    }

    const uniqueMedia = [...new Set(allImgs.map(JSON.stringify))].map(JSON.parse);
    console.log(`找到的唯一图片数量: ${uniqueMedia.length}`);
    return uniqueMedia;
  }

  function getMediaPathsbyMarkdwonPath(files, attachmentTypes) {
    // 获取当前活动文件和缓存的元数据
    const file = app.workspace.getActiveFile();
    if (!file) {
      console.error("无法获取当前活动文件");
      return [];
    }

    const cachedMetadata = app.metadataCache.getFileCache(file);
    if (!cachedMetadata) {
      console.error("无法获取文件缓存");
      return [];
    }

    // 提取链接和嵌入的文件
    const allLinks = [
      ...(cachedMetadata.links || []).map(l => l.link),
      ...(cachedMetadata.embeds || []).map(e => e.link)
    ];

    const selectFiles = allLinks
      .map(note => getFilePath(files, note))
      .filter(Boolean);
    selectFiles.push(file.path);

    const allImgs = selectFiles.flatMap(filePath => {
      const file = app.vault.getFileByPath(filePath);
      const cache = app.metadataCache.getFileCache(file);
      if (!cache) return [];

      const noteName = path.basename(filePath, path.extname(filePath));
      const allFileLinks = [
        ...(cache.embeds || []).map(e => ({
          link: e.link,
          position: e.position,
          noteName: noteName
        })),
        ...(cache.links || []).map(l => ({
          link: l.link,
          position: l.position,
          noteName: noteName
        }))
      ];

      return allFileLinks
        .filter(link => attachmentTypes.includes(path.extname(link.link).slice(1)))
        .map(i => {
          const imgPath = getFilePath(files, i.link);
          return imgPath ? {
            imgPath,
            notePath: filePath,
            position: i.position,
            noteName: i.noteName
          } : null;
        })
        .filter(Boolean);
    });

    const uniqueMedia = [...new Set(allImgs.map(JSON.stringify))].map(JSON.parse);
    console.log(`找到的唯一图片数量: ${uniqueMedia.length}`);
    return uniqueMedia;
  }
};

参考资料

  1. QuickAdd脚本-移动子笔记或附件到当前文件夹

讨论

若阁下有独到的见解或新颖的想法,诚邀您在文章下方留言,与大家共同探讨。



反馈交流

其他渠道

版权声明