一、技术人员请远离一个人能肝到什么程度，我仅仅学习过C语言基础+PHP基础，喜欢逛博客，部署各种应用在服务器中，服务器方面的“搞机玩家”。GPT从4.0开始就已经被大量用于网站的编程，2025年到26年更是AI发展迅速的一年转折点，看到国产Ds、GPT的image2、A社依旧发挥稳定。很多非技术人员开始用现金去代替技术门槛去制作想去制作的产品，类似花钱去定制，自己去做甲方。二、一生万“物”Discourse是目前开源社区最火的项目，很多大站都在使用，官方也是不断更新，理念很符合现代社区，内容去驱动，而不是积分去驱动。复古物语中文社区在搭建前夕，我思考很久，问AI，翻浏览器，PHPBB捣鼓了一两周去搞插件，去搞编辑器插件、模组库插件，后面最终我心境爆发了，绷不住了，哈哈哈，PHPBB虽然成熟，但是不能用于现代社区了，特别是内容驱动和多页面内容。我于是决心进入Discourse，很强！Discourse有很完善的功能、更现代的页面和编辑器、更优秀的性能优化。编辑器支持md、html、bbcode，解决了社区编辑问题，md最适合文本社区了，不需要特别多HTML。插件有：哔哩哔哩视频Onebox、积分插件、Discourse Connect、抽奖插件其中Discourse Connect解决了登录账号问题，特别是多站点，模组网站、维基mediawiki、服务器网站、积分商城网站、Waline评论系统账号均使用一个社区账号。[!NOTE]mediawiki是使用AI制作的兼容Discourse SSO的Auth认证插件，mediawiki可以安装扩展；Discourse SSO和普通Auth0不一样，需要单独写代码，其中Waline是使用替换js和原Github Auth去认证的（没有开源），其他网站都是SSO兼容的项目，单独开发的项目。积分插件还是不完善，ds比较偏向内容，而不是积分，我于是搭建了独立的积分网站，同步ds的数据库去计算积分。[!NOTE]单容器部署Discourse是没有数据库容器的，所以需要修改app.yml文件去开放数据库数据在内网，然后容器连一个网络即可解决（使用容器名称）。除了上面的账号互通，还支持了游戏官方的API认证，2FA验证，让账号绑定游戏账号，游戏官方有模组网站，账号也是游戏账号，于是也解决了模组作者对齐模组网站的想法，便于后期外国作者去我搭建的模组网站去提交和管理模组。Discourse积分插件很简单，只有数值统计逻辑，只用在抽奖插件上面（https://github.com/maiizii/discourse-choujiang），积分商城除了固定计算的积分，还有流动积分（当天固定可划入积分的流动积分），发布委托和解决委托可汇率进入积分（积分=真实积分），更加丰富了社区互助激励功能。
简单介绍MarkdownPlus是让Codex通过本站主题HansJack文章的渲染功能来剥离而出的Typecho插件，不确保你的主题能不能完美适配，不过添加了主题文章内容选中器。https://github.com/TGU-HansJack/MarkdownPlus主要功能提示块（alert）和引用块增强[!NOTE]111[!TIP]111[!IMPORTANT]111[!WARNING]111[!CAUTION]111并且支持折叠。行内语法增强!!剧透!!、++插入++、==高亮==、{字:注音}嵌入短代码{% youtube %}、{% bilibili %}、{% video %}、{% iframe/embed %}、{% comment %}Onebox 卡片段落里单独 URL 自动卡片化；GitHub repo/issue/PR 会调用 GitHub API 补全信息https://github.com/VintageStory-Community/vintagestory-forum-cnhttps://github.com/scgm0/MVL/pull/4https://github.com/VintageStory-Community/vintagestory-forum-cn/blob/main/README.md图片增强独立图片段落转 figure + figcaption，并自动 loading="lazy"代码块增强复制按钮、超过 10 行可折叠class Plugin implements PluginInterface { private const VERSION = '1.0.2'; private static $frontRendered = false; public static function activate() { \Typecho\Plugin::factory('Widget_Archive')->footer = [__CLASS__, 'renderFooter']; return _t('MarkdownPlus 已启用'); } public static function deactivate() { } public static function config(Form $form) { $selector = new Text( 'contentSelector', null, '.post-content, .post_content', _t('文章内容容器选择器'), _t('支持多个 CSS 选择器，使用英文逗号分隔。留空时默认匹配 default / OneBlog 的正文容器。') ); $form->addInput($selector); } public static function personalConfig(Form $form) { } private static function cut($text, $len) { if (function_exists('mb_substr')) { return mb_substr((string) $text, 0, (int) $len); } return substr((string) $text, 0, (int) $len); } private static function getConfig() { $defaults = [ 'contentSelector' => '.post-content, .post_content', ]; try { $raw = \Helper::options()->plugin('MarkdownPlus')->toArray(); if (!is_array($raw)) { $raw = []; } } catch (\Throwable $e) { $raw = []; } $cfg = array_merge($defaults, $raw); $selector = trim((string) ($cfg['contentSelector'] ?? '')); $selector = str_replace(["\r\n", "\r", "\n"], ' ', $selector); $selector = preg_replace('/\s{2,}/u', ' ', $selector); $selector = trim((string) $selector); if ($selector === '') { $selector = $defaults['contentSelector']; } $cfg['contentSelector'] = self::cut($selector, 600); return $cfg; } public static function renderFooter($archive) { if (defined('__TYPECHO_ADMIN__') && __TYPECHO_ADMIN__) { return; } if (self::$frontRendered) { return; } if (!is_object($archive) || !method_exists($archive, 'is')) { return; } $isSingle = false; try { $isSingle = $archive->is('single') || $archive->is('post') || $archive->is('page'); } catch (\Throwable $e) { $isSingle = false; } if (!$isSingle) { return; } $cfg = self::getConfig(); $pluginUrl = ''; try { $pluginUrl = rtrim((string) (\Helper::options()->pluginUrl ?? ''), '/') . '/MarkdownPlus'; } catch (\Throwable $e) { $pluginUrl = ''; } if ($pluginUrl === '') { return; } $bootstrap = [ 'version' => self::VERSION, 'contentSelector' => (string) ($cfg['contentSelector'] ?? ''), 'assetBase' => $pluginUrl . '/assets', ]; $json = json_encode( $bootstrap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); if ($json === false) { return; } self::$frontRendered = true; echo ' '; echo ' '; echo ' '; } }图表/白板渲染支持 vega-lite 和 excalidraw 代码块移动端交互优化tooltip/spoiler 点击展开逻辑
字段内容扩展中文名称数据库目录ext/hansjack/db扩展英文名称DataBase作者HansJack版本号0.0.1用途游戏论坛开发者展示自己的作品项目，支持项目展示、项目筛选、主题帖子绑定项目用于讨论、项目提交、项目审核、项目分类、版本更新涉及页面：ACP（后台管理） UCP（用户中心） 前台（app.php/db）ACP（管理员面板）路径： ACP -> 扩展 -> DataBase分为：项目管理、分类管理、标签管理、版本管理、扩展设置1. 项目管理功能：列表显示所有项目的条目搜索 / 筛选状态控制：pending（待审核）approved（已发布）rejected（拒绝）操作：审核通过 / 拒绝编辑内容删除开启评分2. 分类管理字段：分类名（模组/工具/其他）标识(URL路径)（mod / tool / other）{categorize}是否启用功能：新增分类编辑分类删除分类3. 标签管理功能：创建标签合并标签（防止重复）删除标签查看使用次数4. 版本管理用途：项目添加支持的游戏版本号、支持端（Win/Linux/Mac）链接导入版本号：https://api.vintagestory.at/stable-unstable.json5. 扩展设置是否开启审核标签最大数量允许上传文件大小、类型是否开启全局评分UCP（用户面板）路径： UCP -> 数据库1. 我的贡献用户可以看到：我提交的 Mod等 项目状态（审核中 / 已发布 / 被拒）项目编辑/更新查看页面2. 项目编辑/更新项目字段：名称描述项目唯一{id}（不可与已有项目重复，大小写不敏感，仅支持中文、英文、-、_）分类{categorize}（支持多选）支持端（支持多选）标签（支持创建和输入时下拉选择）预览图（支持多图片上传）绑定主题（仅自己发布的主题，下拉选择）版本添加：添加版本编辑版本删除版本版本字段：版本号支持端支持版本号上传项目文件更新日志前台页面路径：/app.php/db首页（数据库列表）顶部支持：搜索分类筛选标签筛选作者筛选版本筛选下方网格布局形式显示项目：展示：名称短描述标签作者分类封面图（第一张预览图）详情页面路径：/app.php/db/{categorize}/{id}展示：名称短描述标签作者分类预览图最新版本和下载按钮支持版本文件页面（位于详情页面，tabs切换两个内容）展示：历史版本下载按钮隐藏显示更新日志支持版本号更新时间
1. 起因我舍友放假回来宿舍一直玩Minecarft，正好我也打算看看AI对这游戏影响：上周末玩了一下Minecraft，又因为我最近几个月都在玩AI Coding，于是想到能不能使用GPT 5.4去做一个模组：AIPlayers (AIP){% comment https://www.hansjack.com/memos#comment-33 %}2. 思路采用Forge模组，借鉴Britone的寻路逻辑、自研的任务型脚本。多AI玩家之间通过同一个知识库互相获取世界信息，数据来自Minecarft Wiki。使用阿里模型QWEN-ASR实现语音发送命令，Openai中转API实现对话。3. 转折点昨天晚上我才发现已经有项目做的很强，成熟了。其中Mindcraft实现LLM在我的世界服务器中进行命令深层思考然后调用项目中做好的动作逻辑。相比较我的AIP优点是完全可以AI驱动运动，而不是脚本写死的任务。不过，运行一晚上后发现Mindcraft项目很耗费延迟，很容易卡死循环，任务复杂很难依靠简单API去完成，这需要更好的机器（基础设施）。已经把Mindcraft项目加入我的AIPlayers项目中，意味着我的项目跃升至AI智能化，旧AIP项目做了寻路A*算法、动作逻辑、GOAP，但是依旧不智能，自从加入Mindcraft就已经更上一层楼了！🤗{% comment https://www.hansjack.com/memos#comment-36 %}
1、制作背景和功能预览https://github.com/mx-space/mx-admin界面参考与布局实现：Vue3Admin 的后台视觉与交互中，存在基于 mx-admin 的结构化参考与适配实现。采纳内容：侧边栏/内容区变量体系（--sidebar-width、--page-bg、--content-shadow 等）、折叠与移动端遮罩布局思路、Toast 的 sonner 风格层次、评论页 master-detail 分栏交互、Dashboard/Settings 的间距与尺寸标尺映射。调用点：Vue3Admin: admin/assets/app.css, admin/assets/app.js; 来源参考: mx-admin/src/components/sidebar/index.module.css, mx-admin/src/layouts/sidebar/index.module.css, mx-admin/src/layouts/sidebar/index.tsx, mx-admin/src/index.css目前Typecho的后台面板即便官方1.3.0依旧没有进行美化（轻量博客系统，也是合理之中）。于是，参考mx-space系统的后台面板界面进行制作适合现代化的后台面板插件：Vue3Adminhttps://github.com/TGU-HansJack/Vue3Admin-Typecho仪表盘数据统计：图表、数字、快捷操作博文编辑器基于Vditor编辑器，全屏编辑等文件管理提供网格布局预览提供友链页面和功能支持额外功能展示插件面板和守兔塔安全插件功能 免费开源Typecho守兔塔防御应用插件 - 李的日志维护进行备份、升级更加详细的路由数据更完善的AI功能更完善的数据存储本地化更细节的通知系统更好用的代码编辑器和自定义字段更细粒化的权限2、如何使用（1）安装与启用将插件目录上传到：usr/plugins/Vue3Admin/。在 Typecho 后台插件管理中启用 Vue3Admin。启用后插件会自动：复制插件内置的 admin/ 到站点根目录：/Vue3Admin/。修改站点根目录 config.inc.php 内的 __TYPECHO_ADMIN_DIR__ 为 /Vue3Admin/，从而“弃用”旧的 /admin/。完成后请通过 https://你的域名/Vue3Admin/ 访问新后台。（2）如何补救？修改config.inc.php文件中的：// admin directory (relative path) define('__TYPECHO_ADMIN_DIR__', '/Vue3Admin/');如果后台出现域名被截断，补齐域名即可，目前没有解决这个问题。（3）如何添加前台访客统计为了避免把后台面板请求计入统计，并兼容一些缓存/静态化场景，推荐在主题的 footer.php（或页面底部公共文件，建议放在 前）添加以下代码： 其他说明：记录字段：访问 IP（服务端获取）、访问路径（path）、文章 ID（cid，仅文章页）、访问设备（由 UA 解析）、访问时间（服务端时间）。上报端点：站点根目录的 /Vue3Admin/track.php（插件启用后会自动部署）。如果站点在反代/CDN 后，想显示真实访客 IP，请确保服务器正确传递 CF-Connecting-IP / X-Forwarded-For / X-Real-IP 等请求头。（4）插件的配置在插件设置中可配置：主色（Primary Color）：用于面板主题色（CSS 变量）。Vue3 CDN：默认使用 https://unpkg.com/。ECharts CDN：默认使用 https://cdn.jsdelivr.net/。如站点环境无法访问外网 CDN，请替换为自建静态资源地址。3、数据库相关介绍1.2.3版本之前（不包括1.2.3版本）：插件会自动创建/升级数据表（表前缀统一使用 v3a_，实际表名会拼接 Typecho 表前缀，例如 typecho_v3a_visit_log）。注：后续会将数据库转本地文件存储，避免数据库操作复杂或者其他安全问题。1.2.3版本后本地化数据，需要迁移操作！1.2.3版本开始替换本地数据，之前的版本如何迁移数据？可以通过维护/升级底部的旧数据迁移进行删除旧数据库、然后生成目前本地数据（如图）4、如何升级？请下载>=1.1.0版本！在后台面板的左侧栏中，底部维护下拉菜单有升级页面，点击进入，右上角的蓝色下载图标按钮就是升级按钮。必要的时候可以设置升级决策：严格升级 不以 release 为准，直接对比 GitHub 默认分支最新 commit。全局替换 升级时同步替换站点根目录 /Vue3Admin/（无需停用/启用插件）。5、停用与恢复在插件管理中停用 Vue3Admin 后：会把 __TYPECHO_ADMIN_DIR__ 改回 /admin/（恢复默认后台路径）。可选操作（手动）：删除站点根目录的 /Vue3Admin/。（1）无法正常卸载（出现报错）如果无法正常卸载，请数据库操作：typecho_options表中的plugins替换为：a:0:{}参考文章：Typecho不用进入后台如何关闭已启用的插件 | 大鱼博客6、注意事项（必读）插件会改写 config.inc.php，并在首次改写时自动备份到 config.inc.php.vue3admin.bak。请确保 PHP 进程对站点根目录与 config.inc.php 具备写权限，否则启用会失败。比如出现Cannot write config.inc.php: /var/www/html/config.inc.php报错。如出现无法进入后台的情况，可手动把 config.inc.php 中的 __TYPECHO_ADMIN_DIR__ 恢复为 /admin/，或用备份文件回滚。7、致谢基于 GitHub Commit 记录与当前代码调用点整理（持续补充）：mx-space / mx-admin界面参考与布局实现Vue3Admin 后台视觉与交互中，参考并适配了 mx-admin 的结构化设计。采纳内容：侧边栏/内容区变量体系、折叠与移动端遮罩布局思路、Toast 的 sonner 风格层次、评论页 master-detail 分栏交互、Dashboard/Settings 的间距与尺寸标尺映射。Typecho宿主框架提供插件体系、Widget、路由与后台能力，是 Vue3Admin 的运行基础。Vue.js（Vue 3）前端框架后台 SPA 的响应式状态、组件渲染与交互逻辑基础。Apache Software Foundation – Apache ECharts图表引擎用于仪表盘访问趋势、分类/发布数据等可视化图表。ECharts WordCloud图表扩展用于标签词云等扩展图表能力。VditorMarkdown 编辑器支持文章/页面编辑、预览与上传流程。CodeMirror（CodeMirror 6）代码编辑器用于主题编辑器与 JSON 字段编辑弹窗。PHPMailer邮件发送提供评论通知、友链通知与测试邮件 SMTP 能力。jQuery Foundation – jQuery兼容层兼容旧版后台插件配置页与脚本执行环境。jQuery UI兼容层为旧后台依赖的 UI 组件与交互提供支持。DOMPurify安全清洗用于 HTML 内容清洗，降低富文本渲染风险。HyperDownMarkdown 兼容沿用 Typecho 传统后台 Markdown 处理能力。PageDownMarkdown 兼容沿用传统后台编辑链路解析与工具能力。Normalize.css样式基础统一浏览器默认样式基线。Lucide – Lucide Icons图标系统后台导航与操作图标来源。ShouTuTa（守兔塔）插件联动提供状态读取、统计查询与管理联动能力。AbuseIPDB威胁情报在守兔塔联动场景中提供 IP 信誉查询。Gravatar头像服务用户头像展示与镜像配置支持。感谢所有开源项目与服务的支持 ❤️8、赞助
前言很显然，这篇文章是使用我自己制作的obsidian插件发布的文章，既可以使用手机版本的obsidian也可以在windows上面使用obsidian进行发送文章，先设置obdsidian插件比如xmlrpc的链接，账号密码等。一、插件说明为了防止文件上传出错，我并没有加文件上传的逻辑，不过可以使用piclist等插件继续上传文件。1、功能要点使用 XML-RPC 对接 Typecho，支持：标题 title自定义路径 slug标签 tags（数组或逗号分隔）分类 categories（可选）文章内容（当前笔记正文）是否草稿 draft（发布时 publish=false）首次发布会把返回的 cid 写回到笔记 Frontmatter（默认键名 cid），二次发布自动改为 editPost 更新。支持自行映射 Frontmatter 字段名（如把 tags 改成 blogtags 等）。2、安装与配置插件文件夹名：typecho-xmlrpc-publisher下载最新版本，解压 ZIP，把整个 typecho-xmlrpc-publisher 文件夹放到你的库目录：<你的库>/.obsidian/plugins/在 Obsidian 设置 → 第三方插件，启用 Typecho XML-RPC Publisher。typecho需要的配置：obsidian最新版本配置：版本下载最新1.3版本(1.0是打包文件，后续只更新main.js文件，只发布代码，代码在更新内容后面)，添加mainifest.json文件在插件里面：{ "id": "typecho-xmlrpc-publisher", "name": "Typecho XML-RPC Publisher", "version": "1.3.0", "minAppVersion": "1.5.0", "description": "通过 XML-RPC （MetaWeblog） 将当前笔记发布到 Typecho 博客。支持标题、slug、标签、类别、草稿；可选是否更新发布时间；支持强制同步服务器发布时间。", "author": "Hans J. Han", "authorUrl": "https://www.hansjack.com", "isDesktopOnly": false, "js": "main.js" }最终插件文件夹有：如果样式错乱，可以添加styles.css文件：.post-card, .comment-card { border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 10px; margin-bottom: 10px; } .post-header, .comment-header { display: flex; justify-content: space-between; align-items: center; } .post-title { font-weight: bold; color: var(--text-accent); text-decoration: none; } .post-meta { font-size: 12px; color: var(--text-muted); margin-top: 4px; } .post-actions, .comment-actions { display: flex; gap: 6px; /* 按钮间距 */ } .action-btn { padding: 2px 8px; border-radius: 4px; border: 1px solid var(--background-modifier-border); background: var(--interactive-normal); cursor: pointer; } .action-btn:hover { background: var(--interactive-hover); }其他配置默认即可：参考我的Typecho配置：Endpoint：Typecho 的 XML-RPC 入口（常见为 https://你的站点/action/xmlrpc，需后台启用 MetaWeblog/XML-RPC 功能）Username / Password：Typecho 后台账号Default Blog ID：填 0Frontmatter 映射：可修改 title/slug/tags/categories/draft/cid 对应的键名3、使用说明参考文章：--- title: slug: tags: categories: draft: false ---[!IMPORTANT]确保前面属性正确！打开要发布的笔记，运行命令：Publish current note to Typecho弹窗里确认或补齐：标题、slug、标签、分类、是否草稿首发成功后，cid 会自动写入 Frontmatter；之后再发布会自动走更新流程二、插件下载1、版本1.0：存在发布时间错误1.0版本：存在更新文章时间跟随当前时间的错误逻辑2、版本1.1：修复发布时间错误/添加时间偏移纠正下面是1.1版本：添加是否当前时间作为发布时间的选择、添加时间偏移（同步时间、发布时间）插件文件夹名：typecho-xmlrpc-publishermain.js文件代码如下：// Typecho XML-RPC 发布器 - main.js const { Plugin, Modal, Setting, Notice, requestUrl } = require('obsidian'); class XmlRpc { static iso8601(date) { const pad = (n) => (n < 10 ? '0' + n : '' + n); return date.getFullYear().toString() + pad(date.getMonth() + 1) + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); } static parseIso8601(str) { if (!str) return null; let m = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2}):(\d{2}):(\d{2})$/); if (m) { const [_, y, mo, d, h, mi, s] = m; return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); } m = str.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); if (m) { const [_, y, mo, d, h, mi, s] = m; return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); } const dt = new Date(str); return isNaN(dt.getTime()) ? null : dt; } static encodeValue(val) { if (val === null || val === undefined) return '<nil/>'; if (Array.isArray(val)) return '<array> ' + val.map(v => `<value>${XmlRpc.encodeValue(v)}</value>`).join('') + ' </array>'; const t = typeof val; if (t === 'boolean') return `<boolean>${val ? 1 : 0}</boolean>`; if (t === 'number') return Number.isInteger(val) ? `<int>${val}</int>` : `<double>${val}</double>`; if (val instanceof Date) return `<dateTime.iso8601>${XmlRpc.iso8601(val)}</dateTime.iso8601>`; if (t === 'object') return '<struct>' + Object.entries(val).map(([k, v]) => `<member><name>${XmlRpc.escape(k)}</name><value>${XmlRpc.encodeValue(v)}</value></member>`).join('') + '</struct>'; return `<string>${XmlRpc.escape(String(val))}</string>`; } static escape(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); } static buildCall(method, params) { const xml = `<?xml version="1.0"?><methodCall><methodName>${method}</methodName><params>` + params.map(p => ` <value>${XmlRpc.encodeValue(p)}</value> `).join('') + `</params></methodCall>`; return xml; } static parseResponse(text) { const parseValue = (node) => { if (!node) return null; const nName = node.nodeName; if (nName === 'value') { if (node.children.length === 0) return node.textContent ?? ''; return parseValue(node.children[0]); } if (['string','i4','int','double','boolean','dateTime.iso8601'].includes(nName)) { if (nName === 'boolean') return node.textContent.trim() === '1'; if (nName === 'int' || nName === 'i4') return parseInt(node.textContent.trim(), 10); if (nName === 'double') return Number(node.textContent.trim()); return node.textContent ?? ''; } if (nName === 'struct') { const obj = {}; for (let i = 0; i < node.children.length; i++) { const m = node.children[i]; if (m.nodeName !== 'member') continue; const nameEl = m.getElementsByTagName('name')[0]; const valEl = m.getElementsByTagName('value')[0]; const key = nameEl ? nameEl.textContent : ''; obj[key] = parseValue(valEl); } return obj; } if (nName === 'array') { const dataEl = node.getElementsByTagName('data')[0]; if (!dataEl) return []; const vals = []; for (let i = 0; i < dataEl.children.length; i++) { if (dataEl.children[i].nodeName === 'value') vals.push(parseValue(dataEl.children[i])); } return vals; } return node.textContent ?? ''; }; const parser = new DOMParser(); const doc = parser.parseFromString(text, "text/xml"); const fault = doc.getElementsByTagName('fault')[0]; if (fault) { const val = fault.getElementsByTagName('value')[0]; const obj = parseValue(val); const msg = (obj && (obj.faultString || obj['faultString'])) || 'XML-RPC 错误'; const code = (obj && (obj.faultCode || obj['faultCode'])) || -1; const err = new Error(`XML-RPC 错误 ${code}: ${msg}`); err.code = code; throw err; } const params = doc.getElementsByTagName('params')[0]; if (!params) return null; const first = params.getElementsByTagName('param')[0]; if (!first) return null; const value = first.getElementsByTagName('value')[0]; return parseValue(value); } } class PublishModal extends Modal { constructor(app, preset, onSubmit, publishOffset = 0) { super(app); this.preset = preset || {}; this.onSubmit = onSubmit; this.publishOffset = publishOffset; } onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.createEl('h2', { text: '发布到 Typecho' }); this.values = { title: this.preset.title || '', slug: this.preset.slug || '', tags: (this.preset.tags || []).join(', '), categories: (this.preset.categories || []).join(', '), draft: this.preset.draft ?? false, }; new Setting(contentEl).setName('标题').addText(t => t.setValue(this.values.title).onChange(v => this.values.title = v)); new Setting(contentEl).setName('Slug').addText(t => t.setValue(this.values.slug).onChange(v => this.values.slug = v)); new Setting(contentEl).setName('标签（逗号分隔）').addText(t => t.setValue(this.values.tags).onChange(v => this.values.tags = v)); new Setting(contentEl).setName('分类（逗号分隔）').addText(t => t.setValue(this.values.categories).onChange(v => this.values.categories = v)); new Setting(contentEl).setName('草稿').addToggle(t => t.setValue(this.values.draft).onChange(v => this.values.draft = v)); if (this.publishOffset) { new Setting(contentEl).setName('发布时间偏移（小时）').setDesc(`当前偏移: ${this.publishOffset}`).addText(t => t.setValue(String(this.publishOffset))); } new Setting(contentEl) .addButton(b => b.setButtonText('发布').setCta().onClick(() => { if (!this.values.title) { new Notice('标题不能为空'); return; } if (!this.values.slug) { new Notice('Slug 不能为空'); return; } this.close(); const payload = { title: this.values.title, slug: this.values.slug, tags: this.values.tags.split(',').map(s => s.trim()).filter(Boolean), categories: this.values.categories.split(',').map(s => s.trim()).filter(Boolean), draft: !!this.values.draft, }; this.onSubmit(payload); })); } } module.exports = class TypechoXmlRpcPublisher extends Plugin { async onload() { this.settings = Object.assign({ endpoint: '', username: '', password: '', defaultBlogId: '0', useFrontmatter: true, useCurrentTime: true, publishTimeOffset: 0, syncTimeOffset: 0, frontmatterKeys: { title:'title', slug:'slug', tags:'tags', categories:'categories', draft:'draft', cid:'cid', dateCreated:'dateCreated' } }, await this.loadData() || {}); this.addSettingTab(new (class extends require('obsidian').PluginSettingTab { constructor(app, plugin){ super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h2', { text: 'Typecho XML-RPC 发布器设置' }); new Setting(containerEl).setName('接口 URL') .addText(t => t.setValue(this.plugin.settings.endpoint).onChange(async v => { this.plugin.settings.endpoint = v; await this.plugin.saveData(); })); new Setting(containerEl).setName('用户名') .addText(t => t.setValue(this.plugin.settings.username).onChange(async v => { this.plugin.settings.username = v; await this.plugin.saveData(); })); new Setting(containerEl).setName('密码') .addText(t => t.setValue(this.plugin.settings.password).onChange(async v => { this.plugin.settings.password = v; await this.plugin.saveData(); }).inputEl.setAttribute('type','password')); new Setting(containerEl).setName('默认博客ID') .addText(t => t.setValue(this.plugin.settings.defaultBlogId).onChange(async v => { this.plugin.settings.defaultBlogId = v; await this.plugin.saveData(); })); new Setting(containerEl).setName('总是使用当前时间作为发布时间') .addToggle(t => t.setValue(this.plugin.settings.useCurrentTime).onChange(async v => { this.plugin.settings.useCurrentTime = v; await this.plugin.saveData(); })); containerEl.createEl('h3', { text: '时间偏移设置' }); new Setting(containerEl).setName('发布偏移（小时）').addText(t => t.setValue(String(this.plugin.settings.publishTimeOffset)) .onChange(async v => { this.plugin.settings.publishTimeOffset = parseFloat(v) || 0; await this.plugin.saveData(); })); new Setting(containerEl).setName('同步偏移（小时）').addText(t => t.setValue(String(this.plugin.settings.syncTimeOffset)) .onChange(async v => { this.plugin.settings.syncTimeOffset = parseFloat(v) || 0; await this.plugin.saveData(); })); containerEl.createEl('h3', { text: 'Frontmatter 对应键' }); ['title','slug','tags','categories','draft','cid','dateCreated'].forEach(k => { new Setting(containerEl).setName(k + ' 键').addText(t => t.setValue(this.plugin.settings.frontmatterKeys[k]) .onChange(async v => { this.plugin.settings.frontmatterKeys[k] = v; await this.plugin.saveData(); })); }); } })(this.app, this)); this.addCommand({ id: 'typecho-publish-current-note', name: '发布当前笔记到 Typecho', callback: () => this.publishActiveNote(), }); this.addCommand({ id: 'typecho-sync-publish-date', name: '同步 Typecho 发布时间', callback: () => this.syncPublishDate(), }); } async saveSettings() { await this.saveData(this.settings); } getActiveMDFile() { const file = this.app.workspace.getActiveFile(); if (!file) throw new Error('未选中文件'); if (file.extension !== 'md') throw new Error('当前文件不是 Markdown'); return file; } async readFrontmatter(file) { const cache = this.app.metadataCache.getFileCache(file); return (cache && cache.frontmatter) || {}; } async updateFrontmatter(file, updater) { await this.app.fileManager.processFrontMatter(file, updater); } extractContentWithoutFrontmatter(text) { if (text.startsWith('---')) { const end = text.indexOf('\n---', 3); if (end !== -1) return text.slice(end + 4).trimStart(); } if (text.startsWith('+++')) { const end = text.indexOf('\n+++', 3); if (end !== -1) return text.slice(end + 4).trimStart(); } return text; } async publishActiveNote() { try { const file = this.getActiveMDFile(); const fm = await this.readFrontmatter(file); const raw = await this.app.vault.read(file); const body = this.extractContentWithoutFrontmatter(raw); const k = this.settings.frontmatterKeys; const preset = { title: fm[k.title] || file.basename, slug: fm[k.slug] || file.basename.toLowerCase().replace(/\s+/g, '-'), tags: Array.isArray(fm[k.tags]) ? fm[k.tags] : (typeof fm[k.tags] === 'string' ? fm[k.tags].split(',').map(s=>s.trim()).filter(Boolean) : []), categories: Array.isArray(fm[k.categories]) ? fm[k.categories] : (typeof fm[k.categories] === 'string' ? fm[k.categories].split(',').map(s=>s.trim()).filter(Boolean) : []), draft: !!fm[k.draft], }; const proceed = async (vals) => { const postStruct = { title: vals.title, description: body, mt_keywords: vals.tags.join(','), categories: vals.categories, post_type: 'post', wp_slug: vals.slug, mt_allow_comments: 1, }; let postDate = this.settings.useCurrentTime ? new Date() : (fm[k.dateCreated] ? XmlRpc.parseIso8601(fm[k.dateCreated]) || new Date() : new Date()); if (this.settings.publishTimeOffset) postDate = new Date(postDate.getTime() + this.settings.publishTimeOffset*3600*1000); postStruct.dateCreated = postDate; const endpoint = this.settings.endpoint.trim(); if (!endpoint) { new Notice('请设置接口 URL'); return; } const username = this.settings.username.trim(); const password = this.settings.password; if (!username || !password) { new Notice('请填写用户名和密码'); return; } const cidKey = k.cid; const existingCid = fm[cidKey]; let method, params; if (existingCid) { method='metaWeblog.editPost'; params=[String(existingCid), username, password, postStruct, !vals.draft]; } else { method='metaWeblog.newPost'; params=[String(this.settings.defaultBlogId || '0'), username, password, postStruct, !vals.draft]; } const xml = XmlRpc.buildCall(method, params); const response = await requestUrl({ url:endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:xml }); const resultValue = XmlRpc.parseResponse(response.text); await this.updateFrontmatter(file, fm => { fm[k.title] = vals.title; fm[k.slug] = vals.slug; fm[k.tags]=vals.tags; fm[k.categories]=vals.categories; fm[k.draft]=vals.draft; fm[k.dateCreated]=XmlRpc.iso8601(postStruct.dateCreated); fm['lastPublished']=XmlRpc.iso8601(new Date()); if (!existingCid) fm[cidKey]=String(resultValue); }); new Notice(existingCid ? `更新文章成功 (cid=${existingCid})` : `发布新文章成功 (cid=${String(resultValue)})`); }; new PublishModal(this.app, preset, proceed, this.settings.publishTimeOffset).open(); } catch(e) { console.error(e); new Notice('错误: '+e.message); } } async syncPublishDate() { try { const file = this.getActiveMDFile(); const fm = await this.readFrontmatter(file); const k = this.settings.frontmatterKeys; const cid = fm[k.cid]; if (!cid) { new Notice('未找到文章 CID'); return; } const xml = XmlRpc.buildCall('metaWeblog.getPost', [ String(cid), this.settings.username, this.settings.password ]); const response = await requestUrl({ url:this.settings.endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:xml }); const result = XmlRpc.parseResponse(response.text); if (!result || !result.dateCreated) { new Notice('获取发布时间失败'); return; } let serverDate = XmlRpc.parseIso8601(result.dateCreated); if (this.settings.syncTimeOffset) serverDate = new Date(serverDate.getTime() + this.settings.syncTimeOffset*3600*1000); await this.updateFrontmatter(file, fm => { fm[k.dateCreated] = XmlRpc.iso8601(serverDate); }); new Notice('发布时间已同步'); } catch(e) { console.error(e); new Notice('错误: '+e.message); } } };下面是manifest.json文件代码：{ "id": "typecho-xmlrpc-publisher", "name": "Typecho XML-RPC Publisher", "version": "1.1.0", "minAppVersion": "1.5.0", "description": "通过 XML-RPC （MetaWeblog） 将当前笔记发布到 Typecho 博客。支持标题、slug、标签、类别、草稿；可选是否更新发布时间；支持强制同步服务器发布时间。", "author": "Hans J. Han", "authorUrl": "https://www.hansjack.com", "isDesktopOnly": false, "js": "main.js" }3、版本1.2：添加文章管理、添加发布日历时间选择添加日历时间：// Typecho XML-RPC 发布器 - main.js const { Plugin, Modal, Setting, Notice, requestUrl } = require('obsidian'); class XmlRpc { static iso8601(date) { const pad = (n) => (n < 10 ? '0' + n : '' + n); return date.getFullYear().toString() + pad(date.getMonth() + 1) + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); } static parseIso8601(str) { if (!str) return null; let m = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2}):(\d{2}):(\d{2})$/); if (m) { const [_, y, mo, d, h, mi, s] = m; return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); } m = str.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); if (m) { const [_, y, mo, d, h, mi, s] = m; return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); } const dt = new Date(str); return isNaN(dt.getTime()) ? null : dt; } static encodeValue(val) { if (val === null || val === undefined) return '<nil/>'; if (Array.isArray(val)) return '<array> ' + val.map(v => `<value>${XmlRpc.encodeValue(v)}</value>`).join('') + ' </array>'; const t = typeof val; if (t === 'boolean') return `<boolean>${val ? 1 : 0}</boolean>`; if (t === 'number') return Number.isInteger(val) ? `<int>${val}</int>` : `<double>${val}</double>`; if (val instanceof Date) return `<dateTime.iso8601>${XmlRpc.iso8601(val)}</dateTime.iso8601>`; if (t === 'object') return '<struct>' + Object.entries(val).map(([k, v]) => `<member><name>${XmlRpc.escape(k)}</name><value>${XmlRpc.encodeValue(v)}</value></member>`).join('') + '</struct>'; return `<string>${XmlRpc.escape(String(val))}</string>`; } static escape(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); } static buildCall(method, params) { const xml = `<?xml version="1.0"?><methodCall><methodName>${method}</methodName><params>` + params.map(p => ` <value>${XmlRpc.encodeValue(p)}</value> `).join('') + `</params></methodCall>`; return xml; } static parseResponse(text) { const parseValue = (node) => { if (!node) return null; const nName = node.nodeName; if (nName === 'value') { if (node.children.length === 0) return node.textContent ?? ''; return parseValue(node.children[0]); } if (['string','i4','int','double','boolean','dateTime.iso8601'].includes(nName)) { if (nName === 'boolean') return node.textContent.trim() === '1'; if (nName === 'int' || nName === 'i4') return parseInt(node.textContent.trim(), 10); if (nName === 'double') return Number(node.textContent.trim()); return node.textContent ?? ''; } if (nName === 'struct') { const obj = {}; for (let i = 0; i < node.children.length; i++) { const m = node.children[i]; if (m.nodeName !== 'member') continue; const nameEl = m.getElementsByTagName('name')[0]; const valEl = m.getElementsByTagName('value')[0]; const key = nameEl ? nameEl.textContent : ''; obj[key] = parseValue(valEl); } return obj; } if (nName === 'array') { const dataEl = node.getElementsByTagName('data')[0]; if (!dataEl) return []; const vals = []; for (let i = 0; i < dataEl.children.length; i++) { if (dataEl.children[i].nodeName === 'value') vals.push(parseValue(dataEl.children[i])); } return vals; } return node.textContent ?? ''; }; const parser = new DOMParser(); const doc = parser.parseFromString(text, "text/xml"); const fault = doc.getElementsByTagName('fault')[0]; if (fault) { const val = fault.getElementsByTagName('value')[0]; const obj = parseValue(val); const msg = (obj && (obj.faultString || obj['faultString'])) || 'XML-RPC 错误'; const code = (obj && (obj.faultCode || obj['faultCode'])) || -1; const err = new Error(`XML-RPC 错误 ${code}: ${msg}`); err.code = code; throw err; } const params = doc.getElementsByTagName('params')[0]; if (!params) return null; const first = params.getElementsByTagName('param')[0]; if (!first) return null; const value = first.getElementsByTagName('value')[0]; return parseValue(value); } } class ManageModal extends Modal { constructor(app, plugin) { super(app); this.plugin = plugin; } async onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.createEl('h2', { text: '文章管理' }); try { const xml = XmlRpc.buildCall('metaWeblog.getRecentPosts', [ String(this.plugin.settings.defaultBlogId || '0'), this.plugin.settings.username, this.plugin.settings.password, this.plugin.settings.managePostsCount || 20 ]); const resp = await requestUrl({ url:this.plugin.settings.endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:xml }); const posts = XmlRpc.parseResponse(resp.text) || []; posts.forEach(post => { const card = contentEl.createEl('div', { cls: 'post-card' }); const header = card.createEl('div', { cls: 'post-header' }); const titleEl = header.createEl('a', { text: post.title, href: post.link, cls: 'post-title' }); titleEl.setAttr('target','_blank'); const actions = header.createEl('div', { cls:'post-actions' }); const btnDelete = actions.createEl('button', { text: '删除', cls:'action-btn delete' }); btnDelete.onclick = async () => { const delXml = XmlRpc.buildCall('blogger.deletePost', [ '0', String(post.postid), this.plugin.settings.username, this.plugin.settings.password, true ]); await requestUrl({ url:this.plugin.settings.endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:delXml }); new Notice('文章已删除'); this.onOpen(); }; const btnImport = actions.createEl('button', { text: '导入到 Obsidian', cls:'action-btn import' }); btnImport.onclick = async () => { const fileName = `${post.title}.md`; const tagsArray = (post.mt_keywords || '').split(',').map(s => s.trim()).filter(Boolean); const categoriesArray = Array.isArray(post.categories) ? post.categories : []; const yamlLines = [ '---', `title: ${post.title}`, `slug: "${post.wp_slug || ''}"`, // 用双引号包裹，保证是文本 'tags:', ...tagsArray.map(t => ` - ${t}`), 'categories:', ...categoriesArray.map(c => ` - ${c}`), `draft: ${post.post_status === 'draft'}`, `cid: "${post.postid}"`, // 用双引号包裹，保证是文本 `dateCreated: ${XmlRpc.iso8601(XmlRpc.parseIso8601(post.dateCreated))}`, '---', '', ].join('\n'); const fileContent = `${yamlLines}\n${post.description || ''}`; await this.plugin.app.vault.create(fileName, fileContent); new Notice(`已导入文章: ${fileName}`); }; card.createEl('div', { cls:'post-meta', text: `分类: ${(post.categories||[]).join(', ')} | 标签: ${post.mt_keywords||''}` }); }); } catch(e) { console.error(e); new Notice('获取文章失败: '+e.message); } } } class PublishModal extends Modal { constructor(app, preset, onSubmit, publishOffset = 0, useCurrentTime = true) { super(app); this.preset = preset || {}; this.onSubmit = onSubmit; this.publishOffset = publishOffset; this.useCurrentTime = useCurrentTime; } onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.createEl('h2', { text: '发布到 Typecho' }); // 初始化时间 let initDate; if (this.useCurrentTime) { initDate = new Date(); } else { initDate = this.preset.date ? new Date(this.preset.date) : new Date(); } this.values = { title: this.preset.title || '', slug: this.preset.slug || '', tags: (this.preset.tags || []).join(', '), categories: (this.preset.categories || []).join(', '), draft: this.preset.draft ?? false, date: initDate, }; const pad = n => n.toString().padStart(2, '0'); const toLocalDatetime = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; new Setting(contentEl) .setName('标题') .setDesc('文章标题，将显示在 Typecho 前端') .addText(t => t.setValue(this.values.title).onChange(v => this.values.title = v)); new Setting(contentEl) .setName('Slug') .setDesc('文章 URL 片段（如不填写将自动生成）') .addText(t => t.setValue(this.values.slug).onChange(v => this.values.slug = v)); new Setting(contentEl) .setName('标签（逗号分隔）') .setDesc('多个标签用英文逗号分隔') .addText(t => t.setValue(this.values.tags).onChange(v => this.values.tags = v)); new Setting(contentEl) .setName('分类（逗号分隔）') .setDesc('文章分类，用英文逗号分隔') .addText(t => t.setValue(this.values.categories).onChange(v => this.values.categories = v)); new Setting(contentEl) .setName('草稿') .setDesc('启用后文章不会立即公开') .addToggle(t => t.setValue(this.values.draft).onChange(v => this.values.draft = v)); new Setting(contentEl) .setName('发布时间') .setDesc(`选择文章发布时间（当前偏移: ${this.publishOffset} 小时）`) .addText(t => t .setValue(toLocalDatetime(this.values.date)) .onChange(v => { this.values.date = new Date(v); }) .inputEl.setAttribute('type', 'datetime-local')); new Setting(contentEl) .addButton(b => b.setButtonText(' 发布 ').setCta().onClick(() => { if (!this.values.title) { new Notice('标题不能为空'); return; } if (!this.values.slug) { new Notice('Slug 不能为空'); return; } this.close(); const payload = { title: this.values.title, slug: this.values.slug, tags: this.values.tags.split(',').map(s => s.trim()).filter(Boolean), categories: this.values.categories.split(',').map(s => s.trim()).filter(Boolean), draft: !!this.values.draft, dateCreated: this.values.date, }; this.onSubmit(payload); })); } } module.exports = class TypechoXmlRpcPublisher extends Plugin { async onload() { this.settings = Object.assign({ endpoint: '', username: '', password: '', defaultBlogId: '0', useFrontmatter: true, useCurrentTime: true, publishTimeOffset: 0, syncTimeOffset: 0, managePostsCount: 20, frontmatterKeys: { title:'title', slug:'slug', tags:'tags', categories:'categories', draft:'draft', cid:'cid', dateCreated:'dateCreated' } }, await this.loadData() || {}); this.addSettingTab(new (class extends require('obsidian').PluginSettingTab { constructor(app, plugin){ super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h2', { text: 'Typecho XML-RPC 发布器设置' }); const settingsInputs = {}; settingsInputs.endpoint = new Setting(containerEl).setName('接口 URL') .setDesc('填写 Typecho XML-RPC 接口地址，例如：https://your-site.com/xmlrpc.php') .addText(t => t.setValue(this.plugin.settings.endpoint).onChange(v => settingsInputs.endpoint.value = v)); settingsInputs.username = new Setting(containerEl).setName('用户名') .setDesc('用于登录 Typecho 的用户名，确保有发布权限') .addText(t => t.setValue(this.plugin.settings.username).onChange(v => settingsInputs.username.value = v)); settingsInputs.password = new Setting(containerEl).setName('密码') .setDesc('对应用户名的密码，支持明文输入（将以安全方式保存）') .addText(t => t.setValue(this.plugin.settings.password).onChange(v => settingsInputs.password.value = v).inputEl.setAttribute('type','password')); settingsInputs.defaultBlogId = new Setting(containerEl).setName('默认博客ID') .setDesc('如果你的 Typecho 有多个博客，设置默认发布的博客 ID') .addText(t => t.setValue(this.plugin.settings.defaultBlogId).onChange(v => settingsInputs.defaultBlogId.value = v)); settingsInputs.useCurrentTime = new Setting(containerEl).setName('总是使用当前时间作为发布时间') .setDesc('启用后发布文章时会忽略文章原有 Frontmatter 时间，直接使用当前时间') .addToggle(t => t.setValue(this.plugin.settings.useCurrentTime).onChange(v => settingsInputs.useCurrentTime.value = v)); containerEl.createEl('h3', { text: '时间偏移设置' }); settingsInputs.publishTimeOffset = new Setting(containerEl).setName('发布偏移（小时）') .setDesc('调整发布到 Typecho 的时间，例如 +8 表示服务器时间加 8 小时') .addText(t => t.setValue(String(this.plugin.settings.publishTimeOffset)).onChange(v => settingsInputs.publishTimeOffset.value = v)); settingsInputs.syncTimeOffset = new Setting(containerEl).setName('同步偏移（小时）') .setDesc('同步 Typecho 发布时间时的时间偏移设置') .addText(t => t.setValue(String(this.plugin.settings.syncTimeOffset)).onChange(v => settingsInputs.syncTimeOffset.value = v)); containerEl.createEl('h3', { text: '文章管理设置' }); settingsInputs.managePostsCount = new Setting(containerEl).setName('文章管理显示数量') // 添加文章管理显示数量设置 .setDesc('在文章管理界面显示的文章数量') .addText(t => t.setValue(String(this.plugin.settings.managePostsCount)).onChange(v => settingsInputs.managePostsCount.value = v)); containerEl.createEl('h3', { text: 'Frontmatter 对应键' }); const frontmatterKeys = {}; ['title','slug','tags','categories','draft','cid','dateCreated'].forEach(k => { frontmatterKeys[k] = new Setting(containerEl).setName(k + ' 键') .setDesc(`对应 Frontmatter 中的 ${k} 键，用于自动读取或写入`) .addText(t => t.setValue(this.plugin.settings.frontmatterKeys[k]).onChange(v => frontmatterKeys[k].value = v)); }); new Setting(containerEl) .addButton(b => b.setButtonText('保存设置').setCta().onClick(async () => { this.plugin.settings.endpoint = settingsInputs.endpoint.value ?? this.plugin.settings.endpoint; this.plugin.settings.username = settingsInputs.username.value ?? this.plugin.settings.username; this.plugin.settings.password = settingsInputs.password.value ?? this.plugin.settings.password; this.plugin.settings.defaultBlogId = settingsInputs.defaultBlogId.value ?? this.plugin.settings.defaultBlogId; this.plugin.settings.useCurrentTime = settingsInputs.useCurrentTime.value ?? this.plugin.settings.useCurrentTime; this.plugin.settings.publishTimeOffset = parseFloat(settingsInputs.publishTimeOffset.value) || 0; this.plugin.settings.syncTimeOffset = parseFloat(settingsInputs.syncTimeOffset.value) || 0; this.plugin.settings.managePostsCount = parseInt(settingsInputs.managePostsCount.value) || 20; for (let k of ['title','slug','tags','categories','draft','cid','dateCreated']) { this.plugin.settings.frontmatterKeys[k] = frontmatterKeys[k].value ?? this.plugin.settings.frontmatterKeys[k]; } await this.plugin.saveData(this.plugin.settings); new Notice('设置已保存'); })); } })(this.app, this)); this.addCommand({ id: 'typecho-publish-current-note', name: '发布当前笔记到 Typecho', callback: () => this.publishActiveNote(), }); this.addCommand({ id: 'typecho-sync-publish-date', name: '同步 Typecho 发布时间', callback: () => this.syncPublishDate(), }); this.addCommand({ id: 'typecho-manage-posts', name: '管理文章', callback: () => new ManageModal(this.app, this).open(), }); } async getActiveMDFile() { const file = this.app.workspace.getActiveFile(); if (!file) throw new Error('未选中文件'); if (file.extension !== 'md') throw new Error('当前文件不是 Markdown'); return file; } async readFrontmatter(file) { const cache = this.app.metadataCache.getFileCache(file); return (cache && cache.frontmatter) || {}; } async updateFrontmatter(file, updater) { await this.app.fileManager.processFrontMatter(file, updater); } extractContentWithoutFrontmatter(text) { if (text.startsWith('---')) { const end = text.indexOf('\n---', 3); if (end !== -1) return text.slice(end + 4).trimStart(); } if (text.startsWith('+++')) { const end = text.indexOf('\n+++', 3); if (end !== -1) return text.slice(end + 4).trimStart(); } return text; } async publishActiveNote() { try { const file = await this.getActiveMDFile(); const fm = await this.readFrontmatter(file); const raw = await this.app.vault.read(file); const body = this.extractContentWithoutFrontmatter(raw); const k = this.settings.frontmatterKeys; const preset = { title: fm[k.title] || file.basename, slug: fm[k.slug] || file.basename.toLowerCase().replace(/\s+/g, '-'), tags: Array.isArray(fm[k.tags]) ? fm[k.tags] : (typeof fm[k.tags] === 'string' ? fm[k.tags].split(',').map(s=>s.trim()).filter(Boolean) : []), categories: Array.isArray(fm[k.categories]) ? fm[k.categories] : (typeof fm[k.categories] === 'string' ? fm[k.categories].split(',').map(s=>s.trim()).filter(Boolean) : []), draft: !!fm[k.draft], date: fm[k.dateCreated] ? XmlRpc.parseIso8601(fm[k.dateCreated]) : new Date(), }; const proceed = async (vals) => { const postStruct = { title: vals.title, description: body, mt_keywords: vals.tags.join(','), categories: vals.categories, post_type: 'post', wp_slug: vals.slug, mt_allow_comments: 1, }; // 如果设置总是使用当前时间，覆盖用户选择 let postDate = this.settings.useCurrentTime ? new Date() : (vals.dateCreated || new Date()); if (this.settings.publishTimeOffset) { postDate = new Date(postDate.getTime() + this.settings.publishTimeOffset * 3600 * 1000); } postStruct.dateCreated = postDate; const endpoint = this.settings.endpoint.trim(); if (!endpoint) { new Notice('请设置接口 URL'); return; } const username = this.settings.username.trim(); const password = this.settings.password; if (!username || !password) { new Notice('请填写用户名和密码'); return; } const cidKey = k.cid; const existingCid = fm[cidKey]; let method, params; if (existingCid) { method='metaWeblog.editPost'; params=[String(existingCid), username, password, postStruct, !vals.draft]; } else { method='metaWeblog.newPost'; params=[String(this.settings.defaultBlogId || '0'), username, password, postStruct, !vals.draft]; } const xml = XmlRpc.buildCall(method, params); const response = await requestUrl({ url:endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:xml }); const resultValue = XmlRpc.parseResponse(response.text); await this.updateFrontmatter(file, fm => { fm[k.title] = vals.title; fm[k.slug] = vals.slug; fm[k.tags] = vals.tags; fm[k.categories] = vals.categories; fm[k.draft] = vals.draft; fm[k.dateCreated] = XmlRpc.iso8601(postStruct.dateCreated); fm['lastPublished'] = XmlRpc.iso8601(new Date()); if (!existingCid) fm[cidKey] = String(resultValue); }); new Notice(existingCid ? `更新文章成功 (cid=${existingCid})` : `发布新文章成功 (cid=${String(resultValue)})`); }; new PublishModal(this.app, preset, proceed, this.settings.publishTimeOffset, this.settings.useCurrentTime).open(); } catch(e) { console.error(e); new Notice('错误: '+e.message); } } async syncPublishDate() { try { const file = await this.getActiveMDFile(); const fm = await this.readFrontmatter(file); const k = this.settings.frontmatterKeys; const cid = fm[k.cid]; if (!cid) { new Notice('未找到文章 CID'); return; } const xml = XmlRpc.buildCall('metaWeblog.getPost', [ String(cid), this.settings.username, this.settings.password ]); const response = await requestUrl({ url:this.settings.endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:xml }); const result = XmlRpc.parseResponse(response.text); if (!result || !result.dateCreated) { new Notice('获取发布时间失败'); return; } let serverDate = XmlRpc.parseIso8601(result.dateCreated); if (this.settings.syncTimeOffset) serverDate = new Date(serverDate.getTime() + this.settings.syncTimeOffset*3600*1000); await this.updateFrontmatter(file, fm => { fm[k.dateCreated] = XmlRpc.iso8601(serverDate); }); new Notice('发布时间已同步'); } catch(e) { console.error(e); new Notice('错误: '+e.message); } } };添加mainifest.json文件在插件里面：{ "id": "typecho-xmlrpc-publisher", "name": "Typecho XML-RPC Publisher", "version": "1.2.0", "minAppVersion": "1.5.0", "description": "通过 XML-RPC （MetaWeblog） 将当前笔记发布到 Typecho 博客。支持标题、slug、标签、类别、草稿；可选是否更新发布时间；支持强制同步服务器发布时间。", "author": "Hans J. Han", "authorUrl": "https://www.hansjack.com", "isDesktopOnly": false, "js": "main.js" }4、版本1.3：添加调试信息、修复分类空白问题更新说明：直接替换main.js文件代码（必须），修改mainifest.json中版本信息为1.3（可选）// Typecho XML-RPC 发布器 - main.js const { Plugin, Modal, Setting, Notice, requestUrl } = require('obsidian'); class XmlRpc { static iso8601(date) { const pad = (n) => (n < 10 ? '0' + n : '' + n); return date.getFullYear().toString() + pad(date.getMonth() + 1) + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); } static parseIso8601(str) { if (!str) return null; let m = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2}):(\d{2}):(\d{2})$/); if (m) { const [_, y, mo, d, h, mi, s] = m; return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); } m = str.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/); if (m) { const [_, y, mo, d, h, mi, s] = m; return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); } const dt = new Date(str); return isNaN(dt.getTime()) ? null : dt; } static encodeValue(val) { if (val === null || val === undefined) return '<nil/>'; if (Array.isArray(val)) return '<array> ' + val.map(v => `<value>${XmlRpc.encodeValue(v)}</value>`).join('') + ' </array>'; const t = typeof val; if (t === 'boolean') return `<boolean>${val ? 1 : 0}</boolean>`; if (t === 'number') return Number.isInteger(val) ? `<int>${val}</int>` : `<double>${val}</double>`; if (val instanceof Date) return `<dateTime.iso8601>${XmlRpc.iso8601(val)}</dateTime.iso8601>`; if (t === 'object') return '<struct>' + Object.entries(val).map(([k, v]) => `<member><name>${XmlRpc.escape(k)}</name><value>${XmlRpc.encodeValue(v)}</value></member>`).join('') + '</struct>'; return `<string>${XmlRpc.escape(String(val))}</string>`; } static escape(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); } static buildCall(method, params) { const xml = `<?xml version="1.0"?><methodCall><methodName>${method}</methodName><params>` + params.map(p => ` <value>${XmlRpc.encodeValue(p)}</value> `).join('') + `</params></methodCall>`; return xml; } static parseResponse(text) { const parseValue = (node) => { if (!node) return null; const nName = node.nodeName; if (nName === 'value') { if (node.children.length === 0) return node.textContent ?? ''; return parseValue(node.children[0]); } if (['string','i4','int','double','boolean','dateTime.iso8601'].includes(nName)) { if (nName === 'boolean') return node.textContent.trim() === '1'; if (nName === 'int' || nName === 'i4') return parseInt(node.textContent.trim(), 10); if (nName === 'double') return Number(node.textContent.trim()); return node.textContent ?? ''; } if (nName === 'struct') { const obj = {}; for (let i = 0; i < node.children.length; i++) { const m = node.children[i]; if (m.nodeName !== 'member') continue; const nameEl = m.getElementsByTagName('name')[0]; const valEl = m.getElementsByTagName('value')[0]; const key = nameEl ? nameEl.textContent : ''; obj[key] = parseValue(valEl); } return obj; } if (nName === 'array') { const dataEl = node.getElementsByTagName('data')[0]; if (!dataEl) return []; const vals = []; for (let i = 0; i < dataEl.children.length; i++) { if (dataEl.children[i].nodeName === 'value') vals.push(parseValue(dataEl.children[i])); } return vals; } return node.textContent ?? ''; }; const parser = new DOMParser(); const doc = parser.parseFromString(text, "text/xml"); const fault = doc.getElementsByTagName('fault')[0]; if (fault) { const val = fault.getElementsByTagName('value')[0]; const obj = parseValue(val); const msg = (obj && (obj.faultString || obj['faultString'])) || 'XML-RPC 错误'; const code = (obj && (obj.faultCode || obj['faultCode'])) || -1; const err = new Error(`XML-RPC 错误 ${code}: ${msg}`); err.code = code; throw err; } const params = doc.getElementsByTagName('params')[0]; if (!params) return null; const first = params.getElementsByTagName('param')[0]; if (!first) return null; const value = first.getElementsByTagName('value')[0]; return parseValue(value); } } class ManageModal extends Modal { constructor(app, plugin) { super(app); this.plugin = plugin; } async onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.createEl('h2', { text: '文章管理' }); try { const xml = XmlRpc.buildCall('metaWeblog.getRecentPosts', [ String(this.plugin.settings.defaultBlogId || '0'), this.plugin.settings.username, this.plugin.settings.password, this.plugin.settings.managePostsCount || 20 ]); const resp = await requestUrl({ url:this.plugin.settings.endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:xml }); const posts = XmlRpc.parseResponse(resp.text) || []; posts.forEach(post => { const card = contentEl.createEl('div', { cls: 'post-card' }); const header = card.createEl('div', { cls: 'post-header' }); const titleEl = header.createEl('a', { text: post.title, href: post.link, cls: 'post-title' }); titleEl.setAttr('target','_blank'); const actions = header.createEl('div', { cls:'post-actions' }); const btnDelete = actions.createEl('button', { text: '删除', cls:'action-btn delete' }); btnDelete.onclick = async () => { const delXml = XmlRpc.buildCall('blogger.deletePost', [ '0', String(post.postid), this.plugin.settings.username, this.plugin.settings.password, true ]); await requestUrl({ url:this.plugin.settings.endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:delXml }); new Notice('文章已删除'); this.onOpen(); }; const btnImport = actions.createEl('button', { text: '导入到 Obsidian', cls:'action-btn import' }); btnImport.onclick = async () => { const fileName = `${post.title}.md`; const tagsArray = (post.mt_keywords || '').split(',').map(s => s.trim()).filter(Boolean); const categoriesArray = Array.isArray(post.categories) ? post.categories : []; const yamlLines = [ '---', `title: ${post.title}`, `slug: "${post.wp_slug || ''}"`, // 用双引号包裹，保证是文本 'tags:', ...tagsArray.map(t => ` - ${t}`), 'categories:', ...categoriesArray.map(c => ` - ${c}`), `draft: ${post.post_status === 'draft'}`, `cid: "${post.postid}"`, // 用双引号包裹，保证是文本 `dateCreated: ${XmlRpc.iso8601(XmlRpc.parseIso8601(post.dateCreated))}`, '---', '', ].join('\n'); const fileContent = `${yamlLines}\n${post.description || ''}`; await this.plugin.app.vault.create(fileName, fileContent); new Notice(`已导入文章: ${fileName}`); }; card.createEl('div', { cls:'post-meta', text: `分类: ${(post.categories||[]).join(', ')} | 标签: ${post.mt_keywords||''}` }); }); } catch(e) { console.error(e); new Notice('获取文章失败: '+e.message); } } } class PublishModal extends Modal { constructor(app, preset, onSubmit, publishOffset = 0, useCurrentTime = true) { super(app); this.preset = preset || {}; this.onSubmit = onSubmit; this.publishOffset = publishOffset; this.useCurrentTime = useCurrentTime; } onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.createEl('h2', { text: '发布到 Typecho' }); // 初始化时间 let initDate; if (this.useCurrentTime) { initDate = new Date(); } else { initDate = this.preset.date ? new Date(this.preset.date) : new Date(); } this.values = { title: this.preset.title || '', slug: this.preset.slug || '', tags: (this.preset.tags || []).join(', '), categories: (this.preset.categories || []).join(', '), draft: this.preset.draft ?? false, date: initDate, }; const pad = n => n.toString().padStart(2, '0'); const toLocalDatetime = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; new Setting(contentEl) .setName('标题') .setDesc('文章标题，将显示在 Typecho 前端') .addText(t => t.setValue(this.values.title).onChange(v => this.values.title = v)); new Setting(contentEl) .setName('Slug') .setDesc('文章 URL 片段（如不填写将自动生成）') .addText(t => t.setValue(this.values.slug).onChange(v => this.values.slug = v)); new Setting(contentEl) .setName('标签（逗号分隔）') .setDesc('多个标签用英文逗号分隔') .addText(t => t.setValue(this.values.tags).onChange(v => this.values.tags = v)); new Setting(contentEl) .setName('分类（逗号分隔）') .setDesc('文章分类，用英文逗号分隔') .addText(t => t.setValue(this.values.categories).onChange(v => this.values.categories = v)); new Setting(contentEl) .setName('草稿') .setDesc('启用后文章不会立即公开') .addToggle(t => t.setValue(this.values.draft).onChange(v => this.values.draft = v)); new Setting(contentEl) .setName('发布时间') .setDesc(`选择文章发布时间（当前偏移: ${this.publishOffset} 小时）`) .addText(t => t .setValue(toLocalDatetime(this.values.date)) .onChange(v => { this.values.date = new Date(v); }) .inputEl.setAttribute('type', 'datetime-local')); new Setting(contentEl) .addButton(b => b.setButtonText(' 发布 ').setCta().onClick(() => { if (!this.values.title) { new Notice('标题不能为空'); return; } if (!this.values.categories || !this.values.categories.trim()) { // 修改点: 分类必填 new Notice('分类不能为空，请至少填写一个分类'); return; } this.close(); const payload = { title: this.values.title, slug: this.values.slug, // slug 可为空，后面处理 tags: this.values.tags.split(',').map(s => s.trim()).filter(Boolean), categories: this.values.categories.split(',').map(s => s.trim()).filter(Boolean), draft: !!this.values.draft, dateCreated: this.values.date, }; this.onSubmit(payload); })); } } module.exports = class TypechoXmlRpcPublisher extends Plugin { async onload() { this.settings = Object.assign({ endpoint: '', username: '', password: '', defaultBlogId: '0', useFrontmatter: true, useCurrentTime: true, publishTimeOffset: 0, syncTimeOffset: 0, managePostsCount: 20, frontmatterKeys: { title:'title', slug:'slug', tags:'tags', categories:'categories', draft:'draft', cid:'cid', dateCreated:'dateCreated' } }, await this.loadData() || {}); this.addSettingTab(new (class extends require('obsidian').PluginSettingTab { constructor(app, plugin){ super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h2', { text: 'Typecho XML-RPC 发布器设置' }); const settingsInputs = {}; settingsInputs.endpoint = new Setting(containerEl).setName('接口 URL') .setDesc('填写 Typecho XML-RPC 接口地址，例如：https://your-site.com/xmlrpc.php') .addText(t => t.setValue(this.plugin.settings.endpoint).onChange(v => settingsInputs.endpoint.value = v)); settingsInputs.username = new Setting(containerEl).setName('用户名') .setDesc('用于登录 Typecho 的用户名，确保有发布权限') .addText(t => t.setValue(this.plugin.settings.username).onChange(v => settingsInputs.username.value = v)); settingsInputs.password = new Setting(containerEl).setName('密码') .setDesc('对应用户名的密码，支持明文输入（将以安全方式保存）') .addText(t => t.setValue(this.plugin.settings.password).onChange(v => settingsInputs.password.value = v).inputEl.setAttribute('type','password')); settingsInputs.defaultBlogId = new Setting(containerEl).setName('默认博客ID') .setDesc('如果你的 Typecho 有多个博客，设置默认发布的博客 ID') .addText(t => t.setValue(this.plugin.settings.defaultBlogId).onChange(v => settingsInputs.defaultBlogId.value = v)); settingsInputs.useCurrentTime = new Setting(containerEl).setName('总是使用当前时间作为发布时间') .setDesc('启用后发布文章时会忽略文章原有 Frontmatter 时间，直接使用当前时间') .addToggle(t => t.setValue(this.plugin.settings.useCurrentTime).onChange(v => settingsInputs.useCurrentTime.value = v)); containerEl.createEl('h3', { text: '时间偏移设置' }); settingsInputs.publishTimeOffset = new Setting(containerEl).setName('发布偏移（小时）') .setDesc('调整发布到 Typecho 的时间，例如 +8 表示服务器时间加 8 小时') .addText(t => t.setValue(String(this.plugin.settings.publishTimeOffset)).onChange(v => settingsInputs.publishTimeOffset.value = v)); settingsInputs.syncTimeOffset = new Setting(containerEl).setName('同步偏移（小时）') .setDesc('同步 Typecho 发布时间时的时间偏移设置') .addText(t => t.setValue(String(this.plugin.settings.syncTimeOffset)).onChange(v => settingsInputs.syncTimeOffset.value = v)); containerEl.createEl('h3', { text: '文章管理设置' }); settingsInputs.managePostsCount = new Setting(containerEl).setName('文章管理显示数量') // 添加文章管理显示数量设置 .setDesc('在文章管理界面显示的文章数量') .addText(t => t.setValue(String(this.plugin.settings.managePostsCount)).onChange(v => settingsInputs.managePostsCount.value = v)); containerEl.createEl('h3', { text: 'Frontmatter 对应键' }); const frontmatterKeys = {}; ['title','slug','tags','categories','draft','cid','dateCreated'].forEach(k => { frontmatterKeys[k] = new Setting(containerEl).setName(k + ' 键') .setDesc(`对应 Frontmatter 中的 ${k} 键，用于自动读取或写入`) .addText(t => t.setValue(this.plugin.settings.frontmatterKeys[k]).onChange(v => frontmatterKeys[k].value = v)); }); new Setting(containerEl) .addButton(b => b.setButtonText('保存设置').setCta().onClick(async () => { this.plugin.settings.endpoint = settingsInputs.endpoint.value ?? this.plugin.settings.endpoint; this.plugin.settings.username = settingsInputs.username.value ?? this.plugin.settings.username; this.plugin.settings.password = settingsInputs.password.value ?? this.plugin.settings.password; this.plugin.settings.defaultBlogId = settingsInputs.defaultBlogId.value ?? this.plugin.settings.defaultBlogId; this.plugin.settings.useCurrentTime = settingsInputs.useCurrentTime.value ?? this.plugin.settings.useCurrentTime; this.plugin.settings.publishTimeOffset = parseFloat(settingsInputs.publishTimeOffset.value) || 0; this.plugin.settings.syncTimeOffset = parseFloat(settingsInputs.syncTimeOffset.value) || 0; this.plugin.settings.managePostsCount = parseInt(settingsInputs.managePostsCount.value) || 20; for (let k of ['title','slug','tags','categories','draft','cid','dateCreated']) { this.plugin.settings.frontmatterKeys[k] = frontmatterKeys[k].value ?? this.plugin.settings.frontmatterKeys[k]; } await this.plugin.saveData(this.plugin.settings); new Notice('设置已保存'); })); } })(this.app, this)); this.addCommand({ id: 'typecho-publish-current-note', name: '发布当前笔记到 Typecho', callback: () => this.publishActiveNote(), }); this.addCommand({ id: 'typecho-sync-publish-date', name: '同步 Typecho 发布时间', callback: () => this.syncPublishDate(), }); this.addCommand({ id: 'typecho-manage-posts', name: '管理文章', callback: () => new ManageModal(this.app, this).open(), }); } async getActiveMDFile() { const file = this.app.workspace.getActiveFile(); if (!file) throw new Error('未选中文件'); if (file.extension !== 'md') throw new Error('当前文件不是 Markdown'); return file; } async readFrontmatter(file) { const cache = this.app.metadataCache.getFileCache(file); return (cache && cache.frontmatter) || {}; } async updateFrontmatter(file, updater) { await this.app.fileManager.processFrontMatter(file, updater); } extractContentWithoutFrontmatter(text) { if (text.startsWith('---')) { const end = text.indexOf('\n---', 3); if (end !== -1) return text.slice(end + 4).trimStart(); } if (text.startsWith('+++')) { const end = text.indexOf('\n+++', 3); if (end !== -1) return text.slice(end + 4).trimStart(); } return text; } async publishActiveNote() { try { const file = await this.getActiveMDFile(); const fm = await this.readFrontmatter(file); const raw = await this.app.vault.read(file); const body = this.extractContentWithoutFrontmatter(raw); const k = this.settings.frontmatterKeys; const preset = { title: fm[k.title] || file.basename, slug: fm[k.slug] || '', // slug 可为空，后面用 cid 填充 tags: Array.isArray(fm[k.tags]) ? fm[k.tags] : (typeof fm[k.tags] === 'string' ? fm[k.tags].split(',').map(s=>s.trim()).filter(Boolean) : []), categories: Array.isArray(fm[k.categories]) ? fm[k.categories] : (typeof fm[k.categories] === 'string' ? fm[k.categories].split(',').map(s=>s.trim()).filter(Boolean) : []), draft: !!fm[k.draft], date: fm[k.dateCreated] ? XmlRpc.parseIso8601(fm[k.dateCreated]) : new Date(), }; const proceed = async (vals) => { if (!vals.categories || vals.categories.length === 0) { // 修改点: 分类必填 new Notice('分类不能为空，请至少填写一个分类'); return; } const postStruct = { title: vals.title, description: body, mt_keywords: vals.tags.join(','), categories: vals.categories, post_type: 'post', wp_slug: vals.slug || '', // 先传空，等拿到 cid 再修正 mt_allow_comments: 1, }; let postDate = this.settings.useCurrentTime ? new Date() : (vals.dateCreated || new Date()); if (this.settings.publishTimeOffset) { postDate = new Date(postDate.getTime() + this.settings.publishTimeOffset * 3600 * 1000); } postStruct.dateCreated = postDate; const endpoint = this.settings.endpoint.trim(); const username = this.settings.username.trim(); const password = this.settings.password; if (!endpoint) { new Notice('错误: 接口 URL 未设置'); console.error("未配置 endpoint"); return; } if (!username || !password) { new Notice('错误: 用户名或密码未设置'); console.error("账号配置缺失"); return; } const cidKey = k.cid; const existingCid = fm[cidKey]; let method, params; if (existingCid) { method='metaWeblog.editPost'; params=[String(existingCid), username, password, postStruct, !vals.draft]; } else { method='metaWeblog.newPost'; params=[String(this.settings.defaultBlogId || '0'), username, password, postStruct, !vals.draft]; } try { const xml = XmlRpc.buildCall(method, params); const response = await requestUrl({ url:endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:xml }); const resultValue = XmlRpc.parseResponse(response.text); await this.updateFrontmatter(file, fm => { fm[k.title] = vals.title; fm[k.tags] = vals.tags; fm[k.categories] = vals.categories; fm[k.draft] = vals.draft; fm[k.dateCreated] = XmlRpc.iso8601(postStruct.dateCreated); fm['lastPublished'] = XmlRpc.iso8601(new Date()); if (!existingCid) { fm[cidKey] = String(resultValue); // 修改点: 如果 slug 为空，用 cid 填充 fm[k.slug] = vals.slug && vals.slug.trim() ? vals.slug : String(resultValue); } else { fm[k.slug] = vals.slug; } }); new Notice(existingCid ? `更新文章成功 (cid=${existingCid})` : `发布新文章成功 (cid=${String(resultValue)})`); } catch (err) { console.error("发布失败: ", err, err.stack); new Notice(`发布失败: ${err.message || err}`); } }; new PublishModal(this.app, preset, proceed, this.settings.publishTimeOffset, this.settings.useCurrentTime).open(); } catch(e) { console.error("发布过程错误: ", e, e.stack); new Notice('错误: '+e.message); } } async syncPublishDate() { try { const file = await this.getActiveMDFile(); const fm = await this.readFrontmatter(file); const k = this.settings.frontmatterKeys; const cid = fm[k.cid]; if (!cid) { new Notice('未找到文章 CID'); return; } const xml = XmlRpc.buildCall('metaWeblog.getPost', [ String(cid), this.settings.username, this.settings.password ]); const response = await requestUrl({ url:this.settings.endpoint, method:'POST', headers:{'Content-Type':'text/xml'}, body:xml }); const result = XmlRpc.parseResponse(response.text); if (!result || !result.dateCreated) { new Notice('获取发布时间失败'); return; } let serverDate = XmlRpc.parseIso8601(result.dateCreated); if (this.settings.syncTimeOffset) serverDate = new Date(serverDate.getTime() + this.settings.syncTimeOffset*3600*1000); await this.updateFrontmatter(file, fm => { fm[k.dateCreated] = XmlRpc.iso8601(serverDate); }); new Notice('发布时间已同步'); } catch(e) { console.error(e); new Notice('错误: '+e.message); } } };