/**
* 从坚果云接入笔记
*
* 模式 A — 本地同步盘(推荐,有坚果云客户端时):
* .env 设置 NUTSTORE_VAULT=D:/Nutstore/我的坚果云/Obsidian
* npm run sync:nutstore
*
* 模式 B — WebDAV(本机没有客户端时):
* .env 设置:
* NUTSTORE_WEBDAV_USER=你的坚果云邮箱
* NUTSTORE_WEBDAV_PASS=应用密码(账号安全页生成,不是登录密码)
* NUTSTORE_WEBDAV_PATH=/dav/你的库文件夹名 (可选,默认整盘 dav 根下递归 .md)
* npm run sync:nutstore -- --webdav
*
* 其它:
* --dry-run 只列文件
* --clean 同步前清空 src/content/posts
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadEnv } from './lib/load-env.mjs';
import {
IMAGE_EXT,
ensureDir,
ensureTitle,
parseFrontmatter,
shouldPublish,
syncLocalVault,
} from './lib/vault-sync.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
loadEnv(root);
const dryRun = process.argv.includes('--dry-run');
const clean = process.argv.includes('--clean');
const forceWebdav = process.argv.includes('--webdav');
const postsOut = path.join(root, 'src/content/posts');
const assetsOut = path.join(root, 'public/assets');
const cacheDir = path.join(root, '.cache/nutstore-vault');
const DAV_BASE = (process.env.NUTSTORE_WEBDAV_URL || 'https://dav.jianguoyun.com/dav').replace(
/\/$/,
'',
);
function authHeader() {
const user = process.env.NUTSTORE_WEBDAV_USER || process.env.NUTSTORE_USER;
const pass = process.env.NUTSTORE_WEBDAV_PASS || process.env.NUTSTORE_PASS;
if (!user || !pass) {
throw new Error(
'缺少 WebDAV 凭证。请在 .env 设置 NUTSTORE_WEBDAV_USER / NUTSTORE_WEBDAV_PASS\n' +
'应用密码:坚果云网页 → 账户信息 → 安全选项 → 添加应用密码',
);
}
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
function joinDav(base, rel) {
const b = base.replace(/\/$/, '');
const r = String(rel || '').replace(/^\/+/, '');
return r ? `${b}/${r}` : b;
}
/** PROPFIND 列出一层;返回 { href, isDir, displayName }[] */
async function propfind(url, depth = '1') {
const res = await fetch(url, {
method: 'PROPFIND',
headers: {
Authorization: authHeader(),
Depth: depth,
'Content-Type': 'application/xml; charset=utf-8',
},
body: `
`,
});
if (!res.ok) {
const t = await res.text().catch(() => '');
throw new Error(`WebDAV PROPFIND 失败 ${res.status} ${url}\n${t.slice(0, 300)}`);
}
const xml = await res.text();
const responses = [];
const chunks = xml.split(/]/i).slice(1);
for (const chunk of chunks) {
const hrefM = chunk.match(/([^<]+)<\/d:href>/i);
if (!hrefM) continue;
let href = hrefM[1].trim();
try {
href = decodeURIComponent(href);
} catch {
/* keep raw */
}
const isDir = //i.test(chunk) || //i.test(chunk);
const nameM = chunk.match(/([^<]*)<\/d:displayname>/i);
const displayName = nameM ? nameM[1] : path.posix.basename(href.replace(/\/$/, ''));
responses.push({ href, isDir, displayName });
}
return responses;
}
/** 递归收集 .md / 图片的 WebDAV 路径(相对 dav root 的 path) */
async function listRemoteFiles(startUrl) {
const found = [];
const queue = [startUrl];
const seen = new Set();
while (queue.length) {
const url = queue.shift();
if (seen.has(url)) continue;
seen.add(url);
const items = await propfind(url, '1');
// 第一项通常是自己
for (const item of items) {
const abs = item.href.startsWith('http')
? item.href
: `https://dav.jianguoyun.com${item.href.startsWith('/') ? '' : '/'}${item.href}`;
// 跳过自身
const normSelf = url.replace(/\/$/, '');
const normItem = abs.replace(/\/$/, '');
if (normItem === normSelf) continue;
if (item.isDir) {
// 跳过隐藏/系统目录
const base = item.displayName || path.posix.basename(normItem);
if (base.startsWith('.')) continue;
if (['private', 'templates', 'template', '.obsidian', '.trash'].includes(base.toLowerCase()))
continue;
queue.push(normItem.endsWith('/') ? normItem : normItem + '/');
continue;
}
const ext = path.extname(normItem).toLowerCase();
if (ext === '.md' || ext === '.mdx' || IMAGE_EXT.has(ext)) {
found.push(abs);
}
}
}
return found;
}
function remoteToRel(remoteUrl, rootUrl) {
const r = remoteUrl.replace(/\/$/, '');
const root = rootUrl.replace(/\/$/, '');
let rel = r.startsWith(root) ? r.slice(root.length) : r;
rel = rel.replace(/^\/+/, '');
try {
rel = decodeURIComponent(rel);
} catch {
/* ignore */
}
return rel;
}
async function downloadToCache(remoteFiles, rootUrl) {
ensureDir(cacheDir);
// 清空缓存目录
for (const name of fs.readdirSync(cacheDir)) {
fs.rmSync(path.join(cacheDir, name), { recursive: true, force: true });
}
let n = 0;
for (const url of remoteFiles) {
const rel = remoteToRel(url, rootUrl);
const dest = path.join(cacheDir, rel);
if (dryRun) {
console.log(` ~ ${rel}`);
n++;
continue;
}
ensureDir(path.dirname(dest));
const res = await fetch(url, { headers: { Authorization: authHeader() } });
if (!res.ok) {
console.warn(` ! 下载失败 ${res.status}: ${rel}`);
continue;
}
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(dest, buf);
n++;
if (n % 20 === 0) console.log(` … 已下载 ${n} 个文件`);
}
return n;
}
function resolveLocalVault() {
return (
process.env.NUTSTORE_VAULT ||
process.env.NUTSTORE_LOCAL_PATH ||
process.env.OBSIDIAN_VAULT ||
''
);
}
async function main() {
const local = resolveLocalVault();
const hasWebdavCred =
Boolean(process.env.NUTSTORE_WEBDAV_USER || process.env.NUTSTORE_USER) &&
Boolean(process.env.NUTSTORE_WEBDAV_PASS || process.env.NUTSTORE_PASS);
// 有本地路径且存在、未强制 webdav → 本地模式
if (local && fs.existsSync(local) && !forceWebdav) {
console.log('模式: 坚果云本地同步盘');
console.log(`Vault: ${local}`);
console.log(`Out: ${postsOut}(统一文章流)`);
const r = syncLocalVault({ vault: local, postsOut, assetsOut, dryRun, clean });
console.log(
dryRun
? `[dry-run] 将同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}`
: `完成:同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}`,
);
return;
}
// 无本机库 → WebDAV(填了凭证即可,不必强加 --webdav)
if (!hasWebdavCred) {
console.error('笔记不在本机时,请用坚果云 WebDAV:\n');
console.error('1. 打开 https://www.jianguoyun.com/d/account → 安全选项 → 添加应用密码');
console.error('2. 编辑项目根目录 .env:');
console.error(' NUTSTORE_WEBDAV_USER=你的邮箱');
console.error(' NUTSTORE_WEBDAV_PASS=应用密码(不是登录密码)');
console.error(' NUTSTORE_WEBDAV_PATH=库文件夹名 # 可选,先留空可扫整个盘');
console.error('3. 运行: npm run sync:nutstore:webdav');
console.error(' 或: npm run sync:nutstore -- --dry-run');
process.exit(1);
}
if (local && !fs.existsSync(local)) {
console.warn(`提示: NUTSTORE_VAULT 路径不存在,改走 WebDAV: ${local}`);
}
// WebDAV 模式
const sub = (process.env.NUTSTORE_WEBDAV_PATH || process.env.NUTSTORE_DAV_PATH || '').replace(
/^\/dav\/?/i,
'',
);
const rootUrl = joinDav(DAV_BASE, sub);
console.log('模式: 坚果云 WebDAV');
console.log(`Remote: ${rootUrl}`);
console.log(`Cache: ${cacheDir}`);
console.log(`Out: ${postsOut}(统一文章流)`);
console.log('列出远程文件…');
const files = await listRemoteFiles(rootUrl.endsWith('/') ? rootUrl : rootUrl + '/');
console.log(`远程匹配 ${files.length} 个文件(md/图片)`);
const downloaded = await downloadToCache(files, rootUrl);
console.log(dryRun ? `[dry-run] 将下载 ${downloaded} 个` : `已下载 ${downloaded} 个到缓存`);
if (dryRun) return;
const r = syncLocalVault({
vault: cacheDir,
postsOut,
assetsOut,
dryRun: false,
clean,
});
console.log(`完成:同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}`);
}
main().catch((e) => {
console.error(e.message || e);
process.exit(1);
});