feat: Nutstore WebDAV sync scripts and env example

This commit is contained in:
secret-deus
2026-07-12 11:46:40 +08:00
parent 50c6f241e6
commit 614f64e1e3
9 changed files with 542 additions and 143 deletions
+24
View File
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import path from 'node:path';
/** 把项目根目录 .env 读进 process.env(不覆盖已有环境变量) */
export function loadEnv(root) {
const file = path.join(root, '.env');
if (!fs.existsSync(file)) return;
const text = fs.readFileSync(file, 'utf8');
for (const line of text.split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const i = t.indexOf('=');
if (i <= 0) continue;
const key = t.slice(0, i).trim();
let val = t.slice(i + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = val;
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 共享:从本地 Vault 目录同步公开笔记到 src/content/notes
*/
import fs from 'node:fs';
import path from 'node:path';
export const IGNORE_DIR = new Set([
'.obsidian',
'.git',
'.trash',
'node_modules',
'private',
'templates',
'template',
'attachments-private',
'.nutstore',
'.sync',
]);
export const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf']);
export function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
export function walk(dir, base = dir, files = []) {
if (!fs.existsSync(dir)) return files;
for (const name of fs.readdirSync(dir)) {
if (name.startsWith('.') && name !== '.') continue;
const full = path.join(dir, name);
const rel = path.relative(base, full);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
if (IGNORE_DIR.has(name.toLowerCase())) continue;
walk(full, base, files);
} else {
files.push({ full, rel });
}
}
return files;
}
export function parseFrontmatter(raw) {
if (!raw.startsWith('---')) return { data: {}, body: raw };
const end = raw.indexOf('\n---', 3);
if (end === -1) return { data: {}, body: raw };
const fm = raw.slice(3, end).trim();
const body = raw.slice(end + 4).replace(/^\r?\n/, '');
const data = {};
for (const line of fm.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!m) continue;
const key = m[1];
let val = m[2].trim();
if (val === 'true') val = true;
else if (val === 'false') val = false;
else if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
data[key] = val;
}
return { data, body, fm };
}
export function shouldPublish(data) {
if (data.publish === false) return false;
if (data.draft === true) return false;
return true;
}
export function ensureTitle(raw, filename) {
const { data, body, fm } = parseFrontmatter(raw);
if (data.title) return raw;
const title = path.basename(filename, path.extname(filename));
if (raw.startsWith('---') && fm !== undefined) {
return `---\n${fm}\ntitle: ${JSON.stringify(title)}\n---\n${body}`;
}
return `---\ntitle: ${JSON.stringify(title)}\npublish: true\n---\n\n${raw}`;
}
/**
* @param {object} opts
* @param {string} opts.vault 源目录
* @param {string} opts.notesOut 目标 notes 目录
* @param {string} opts.assetsOut 目标资源目录
* @param {boolean} [opts.dryRun]
* @param {boolean} [opts.clean] 同步前清空 notesOut 下旧文件(不含子目录 .gitkeep 类)
*/
export function syncLocalVault({ vault, notesOut, assetsOut, dryRun = false, clean = false }) {
if (!fs.existsSync(vault)) {
throw new Error(`Vault 路径不存在: ${vault}`);
}
ensureDir(notesOut);
ensureDir(assetsOut);
if (clean && !dryRun) {
for (const name of fs.readdirSync(notesOut)) {
if (name === '.gitkeep') continue;
fs.rmSync(path.join(notesOut, name), { recursive: true, force: true });
}
}
const files = walk(vault);
let mdCount = 0;
let assetCount = 0;
let skipCount = 0;
for (const { full, rel } of files) {
const ext = path.extname(full).toLowerCase();
const relPosix = rel.split(path.sep).join('/');
if (IMAGE_EXT.has(ext)) {
const dest = path.join(assetsOut, path.basename(full));
if (!dryRun) fs.copyFileSync(full, dest);
assetCount++;
continue;
}
if (ext !== '.md' && ext !== '.mdx') continue;
const raw = fs.readFileSync(full, 'utf8');
const { data } = parseFrontmatter(raw);
if (!shouldPublish(data)) {
skipCount++;
continue;
}
const destRel = relPosix.replace(/\.mdx?$/i, '') + path.extname(full);
const dest = path.join(notesOut, destRel);
const content = ensureTitle(raw, full);
if (dryRun) {
console.log(` + ${destRel}`);
} else {
ensureDir(path.dirname(dest));
fs.writeFileSync(dest, content, 'utf8');
}
mdCount++;
}
return { mdCount, assetCount, skipCount };
}
+275
View File
@@ -0,0 +1,275 @@
/**
* 从坚果云接入笔记
*
* 模式 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/notes
*/
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 notesOut = path.join(root, 'src/content/notes');
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: `<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:resourcetype/>
<d:getcontentlength/>
</d:prop>
</d:propfind>`,
});
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(/<d:response[\s>]/i).slice(1);
for (const chunk of chunks) {
const hrefM = chunk.match(/<d:href>([^<]+)<\/d:href>/i);
if (!hrefM) continue;
let href = hrefM[1].trim();
try {
href = decodeURIComponent(href);
} catch {
/* keep raw */
}
const isDir = /<d:collection\s*\/>/i.test(chunk) || /<d:collection>/i.test(chunk);
const nameM = chunk.match(/<d:displayname>([^<]*)<\/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: ${notesOut}`);
const r = syncLocalVault({ vault: local, notesOut, 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: ${notesOut}`);
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,
notesOut,
assetsOut,
dryRun: false,
clean,
});
console.log(`完成:同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}`);
}
main().catch((e) => {
console.error(e.message || e);
process.exit(1);
});
+31 -131
View File
@@ -1,154 +1,54 @@
/**
* 从 Obsidian Vault 同步公开笔记到 src/content/notes
* 从本地 Obsidian / 坚果云同步目录拉取公开笔记
*
* 用法:
* npm run sync:vault
* OBSIDIAN_VAULT="D:/notes" npm run sync:vault
* node scripts/sync-vault.mjs --dry-run
* OBSIDIAN_VAULT="D:/Nutstore/Notes" npm run sync:vault
* npm run sync:vault -- --dry-run
* npm run sync:vault -- --clean
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadEnv } from './lib/load-env.mjs';
import { syncLocalVault } from './lib/vault-sync.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const dryRun = process.argv.includes('--dry-run');
loadEnv(root);
const dryRun = process.argv.includes('--dry-run');
const clean = process.argv.includes('--clean');
/** 优先:环境变量 → 坚果云本地盘 → 默认 Obsidian Vault */
const DEFAULT_VAULT = 'C:/Users/admin/Documents/Obsidian Vault';
const vault = process.env.OBSIDIAN_VAULT || DEFAULT_VAULT;
const vault =
process.env.OBSIDIAN_VAULT ||
process.env.NUTSTORE_VAULT ||
process.env.NUTSTORE_LOCAL_PATH ||
DEFAULT_VAULT;
const notesOut = path.join(root, 'src/content/notes');
const assetsOut = path.join(root, 'public/assets');
const IGNORE_DIR = new Set([
'.obsidian',
'.git',
'.trash',
'node_modules',
'private',
'templates',
'template',
'attachments-private',
]);
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf']);
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function walk(dir, base = dir, files = []) {
if (!fs.existsSync(dir)) return files;
for (const name of fs.readdirSync(dir)) {
if (name.startsWith('.') && name !== '.') continue;
const full = path.join(dir, name);
const rel = path.relative(base, full);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
if (IGNORE_DIR.has(name.toLowerCase())) continue;
walk(full, base, files);
} else {
files.push({ full, rel });
}
}
return files;
}
function parseFrontmatter(raw) {
if (!raw.startsWith('---')) return { data: {}, body: raw };
const end = raw.indexOf('\n---', 3);
if (end === -1) return { data: {}, body: raw };
const fm = raw.slice(3, end).trim();
const body = raw.slice(end + 4).replace(/^\r?\n/, '');
const data = {};
for (const line of fm.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!m) continue;
const key = m[1];
let val = m[2].trim();
if (val === 'true') val = true;
else if (val === 'false') val = false;
else if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
data[key] = val;
}
return { data, body, fm };
}
function shouldPublish(data) {
if (data.publish === false) return false;
if (data.draft === true) return false;
return true;
}
function ensureTitle(raw, filename) {
const { data, body, fm } = parseFrontmatter(raw);
if (data.title) return raw;
const title = path.basename(filename, path.extname(filename));
if (raw.startsWith('---') && fm !== undefined) {
return `---\n${fm}\ntitle: ${JSON.stringify(title)}\n---\n${body}`;
}
return `---\ntitle: ${JSON.stringify(title)}\npublish: true\n---\n\n${raw}`;
}
function main() {
try {
console.log(`Vault: ${vault}`);
console.log(`Out: ${notesOut}`);
if (!fs.existsSync(vault)) {
console.error('Vault 路径不存在。请设置 OBSIDIAN_VAULT 或修改 scripts/sync-vault.mjs / src/consts.ts');
process.exit(1);
}
ensureDir(notesOut);
ensureDir(assetsOut);
const files = walk(vault);
let mdCount = 0;
let assetCount = 0;
let skipCount = 0;
for (const { full, rel } of files) {
const ext = path.extname(full).toLowerCase();
const relPosix = rel.split(path.sep).join('/');
if (IMAGE_EXT.has(ext)) {
const dest = path.join(assetsOut, path.basename(full));
if (!dryRun) fs.copyFileSync(full, dest);
assetCount++;
continue;
}
if (ext !== '.md' && ext !== '.mdx') continue;
const raw = fs.readFileSync(full, 'utf8');
const { data } = parseFrontmatter(raw);
if (!shouldPublish(data)) {
skipCount++;
continue;
}
// 输出扁平或保留相对路径:保留相对路径更利于子目录笔记
const destRel = relPosix.replace(/\.mdx?$/i, '') + path.extname(full);
const dest = path.join(notesOut, destRel);
const content = ensureTitle(raw, full);
if (dryRun) {
console.log(` + ${destRel}`);
} else {
ensureDir(path.dirname(dest));
fs.writeFileSync(dest, content, 'utf8');
}
mdCount++;
}
const { mdCount, assetCount, skipCount } = syncLocalVault({
vault,
notesOut,
assetsOut,
dryRun,
clean,
});
console.log(
dryRun
? `[dry-run] 将同步 ${mdCount} 篇笔记, ${assetCount} 个资源, 跳过 ${skipCount}`
: `完成:同步 ${mdCount} 篇笔记, ${assetCount} 个资源, 跳过 ${skipCount}`,
);
} catch (e) {
console.error(e.message || e);
console.error(
'提示:把坚果云里的库路径写到 .env 的 NUTSTORE_VAULT,或设环境变量 OBSIDIAN_VAULT',
);
process.exit(1);
}
main();