first commit

This commit is contained in:
huojl 2026-05-06 14:27:15 +08:00
parent e0a2af6b3c
commit 9a018c4494
11 changed files with 339 additions and 87 deletions

View file

@ -1,9 +1,9 @@
{
"id": "znote-sync",
"name": "Znote Sync",
"id": "sonicnote-sync",
"name": "SonicNote Sync",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Sync recordings from Znote (SonicNote) to Obsidian as Markdown files",
"author": "EasyLink",
"description": "Sync recordings from SonicNote to Obsidian as Markdown files",
"author": "EasyLinkIn",
"isDesktopOnly": false
}

View file

@ -1,13 +1,13 @@
{
"name": "znote-sync",
"name": "sonicnote-sync",
"version": "1.0.0",
"description": "Sync recordings from Znote (SonicNote) to Obsidian as Markdown files",
"description": "Sync recordings from SonicNote to Obsidian as Markdown files",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
},
"keywords": ["obsidian", "znote", "recording", "sync"],
"keywords": ["obsidian", "sonicnote", "recording", "sync"],
"author": "EasyLink",
"license": "MIT",
"devDependencies": {

View file

@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SonicNote Sync — Obsidian 插件</title>
<style>
:root {
--bg: #f7f8fa;
--surface: #ffffff;
--border: #ebedf0;
--text: #1d2129;
--text-secondary: #656a72;
--accent: #333;
--accent-light: #f0f1f3;
--code-bg: #1e1e2e;
--code-text: #cdd6f4;
--radius: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 36px 24px 28px;
text-align: center;
}
.header .version {
display: inline-block;
background: var(--accent-light);
padding: 2px 10px;
border-radius: 4px;
font-size: 0.78rem;
color: var(--text-secondary);
margin-bottom: 10px;
}
.header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.3px; margin-bottom: 6px; }
.header p { font-size: 0.92rem; color: var(--text-secondary); margin-bottom: 18px; }
.download-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
color: #fff;
padding: 9px 28px;
border-radius: 6px;
font-size: 0.92rem;
font-weight: 500;
text-decoration: none;
transition: opacity 0.15s;
}
.download-btn:hover { opacity: 0.82; }
.download-btn svg { width: 16px; height: 16px; }
.container { max-width: 700px; margin: 0 auto; padding: 24px 20px 60px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 22px 26px;
margin-bottom: 14px;
}
.card h2 {
font-size: 1.05rem;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.card h3 { font-size: 0.92rem; margin: 16px 0 6px; font-weight: 600; }
.card p, .card li { color: var(--text-secondary); font-size: 0.9rem; }
.card ol, .card ul { padding-left: 18px; margin: 4px 0; }
.card li { margin-bottom: 2px; }
.code-wrap {
position: relative;
margin: 8px 0;
}
.code-wrap pre {
background: var(--code-bg);
color: var(--code-text);
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
font-size: 0.82rem;
line-height: 1.55;
margin: 0;
}
.copy-btn {
position: absolute;
top: 6px;
right: 6px;
background: rgba(255,255,255,0.1);
border: none;
border-radius: 4px;
padding: 4px 7px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
color: #aaa;
line-height: 1;
}
.code-wrap:hover .copy-btn { opacity: 1; }
.copy-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
.copy-btn.copied { opacity: 1; color: #4ade80; }
code { font-family: "SF Mono", "Fira Code", "Cascadia Code", Consolas, monospace; }
.inline-code {
background: var(--accent-light);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.85em;
color: var(--text);
}
table { width: 100%; border-collapse: collapse; margin: 8px 0; }
th { text-align: left; padding: 7px 12px; background: var(--accent-light); font-weight: 600; font-size: 0.85rem; }
td { padding: 7px 12px; border-bottom: 1px solid var(--border); font-size: 0.88rem; }
footer {
text-align: center;
padding: 20px;
color: var(--text-secondary);
font-size: 0.8rem;
}
@media (max-width: 640px) {
.header { padding: 28px 16px 22px; }
.header h1 { font-size: 1.35rem; }
.card { padding: 18px 16px; }
}
</style>
</head>
<body>
<header class="header">
<div class="version">v1.0.0</div>
<h1>SonicNote Sync</h1>
<p>将 SonicNote 妙记的录音、转录、总结同步到 Obsidian以 Markdown 文件管理。</p>
<a class="download-btn" href="sonicnote-sync.zip" download>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
下载插件
</a>
</header>
<div class="container">
<div class="card">
<h2>前置要求</h2>
<ul>
<li>Obsidian 桌面版 v0.15.0 及以上(<a href="https://obsidian.md/download" target="_blank">下载地址</a></li>
<li>SonicNote 妙记账号</li>
</ul>
</div>
<div class="card">
<h2>安装</h2>
<ol>
<li>点击上方按钮下载 <span class="inline-code">sonicnote-sync.zip</span></li>
<li>解压 zip 文件,得到以下文件:
<ul style="margin-top:4px;">
<li><span class="inline-code">main.js</span></li>
<li><span class="inline-code">manifest.json</span></li>
<li><span class="inline-code">styles.css</span></li>
</ul>
</li>
<li>打开 Obsidian Vault 所在文件夹,进入插件目录:</li>
</ol>
<div class="code-wrap"><button class="copy-btn" title="复制"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button><pre><code>.obsidian/plugins/</code></pre></div>
<ol start="4">
<li><span class="inline-code">plugins</span> 目录下新建文件夹 <span class="inline-code">sonicnote-sync</span>,将解压的 3 个文件放进去:</li>
</ol>
<div class="code-wrap"><button class="copy-btn" title="复制"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button><pre><code>.obsidian/plugins/sonicnote-sync/
├── main.js
├── manifest.json
└── styles.css</code></pre></div>
<ol start="5">
<li>重启 Obsidian进入 <strong>设置 → 第三方/社区插件</strong>,在列表中找到 <strong>SonicNote Sync</strong> 并启用</li>
</ol>
</div>
<div class="card">
<h2>使用方法</h2>
<h3>登录</h3>
<ol>
<li>进入 <strong>设置 → SonicNote Sync</strong></li>
<li>点击"登录"按钮</li>
<li>输入 SonicNote 妙记的手机号和密码(使用 APP 验证码或者申领长期密码)</li>
</ol>
<h3>同步录音</h3>
<p>登录成功后,通过以下任一方式触发同步:</p>
<ul>
<li>点击左侧功能区的 <strong>耳机图标</strong></li>
<li>命令面板Ctrl/Cmd + P输入 <strong>"SonicNote: 同步录音"</strong></li>
</ul>
<p style="margin-top:6px;">同步完成后Vault 中会生成 <span class="inline-code">SonicNoteSync/</span> 文件夹,每条录音对应一个 Markdown 文件。</p>
</div>
<div class="card">
<h2>同步内容</h2>
<p>每个录音文件包含:</p>
<table>
<thead><tr><th>内容</th><th>说明</th></tr></thead>
<tbody>
<tr><td>Frontmatter</td><td>录音元数据ID、时间、时长、设备名、标签等</td></tr>
<tr><td>笔记</td><td>你在 App 中手写的笔记内容</td></tr>
<tr><td>转录内容</td><td>AI 转写文本(含说话人、时间戳)</td></tr>
<tr><td>AI 总结</td><td>智能总结内容(要点、行动项等)</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>设置项</h2>
<table>
<thead><tr><th>设置</th><th>默认值</th><th>说明</th></tr></thead>
<tbody>
<tr><td>同步文件夹</td><td><span class="inline-code">SonicNoteSync</span></td><td>Markdown 文件存放目录</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>卸载</h2>
<ol>
<li>进入 <strong>设置 → 社区插件</strong>,找到 SonicNote Sync点击禁用并卸载</li>
<li>手动删除 <span class="inline-code">.obsidian/plugins/sonicnote-sync/</span> 文件夹</li>
</ol>
</div>
</div>
<footer>SonicNote Sync &copy; 2026</footer>
<script>
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const code = btn.parentElement.querySelector('code');
navigator.clipboard.writeText(code.textContent).then(() => {
btn.classList.add('copied');
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>';
}, 1500);
});
});
});
</script>
</body>
</html>

BIN
release/sonicnote-sync.zip Normal file

Binary file not shown.

View file

@ -1,8 +1,8 @@
import { requestUrl, RequestUrlParam } from 'obsidian';
import { BackendResponse, Recording, TranscriptSegment, SummaryData, ZnotePluginSettings } from './types';
import { BackendResponse, Recording, TranscriptSegment, SummaryData, SonicNotePluginSettings } from './types';
export class ZnoteApiClient {
constructor(private getSettings: () => ZnotePluginSettings) {}
export class SonicNoteApiClient {
constructor(private getSettings: () => SonicNotePluginSettings) {}
isAuthenticated(): boolean {
return this.getSettings().token !== '';
@ -31,19 +31,25 @@ export class ZnoteApiClient {
headers['Authorization'] = `Bearer ${this.token}`;
}
const params: RequestUrlParam = {
url,
method,
headers,
};
if (method === 'POST' && options?.body) {
headers['Content-Type'] = 'application/json';
params.body = JSON.stringify(options.body);
}
const response = await requestUrl(params);
return response.json as BackendResponse;
try {
const response = await requestUrl({
url,
method,
headers,
body: method === 'POST' && options?.body ? JSON.stringify(options.body) : undefined,
});
return response.json as BackendResponse;
} catch (e: any) {
// requestUrl throws on non-2xx status codes; try to extract the body
if (e?.json) {
return e.json as BackendResponse;
}
throw new Error(e?.message || `请求失败: ${method} ${path}`);
}
}
async login(phone: string, code: string): Promise<{ token: string; userId: string }> {
@ -53,9 +59,14 @@ export class ZnoteApiClient {
if (res.code !== 200) {
throw new Error(res.msg || '登录失败');
}
const data = res.data;
const token = typeof data === 'string' ? data : data?.token;
if (!token) {
throw new Error('登录响应中缺少 token');
}
return {
token: res.data.token || res.data,
userId: res.data.userId || '',
token,
userId: data?.user?.userId || data?.userId || '',
};
}

View file

@ -50,7 +50,7 @@ export function generateFrontmatter(recording: Recording, syncTime: string): str
}
lines.push('tags:');
lines.push(' - znote');
lines.push(' - sonicnote');
const recordTypeLabel = recording.recordType === '00' ? '通话' : '录音';
lines.push(` - ${recordTypeLabel}`);

View file

@ -1,12 +1,12 @@
import { Notice, Plugin } from 'obsidian';
import { ZnoteApiClient } from './api';
import { SonicNoteApiClient } from './api';
import { SyncService } from './sync';
import { ZnoteSettingTab } from './settings';
import { DEFAULT_SETTINGS, ZnotePluginSettings } from './types';
import { SonicNoteSettingTab } from './settings';
import { DEFAULT_SETTINGS, SonicNotePluginSettings } from './types';
export default class ZnoteSyncPlugin extends Plugin {
settings: ZnotePluginSettings = DEFAULT_SETTINGS;
private api!: ZnoteApiClient;
export default class SonicNoteSyncPlugin extends Plugin {
settings: SonicNotePluginSettings = DEFAULT_SETTINGS;
private api!: SonicNoteApiClient;
private syncService!: SyncService;
private statusBarEl!: HTMLElement;
@ -14,7 +14,7 @@ export default class ZnoteSyncPlugin extends Plugin {
await this.loadSettings();
// Initialize API client and sync service
this.api = new ZnoteApiClient(() => this.settings);
this.api = new SonicNoteApiClient(() => this.settings);
this.syncService = new SyncService(
this.app,
this.api,
@ -27,42 +27,42 @@ export default class ZnoteSyncPlugin extends Plugin {
this.updateStatusBar();
// Ribbon icon
this.addRibbonIcon('headphones', 'Znote Sync: 同步录音', () => {
this.addRibbonIcon('headphones', 'SonicNote Sync: 同步录音', () => {
this.triggerSync();
});
// Commands
this.addCommand({
id: 'znote-sync:sync',
id: 'sonicnote-sync:sync',
name: '同步录音',
callback: () => this.triggerSync(),
});
this.addCommand({
id: 'znote-sync:login',
id: 'sonicnote-sync:login',
name: '登录',
callback: () => {
// @ts-ignore - internal API to open settings
this.app.setting.open();
// @ts-ignore
this.app.setting.openTabById('znote-sync');
this.app.setting.openTabById('sonicnote-sync');
},
});
this.addCommand({
id: 'znote-sync:logout',
id: 'sonicnote-sync:logout',
name: '登出',
callback: async () => {
this.settings.token = '';
this.settings.phoneNumber = '';
await this.saveSettings();
new Notice('已登出 Znote');
new Notice('已登出 SonicNote');
this.updateStatusBar();
},
});
// Settings tab
this.addSettingTab(new ZnoteSettingTab(
this.addSettingTab(new SonicNoteSettingTab(
this.app,
this,
this.api,
@ -70,11 +70,11 @@ export default class ZnoteSyncPlugin extends Plugin {
() => this.saveSettings()
));
console.log('Znote Sync plugin loaded');
console.log('SonicNote Sync plugin loaded');
}
onunload() {
console.log('Znote Sync plugin unloaded');
console.log('SonicNote Sync plugin unloaded');
}
async loadSettings() {
@ -87,15 +87,15 @@ export default class ZnoteSyncPlugin extends Plugin {
private async triggerSync() {
if (!this.api.isAuthenticated()) {
new Notice('请先登录 Znote设置 → Znote Sync');
new Notice('请先登录 SonicNote设置 → SonicNote Sync');
return;
}
this.statusBarEl.setText('Znote: 同步中...');
this.statusBarEl.setText('SonicNote: 同步中...');
try {
const result = await this.syncService.syncAll((msg) => {
this.statusBarEl.setText(`Znote: ${msg}`);
this.statusBarEl.setText(`SonicNote: ${msg}`);
});
let message = `同步完成: ${result.synced} 条新/更新`;
@ -115,9 +115,9 @@ export default class ZnoteSyncPlugin extends Plugin {
const lastSync = this.settings.lastSyncTime
? `上次同步: ${this.settings.lastSyncTime.substring(0, 16).replace('T', ' ')}`
: '未同步';
this.statusBarEl.setText(`Znote: ${lastSync}`);
this.statusBarEl.setText(`SonicNote: ${lastSync}`);
} else {
this.statusBarEl.setText('Znote: 未登录');
this.statusBarEl.setText('SonicNote: 未登录');
}
}
}

View file

@ -1,17 +1,17 @@
import { App, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { ZnoteApiClient } from './api';
import { ZnotePluginSettings, DEFAULT_SETTINGS } from './types';
import { SonicNoteApiClient } from './api';
import { SonicNotePluginSettings, DEFAULT_SETTINGS } from './types';
export class ZnoteSettingTab extends PluginSettingTab {
private api: ZnoteApiClient;
private getSettings: () => ZnotePluginSettings;
export class SonicNoteSettingTab extends PluginSettingTab {
private api: SonicNoteApiClient;
private getSettings: () => SonicNotePluginSettings;
private saveSettings: () => Promise<void>;
constructor(
app: App,
plugin: Plugin,
api: ZnoteApiClient,
getSettings: () => ZnotePluginSettings,
api: SonicNoteApiClient,
getSettings: () => SonicNotePluginSettings,
saveSettings: () => Promise<void>
) {
super(app, plugin);
@ -25,26 +25,14 @@ export class ZnoteSettingTab extends PluginSettingTab {
const settings = this.getSettings();
containerEl.empty();
containerEl.createEl('h2', { text: 'Znote Sync 设置' });
// Server URL
new Setting(containerEl)
.setName('服务器地址')
.setDesc('智音笔记后端服务地址')
.addText(text => text
.setPlaceholder('http://localhost:8048')
.setValue(settings.serverUrl)
.onChange(async (value) => {
settings.serverUrl = value;
await this.saveSettings();
}));
containerEl.createEl('h2', { text: 'SonicNote Sync 设置' });
// Sync folder
new Setting(containerEl)
.setName('同步文件夹')
.setDesc('录音 Markdown 文件存放的文件夹(相对于 Vault 根目录)')
.addText(text => text
.setPlaceholder('ZnoteSync')
.setPlaceholder('SonicNoteSync')
.setValue(settings.syncFolder)
.onChange(async (value) => {
settings.syncFolder = value;
@ -83,15 +71,15 @@ export class ZnoteSettingTab extends PluginSettingTab {
}
class LoginModal extends Modal {
private api: ZnoteApiClient;
private getSettings: () => ZnotePluginSettings;
private api: SonicNoteApiClient;
private getSettings: () => SonicNotePluginSettings;
private saveSettings: () => Promise<void>;
private onCloseCallback: () => void;
constructor(
app: App,
api: ZnoteApiClient,
getSettings: () => ZnotePluginSettings,
api: SonicNoteApiClient,
getSettings: () => SonicNotePluginSettings,
saveSettings: () => Promise<void>,
onCloseCallback: () => void
) {
@ -106,7 +94,7 @@ class LoginModal extends Modal {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: '登录智音笔记' });
contentEl.createEl('h2', { text: '登录 SonicNote' });
let phone = '';
let code = '';
@ -118,9 +106,10 @@ class LoginModal extends Modal {
.onChange((value) => { phone = value; }));
new Setting(contentEl)
.setName('验证码')
.setName('密码')
.setDesc('使用 APP 验证码或者申领长期密码')
.addText(text => text
.setPlaceholder('请输入验证码')
.setPlaceholder('请输入码')
.onChange((value) => { code = value; }));
new Setting(contentEl)
@ -129,7 +118,7 @@ class LoginModal extends Modal {
.setCta()
.onClick(async () => {
if (!phone || !code) {
new Notice('请输入手机号和验证码');
new Notice('请输入手机号和码');
return;
}
try {

View file

@ -1,18 +1,18 @@
import { App, Notice, TFile, TFolder } from 'obsidian';
import { ZnoteApiClient } from './api';
import { ZnotePluginSettings, LocalFileInfo, Recording, SyncResult, TranscriptSegment, SummaryData } from './types';
import { SonicNoteApiClient } from './api';
import { SonicNotePluginSettings, LocalFileInfo, Recording, SyncResult, TranscriptSegment, SummaryData } from './types';
import { formatFileName, toMarkdown } from './formatter';
export class SyncService {
private api: ZnoteApiClient;
private api: SonicNoteApiClient;
private app: App;
private getSettings: () => ZnotePluginSettings;
private getSettings: () => SonicNotePluginSettings;
private saveSettings: () => Promise<void>;
constructor(
app: App,
api: ZnoteApiClient,
getSettings: () => ZnotePluginSettings,
api: SonicNoteApiClient,
getSettings: () => SonicNotePluginSettings,
saveSettings: () => Promise<void>
) {
this.app = app;
@ -113,7 +113,7 @@ export class SyncService {
return match ? match[1] : null;
}
private async processRecording(recording: Recording, localIndex: Map<string, LocalFileInfo>, settings: ZnotePluginSettings): Promise<void> {
private async processRecording(recording: Recording, localIndex: Map<string, LocalFileInfo>, settings: SonicNotePluginSettings): Promise<void> {
const syncTime = new Date().toISOString();
// Check if we need to update
@ -150,7 +150,7 @@ export class SyncService {
});
}
private async writeRecordingToNewFile(recording: Recording, syncTime: string, settings: ZnotePluginSettings, conflictWithPath?: string): Promise<void> {
private async writeRecordingToNewFile(recording: Recording, syncTime: string, settings: SonicNotePluginSettings, conflictWithPath?: string): Promise<void> {
const content = await this.buildRecordingContent(recording, syncTime);
let fileName = formatFileName(recording);

View file

@ -1,6 +1,6 @@
// ===== 插件设置 =====
export interface ZnotePluginSettings {
export interface SonicNotePluginSettings {
serverUrl: string;
syncFolder: string;
pageSize: number;
@ -9,9 +9,9 @@ export interface ZnotePluginSettings {
lastSyncTime: string;
}
export const DEFAULT_SETTINGS: ZnotePluginSettings = {
serverUrl: 'http://ainote.easylinkin.com:8048',
syncFolder: 'ZnoteSync',
export const DEFAULT_SETTINGS: SonicNotePluginSettings = {
serverUrl: 'https://ainote.easylinkin.com:18048/prod-api',
syncFolder: 'SonicNoteSync',
pageSize: 50,
token: '',
phoneNumber: '',

View file

@ -1,10 +1,10 @@
/* Znote Sync Plugin Styles */
/* SonicNote Sync Plugin Styles */
/* Login modal */
.znote-login-modal .setting-item {
.sonicnote-login-modal .setting-item {
padding: 8px 0;
}
.znote-login-modal .setting-item-control input {
.sonicnote-login-modal .setting-item-control input {
width: 200px;
}