This commit is contained in:
huojl 2026-04-30 13:44:12 +08:00
commit e0a2af6b3c
14 changed files with 1505 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
main.js
main.js.map
data.json

31
esbuild.config.mjs Normal file
View file

@ -0,0 +1,31 @@
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from "node:module";
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/state",
"@codemirror/view",
...builtinModules
],
format: "cjs",
target: "es2018",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

9
manifest.json Normal file
View file

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

623
package-lock.json generated Normal file
View file

@ -0,0 +1,623 @@
{
"name": "znote-sync",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "znote-sync",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "5.8.3"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/codemirror": {
"version": "5.60.8",
"resolved": "https://registry.npmmirror.com/@types/codemirror/-/codemirror-5.60.8.tgz",
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmmirror.com/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/esbuild": {
"version": "0.25.5",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.5.tgz",
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.5",
"@esbuild/android-arm": "0.25.5",
"@esbuild/android-arm64": "0.25.5",
"@esbuild/android-x64": "0.25.5",
"@esbuild/darwin-arm64": "0.25.5",
"@esbuild/darwin-x64": "0.25.5",
"@esbuild/freebsd-arm64": "0.25.5",
"@esbuild/freebsd-x64": "0.25.5",
"@esbuild/linux-arm": "0.25.5",
"@esbuild/linux-arm64": "0.25.5",
"@esbuild/linux-ia32": "0.25.5",
"@esbuild/linux-loong64": "0.25.5",
"@esbuild/linux-mips64el": "0.25.5",
"@esbuild/linux-ppc64": "0.25.5",
"@esbuild/linux-riscv64": "0.25.5",
"@esbuild/linux-s390x": "0.25.5",
"@esbuild/linux-x64": "0.25.5",
"@esbuild/netbsd-arm64": "0.25.5",
"@esbuild/netbsd-x64": "0.25.5",
"@esbuild/openbsd-arm64": "0.25.5",
"@esbuild/openbsd-x64": "0.25.5",
"@esbuild/sunos-x64": "0.25.5",
"@esbuild/win32-arm64": "0.25.5",
"@esbuild/win32-ia32": "0.25.5",
"@esbuild/win32-x64": "0.25.5"
}
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmmirror.com/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/obsidian": {
"version": "1.12.3",
"resolved": "https://registry.npmmirror.com/obsidian/-/obsidian-1.12.3.tgz",
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/tslib": {
"version": "2.4.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"dev": true,
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT",
"peer": true
}
}
}

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "znote-sync",
"version": "1.0.0",
"description": "Sync recordings from Znote (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"],
"author": "EasyLink",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "5.8.3"
}
}

110
src/api.ts Normal file
View file

@ -0,0 +1,110 @@
import { requestUrl, RequestUrlParam } from 'obsidian';
import { BackendResponse, Recording, TranscriptSegment, SummaryData, ZnotePluginSettings } from './types';
export class ZnoteApiClient {
constructor(private getSettings: () => ZnotePluginSettings) {}
isAuthenticated(): boolean {
return this.getSettings().token !== '';
}
private get serverUrl(): string {
return this.getSettings().serverUrl;
}
private get token(): string {
return this.getSettings().token;
}
private async request(method: 'GET' | 'POST', path: string, options?: { query?: Record<string, string | number>; body?: unknown }): Promise<BackendResponse> {
let url = `${this.serverUrl}${path}`;
if (options?.query) {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(options.query)) {
if (v !== undefined && v !== null) params.set(k, String(v));
}
url += '?' + params.toString();
}
const headers: Record<string, string> = {};
if (this.token) {
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;
}
async login(phone: string, code: string): Promise<{ token: string; userId: string }> {
const res = await this.request('POST', '/app/user/loginByPhone', {
body: { phonenumber: phone, code: code },
});
if (res.code !== 200) {
throw new Error(res.msg || '登录失败');
}
return {
token: res.data.token || res.data,
userId: res.data.userId || '',
};
}
async fetchRecordingList(page: number, size: number): Promise<{ list: Recording[]; total: number }> {
const res = await this.request('GET', '/app/recording/list', {
query: { page, size },
});
if (res.code !== 200) {
throw new Error(res.msg || '获取录音列表失败');
}
return {
list: res.data?.records || res.data?.list || [],
total: res.data?.total || 0,
};
}
async fetchRecordingDetail(audioId: string): Promise<Recording> {
const res = await this.request('GET', '/app/recording/detail', {
query: { audioId },
});
if (res.code !== 200) {
throw new Error(res.msg || '获取录音详情失败');
}
return res.data;
}
async fetchNote(audioId: string): Promise<string> {
const res = await this.request('GET', '/app/recording/getNote', {
query: { audioId },
});
if (res.code !== 200) {
return '';
}
return res.data?.note || '';
}
async fetchTranscriptResult(audioId: string): Promise<TranscriptSegment[]> {
const res = await this.request('GET', `/share/${audioId}/transcript/result`);
if (res.code !== 200) {
return [];
}
return res.data || [];
}
async fetchSummary(audioId: string): Promise<SummaryData | null> {
const res = await this.request('GET', `/share/${audioId}/summary`);
if (res.code !== 200) {
return null;
}
return res.data;
}
}

116
src/formatter.ts Normal file
View file

@ -0,0 +1,116 @@
import { Recording, TranscriptSegment, SummaryData } from './types';
export function sanitizeFileName(name: string): string {
return name
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
|| 'untitled';
}
export function formatFileName(recording: Recording): string {
const displayName = recording.recordNickName || recording.recordName || '未命名录音';
const date = recording.recordTime
? recording.recordTime.substring(0, 10)
: new Date().toISOString().substring(0, 10);
return `${date}-${sanitizeFileName(displayName)}`;
}
function formatDuration(seconds: string | number): string {
const s = Number(seconds) || 0;
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
if (h > 0) {
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
function formatTranscriptTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
return formatDuration(totalSeconds);
}
export function generateFrontmatter(recording: Recording, syncTime: string): string {
const lines: string[] = [
'---',
`audio_id: "${recording.audioId}"`,
`record_name: "${recording.recordName || ''}"`,
`record_nick_name: "${(recording.recordNickName || '').replace(/"/g, '\\"')}"`,
`duration: "${recording.duration || '0'}"`,
`record_time: "${recording.recordTime || ''}"`,
`record_type: "${recording.recordType || ''}"`,
`device_name: "${recording.deviceName || ''}"`,
];
if (recording.audioUrl) {
lines.push(`audio_url: "${recording.audioUrl}"`);
}
lines.push('tags:');
lines.push(' - znote');
const recordTypeLabel = recording.recordType === '00' ? '通话' : '录音';
lines.push(` - ${recordTypeLabel}`);
lines.push(`sync_time: "${syncTime}"`);
lines.push('---');
return lines.join('\n');
}
export function formatTranscript(segments: TranscriptSegment[]): string {
if (!segments || segments.length === 0) return '';
return segments.map(seg => {
const time = formatTranscriptTime(seg.time);
const speaker = seg.spokesperson || '未知';
return `**[${time}] ${speaker}** ${seg.text}`;
}).join('\n\n');
}
export function toMarkdown(
recording: Recording,
transcript: TranscriptSegment[] | null,
summary: SummaryData | null,
note: string,
syncTime: string
): string {
const parts: string[] = [];
// Frontmatter
parts.push(generateFrontmatter(recording, syncTime));
parts.push('');
// Title
const title = recording.recordNickName || recording.recordName || '未命名录音';
parts.push(`# ${title}`);
parts.push('');
// Note section
if (note && note.trim()) {
parts.push('## 笔记');
parts.push('');
parts.push(note.trim());
parts.push('');
}
// Transcript section
if (transcript && transcript.length > 0) {
parts.push('## 转录内容');
parts.push('');
parts.push(formatTranscript(transcript));
parts.push('');
}
// Summary section
if (summary && summary.summaryContent) {
parts.push('## AI 总结');
parts.push('');
parts.push(summary.summaryContent.trim());
parts.push('');
}
return parts.join('\n');
}

123
src/main.ts Normal file
View file

@ -0,0 +1,123 @@
import { Notice, Plugin } from 'obsidian';
import { ZnoteApiClient } from './api';
import { SyncService } from './sync';
import { ZnoteSettingTab } from './settings';
import { DEFAULT_SETTINGS, ZnotePluginSettings } from './types';
export default class ZnoteSyncPlugin extends Plugin {
settings: ZnotePluginSettings = DEFAULT_SETTINGS;
private api!: ZnoteApiClient;
private syncService!: SyncService;
private statusBarEl!: HTMLElement;
async onload() {
await this.loadSettings();
// Initialize API client and sync service
this.api = new ZnoteApiClient(() => this.settings);
this.syncService = new SyncService(
this.app,
this.api,
() => this.settings,
() => this.saveSettings()
);
// Status bar
this.statusBarEl = this.addStatusBarItem();
this.updateStatusBar();
// Ribbon icon
this.addRibbonIcon('headphones', 'Znote Sync: 同步录音', () => {
this.triggerSync();
});
// Commands
this.addCommand({
id: 'znote-sync:sync',
name: '同步录音',
callback: () => this.triggerSync(),
});
this.addCommand({
id: 'znote-sync:login',
name: '登录',
callback: () => {
// @ts-ignore - internal API to open settings
this.app.setting.open();
// @ts-ignore
this.app.setting.openTabById('znote-sync');
},
});
this.addCommand({
id: 'znote-sync:logout',
name: '登出',
callback: async () => {
this.settings.token = '';
this.settings.phoneNumber = '';
await this.saveSettings();
new Notice('已登出 Znote');
this.updateStatusBar();
},
});
// Settings tab
this.addSettingTab(new ZnoteSettingTab(
this.app,
this,
this.api,
() => this.settings,
() => this.saveSettings()
));
console.log('Znote Sync plugin loaded');
}
onunload() {
console.log('Znote Sync plugin unloaded');
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
private async triggerSync() {
if (!this.api.isAuthenticated()) {
new Notice('请先登录 Znote设置 → Znote Sync');
return;
}
this.statusBarEl.setText('Znote: 同步中...');
try {
const result = await this.syncService.syncAll((msg) => {
this.statusBarEl.setText(`Znote: ${msg}`);
});
let message = `同步完成: ${result.synced} 条新/更新`;
if (result.skipped > 0) message += `, ${result.skipped} 条跳过`;
if (result.errors > 0) message += `, ${result.errors} 条失败`;
new Notice(message, 5000);
this.updateStatusBar();
} catch (e) {
new Notice(`同步失败: ${e instanceof Error ? e.message : '未知错误'}`);
this.updateStatusBar();
}
}
private updateStatusBar() {
if (this.api.isAuthenticated()) {
const lastSync = this.settings.lastSyncTime
? `上次同步: ${this.settings.lastSyncTime.substring(0, 16).replace('T', ' ')}`
: '未同步';
this.statusBarEl.setText(`Znote: ${lastSync}`);
} else {
this.statusBarEl.setText('Znote: 未登录');
}
}
}

158
src/settings.ts Normal file
View file

@ -0,0 +1,158 @@
import { App, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { ZnoteApiClient } from './api';
import { ZnotePluginSettings, DEFAULT_SETTINGS } from './types';
export class ZnoteSettingTab extends PluginSettingTab {
private api: ZnoteApiClient;
private getSettings: () => ZnotePluginSettings;
private saveSettings: () => Promise<void>;
constructor(
app: App,
plugin: Plugin,
api: ZnoteApiClient,
getSettings: () => ZnotePluginSettings,
saveSettings: () => Promise<void>
) {
super(app, plugin);
this.api = api;
this.getSettings = getSettings;
this.saveSettings = saveSettings;
}
display(): void {
const { containerEl } = this;
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();
}));
// Sync folder
new Setting(containerEl)
.setName('同步文件夹')
.setDesc('录音 Markdown 文件存放的文件夹(相对于 Vault 根目录)')
.addText(text => text
.setPlaceholder('ZnoteSync')
.setValue(settings.syncFolder)
.onChange(async (value) => {
settings.syncFolder = value;
await this.saveSettings();
}));
// Login status & actions
const loginSection = containerEl.createDiv();
loginSection.createEl('h3', { text: '账号' });
if (this.api.isAuthenticated()) {
new Setting(loginSection)
.setName('登录状态')
.setDesc(`已登录: ${settings.phoneNumber}`)
.addButton(btn => btn
.setButtonText('登出')
.setWarning()
.onClick(async () => {
settings.token = '';
settings.phoneNumber = '';
await this.saveSettings();
new Notice('已登出');
this.display();
}));
} else {
new Setting(loginSection)
.setName('登录')
.setDesc('使用手机号和验证码登录智音笔记')
.addButton(btn => btn
.setButtonText('登录')
.onClick(() => {
new LoginModal(this.app, this.api, this.getSettings, this.saveSettings, () => this.display()).open();
}));
}
}
}
class LoginModal extends Modal {
private api: ZnoteApiClient;
private getSettings: () => ZnotePluginSettings;
private saveSettings: () => Promise<void>;
private onCloseCallback: () => void;
constructor(
app: App,
api: ZnoteApiClient,
getSettings: () => ZnotePluginSettings,
saveSettings: () => Promise<void>,
onCloseCallback: () => void
) {
super(app);
this.api = api;
this.getSettings = getSettings;
this.saveSettings = saveSettings;
this.onCloseCallback = onCloseCallback;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: '登录智音笔记' });
let phone = '';
let code = '';
new Setting(contentEl)
.setName('手机号')
.addText(text => text
.setPlaceholder('请输入手机号')
.onChange((value) => { phone = value; }));
new Setting(contentEl)
.setName('验证码')
.addText(text => text
.setPlaceholder('请输入验证码')
.onChange((value) => { code = value; }));
new Setting(contentEl)
.addButton(btn => btn
.setButtonText('登录')
.setCta()
.onClick(async () => {
if (!phone || !code) {
new Notice('请输入手机号和验证码');
return;
}
try {
btn.setButtonText('登录中...');
btn.setDisabled(true);
const result = await this.api.login(phone, code);
const settings = this.getSettings();
settings.token = result.token;
settings.phoneNumber = phone;
await this.saveSettings();
new Notice('登录成功');
this.close();
} catch (e) {
new Notice(`登录失败: ${e instanceof Error ? e.message : '未知错误'}`);
btn.setButtonText('登录');
btn.setDisabled(false);
}
}));
}
onClose() {
const { contentEl } = this;
contentEl.empty();
this.onCloseCallback();
}
}

204
src/sync.ts Normal file
View file

@ -0,0 +1,204 @@
import { App, Notice, TFile, TFolder } from 'obsidian';
import { ZnoteApiClient } from './api';
import { ZnotePluginSettings, LocalFileInfo, Recording, SyncResult, TranscriptSegment, SummaryData } from './types';
import { formatFileName, toMarkdown } from './formatter';
export class SyncService {
private api: ZnoteApiClient;
private app: App;
private getSettings: () => ZnotePluginSettings;
private saveSettings: () => Promise<void>;
constructor(
app: App,
api: ZnoteApiClient,
getSettings: () => ZnotePluginSettings,
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().toISOString();
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: ZnotePluginSettings): Promise<void> {
const syncTime = new Date().toISOString();
// Check if we need to update
const local = localIndex.get(recording.audioId);
if (local && local.syncTime && recording.updateTime) {
if (recording.updateTime <= local.syncTime) {
// No update needed
return;
}
// Check for conflict (user edited file locally after sync)
const file = this.app.vault.getAbstractFileByPath(local.path);
if (file instanceof TFile) {
const fileMtime = new Date(file.stat.mtime).toISOString();
if (fileMtime > local.syncTime) {
// Conflict — write to a new file instead
await this.writeRecordingToNewFile(recording, syncTime, settings, local.path);
return;
}
// Update existing file
const content = await this.buildRecordingContent(recording, syncTime);
await this.app.vault.modify(file, content);
localIndex.set(recording.audioId, { path: local.path, syncTime });
return;
}
}
// New recording — create file
await this.writeRecordingToNewFile(recording, syncTime, settings);
localIndex.set(recording.audioId, {
path: `${settings.syncFolder}/${formatFileName(recording)}.md`,
syncTime,
});
}
private async writeRecordingToNewFile(recording: Recording, syncTime: string, settings: ZnotePluginSettings, conflictWithPath?: string): Promise<void> {
const content = await this.buildRecordingContent(recording, syncTime);
let fileName = formatFileName(recording);
if (conflictWithPath) {
// Create conflict copy
const timestamp = Date.now();
fileName = `${fileName}-conflict-${timestamp}`;
}
// Deduplicate filename
let filePath = `${settings.syncFolder}/${fileName}.md`;
let counter = 2;
while (await this.app.vault.adapter.exists(filePath)) {
filePath = `${settings.syncFolder}/${fileName}-${counter}.md`;
counter++;
}
await this.app.vault.create(filePath, content);
}
private async buildRecordingContent(recording: Recording, syncTime: string): Promise<string> {
const tasks: [string, Promise<TranscriptSegment[] | SummaryData | string | null>][] = [];
// Fetch transcript if available (status 2 = completed)
let transcript: TranscriptSegment[] | null = null;
if (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);
}
}

77
src/types.ts Normal file
View file

@ -0,0 +1,77 @@
// ===== 插件设置 =====
export interface ZnotePluginSettings {
serverUrl: string;
syncFolder: string;
pageSize: number;
token: string;
phoneNumber: string;
lastSyncTime: string;
}
export const DEFAULT_SETTINGS: ZnotePluginSettings = {
serverUrl: 'http://ainote.easylinkin.com:8048',
syncFolder: 'ZnoteSync',
pageSize: 50,
token: '',
phoneNumber: '',
lastSyncTime: '',
};
// ===== 后端数据类型 =====
export interface BackendResponse {
code: number;
msg: string;
data: any;
}
export interface Recording {
audioId: string;
userId: string;
deviceId: string;
audioUrl: string;
recordTime: string;
recordName: string;
recordNickName: string;
duration: string;
note: string;
recordType: string;
isTranscribed: string;
isSummarized: string;
transcriptStatus: number;
summaryStatus: number;
delFlag: string;
deviceName: string;
createTime: string;
updateTime: string;
}
export interface TranscriptSegment {
spokesperson: string;
text: string;
time: number;
}
export interface SummaryData {
summaryId: string;
audioId: string;
summaryContent: string;
templateId: string;
status: number;
}
// ===== 同步相关 =====
export interface LocalFileInfo {
path: string;
syncTime: string;
}
export interface SyncResult {
total: number;
synced: number;
skipped: number;
errors: number;
errorMessages: string[];
}

10
styles.css Normal file
View file

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

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["src/**/*.ts"]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}