跳转到内容

Eidos File 格式与自定义视图

.eidos 是一种开放、local-first 的多维表格文件:它是标准 SQLite 数据库,加上带版本的 Eidos 元数据。数据、字段定义、已保存的筛选/排序和视图配置都在同一个文件里;UI 不会写进 文件,因此其他应用无需运行 Eidos 也可以读取其中的数据。

本文描述 @eidos.space/eidos-file@eidos.space/eidos-file-ui 的公开应用集成边界。

如果你正在决定应用应该 import 哪些模块,请先阅读 使用 Eidos File UI 构建编辑器;本文继续讲解持久化数据与 renderer contract。

tasks.eidos (SQLite)
├─ 用户表与记录
├─ Eidos metadata 数据表、字段、派生定义与 feature
├─ eidos__views type、query_json、layout_json、position
└─ 格式身份 PRAGMA application_id/user_version 与 eidos__meta
@eidos.space/eidos-file 校验、查询、分组、写入
EidosFileEditorDataSource worker/RPC adapter;React 主线程不运行 SQLite
@eidos.space/eidos-file-ui Grid、Gallery、Kanban 或宿主注册的 renderer

完整 metadata schema 由 Eidos File Format 1.0 定义。Reader 通过 SQLite header、 PRAGMA application_idPRAGMA user_version 与 singleton eidos__meta row 识别格式, 不能只根据扩展名猜测。

UI 包不要复制这些表,也不要自行把视图筛选编译成 SQL。标识符校验、query 语义、cursor paging、字段 codec 和 mutation 都由 @eidos.space/eidos-file 负责。这样浏览器、 Desktop 和未来宿主才能安全编辑同一份文件。

eidos__views.type 是开放字符串;query_json 保存 Runtime query,layout_json 保存 UI layout。Runtime 将它们暴露为 filtersortspropertiesorderMaphiddenFields。Eidos 内置 gridgallerykanban,Host 也可以保存 timeline 等新 类型。即使当前 Host 无法渲染,仍必须保留未知 View type 与 layout member。

Runtime 将视图暴露为 EidosFileViewInfo

interface EidosFileViewInfo {
id: string
name: string
type: string
tableId: string
properties: Record<string, unknown> | null
filter: EidosFileFilterGroup | null
sorts: EidosFileSort[]
orderMap: Record<string, number> | null
hiddenFields: string[]
// 此处省略 timestamp 与 portable query 文本
}

内置 renderer 使用以下 properties:

视图PropertiesRuntime 操作
GridfreezeColumnsfieldWidthMapcolumnStats分页记录,以及 record/field mutation
GallerycardSizecoverPreviewfitContenthideEmptyFields带 projection 的 cursor page,以及 inspector 完整记录
Kanban必需的 groupByField,以及 Gallery 的 card propertiescountRowsByField、每组独立分页与记录写入

应通过 runtime 创建自定义视图,而不是直接修改元数据 SQL:

runtime.createView("tasks", {
name: "Schedule",
type: "timeline",
properties: {
startField: "starts_at",
endField: "ends_at",
zoom: "week",
},
sorts: [{ field: "starts_at", direction: "asc" }],
})

properties 必须可以被 JSON 序列化。若多个集成会使用相同 type,实验字段应带命名空间, 例如 acme.timelineVersion

React renderer 只消费 EidosFileEditorDataSource。浏览器或 Electron 宿主应将调用转发给自己的 Eidos File runtime worker:

const source: EidosFileEditorDataSource = {
getSnapshot: () => worker.call({ type: "snapshot" }),
getPage: (tableId, offset, limit, query, totalHint, cursor, projection) =>
worker.call({
type: "page",
tableId,
offset,
limit,
query,
totalHint,
cursor,
projection,
}),
getRow: (tableId, rowId) => worker.call({ type: "row", tableId, rowId }),
getGroupCounts: (tableId, columnName, query) =>
worker.call({ type: "group-counts", tableId, columnName, query }),
insertRow: (tableId, row) =>
worker.call({ type: "insert-row", tableId, row }),
updateRow: (tableId, rowId, changes) =>
worker.call({ type: "update-row", tableId, rowId, changes }),
// field 与 view mutation 也采用相同方式转发。
}

getGroupCounts 必须调用 runtime 的 countRowsByField,不能先拉取所有记录再在 JavaScript 中分组。getPage 应原样转发 opaque cursor 和 EidosFileRowPageProjection。Gallery/Kanban 因此 无需全表读取,昂贵的 SQLite 工作也不会阻塞 React 主线程。

全局引入一次公共样式,在 EidosFileUIProvider 中渲染,然后按持久化的 type 注册 renderer:

import { useEffect, useState } from "react"
import type { EidosFileRow } from "@eidos.space/eidos-file"
import {
EidosFileEditorView,
EidosFileUIProvider,
type EidosFileViewRenderer,
} from "@eidos.space/eidos-file-ui"
import "@eidos.space/eidos-file-ui/styles.css"
const TimelineView: EidosFileViewRenderer = ({
source,
table,
view,
query,
reloadToken,
onError,
}) => {
const [rows, setRows] = useState<EidosFileRow[]>([])
const startField =
typeof view?.properties?.startField === "string"
? view.properties.startField
: null
useEffect(() => {
let active = true
source
.getPage(table.table.id, 0, 100, query, undefined, undefined, {
columns: startField ? [startField] : [],
})
.then((page) => {
if (active) setRows(page.rows)
})
.catch(onError)
return () => {
active = false
}
}, [onError, query, reloadToken, source, startField, table.table.id])
if (!startField) return <p>请为 Timeline 选择开始时间字段。</p>
return (
<ol aria-label={view?.name ?? "Timeline"}>
{rows.map((row) => (
<li key={String(row._id)}>
<time>{String(row[startField] ?? "未安排")}</time>
{String(row.title ?? "未命名")}
</li>
))}
</ol>
)
}
const renderers = { timeline: TimelineView }
export function TasksEidosFile({ source, table, view }) {
return (
<EidosFileUIProvider themeName="light">
<EidosFileEditorView
source={source}
table={table}
view={view}
renderers={renderers}
/>
</EidosFileUIProvider>
)
}

EidosFileEditorView 会先把临时 search 与保存的 filter、sorts 合并,再将 query 传给 renderer。 宿主只有注册相同 type key 时才会覆盖内置视图。找不到 renderer 时,UI 会诚实显示 unsupported 状态,同时不修改文件中的视图元数据。

  • 使用稳定的 _id 作为 React 与 drag-and-drop identity。
  • 数据必须分页;cell edit 后不要重新拉取整张表。
  • Card 使用 projection,只有打开 record inspector 时才获取完整记录。
  • 分组计数和筛选留在 runtime。Kanban 按组独立加载,移动记录后只刷新受影响的组。
  • 成功写入后调用 onMutation,让宿主更新 row count、dirty/recovery/save state。
  • Optimistic UI 必须可恢复。失败时应保留可重试的内存修改,或显式 rollback,并把错误传给 onError
  • 自定义 renderer 不得直接写 SQLite 文件。文件锁、外部冲突检测、export 和持久化写回属于 host adapter。

Renderer registry 面向可信应用代码或可信 embedder,它会在宿主 React 进程中执行。它不是 沙箱 Extension SDK 的 contribution point;安装任意 extension 不能自动获得 registry 权限。 不受信任或可分发的 view extension 需要隔离的 UI surface 和显式 capability bridge。在该契约 稳定之前,请将自定义 renderer 保留在宿主应用代码中。