717 lines
23 KiB
JavaScript
717 lines
23 KiB
JavaScript
#!/usr/bin/env bun
|
|
|
|
import { createInterface } from "node:readline/promises";
|
|
import { stdin as input, stdout as output } from "node:process";
|
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
import { basename, extname, join, relative } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { XMLParser } from "fast-xml-parser";
|
|
import TurndownService from "turndown";
|
|
import { createHash } from "node:crypto";
|
|
import sharp from "sharp";
|
|
|
|
const CONFIG_PATH = process.env.EDGEEVER_CONFIG || join(homedir(), ".edgeever", "config.json");
|
|
const DEFAULT_URL = "http://127.0.0.1:8787";
|
|
const options = parseOptions(process.argv.slice(2));
|
|
|
|
const usage = `Import Evernote ENEX files into EdgeEver through MCP.
|
|
|
|
Usage:
|
|
EDGEEVER_URL=https://your.edgeever.host EDGEEVER_TOKEN=... \\
|
|
bun scripts/import-evernote-enex-via-mcp.mjs --input ./evernote-export
|
|
|
|
bun scripts/import-evernote-enex-via-mcp.mjs --profile prod --input ./evernote-export --dry-run
|
|
|
|
Options:
|
|
--input <path> ENEX file or a directory containing one ENEX file per notebook.
|
|
--profile <name> Read URL and token from ~/.edgeever/config.json.
|
|
--dry-run Parse and print the plan without writing to EdgeEver.
|
|
--yes Import all notebooks without interactive confirmations.
|
|
--include <names> Comma-separated notebook/stack names to import (only these).
|
|
--exclude <names> Comma-separated notebook/stack names to exclude.
|
|
--include-tag <tag> Only import notes containing this tag.
|
|
--exclude-tag <tag> Exclude notes containing this tag.
|
|
`;
|
|
|
|
if (options.help || options.h) {
|
|
console.log(usage);
|
|
process.exit(0);
|
|
}
|
|
|
|
const inputPath = requireValue(options.input, "--input");
|
|
const dryRun = Boolean(options["dry-run"]);
|
|
const assumeYes = Boolean(options.yes);
|
|
const readline = assumeYes ? null : createInterface({ input, output });
|
|
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
} finally {
|
|
readline?.close();
|
|
}
|
|
|
|
async function main() {
|
|
const client = dryRun ? null : await createMcpClient(options.profile);
|
|
let files = await listEnexFiles(inputPath);
|
|
|
|
const includeList = options.include ? options.include.split(",").map((s) => s.trim().toLowerCase()) : null;
|
|
const excludeList = options.exclude ? options.exclude.split(",").map((s) => s.trim().toLowerCase()) : null;
|
|
|
|
if (includeList) {
|
|
files = files.filter(
|
|
(file) =>
|
|
includeList.includes(notebookNameFromFile(file.filePath).toLowerCase()) ||
|
|
(file.stackName && includeList.includes(file.stackName.toLowerCase()))
|
|
);
|
|
}
|
|
|
|
if (excludeList) {
|
|
files = files.filter(
|
|
(file) =>
|
|
!excludeList.includes(notebookNameFromFile(file.filePath).toLowerCase()) &&
|
|
(!file.stackName || !excludeList.includes(file.stackName.toLowerCase()))
|
|
);
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
throw new Error(`No .enex files found: ${inputPath}`);
|
|
}
|
|
|
|
console.log("Analyzing Evernote export files...");
|
|
const notebooksInfo = await getNotebooksPlanInfo(files);
|
|
printPlan(notebooksInfo);
|
|
|
|
if (dryRun) {
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!assumeYes) {
|
|
await confirmOrExit("Start importing the first notebook? Type yes to continue: ");
|
|
}
|
|
|
|
let importedNotebookCount = 0;
|
|
let importedMemoCount = 0;
|
|
|
|
for (const [index, fileInfo] of notebooksInfo.entries()) {
|
|
const stackPrefix = fileInfo.stackName ? `[${fileInfo.stackName}] ` : "";
|
|
console.log(`\n[${index + 1}/${notebooksInfo.length}] Importing notebook: ${stackPrefix}${fileInfo.name}`);
|
|
|
|
// Parse the notes for this notebook only (releasing memory afterwards)
|
|
let notes = await parseEnex(fileInfo.filePath);
|
|
|
|
const excludeTag = options["exclude-tag"] ? options["exclude-tag"].toLowerCase() : null;
|
|
const includeTag = options["include-tag"] ? options["include-tag"].toLowerCase() : null;
|
|
|
|
if (excludeTag) {
|
|
notes = notes.filter((note) => !note.tags.map((t) => t.toLowerCase()).includes(excludeTag));
|
|
}
|
|
|
|
if (includeTag) {
|
|
notes = notes.filter((note) => note.tags.map((t) => t.toLowerCase()).includes(includeTag));
|
|
}
|
|
|
|
const before = await mcpCall(client, "list_notebooks", {});
|
|
|
|
let parentId = null;
|
|
if (fileInfo.stackName) {
|
|
const parentNotebook = await findOrCreateNotebook(client, before.notebooks || [], fileInfo.stackName, index, null);
|
|
parentId = parentNotebook.id;
|
|
}
|
|
|
|
const targetNotebook = await findOrCreateNotebook(client, before.notebooks || [], fileInfo.name, index, parentId);
|
|
const beforeMemoCount = Number(targetNotebook.memoCount || 0);
|
|
|
|
const createdMemoIds = [];
|
|
let skippedMemoCount = 0;
|
|
|
|
await mapLimit(notes, 5, async (note, memoIndex) => {
|
|
const result = await mcpCall(client, "import_memos", {
|
|
source: "evernote",
|
|
notebookId: targetNotebook.id,
|
|
items: [{
|
|
externalId: note.externalId,
|
|
title: note.title,
|
|
contentMarkdown: note.markdown,
|
|
tags: note.tags,
|
|
createdAt: note.createdAt,
|
|
updatedAt: note.updatedAt,
|
|
}],
|
|
});
|
|
const imported = result.results?.[0];
|
|
if (!imported || imported.status === "failed") {
|
|
throw new Error(imported?.error || `No result returned while importing ${note.title || note.externalId}`);
|
|
}
|
|
if (imported.status === "skipped") {
|
|
skippedMemoCount += 1;
|
|
console.log(` ${memoIndex + 1}/${notes.length} Skipped (already imported): ${note.title || "Untitled"}`);
|
|
return;
|
|
}
|
|
if (imported.status !== "created" || !imported.memo) {
|
|
throw new Error(`Unexpected import status "${imported.status}" for ${note.title || note.externalId}`);
|
|
}
|
|
|
|
assertImportedMemoTimestamps(imported.memo, note);
|
|
|
|
// Handle resources (images / attachments) in parallel!
|
|
let updatedMarkdown = note.markdown;
|
|
let hasUploadedResources = false;
|
|
|
|
if (note.resources && note.resources.length > 0) {
|
|
const uploadPromises = note.resources.map(async (res) => {
|
|
try {
|
|
const processedRes = await compressImageLocal(res);
|
|
console.log(` Uploading resource: ${processedRes.filename} (${processedRes.mimeType})`);
|
|
const resource = await uploadResource(client, imported.memo.id, processedRes);
|
|
return { md5: res.md5, url: resource.url };
|
|
} catch (err) {
|
|
console.warn(` [Warning] Failed to upload resource ${res.filename}: ${err.message}`);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const uploaded = (await Promise.all(uploadPromises)).filter(Boolean);
|
|
for (const item of uploaded) {
|
|
const placeholder = `evernote-resource:${item.md5}`;
|
|
if (updatedMarkdown.includes(placeholder)) {
|
|
updatedMarkdown = updatedMarkdown.replaceAll(placeholder, item.url);
|
|
hasUploadedResources = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update markdown if placeholders were replaced
|
|
if (hasUploadedResources) {
|
|
try {
|
|
await mcpCall(client, "update_memo", {
|
|
memoId: imported.memo.id,
|
|
contentMarkdown: updatedMarkdown,
|
|
createdAt: note.createdAt,
|
|
updatedAt: note.updatedAt,
|
|
});
|
|
} catch (err) {
|
|
console.warn(` [Warning] Failed to update memo content with resource URLs: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
createdMemoIds.push(imported.memo.id);
|
|
console.log(` ${memoIndex + 1}/${notes.length} ${note.title || "Untitled"}`);
|
|
});
|
|
|
|
const after = await mcpCall(client, "list_notebooks", {});
|
|
const verifiedNotebook = (after.notebooks || []).find((item) => item.id === targetNotebook.id);
|
|
const afterMemoCount = Number(verifiedNotebook?.memoCount || 0);
|
|
const delta = afterMemoCount - beforeMemoCount;
|
|
|
|
importedNotebookCount += 1;
|
|
importedMemoCount += createdMemoIds.length;
|
|
|
|
console.log(`\nNotebook imported: ${fileInfo.name}`);
|
|
console.log(` Created memos: ${createdMemoIds.length}`);
|
|
console.log(` Skipped memos: ${skippedMemoCount}`);
|
|
console.log(` Notebook memo count before: ${beforeMemoCount}`);
|
|
console.log(` Notebook memo count after: ${afterMemoCount}`);
|
|
|
|
if (delta !== createdMemoIds.length) {
|
|
console.log(` Warning: memo count delta is ${delta}, expected ${createdMemoIds.length}. Check for concurrent edits or retries.`);
|
|
}
|
|
|
|
if (!assumeYes && index < notebooksInfo.length - 1) {
|
|
const answer = await readline.question("Review the result in EdgeEver. Continue with the next notebook? Type yes, or anything else to stop: ");
|
|
|
|
if (answer.trim().toLowerCase() !== "yes") {
|
|
console.log("Stopped by user confirmation.");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`\nDone. Imported notebooks: ${importedNotebookCount}; imported memos: ${importedMemoCount}.`);
|
|
}
|
|
|
|
async function getNotebooksPlanInfo(files) {
|
|
const info = [];
|
|
for (const file of files) {
|
|
const xml = await readFile(file.filePath, "utf8");
|
|
assertReadableEvernoteXml(xml, file.filePath);
|
|
const noteCount = (xml.match(/<note[\s>]/ig) || []).length;
|
|
|
|
info.push({
|
|
name: notebookNameFromFile(file.filePath),
|
|
stackName: file.stackName,
|
|
filePath: file.filePath,
|
|
noteCount,
|
|
});
|
|
}
|
|
return info;
|
|
}
|
|
|
|
async function listEnexFiles(path) {
|
|
if (path.toLowerCase().endsWith(".enex")) {
|
|
return [{ filePath: path, stackName: null }];
|
|
}
|
|
|
|
if ((await stat(path)).isFile()) {
|
|
throw new Error(`${path} is not an .enex file.`);
|
|
}
|
|
|
|
const files = await scanEnexFiles(path, path);
|
|
return files.sort((a, b) => a.filePath.localeCompare(b.filePath, "zh-Hans-CN"));
|
|
}
|
|
|
|
async function scanEnexFiles(dir, baseDir) {
|
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
let files = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files = files.concat(await scanEnexFiles(fullPath, baseDir));
|
|
} else if (entry.isFile() && isSupportedExportFile(entry.name)) {
|
|
const relDir = relative(baseDir, dir);
|
|
files.push({
|
|
filePath: fullPath,
|
|
stackName: relDir ? basename(relDir) : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
async function parseEnex(filePath) {
|
|
const xml = await readFile(filePath, "utf8");
|
|
assertReadableEvernoteXml(xml, filePath);
|
|
|
|
const parser = new XMLParser({
|
|
ignoreAttributes: false,
|
|
parseTagValue: false,
|
|
trimValues: false,
|
|
cdataPropName: "__cdata",
|
|
isArray: (_, jpath) =>
|
|
jpath === "en-export.note" ||
|
|
jpath === "en-export.note.tag" ||
|
|
jpath === "en-export.note.resource",
|
|
});
|
|
const parsed = parser.parse(xml);
|
|
const notes = parsed?.["en-export"]?.note || [];
|
|
|
|
return notes.map((note, index) => normalizeNote(note, index));
|
|
}
|
|
|
|
function isSupportedExportFile(filePath) {
|
|
const normalized = filePath.toLowerCase();
|
|
return normalized.endsWith(".enex");
|
|
}
|
|
|
|
function assertReadableEvernoteXml(xml, filePath) {
|
|
if (/encoding\s*=\s*["']base64:aes["']/i.test(xml)) {
|
|
throw new Error(
|
|
`${filePath} is not a supported ENEX file. Export or convert your notes to .enex before importing.`
|
|
);
|
|
}
|
|
|
|
if (!/<en-export[\s>]/i.test(xml) || !/<note[\s>]/i.test(xml)) {
|
|
throw new Error(`${filePath} does not look like a readable Evernote XML export.`);
|
|
}
|
|
}
|
|
|
|
function normalizeNote(note, index) {
|
|
const title = getText(note.title)?.trim() || `Untitled ${index + 1}`;
|
|
const content = getText(note.content) || "";
|
|
const createdAt = enexDateToIso(getText(note.created));
|
|
const updatedAt = enexDateToIso(getText(note.updated));
|
|
const tags = Array.isArray(note.tag)
|
|
? note.tag.map((tag) => getText(tag)?.trim()).filter(Boolean)
|
|
: [];
|
|
|
|
if (!createdAt || !updatedAt) {
|
|
throw new Error(`Note "${title}" is missing a valid Evernote created/updated timestamp.`);
|
|
}
|
|
|
|
const externalId = getText(note.guid)?.trim()
|
|
|| createHash("sha256").update([title, createdAt, updatedAt, content].join("\0")).digest("hex");
|
|
|
|
let markdown = enexContentToMarkdown(content);
|
|
const MAX_MARKDOWN_LENGTH = 400000; // Safe limit to prevent exceeding D1 1MB SQLITE_TOOBIG limit
|
|
if (markdown.length > MAX_MARKDOWN_LENGTH) {
|
|
markdown = markdown.slice(0, MAX_MARKDOWN_LENGTH) + "\n\n*(Note truncated during migration due to Cloudflare D1 1MB database limit)*";
|
|
console.warn(`\n [Warning] Note "${title}" truncated to ${MAX_MARKDOWN_LENGTH} chars (exceeded D1 limits).`);
|
|
}
|
|
|
|
const resources = [];
|
|
if (note.resource) {
|
|
const rawResources = Array.isArray(note.resource) ? note.resource : [note.resource];
|
|
for (const res of rawResources) {
|
|
const mimeType = getText(res.mime)?.trim() || "application/octet-stream";
|
|
const dataBase64 = getText(res.data)?.replace(/\s+/g, "") || "";
|
|
if (!dataBase64) continue;
|
|
|
|
const attrs = res["resource-attributes"];
|
|
const filename = getText(attrs?.["file-name"])?.trim() || `file_${resources.length}`;
|
|
const md5 = createHash("md5").update(Buffer.from(dataBase64, "base64")).digest("hex");
|
|
|
|
resources.push({
|
|
dataBase64,
|
|
mimeType,
|
|
filename,
|
|
md5,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
externalId,
|
|
title: title.slice(0, 160),
|
|
markdown,
|
|
tags,
|
|
createdAt,
|
|
updatedAt,
|
|
resources,
|
|
};
|
|
}
|
|
|
|
function assertImportedMemoTimestamps(memo, note) {
|
|
if (memo.createdAt !== note.createdAt || memo.updatedAt !== note.updatedAt) {
|
|
throw new Error(
|
|
`Timestamp mismatch for "${note.title}". Expected createdAt=${note.createdAt}, updatedAt=${note.updatedAt}; got createdAt=${memo.createdAt}, updatedAt=${memo.updatedAt}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function enexContentToMarkdown(content) {
|
|
let body = content
|
|
.replace(/<\?xml[\s\S]*?\?>/i, "")
|
|
.replace(/<!DOCTYPE[\s\S]*?>/i, "")
|
|
.trim();
|
|
|
|
// Prevent Turndown from stripping empty en-media elements
|
|
body = body.replace(/<en-media([^>]*?)(?:\/>|>\s*<\/en-media>)/g, '<en-media$1>_</en-media>');
|
|
|
|
const turndown = new TurndownService({
|
|
headingStyle: "atx",
|
|
bulletListMarker: "-",
|
|
codeBlockStyle: "fenced",
|
|
});
|
|
|
|
turndown.addRule("evernoteTodo", {
|
|
filter: "en-todo",
|
|
replacement: (_, node) => {
|
|
const checked = node.getAttribute("checked") === "true" || node.getAttribute("checked") === "checked";
|
|
return checked ? "[x] " : "[ ] ";
|
|
},
|
|
});
|
|
|
|
turndown.addRule("evernoteCrypt", {
|
|
filter: "en-crypt",
|
|
replacement: () => "[Encrypted Evernote content]",
|
|
});
|
|
|
|
turndown.addRule("evernoteMedia", {
|
|
filter: "en-media",
|
|
replacement: (_, node) => {
|
|
const type = node.getAttribute("type") || "attachment";
|
|
const hash = node.getAttribute("hash") || "unknown";
|
|
return type.startsWith("image/")
|
|
? `\n\n\n\n`
|
|
: `\n\n[Evernote attachment ${hash}](evernote-resource:${hash})\n\n`;
|
|
},
|
|
});
|
|
|
|
return turndown
|
|
.turndown(body || "")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
async function findOrCreateNotebook(client, notebooks, name, index, parentId = null) {
|
|
const existing = notebooks.find((notebook) => notebook.parentId === parentId && notebook.name === name);
|
|
|
|
if (existing) {
|
|
console.log(`Using existing notebook: ${name}`);
|
|
return existing;
|
|
}
|
|
|
|
const result = await mcpCall(client, "create_notebook", {
|
|
name: name.slice(0, 80),
|
|
parentId,
|
|
sortOrder: 1000 + index,
|
|
});
|
|
|
|
console.log(`Created notebook: ${result.notebook.name}`);
|
|
return result.notebook;
|
|
}
|
|
|
|
async function createMcpClient(profileName) {
|
|
const config = await readConfig();
|
|
const profile = profileName ? config.profiles?.[profileName] : undefined;
|
|
const baseUrl = (process.env.EDGEEVER_URL || profile?.url || DEFAULT_URL).replace(/\/+$/, "");
|
|
const token = process.env.EDGEEVER_TOKEN || profile?.token;
|
|
|
|
if (!token) {
|
|
throw new Error("EDGEEVER_TOKEN is required, or configure a profile with `bun run cli -- profile set`.");
|
|
}
|
|
|
|
return { baseUrl, token, nextId: 1 };
|
|
}
|
|
|
|
async function mcpCall(client, toolName, args, retries = 5, delay = 1000) {
|
|
const id = client.nextId++;
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
try {
|
|
const response = await fetch(`${client.baseUrl}/mcp`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${client.token}`,
|
|
Accept: "application/json, text/event-stream",
|
|
"Content-Type": "application/json",
|
|
"MCP-Protocol-Version": "2025-11-25",
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
method: "tools/call",
|
|
params: {
|
|
name: toolName,
|
|
arguments: args,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const body = await response.json().catch(() => null);
|
|
|
|
if (!response.ok || body?.error || body?.result?.isError) {
|
|
const toolError = body?.result?.structuredContent?.error?.message
|
|
|| body?.result?.content?.find((item) => item.type === "text")?.text;
|
|
throw new Error(toolError || body?.error?.message || `${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return parseMcpToolResult(body.result);
|
|
} catch (error) {
|
|
if (attempt === retries) {
|
|
throw error;
|
|
}
|
|
console.warn(`\n [MCP Warning] Attempt ${attempt} failed: ${error.message}. Retrying in ${delay}ms...`);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
delay *= 2; // Exponential backoff
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseMcpToolResult(result) {
|
|
if (result?.structuredContent && typeof result.structuredContent === "object") {
|
|
return result.structuredContent;
|
|
}
|
|
|
|
const text = result?.content?.find((item) => item.type === "text")?.text;
|
|
|
|
if (!text) {
|
|
return result;
|
|
}
|
|
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
async function compressImageLocal(res) {
|
|
if (!res.mimeType.startsWith("image/") || res.mimeType === "image/gif") {
|
|
return res;
|
|
}
|
|
|
|
try {
|
|
const inputBuffer = Buffer.from(res.dataBase64, "base64");
|
|
const pipeline = sharp(inputBuffer);
|
|
const metadata = await pipeline.metadata();
|
|
|
|
let needsResize = false;
|
|
const maxEdge = 2560;
|
|
let width = metadata.width;
|
|
let height = metadata.height;
|
|
|
|
if (width && height) {
|
|
const currentMax = Math.max(width, height);
|
|
if (currentMax > maxEdge) {
|
|
needsResize = true;
|
|
const scale = maxEdge / currentMax;
|
|
width = Math.round(width * scale);
|
|
height = Math.round(height * scale);
|
|
}
|
|
}
|
|
|
|
if (needsResize) {
|
|
pipeline.resize(width, height, { fit: "inside" });
|
|
}
|
|
|
|
const outputBuffer = await pipeline.webp({ quality: 82 }).toBuffer();
|
|
|
|
if (outputBuffer.byteLength < inputBuffer.byteLength) {
|
|
const compressedBase64 = outputBuffer.toString("base64");
|
|
const origExt = extname(res.filename);
|
|
const newFilename = origExt
|
|
? res.filename.slice(0, -origExt.length) + ".webp"
|
|
: res.filename + ".webp";
|
|
|
|
console.log(` [Local Compress] ${res.filename} (${(inputBuffer.byteLength / 1024).toFixed(1)}KB) -> ${newFilename} (${(outputBuffer.byteLength / 1024).toFixed(1)}KB)`);
|
|
|
|
return {
|
|
...res,
|
|
dataBase64: compressedBase64,
|
|
mimeType: "image/webp",
|
|
filename: newFilename,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
console.warn(` [Local Compress Warning] Failed to compress image ${res.filename}: ${err.message}`);
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
async function uploadResource(client, memoId, res, retries = 5, delay = 1000) {
|
|
const formData = new FormData();
|
|
const fileBlob = new Blob([Buffer.from(res.dataBase64, "base64")], { type: res.mimeType });
|
|
formData.append("file", fileBlob, res.filename);
|
|
|
|
const url = `${client.baseUrl}/api/v1/memos/${memoId}/resources`;
|
|
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${client.token}`,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`${response.status} ${response.statusText} - ${errorText}`);
|
|
}
|
|
|
|
const body = await response.json();
|
|
return body.resource;
|
|
} catch (error) {
|
|
if (attempt === retries) {
|
|
throw error;
|
|
}
|
|
console.warn(`\n [Upload Warning] Attempt ${attempt} failed to upload ${res.filename}: ${error.message}. Retrying in ${delay}ms...`);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
delay *= 2; // Exponential backoff
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readConfig() {
|
|
try {
|
|
const value = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
return value && typeof value === "object" ? value : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function confirmOrExit(question) {
|
|
const answer = await readline.question(question);
|
|
|
|
if (answer.trim().toLowerCase() !== "yes") {
|
|
console.log("Canceled.");
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
function printPlan(notebooksInfo) {
|
|
const total = notebooksInfo.reduce((sum, item) => sum + item.noteCount, 0);
|
|
|
|
console.log("Evernote import plan:");
|
|
console.log(` Notebooks: ${notebooksInfo.length}`);
|
|
console.log(` Notes: ${total}`);
|
|
|
|
for (const item of notebooksInfo) {
|
|
const stackPrefix = item.stackName ? `[${item.stackName}] ` : "";
|
|
console.log(` - ${stackPrefix}${item.name}: ${item.noteCount} notes (${item.filePath})`);
|
|
}
|
|
}
|
|
|
|
function getText(value) {
|
|
if (value === undefined || value === null) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "object") {
|
|
return value.__cdata || value["#text"] || "";
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
function enexDateToIso(value) {
|
|
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value || "");
|
|
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
|
|
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}.000Z`;
|
|
}
|
|
|
|
function notebookNameFromFile(filePath) {
|
|
return basename(filePath, extname(filePath)).trim().slice(0, 80) || "Evernote Import";
|
|
}
|
|
|
|
function parseOptions(args) {
|
|
const parsed = {};
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
|
|
if (!arg.startsWith("--")) {
|
|
continue;
|
|
}
|
|
|
|
const key = arg.slice(2);
|
|
const next = args[index + 1];
|
|
|
|
if (!next || next.startsWith("--")) {
|
|
parsed[key] = "true";
|
|
continue;
|
|
}
|
|
|
|
parsed[key] = next;
|
|
index += 1;
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function requireValue(value, name) {
|
|
if (!value) {
|
|
console.error(`${name} is required.\n`);
|
|
console.error(usage);
|
|
process.exit(1);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
async function mapLimit(array, limit, fn) {
|
|
const results = [];
|
|
const executing = new Set();
|
|
for (let i = 0; i < array.length; i++) {
|
|
const item = array[i];
|
|
const p = Promise.resolve().then(() => fn(item, i));
|
|
results.push(p);
|
|
executing.add(p);
|
|
const clean = () => executing.delete(p);
|
|
p.then(clean, clean);
|
|
if (executing.size >= limit) {
|
|
await Promise.race(executing);
|
|
}
|
|
}
|
|
return Promise.all(results);
|
|
}
|