301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import type {
|
|
ApiNote,
|
|
ApiWorkspaceEntry,
|
|
ApiWorkspaceInfo,
|
|
ApiWorkspaceSnapshot,
|
|
FileNode,
|
|
OperationResponse,
|
|
} from '@/contracts'
|
|
import apiClient from './apiClient'
|
|
import { t } from '@/i18n'
|
|
import * as noteService from './noteService'
|
|
import { splitNoteMetadata } from '@/utils/noteMetadata'
|
|
import { contentHash, hostInvoke, isDesktop, nativePath, nativeTree, type HostDocument, type HostEntry, type HostVault } from './platform/desktop'
|
|
|
|
export interface WorkspaceAsset {
|
|
asset_id: string
|
|
path: string
|
|
content_hash: string
|
|
media_type: string
|
|
size: number
|
|
original_name: string
|
|
}
|
|
|
|
export type WorkspaceAssetSource = 'paste' | 'drop' | 'upload'
|
|
|
|
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
|
export interface VaultInfo {
|
|
vault_id: string
|
|
path: string
|
|
name: string
|
|
}
|
|
|
|
let cachedTree: FileNode[] | null = null
|
|
let treeRequestVersion = 0
|
|
const noteIdByPath = new Map<string, string>()
|
|
const typeByPath = new Map<string, FileNode['type']>()
|
|
|
|
function normalizePublicPath(path: string): string {
|
|
const normalized = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
|
return normalized ? `/${normalized}` : '/'
|
|
}
|
|
|
|
function relativePath(path: string): string {
|
|
return normalizePublicPath(path).replace(/^\//, '')
|
|
}
|
|
|
|
function toFileNode(entry: ApiWorkspaceEntry): FileNode {
|
|
const path = normalizePublicPath(entry.path)
|
|
const node: FileNode = {
|
|
id: entry.entry_id,
|
|
note_id: entry.note_id ?? undefined,
|
|
name: entry.name,
|
|
path,
|
|
type: entry.type,
|
|
is_open: false,
|
|
children: entry.type === 'folder' ? entry.children.map(toFileNode) : undefined,
|
|
}
|
|
typeByPath.set(path, entry.type)
|
|
if (entry.note_id) noteIdByPath.set(path, entry.note_id)
|
|
return node
|
|
}
|
|
|
|
function cacheEntries(entries: ApiWorkspaceEntry[]): FileNode[] {
|
|
noteIdByPath.clear()
|
|
typeByPath.clear()
|
|
cachedTree = entries.map(toFileNode)
|
|
return cachedTree
|
|
}
|
|
|
|
function nodeFromNote(note: ApiNote): FileNode {
|
|
const path = normalizePublicPath(note.file_path)
|
|
noteIdByPath.set(path, note.note_id)
|
|
typeByPath.set(path, 'file')
|
|
return {
|
|
id: note.note_id,
|
|
note_id: note.note_id,
|
|
name: path.split('/').at(-1) || note.title,
|
|
path,
|
|
type: 'file',
|
|
}
|
|
}
|
|
|
|
async function requireNoteId(filePath: string): Promise<string> {
|
|
const path = normalizePublicPath(filePath)
|
|
let noteId = noteIdByPath.get(path)
|
|
if (!noteId) {
|
|
await refreshTree()
|
|
noteId = noteIdByPath.get(path)
|
|
}
|
|
if (!noteId) throw new Error(`${t('笔记尚未建立后端索引:', 'The note has not been indexed by the backend: ')}${path}`)
|
|
return noteId
|
|
}
|
|
|
|
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
|
|
if (isDesktop()) {
|
|
const vault = (await getRecentVaults())[0]
|
|
if (!vault) throw new Error('VAULT_NOT_OPEN')
|
|
const entries = await hostInvoke<HostEntry[]>('workspace_tree')
|
|
return { ...vault, file_count: entries.length, indexed_note_count: 0, requires_refresh: false }
|
|
}
|
|
return apiClient.get('/api/workspace', { timeoutMs: 15000 })
|
|
}
|
|
|
|
export async function getRecentVaults(): Promise<VaultInfo[]> {
|
|
if (isDesktop()) return hostInvoke<HostVault[]>('workspace_recent')
|
|
const workspace = await getWorkspaceInfo()
|
|
return [{ vault_id: workspace.vault_id, path: workspace.path, name: workspace.name }]
|
|
}
|
|
|
|
export async function openVault(path: string): Promise<VaultInfo> {
|
|
if (isDesktop()) {
|
|
// 空路径只表示用户点击“选择目录”;最近列表只能重开 Host 已持久化授权的路径。
|
|
const vault = path
|
|
? await hostInvoke<HostVault>('workspace_open', { path })
|
|
: await hostInvoke<HostVault | null>('workspace_choose')
|
|
if (!vault) throw new Error(t('已取消选择', 'Selection cancelled'))
|
|
cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++
|
|
return vault
|
|
}
|
|
treeRequestVersion++
|
|
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
|
|
cacheEntries(snapshot.items)
|
|
return {
|
|
vault_id: snapshot.workspace.vault_id,
|
|
path: snapshot.workspace.path,
|
|
name: snapshot.workspace.name,
|
|
}
|
|
}
|
|
|
|
export async function createVault(path: string, name: string): Promise<VaultInfo> {
|
|
// Web 模式不能创建任意本地目录;路径匹配时等价于初始化后端配置的 Vault。
|
|
void name
|
|
return openVault(path)
|
|
}
|
|
|
|
export async function refreshTree(): Promise<FileNode[]> {
|
|
const version = ++treeRequestVersion
|
|
if (isDesktop()) {
|
|
const entries = await hostInvoke<HostEntry[]>('workspace_tree')
|
|
if (version !== treeRequestVersion) return cachedTree ?? []
|
|
noteIdByPath.clear(); typeByPath.clear()
|
|
for (const entry of entries) { if (!entry.is_folder) noteIdByPath.set(`/${entry.path}`, entry.file_id); typeByPath.set(`/${entry.path}`, entry.is_folder ? 'folder' : 'file') }
|
|
cachedTree = nativeTree(entries)
|
|
return cachedTree
|
|
}
|
|
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree', { timeoutMs: 10000 })
|
|
if (version !== treeRequestVersion) return cachedTree ?? []
|
|
return cacheEntries(entries)
|
|
}
|
|
|
|
export async function getFileTree(): Promise<FileNode[]> {
|
|
return cachedTree ?? refreshTree()
|
|
}
|
|
|
|
export async function readFileContent(filePath: string): Promise<string> {
|
|
if (isDesktop()) return (await hostInvoke<HostDocument>('workspace_read', { path: nativePath(filePath) })).content
|
|
const note = await noteService.getNote(await requireNoteId(filePath))
|
|
return note.markdown
|
|
}
|
|
|
|
/** 将 Vault 根路径转换为相对当前笔记的可移植 Markdown 引用。 */
|
|
export function workspaceAssetReference(notePath: string, assetPath: string): string {
|
|
const noteParts = relativePath(notePath).split('/').filter(Boolean)
|
|
noteParts.pop()
|
|
return `${'../'.repeat(noteParts.length)}${relativePath(assetPath)}`
|
|
}
|
|
|
|
/** 将笔记内的相对附件引用还原为 Vault 根路径。 */
|
|
export function resolveWorkspaceAssetPath(notePath: string, reference: string): string | null {
|
|
if (/^(?:[a-z]+:|\/\/|#)/i.test(reference)) return null
|
|
const parts = [...relativePath(notePath).split('/').slice(0, -1)]
|
|
for (const part of reference.replace(/\\/g, '/').split('/')) {
|
|
if (!part || part === '.') continue
|
|
if (part === '..') { if (!parts.length) return null; parts.pop() }
|
|
else parts.push(part)
|
|
}
|
|
const path = parts.join('/')
|
|
return path.startsWith('attachments/') ? path : null
|
|
}
|
|
|
|
export async function storeWorkspaceImage(
|
|
file: Blob & { name?: string }, source: WorkspaceAssetSource,
|
|
notePath: string, noteId: string | null,
|
|
): Promise<WorkspaceAsset & { reference: string }> {
|
|
const asset = await apiClient.postBinary<WorkspaceAsset>('/api/workspace/assets', file, { 'Content-Type': 'application/octet-stream' }, {
|
|
filename: file.name || 'image', note_id: noteId || '', note_path: notePath, source,
|
|
})
|
|
return { ...asset, reference: workspaceAssetReference(notePath, asset.path) }
|
|
}
|
|
|
|
export async function loadWorkspaceImage(path: string, notePath = '', noteId: string | null = null): Promise<Blob> {
|
|
const response = await apiClient.get<Response>('/api/workspace/assets/content', { params: { path, note_path: notePath, note_id: noteId || '' } })
|
|
return response.blob()
|
|
}
|
|
|
|
/** 解析已与工作空间路径关联的后端笔记标识。 */
|
|
export async function getNoteId(filePath: string): Promise<string> {
|
|
return requireNoteId(filePath)
|
|
}
|
|
|
|
export async function saveFileContent(filePath: string, content: string, expectedContent?: string): Promise<void> {
|
|
if (isDesktop()) {
|
|
if (expectedContent === undefined) throw new Error('EXPECTED_REVISION_REQUIRED')
|
|
await hostInvoke('workspace_write', { path: nativePath(filePath), expected: await contentHash(expectedContent), content })
|
|
return
|
|
}
|
|
const metadata = splitNoteMetadata(content)
|
|
const expectedHash = expectedContent === undefined ? undefined : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(expectedContent)))).map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
await noteService.updateNote(await requireNoteId(filePath), {
|
|
markdown: content,
|
|
...(expectedHash ? { expected_content_hash: expectedHash } : {}),
|
|
// 显式[]清除索引;缺失的标签保留 API 管理的标签。
|
|
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
|
|
})
|
|
}
|
|
|
|
export async function createFile(
|
|
folderPath: string,
|
|
name: string,
|
|
content = '',
|
|
): Promise<FileNode> {
|
|
if (isDesktop()) {
|
|
const path = [nativePath(folderPath), name.endsWith('.md') ? name : `${name}.md`].filter(Boolean).join('/')
|
|
const entry = await hostInvoke<HostEntry>('workspace_write', { path, expected: '', content })
|
|
noteIdByPath.set(`/${path}`, entry.file_id)
|
|
return { id: entry.file_id, note_id: entry.file_id, path: `/${path}`, name: path.split('/').at(-1)!, type: 'file' }
|
|
}
|
|
const title = name.replace(/\.md$/i, '')
|
|
const note = await noteService.createNote({
|
|
title,
|
|
folder: relativePath(folderPath),
|
|
markdown: content,
|
|
})
|
|
return nodeFromNote(note)
|
|
}
|
|
|
|
export async function createFolder(parentPath: string, name: string): Promise<FileNode> {
|
|
if (isDesktop()) {
|
|
const path = [nativePath(parentPath), name].filter(Boolean).join('/')
|
|
await hostInvoke('workspace_mkdir', { path })
|
|
return { id: `folder:/${path}`, path: `/${path}`, name, type: 'folder', children: [] }
|
|
}
|
|
const entry = await apiClient.post<ApiWorkspaceEntry>('/api/workspace/folders', {
|
|
parent: relativePath(parentPath),
|
|
name,
|
|
})
|
|
return toFileNode(entry)
|
|
}
|
|
|
|
export async function renameFile(oldPath: string, newName: string): Promise<void> {
|
|
if (isDesktop()) {
|
|
const path = nativePath(oldPath)
|
|
const document = await hostInvoke<HostDocument>('workspace_read', { path })
|
|
await hostInvoke('workspace_rename', { path, destination: [...path.split('/').slice(0, -1), newName].join('/'), expected: document.hash })
|
|
await refreshTree(); return
|
|
}
|
|
const path = normalizePublicPath(oldPath)
|
|
if (typeByPath.get(path) === 'folder') {
|
|
await apiClient.post('/api/workspace/folders/rename', {
|
|
path: relativePath(path),
|
|
new_name: newName,
|
|
})
|
|
} else {
|
|
await noteService.renameNote(await requireNoteId(path), newName)
|
|
}
|
|
await refreshTree()
|
|
}
|
|
|
|
export async function deleteFile(pathValue: string): Promise<void> {
|
|
if (isDesktop()) {
|
|
const path = nativePath(pathValue)
|
|
const document = await hostInvoke<HostDocument>('workspace_read', { path })
|
|
await hostInvoke('workspace_delete', { path, expected: document.hash })
|
|
await refreshTree(); return
|
|
}
|
|
const path = normalizePublicPath(pathValue)
|
|
if (typeByPath.get(path) === 'folder') {
|
|
await apiClient.post<OperationResponse>('/api/workspace/folders/delete', {
|
|
path: relativePath(path),
|
|
})
|
|
} else {
|
|
await noteService.deleteNote(await requireNoteId(path))
|
|
}
|
|
await refreshTree()
|
|
}
|
|
|
|
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
|
|
if (isDesktop()) {
|
|
const path = nativePath(sourcePath)
|
|
const document = await hostInvoke<HostDocument>('workspace_read', { path })
|
|
await hostInvoke('workspace_rename', { path, destination: [nativePath(targetPath), path.split('/').at(-1)].filter(Boolean).join('/'), expected: document.hash })
|
|
await refreshTree(); return
|
|
}
|
|
const source = normalizePublicPath(sourcePath)
|
|
if (typeByPath.get(source) !== 'file') {
|
|
throw new Error(t('当前阶段只支持移动笔记文件。', 'Only note files can be moved at this stage.'))
|
|
}
|
|
await noteService.moveNote(await requireNoteId(source), relativePath(targetPath))
|
|
await refreshTree()
|
|
}
|