第一周作业:对JSONEditor的二次开发

JSONEditor 的优点与局限

JSONEditor适合承担各类json编辑工作,其优点在于结构展示清晰、编辑方式直观、集成成本较低,并且便于对关键字段进行只读控制;但其本质上仍是json编辑工具,它在资产管理上仍有不足,例如在编辑大规模知识图谱时,用户往往难以快速找到并对应到需要编辑的具体节点,因此需要额外配合搜索与高亮定位机制来弥补这一不足。

代码引用与改良

该项目提供的是一个可以直接写于html前端模块,因此我通过对前端内容的重新组装,并搭建一个python服务端来补充功能。

引用JsonEditor

其中,对JsonEditor实例的引用为:

function initJsonEditor() {
      const container = document.getElementById("jsoneditor");

      editor = new JSONEditor(container, {
        mode: "tree",
        modes: ["tree", "form", "view", "code"],
        mainMenuBar: false,
        navigationBar: false,
        statusBar: false,
        onEditable: function(node) {
          if (currentSelection.type !== "node") return true;
          const field = node.field;
          if (field === "id" || field === "table" || field === "entityType") {
            return { field: false, value: false };
          }
          return true;
        },
        onError: function(error) {
          console.error(error);
          setEditorStatus("编辑器错误:" + error.message, true);
        }
      });

      editor.set({ message: "请先在左侧选择一个节点" });
    }

1、去除了上方搜索栏,单独制作搜索

mainMenuBar: false,
navigationBar: false,
statusBar: false,

2、保留了图谱中必要的保留字段

if (field === "id" || field === "table" || field === "entityType") {
    return { field: false, value: false };
}

改良搜索

我总体使用levenshtein模糊搜索算法。通过修改前端页面、编写KuzuDB查询函数、编写服务端HTTP端口来实现这种效果

1、前端页面部分代码

新编写的搜索栏:

<div class="graph-tools">
  <input id="searchInput" class="search-input" type="text" placeholder="按 name 模糊搜索节点..." />
  <button id="btnSearch">搜索</button>
  <button id="btnClearSearch">清空搜索</button>
  <div id="searchResults" class="search-results" style="display:none;"></div>
</div>

与后端搜索的绑定:

async function searchNodes() {
  const keyword = searchInput.value.trim();
  if (!keyword) {
    setGraphStatus("请输入搜索关键词。", true);
    return;
  }

  try {
    const results = await apiGet(`/api/search?keyword=${encodeURIComponent(keyword)}`);
    renderSearchResults(results);
    setGraphStatus(`搜索完成,共找到 ${results.length} 个候选结果。`);
  } catch (error) {
    console.error(error);
    setGraphStatus("搜索失败:" + error.message, true);
  }
}

搜索完成后高亮闪烁对应结点:

function renderSearchResults(results) {
  if (!results || results.length === 0) {
    searchResultsEl.style.display = "block";
    searchResultsEl.innerHTML = `<div class="search-item">没有搜索到结果。</div>`;
    return;
  }

  searchResultsEl.style.display = "block";
  searchResultsEl.innerHTML = results.map(item => `
    <div class="search-item" data-node-id="${escapeHtml(item.id)}" data-table="${escapeHtml(item.table)}">
      ${escapeHtml(item.name)} | ${escapeHtml(item.table)} | id=${escapeHtml(item.id)} | 距离=${item.distance}
    </div>
  `).join("");

  searchResultsEl.querySelectorAll(".search-item").forEach(el => {
    el.addEventListener("click", async () => {
      const nodeId = el.getAttribute("data-node-id");
      const table = el.getAttribute("data-table");

      if (network && visNodes.get(nodeId)) {
        network.selectNodes([nodeId]);
        network.focus(nodeId, {
          scale: 1.2,
          animation: {
            duration: 600,
            easingFunction: "easeInOutQuad"
          }
        });
      }

      startBlinkHighlight(nodeId);
      await handleNodeSelect(table, nodeId);
      setGraphStatus(`已定位搜索结果:${table} / ${nodeId}`);
    });
  });
}

2、后端搜索功能实现

路由/api/search的调用如下:

def _api_search(self, query: str):
    params = parse_qs(query)
    keyword = self._get_required_query_param(params, "keyword")

    client = KuzuClient()
    data = client.search_nodes_by_name(keyword)
    json_response(self, 200, {"ok": True, "data": data})

client.search_nodes_by_name函数的具体实现如下:

def search_nodes_by_name(self, keyword: str, limit: int = 20) -> List[Dict[str, Any]]:
    if not isinstance(keyword, str) or not keyword.strip():
        return []

    keyword = keyword.strip()
    results: List[Dict[str, Any]] = []

    for table in self.get_node_tables():
        columns = self.get_table_columns(table)
        if "id" not in columns or "name" not in columns:
            continue

        cypher = f"MATCH (n:{table}) RETURN n.id AS id, n.name AS name;"
        rows = self.fetch_all_dict(cypher)

        for row in rows:
            node_id = row.get("id")
            name = row.get("name")

            if not isinstance(node_id, str):
                continue
            if not isinstance(name, str):
                continue

            distance = self._levenshtein(keyword.lower(), name.lower())
            results.append({
                "id": node_id,
                "name": name,
                "table": table,
                "distance": distance
            })

    results.sort(key=lambda x: (x["distance"], len(x["name"]), x["name"]))
    return results[:limit]


def _levenshtein(self, a: str, b: str) -> int:
    if a == b:
        return 0
    if len(a) == 0:
        return len(b)
    if len(b) == 0:
        return len(a)

    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, start=1):
        curr = [i]
        for j, cb in enumerate(b, start=1):
            cost = 0 if ca == cb else 1
            curr.append(min(
                prev[j] + 1,
                curr[j - 1] + 1,
                prev[j - 1] + cost
            ))
        prev = curr
    return prev[-1]

效果展示

总体效果

下图为本项目加载了一个图数据库的后的效果。左侧为图谱的总览图,左上为我添加的搜索功能。右侧为编辑页面,右上为JsonEditor自带的编辑框,右下为各类图编辑功能(下列截图没有翻到底,底部有更多我的图谱编辑功能)

特别功能演示

模糊搜索功能

1、搜索时按照levenshtein距离找到结点

2、通过搜索选中或直接选中的结点与相关关系会高亮闪烁,让编辑者更明显地找到相关结点。

json编辑功能

此为JSONEditor项目的自带功能。以温度结点为例,entityType、table、id均是只读,而min_temp等则可编辑(选中后可以看到黄色输入框)

图谱编辑功能

有常规的添加、删除各种类结点、关系的功能。通过配合搜索更便于找到内容。

github仓库地址

https://github.com/ApocalypseAPO/kuzu-graph-editor