npm package: @movevom/dangan
Description: DangAn sidecar NDJSON (four-line) format utilities
Latest version: 0.1.0

# @movevom/dangan

DangAn（档案）是一个“sidecar NDJSON（四行 JSON Lines）”格式与工具集，用于为任意内容文件在同目录维护一个同名 `.json` 侧车文件：

- `a.jpg` → `a.jpg.json`
- `b.md` → `b.md.json`
- `c.tar.gz` → `c.tar.gz.json`

本包只提供纯逻辑（解析/格式化/合并/历史策略/sidecar key 规则）与可选的流程/Node 便捷入口：

- 默认入口 `@movevom/dangan`：跨平台纯逻辑（不绑定 Node/浏览器/小程序）
- 子入口 `@movevom/dangan/node`：Node/Electron/脚本可用的 fs adapter（核心入口仍无 Node 依赖）

核心原则：

- DangAn 只负责“内容与规则”（四行 NDJSON 的结构、合并策略、历史策略、sidecar 命名规则）
- DangAn 不负责“存储位置与权限”（文件系统 / IndexedDB / 云端 / 小程序沙盒等由宿主决定）
- sidecar 是一个逻辑概念：它可以是一个真实的 `*.json` 文件，也可以是一个“同名 key”的独立存储记录

## 安装

```bash
npm i @movevom/dangan
```

## DangAn 文件格式（四行 NDJSON）

DangAn 文件内容是 4 行，每行都是一个 JSON object（不是一个“大 JSON”），顺序固定：

1. `auto`：自动生成/确定性字段（作者、时间、字数、文件指纹等）
2. `ai`：AI（或其他非确定性生成器）写入的“当前有效版本”
3. `aiHistory`：`ai` 的版本历史列表
4. `nameHistory`：文件名/路径历史（create/rename/move）

读取方式必须按“逐行 JSON.parse”处理，不要对整个文件内容做一次 `JSON.parse(wholeText)`。

## 不同运行环境的“sidecar”落地方式

DangAn 的标准命名是“同名 + .json”。落地时分两种情况：

### A. 有真实文件系统（Desktop / Node / Electron / Tauri）

目标：在同目录创建一个真实侧车文件 `original.ext.json`。

- 读取：先读 `original.ext.json`，再按需读 `original.ext`
- 写入：原文件保存/更新后，同步更新其侧车文件
- 更名/移动：更新原文件名/路径的同时，更新侧车文件名/路径，并追加一条 `nameHistory` 事件
- 删除：原文件与侧车文件一起删除

在 Node/Electron/脚本场景可以使用子入口提供的 fs adapter（见文末）。

#### Tauri App（更详细）

Tauri 的关键点是：前端不是 Node 运行时，所以不要导入 `@movevom/dangan/node`。你仍然使用默认入口 `@movevom/dangan`，并在“宿主层”用 Tauri 的文件 API 实现存储。

推荐的两种组织方式：

- **App 管理的内容目录（推荐）**
  - 把“主内容文件 + sidecar”统一放进 app 的数据目录（例如 AppData / Application Support）
  - 好处：权限简单、跨平台一致、迁移与备份更可控
- **用户选择的外部目录**
  - 主内容文件位于用户选择的文件夹（例如文库目录）
  - sidecar 与主内容同目录：`original.ext.json`
  - 需要处理：权限授权、路径变化、同名冲突、移动/重命名

实现要点（指导性）：

- **用 adapter 把 DangAn 接到 Tauri 的读写能力上**
  - 实现 `DangAnTextAdapter`：`readText(key)` / `writeText(key, text)`
  - `key` 可以是“绝对路径”，也可以是你自己的“逻辑 key（再映射到路径）”
- **写入要尽量原子化**
  - 推荐：写到 `*.json.tmp`，再 rename 成 `*.json`
  - 避免：直接覆盖写导致半写入、或 app 崩溃留下损坏文件
- **重命名/移动需要同时迁移 sidecar**
  - 文件系统改名/移动后：sidecar 同步改名/移动
  - 追加 `nameHistory` 事件，保留可追溯链路
- **跨平台路径与大小写**
  - Windows/macOS 常见是大小写不敏感；path normalization 与冲突处理要在宿主层完成
  - 尽量不要把“路径字符串”当作唯一稳定 id；建议另存一个稳定 `id`（例如 uuid/哈希）写入 auto.id
- **扫描策略（性能）**
  - AI 先扫 sidecar：遍历目录时优先读 `*.json`，避免无谓读取大正文文件
  - 如果 sidecar 缺失：按需补齐（lazy ensure）

### B. 没有真实文件系统或权限受限（Web / 移动端 / 小程序）

目标：用“同名 key”来模拟 sidecar：

- 主文件 key：`fileKey`
- 侧车 key：`toDangAnSidecarKey(fileKey)`，等价于 `${fileKey}.json`

常见存储选择：

- IndexedDB：为 DangAn 单独建一个 store（推荐），value 保存四行文本或结构化对象
- KV 存储：例如 localStorage（不推荐大内容）、小程序 storage、移动端 KV
- 云端：用对象存储/数据库的 key 体系映射 sidecar key

重要：在这类环境里，“sidecar 是否对用户可见”完全由宿主 UI 决定。推荐做法是让 sidecar 只存在于专用存储域，不参与用户文件列表的枚举。

## 快速开始（纯逻辑）

```ts
import {
  createEmptyDangAn,
  parseDangAnText,
  formatDangAnText,
  updateAi,
  updateAuto,
  appendNameEvent
} from '@movevom/dangan'
} from '@movevom/dangan'

let doc = createEmptyDangAn({ now: Date.now(), name: 'hello.md' })

doc = updateAuto(doc, { updatedAt: Date.now(), wordCount: 123 })

doc = updateAi(
  doc,
  { model: 'my-model', keywords: ['dangAn'] },
  { now: Date.now(), maxHistoryItems: 20 }
)

doc = appendNameEvent(doc, { op: 'rename', from: 'hello.md', name: 'hello-2.md', at: Date.now() })

const text = formatDangAnText(doc)
const parsed = parseDangAnText(text)
```

## 推荐的宿主集成流程（指导性）

### 1) 创建（Create）

当宿主创建一个新内容文件/文档时：

- 生成主文件 key（路径、ID、URL、对象 key 都可以）
- 用 `createEmptyDangAn({ now, name })` 初始化
- 把确定性字段写进 `auto`（例如 createdAt/updatedAt/作者/字数/指纹等）
- 把四行文本保存到 sidecar（真实文件 `*.json` 或 sidecarKey）

### 2) 更新（Update）

当主文件内容变化时：

- 重新计算确定性字段（例如 updatedAt、wordCount、fingerprint）
- 只用 `updateAuto` 更新 `auto` 行，不要覆盖 `ai` 行
- 保存 sidecar

### 3) AI 写入（AI Update）

当 AI 产生新的 tags/keywords/description/links 等时：

- 只用 `updateAi(doc, nextAi, { now, maxHistoryItems })` 更新 `ai` 行
- 让库自动追加版本到 `aiHistory`
- 保存 sidecar

### 4) 重命名/移动（Rename/Move）

当主文件发生 rename/move 时：

- 先迁移 sidecar（文件系统改名；或更新主 key / sidecar key 的映射）
- 用 `appendNameEvent` 追加一条 nameHistory 事件
- 保存 sidecar

### 5) 读取与检索（Scan -> Decide -> Read）

当你需要“让 AI 先扫元信息再决定读正文”：

1. 枚举候选集合（按目录、标签、时间等）
2. 对每个候选先读取 sidecar，拿到 `auto/ai` 的轻量信息
3. 只对被选中的少数候选，再读取其主文件正文内容

这个流程能显著降低 token/IO，并让 AI 的选择更可控。

## 核心 API

### 解析与格式化

- `parseDangAnText(text): DangAnDoc`：容错解析（缺行/乱序/空行均可）
- `formatDangAnText(doc): string`：输出标准四行顺序
- `migrateDangAn(doc): { doc; changed }`：把结构规范化到当前 schema（用于长期演进）

### Sidecar 命名规则

- `toDangAnSidecarPath(path): string`：`${path}.json`
- `toDangAnSidecarKey(fileKey): string`：`${fileKey}.json`（适用于虚拟 key/IDB key 等）

### 合并与历史策略

- `createEmptyDangAn({ now?, auto?, ai?, name? }): DangAnDoc`
- `updateAuto(doc, patch, policy?): DangAnDoc`
  - 默认不允许在 auto 行覆盖 AI 字段；如确需覆盖可传 `policy.allowAiFieldsInAuto = true`
- `updateAi(doc, next, policy): DangAnDoc`
  - `policy.now` 必填（由宿主提供时间源）
  - 自动将新版本写入 `aiHistory.items` 并按 `maxHistoryItems` 裁剪（默认 20）
- `appendNameEvent(doc, event): DangAnDoc`

## 流程 API（带 adapter，不绑定环境）

本包提供了一个最小 “文本存储 adapter” 接口，用于把 core 逻辑串成 load/save/ensure 流程：

```ts
import type { DangAnTextAdapter } from '@movevom/dangan'
import { ensureDangAnForFile, loadDangAn, saveDangAn } from '@movevom/dangan'
```

`DangAnTextAdapter` 只要求：

- `readText(key): Promise<string | null>`
- `writeText(key, text): Promise<void>`

你可以在 Web/小程序/移动端用 IndexedDB/KV/沙盒文件 API 实现它，在桌面端用 Tauri/Electron/Node 实现它。

## Node 子入口（可选）

在 Node/Electron/脚本场景，可直接使用内置的 fs adapter：

```ts
import { createNodeFsAdapter } from '@movevom/dangan/node'
import { ensureDangAnForFile } from '@movevom/dangan'

const adapter = createNodeFsAdapter()
const { doc } = await ensureDangAnForFile(adapter, '/abs/path/to/a.jpg', {
  now: Date.now(),
  name: 'a.jpg'
})
```

注意：浏览器/小程序/受限容器不要导入 `@movevom/dangan/node`。

## 本地测试（不发布）

如果你想在另一个项目里本地测试本包而不发布到 npm，推荐使用 `npm pack` 的 tgz 安装方式。见：