feat: Nutstore WebDAV sync scripts and env example
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# ========== 站点(可选)==========
|
||||||
|
# SITE_URL=https://notebook.any.me.uk
|
||||||
|
|
||||||
|
# ========== 笔记来源(三选一)==========
|
||||||
|
|
||||||
|
# 1) 本机 Obsidian 库
|
||||||
|
# OBSIDIAN_VAULT=C:/Users/admin/Documents/Obsidian Vault
|
||||||
|
|
||||||
|
# 2) 坚果云「本地同步盘」里的库路径(装了坚果云客户端时推荐)
|
||||||
|
# 在资源管理器打开坚果云目录,复制库文件夹完整路径:
|
||||||
|
# NUTSTORE_VAULT=D:/Nutstore/我的坚果云/Obsidian
|
||||||
|
|
||||||
|
# 3) 坚果云 WebDAV(本机没装客户端时)
|
||||||
|
# 网页:https://www.jianguoyun.com/d/account → 安全选项 → 添加应用密码
|
||||||
|
# NUTSTORE_WEBDAV_USER=you@example.com
|
||||||
|
# NUTSTORE_WEBDAV_PASS=这里填应用密码不是登录密码
|
||||||
|
# 只同步某个子目录时设置(相对 /dav):
|
||||||
|
# NUTSTORE_WEBDAV_PATH=Obsidian
|
||||||
|
# NUTSTORE_WEBDAV_URL=https://dav.jianguoyun.com/dav
|
||||||
@@ -15,6 +15,10 @@ pnpm-debug.log*
|
|||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# nutstore / vault download cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
# OS / IDE
|
# OS / IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -52,21 +52,46 @@ publish: true
|
|||||||
|
|
||||||
`publish: false` 或 `draft: true` 不会上线。
|
`publish: false` 或 `draft: true` 不会上线。
|
||||||
|
|
||||||
## 从 Obsidian 同步
|
## 从 Obsidian / 坚果云同步
|
||||||
|
|
||||||
默认库路径:`C:/Users/admin/Documents/Obsidian Vault`
|
复制 `.env.example` 为 `.env`,按你的接入方式填写。
|
||||||
(可在 `src/consts.ts` 或环境变量 `OBSIDIAN_VAULT` 修改)
|
|
||||||
|
### 方式 A:本地路径(Obsidian 或坚果云同步盘)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
# .env
|
||||||
|
# NUTSTORE_VAULT=D:/Nutstore/我的坚果云/你的库
|
||||||
|
# 或 OBSIDIAN_VAULT=C:/Users/admin/Documents/Obsidian Vault
|
||||||
|
|
||||||
npm run sync:vault
|
npm run sync:vault
|
||||||
# 或
|
# 坚果云别名命令(相同逻辑,优先读 NUTSTORE_VAULT)
|
||||||
$env:OBSIDIAN_VAULT="D:\path\to\vault"
|
npm run sync:nutstore
|
||||||
npm run sync:vault
|
npm run sync:nutstore -- --dry-run
|
||||||
# 只预览不同步
|
npm run sync:nutstore -- --clean # 先清空 notes 再同步
|
||||||
npm run sync:vault -- --dry-run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
会跳过 `.obsidian`、`private`、`templates` 等目录,以及 `publish: false` 的笔记。
|
### 方式 B:坚果云 WebDAV(本机没有客户端)
|
||||||
|
|
||||||
|
1. 打开 [坚果云账户安全](https://www.jianguoyun.com/d/account) → **添加应用密码**
|
||||||
|
2. `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NUTSTORE_WEBDAV_USER=你的邮箱
|
||||||
|
NUTSTORE_WEBDAV_PASS=应用密码
|
||||||
|
NUTSTORE_WEBDAV_PATH=你的库文件夹名
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 拉取:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run sync:nutstore -- --webdav
|
||||||
|
```
|
||||||
|
|
||||||
|
会先下载到 `.cache/nutstore-vault`,再写入 `src/content/notes/`。
|
||||||
|
|
||||||
|
### 发布规则
|
||||||
|
|
||||||
|
会跳过 `.obsidian`、`private`、`templates` 等目录,以及 frontmatter 里 `publish: false` / `draft: true` 的笔记。
|
||||||
|
|
||||||
## 双链
|
## 双链
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -10,7 +10,9 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"sync:vault": "node scripts/sync-vault.mjs"
|
"sync:vault": "node scripts/sync-vault.mjs",
|
||||||
|
"sync:nutstore": "node scripts/sync-nutstore.mjs",
|
||||||
|
"sync:nutstore:webdav": "node scripts/sync-nutstore.mjs --webdav"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^7.0.2",
|
"@astrojs/mdx": "^7.0.2",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -1,154 +1,54 @@
|
|||||||
/**
|
/**
|
||||||
* 从 Obsidian Vault 同步公开笔记到 src/content/notes
|
* 从本地 Obsidian / 坚果云同步目录拉取公开笔记
|
||||||
*
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* npm run sync:vault
|
* npm run sync:vault
|
||||||
* OBSIDIAN_VAULT="D:/notes" npm run sync:vault
|
* OBSIDIAN_VAULT="D:/Nutstore/Notes" npm run sync:vault
|
||||||
* node scripts/sync-vault.mjs --dry-run
|
* npm run sync:vault -- --dry-run
|
||||||
|
* npm run sync:vault -- --clean
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const root = path.resolve(__dirname, '..');
|
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 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 notesOut = path.join(root, 'src/content/notes');
|
||||||
const assetsOut = path.join(root, 'public/assets');
|
const assetsOut = path.join(root, 'public/assets');
|
||||||
|
|
||||||
const IGNORE_DIR = new Set([
|
try {
|
||||||
'.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() {
|
|
||||||
console.log(`Vault: ${vault}`);
|
console.log(`Vault: ${vault}`);
|
||||||
console.log(`Out: ${notesOut}`);
|
console.log(`Out: ${notesOut}`);
|
||||||
if (!fs.existsSync(vault)) {
|
const { mdCount, assetCount, skipCount } = syncLocalVault({
|
||||||
console.error('Vault 路径不存在。请设置 OBSIDIAN_VAULT 或修改 scripts/sync-vault.mjs / src/consts.ts');
|
vault,
|
||||||
process.exit(1);
|
notesOut,
|
||||||
}
|
assetsOut,
|
||||||
|
dryRun,
|
||||||
ensureDir(notesOut);
|
clean,
|
||||||
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++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
dryRun
|
dryRun
|
||||||
? `[dry-run] 将同步 ${mdCount} 篇笔记, ${assetCount} 个资源, 跳过 ${skipCount}`
|
? `[dry-run] 将同步 ${mdCount} 篇笔记, ${assetCount} 个资源, 跳过 ${skipCount}`
|
||||||
: `完成:同步 ${mdCount} 篇笔记, ${assetCount} 个资源, 跳过 ${skipCount}`,
|
: `完成:同步 ${mdCount} 篇笔记, ${assetCount} 个资源, 跳过 ${skipCount}`,
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message || e);
|
||||||
|
console.error(
|
||||||
|
'提示:把坚果云里的库路径写到 .env 的 NUTSTORE_VAULT,或设环境变量 OBSIDIAN_VAULT',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
|
||||||
|
|||||||
+6
-2
@@ -7,7 +7,11 @@ export const SITE_AUTHOR = '你的名字';
|
|||||||
export const SITE_URL = 'https://notebook.any.me.uk';
|
export const SITE_URL = 'https://notebook.any.me.uk';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obsidian 库路径(给 scripts/sync-vault.mjs 用)
|
* 默认本地库路径(scripts/sync-vault.mjs / sync-nutstore.mjs)
|
||||||
* 也可用环境变量 OBSIDIAN_VAULT 覆盖
|
* 优先级实际由脚本决定:
|
||||||
|
* OBSIDIAN_VAULT / NUTSTORE_VAULT 环境变量 或 .env
|
||||||
|
* → 本字段
|
||||||
|
* 坚果云:把同步盘里的库路径写到 .env 的 NUTSTORE_VAULT
|
||||||
|
* WebDAV:见 .env.example 的 NUTSTORE_WEBDAV_*
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_VAULT_PATH = 'C:/Users/admin/Documents/Obsidian Vault';
|
export const DEFAULT_VAULT_PATH = 'C:/Users/admin/Documents/Obsidian Vault';
|
||||||
|
|||||||
Reference in New Issue
Block a user