見出し画像

タグ検索をもとに添付ファイル一覧を表示するObsidian Dataviewスクリプト

【追記】ノートごとに検索条件を保存できるようにした。
【追記2】スクリプトをすべて表示させないと走らないのが面倒。手間だが別のノートに埋め込めば即座に表示される。

発想

一次資料をPDF++でハイライトしながらノートにまとめていたが、そうすると複数のノートからリンクされる資料があることに気づく。このような資料を、特定のタグで絞り込んで一覧表示できたら、vault内にある資料をジャンルごとに把握することが簡単にできそうだと思った。副次的には、重複やサイズの大きなファイルの発見にも繋がる。
現状のObsidianでは添付ファイルに直接タグ付けできない(それ用のノートを作れば良いが)ため、Dataviewを利用した運用になった。

概要

.md以外のファイル、すなわち画像やpdfといった、添付ファイルの一覧をDataviewで表示する。当初はBasesに取り組んでいたが、「特定のタグを持つノートにリンクしている添付ファイルの一覧」を取得することが、現状では難しそうだった。

DataviewJSスクリプトは、注文を入れつつChatGPT 5に書いてもらった。
使用するには、Dataview pluginの設定からJavascriptを有効にする。

Dataview設定

使い方の説明は特に不要と思う。PDF高さの設定が想定ではないが動いているので良し。

Custom attachment location pluginで、添付ファイルをリンクさせたノート名と同じ名前のフォルダを作りそこに格納しているので、異なる環境では上手く動かない可能性がある。そのような環境では添付ファイル一覧を確認するのが難しかったので、このスクリプトを使うインセンティブが生まれたとも言える。

DataviewJSスクリプト

```dataviewjs
/********************************************************************
 * Obsidian DataviewJS: Tagged notes → referenced files browser
 * v11
 * - 追加: ノートごとに検索条件を保存(LocalStorage をノート単位キーで分離)
 *         - このノートの条件は NOTE_KEY に自動保存(検索/リセット/列トグル時など)
 *         - 「この条件を全体デフォルトにする」ボタンで DEFAULTS を更新可能
 * - 既存: 列の表示/非表示、拡張子グルーピング、ソート昇降順・表示上限、
 *         タグAND/OR/NOT、拡張子指定、md除外、グリッド/テーブル、サムネ/PDF FitH、
 *         途中一致サジェスト、MB表示、重複レンダリング防止、対象ノート数表示
 ********************************************************************/

(async () => {
  // ---------- Keys (ノート単位で保存) ----------
  const BASE_KEY      = "dvjs-ref-files-browser:v11";
  const DEFAULTS_KEY  = `${BASE_KEY}::__defaults__`;
  const CUR_PATH      = dv?.current()?.file?.path ?? "";
  const NOTE_KEY      = `${BASE_KEY}::note::${encodeURIComponent(CUR_PATH)}`;

  const container = dv.container; clear(container);
  const appCtx = app;

  // ---------- helpers ----------
  const normTag = (t) => {
    if (!t) return "";
    const s = String(t).trim();
    return (s.startsWith("#") ? s : "#" + s).toLowerCase();
  };
  const splitCsv = (s) =>
    String(s || "").replace(/,/g, ",").split(",").map(x => x.trim()).filter(Boolean);
  const uniq = (arr) => [...new Set(arr)];
  const bytesToMB = (n) => (typeof n === "number" && !isNaN(n)) ? (n / 1048576).toFixed(2) : "";
  function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
  const fileNameNoExt = (p) => { const b = (p.split("/").pop() || p); const i = b.lastIndexOf("."); return i >= 0 ? b.slice(0, i) : b; };

  function makeLinktext(anyPath) {
    const raw = String(anyPath || "");
    const dest = appCtx.metadataCache.getFirstLinkpathDest(raw, CUR_PATH || "/");
    const finalPath = dest?.path ?? raw;
    return appCtx.metadataCache.fileToLinktext(finalPath, CUR_PATH || "/") || finalPath;
  }
  function aInternal(path, text) {
    const lt = makeLinktext(path);
    const a = document.createElement("a");
    a.className = "internal-link";
    a.href = lt;
    a.setAttribute("data-href", lt);
    a.textContent = text ?? lt;
    a.addEventListener("click", (e) => { e.preventDefault(); appCtx.workspace.openLinkText(lt, CUR_PATH, e.ctrlKey || e.metaKey); });
    return a;
  }
  const getRes = (file) => {
    try { return appCtx.vault.getResourcePath(file); }
    catch { return appCtx.vault.adapter.getResourcePath(file.path); }
  };
  const normalizeLinkTarget = (link) => String(link || "").split("|")[0].split("#")[0];

  // ---- tag gathering (冗長統合) ----
  function gatherTagsFromCache(path) {
    const cache = appCtx.metadataCache.getCache(path) || {};
    const out = new Set();
    if (Array.isArray(cache.tags)) for (const t of cache.tags) if (t?.tag) out.add(normTag(t.tag));
    const fm = cache.frontmatter || {};
    const cands = [fm.tags, fm.tag];
    for (const cand of cands) {
      if (!cand) continue;
      if (Array.isArray(cand)) for (const v of cand.flat()) if (v) out.add(normTag(String(v)));
      else if (typeof cand === "string") for (const v of cand.split(/[, ]+/)) if (v) out.add(normTag(v));
    }
    return out;
  }
  function gatherTagsFromDv(path) {
    const pg = dv.page(path);
    const out = new Set();
    const addAll = (xs) => Array.isArray(xs) && xs.forEach(v => v && out.add(normTag(String(v))));
    addAll(pg?.tags);
    addAll(pg?.file?.tags);
    const fmTags = pg?.frontmatter?.tags ?? pg?.tags;
    if (typeof fmTags === "string") fmTags.split(/[, ]+/).forEach(v => v && out.add(normTag(v)));
    else addAll(fmTags);
    return out;
  }
  function collectAllTags(path) { return new Set([...gatherTagsFromCache(path), ...gatherTagsFromDv(path)]); }
  function noteTitle(path) { const p = dv.page(path); return p?.title || fileNameNoExt(path); }

  // ---------- state ----------
  const defaultColumns = { thumb: true, name: true, ext: true, size: true, refcount: true, path: true, sources: true };
  const DEFAULT_STATE = {
    tagsAND: "", tagsOR: "", tagsNOT: "",
    exts: "png,jpg,jpeg,webp,pdf",
    view: "grid",
    sort: "source",
    sortDir: "asc",
    limit: 10,
    groupBy: "none",    // "none" | "ext"
    columns: defaultColumns,
    thumbHeight: 180,
    pdfFitHeight: true
  };

  // load order: built-in defaults -> global defaults -> note-specific
  function loadJSON(key) {
    try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : null; } catch { return null; }
  }
  function saveJSON(key, val) {
    try { localStorage.setItem(key, JSON.stringify(val)); } catch {}
  }

  const globalDefaults = { ...DEFAULT_STATE, ...(loadJSON(DEFAULTS_KEY) || {}) };
  let state = { ...globalDefaults, ...(loadJSON(NOTE_KEY) || {}) };
  // columns マイグレーション
  state.columns = { ...defaultColumns, ...(state.columns || {}) };

  const saveNote = () => saveJSON(NOTE_KEY, state);
  const saveAsGlobalDefaults = () => saveJSON(DEFAULTS_KEY, state);

  // ---------- UI ----------
  const ui = container.createEl("div", { cls: "dvjs-ui" });
  const row1 = ui.createEl("div", { cls: "row grid2" });
  const row2 = ui.createEl("div", { cls: "row grid3" });
  const row3 = ui.createEl("div", { cls: "row opt" });
  const rowCols = ui.createEl("div", { cls: "row cols" });
  const row4 = ui.createEl("div", { cls: "row sum" });
  const results = container.createEl("div", { cls: "dvjs-results" });

  const style = document.createElement("style");
  style.textContent = `
  .dvjs-ui{display:grid;gap:.5rem;margin-bottom:.5rem}
  .row{display:grid;gap:.5rem}
  .grid2{grid-template-columns:1fr 1fr}
  .grid3{grid-template-columns:1fr 1fr 1fr}
  .opt{grid-template-columns:repeat(11,max-content);gap:.75rem;align-items:end}
  .cols{grid-template-columns:repeat(auto-fill,minmax(140px,max-content));align-items:center}
  .fld{display:grid;gap:.25rem}
  .fld>label{font-size:.85em;opacity:.8}
  .txt,.sel,.num{padding:.3rem .5rem;border:1px solid var(--background-modifier-border);border-radius:6px;background:var(--background-secondary)}
  .btn{padding:.35rem .6rem;border:1px solid var(--background-modifier-border);border-radius:6px;background:var(--interactive-normal);cursor:pointer}
  .btn:hover{background:var(--interactive-hover)}
  .sum{font-size:.9em;opacity:.85}
  .sum small{opacity:.75}
  .dvjs-results{margin-top:.25rem}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:.75rem}
  .card{border:1px solid var(--background-modifier-border);border-radius:10px;padding:.5rem;display:grid;gap:.35rem;background:var(--background-secondary)}
  .thumb{width:100%;display:grid;place-items:center;border:1px dashed var(--background-modifier-border);border-radius:8px;overflow:hidden;background:var(--background-primary)}
  .thumb img,.thumb embed{width:100%;height:var(--th,180px);object-fit:contain;border:0}
  table.dvjs-t{width:100%;border-collapse:collapse}
  .dvjs-t th,.dvjs-t td{border-bottom:1px solid var(--background-modifier-border);padding:.4rem .5rem;vertical-align:top}
  .thumbcell{width:220px}
  /* サジェスト */
  .dvjs-suggest{position:absolute;z-index:9999;background:var(--background-primary);
    border:1px solid var(--background-modifier-border);border-radius:6px;box-shadow:var(--shadow-s);
    max-height:220px;overflow:auto}
  .dvjs-suggest-item{padding:.3rem .5rem;cursor:pointer}
  .dvjs-suggest-item:hover,.dvjs-suggest-item.active{background:var(--background-modifier-hover)}
  /* グループ見出し */
  .dvjs-group{display:grid;gap:.4rem;margin-top:.75rem}
  .dvjs-group-title{font-weight:600;opacity:.9;border-left:3px solid var(--interactive-accent);padding-left:.5rem}
  `;
  container.appendChild(style);

  // inputs
  const extsInp  = fld(row1, "対象拡張子(カンマ区切り)", "input", { class: "txt", placeholder: "例: png,jpg,jpeg,webp,pdf" }); extsInp.value = state.exts;
  const viewSel  = fld(row1, "表示形式", "select", null, [["grid","グリッド"],["table","テーブル"]]); viewSel.value = state.view;

  const andInp   = fld(row2, "タグ(AND / すべて含む)", "input", { class: "txt", placeholder: "#tag1, #tag2" }); andInp.value = state.tagsAND;
  const orInp    = fld(row2, "タグ(OR / いずれか含む)", "input", { class: "txt", placeholder: "#tag3, #tag4" }); orInp.value = state.tagsOR;
  const notInp   = fld(row2, "タグ(NOT / 含まない)", "input", { class: "txt", placeholder: "#tag5, #tag6" }); notInp.value = state.tagsNOT;

  const sortSel     = fld(row3, "ソート", "select", null, [["source","参照元順"],["ext","拡張子順"],["size","サイズ順"],["name","ファイル名順"],["refcount","参照数順"]]); sortSel.value = state.sort;
  const sortDirSel  = fld(row3, "順序", "select", null, [["asc","昇順"],["desc","降順"]]); sortDirSel.value = state.sortDir;
  const groupSel    = fld(row3, "グルーピング", "select", null, [["none","なし"],["ext","拡張子"]]); groupSel.value = state.groupBy;
  const thNum       = fld(row3, "サムネ高さ(px)", "number", { class: "num", min: "80", step: "10" }); thNum.value = String(state.thumbHeight);
  const limitNum    = fld(row3, "表示上限", "number", { class: "num", min: "1", step: "1" }); limitNum.value = String(state.limit);
  const pdfWrap     = row3.createEl("div", { cls: "fld" }); pdfWrap.createEl("label", { text: "PDFを高さにフィット" }); const pdfChk = pdfWrap.createEl("input", { type: "checkbox" }); pdfChk.checked = !!state.pdfFitHeight;

  const btnApply    = row3.createEl("button", { cls: "btn", text: "検索(このノートに保存)" });
  const btnReset    = row3.createEl("button", { cls: "btn", text: "リセット(既定に戻す)" });
  const btnMakeDef  = row3.createEl("button", { cls: "btn", text: "この条件を全体デフォルトにする" });

  const sumDiv   = row4.createEl("div");

  // --- 列トグル ---
  rowCols.createEl("div", { text: "表示列", cls: "fld" });
  const columnsMeta = [
    { key: "thumb", label: "サムネ" },
    { key: "name",  label: "ファイル名" },
    { key: "ext",   label: "拡張子" },
    { key: "size",  label: "サイズ" },
    { key: "refcount", label: "参照数" },
    { key: "path",  label: "ファイルパス" },
    { key: "sources", label: "参照元" }
  ];
  const colInputs = {};
  for (const c of columnsMeta) {
    const wrap = rowCols.createEl("label", { cls: "fld" });
    const cb = wrap.createEl("input", { type: "checkbox" });
    cb.checked = !!state.columns[c.key];
    wrap.appendChild(document.createTextNode(" " + c.label));
    colInputs[c.key] = cb;
    cb.addEventListener("change", async () => {
      state.columns[c.key] = !!cb.checked; saveNote(); await run();
    });
  }

  // --- Tag suggest(途中一致対応) ---
  const allTags = (() => {
    const set = new Set();
    try { Object.keys(appCtx.metadataCache.getTags?.() ?? {}).forEach(k => set.add(normTag(k))); } catch {}
    try { dv.pages().forEach(p => collectAllTags(p.file.path).forEach(t => set.add(t))); } catch {}
    return [...set].sort();
  })();
  attachSuggest(andInp, allTags); attachSuggest(orInp, allTags); attachSuggest(notInp, allTags);

  btnApply.addEventListener("click", async () => { pull(); saveNote(); await run(); });
  btnReset.addEventListener("click", async () => {
    // 全体デフォルト + ビルトインに戻す(このノートに保存)
    state = { ...DEFAULT_STATE, ...(loadJSON(DEFAULTS_KEY) || {}) };
    state.columns = { ...defaultColumns, ...(state.columns || {}) };
    pushToUI(); saveNote(); await run();
  });
  btnMakeDef.addEventListener("click", async () => {
    pull(); saveAsGlobalDefaults();
    // 既定を更新しても今見ているノートの状態は変えない。必要なら再検索
  });

  // 初期描画
  await run();

  // ---------- fn ----------
  function fld(parent, label, type, attrs, opts) {
    const w = parent.createEl("div", { cls: "fld" }); w.createEl("label", { text: label });
    let el;
    if (type === "select") { el = w.createEl("select", { class: "sel" }); (opts || []).forEach(([v, t]) => { const o = document.createElement("option"); o.value = v; o.textContent = t; el.appendChild(o); }); }
    else if (type === "number") { el = w.createEl("input", { type: "number", ...(attrs || {}) }); }
    else { el = w.createEl("input", { type: "text", ...(attrs || {}) }); }
    return el;
  }
  function pull() {
    state.exts = extsInp.value.trim();
    state.view = viewSel.value;
    state.tagsAND = andInp.value.trim();
    state.tagsOR = orInp.value.trim();
    state.tagsNOT = notInp.value.trim();
    state.sort = sortSel.value;
    state.sortDir = sortDirSel.value;
    state.groupBy = groupSel.value;
    state.thumbHeight = Math.max(80, parseInt(thNum.value || "180", 10));
    state.limit = Math.max(1, parseInt(limitNum.value || "10", 10));
    state.pdfFitHeight = !!pdfChk.checked;
    for (const c of columnsMeta) state.columns[c.key] = !!colInputs[c.key].checked;
  }
  function pushToUI() {
    extsInp.value = state.exts; viewSel.value = state.view;
    andInp.value = state.tagsAND; orInp.value = state.tagsOR; notInp.value = state.tagsNOT;
    sortSel.value = state.sort; sortDirSel.value = state.sortDir; groupSel.value = state.groupBy;
    thNum.value = String(state.thumbHeight); limitNum.value = String(state.limit);
    pdfChk.checked = !!state.pdfFitHeight;
    for (const c of columnsMeta) colInputs[c.key].checked = !!state.columns[c.key];
  }
  function attachSuggest(inputEl, tags) {
    let dd = null, idx = -1;
    const close = () => { dd?.remove(); dd = null; idx = -1; };
    const place = () => { if (!dd) { dd = document.createElement("div"); dd.className = "dvjs-suggest"; document.body.appendChild(dd); } const r = inputEl.getBoundingClientRect(); dd.style.left = `${r.left + window.scrollX}px`; dd.style.top = `${r.bottom + window.scrollY}px`; dd.style.minWidth = `${r.width}px`; };
    const render = (items) => { if (!dd) place(); dd.innerHTML = ""; items.slice(0, 100).forEach((t, i) => { const it = document.createElement("div"); it.className = "dvjs-suggest-item" + (i === idx ? " active" : ""); it.textContent = t; it.addEventListener("mousedown", (e) => { e.preventDefault(); commit(t); }); dd.appendChild(it); }); if (!items.length) close(); };
    const parts = () => { const list = inputEl.value.split(",").map(s => s.trim()); return { list, i: list.length - 1, token: list[list.length - 1] || "" }; };
    const commit = (t) => { const { list, i } = parts(); list[i] = t; inputEl.value = list.join(", "); close(); inputEl.focus(); inputEl.selectionStart = inputEl.selectionEnd = inputEl.value.length; };
    const update = () => {
      const { token } = parts();
      const raw = (token || "").trim();
      if (!raw) { close(); return; }
      const q = raw.toLowerCase();
      const items = tags.filter(t => {
        const full = t.toLowerCase();
        const nohash = full.replace(/^#/, "");
        return full.includes(q) || nohash.includes(q);
      });
      if (items.length) { place(); idx = 0; render(items); } else close();
    };
    inputEl.addEventListener("input", update);
    inputEl.addEventListener("blur", () => setTimeout(close, 120));
    inputEl.addEventListener("keydown", (e) => {
      if (!dd) return;
      const items = [...dd.querySelectorAll(".dvjs-suggest-item")];
      if (e.key === "ArrowDown") { e.preventDefault(); idx = Math.min(items.length - 1, idx + 1); items.forEach((el, i) => el.classList.toggle("active", i === idx)); }
      else if (e.key === "ArrowUp") { e.preventDefault(); idx = Math.max(0, idx - 1); items.forEach((el, i) => el.classList.toggle("active", i === idx)); }
      else if (e.key === "Enter" && idx >= 0) { e.preventDefault(); const t = items[idx]?.textContent; if (t) commit(t); }
      else if (e.key === "Escape") { close(); }
    });
  }

  function summarize({ andS, orS, notS, extS, view, sort, sortDir, groupBy, th, pdf, pages, totalFiles, shown, limit, colsText }) {
    const scope = `保存先: このノート(${decodeURIComponent(NOTE_KEY.split("::").slice(-1)[0])})`;
    sumDiv.innerHTML =
      `検索条件|AND: ${escapeHtml(andS || "(なし)")}|OR: ${escapeHtml(orS || "(なし)")}|NOT: ${escapeHtml(notS || "(なし)")}|` +
      `拡張子: ${escapeHtml(extS || "(指定なし→md以外)")}|表示: ${view}|ソート: ${sort}|順序: ${sortDir === "asc" ? "昇順" : "降順"}|` +
      `グルーピング: ${groupBy === "ext" ? "拡張子" : "なし"}|表示列: ${escapeHtml(colsText)}|` +
      `サムネ高さ: ${th}px|PDF高さフィット: ${pdf ? "ON" : "OFF"}|対象ノート数: ${pages}|` +
      `結果ファイル数: ${totalFiles}(表示: ${shown} / 上限: ${limit})` +
      `<br><small>${scope}</small>`;
  }
  function escapeHtml(s){ return String(s).replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }

  function pageMatches(path, AND, OR, NOT) {
    const tags = collectAllTags(path);
    if (AND.length && !AND.every(t => tags.has(normTag(t)))) return false;
    if (OR.length && !OR.some(t => tags.has(normTag(t)))) return false;
    if (NOT.length && NOT.some(t => tags.has(normTag(t)))) return false;
    return true;
  }

  function sortFiles(arr, key, dirStr) {
    const dir = (dirStr === "desc") ? -1 : 1;
    const getter = ((k) => {
      switch (k) {
        case "ext": return (x) => (x.ext || "").toLowerCase();
        case "size": return (x) => x.sizeBytes ?? 0;
        case "name": return (x) => (x.file?.name || "").toLowerCase();
        case "refcount": return (x) => x.refCount ?? 0;
        case "source":
        default: return (x) => x.firstSeenIndex ?? 0;
      }
    })(key);
    return arr.sort((a, b) => {
      const A = getter(a), B = getter(b);
      if (A < B) return -1 * dir;
      if (A > B) return  1 * dir;
      return 0;
    });
  }

  function groupItems(items, groupBy) {
    if (groupBy !== "ext") return [{ key: "", label: "", items }];
    const map = new Map();
    for (const it of items) {
      const k = (it.ext || "").toLowerCase();
      if (!map.has(k)) map.set(k, []);
      map.get(k).push(it);
    }
    const groups = [...map.entries()].map(([k, arr]) => ({ key: k, label: k.toUpperCase(), items: arr }));
    groups.sort((a, b) => a.key.localeCompare(b.key));
    return groups;
  }

  async function run() {
    const AND = splitCsv(state.tagsAND).map(normTag);
    const OR  = splitCsv(state.tagsOR ).map(normTag);
    const NOT = splitCsv(state.tagsNOT).map(normTag);
    let exts  = uniq(splitCsv(state.exts).map(s => s.toLowerCase().replace(/^\./, "")));
    const restrict = exts.length > 0;

    clear(results);
    const all   = dv.pages().array();
    const pages = all.filter(p => pageMatches(p.file.path, AND, OR, NOT));

    const fileMap = new Map();
    let idx = 0;

    for (const page of pages) {
      const cache = appCtx.metadataCache.getCache(page.file.path) || {};
      const links = [...(cache.links ?? []), ...(cache.embeds ?? [])];
      for (const L of links) {
        const raw = normalizeLinkTarget(L.link);
        if (!raw) continue;
        const dest = appCtx.metadataCache.getFirstLinkpathDest?.(raw, page.file.path);
        if (!dest || dest.extension?.toLowerCase?.() === "md") continue;
        const ext = String(dest.extension || "").toLowerCase();
        if (restrict && !exts.includes(ext)) continue;

        let e = fileMap.get(dest.path);
        if (!e) {
          e = { file: dest, ext, sizeBytes: null, refCount: 0, sources: new Set(), firstSeenIndex: idx++ };
          fileMap.set(dest.path, e);
        }
        e.refCount++;
        e.sources.add(page.file.path);
      }
    }

    const entries = [...fileMap.values()];
    await Promise.all(entries.map(async e => {
      try { const st = await appCtx.vault.adapter.stat(e.file.path); e.sizeBytes = st?.size ?? null; }
      catch { e.sizeBytes = null; }
    }));

    // sort & limit
    sortFiles(entries, state.sort, state.sortDir);
    const totalFiles = entries.length;
    const limited = entries.slice(0, Math.max(1, state.limit));

    // summary
    const colsText = ["thumb","name","ext","size","refcount","path","sources"]
      .filter(k => state.columns[k]).map(k => ({thumb:"サムネ",name:"ファイル名",ext:"拡張子",size:"サイズ",refcount:"参照数",path:"ファイルパス",sources:"参照元"}[k]))
      .join("、") || "(なし)";

    summarize({
      andS: AND.join(", "), orS: OR.join(", "), notS: NOT.join(", "),
      extS: exts.join(", "), view: state.view, sort: state.sort, sortDir: state.sortDir,
      groupBy: state.groupBy, th: state.thumbHeight, pdf: state.pdfFitHeight, pages: pages.length,
      totalFiles, shown: limited.length, limit: state.limit, colsText
    });

    // render
    const groups = groupItems(limited, state.groupBy);
    if (state.groupBy === "none") {
      if (state.view === "table") renderTable(groups[0].items);
      else renderGrid(groups[0].items);
    } else {
      for (const g of groups) {
        const sect = results.createEl("div", { cls: "dvjs-group" });
        sect.createEl("div", { cls: "dvjs-group-title", text: `拡張子: ${g.label}(${g.items.length}件)` });
        if (state.view === "table") renderTable(g.items, sect);
        else renderGrid(g.items, sect);
      }
    }
  }

  function renderGrid(items, mount) {
    const root = mount || results;
    const grid = root.createEl("div", { cls: "grid" });
    grid.style.setProperty("--th", `${state.thumbHeight}px`);
    for (const it of items) {
      const card = grid.createEl("div", { cls: "card" });

      if (state.columns.thumb) {
        const tw = card.createEl("div", { cls: "thumb" });
        renderThumb(tw, it);
      }
      if (state.columns.name || state.columns.ext) {
        const top = card.createEl("div");
        if (state.columns.name) top.appendChild(aInternal(it.file.path, it.file.name));
        if (state.columns.ext) top.appendChild(document.createTextNode(state.columns.name ? `  (${it.ext.toUpperCase()})` : it.ext.toUpperCase()));
      }
      if (state.columns.size || state.columns.refcount) {
        const sz = card.createEl("div");
        const parts = [];
        if (state.columns.size) parts.push(`サイズ: ${bytesToMB(it.sizeBytes)} MB`);
        if (state.columns.refcount) parts.push(`参照: ${it.refCount}`);
        sz.textContent = parts.join(" / ");
      }
      if (state.columns.path) {
        const pathDiv = card.createEl("div");
        pathDiv.appendChild(aInternal(it.file.path, it.file.path));
      }
      if (state.columns.sources) {
        const srcDiv = card.createEl("div");
        srcDiv.appendText("参照元: ");
        const list = srcDiv.createEl("span");
        const srcs = [...it.sources].sort((a, b) => a.localeCompare(b));
        srcs.forEach((p, i) => { list.appendChild(aInternal(p, noteTitle(p))); if (i < srcs.length - 1) list.appendText("、"); });
      }
    }
  }

  function renderTable(items, mount) {
    const root = mount || results;
    const t = root.createEl("table", { cls: "dvjs-t" });
    const thead = t.createEl("thead"); const trh = thead.createEl("tr");
    const heads = [
      ["thumb", "サムネ"],
      ["name", "ファイル名"],
      ["ext", "拡張子"],
      ["size", "サイズ(MB)"],
      ["refcount", "参照数"],
      ["path", "ファイルパス"],
      ["sources", "参照元ノート"]
    ].filter(([k]) => state.columns[k]);
    heads.forEach(([k, h]) => trh.createEl("th", { text: h, cls: k === "thumb" ? "thumbcell" : "" }));

    const tb = t.createEl("tbody");
    for (const it of items) {
      const tr = tb.createEl("tr");
      if (state.columns.thumb) {
        const td0 = tr.createEl("td", { cls: "thumbcell" });
        const tw = td0.createEl("div", { cls: "thumb" });
        tw.style.setProperty("--th", `${state.thumbHeight}px`);
        renderThumb(tw, it);
      }
      if (state.columns.name) { const td1 = tr.createEl("td"); td1.appendChild(aInternal(it.file.path, it.file.name)); }
      if (state.columns.ext) tr.createEl("td", { text: it.ext.toUpperCase() });
      if (state.columns.size) tr.createEl("td", { text: bytesToMB(it.sizeBytes) });
      if (state.columns.refcount) tr.createEl("td", { text: String(it.refCount) });
      if (state.columns.path) { const td5 = tr.createEl("td"); td5.appendChild(aInternal(it.file.path, it.file.path)); }
      if (state.columns.sources) {
        const td6 = tr.createEl("td");
        const srcs = [...it.sources].sort((a, b) => a.localeCompare(b));
        srcs.forEach((p, i) => { td6.appendChild(aInternal(p, noteTitle(p))); if (i < srcs.length - 1) td6.appendText("、"); });
      }
    }
  }

  function renderThumb(wrap, entry) {
    const res = getRes(entry.file);
    const ext = entry.ext.toLowerCase();
    if (["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "tiff", "tif", "avif", "heic"].includes(ext)) {
      const img = document.createElement("img"); img.src = res; img.alt = entry.file.name; wrap.appendChild(img); return;
    }
    if (ext === "pdf") {
      if (state.pdfFitHeight) { const emb = document.createElement("embed"); emb.type = "application/pdf"; emb.src = res + "#view=FitH"; wrap.appendChild(emb); }
      else { wrap.textContent = "PDF"; }
      return;
    }
    wrap.textContent = ext.toUpperCase();
  }
})();
```

いいなと思ったら応援しよう!