long2icc_sonicnote-sync/src/sync.ts

181 lines
6.9 KiB
TypeScript

import { App, Notice, TFile, TFolder } from 'obsidian';
import { SonicNoteApiClient } from './api';
import { SonicNotePluginSettings, LocalFileInfo, Recording, SyncResult, TranscriptSegment, SummaryData } from './types';
import { formatFileName, sanitizeFileName, toMarkdown } from './formatter';
export class SyncService {
private api: SonicNoteApiClient;
private app: App;
private getSettings: () => SonicNotePluginSettings;
private saveSettings: () => Promise<void>;
constructor(
app: App,
api: SonicNoteApiClient,
getSettings: () => SonicNotePluginSettings,
saveSettings: () => Promise<void>
) {
this.app = app;
this.api = api;
this.getSettings = getSettings;
this.saveSettings = saveSettings;
}
async syncAll(onProgress?: (msg: string) => void): Promise<SyncResult> {
const settings = this.getSettings();
const result: SyncResult = { total: 0, synced: 0, skipped: 0, errors: 0, errorMessages: [] };
// 1. Ensure sync folder exists
await this.ensureFolder(settings.syncFolder);
// 2. Build local index
onProgress?.('正在读取本地索引...');
const localIndex = await this.buildLocalIndex(settings.syncFolder);
// 3. Fetch all recordings from backend
onProgress?.('正在从服务器拉取录音列表...');
let page = 1;
let allRecordings: Recording[] = [];
while (true) {
try {
const res = await this.api.fetchRecordingList(page, settings.pageSize);
allRecordings = allRecordings.concat(res.list);
if (allRecordings.length >= res.total || res.list.length === 0) break;
page++;
} catch (e) {
const msg = e instanceof Error ? e.message : '获取录音列表失败';
result.errorMessages.push(msg);
new Notice(`同步失败: ${msg}`);
return result;
}
}
// Filter out deleted
allRecordings = allRecordings.filter(r => r.delFlag !== '2');
result.total = allRecordings.length;
// 4. Process each recording
for (let i = 0; i < allRecordings.length; i++) {
const recording = allRecordings[i];
onProgress?.(`正在同步 ${i + 1}/${result.total}: ${recording.recordNickName || recording.recordName}`);
try {
await this.processRecording(recording, localIndex, settings);
result.synced++;
} catch (e) {
result.errors++;
const msg = e instanceof Error ? e.message : '未知错误';
result.errorMessages.push(`${recording.recordNickName || recording.recordName}: ${msg}`);
}
}
// 5. Update last sync time
const now = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
settings.lastSyncTime = now;
await this.saveSettings();
return result;
}
private async ensureFolder(folderPath: string): Promise<void> {
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (!folder) {
await this.app.vault.createFolder(folderPath);
}
}
async buildLocalIndex(syncFolder: string): Promise<Map<string, LocalFileInfo>> {
const index = new Map<string, LocalFileInfo>();
const folder = this.app.vault.getAbstractFileByPath(syncFolder);
if (!(folder instanceof TFolder)) return index;
for (const file of folder.children) {
if (!(file instanceof TFile) || file.extension !== 'md') continue;
try {
const content = await this.app.vault.read(file);
const audioId = this.extractFrontmatterField(content, 'audio_id');
const syncTime = this.extractFrontmatterField(content, 'sync_time');
if (audioId) {
index.set(audioId.replace(/"/g, ''), { path: file.path, syncTime: syncTime || '' });
}
} catch {
// Skip files that can't be read
}
}
return index;
}
private extractFrontmatterField(content: string, field: string): string | null {
const match = content.match(new RegExp(`^${field}:\\s*"?([^"]*)"\\s*$`, 'm'));
return match ? match[1] : null;
}
private async processRecording(recording: Recording, localIndex: Map<string, LocalFileInfo>, settings: SonicNotePluginSettings): Promise<void> {
const syncTime = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
const local = localIndex.get(recording.audioId);
// Already synced — check if file still has original name but server has a summarized title
if (local && local.path) {
const hasNewTitle = recording.recordNickName && recording.recordNickName !== recording.recordName;
const localBaseName = local.path.split('/').pop()?.replace(/\.md$/, '') || '';
const originalName = sanitizeFileName(recording.recordName || '');
if (hasNewTitle && localBaseName === originalName) {
// Overwrite content and rename to new title
const content = await this.buildRecordingContent(recording, syncTime);
const newFileName = formatFileName(recording);
const newFilePath = `${settings.syncFolder}/${newFileName}.md`;
const oldFile = this.app.vault.getAbstractFileByPath(local.path);
if (oldFile instanceof TFile) {
await this.app.vault.modify(oldFile, content);
await this.app.vault.rename(oldFile, newFilePath);
localIndex.set(recording.audioId, { path: newFilePath, syncTime });
}
}
return;
}
// New recording — create file
const content = await this.buildRecordingContent(recording, syncTime);
const fileName = formatFileName(recording);
const filePath = `${settings.syncFolder}/${fileName}.md`;
await this.app.vault.create(filePath, content);
localIndex.set(recording.audioId, { path: filePath, syncTime });
}
private async buildRecordingContent(recording: Recording, syncTime: string): Promise<string> {
const settings = this.getSettings();
const tasks: [string, Promise<TranscriptSegment[] | SummaryData | string | null>][] = [];
// Fetch transcript if enabled and available (status 2 = completed)
let transcript: TranscriptSegment[] | null = null;
if (settings.includeTranscript && recording.transcriptStatus === 2) {
tasks.push(['transcript', this.api.fetchTranscriptResult(recording.audioId).catch(() => null)]);
}
// Fetch summary if available (status 2 = completed)
let summary: SummaryData | null = null;
if (recording.summaryStatus === 2) {
tasks.push(['summary', this.api.fetchSummary(recording.audioId).catch(() => null)]);
}
// Fetch note
let note = '';
tasks.push(['note', this.api.fetchNote(recording.audioId).catch(() => '')]);
// Execute all requests in parallel
const results = await Promise.all(tasks.map(([_, p]) => p));
tasks.forEach(([name], i) => {
const val = results[i];
if (name === 'transcript') transcript = val as TranscriptSegment[] | null;
if (name === 'summary') summary = val as SummaryData | null;
if (name === 'note') note = (val as string) || '';
});
return toMarkdown(recording, transcript, summary, note, syncTime);
}
}