Dataviewjs 单篇笔记可视化编辑查询表格

效果演示

VID_20260109_154340

更新

1.10 增加可选参数:目标文件路径 tarPath
1.12 增加可选参数,表格处理操作
可设置筛选、排序、条件样式等
1.13 表格处理操作增加:添加行/列等
可实现类似公式的功能

用法示例


```dataviewjs
await dv.view("JS/单篇可视化编辑表格", {
  tarPath: "水果", // 指定目标文档,可用相对路径,默认当篇文档
  tableHead: "水果", // 指定表头,默认当前文档名
  headingLv: 3, // 指定目标标题级别,默认0全部标题
  $process: data=>data
    .addCss("数量", v=>v>=5, "font-weight:bold;") // 条件样式
    .addColor("颜色", v=>v==="红", "red") // 条件颜色
    .addFormat("单价", v=>v>=1, "**") //条件格式
    //.filter("好吃", v=>v) // 筛选
    .colsOrder([0, "数量", "单价"], true) // 列顺序
    .colsHide("大小") // 隐藏列
    .addCol("总价", (_,r)=>r[1]*r[2]) // 添加列
    .sort("总价") // 排序
    .reverse() // 逆序
    .setValue("葡萄", "好吃", ()=>false) // 设置单元格值
    .addRow("总和", (_,c)=>c.reduce((r,c)=>r+=c)) // 添加行
    .setHead(5, "花费") // 设置列名
})
```


### 苹果
[颜色:: 红]
[大小:: 中]
[数量:: 5]
[单价:: 1]
[好吃:: false]

### 葡萄
[颜色:: 紫]
[大小:: 小]
[数量:: 8]
[单价:: 0.8]
[好吃:: true]

### 西瓜
[颜色:: 绿]
[大小:: 大]
[数量:: 1]
[单价:: 2.5]
[好吃:: true]

### 草莓
[颜色:: 红]
[大小:: 小]
[数量:: 13]
[单价:: 1.2]
[好吃:: true]

js 脚本


/**
 * 用法: await dv.view("JS/单篇可视化编辑表格", input)
 * 
 * 为方便表格实时修改,不支持多个同名标题,不支持同标题下多个同名元数据
 */

// 配置参数
const CONFIG = {
  // 目标文件路径,默认为当前文件路径;自动转为绝对路径
  tarPath: input?.tarPath ?? undefined,
  // 表头,默认为文件名
  tableHead: input?.tableHead ?? undefined,
  // 指定标题级别 (1-6);null 或 0 表示处理所有级别的标题
  headingLv: input?.headingLv ?? 0,
  // 是否启用类型转换
  enableTypeConversion: input?.enableTypeConversion ?? true,
  // 表格处理操作(筛选、排序、条件样式等)
  // !!!错误使用可能导致文档被意外修改!!!
  process: input?.$process ?? (data=>data)
}


/**
 * 主函数 - 执行数据库查询和渲染
 */
async function renderDatabase() {
  try {
    // 1. 获取文件元数据
    const tarData = CONFIG.tarPath
      ? dv.page(CONFIG.tarPath)
      : dv.current()
    CONFIG.tarPath = tarData.file.path
    CONFIG.tableHead ??= CONFIG.tarData.file.name
    const fileMeta = await app.metadataCache.getCache(CONFIG.tarPath)
    if (!fileMeta?.headings) {
      dv.span("目标文档缺少标题或无法读取")
      return
    }

    // 2. 过滤标题
    const headingsList = filterHeadings(fileMeta.headings, CONFIG.headingLv)
    if (headingsList.length === 0) {
      const levelMsg = CONFIG.headingLv ? `级别 ${CONFIG.headingLv}` : "所有级别"
      dv.span(`目标文档没有找到${levelMsg}的标题`)
      return
    }

    // 3. 读取并解析内容
    const tarFile = await app.vault.getFileByPath(CONFIG.tarPath)
    if (!tarFile) {
      dv.span("目标文档未找到")
      return
    }
    const content = await app.vault.cachedRead(tarFile)
    const matches = getMetadata(content.split('\n'), headingsList, CONFIG.headingLv)
    if (matches.length === 0) {
      dv.span("未找到有效的元数据")
      return
    }

    // 4. 处理数据
    const tableData = toTableData(matches)
    const finalData = CONFIG.process(tableData)
    if (!(finalData instanceof TableData)) {
      dv.span("表格处理操作出错")
      return
    }

    // 5. 渲染表格
    renderTable(finalData, tarFile)
    
  } catch (e) {
    dv.span(`处理文档时出错: ${e.message}`)
    console.error(e.message, e.stack)
  }
}

/**
 * 过滤标题
 */
function filterHeadings(headings, targetLevel) {
  if (targetLevel && targetLevel >= 1 && targetLevel <= 6) {
    return headings
      .filter(k => k.level === targetLevel)
      .map(k => k.heading)
  }
  return headings.map(k => k.heading)
}

/**
 * 获取元数据
 */
function getMetadata(lines, headingsList, targetLevel) {
  const matches = []
  let currentTitle = null
  let collecting = false
  let currentMetaLines = []

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    
    if (line.trim() === '') continue
    
    const headingMatch = line.match(/^(#{1,6})\s+(.+)$/)
    
    if (headingMatch) {
      // 保存上一个标题的数据
      if (currentTitle && currentMetaLines.length > 0) {
        matches.push({ title: currentTitle, metaLines: currentMetaLines })
      }
      
      // 开始新标题
      const level = headingMatch[1].length
      const title = headingMatch[2].trim()
      
      currentTitle = title
      currentMetaLines = []
      
      // 检查是否为目标标题
      const isTargetLevel = !targetLevel || level === targetLevel
      collecting = isTargetLevel && headingsList.includes(title)
      
    } else if (collecting && isMetadataLine(line)) {
      currentMetaLines.push(line.trim())
    } else if (collecting) {
      collecting = false
    }
  }
  
  // 添加最后一个标题的数据
  if (currentTitle && currentMetaLines.length > 0) {
    matches.push({ title: currentTitle, metaLines: currentMetaLines })
  }
  
  return matches
}

/**
 * 检查是否为元数据行
 */
function isMetadataLine(line) {
  const trimmedLine = line.trim()
  const metadataPattern = /^(\s*\[.*?::.*?\])+\s*$/
  return metadataPattern.test(trimmedLine)
}

/**
 * 处理原始数据生成表格数据
 */
function toTableData(matches) {
  const tableHeads = [CONFIG.tableHead]
  const metaValues = []
  const allKeys = new Set()

  // 第一次遍历:收集所有可能的键
  matches.forEach(match => {
    match.metaLines.forEach(line => {
      extractKeyValuePairs(line).forEach(({ key }) => {
        if (key) allKeys.add(key)
      })
    })
  })
  // 构建表头
  tableHeads.push(...Array.from(allKeys))

  // 第二次遍历:填充数据
  matches.forEach(match => {
    const rowData = new Array(tableHeads.length).fill("")
    rowData[0] = `[[#${match.title}]]`
    // 解析该标题下的元数据
    const metaDict = parseMetaLines(match.metaLines)
    // 填充数据到对应列
    Object.entries(metaDict).forEach(([key, value]) => {
      const index = tableHeads.indexOf(key)
      if (index > 0) {
        rowData[index] = new Cell(match.title, key, value)
      }
    })
    metaValues.push(rowData)
  })

  return new TableData(tableHeads, metaValues)
}

/**
 * 解析多行元数据
 */
function parseMetaLines(metaLines) {
  const metaDict = {}
  metaLines.forEach(line => {
    extractKeyValuePairs(line).forEach(({ key, value }) => {
      if (!key) return
      metaDict[key] = value
    })
  })
  return metaDict
}

/**
 * 从一行中提取键值对
 */
function extractKeyValuePairs(line) {
  const kvPairs = []
  const stack = []
  let currentPart = ''
  let startIndex = -1
  let inMetadata = false

  for (let i = 0; i < line.length; i++) {
    const char = line[i]
    
    if (char === '[') {
      if (stack.length === 0) {
        startIndex = i
        currentPart = ''
        inMetadata = true
      }
      stack.push(char)
      if (stack.length > 1) currentPart += char
    } 
    else if (char === ']') {
      if (stack.length === 0) continue
      
      stack.pop()
      
      if (stack.length === 0 && inMetadata && currentPart.includes('::')) {
        const parts = currentPart.split('::')
        if (parts.length >= 2) {
          const key = parts[0].trim()
          let value = parts.slice(1).join('::').trim()
          if (key) kvPairs.push({ key, value })
        }
        inMetadata = false
      } 
      else if (stack.length > 0) {
        currentPart += char
      }
    } 
    else if (stack.length > 0) {
      currentPart += char
    }
  }
  return kvPairs
}

/**
 * 将字符串转换为相应的类型
 * 支持的类型转换:
 * * 空字符串 null
 * * 单、双、反引号包裹的字符串
 * * 函数 xxx(yyy)
 * * * 默认 dv 表达式 (evaluate)
 * * dv 表达式
 * * * 字符串应使用双引号,无法识别单引号
 * * * 支持列表、对象
 * * dv 默认类型
 */
function parse(str) {
  // 空字符串
  if (str==='') return null
  
  // 强制字符串类型:引号['"`]包裹
  const quotedStringRegex = /^(['"`])(.*)\1$/
  const quotedMatch = str.match(quotedStringRegex)
  if (quotedMatch) {
    return quotedMatch[2]
  }
  
  // 函数类型
  const funcPattern = /^([a-zA-Z_$]?[a-zA-Z0-9_$]*)\((.*)\)$/
  const match = str.match(funcPattern)
  if (match) {
    const func = new FuncValue(match[1], match[2], str)
    return func.tryCall()
  }
  
  // 检查 Dataview 表达式
  const result = dv.evaluate(str)
  if (result.successful && result.value) return result.value
  
  // dv 默认类型
  return dv.parse(str)
}

/**
 * 将值转换用于显示
 */
function toShow(value, format) {
  if (value === true) return `<input type="checkbox" checked=true disabled=true />`
  if (value === false) return `<input type="checkbox" disabled=true />`
  if (Array.isArray(value)) {
    return (format?.includes('array-csv'))
      ? value.map(v => toShow(v)).join(', ')
      : value
  }
  
  if (value instanceof FuncValue) value = String(value)
  if (typeof value === 'number') {
    value = String(value)
  }
  
  if (typeof value === 'string') {
    if (format?.includes('*')) value = `*${value}*`
    if (format?.includes('**')) value = `**${value}**`
    if (format?.includes('~~')) value = `~~${value}~~`
    if (format?.includes('==')) value = `==${value}==`
  }
  
  return value
}


/**
 * 渲染表格
 */
function renderTable(data, tarFile) {
  const { heads, rows } = data
  dv.table(heads, rows.map(row=>row.map((cell)=>{
    if (!(cell instanceof BaseCell)) return toShow(cell)
    if (!(cell instanceof Cell)) {
      if (cell?.css) {
        const el = dv.paragraph(cell.show)
        el.style.cssText += cell.cssText
        return el
      }
      return cell.show
    }
    const cellEl = dv.container.createEl("div")
    if (cell?.css) {
      cellEl.style.cssText += cell.css
    }
    
    const showEl = dv.paragraph(cell.show)
    showEl.style.height = "auto"
    showEl.style.padding = "0"
    showEl.style.margin = "0"
    const inputEl = dv.container.createEl("input", {
      "type": "text",
      "value": cell.rawV
    })
    inputEl.style.display = "none"
    inputEl.style.height = "auto"
    
    showEl.addEventListener('click', async (evt) => {
      showEl.style.display = "none"
      inputEl.style.display = "unset"
      inputEl.focus()
    })
    inputEl.addEventListener('change', async (evt) => {
      updateMetadata(tarFile, cell, inputEl.value)
      inputEl.style.display = "none"
      showEl.style.display = "unset"
    })
    inputEl.addEventListener('blur', async (evt) => {
      inputEl.style.display = "none"
      showEl.style.display = "unset"
    })
    
    cellEl.appendChild(showEl)
    cellEl.appendChild(inputEl)
    return cellEl
  })))
}

/**
 * 更新元数据
 */
async function updateMetadata(tarFile, cell, newV) {
  await app.vault.process(tarFile, content => {
    const lines = content.split('\n')
    let inTargetSection = false
    
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i]
      const isHeading = line.match(/^#{1,6}\s/)
      // 标题行处理
      if (isHeading) {
        const level = isHeading[0].trim().length
        const title = line.replace(/^#{1,6}\s+/, '').trim()
        const isTarget = (CONFIG.headingLv === 0)
          ? (title === cell.title)
          : (level === CONFIG.headingLv && title === cell.title)
        inTargetSection = isTarget
      }
      // 非标题行:检查是否应退出目标区域
      else if (inTargetSection && !isMetadataLine(line)) {
        inTargetSection = false
      }
      
      // 在目标区域内且是元数据行时进行替换
      if (inTargetSection && isMetadataLine(line)) {
        const pattern = new RegExp(`(\\[\\s*)${RegExp.escape(cell.rawK)}(\\s*::\\s*)${RegExp.escape(cell.rawV)}(\\s*\\])`)
        if (pattern.test(line)) {
          lines[i] = line.replace(pattern, `$1${cell.rawK}$2${newV}$3`)
          cell.rawV = newV
        }
      }
    }
    return lines.join('\n')
  })
}

// 执行主函数
renderDatabase()

/**
 * 表格单元格
 */
class BaseCell {
  constructor(value) {
    this._value = value
  }
  get value() {
    return this._value
  }
  get show() {
    return toShow(this.value, this?.format)
  }
}
class Cell extends BaseCell {
  title; rawK; rawV
  constructor(title, rawK, rawV) {
    super(undefined, true)
    this.title = title
    this.rawK = rawK
    this.rawV = rawV
  }
  get value() {
    return CONFIG.enableTypeConversion
      ? parse(this.rawV) : this.rawV
  }
}

/**
 * 元数据值:函数类型
 */
class FuncValue {
  constructor(funcName, args, str) {
    this.funcName = funcName
    this.args = args
    this.toString = () => str
  }
  tryCall() {
    let result
    switch (this.funcName) {
      case '':
      case 'evaluate':
        result = dv.evaluate(this.args)
        if (result.successful) return result.value
        break
      default:
        result = dv.evaluate(this.toString())
        if (result.successful) return result.value
        break
    }
    return this
  }
}

/**
 * 表格数据,实现了筛选、排序等功能
 */
class TableData {
  heads; rows
  constructor(heads, rows) {
    this.heads = heads
    this.rows = rows
  }
  /**
   * 任意处理表格,慎用
   */
  $then(dataToAny) {
    dataToAny(this)
    return this
  }
  $newCell(value) {
    return new BaseCell(value)
  }
  _forId(id, toAny) {
    if (Array.isArray(id)) {
      for (let _ of id) this._forId(_, toAny)
      return this
    }
    if (typeof id === 'string') {
      id = this.heads.indexOf(id)
    }
    if (id >= 0) toAny(id)
    return this
  }
  _forCell(id, toAny) {
    return this._forId(id, id => {
      for (let row of this.rows) {
        let cell = row[id]
        if (cell instanceof BaseCell) {
          toAny(cell)
        }
      }
    })
  }
  /**
   * 条件样式
   */
  addCss(id, valueToBool, css) {
    return this._forCell(id, cell => {
      if (valueToBool(cell.value)) {
        cell.css = (cell?.css ?? '') + css
      }
    })
  }
  addColor(id, valueToBool, color) {
    return this.addCss(id, valueToBool, `color: ${color};`)
  }
  addBackground(id, valueToBool, bg) {
    return this.addCss(id, valueToBool, `background: ${bg};`)
  }
  addFormat(id, valueToBool, format) {
    return this._forCell(id, cell => {
      if (valueToBool(cell.value)) {
        cell.format = (cell?.format)
          ? cell.format.push(format)
          : [format]
      }
    })
  }
  /**
   * 筛选
   */
  filter(id, valueToBool) {
    return this._forId(id, id => {
      this.rows = this.rows.filter(row => {
        let value = row[id]
        if (value instanceof BaseCell) {
          value = value.value
        }
        return valueToBool(value)
      })
    })
  }
  /**
   * 排序
   */
  sort(id, abToTernary) {
    return this._forId(id, id => {
      this.rows.sort((rowA, rowB) => {
        let a = rowA[id], b = rowB[id]
        if (a instanceof BaseCell) a = a.value
        if (b instanceof BaseCell) b = b.value
        return abToTernary ? abToTernary(a, b) : dv.compare(a, b)
      })
    })
  }
  reverse() {
    this.rows.reverse()
    return this
  }
  /**
   * 列顺序
   * 如果是永久调换建议直接修改第一个标题下元数据顺序
   */
  colsOrder(ids, isComplete) {
    if (!Array.isArray(ids)) ids = [ids]
    ids = ids
      .map(id => (typeof id === 'string') ? this.heads.indexOf(id) : id)
      .filter(id => id >= 0)
    if (isComplete && ids.length < this.heads.length) {
      ids = ids.concat([...this.heads.keys()].filter(i=>!ids.includes(i)))
    }
    this.rows = this.rows.map(row=>ids.map(id=>row[id]))
    this.heads = ids.map(id => this.heads[id])
    return this
  }
  colsHide(ids) {
    if (!Array.isArray(ids)) ids = [ids]
    ids = ids
      .map(id => (typeof id === 'string') ? this.heads.indexOf(id) : id)
        .filter(id => id >= 0)
    return this.colsOrder([...this.heads.keys()].filter(i=>!ids.includes(i)))
  }
  /**
   * 添加列
   * toValue: (行序号, 行, 所有行, 表头) => 单元格值
   */
  addCol(colName, toValue) {
    this.heads.push(colName)
    const table = this._table
    this.rows.forEach((row, i) =>
      row.push(new BaseCell(toValue(i, table[i], table, this.heads)))
    )
    return this
  }
  /**
   * 添加行
   * toValue: (列序号, 列, 所有列, 表头) => 单元格值
   */
  addRow(rowName, toValue) {
    const trans = this._trans
    let newRow = this.rows[0].map((_, i) =>
      new BaseCell(toValue(i, trans[i], trans, this.heads))
    )
    newRow[0] = rowName ?? newRow[0]
    this.rows.push(newRow)
    return this
  }
  /**
   * 设置值
   * toValue: (原值, 所有行, 表头) => 单元格值
   */
  setValue(rowId, colId, toValue) {
    if (typeof colId === 'string') colId = this.heads.indexOf(colId)
    const headings = this._headings
    if (typeof rowId === 'string') rowId = headings.indexOf(`[[#${rowId}]]`)
    if (rowId === -1) rowId = headings.indexOf(rowId)
    if (colId>=0 && rowId>=0) {
      let value = this.rows[rowId][colId]
      if (value instanceof BaseCell) value = value.value
      const table = this._table
      this.rows[rowId][colId] = new BaseCell(toValue(value, table, this.heads))
    }
    return this
  }
  setHead(id, head) {
    if (typeof id === 'string') id = this.heads.indexOf(id)
    this.heads[id] = head
    return this
  }
  /**
   * 纯值表格,不含表头
   */
  get _table() {
    return this.rows.map(row=>row.map(cell =>
      (cell instanceof BaseCell) ? cell.value : cell
    ))
  }
  /**
   * 纯值转置表格,不含表头
   */
  get _trans() {
    return this.rows[0].map((_,i)=>this.rows.map(row =>
      (row[i] instanceof BaseCell) ? row[i].value : row[i]
    ))
  }
  get _headings() {
    return this.rows.map(row => row[0])
  }
}

参考

【DV脚本】在单篇笔记里实现类 Notion Database 的轻量数据库
obsidian常用api汇总

2 个赞

感谢啊,可用。。。

谢谢,页内数据库使用更方便了

然后我的显示第一列标题总是偏上不居中,其他属性值的都是居中
ai认为问题: 单元格偏上显示的问题是由于表格第一行(标题行)的单元格被当作文本处理,而不是特殊的标题单元格导致的。在Dataview中,表格的标题行应该通过dv.header()来渲染,但目前代码中所有单元格都是用普通dv.paragraph()渲染的
让ai改了2小时了,还没改成功,但也在这边来反馈一下

我修改元数据的写法换符号‹属性:: 属性值›(避免与any-block冲突)

/**
 * 为方便表格实时修改,不支持多个同名标题,不支持同标题下多个同名元数据
 * 
 * 更新:
 * 1.10 增加可选参数,目标文件路径 tarPath
 * 1.12 增加可选参数,表格处理操作
 * * 可设置筛选、排序、条件样式等
 * 修改元数据的写法,换符号‹属性:: 属性值›
 */

// 配置参数
const CONFIG = {
  // 目标文件路径,默认为当前文件路径;自动转为绝对路径
  tarPath: input?.tarPath ?? undefined,
  // 表头,默认为文件名
  tableHead: input?.tableHead ?? undefined,
  // 数组展示模式,为列表(list)或是逗号(csv)
  arrayDisplayMode: input?.arrayDisplayMode ?? "list",
  // 指定标题级别 (1-6);null 或 0 表示处理所有级别的标题
  headingLv: input?.headingLv ?? 0,
  // 是否启用类型转换
  enableTypeConversion: input?.enableTypeConversion ?? true,
  // 表格处理操作(筛选、排序、条件样式等)
  // !!!错误使用可能导致文档被意外修改!!!
  process: input?.$process ?? (data=>data)
}

/**
 * 主函数 - 执行数据库查询和渲染
 */
async function renderDatabase() {
  try {
    // 1. 获取文件元数据
    const tarData = CONFIG.tarPath
      ? dv.page(CONFIG.tarPath)
      : dv.current()
    CONFIG.tarPath = tarData.file.path
    CONFIG.tableHead ??= CONFIG.tarData.file.name
    const fileMeta = await app.metadataCache.getCache(CONFIG.tarPath)
    if (!fileMeta?.headings) {
      dv.span("目标文档缺少标题或无法读取")
      return
    }

    // 2. 过滤标题
    const headingsList = filterHeadings(fileMeta.headings, CONFIG.headingLv)
    if (headingsList.length === 0) {
      const levelMsg = CONFIG.headingLv ? `级别 ${CONFIG.headingLv}` : "所有级别"
      dv.span(`目标文档没有找到${levelMsg}的标题`)
      return
    }

    // 3. 读取并解析内容
    const tarFile = await app.vault.getFileByPath(CONFIG.tarPath)
    if (!tarFile) {
      dv.span("目标文档未找到")
      return
    }
    const content = await app.vault.cachedRead(tarFile)
    const matches = getMetadata(content.split('\n'), headingsList, CONFIG.headingLv)
    if (matches.length === 0) {
      dv.span("未找到有效的元数据")
      return
    }

    // 4. 处理数据
    const tableData = toTableData(matches)
    const finalData = CONFIG.process(tableData)
    if (!(finalData instanceof TableData)) {
      dv.span("表格处理操作出错")
      return
    }

    // 5. 渲染表格
    renderTable(finalData, tarFile)
    
  } catch (e) {
    dv.span(`处理文档时出错: ${e.message}`)
  }
}

/**
 * 过滤标题
 */
function filterHeadings(headings, targetLevel) {
  if (targetLevel && targetLevel >= 1 && targetLevel <= 6) {
    return headings
      .filter(k => k.level === targetLevel)
      .map(k => k.heading)
  }
  return headings.map(k => k.heading)
}

/**
 * 获取元数据
 */
function getMetadata(lines, headingsList, targetLevel) {
  const matches = []
  let currentTitle = null
  let collecting = false
  let currentMetaLines = []

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    
    if (line.trim() === '') continue
    
    const headingMatch = line.match(/^(#{1,6})\s+(.+)$/)
    
    if (headingMatch) {
      // 保存上一个标题的数据
      if (currentTitle && currentMetaLines.length > 0) {
        matches.push({ title: currentTitle, metaLines: currentMetaLines })
      }
      
      // 开始新标题
      const level = headingMatch[1].length
      const title = headingMatch[2].trim()
      
      currentTitle = title
      currentMetaLines = []
      
      // 检查是否为目标标题
      const isTargetLevel = !targetLevel || level === targetLevel
      collecting = isTargetLevel && headingsList.includes(title)
      
    } else if (collecting && isMetadataLine(line)) {
      currentMetaLines.push(line.trim())
    } else if (collecting) {
      collecting = false
    }
  }
  
  // 添加最后一个标题的数据
  if (currentTitle && currentMetaLines.length > 0) {
    matches.push({ title: currentTitle, metaLines: currentMetaLines })
  }
  
  return matches
}

/**
 * 检查是否为元数据行 - 修改为尖括号语法
 */
function isMetadataLine(line) {
  const trimmedLine = line.trim()
  // 将方括号模式改为尖括号模式
  const metadataPattern = /^(\s*‹.*?::.*?›)+\s*$/
  return metadataPattern.test(trimmedLine)
}

/**
 * 处理原始数据生成表格数据
 */
function toTableData(matches) {
  const tableHeads = [CONFIG.tableHead]
  const metaValues = []
  const allKeys = new Set()

  // 第一次遍历:收集所有可能的键
  matches.forEach(match => {
    match.metaLines.forEach(line => {
      extractKeyValuePairs(line).forEach(({ key }) => {
        if (key) allKeys.add(key)
      })
    })
  })
  // 构建表头
  tableHeads.push(...Array.from(allKeys))

  // 第二次遍历:填充数据
  matches.forEach(match => {
    const rowData = new Array(tableHeads.length).fill("")
    rowData[0] = `[[#${match.title}]]`
    // 解析该标题下的元数据
    const metaDict = parseMetaLines(match.metaLines)
    // 填充数据到对应列
    Object.entries(metaDict).forEach(([key, value]) => {
      const index = tableHeads.indexOf(key)
      if (index > 0) {
        rowData[index] = new Cell(match.title, key, value)
      }
    })
    metaValues.push(rowData)
  })

  return new TableData(tableHeads, metaValues)
}

/**
 * 解析多行元数据
 */
function parseMetaLines(metaLines) {
  const metaDict = {}
  metaLines.forEach(line => {
    extractKeyValuePairs(line).forEach(({ key, value }) => {
      if (!key) return
      metaDict[key] = value
    })
  })
  return metaDict
}

/**
 * 从一行中提取键值对 - 修改为尖括号语法
 */
function extractKeyValuePairs(line) {
  const kvPairs = []
  const stack = []
  let currentPart = ''
  let startIndex = -1
  let inMetadata = false

  for (let i = 0; i < line.length; i++) {
    const char = line[i]
    
    // 将 '[' 改为 '‹'
    if (char === '‹') {
      if (stack.length === 0) {
        startIndex = i
        currentPart = ''
        inMetadata = true
      }
      stack.push(char)
      if (stack.length > 1) currentPart += char
    } 
    // 将 ']' 改为 '›'
    else if (char === '›') {
      if (stack.length === 0) continue
      
      stack.pop()
      
      if (stack.length === 0 && inMetadata && currentPart.includes('::')) {
        const parts = currentPart.split('::')
        if (parts.length >= 2) {
          const key = parts[0].trim()
          let value = parts.slice(1).join('::').trim()
          if (key) kvPairs.push({ key, value })
        }
        inMetadata = false
      } 
      else if (stack.length > 0) {
        currentPart += char
      }
    } 
    else if (stack.length > 0) {
      currentPart += char
    }
  }
  return kvPairs
}

/**
 * 将字符串转换为相应的类型
 * 支持的类型转换:
 * * 空字符串 null
 * * 单、双、反引号包裹的字符串
 * * 函数 xxx(yyy)
 * * * 默认 dv 表达式 (evaluate)
 * * dv 表达式
 * * * 字符串应使用双引号,无法识别单引号
 * * * 支持列表、对象
 * * dv 默认类型
 */
function parse(str) {
  // 空字符串
  if (str==='') return null
  
  // 强制字符串类型:引号['"`]包裹
  const quotedStringRegex = /^(['"`])(.*)\1$/
  const quotedMatch = str.match(quotedStringRegex)
  if (quotedMatch) {
    return quotedMatch[2]
  }
  
  // 函数类型
  const funcPattern = /^([a-zA-Z_$]?[a-zA-Z0-9_$]*)\((.*)\)$/
  const match = str.match(funcPattern)
  if (match) {
    const func = new FuncValue(match[1], match[2], str)
    return func.tryCall()
  }
  
  // 检查 Dataview 表达式
  const result = dv.evaluate(str)
  if (result.successful && result.value) return result.value
  
  // dv 默认类型
  return dv.parse(str)
}

/**
 * 将值转换用于显示
 */
function toShow(value) {
  if (Array.isArray(value)) {
    if (CONFIG.arrayDisplayMode === 'csv') {
      return value.map(v => toShow(v)).join(', ')
    } else {
      return value
    }
  }
  if (value instanceof FuncValue) return String(value)
  
  if (value === true) return `<input type="checkbox" checked=true disabled=true />`
  if (value === false) return `<input type="checkbox" disabled=true />`
  
  return value
}

/**
 * 渲染表格
 */
function renderTable(data, tarFile) {
  const { heads, rows } = data
  dv.table(heads, rows.map(row=>row.map((cell)=>{
    if (!(cell instanceof Cell)) return cell
    const cellEl = dv.container.createEl("div")
    if (cell?.css) {
      cellEl.style.cssText += cell.css
    }
    
    const showEl = dv.container.createEl("div")
    const showText = CONFIG.enableTypeConversion 
      ? toShow(parse(cell.rawV))
      : cell.rawV
    showEl.appendChild(dv.paragraph(showText))
    
    const inputEl = dv.container.createEl("input", {
      "type": "text",
      "value": cell.rawV
    })
    inputEl.style.display = "none"
    inputEl.style.height = "auto"
    
    showEl.addEventListener('click', async (evt) => {
      showEl.style.display = "none"
      inputEl.style.display = "unset"
      inputEl.focus()
    })
    inputEl.addEventListener('change', async (evt) => {
      updateMetadata(tarFile, cell, inputEl.value)
      inputEl.style.display = "none"
      showEl.style.display = "unset"
    })
    inputEl.addEventListener('blur', async (evt) => {
      inputEl.style.display = "none"
      showEl.style.display = "unset"
    })
    
    cellEl.appendChild(showEl)
    cellEl.appendChild(inputEl)
    return cellEl
  })))
}

/**
 * 更新元数据 - 修改为尖括号语法
 */
async function updateMetadata(tarFile, cell, newV) {
  await app.vault.process(tarFile, content => {
    const lines = content.split('\n')
    let inTargetSection = false
    
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i]
      const isHeading = line.match(/^#{1,6}\s/)
      // 标题行处理
      if (isHeading) {
        const level = isHeading[0].trim().length
        const title = line.replace(/^#{1,6}\s+/, '').trim()
        const isTarget = (CONFIG.headingLv === 0)
          ? (title === cell.title)
          : (level === CONFIG.headingLv && title === cell.title)
        inTargetSection = isTarget
      }
      // 非标题行:检查是否应退出目标区域
      else if (inTargetSection && !isMetadataLine(line)) {
        inTargetSection = false
      }
      
      // 在目标区域内且是元数据行时进行替换
      if (inTargetSection && isMetadataLine(line)) {
        // 将方括号模式改为尖括号模式
        const pattern = new RegExp(`(‹\\s*)${RegExp.escape(cell.rawK)}(\\s*::\\s*)${RegExp.escape(cell.rawV)}(\\s*›)`)
        if (pattern.test(line)) {
          lines[i] = line.replace(pattern, `$1${cell.rawK}$2${newV}$3`)
          cell.rawV = newV
        }
      }
    }
    return lines.join('\n')
  })
}

// 执行主函数
renderDatabase()

/**
 * 表格单元格
 */
class Cell {
  constructor(title, rawK, rawV) {
    this.title = title
    this.rawK = rawK
    this.rawV = rawV
  }
}

/**
 * 元数据值:函数类型
 */
class FuncValue {
  constructor(funcName, args, str) {
    this.funcName = funcName
    this.args = args
    this.toString = () => str
  }
  tryCall() {
    let result
    switch (this.funcName) {
      case '':
      case 'evaluate':
        result = dv.evaluate(this.args)
        if (result.successful) return result.value
        break
      default:
        result = dv.evaluate(this.toString())
        if (result.successful) return result.value
        break
    }
    return this
  }
}

/**
 * 表格数据,实现了筛选、排序等功能
 */
class TableData {
  constructor(heads, rows) {
    this.heads = heads
    this.rows = rows
  }
  /**
   * 条件样式
   */
  addCss(id, valueToBool, css) {
    if (id instanceof String) {
      id = this.heads.indexOf(id)
    }
    if (id >= 0) {
      this.rows.forEach(row => {
        let cell = row[id]
        if (cell instanceof Cell) {
          if (valueToBool(cell.rawV)) {
            cell.css = (cell?.css ?? '') + css
          }
        }
      })
    }
    return this
  }
  addColor(id, valueToBool, color) {
    return this.addCss(id, valueToBool, `color: ${color};`)
  }
  addBackground(id, valueToBool, bg) {
    return this.addCss(id, valueToBool, `background: ${bg};`)
  }
  /**
   * 筛选
   */
  filter(id, valueToBool) {
    if (id instanceof String) {
      id = this.heads.indexOf(id)
    }
    if (id >= 0) {
      this.rows = this.rows.filter(row => {
        let value = row[id]
        if (value instanceof Cell) {
          value = value.rawV
        }
        return valueToBool(value)
      })
    }
    return this
  }
  /**
   * 排序
   */
  sort(id, abToTernary) {
    if (id instanceof String) {
      id = this.heads.indexOf(id)
    }
    if (id >= 0) {
      this.rows.sort((rowA, rowB) => {
        let a = rowA[id], b = rowB[id]
        if (a instanceof Cell) {
          a = a.rawV
        }
        if (b instanceof Cell) {
          b = b.rawV
        }
        function defaultCompare(a, b) {
          const x = String(a), y = String(b)
          if (x < y) return -1
          if (x > y) return 1
          return 0
        }
        return abToTernary ? abToTernary(a, b) : defaultCompare(a, b)
      })
    }
    return this
  }
  /**
   * 列顺序
   * 如果是永久调换建议直接修改第一个标题下元数据顺序
   */
  colsOrder(ids, isComplete) {
    ids = ids
      .map(id => (id instanceof String) ? this.heads.indexOf(id) : id)
      .filter(id => id >= 0)
    if (isComplete && ids.length < this.heads.length) {
      ids = ids.concat([...this.heads.keys()].filter(i=>!ids.includes(i)))
    }
    this.rows = this.rows.map(row=>ids.map(id=>row[id]))
    this.heads = ids.map(id => this.heads[id])
    return this
  }
  colsHide(ids) {
    return this.colsOrder([...this.heads.keys()].filter(i=>!ids.includes(i)))
  }
  /**
   * 转置
   */
  _trans() {
    return this.rows[0].map((_,i)=>this.rows.map(row=>row[i]))
  }
}

关于 ab 与 dv 插件冲突,ab 作者给出了解决方案,按照他的解决方案,我创建了一个分支,目前使用正常。
用尖括号的问题是无法被 dv 识别,也不能被 dv 的渲染。

至于你说的居中的问题,在我的效果演示中是正常的,可能是因为你的主题或者 css 代码片段的问题。建议先用 css 代码片段改改。

  • 有点看不懂什么意思 :joy:
  • 我使用你的笔记案例,替换为尖括号后,再js代码修改为ai改过的,能得到和你的一样的结果
  • ai说你的代码还能跨文件使用,我还没尝试,不知道改后能不能用
  • 但之前确实有其他js代码单独为文件,然后dv.view()引用不能工作

  • 解决方案看起来是自己修改ab的代码,关闭ab的功能
  • 分支点进去好像就是ab的地址?

我没有用主题,刚刚CSS全关了,还是我截图那样偏上的

就是我演示中元数据的样式没有了。
分支是我修改后ab地址

贴一个改用尖括号的笔记内容:


```dataviewjs
await dv.view("页内数据库_可编辑", {
  tableHead:"水果",
  headingLv:3
})
```

### 苹果
‹颜色:: **红**›  ‹大小:: 中›  ‹数量:: 中›  ‹好吃:: true›

### 葡萄
‹颜色:: 紫›  ‹大小:: 小›  ‹数量:: 多›  ‹好吃:: true›

### 西瓜
‹颜色:: 绿›  ‹大小:: 大›  ‹数量:: 少›  ‹好吃:: true›

中括号效果
IMG_20260112_195912

尖括号效果

已修复了,现在在原版无主题无css无其它插件情况下测试正常