Files
disknext/docs/text-editor-api.md
于小丘 eac0766e79 feat: migrate ORM base to sqlmodel-ext, add file viewers and WOPI integration
- Migrate SQLModel base classes, mixins, and database management to
  external sqlmodel-ext package; remove sqlmodels/base/, sqlmodels/mixin/,
  and sqlmodels/database.py
- Add file viewer/editor system with WOPI protocol support for
  collaborative editing (OnlyOffice, Collabora)
- Add enterprise edition license verification module (ee/)
- Add Dockerfile multi-stage build with Cython compilation support
- Add new dependencies: sqlmodel-ext, cryptography, whatthepatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:23:17 +08:00

243 lines
5.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 文本文件在线编辑 — 前端适配文档
## 概述
Monaco Editor 打开文本文件时,通过 GET 获取内容和哈希作为编辑基线;保存时用 jsdiff 计算 unified diff仅发送差异部分后端验证无并发冲突后应用 patch。
```
打开文件: GET /api/v1/file/content/{file_id} → { content, hash, size }
保存文件: PATCH /api/v1/file/content/{file_id} ← { patch, base_hash }
→ { new_hash, new_size }
```
---
## 约定
| 项目 | 约定 |
|------|------|
| 编码 | 全程 UTF-8 |
| 换行符 | 后端 GET 时统一规范化为 `\n`,前端无需处理 `\r\n` |
| hash 算法 | SHA-256hex 编码64 字符),基于 UTF-8 bytes 计算 |
| diff 格式 | jsdiff `createPatch()` 输出的标准 unified diff |
| 空 diff | 前端自行判断,内容未变时不发请求 |
---
## GET /api/v1/file/content/{file_id}
获取文本文件内容。
### 请求
```
GET /api/v1/file/content/{file_id}
Authorization: Bearer <token>
```
### 响应 200
```json
{
"content": "line1\nline2\nline3\n",
"hash": "a1b2c3d4...64字符 SHA-256 hex",
"size": 18
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `content` | string | 文件文本内容,换行符已规范化为 `\n` |
| `hash` | string | 基于规范化内容 UTF-8 bytes 的 SHA-256 hex |
| `size` | number | 规范化后的字节大小 |
### 错误
| 状态码 | 说明 |
|--------|------|
| 400 | 文件不是有效的 UTF-8 文本(二进制文件) |
| 401 | 未认证 |
| 404 | 文件不存在 |
---
## PATCH /api/v1/file/content/{file_id}
增量保存文本文件。
### 请求
```
PATCH /api/v1/file/content/{file_id}
Authorization: Bearer <token>
Content-Type: application/json
```
```json
{
"patch": "--- a\n+++ b\n@@ -1,3 +1,3 @@\n line1\n-line2\n+LINE2\n line3\n",
"base_hash": "a1b2c3d4...GET 返回的 hash"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `patch` | string | jsdiff `createPatch()` 生成的 unified diff |
| `base_hash` | string | 编辑前 GET 返回的 `hash` 值 |
### 响应 200
```json
{
"new_hash": "e5f6a7b8...64字符",
"new_size": 18
}
```
保存成功后,前端应将 `new_hash` 作为新的 `base_hash`,用于下次保存。
### 错误
| 状态码 | 说明 | 前端处理 |
|--------|------|----------|
| 401 | 未认证 | — |
| 404 | 文件不存在 | — |
| 409 | `base_hash` 不匹配(并发冲突) | 提示用户刷新,重新加载内容 |
| 422 | patch 格式无效或应用失败 | 回退到全量保存或提示用户 |
---
## 前端实现参考
### 依赖
```bash
npm install jsdiff
```
### 计算 hash
```typescript
async function sha256(text: string): Promise<string> {
const bytes = new TextEncoder().encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}
```
### 打开文件
```typescript
interface TextContent {
content: string;
hash: string;
size: number;
}
async function openFile(fileId: string): Promise<TextContent> {
const resp = await fetch(`/api/v1/file/content/${fileId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
if (resp.status === 400) throw new Error("该文件不是文本文件");
throw new Error("获取文件内容失败");
}
return resp.json();
}
```
### 保存文件
```typescript
import { createPatch } from "diff";
interface PatchResult {
new_hash: string;
new_size: number;
}
async function saveFile(
fileId: string,
originalContent: string,
currentContent: string,
baseHash: string,
): Promise<PatchResult | null> {
// 内容未变,不发请求
if (originalContent === currentContent) return null;
const patch = createPatch("file", originalContent, currentContent);
const resp = await fetch(`/api/v1/file/content/${fileId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ patch, base_hash: baseHash }),
});
if (resp.status === 409) {
// 并发冲突,需要用户决策
throw new Error("CONFLICT");
}
if (!resp.ok) throw new Error("保存失败");
return resp.json();
}
```
### 完整编辑流程
```typescript
// 1. 打开
const file = await openFile(fileId);
let baseContent = file.content;
let baseHash = file.hash;
// 2. 用户在 Monaco 中编辑...
editor.setValue(baseContent);
// 3. 保存Ctrl+S
const currentContent = editor.getValue();
const result = await saveFile(fileId, baseContent, currentContent, baseHash);
if (result) {
// 更新基线
baseContent = currentContent;
baseHash = result.new_hash;
}
```
### 冲突处理建议
当 PATCH 返回 409 时,说明文件已被其他会话修改:
```typescript
try {
await saveFile(fileId, baseContent, currentContent, baseHash);
} catch (e) {
if (e.message === "CONFLICT") {
// 方案 A提示用户提供"覆盖"和"放弃"选项
// 方案 B重新 GET 最新内容,展示 diff 让用户合并
const latest = await openFile(fileId);
// 展示合并 UI...
}
}
```
---
## hash 一致性验证
前端可以在 GET 后本地验证 hash确保传输无误
```typescript
const file = await openFile(fileId);
const localHash = await sha256(file.content);
console.assert(localHash === file.hash, "hash 不一致,内容可能损坏");
```