| const fs = require('fs'); |
| const path = require('path'); |
| const yaml = require('js-yaml'); |
| const { FileError, wrapAsyncError } = require('./generator/utils/errors'); |
| const { createLogger, isVerbose, startTimer } = require('./generator/utils/logger'); |
|
|
| const log = createLogger('import-bookmarks'); |
|
|
| |
| const BOOKMARKS_DIR = 'bookmarks'; |
| |
| const CONFIG_USER_DIR = 'config/user'; |
| |
| const CONFIG_DEFAULT_DIR = 'config/_default'; |
| |
| const CONFIG_USER_PAGES_DIR = path.join(CONFIG_USER_DIR, 'pages'); |
| |
| const MODULAR_OUTPUT_FILE = path.join(CONFIG_USER_PAGES_DIR, 'bookmarks.yml'); |
| |
| const MODULAR_DEFAULT_BOOKMARKS_FILE = 'config/_default/pages/bookmarks.yml'; |
|
|
| const USER_SITE_YML = path.join(CONFIG_USER_DIR, 'site.yml'); |
| const DEFAULT_SITE_YML = path.join(CONFIG_DEFAULT_DIR, 'site.yml'); |
|
|
| function ensureUserConfigInitialized() { |
| if (fs.existsSync(CONFIG_USER_DIR)) { |
| return { initialized: false, source: 'existing' }; |
| } |
|
|
| if (fs.existsSync(CONFIG_DEFAULT_DIR)) { |
| fs.cpSync(CONFIG_DEFAULT_DIR, CONFIG_USER_DIR, { recursive: true }); |
| log.info('config/user 不存在,已从 config/_default 初始化用户配置(完全替换策略)'); |
| return { initialized: true, source: '_default' }; |
| } |
|
|
| fs.mkdirSync(CONFIG_USER_DIR, { recursive: true }); |
| log.warn('未找到 config/_default,已创建空的 config/user;建议补齐 site.yml 与 pages/*.yml'); |
| return { initialized: true, source: 'empty' }; |
| } |
|
|
| function ensureUserSiteYmlExists() { |
| if (fs.existsSync(USER_SITE_YML)) { |
| return true; |
| } |
|
|
| if (fs.existsSync(DEFAULT_SITE_YML)) { |
| if (!fs.existsSync(CONFIG_USER_DIR)) { |
| fs.mkdirSync(CONFIG_USER_DIR, { recursive: true }); |
| } |
| fs.copyFileSync(DEFAULT_SITE_YML, USER_SITE_YML); |
| log.info('未找到 config/user/site.yml,已从 config/_default/site.yml 复制'); |
| return true; |
| } |
|
|
| log.warn( |
| '未找到可用的 site.yml,无法自动更新导航;请在 config/user/site.yml 添加 navigation(含 id: bookmarks)' |
| ); |
| return false; |
| } |
|
|
| function upsertBookmarksNavInSiteYml(siteYmlPath) { |
| try { |
| const raw = fs.readFileSync(siteYmlPath, 'utf8'); |
| const loaded = yaml.load(raw); |
|
|
| if (!loaded || typeof loaded !== 'object') { |
| return { updated: false, reason: 'site_yml_not_object' }; |
| } |
|
|
| const navigation = loaded.navigation; |
|
|
| if (Array.isArray(navigation) && navigation.some((item) => item && item.id === 'bookmarks')) { |
| return { updated: false, reason: 'already_present' }; |
| } |
|
|
| if (navigation !== undefined && !Array.isArray(navigation)) { |
| return { updated: false, reason: 'navigation_not_array' }; |
| } |
|
|
| const lines = raw.split(/\r?\n/); |
| const navLineIndex = lines.findIndex((line) => /^navigation\s*:/.test(line)); |
|
|
| const itemIndent = ' '; |
| const propIndent = `${itemIndent} `; |
| const snippet = [ |
| `${itemIndent}- name: 书签`, |
| `${propIndent}icon: fas fa-bookmark`, |
| `${propIndent}id: bookmarks`, |
| ]; |
|
|
| if (navLineIndex === -1) { |
| |
| const normalized = raw.endsWith('\n') ? raw : `${raw}\n`; |
| const spacer = normalized.trim().length === 0 ? '' : '\n'; |
| const updatedRaw = `${normalized}${spacer}navigation:\n${snippet.join('\n')}\n`; |
| fs.writeFileSync(siteYmlPath, updatedRaw, 'utf8'); |
| return { updated: true, reason: 'added_navigation_block' }; |
| } |
|
|
| |
| let insertAt = lines.length; |
| for (let i = navLineIndex + 1; i < lines.length; i++) { |
| const line = lines[i]; |
| if (line.trim() === '' || /^\s*#/.test(line)) continue; |
| if (/^[A-Za-z0-9_-]+\s*:/.test(line)) { |
| insertAt = i; |
| break; |
| } |
| } |
|
|
| const updatedLines = [...lines]; |
| |
| if (insertAt > 0 && updatedLines[insertAt - 1].trim() !== '') { |
| snippet.unshift(''); |
| } |
| updatedLines.splice(insertAt, 0, ...snippet); |
|
|
| fs.writeFileSync(siteYmlPath, `${updatedLines.join('\n')}\n`, 'utf8'); |
| return { updated: true, reason: 'updated_navigation_block' }; |
| } catch (error) { |
| return { updated: false, reason: 'error', error }; |
| } |
| } |
|
|
| |
| const ICON_MAPPING = { |
| 'github.com': 'fab fa-github', |
| 'stackoverflow.com': 'fab fa-stack-overflow', |
| 'youtube.com': 'fab fa-youtube', |
| 'twitter.com': 'fab fa-twitter', |
| 'facebook.com': 'fab fa-facebook', |
| 'instagram.com': 'fab fa-instagram', |
| 'linkedin.com': 'fab fa-linkedin', |
| 'reddit.com': 'fab fa-reddit', |
| 'amazon.com': 'fab fa-amazon', |
| 'google.com': 'fab fa-google', |
| 'gmail.com': 'fas fa-envelope', |
| 'drive.google.com': 'fab fa-google-drive', |
| 'docs.google.com': 'fas fa-file-alt', |
| 'medium.com': 'fab fa-medium', |
| 'dev.to': 'fab fa-dev', |
| 'gitlab.com': 'fab fa-gitlab', |
| 'bitbucket.org': 'fab fa-bitbucket', |
| 'wikipedia.org': 'fab fa-wikipedia-w', |
| 'discord.com': 'fab fa-discord', |
| 'slack.com': 'fab fa-slack', |
| 'apple.com': 'fab fa-apple', |
| 'microsoft.com': 'fab fa-microsoft', |
| 'android.com': 'fab fa-android', |
| 'twitch.tv': 'fab fa-twitch', |
| 'spotify.com': 'fab fa-spotify', |
| 'pinterest.com': 'fab fa-pinterest', |
| 'telegram.org': 'fab fa-telegram', |
| 'whatsapp.com': 'fab fa-whatsapp', |
| 'netflix.com': 'fas fa-film', |
| 'trello.com': 'fab fa-trello', |
| 'wordpress.com': 'fab fa-wordpress', |
| jira: 'fab fa-jira', |
| 'atlassian.com': 'fab fa-atlassian', |
| 'dropbox.com': 'fab fa-dropbox', |
| npm: 'fab fa-npm', |
| 'docker.com': 'fab fa-docker', |
| 'python.org': 'fab fa-python', |
| javascript: 'fab fa-js', |
| 'php.net': 'fab fa-php', |
| java: 'fab fa-java', |
| 'codepen.io': 'fab fa-codepen', |
| 'behance.net': 'fab fa-behance', |
| 'dribbble.com': 'fab fa-dribbble', |
| 'tumblr.com': 'fab fa-tumblr', |
| 'vimeo.com': 'fab fa-vimeo', |
| 'flickr.com': 'fab fa-flickr', |
| 'github.io': 'fab fa-github', |
| 'airbnb.com': 'fab fa-airbnb', |
| bitcoin: 'fab fa-bitcoin', |
| 'paypal.com': 'fab fa-paypal', |
| ethereum: 'fab fa-ethereum', |
| steam: 'fab fa-steam', |
| }; |
|
|
| |
| function getLatestBookmarkFile() { |
| try { |
| |
| if (!fs.existsSync(BOOKMARKS_DIR)) { |
| fs.mkdirSync(BOOKMARKS_DIR, { recursive: true }); |
| log.warn('bookmarks 目录不存在,已创建;未找到 HTML 书签文件', { dir: BOOKMARKS_DIR }); |
| return null; |
| } |
|
|
| |
| const files = fs |
| .readdirSync(BOOKMARKS_DIR) |
| .filter((file) => file.toLowerCase().endsWith('.html')); |
|
|
| if (files.length === 0) { |
| log.warn('未找到任何 HTML 书签文件', { dir: BOOKMARKS_DIR }); |
| return null; |
| } |
|
|
| |
| const parseFilenameTimestamp = (filename) => { |
| const base = path.basename(filename); |
|
|
| |
| const isoMatch = base.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/); |
| if (isoMatch) { |
| const [, year, month, day, hour, minute, second] = isoMatch; |
| return Date.UTC( |
| Number(year), |
| Number(month) - 1, |
| Number(day), |
| Number(hour), |
| Number(minute), |
| Number(second) |
| ); |
| } |
|
|
| |
| const dateMatch = base.match(/(\d{4})(\d{2})(\d{2})/); |
| if (dateMatch) { |
| const [, year, month, day] = dateMatch; |
| return Date.UTC(Number(year), Number(month) - 1, Number(day)); |
| } |
|
|
| return 0; |
| }; |
|
|
| |
| const fileStats = files.map((file) => { |
| const filenameTimestamp = parseFilenameTimestamp(file); |
| const mtime = fs.statSync(path.join(BOOKMARKS_DIR, file)).mtime.getTime(); |
| return { |
| file, |
| timestamp: filenameTimestamp || mtime, |
| }; |
| }); |
|
|
| |
| fileStats.sort((a, b) => b.timestamp - a.timestamp || a.file.localeCompare(b.file)); |
|
|
| const latestFile = fileStats[0].file; |
| const latestFilePath = path.join(BOOKMARKS_DIR, latestFile); |
|
|
| log.info('选择最新的书签文件', { file: latestFile }); |
|
|
| return latestFilePath; |
| } catch (error) { |
| log.error('查找书签文件时出错', { message: error && error.message ? error.message : error }); |
| if (isVerbose() && error && error.stack) console.error(error.stack); |
| return null; |
| } |
| } |
|
|
| |
| function parseBookmarks(htmlContent) { |
| |
| const folderRegex = /<DT><H3([^>]*)>(.*?)<\/H3>/g; |
| const bookmarkRegex = /<DT><A HREF="([^"]+)"[^>]*>(.*?)<\/A>/g; |
|
|
| |
| const bookmarks = { |
| categories: [], |
| }; |
|
|
| |
| function extractRootBookmarks(htmlContent) { |
| |
| const bookmarkBarMatch = htmlContent.match( |
| /<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i |
| ); |
| if (!bookmarkBarMatch) { |
| return []; |
| } |
| const bookmarkBarStart = bookmarkBarMatch.index + bookmarkBarMatch[0].length; |
|
|
| |
| const remainingAfterBar = htmlContent.substring(bookmarkBarStart); |
| const dlMatch = remainingAfterBar.match(/<DL><p>/i); |
| if (!dlMatch) { |
| return []; |
| } |
|
|
| const bookmarkBarContentStart = bookmarkBarStart + dlMatch.index + dlMatch[0].length; |
|
|
| |
| let depth = 1; |
| let pos = bookmarkBarContentStart; |
| let bookmarkBarContentEnd = htmlContent.length; |
|
|
| while (pos < htmlContent.length && depth > 0) { |
| const remaining = htmlContent.substring(pos); |
| const dlStartIndex = remaining.search(/<DL><p>/i); |
| const dlEndIndex = remaining.search(/<\/DL><p>/i); |
|
|
| if (dlStartIndex !== -1 && (dlEndIndex === -1 || dlStartIndex < dlEndIndex)) { |
| depth++; |
| pos += dlStartIndex + '<DL><p>'.length; |
| } else if (dlEndIndex !== -1) { |
| depth--; |
| pos += dlEndIndex; |
| if (depth === 0) { |
| bookmarkBarContentEnd = pos; |
| } |
| pos += '</DL><p>'.length; |
| } else { |
| break; |
| } |
| } |
|
|
| const bookmarkBarContent = htmlContent.substring( |
| bookmarkBarContentStart, |
| bookmarkBarContentEnd |
| ); |
|
|
| |
| const subfolderRanges = []; |
| const folderRegex = /<DT><H3[^>]*>([^<]+)<\/H3>/g; |
| let folderMatch; |
|
|
| while ((folderMatch = folderRegex.exec(bookmarkBarContent)) !== null) { |
| const folderName = folderMatch[1].trim(); |
| const folderStart = folderMatch.index + folderMatch[0].length; |
|
|
| |
| let folderDepth = 0; |
| let folderPos = folderStart; |
| let folderContentEnd = bookmarkBarContent.length; |
|
|
| |
| const afterFolder = bookmarkBarContent.substring(folderPos); |
| const folderDLMatch = afterFolder.match(/<DL><p>/i); |
| if (folderDLMatch) { |
| folderDepth = 1; |
| folderPos += folderDLMatch.index + folderDLMatch[0].length; |
|
|
| while (folderPos < bookmarkBarContent.length && folderDepth > 0) { |
| const remaining = bookmarkBarContent.substring(folderPos); |
| const dlStartIdx = remaining.search(/<DL><p>/i); |
| const dlEndIdx = remaining.search(/<\/DL><p>/i); |
|
|
| if (dlStartIdx !== -1 && (dlEndIdx === -1 || dlStartIdx < dlEndIdx)) { |
| folderDepth++; |
| folderPos += dlStartIdx + '<DL><p>'.length; |
| } else if (dlEndIdx !== -1) { |
| folderDepth--; |
| folderPos += dlEndIdx; |
| if (folderDepth === 0) { |
| folderContentEnd = folderPos + '</DL><p>'.length; |
| } |
| folderPos += '</DL><p>'.length; |
| } else { |
| break; |
| } |
| } |
|
|
| subfolderRanges.push({ |
| name: folderName, |
| start: folderMatch.index, |
| end: folderContentEnd, |
| }); |
| } |
| } |
|
|
| |
| const rootSites = []; |
| const bookmarkRegex = /<DT><A HREF="([^"]+)"[^>]*>(.*?)<\/A>/g; |
| let bookmarkMatch; |
|
|
| while ((bookmarkMatch = bookmarkRegex.exec(bookmarkBarContent)) !== null) { |
| const bookmarkPos = bookmarkMatch.index; |
| const url = bookmarkMatch[1]; |
| const name = bookmarkMatch[2].trim(); |
|
|
| |
| let inFolder = false; |
| for (const folder of subfolderRanges) { |
| if (bookmarkPos >= folder.start && bookmarkPos < folder.end) { |
| inFolder = true; |
| break; |
| } |
| } |
|
|
| if (!inFolder) { |
| |
| let icon = 'fas fa-link'; |
| for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) { |
| if (url.includes(keyword)) { |
| icon = iconClass; |
| break; |
| } |
| } |
|
|
| rootSites.push({ |
| name: name, |
| url: url, |
| icon: icon, |
| description: '', |
| }); |
| } |
| } |
|
|
| return rootSites; |
| } |
|
|
| |
| function parseNestedFolder(htmlContent, parentPath = [], level = 1) { |
| const folders = []; |
|
|
| |
| const folderRanges = []; |
| const scanRegex = /<DT><H3([^>]*)>(.*?)<\/H3>/g; |
| let scanMatch; |
|
|
| while ((scanMatch = scanRegex.exec(htmlContent)) !== null) { |
| const folderName = scanMatch[2].trim(); |
| const folderStart = scanMatch.index; |
| const folderHeaderEnd = scanMatch.index + scanMatch[0].length; |
|
|
| |
| let depth = 0; |
| let pos = folderHeaderEnd; |
|
|
| |
| const afterFolder = htmlContent.substring(pos); |
| const folderDLMatch = afterFolder.match(/<DL><p>/i); |
| if (folderDLMatch) { |
| depth = 1; |
| pos += folderDLMatch.index + folderDLMatch[0].length; |
|
|
| while (pos < htmlContent.length && depth > 0) { |
| const remaining = htmlContent.substring(pos); |
| const dlStartIdx = remaining.search(/<DL><p>/i); |
| const dlEndIdx = remaining.search(/<\/DL><p>/i); |
|
|
| if (dlStartIdx !== -1 && (dlEndIdx === -1 || dlStartIdx < dlEndIdx)) { |
| depth++; |
| pos += dlStartIdx + '<DL><p>'.length; |
| } else if (dlEndIdx !== -1) { |
| depth--; |
| pos += dlEndIdx; |
| if (depth === 0) { |
| const folderEnd = pos + '</DL><p>'.length; |
| folderRanges.push({ |
| name: folderName, |
| start: folderStart, |
| headerEnd: folderHeaderEnd, |
| end: folderEnd, |
| }); |
| } |
| pos += '</DL><p>'.length; |
| } else { |
| break; |
| } |
| } |
| } |
| } |
|
|
| |
| for (let i = 0; i < folderRanges.length; i++) { |
| const currentFolder = folderRanges[i]; |
|
|
| |
| let isNested = false; |
| for (let j = 0; j < folderRanges.length; j++) { |
| if (i === j) continue; |
|
|
| const otherFolder = folderRanges[j]; |
| |
| if (currentFolder.start > otherFolder.start && currentFolder.end <= otherFolder.end) { |
| isNested = true; |
| break; |
| } |
| } |
|
|
| if (isNested) { |
| continue; |
| } |
|
|
| const folderName = currentFolder.name; |
| const folderStart = currentFolder.start; |
| const folderHeaderEnd = currentFolder.headerEnd; |
| const folderEnd = currentFolder.end; |
|
|
| |
| |
| const folderContent = htmlContent.substring(folderHeaderEnd, folderEnd); |
|
|
| |
| if (!/<DL><p>/i.test(folderContent)) { |
| continue; |
| } |
|
|
| |
| const folder = { |
| name: folderName, |
| icon: 'fas fa-folder', |
| path: [...parentPath, folderName], |
| }; |
|
|
| |
| const testFolderRegex = /<DT><H3([^>]*)>(.*?)<\/H3>/; |
| const hasSubfolders = testFolderRegex.test(folderContent); |
|
|
| |
| const currentLevelSites = parseSitesInFolder(folderContent, folderName); |
|
|
| if (hasSubfolders && level < 4) { |
| |
| const subfolders = parseNestedFolder(folderContent, folder.path, level + 1); |
|
|
| |
| if (level === 1) { |
| folder.subcategories = subfolders; |
| } else if (level === 2) { |
| folder.groups = subfolders; |
| } else if (level === 3) { |
| folder.subgroups = subfolders; |
| } |
|
|
| |
| if (currentLevelSites.length > 0) { |
| folder.sites = currentLevelSites; |
| } |
| } else { |
| |
| folder.sites = currentLevelSites; |
| } |
|
|
| |
| const hasContent = |
| (folder.sites && folder.sites.length > 0) || |
| (folder.subcategories && folder.subcategories.length > 0) || |
| (folder.groups && folder.groups.length > 0) || |
| (folder.subgroups && folder.subgroups.length > 0); |
|
|
| if (hasContent) { |
| folders.push(folder); |
| } |
| } |
|
|
| return folders; |
| } |
|
|
| |
| function parseSitesInFolder(folderContent) { |
| const sites = []; |
| let siteCount = 0; |
|
|
| |
| const subfolderRanges = []; |
| const folderRegex = /<DT><H3[^>]*>([^<]+)<\/H3>/g; |
| let folderMatch; |
|
|
| while ((folderMatch = folderRegex.exec(folderContent)) !== null) { |
| const folderName = folderMatch[1].trim(); |
| const folderStart = folderMatch.index; |
| const folderHeaderEnd = folderMatch.index + folderMatch[0].length; |
|
|
| |
| let folderDepth = 0; |
| let folderPos = folderHeaderEnd; |
| let folderContentEnd = folderContent.length; |
|
|
| |
| const afterFolder = folderContent.substring(folderPos); |
| const folderDLMatch = afterFolder.match(/<DL><p>/i); |
| if (folderDLMatch) { |
| folderDepth = 1; |
| folderPos += folderDLMatch.index + folderDLMatch[0].length; |
|
|
| while (folderPos < folderContent.length && folderDepth > 0) { |
| const remaining = folderContent.substring(folderPos); |
| const dlStartIdx = remaining.search(/<DL><p>/i); |
| const dlEndIdx = remaining.search(/<\/DL><p>/i); |
|
|
| if (dlStartIdx !== -1 && (dlEndIdx === -1 || dlStartIdx < dlEndIdx)) { |
| folderDepth++; |
| folderPos += dlStartIdx + '<DL><p>'.length; |
| } else if (dlEndIdx !== -1) { |
| folderDepth--; |
| folderPos += dlEndIdx; |
| if (folderDepth === 0) { |
| folderContentEnd = folderPos + '</DL><p>'.length; |
| } |
| folderPos += '</DL><p>'.length; |
| } else { |
| break; |
| } |
| } |
|
|
| subfolderRanges.push({ |
| name: folderName, |
| start: folderStart, |
| end: folderContentEnd, |
| }); |
| } |
| } |
|
|
| |
| const bookmarkRegex = /<DT><A HREF="([^"]+)"[^>]*>(.*?)<\/A>/g; |
| let bookmarkMatch; |
|
|
| while ((bookmarkMatch = bookmarkRegex.exec(folderContent)) !== null) { |
| const bookmarkPos = bookmarkMatch.index; |
| const url = bookmarkMatch[1]; |
| const name = bookmarkMatch[2].trim(); |
|
|
| |
| let inSubfolder = false; |
| for (const folder of subfolderRanges) { |
| if (bookmarkPos >= folder.start && bookmarkPos < folder.end) { |
| inSubfolder = true; |
| break; |
| } |
| } |
|
|
| if (!inSubfolder) { |
| |
| let icon = 'fas fa-link'; |
| for (const [keyword, iconClass] of Object.entries(ICON_MAPPING)) { |
| if (url.includes(keyword)) { |
| icon = iconClass; |
| break; |
| } |
| } |
|
|
| sites.push({ |
| name: name, |
| url: url, |
| icon: icon, |
| description: '', |
| }); |
| } |
| } |
|
|
| return sites; |
| } |
|
|
| |
| const rootSites = extractRootBookmarks(htmlContent); |
|
|
| |
| const bookmarkBarMatch = htmlContent.match( |
| /<DT><H3[^>]*PERSONAL_TOOLBAR_FOLDER[^>]*>([^<]+)<\/H3>/i |
| ); |
| if (!bookmarkBarMatch) { |
| log.warn('未找到书签栏文件夹(PERSONAL_TOOLBAR_FOLDER),使用备用方案'); |
| |
| const firstDLMatch = htmlContent.match(/<DL><p>/i); |
| if (!firstDLMatch) { |
| log.error('未找到任何书签容器'); |
| bookmarks.categories = []; |
| } else { |
| const dlStart = firstDLMatch.index + firstDLMatch[0].length; |
| let dlEnd = htmlContent.length; |
| let depth = 1; |
| let pos = dlStart; |
|
|
| while (pos < htmlContent.length && depth > 0) { |
| const remainingContent = htmlContent.substring(pos); |
| const dlStartIndex = remainingContent.search(/<DL><p>/i); |
| const dlEndIndex = remainingContent.search(/<\/DL><p>/i); |
|
|
| if (dlStartIndex !== -1 && (dlEndIndex === -1 || dlStartIndex < dlEndIndex)) { |
| depth++; |
| pos += dlStartIndex + '<DL><p>'.length; |
| } else if (dlEndIndex !== -1) { |
| depth--; |
| pos += dlEndIndex + '</DL><p>'.length; |
| } else { |
| break; |
| } |
| } |
|
|
| dlEnd = pos - '</DL><p>'.length; |
| const bookmarksBarContent = htmlContent.substring(dlStart, dlEnd); |
| bookmarks.categories = parseNestedFolder(bookmarksBarContent); |
| } |
| } else { |
| const bookmarkBarStart = bookmarkBarMatch.index + bookmarkBarMatch[0].length; |
|
|
| |
| const remainingAfterBar = htmlContent.substring(bookmarkBarStart); |
| const dlMatch = remainingAfterBar.match(/<DL><p>/i); |
| if (!dlMatch) { |
| log.error('未找到书签栏的内容容器 <DL><p>'); |
| bookmarks.categories = []; |
| } else { |
| const bookmarkBarContentStart = bookmarkBarStart + dlMatch.index + dlMatch[0].length; |
|
|
| |
| let depth = 1; |
| let pos = bookmarkBarContentStart; |
| let bookmarkBarContentEnd = htmlContent.length; |
|
|
| while (pos < htmlContent.length && depth > 0) { |
| const remaining = htmlContent.substring(pos); |
| const dlStartIndex = remaining.search(/<DL><p>/i); |
| const dlEndIndex = remaining.search(/<\/DL><p>/i); |
|
|
| if (dlStartIndex !== -1 && (dlEndIndex === -1 || dlStartIndex < dlEndIndex)) { |
| depth++; |
| pos += dlStartIndex + '<DL><p>'.length; |
| } else if (dlEndIndex !== -1) { |
| depth--; |
| pos += dlEndIndex; |
| if (depth === 0) { |
| bookmarkBarContentEnd = pos; |
| } |
| pos += '</DL><p>'.length; |
| } else { |
| break; |
| } |
| } |
|
|
| const bookmarkBarContent = htmlContent.substring( |
| bookmarkBarContentStart, |
| bookmarkBarContentEnd |
| ); |
|
|
| |
| bookmarks.categories = parseNestedFolder(bookmarkBarContent); |
| } |
| } |
|
|
| log.info('解析完成', { categories: bookmarks.categories.length }); |
|
|
| |
| if (rootSites.length > 0) { |
| log.info('创建"根目录书签"特殊分类', { sites: rootSites.length }); |
| const rootCategory = { |
| name: '根目录书签', |
| icon: 'fas fa-star', |
| path: ['根目录书签'], |
| sites: rootSites, |
| }; |
|
|
| |
| bookmarks.categories.unshift(rootCategory); |
| log.info('"根目录书签"已插入到分类列表首位'); |
| } |
|
|
| return bookmarks; |
| } |
|
|
| |
| function generateBookmarksYaml(bookmarks) { |
| try { |
| |
| const bookmarksPage = { |
| title: '我的书签', |
| subtitle: '从浏览器导入的书签收藏', |
| categories: bookmarks.categories, |
| }; |
|
|
| |
| const yamlString = yaml.dump(bookmarksPage, { |
| indent: 2, |
| lineWidth: -1, |
| quotingType: '"', |
| }); |
|
|
| |
| const deterministic = process.env.MENAV_BOOKMARKS_DETERMINISTIC === '1'; |
| const timestampLine = deterministic |
| ? '' |
| : `# 由bookmark-processor.js生成于 ${new Date().toISOString()}\n`; |
|
|
| const yamlWithComment = `# 自动生成的书签配置文件 |
| ${timestampLine}# 若要更新,请将新的书签HTML文件放入bookmarks/目录 |
| # 此文件使用模块化配置格式,位于config/user/pages/目录下 |
| |
| ${yamlString}`; |
|
|
| return yamlWithComment; |
| } catch (error) { |
| log.error('生成 YAML 失败', { |
| message: error && error.message ? error.message : String(error), |
| }); |
| if (isVerbose() && error && error.stack) console.error(error.stack); |
| return null; |
| } |
| } |
|
|
| |
| function updateNavigationWithBookmarks() { |
| |
| if (ensureUserSiteYmlExists()) { |
| const result = upsertBookmarksNavInSiteYml(USER_SITE_YML); |
| if (result.updated) { |
| return { updated: true, target: 'site.yml', reason: result.reason }; |
| } |
| if (result.reason === 'already_present') { |
| return { updated: false, target: 'site.yml', reason: 'already_present' }; |
| } |
| if (result.reason === 'error') { |
| return { updated: false, target: 'site.yml', reason: 'error', error: result.error }; |
| } |
| return { updated: false, target: 'site.yml', reason: result.reason }; |
| } |
| return { updated: false, target: null, reason: 'no_site_yml' }; |
| } |
|
|
| |
| async function main() { |
| const elapsedMs = startTimer(); |
| log.info('开始', { version: process.env.npm_package_version }); |
|
|
| |
| log.info('查找书签文件', { dir: BOOKMARKS_DIR }); |
| const bookmarkFile = getLatestBookmarkFile(); |
| if (!bookmarkFile) { |
| log.ok('未找到书签文件,跳过', { dir: BOOKMARKS_DIR }); |
| return; |
| } |
| log.ok('找到书签文件', { file: bookmarkFile }); |
|
|
| try { |
| |
| log.info('读取书签文件', { file: bookmarkFile }); |
| const htmlContent = fs.readFileSync(bookmarkFile, 'utf8'); |
| log.ok('读取成功', { chars: htmlContent.length }); |
|
|
| |
| log.info('解析书签结构'); |
| const bookmarks = parseBookmarks(htmlContent); |
| if (bookmarks.categories.length === 0) { |
| log.error('HTML 文件中未找到书签分类,处理终止'); |
| return; |
| } |
| log.ok('解析完成', { categories: bookmarks.categories.length }); |
|
|
| |
| log.info('生成 YAML 配置'); |
| const yamlContent = generateBookmarksYaml(bookmarks); |
| if (!yamlContent) { |
| log.error('YAML 生成失败,处理终止'); |
| return; |
| } |
| log.ok('YAML 生成成功'); |
|
|
| |
| log.info('写入配置文件', { path: MODULAR_OUTPUT_FILE }); |
| try { |
| |
| ensureUserConfigInitialized(); |
|
|
| |
| if (!fs.existsSync(CONFIG_USER_PAGES_DIR)) { |
| fs.mkdirSync(CONFIG_USER_PAGES_DIR, { recursive: true }); |
| } |
|
|
| |
| fs.writeFileSync(MODULAR_OUTPUT_FILE, yamlContent, 'utf8'); |
|
|
| |
| if (!fs.existsSync(MODULAR_OUTPUT_FILE)) { |
| throw new FileError('文件未能创建', MODULAR_OUTPUT_FILE, [ |
| '检查目录权限是否正确', |
| '确认磁盘空间是否充足', |
| '尝试手动创建目录: mkdir -p config/user/pages', |
| ]); |
| } |
|
|
| log.ok('写入成功', { path: MODULAR_OUTPUT_FILE }); |
|
|
| |
| log.info('更新导航配置(确保包含 bookmarks 入口)'); |
| const navUpdateResult = updateNavigationWithBookmarks(); |
| if (navUpdateResult.updated) { |
| log.ok('导航配置已更新', { |
| target: navUpdateResult.target, |
| reason: navUpdateResult.reason, |
| }); |
| } else if (navUpdateResult.reason === 'already_present') { |
| log.ok('导航配置已包含书签入口,无需更新', { target: navUpdateResult.target }); |
| } else if (navUpdateResult.reason === 'no_site_yml') { |
| log.warn('未找到可用的 site.yml,无法自动更新导航', { path: USER_SITE_YML }); |
| } else if (navUpdateResult.reason === 'navigation_not_array') { |
| log.warn('site.yml 中 navigation 不是数组,无法自动更新导航', { path: USER_SITE_YML }); |
| } else if (navUpdateResult.reason === 'error') { |
| log.warn('导航更新失败,请手动检查配置文件格式(详见错误信息)'); |
| if (navUpdateResult.error) { |
| log.warn('导航更新错误详情', { |
| message: navUpdateResult.error.message |
| ? navUpdateResult.error.message |
| : String(navUpdateResult.error), |
| }); |
| if (isVerbose() && navUpdateResult.error.stack) |
| console.error(navUpdateResult.error.stack); |
| } |
| } else { |
| log.info('导航配置无需更新', { reason: navUpdateResult.reason }); |
| } |
| } catch (writeError) { |
| throw new FileError('写入文件时出错', MODULAR_OUTPUT_FILE, [ |
| '检查文件路径是否正确', |
| '确认目录权限是否正确', |
| `错误详情: ${writeError.message}`, |
| ]); |
| } |
|
|
| log.ok('完成', { ms: elapsedMs(), output: MODULAR_OUTPUT_FILE }); |
| } catch (error) { |
| |
| if (error instanceof FileError) { |
| throw error; |
| } |
| |
| throw new FileError('处理书签文件时发生错误', null, [ |
| '检查书签 HTML 文件格式是否正确', |
| '确认配置目录结构是否完整', |
| `错误详情: ${error.message}`, |
| ]); |
| } |
| } |
|
|
| |
| if (require.main === module) { |
| wrapAsyncError(main)(); |
| } |
|
|
| module.exports = { |
| ensureUserConfigInitialized, |
| ensureUserSiteYmlExists, |
| upsertBookmarksNavInSiteYml, |
| parseBookmarks, |
| generateBookmarksYaml, |
| updateNavigationWithBookmarks, |
| }; |
|
|