From 60be7dc52e58b04716bd79618706f6242746823c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E9=B9=8F?= Date: Sun, 2 Aug 2026 11:37:01 +0800 Subject: [PATCH] feat: expand Writer Studio workflows and distribution Add multi-column ideas tracking, platform-ready publication packages, and conflict-safe local editing for Writer Studio.\n\nCo-Authored-By: Codex --- docs/PUBLISHING_CHECKLIST.md | 37 +- docs/WRITER_STUDIO_ROADMAP.md | 230 ++++ tools/writer-studio/README.md | 50 +- tools/writer-studio/columns.mjs | 72 ++ tools/writer-studio/idea-store.mjs | 309 +++++ tools/writer-studio/public/app.js | 1112 +++++++++++++++-- tools/writer-studio/public/index.html | 302 ++++- .../public/publication-package.js | 982 +++++++++++++++ tools/writer-studio/public/styles.css | 626 +++++++++- tools/writer-studio/server.mjs | 236 +++- tools/writer-studio/server.test.mjs | 661 +++++++++- tools/writer-studio/task-workspace.mjs | 310 +++-- 12 files changed, 4705 insertions(+), 222 deletions(-) create mode 100644 docs/WRITER_STUDIO_ROADMAP.md create mode 100644 tools/writer-studio/columns.mjs create mode 100644 tools/writer-studio/idea-store.mjs create mode 100644 tools/writer-studio/public/publication-package.js diff --git a/docs/PUBLISHING_CHECKLIST.md b/docs/PUBLISHING_CHECKLIST.md index 5585a64..b63914a 100644 --- a/docs/PUBLISHING_CHECKLIST.md +++ b/docs/PUBLISHING_CHECKLIST.md @@ -60,13 +60,36 @@ in source files, issue text, pull request comments, screenshots, build artifacts Use this model for future automation: -- Keep all credentials in GitHub Actions Secrets or Environment Secrets. -- Prefer a protected production environment with manual approval. -- Trigger platform publishing with `workflow_dispatch`, not from untrusted pull requests. -- Use one token per platform with the smallest usable scope. -- Rotate tokens after testing or whenever a platform account changes. -- For WeChat, prefer draft generation or manual review unless the official account API flow is - fully confirmed. +- Keep authoring and pre-publication payloads in Writer Studio's private runtime, not in public-repo + CI jobs. +- Store local credentials in the OS keychain. If an always-on scheduler is required, use a separate + private worker with a managed secret store and KMS; the public repository contains only code and + secret references. +- Require a preview and explicit approval before the first live action on each platform or account. +- Use one token per platform with the smallest usable scope, and rotate or revoke it after testing + or whenever an account changes. +- Log content hashes, public remote IDs, timestamps, and redacted error codes only. Never log tokens, + unpublished bodies, authorization headers, or raw platform responses containing account data. +- For WeChat, prefer draft generation and manual review unless the official account's subject type, + certification, IP whitelist, and `freepublish` permissions are confirmed. +- Do not automate Medium or Xueqiu through cookies, private endpoints, or browser scripting. Generate + a reviewable manual publication package instead. + +For a manual publication from Writer Studio: + +1. Open **平台发布包**, select the destination platform and language, and read its compatibility + note. +2. Copy the title into the platform's title field, then use **复制带排版正文** for a long-form editor + or **复制发布文案** for LinkedIn/X feed posts. +3. Replace each numbered image marker by using **复制图片** in order. Do not publish a localhost, + `blob:`, or `data:` image URL. +4. For Medium, prefer its official URL Import with the published canonical URL. Use rich-text copy + only as a fallback. +5. Resolve every formula, data-table, MDX-component, missing-image, or legacy-HTML warning before the + final platform action. +6. Inspect the destination draft before publishing. A successful clipboard write proves only that + the package was copied; it does not prove the platform preserved every style or uploaded every + image. ## Final Review diff --git a/docs/WRITER_STUDIO_ROADMAP.md b/docs/WRITER_STUDIO_ROADMAP.md new file mode 100644 index 0000000..b4047fb --- /dev/null +++ b/docs/WRITER_STUDIO_ROADMAP.md @@ -0,0 +1,230 @@ +# Writer Studio:多栏目、Ideas 与唯一分发控制台路线 + +> 方案基线:2026-08-02。平台接口、权限与价格会变化,实施连接器时必须再次核对官方文档。 + +## 目标定义 + +Writer Studio 应成为本站内容的**唯一控制平面**,但不承诺所有平台都能全自动执行: + +```text +本地构思与写作 → 人工定稿 → 本站 canonical → 私有分发队列 + ├─ 官方 API + └─ 人工发布包 +``` + +- 本站保存完整、稳定、可引用的双语 canonical 版本。 +- 各平台版本都从同一个已批准的内容版本派生,不在平台编辑器里产生第二份“源稿”。 +- API 可用且合规时自动发布;平台不开放 API 时,由 Studio 生成发布包并记录人工完成状态。 +- 未发布的 ideas、草稿、排期 payload、凭据与平台内部 ID 不进入公开 Git 历史。 + +## 当前事实基线 + +仓库在本轮审计时共有 37 个完整中英文章对(74 个 MDX): + +| 平台 | 仓库能证明的状态 | +| -------------- | ------------------------------------------------------ | +| 本站 | 37 个中文版本、37 个英文版本均已发布 | +| 微信公众号 | 37 个中文版本声明已发布;其中没有直达 URL 或 remote ID | +| 英文微信公众号 | 6 个英文版本也声明发布,需核验是否为复制误标 | +| LinkedIn / X | 没有结构化发布记录 | +| Medium / 雪球 | 当前 schema 无法表达 | + +因此目前首先要解决的是“远端对账”,而不是把所有空白都直接当成“未发布”。已知线索包括: + +- `2025/glp1-china-giants-battle` 英文正文保留了一条 Medium 版本链接,应核验后导入台账。 +- `2026/continuous-refactoring` 的历史版本曾声明微信、雪球和 Medium 同步发布,但缺少 URL,应标记为待核验。 + +## 产品模型 + +### 栏目注册表 + +Writer Studio 的一级对象是栏目,而不是博客目录: + +| ID | 栏目 | 当前内容类型 | 当前可执行能力 | +| -------------------- | --------------- | ------------ | --------------------------------------- | +| `intellipharma` | 智药深瞳 | 双语文章 | Ideas、文章任务、站点发布、未来外部分发 | +| `economics-after-ai` | AI 时代的经济学 | 在线书 | Ideas;章节格式稳定后再增加书稿适配器 | +| `gaia-project` | 盖亚计划 | 长期研究项目 | Ideas;研究输出格式稳定后再增加适配器 | + +不同栏目应配置自己的工作流、文档模板、语言、校验器、canonical 规则和发布适配器,不能继续在 `server.mjs` 中堆叠栏目条件分支。 + +### Ideas Track + +Idea 是跨文章、跨章节的独立对象,至少包含: + +```text +columnId, title, body, tags, sourceUrl, status, targetDate, +createdAt, updatedAt +``` + +推荐状态: + +```text +收件箱 → 继续发展 → 已排期 → 已进入写作 → 已归档 +``` + +下一步增加:Idea 之间的连接、证据条目、合并、全文搜索,以及“晋升为文章/章节/研究任务”。 + +### 分发台账 + +台账按文章语言版本和平台 rendition 记录,不在中英文 frontmatter 中重复制造模糊状态: + +```text +translationKey +locale +platform +format: full_text | native_article | summary | thread | link_post +status: planned | approved | site_published | queued | publishing | + published_claimed | published_verified | manual_required | + needs_reconcile | failed | withdrawn +contentHash +publicUrl +remoteId +publishedAt +lastVerifiedAt +evidence +``` + +唯一键使用: + +```text +(translationKey, locale, platform, format, contentHash) +``` + +API 超时且无法确认是否成功时必须进入 `needs_reconcile`,不能盲目重试并制造重复内容。 + +## 平台执行策略 + +| 平台 | 推荐内容 | 执行方式 | 关键边界 | +| ---------- | ---------------------------------------- | ------------------------------------- | ---------------------------------------------------------- | +| 本站 | 中英完整正文 | 现有站点发布器 | 唯一 canonical | +| 微信公众号 | 中文完整正文 | 官方 API 进草稿;权限允许时再提交发布 | `freepublish` 权限取决于主体和认证状态;发布不等于群发 | +| 雪球 | 产业、资本、经济学相关中文全文或深度节选 | Studio 生成发布包,人工发布 | 未发现公开发布 API;不得使用 cookie、私有接口或机器人绕过 | +| LinkedIn | 英文洞见、关键图表、canonical 链接 | 官方 OAuth + Share/Post API | API 的 article 是 URL 卡片;原生长文章/Newsletter 人工完成 | +| X | 明确结论、短线程、精选英文 Article | Create Post;权限确认后接 Article API | Articles 需合资格订阅;API 权限和调用成本须在接入时复核 | +| Medium | 精选常青英文长文 | 发布本站后,用官方 Import 人工导入 | 不再签发新 integration token;旧 API 不受支持 | + +官方依据: + +- 微信:[新增草稿](https://developers.weixin.qq.com/doc/service/api/draftbox/draftmanage/api_draft_add.html)、[发布草稿](https://developers.weixin.qq.com/doc/service/api/public/api_freepublish_submit.html)、[Stable Access Token](https://developers.weixin.qq.com/doc/subscription/api/base/api_getstableaccesstoken.html) +- LinkedIn:[权限](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access)、[Share on LinkedIn](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin)、[OAuth](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow) +- X:[Create Post](https://docs.x.com/x-api/posts/create-post)、[Create draft Article](https://docs.x.com/x-api/articles/create-draft-article)、[Articles 资格与编辑器](https://help.x.com/en/using-x/articles)、[OAuth 2.0](https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code) +- Medium:[API 现状](https://help.medium.com/hc/en-us/articles/213480228-API-Importing)、[Import](https://help.medium.com/hc/en-us/articles/214550207-Importing-a-post-to-Medium) +- 雪球:[服务协议](https://xueqiu.com/about/terms) + +## 隐私与凭据边界 + +### 可以公开提交 + +- 栏目注册表、状态机、连接器代码与 payload 模板。 +- 已公开文章、公开 URL、公开发布时间与已验证分发状态。 +- 不含正文和授权信息的测试 fixture。 + +### 只能保存在私有运行层 + +- Ideas、草稿、未发布的分发 payload。 +- OAuth access/refresh token、AppSecret、平台内部草稿 ID、账号内部 ID。 +- 可能包含正文或身份信息的错误响应与调度日志。 + +本机凭据优先放 macOS Keychain,Writer Studio 只保存 `secretRef`。不要把 token 放入前端、`localStorage`、截图、Issue、PR 评论或 GitHub Actions 日志。 + +如果接受“电脑开机时才能定时发布”,首版调度器使用本机 `launchd + Keychain`,保密性最好。如果要求关机后仍准时发布,再部署独立的私有 worker,并使用云 Secret Manager/KMS;它不应运行在公开仓库的普通 CI 上。微信 API 还需要稳定出口 IP。 + +发布采用两阶段操作: + +```text +prepare(生成平台版本并锁定 hash) + → approve(人工预览确认) + → execute(API 或人工发布) + → reconcile(回填 remote ID、URL 与状态) +``` + +## 分阶段开发路线 + +### Phase 0:安全的多栏目底座(本轮已完成) + +- 三栏目注册表和栏目选择器。 +- 跨栏目的本地 Ideas Inbox:快速捕获、来源、标签、目标日期、编辑和状态推进。 +- 旧博客任务自动补上版本、栏目和内容类型,不改变已发布内容。 +- 非博客栏目不会被错误套入博客 MDX 模板。 +- 所有写操作要求启动时会话令牌,并拒绝跨源请求。 + +### Phase 0.5:人工发布包(本轮已完成) + +- Writer Studio 为微信公众号、雪球、Medium、LinkedIn 长文和 X Article 生成保守的内联 + 样式富文本,同时写入 `text/html` 与 `text/plain` 剪贴板格式。 +- LinkedIn 动态与 X 帖子生成适合平台限制的纯文本引流文案,不伪装成富文本。 +- 标题、canonical、正文和图片分别一键复制;已发布文章图片可从正式内容目录安全回退, + SVG/JPG/WebP 等在复制图片时转为 PNG。 +- 公式、站点数据表、未知 MDX 组件、遗失图片与旧 HTML 会显示复核提示,不静默丢失。 +- 发布包不读取凭据、不访问平台 API、不自动登录,也不会把 localhost 图片地址写入正文。 +- Medium 明确提示优先使用官方 URL Import;各平台剪贴板兼容性仍需以真实粘贴结果为准。 + +当前发布包服务于 Writer Studio 内已载入的文章任务。覆盖 37 篇存量的下一步仍是 Phase 2 +的站点文章目录与分发台账;不能把“可复制”误当成“已完成远端对账”。 + +### Phase 1:Idea 晋升与经济学书稿适配器 + +- Idea 支持搜索、连接、合并和证据条目。 +- “晋升”动作建立写作任务,并保留 Idea 回链。 +- 增加移动端快速捕获界面和离线暂存;移动端只暴露最小化的 capture surface, + 不暴露文章发布、OAuth 或凭据管理。 +- 首选通过 Tailscale Serve 在个人 tailnet 内访问专用 capture endpoint,使用独立设备令牌; + 不把当前 localhost 管理后台直接绑定到 `0.0.0.0`。若要求主机离线时仍可捕获,再增加只保存 + 端到端加密 payload 的中继,解密密钥只留在个人设备。 +- 为《AI 时代的经济学》建立独立章节 collection、动态路由、章节排序和书级大纲。 +- 保留现有经济学地图为专题可视化,不让普通 MDX 发布器覆盖它。 +- 让 P02/P03 能进入站点统一 feed,但拥有自己的编辑边界与页面外壳。 + +验收:能从一条经济学 Idea 创建章节任务、完成双语或指定语言书稿,并安全发布到 P02 路径。 + +### Phase 2:私有分发台账与远端对账 + +- 导入 74 个站内版本为 `published_verified`。 +- 导入 37 个中文微信声明为 `published_claimed`。 +- 将 6 个英文微信声明和已知 Medium/雪球线索标为 `needs_reconcile`。 +- 认证后先读取或导出远端文章清单,再按标题、日期、语言匹配 `translationKey`。 +- 只把确认过的公开 URL 回填到公开 frontmatter。 + +验收:Writer Studio 能准确区分“已验证、仅声明、真正未发布”,并生成可靠 backlog。 + +### Phase 3:连接器试点 + +建议按风险从低到高: + +1. LinkedIn 个人 Share:一条测试动态。 +2. X:先发一条测试帖并设置调用预算上限;确认账号资格与 API 权限后,只写入一篇 Article + 草稿供人工复核,再单独测试发布动作。 +3. 微信:先探测账号能力和 IP 白名单,只向草稿箱写入一篇测试稿。 +4. Medium:生成官方 Import 任务并记录人工完成。 +5. 雪球:生成发布包,不进行自动化登录或发布。 + +每次真正向外部账号发内容前,都要在预览页进行动作级确认。 + +### Phase 4:排期、重试与观测 + +- 状态机、幂等键、指数退避、`needs_reconcile` 和全局 kill switch。 +- 本机 `launchd` 调度;按需求再升级私有 worker。 +- 日志只记录内容 hash、脱敏错误码、remote ID 和时间。 +- Token 到期提醒、撤销 OAuth、密钥轮换和平台预算上限。 + +### Phase 5:多平台引流而非机械复制 + +- 首轮只选 8–12 篇常青高价值文章,不一次性倾倒 37 篇存量。 +- 微信保留中文完整论证;雪球只选产业/资本/经济学相关内容。 +- LinkedIn 使用英文专业洞见和关键图表;X 使用结论、3–6 条线程或少量精选 Article。 +- Medium 用官方 Import 发布精选英文长文,并指向本站英文 canonical。 +- 每个平台使用独立 CTA 与 UTM;canonical 本身保持干净。 +- 中英文 RSS/Atom 与邮件订阅属于高优先级自有渠道,订阅者名单必须保持私有。 + +## 下一次需要的账号侧信息 + +在开始真实认证与发布试点前,需要在平台后台确认: + +- “智药深瞳”的主体类型、认证状态、AppID、草稿/发布接口权限和 IP 白名单能力。 +- LinkedIn 使用个人账号还是组织主页;首版推荐个人账号。 +- X 开发者项目、付费计划与可接受的单次/月度预算。 +- 是否仍持有 Medium 旧 integration token;即使持有,默认仍推荐官方 Import。 +- 已发布的微信、LinkedIn、Medium、雪球文章清单或后台导出,以完成首次对账。 + +凭据本身不要粘贴进 Codex 对话或提交到仓库;授权应只在平台官方 OAuth 页面完成。 diff --git a/tools/writer-studio/README.md b/tools/writer-studio/README.md index c013af7..0973e49 100644 --- a/tools/writer-studio/README.md +++ b/tools/writer-studio/README.md @@ -1,8 +1,8 @@ # Writer Studio -Writer Studio is a localhost-only writing environment for IntelliPharma Hub. It keeps unfinished -work outside the published Astro content collection while allowing Codex and the browser editor to -work on the same files. +Writer Studio is a localhost-only, multi-column writing environment for IntelliPharma Hub. It keeps +unfinished work outside the published Astro content collection while allowing Codex and the browser +editor to work on the same files. ## Start @@ -13,7 +13,11 @@ pnpm writer Open `http://127.0.0.1:4321`. On first start, the newest site article is copied byte-for-byte into `.drafts/blog/` as an editable example. The `.drafts/` directory is intentionally ignored by Git. -The writing workflow is: +Use the column selector to move between IntelliPharma Insights, Economics After AI, and the Gaia +Project. Every column has a private Ideas Inbox under `.drafts/ideas/`; the fixed bilingual article +adapter currently remains exclusive to IntelliPharma Insights. + +The article writing workflow is: ```text $start-article-research → human outline → article-specific style → $draft-from-outline → revision @@ -21,18 +25,43 @@ $start-article-research → human outline → article-specific style → $draft- Every new article task contains `TASK.md`, `task.json`, an Agent-maintained `references.md`, a human-authored `outline.md`, an editable `style.md`, bilingual MDX files, and an `images/` folder. -Writer Studio deliberately shows only `提纲 → 本篇风格 → 正文`: research context stays on disk for -the Skills, while images are managed inside the body editor instead of as a separate stage. Legacy -`brief.md`, `research.md`, and `image-plan.md` files are preserved but no longer displayed. +Inside an article task, Writer Studio deliberately shows only `提纲 → 本篇风格 → 正文`: research +context stays on disk for the Skills, while images are managed inside the body editor instead of as +a separate stage. Legacy `brief.md`, `research.md`, and `image-plan.md` files are preserved but no +longer displayed. The client auto-saves after a short pause and also provides a manual Save button and `Ctrl/Cmd+S`. It polls the task directory and reloads external Skill or editor changes when there are no unsaved local changes. The `$draft-from-outline` control exposes only the explicit Skill invocation for the active task; Writer Studio itself does not pretend to call a model or hide a generated prompt. +## Manual distribution package + +For an active article, click **平台发布包**. Writer Studio creates platform-specific output without +contacting or signing in to any external service: + +- WeChat, Xueqiu, Medium, LinkedIn Article, and X Article receive conservative semantic HTML with + inline styles, plus a plain-text fallback. +- LinkedIn feed posts and X posts receive concise plain-text copy with the canonical site URL. +- Title, canonical URL, body, and each referenced image can be copied separately because platform + editors use separate fields and do not share one clipboard contract. +- Published article images are read from the site content directory when the seeded draft has no + private copy. Non-PNG images are converted in the browser when **复制图片** is used; no localhost + image URL is inserted into the external article. +- MDX-only tables, formulas, custom components, missing images, and legacy HTML are surfaced as + review warnings instead of being silently dropped. Legacy HTML text is retained without its site + styles. + +Medium's official URL Import remains preferable for an already published article because it handles +canonical metadata and images more reliably. Clipboard formatting is a convenience layer, not an +API guarantee from the destination platforms, so always inspect the pasted draft before publishing. + ## Boundaries - The server binds to `127.0.0.1` only. +- Mutating API requests require a per-process session token and same-origin browser requests. +- Column definitions are explicit; book and research projects cannot accidentally publish through + the blog adapter. - Draft identifiers are restricted to `YYYY/kebab-case-slug`. - The API can write only language files under `.drafts/blog/` and can publish only into `src/content/blog/`. @@ -42,5 +71,12 @@ active task; Writer Studio itself does not pretend to call a model or hide a gen invents missing research evidence. - Publishing never overwrites an existing site article, runs the repository content audit, and rolls back the published copy if that audit fails. It never runs Git or deploy commands. +- Ideas and drafts remain under the Git-ignored `.drafts/` directory. Platform credentials do not + belong there; future connectors must use an OS keychain or a private secret manager. +- The manual distribution package neither stores credentials nor sends content. Its external-editor + links navigate only after an explicit click. + +See `docs/WRITER_STUDIO_ROADMAP.md` for the content-adapter, distribution-ledger, platform API, and +privacy roadmap. Run the focused tests with `pnpm writer:test`. diff --git a/tools/writer-studio/columns.mjs b/tools/writer-studio/columns.mjs new file mode 100644 index 0000000..b73a7bb --- /dev/null +++ b/tools/writer-studio/columns.mjs @@ -0,0 +1,72 @@ +export const DEFAULT_COLUMN_ID = 'intellipharma'; + +const columns = [ + { + id: DEFAULT_COLUMN_ID, + code: 'P01', + name: '智药深瞳', + englishName: 'IntelliPharma Insights', + description: 'AI、制药产业、科学哲学与长期价值逻辑的双语文章。', + contentKind: 'bilingual_article', + status: 'active', + capabilities: { + ideas: true, + drafts: true, + sitePublish: true, + distribution: true, + }, + }, + { + id: 'economics-after-ai', + code: 'P02', + name: 'AI 时代的经济学', + englishName: 'Economics After AI', + description: '围绕 AI 生产力、社会再生产与增长循环持续生长的在线书。', + contentKind: 'book', + status: 'evolving', + capabilities: { + ideas: true, + drafts: false, + sitePublish: false, + distribution: false, + }, + }, + { + id: 'gaia-project', + code: 'P03', + name: '盖亚计划', + englishName: 'Gaia Project', + description: 'AI 蛋白设计、合成生物学与人工生态系统的长期研究构想。', + contentKind: 'research_project', + status: 'research', + capabilities: { + ideas: true, + drafts: false, + sitePublish: false, + distribution: false, + }, + }, +]; + +const columnMap = new Map(columns.map((column) => [column.id, column])); + +export function listColumns() { + return columns.map((column) => ({ + ...column, + capabilities: { ...column.capabilities }, + })); +} + +export function getColumn(id) { + return columnMap.get(id) ?? null; +} + +export function requireColumn(id) { + const column = getColumn(id); + if (!column) { + const error = new Error('Unknown Writer Studio column.'); + error.status = 400; + throw error; + } + return column; +} diff --git a/tools/writer-studio/idea-store.mjs b/tools/writer-studio/idea-store.mjs new file mode 100644 index 0000000..0879280 --- /dev/null +++ b/tools/writer-studio/idea-store.mjs @@ -0,0 +1,309 @@ +import { randomUUID } from 'node:crypto'; +import { access, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { URL } from 'node:url'; +import { requireColumn } from './columns.mjs'; + +export const IDEA_STATUSES = [ + { id: 'inbox', label: '收件箱' }, + { id: 'developing', label: '继续发展' }, + { id: 'planned', label: '已排期' }, + { id: 'drafted', label: '已进入写作' }, + { id: 'archived', label: '已归档' }, +]; + +const ideaStatusIds = new Set(IDEA_STATUSES.map((status) => status.id)); +const ideaIdPattern = /^\d{8}t\d{6}-[a-f0-9]{8}$/; +const ideaUpdateQueues = new Map(); + +function ideaRoot(root) { + return path.join(root, '.drafts', 'ideas'); +} + +function ideaDirectory(root, columnId) { + requireColumn(columnId); + return path.join(ideaRoot(root), columnId); +} + +function ideaFile(root, columnId, id) { + if (!ideaIdPattern.test(id)) { + const error = new Error('Invalid idea id.'); + error.status = 400; + throw error; + } + return path.join(ideaDirectory(root, columnId), `${id}.json`); +} + +async function exists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + +function timestamp() { + return new Date().toISOString(); +} + +function createIdeaId() { + const date = new Date() + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}Z$/, ''); + return `${date.toLowerCase()}-${randomUUID().replaceAll('-', '').slice(0, 8)}`; +} + +function normalizeText(value, name, limit, { required = false } = {}) { + const normalized = String(value ?? '').trim(); + if (required && !normalized) { + const error = new Error(`${name} is required.`); + error.status = 400; + throw error; + } + if (normalized.length > limit) { + const error = new Error(`${name} is too long.`); + error.status = 400; + throw error; + } + return normalized; +} + +function normalizeTags(value) { + const source = Array.isArray(value) ? value : String(value ?? '').split(/[,,]/); + const tags = [...new Set(source.map((item) => String(item).trim()).filter(Boolean))]; + if (tags.length > 12 || tags.some((tag) => tag.length > 40)) { + const error = new Error('Ideas support at most 12 tags, each no longer than 40 characters.'); + error.status = 400; + throw error; + } + return tags; +} + +function normalizeSourceUrl(value) { + const sourceUrl = normalizeText(value, 'Source URL', 2_048); + if (!sourceUrl) return ''; + try { + const parsed = new URL(sourceUrl); + if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(); + return parsed.href; + } catch { + const error = new Error('Source URL must be an HTTP or HTTPS URL.'); + error.status = 400; + throw error; + } +} + +function normalizeTargetDate(value) { + const targetDate = normalizeText(value, 'Target date', 10); + if (targetDate && !/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { + const error = new Error('Target date must use YYYY-MM-DD.'); + error.status = 400; + throw error; + } + return targetDate; +} + +function normalizeStatus(value, fallback = 'inbox') { + const status = String(value || fallback); + if (!ideaStatusIds.has(status)) { + const error = new Error('Invalid idea status.'); + error.status = 400; + throw error; + } + return status; +} + +function normalizedIdeaInput(input, current = {}) { + return { + title: normalizeText(input.title ?? current.title, 'Idea title', 200), + body: normalizeText(input.body ?? current.body, 'Idea body', 20_000, { required: true }), + tags: normalizeTags(input.tags ?? current.tags), + sourceUrl: normalizeSourceUrl(input.sourceUrl ?? current.sourceUrl), + targetDate: normalizeTargetDate(input.targetDate ?? current.targetDate), + status: normalizeStatus(input.status, current.status), + }; +} + +function validTimestamp(value) { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} + +async function readIdeaFile(file, expected = {}) { + try { + const parsed = JSON.parse(await readFile(file, 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('record must be a JSON object'); + } + if (parsed.schemaVersion !== 1) throw new Error('unsupported schema version'); + if (expected.id && parsed.id !== expected.id) throw new Error('id does not match its filename'); + if (expected.columnId && parsed.columnId !== expected.columnId) { + throw new Error('columnId does not match its directory'); + } + requireColumn(parsed.columnId); + if (!ideaIdPattern.test(parsed.id)) throw new Error('invalid stored idea id'); + if (!validTimestamp(parsed.createdAt) || !validTimestamp(parsed.updatedAt)) { + throw new Error('createdAt or updatedAt is invalid'); + } + const revision = parsed.revision ?? 1; + if (!Number.isInteger(revision) || revision < 1) throw new Error('revision is invalid'); + return { + idea: { + schemaVersion: 1, + id: parsed.id, + columnId: parsed.columnId, + revision, + ...normalizedIdeaInput(parsed), + createdAt: parsed.createdAt, + updatedAt: parsed.updatedAt, + }, + error: '', + }; + } catch (error) { + return { + idea: null, + error: error instanceof Error ? error.message : 'record is unreadable', + }; + } +} + +export async function listIdeasWithDiagnostics(root, columnId = '') { + const columnIds = columnId + ? [requireColumn(columnId).id] + : (await readdir(ideaRoot(root), { withFileTypes: true }).catch(() => [])) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((id) => { + try { + requireColumn(id); + return true; + } catch { + return false; + } + }); + const ideas = []; + const invalidRecords = []; + for (const id of columnIds) { + const directory = ideaDirectory(root, id); + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const recordId = entry.name.slice(0, -'.json'.length); + if (!ideaIdPattern.test(recordId)) continue; + const result = await readIdeaFile(path.join(directory, entry.name), { + columnId: id, + id: recordId, + }); + if (result.idea) ideas.push(result.idea); + else invalidRecords.push({ columnId: id, id: recordId, error: result.error }); + } + } + return { + ideas: ideas.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + invalidRecords, + }; +} + +export async function listIdeas(root, columnId = '') { + return (await listIdeasWithDiagnostics(root, columnId)).ideas; +} + +export async function createIdea(root, input) { + const column = requireColumn(String(input.columnId || '')); + if (!column.capabilities.ideas) { + const error = new Error('This column does not support ideas.'); + error.status = 400; + throw error; + } + const createdAt = timestamp(); + const idea = { + schemaVersion: 1, + id: createIdeaId(), + columnId: column.id, + revision: 1, + ...normalizedIdeaInput(input), + createdAt, + updatedAt: createdAt, + }; + const directory = ideaDirectory(root, column.id); + await mkdir(directory, { recursive: true }); + await writeFile(ideaFile(root, column.id, idea.id), `${JSON.stringify(idea, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + return idea; +} + +async function findIdea(root, id) { + if (!ideaIdPattern.test(id)) { + const error = new Error('Invalid idea id.'); + error.status = 400; + throw error; + } + const ideasRoot = ideaRoot(root); + const columns = await readdir(ideasRoot, { withFileTypes: true }).catch(() => []); + for (const column of columns.filter((entry) => entry.isDirectory())) { + let file; + try { + file = ideaFile(root, column.name, id); + } catch { + continue; + } + if (await exists(file)) { + const result = await readIdeaFile(file, { columnId: column.name, id }); + return { file, ...result }; + } + } + const error = new Error('Idea does not exist.'); + error.status = 404; + throw error; +} + +async function writeIdeaAtomically(file, idea) { + const temporary = `${file}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify(idea, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + try { + await rename(temporary, file); + } finally { + await rm(temporary, { force: true }); + } +} + +async function serializeIdeaUpdate(id, action) { + const previous = ideaUpdateQueues.get(id) ?? Promise.resolve(); + const queued = previous.catch(() => {}).then(action); + ideaUpdateQueues.set(id, queued); + try { + return await queued; + } finally { + if (ideaUpdateQueues.get(id) === queued) ideaUpdateQueues.delete(id); + } +} + +export async function updateIdea(root, id, input) { + return serializeIdeaUpdate(id, async () => { + const found = await findIdea(root, id); + if (!found.idea) { + const error = new Error(`Idea file is invalid: ${found.error || 'unknown record error'}.`); + error.status = 422; + throw error; + } + if (input.revision != null && Number(input.revision) !== found.idea.revision) { + const error = new Error('Idea changed on disk; reload it before saving again.'); + error.status = 409; + throw error; + } + const idea = { + ...found.idea, + ...normalizedIdeaInput(input, found.idea), + revision: found.idea.revision + 1, + updatedAt: timestamp(), + }; + await writeIdeaAtomically(found.file, idea); + return idea; + }); +} diff --git a/tools/writer-studio/public/app.js b/tools/writer-studio/public/app.js index ad7ec4a..aebe27e 100644 --- a/tools/writer-studio/public/app.js +++ b/tools/writer-studio/public/app.js @@ -1,15 +1,37 @@ +import { buildPublicationPackage, PUBLICATION_PLATFORMS } from './publication-package.js'; + const state = { + columns: [], drafts: [], + ideas: [], + invalidIdeaRecords: [], + ideaStatuses: [], + ideaCounts: {}, + activeColumnId: 'intellipharma', activeId: '', + mode: 'writing', language: 'cn', section: 'outline', content: '', workspace: null, + ideaFilter: 'active', + editingIdeaId: '', + ideaFormDirty: false, + ideasRequestSequence: 0, + sessionToken: '', dirty: false, loading: false, saveTimer: null, syncTimer: null, toastTimer: null, + publicationPackage: null, + publicationRequestSequence: 0, + baseHash: '', + editRevision: 0, + saveInFlight: null, + saveConflict: null, + documentLoadSequence: 0, + documentLoadsInFlight: 0, }; const sectionLabels = { @@ -30,6 +52,9 @@ const elements = Object.fromEntries( [ 'draft-list', 'draft-count', + 'column-select', + 'idea-count', + 'new-idea-button', 'document-path', 'document-title', 'save-status', @@ -38,9 +63,23 @@ const elements = Object.fromEntries( 'stage-list', 'draft-skill-button', 'validate-button', + 'publication-package-button', 'publish-button', + 'writing-topbar', + 'writing-stage-bar', + 'writing-editor-toolbar', 'empty-state', 'writing-area', + 'ideas-workspace', + 'ideas-column-code', + 'ideas-column-title', + 'ideas-column-description', + 'idea-form', + 'idea-form-title', + 'save-idea-button', + 'cancel-idea-edit', + 'idea-status-filter', + 'ideas-list', 'editor', 'editor-label', 'editor-stats', @@ -54,6 +93,7 @@ const elements = Object.fromEntries( 'new-draft-button', 'new-draft-dialog', 'new-draft-form', + 'new-draft-column', 'close-dialog', 'cancel-dialog', 'result-dialog', @@ -66,6 +106,27 @@ const elements = Object.fromEntries( 'close-skill', 'copy-skill', 'confirm-skill', + 'publication-package-dialog', + 'close-publication-package', + 'publication-platform', + 'publication-language', + 'publication-format', + 'publication-guidance', + 'publication-title', + 'publication-canonical', + 'publication-count', + 'publication-preview-label', + 'publication-rich-preview', + 'publication-plain-preview', + 'publication-assets', + 'publication-asset-count', + 'publication-asset-list', + 'publication-warnings', + 'open-publication-platform', + 'copy-publication-title', + 'copy-publication-link', + 'copy-publication-plain', + 'copy-publication-main', 'toast', ].map((id) => [ id.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), @@ -73,12 +134,33 @@ const elements = Object.fromEntries( ]) ); -async function api(url, options) { +async function api(url, options, allowTokenRefresh = true) { + const method = options?.method || 'GET'; + const headers = { 'Content-Type': 'application/json', ...(options?.headers || {}) }; + if (method !== 'GET' && state.sessionToken) { + headers['X-Writer-Studio-Token'] = state.sessionToken; + } const response = await fetch(url, { - headers: { 'Content-Type': 'application/json' }, ...options, + headers, }); const payload = await response.json(); + if ( + !response.ok && + response.status === 403 && + method !== 'GET' && + allowTokenRefresh && + /session token/i.test(payload.error || '') + ) { + const stateResponse = await fetch('/api/state', { + headers: { 'Content-Type': 'application/json' }, + }); + if (stateResponse.ok) { + const refreshedState = await stateResponse.json(); + state.sessionToken = refreshedState.sessionToken; + return api(url, options, false); + } + } if (!response.ok) { const error = new Error(payload.error || '请求失败'); error.payload = payload; @@ -262,9 +344,98 @@ function currentDraft() { return state.drafts.find((draft) => draft.id === state.activeId); } +function currentColumn() { + return state.columns.find((column) => column.id === state.activeColumnId); +} + +function columnDrafts() { + return state.drafts.filter((draft) => draft.columnId === state.activeColumnId); +} + +function setTabState(tab, selected) { + tab.classList.toggle('active', selected); + tab.setAttribute('aria-selected', String(selected)); + tab.tabIndex = selected ? 0 : -1; +} + +function updateWritingPanelLabel() { + const sectionTab = [...document.querySelectorAll('.section-tab')].find( + (tab) => tab.dataset.section === state.section + ); + const languageTab = + state.section === 'article' + ? [...document.querySelectorAll('.language-tab')].find( + (tab) => tab.dataset.language === state.language + ) + : null; + elements.writingArea.setAttribute( + 'aria-labelledby', + ['writing-mode-tab', sectionTab?.id, languageTab?.id].filter(Boolean).join(' ') + ); +} + +function enableTabKeyboardNavigation(tablist) { + tablist.addEventListener('keydown', (event) => { + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { + return; + } + const tabs = [...tablist.querySelectorAll('[role="tab"]')].filter( + (tab) => !tab.disabled && !tab.hidden + ); + const currentIndex = tabs.indexOf(document.activeElement); + if (currentIndex < 0 || !tabs.length) return; + event.preventDefault(); + let nextIndex; + if (event.key === 'Home') nextIndex = 0; + else if (event.key === 'End') nextIndex = tabs.length - 1; + else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + nextIndex = (currentIndex + 1) % tabs.length; + } else { + nextIndex = (currentIndex - 1 + tabs.length) % tabs.length; + } + tabs[nextIndex].focus(); + tabs[nextIndex].click(); + }); +} + +function renderColumnControls() { + const options = state.columns + .map( + (column) => + `` + ) + .join(''); + elements.columnSelect.innerHTML = options; + elements.columnSelect.value = state.activeColumnId; + elements.newDraftColumn.innerHTML = state.columns + .map( + (column) => + `` + ) + .join(''); + const column = currentColumn(); + const ideaCount = state.ideaCounts[state.activeColumnId] || 0; + elements.ideaCount.textContent = String(ideaCount); + elements.newDraftButton.disabled = !column?.capabilities.drafts; + elements.newDraftButton.textContent = column?.capabilities.drafts + ? '+ 新建文章草稿' + : '本栏目先从 Ideas 开始'; + elements.ideasColumnCode.textContent = `${column?.code || ''} / IDEAS INBOX`; + elements.ideasColumnTitle.textContent = column?.name || 'Ideas'; + elements.ideasColumnDescription.textContent = + column?.description || '先记录未经打磨的判断,再决定如何写作。'; +} + function renderDraftList() { - elements.draftCount.textContent = String(state.drafts.length); - elements.draftList.innerHTML = state.drafts + const drafts = columnDrafts(); + elements.draftCount.textContent = String(drafts.length); + elements.draftList.innerHTML = drafts .map( (draft) => `` + }" data-stage="${stage.id}" type="button" ${taskMetadataReady ? '' : 'disabled'}>${escapeHtml( + stage.label + )}` ) .join(''); } @@ -322,51 +501,286 @@ function renderAssets() { .join(''); } +function ideaStatusLabel(status) { + return state.ideaStatuses.find((item) => item.id === status)?.label || status; +} + +function renderIdeas() { + const filtered = state.ideas.filter((idea) => { + if (state.ideaFilter === 'all') return true; + if (state.ideaFilter === 'active') return idea.status !== 'archived'; + return idea.status === state.ideaFilter; + }); + const invalidNotice = state.invalidIdeaRecords.length + ? `
检测到 ${state.invalidIdeaRecords.length} 条无法读取的 Idea 记录。原文件已保留且没有被改写,请在本地检查其 JSON 结构。
` + : ''; + if (!filtered.length) { + elements.ideasList.innerHTML = `${invalidNotice}
这里还没有符合当前筛选条件的 Idea。先记录一句问题、一条判断,或两个原本没有被连接起来的概念。
`; + return; + } + const statusOptions = (selected) => + state.ideaStatuses + .map( + (status) => + `` + ) + .join(''); + elements.ideasList.innerHTML = `${invalidNotice}${filtered + .map((idea) => { + const title = idea.title || idea.body.split(/\r?\n/)[0].slice(0, 44) || '未命名想法'; + const updated = new Date(idea.updatedAt).toLocaleDateString('zh-CN', { + month: '2-digit', + day: '2-digit', + }); + return `
+

${escapeHtml(title)}

+

${escapeHtml(idea.body)}

+ ${ + idea.tags.length + ? `
${idea.tags + .map((tag) => `${escapeHtml(tag)}`) + .join('')}
` + : '' + } + ${ + idea.sourceUrl + ? `${escapeHtml(idea.sourceUrl)}` + : '' + } + +
`; + }) + .join('')}`; +} + +async function refreshIdeas() { + const requestedColumnId = state.activeColumnId; + const requestSequence = ++state.ideasRequestSequence; + const payload = await api(`/api/ideas?column=${encodeURIComponent(requestedColumnId)}`); + if ( + requestSequence !== state.ideasRequestSequence || + requestedColumnId !== state.activeColumnId + ) { + return false; + } + state.ideas = payload.ideas; + state.invalidIdeaRecords = payload.invalidRecords || []; + state.ideaStatuses = payload.statuses; + elements.ideaStatusFilter.innerHTML = [ + '', + '', + ...state.ideaStatuses.map( + (status) => `` + ), + ].join(''); + elements.ideaStatusFilter.value = state.ideaFilter; + renderIdeas(); + return true; +} + +function resetIdeaForm() { + state.editingIdeaId = ''; + state.ideaFormDirty = false; + elements.ideaForm.reset(); + elements.ideaFormTitle.textContent = '记录一个想法'; + elements.saveIdeaButton.textContent = '存入 Ideas'; + elements.cancelIdeaEdit.classList.add('hidden'); +} + +function startEditingIdea(id) { + if (!confirmDiscardIdeaForm()) return; + const idea = state.ideas.find((item) => item.id === id); + if (!idea) return; + state.editingIdeaId = id; + const form = elements.ideaForm.elements; + form.title.value = idea.title; + form.body.value = idea.body; + form.sourceUrl.value = idea.sourceUrl; + form.tags.value = idea.tags.join(', '); + form.targetDate.value = idea.targetDate; + state.ideaFormDirty = false; + elements.ideaFormTitle.textContent = '编辑这个想法'; + elements.saveIdeaButton.textContent = '保存修改'; + elements.cancelIdeaEdit.classList.remove('hidden'); + elements.ideaForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + form.body.focus(); +} + +function confirmDiscardIdeaForm() { + if (!state.ideaFormDirty) return true; + return confirm('当前 Idea 还没有保存。确认放弃这些改动吗?'); +} + +async function setMode(mode) { + if (!['writing', 'ideas'].includes(mode)) return; + if (state.mode === 'writing' && mode !== 'writing' && !(await saveCurrent())) return false; + if (state.mode === 'ideas' && mode !== 'ideas' && !confirmDiscardIdeaForm()) return false; + if (state.mode === 'ideas' && mode !== 'ideas') resetIdeaForm(); + state.mode = mode; + document.querySelectorAll('.studio-mode-tab').forEach((tab) => { + const selected = tab.dataset.mode === mode; + setTabState(tab, selected); + }); + const ideasMode = mode === 'ideas'; + elements.writingTopbar.classList.toggle('hidden', ideasMode); + elements.writingStageBar.classList.toggle('hidden', ideasMode); + elements.writingEditorToolbar.classList.toggle('hidden', ideasMode); + elements.ideasWorkspace.classList.toggle('hidden', !ideasMode); + if (ideasMode) { + elements.emptyState.classList.add('hidden'); + elements.writingArea.classList.add('hidden'); + await refreshIdeas(); + return true; + } + if (state.activeId && currentDraft()?.columnId === state.activeColumnId) { + elements.emptyState.classList.add('hidden'); + elements.writingArea.classList.remove('hidden'); + } else { + elements.emptyState.classList.remove('hidden'); + elements.writingArea.classList.add('hidden'); + const column = currentColumn(); + elements.documentPath.textContent = `.drafts/ideas/${state.activeColumnId}/`; + elements.documentTitle.textContent = column?.name || 'Writer Studio'; + elements.saveStatus.textContent = '等待写作任务'; + elements.syncStatus.textContent = '当前栏目尚无固定格式'; + elements.stageList.innerHTML = ''; + elements.saveButton.disabled = true; + elements.draftSkillButton.disabled = true; + elements.validateButton.disabled = true; + elements.publicationPackageButton.disabled = true; + elements.publishButton.disabled = true; + elements.publishButton.textContent = '发布到站点'; + elements.emptyState.querySelector('h2').textContent = column?.capabilities.drafts + ? '在浏览器里安静地写作' + : `${column?.name || '这个栏目'}先从 Ideas 生长`; + elements.emptyState.querySelector('p').textContent = column?.capabilities.drafts + ? '选择左侧草稿,或者创建一篇新文章。Codex 对文件的修改会在这里自动显示。' + : '这里暂不强行套用博客文章格式。先记录灵感、证据和问题,等书稿或研究结构稳定后再建立正式发布适配器。'; + } + return true; +} + async function refreshState() { const payload = await api('/api/state'); + state.sessionToken = payload.sessionToken; + state.columns = payload.columns; state.drafts = payload.drafts; + state.ideaCounts = payload.ideaCounts || {}; + if (!state.columns.some((column) => column.id === state.activeColumnId)) { + state.activeColumnId = state.columns[0]?.id || 'intellipharma'; + } + renderColumnControls(); renderDraftList(); } -async function refreshWorkspace() { - state.workspace = await api(`/api/drafts/${encodeURIComponent(state.activeId)}/workspace`); +async function refreshWorkspace(id = state.activeId) { + if (!id) return null; + const workspace = await api(`/api/drafts/${encodeURIComponent(id)}/workspace`); + if (id !== state.activeId) return null; + state.workspace = workspace; renderStages(); renderAssets(); + return workspace; } -function setEditorContent(content, pathLabel) { +function setEditorContent(content, pathLabel, hash = '') { state.content = content; + state.baseHash = hash; + state.editRevision += 1; + state.saveConflict = null; state.dirty = false; elements.editor.value = content; elements.documentPath.textContent = pathLabel; elements.editorLabel.textContent = sectionLabels[state.section]; elements.previewLabel.textContent = '阅读预览'; elements.saveStatus.textContent = '已载入'; + elements.saveButton.textContent = '保存'; elements.saveButton.disabled = false; updateStats(); updatePreview(); } async function loadSection(section, options = {}) { - if (!state.activeId) return; - if (!options.skipSave) await saveCurrent(); - state.section = section; - document.querySelectorAll('.section-tab').forEach((tab) => { - tab.classList.toggle('active', tab.dataset.section === section); - }); - const languageTabs = document.querySelector('.language-tabs'); - languageTabs.classList.toggle('hidden', section !== 'article'); + if (!state.activeId) return false; + const requestSequence = ++state.documentLoadSequence; + const requestedId = state.activeId; + const requestedLanguage = options.language || state.language; + state.documentLoadsInFlight += 1; + elements.editor.readOnly = true; + elements.writingArea.setAttribute('aria-busy', 'true'); + try { + if (!options.skipSave && !(await saveCurrent())) return false; + if (requestSequence !== state.documentLoadSequence || requestedId !== state.activeId) { + return false; + } - if (section === 'article') { - const payload = await api( - `/api/drafts/${encodeURIComponent(state.activeId)}?lang=${state.language}` - ); - setEditorContent(payload.content, `.drafts/blog/${state.activeId}/${state.language}.mdx`); - elements.documentTitle.textContent = payload.metadata.title || state.activeId; - } else { - await refreshWorkspace(); - const document = state.workspace.documents[section]; - setEditorContent(document.content, `.drafts/blog/${state.activeId}/${document.file}`); + let payload; + let taskDocument; + if (section === 'article') { + payload = await api( + `/api/drafts/${encodeURIComponent(requestedId)}?lang=${requestedLanguage}` + ); + } else { + const workspace = await refreshWorkspace(requestedId); + if (!workspace) return false; + taskDocument = workspace.documents[section]; + } + + if (requestSequence !== state.documentLoadSequence || requestedId !== state.activeId) { + return false; + } + state.section = section; + if (section === 'article') state.language = requestedLanguage; + document.querySelectorAll('.section-tab').forEach((tab) => { + setTabState(tab, tab.dataset.section === section); + }); + document.querySelectorAll('.language-tab').forEach((tab) => { + setTabState(tab, tab.dataset.language === state.language); + }); + document.querySelector('.language-tabs').classList.toggle('hidden', section !== 'article'); + updateWritingPanelLabel(); + + if (section === 'article') { + setEditorContent( + payload.content, + `.drafts/blog/${requestedId}/${requestedLanguage}.mdx`, + payload.hash + ); + elements.documentTitle.textContent = payload.metadata.title || requestedId; + } else { + setEditorContent( + taskDocument.content, + `.drafts/blog/${requestedId}/${taskDocument.file}`, + taskDocument.hash + ); + } + return true; + } finally { + state.documentLoadsInFlight -= 1; + if (state.documentLoadsInFlight === 0) { + elements.editor.readOnly = false; + elements.writingArea.removeAttribute('aria-busy'); + } } } @@ -374,14 +788,20 @@ async function loadDraft(id, preferredLanguage = state.language) { if (state.loading) return; state.loading = true; try { - await saveCurrent(); + if (!(await saveCurrent())) return false; + const selectedDraft = state.drafts.find((draft) => draft.id === id); + if (selectedDraft?.columnId && selectedDraft.columnId !== state.activeColumnId) { + state.activeColumnId = selectedDraft.columnId; + renderColumnControls(); + } state.activeId = id; const draft = currentDraft(); state.language = draft?.languages.includes(preferredLanguage) ? preferredLanguage : draft?.languages[0] || 'cn'; - await refreshWorkspace(); - const entrySection = ['draft', 'polish', 'ready'].includes(state.workspace.task.stage) + const workspace = await refreshWorkspace(id); + if (!workspace) return false; + const entrySection = ['draft', 'polish', 'ready'].includes(workspace.task.stage) ? 'article' : 'outline'; elements.documentTitle.textContent = draft?.title || id; @@ -389,74 +809,163 @@ async function loadDraft(id, preferredLanguage = state.language) { elements.writingArea.classList.remove('hidden'); elements.validateButton.disabled = false; elements.draftSkillButton.disabled = false; + elements.publicationPackageButton.disabled = false; elements.saveButton.disabled = false; elements.publishButton.disabled = Boolean(draft?.published); elements.publishButton.textContent = draft?.published ? '站点已有版本' : '发布到站点'; document.querySelectorAll('.language-tab').forEach((tab) => { - tab.classList.toggle('active', tab.dataset.language === state.language); + const selected = tab.dataset.language === state.language; + setTabState(tab, selected); tab.disabled = !draft?.languages.includes(tab.dataset.language); }); + updateWritingPanelLabel(); renderDraftList(); await loadSection(entrySection, { skipSave: true }); - elements.syncStatus.textContent = '已连接 Codex 文件协作'; + elements.syncStatus.textContent = + state.workspace.taskMetadata && state.workspace.taskMetadata.status !== 'ready' + ? 'task.json 异常;阶段更新已锁定,原文件仍保留' + : '已连接 Codex 文件协作'; + return true; } finally { state.loading = false; } } +function isCurrentDocument(snapshot) { + return ( + state.activeId === snapshot.id && + state.section === snapshot.section && + (snapshot.section !== 'article' || state.language === snapshot.language) + ); +} + +async function saveSnapshot(manual) { + const snapshot = { + id: state.activeId, + section: state.section, + language: state.language, + content: elements.editor.value, + revision: state.editRevision, + baseHash: state.baseHash, + }; + elements.saveStatus.textContent = manual ? '正在手动保存…' : '正在自动保存…'; + try { + let result; + if (snapshot.section === 'article') { + result = await api( + `/api/drafts/${encodeURIComponent(snapshot.id)}?lang=${snapshot.language}`, + { + method: 'PUT', + body: JSON.stringify({ content: snapshot.content, baseHash: snapshot.baseHash }), + } + ); + } else { + result = await api( + `/api/drafts/${encodeURIComponent(snapshot.id)}/documents/${snapshot.section}`, + { + method: 'PUT', + body: JSON.stringify({ content: snapshot.content, baseHash: snapshot.baseHash }), + } + ); + } + if (isCurrentDocument(snapshot)) { + state.baseHash = result.hash; + state.content = snapshot.content; + state.dirty = + state.editRevision !== snapshot.revision || elements.editor.value !== snapshot.content; + elements.saveStatus.textContent = state.dirty + ? '保存期间出现新改动,继续保存…' + : manual + ? '已手动保存' + : '已自动保存'; + } + return true; + } catch (error) { + if (isCurrentDocument(snapshot) && error.payload?.currentHash) { + state.saveConflict = { currentHash: error.payload.currentHash }; + state.dirty = true; + elements.saveButton.textContent = '解决冲突'; + elements.saveStatus.textContent = '保存冲突:磁盘已有新版本,当前输入仍保留'; + showToast('Codex 或外部编辑器已修改此文件;浏览器内容没有覆盖磁盘'); + } else if (isCurrentDocument(snapshot)) { + elements.saveStatus.textContent = `保存失败:${error.message}`; + } + return false; + } +} + async function saveCurrent(options = {}) { const manual = Boolean(options.manual); - if (!state.activeId) return false; - if (!state.dirty) { + if (!state.activeId) return true; + + if (state.saveConflict) { + if (!manual) return false; + const overwrite = confirm( + 'Codex 或外部编辑器已在磁盘写入新版本。\n\n确定:用当前浏览器内容覆盖该版本。\n取消:保持两边内容不变,先手动复制并比较。' + ); + if (!overwrite) return false; + state.baseHash = state.saveConflict.currentHash; + state.saveConflict = null; + elements.saveButton.textContent = '保存'; + } + + if (state.saveInFlight) { + const saved = await state.saveInFlight; + if (!saved) return false; + if (state.dirty) return saveCurrent(options); if (manual) { elements.saveStatus.textContent = '内容已保存'; showToast('当前内容已经保存'); } return true; } - const content = elements.editor.value; - elements.saveStatus.textContent = manual ? '正在手动保存…' : '正在自动保存…'; - try { - if (state.section === 'article') { - await api(`/api/drafts/${encodeURIComponent(state.activeId)}?lang=${state.language}`, { - method: 'PUT', - body: JSON.stringify({ content }), - }); - } else { - await api(`/api/drafts/${encodeURIComponent(state.activeId)}/documents/${state.section}`, { - method: 'PUT', - body: JSON.stringify({ content }), - }); + + if (!state.dirty) { + if (manual) { + elements.saveStatus.textContent = '内容已保存'; + showToast('当前内容已经保存'); } - state.content = content; - state.dirty = false; - elements.saveStatus.textContent = manual ? '已手动保存' : '已自动保存'; - if (manual) showToast('当前文件已手动保存'); - await refreshState(); - await refreshWorkspace(); return true; - } catch (error) { - elements.saveStatus.textContent = `保存失败:${error.message}`; - return false; } + + const operation = saveSnapshot(manual); + state.saveInFlight = operation; + const saved = await operation; + if (state.saveInFlight === operation) state.saveInFlight = null; + if (!saved) return false; + if (state.dirty) return saveCurrent(options); + + await Promise.allSettled([refreshState(), refreshWorkspace()]); + if (manual) showToast('当前文件已手动保存'); + return true; } async function syncFromDisk() { - if (!state.activeId || state.dirty || state.loading) return; + if ( + state.mode !== 'writing' || + !state.activeId || + state.dirty || + state.loading || + state.documentLoadsInFlight + ) + return; try { const previousStage = state.workspace?.task?.stage; await refreshWorkspace(); let incoming; + let incomingHash; if (state.section === 'article') { incoming = await api( `/api/drafts/${encodeURIComponent(state.activeId)}?lang=${state.language}` ); + incomingHash = incoming.hash; incoming = incoming.content; } else { + incomingHash = state.workspace.documents[state.section].hash; incoming = state.workspace.documents[state.section].content; } if (incoming !== state.content) { - setEditorContent(incoming, elements.documentPath.textContent); + setEditorContent(incoming, elements.documentPath.textContent, incomingHash); showToast('已载入 Codex 或外部软件写入的新内容'); } if (previousStage !== state.workspace.task.stage) renderStages(); @@ -514,7 +1023,7 @@ async function uploadAsset(file) { } async function insertAsset(name) { - await loadSection('article'); + if (!(await loadSection('article'))) return; const editor = elements.editor; const insertion = `\n\n![${name.replace(/\.[^.]+$/, '')}](./images/${name})\n\n`; const start = editor.selectionStart; @@ -525,13 +1034,372 @@ async function insertAsset(name) { showToast('图片引用已插入正文,请补充准确的 alt 与 caption'); } -elements.draftList.addEventListener('click', (event) => { +function publicationPreviewDocument(packageData) { + const language = elements.publicationLanguage.value; + return `${packageData.richHtml}`; +} + +async function copyPlainText(value) { + if (!value) throw new Error('没有可以复制的内容'); + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + // Older embedded browsers can expose the async API while denying it. + } + } + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-10000px'; + document.body.append(textarea); + textarea.select(); + const copied = document.execCommand('copy'); + textarea.remove(); + if (!copied) throw new Error('浏览器未允许剪贴板访问'); +} + +async function copyRichText(html, plainText) { + if (!html) { + await copyPlainText(plainText); + return; + } + if (navigator.clipboard?.write && window.ClipboardItem) { + try { + await navigator.clipboard.write([ + new window.ClipboardItem({ + 'text/html': new Blob([html], { type: 'text/html' }), + 'text/plain': new Blob([plainText], { type: 'text/plain' }), + }), + ]); + return; + } catch { + // Fall through to a selection copy for browsers with partial ClipboardItem support. + } + } + + const container = document.createElement('div'); + container.contentEditable = 'true'; + container.style.position = 'fixed'; + container.style.left = '-10000px'; + container.innerHTML = html; + document.body.append(container); + const range = document.createRange(); + range.selectNodeContents(container); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + const copied = document.execCommand('copy'); + selection.removeAllRanges(); + container.remove(); + if (!copied) throw new Error('浏览器未允许富文本剪贴板访问'); +} + +async function imageBlobAsPng(blob) { + if (blob.type === 'image/png') return blob; + let bitmap; + let objectUrl = ''; + try { + bitmap = await window.createImageBitmap(blob); + } catch { + objectUrl = URL.createObjectURL(blob); + bitmap = await new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('浏览器无法读取这张图片')); + image.src = objectUrl; + }); + } + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width || bitmap.naturalWidth; + canvas.height = bitmap.height || bitmap.naturalHeight; + const context = canvas.getContext('2d'); + if (!context) throw new Error('浏览器无法创建图片转换画布'); + context.drawImage(bitmap, 0, 0); + bitmap.close?.(); + if (objectUrl) URL.revokeObjectURL(objectUrl); + return new Promise((resolve, reject) => { + canvas.toBlob( + (png) => (png ? resolve(png) : reject(new Error('图片转换失败'))), + 'image/png', + 1 + ); + }); +} + +async function copyPublicationImage(fileName) { + if (!navigator.clipboard?.write || !window.ClipboardItem) { + throw new Error('当前浏览器不支持复制图片,请从文章图片面板手动上传'); + } + const response = await fetch( + `/api/drafts/${encodeURIComponent(state.activeId)}/assets/${encodeURIComponent(fileName)}` + ); + if (!response.ok) throw new Error('本地任务中找不到这张图片'); + const png = await imageBlobAsPng(await response.blob()); + await navigator.clipboard.write([new window.ClipboardItem({ 'image/png': png })]); +} + +function renderPublicationPackage() { + const packageData = state.publicationPackage; + if (!packageData) return; + const rich = packageData.platform.format === 'rich'; + elements.publicationFormat.textContent = rich ? '富文本 HTML' : '纯文本'; + elements.publicationFormat.dataset.format = packageData.platform.format; + elements.publicationGuidance.textContent = packageData.platform.guidance; + elements.publicationTitle.textContent = packageData.metadata.title || '未填写标题'; + elements.publicationCanonical.textContent = packageData.metadata.canonicalUrl || '尚无原文链接'; + elements.copyPublicationLink.disabled = !packageData.metadata.canonicalUrl; + elements.copyPublicationTitle.disabled = !packageData.metadata.title; + elements.copyPublicationPlain.disabled = !packageData.plainText; + elements.copyPublicationMain.disabled = !packageData.plainText; + if (packageData.metadata.canonicalUrl) { + elements.publicationCanonical.href = packageData.metadata.canonicalUrl; + } else { + elements.publicationCanonical.removeAttribute('href'); + } + elements.publicationCount.textContent = `${packageData.characterCount} 字符`; + elements.publicationPreviewLabel.textContent = rich ? '正文(不含标题)' : '完整发布文案'; + elements.copyPublicationMain.textContent = packageData.platform.actionLabel; + elements.copyPublicationPlain.textContent = rich ? '复制纯文本' : '复制备用纯文本'; + elements.openPublicationPlatform.href = packageData.platform.editorUrl; + elements.openPublicationPlatform.textContent = packageData.platform.editorLabel; + + elements.publicationRichPreview.classList.toggle('hidden', !rich); + elements.publicationPlainPreview.classList.toggle('hidden', rich); + if (rich) { + elements.publicationRichPreview.srcdoc = publicationPreviewDocument(packageData); + } else { + elements.publicationRichPreview.srcdoc = ''; + elements.publicationPlainPreview.value = packageData.plainText; + } + + const workspaceAssets = new Set((state.workspace?.assets || []).map((asset) => asset.name)); + elements.publicationAssets.classList.toggle('hidden', !packageData.assets.length); + elements.publicationAssetCount.textContent = `${packageData.assets.length} 张`; + elements.publicationAssetList.innerHTML = packageData.assets + .map((asset) => { + const exists = asset.fileName && workspaceAssets.has(asset.fileName); + const url = exists + ? `/api/drafts/${encodeURIComponent(state.activeId)}/assets/${encodeURIComponent( + asset.fileName + )}` + : ''; + const kind = asset.kind === 'cover' ? '封面' : `正文 ${asset.index}`; + const description = asset.caption || asset.alt || asset.fileName || '未识别图片'; + return `
+ ${ + exists + ? `${escapeHtml(description)}` + : '' + } +
${escapeHtml(kind)}${escapeHtml(description)}${escapeHtml( + asset.fileName || 'MDX 中未解析出文件名' + )}
+ +
`; + }) + .join(''); + + elements.publicationWarnings.classList.toggle('hidden', !packageData.warnings.length); + elements.publicationWarnings.innerHTML = packageData.warnings.length + ? `发布前复核` + : ''; +} + +function setPublicationPackageLoading() { + state.publicationPackage = null; + elements.publicationFormat.textContent = '正在生成'; + elements.publicationFormat.dataset.format = 'loading'; + elements.publicationGuidance.textContent = '正在从本地文章生成目标平台版本……'; + elements.publicationTitle.textContent = '—'; + elements.publicationCanonical.textContent = '—'; + elements.publicationCanonical.removeAttribute('href'); + elements.publicationCount.textContent = '0 字符'; + elements.publicationRichPreview.srcdoc = ''; + elements.publicationPlainPreview.value = ''; + elements.publicationAssets.classList.add('hidden'); + elements.publicationAssetList.innerHTML = ''; + elements.publicationWarnings.classList.add('hidden'); + elements.publicationWarnings.innerHTML = ''; + elements.openPublicationPlatform.removeAttribute('href'); + elements.openPublicationPlatform.textContent = '打开平台编辑器'; + elements.copyPublicationTitle.disabled = true; + elements.copyPublicationLink.disabled = true; + elements.copyPublicationPlain.disabled = true; + elements.copyPublicationMain.disabled = true; +} + +async function refreshPublicationPackage() { + const requestSequence = ++state.publicationRequestSequence; + const language = elements.publicationLanguage.value; + const platformId = elements.publicationPlatform.value; + setPublicationPackageLoading(); + try { + const payload = await api( + `/api/drafts/${encodeURIComponent(state.activeId)}?lang=${encodeURIComponent(language)}` + ); + if (requestSequence !== state.publicationRequestSequence) return; + state.publicationPackage = buildPublicationPackage({ + source: payload.content, + platformId, + articleId: state.activeId, + language, + published: Boolean(currentDraft()?.published), + }); + const availableAssets = new Set((state.workspace?.assets || []).map((asset) => asset.name)); + const missingAssets = state.publicationPackage.assets.filter( + (asset) => !asset.fileName || !availableAssets.has(asset.fileName) + ); + if (missingAssets.length) { + state.publicationPackage.warnings.push( + `${missingAssets.length} 张图片未在草稿或已发布文章目录中找到,不能一键复制。` + ); + } + renderPublicationPackage(); + } catch (error) { + showToast(`发布包生成失败:${error.message}`); + } +} + +async function openPublicationPackage() { + if (!state.activeId || !(await saveCurrent())) return; + await refreshWorkspace(); + elements.publicationPlatform.innerHTML = PUBLICATION_PLATFORMS.map( + (platform) => + `` + ).join(''); + const defaultPlatformId = state.language === 'en' ? 'medium' : 'wechat'; + elements.publicationPlatform.value = defaultPlatformId; + elements.publicationLanguage.innerHTML = (currentDraft()?.languages || []) + .map( + (language) => + `` + ) + .join(''); + const preferredLanguage = PUBLICATION_PLATFORMS.find( + (platform) => platform.id === defaultPlatformId + )?.defaultLanguage; + elements.publicationLanguage.value = currentDraft()?.languages.includes(preferredLanguage) + ? preferredLanguage + : state.language; + elements.publicationPackageDialog.showModal(); + await refreshPublicationPackage(); +} + +elements.draftList.addEventListener('click', async (event) => { const button = event.target.closest('[data-id]'); - if (button) loadDraft(button.dataset.id); + if (button && (await setMode('writing'))) await loadDraft(button.dataset.id); +}); + +elements.columnSelect.addEventListener('change', async () => { + const previousColumnId = state.activeColumnId; + const nextColumnId = elements.columnSelect.value; + if (state.mode === 'ideas' && !confirmDiscardIdeaForm()) { + elements.columnSelect.value = previousColumnId; + return; + } + if (state.mode === 'writing' && !(await saveCurrent())) { + elements.columnSelect.value = previousColumnId; + return; + } + if (state.mode === 'ideas') resetIdeaForm(); + state.activeColumnId = nextColumnId; + state.activeId = ''; + state.workspace = null; + renderColumnControls(); + renderDraftList(); + if (state.mode === 'ideas') { + resetIdeaForm(); + await refreshIdeas(); + return; + } + const [draft] = columnDrafts(); + if (draft) await loadDraft(draft.id); + else await setMode('writing'); +}); + +document.querySelectorAll('.studio-mode-tab').forEach((tab) => { + tab.addEventListener('click', () => setMode(tab.dataset.mode)); }); +elements.newIdeaButton.addEventListener('click', async () => { + if (await setMode('ideas')) elements.ideaForm.elements.body.focus(); +}); + +elements.ideaStatusFilter.addEventListener('change', () => { + state.ideaFilter = elements.ideaStatusFilter.value; + renderIdeas(); +}); + +elements.ideasList.addEventListener('change', async (event) => { + const select = event.target.closest('[data-idea-status]'); + if (!select) return; + try { + await api(`/api/ideas/${encodeURIComponent(select.dataset.ideaStatus)}`, { + method: 'PATCH', + body: JSON.stringify({ status: select.value }), + }); + await refreshIdeas(); + await refreshState(); + showToast(`Idea 状态已更新为 ${select.options[select.selectedIndex].textContent}`); + } catch (error) { + showToast(`状态更新失败:${error.message}`); + } +}); + +elements.ideasList.addEventListener('click', (event) => { + const button = event.target.closest('[data-edit-idea]'); + if (button) startEditingIdea(button.dataset.editIdea); +}); + +elements.ideaForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(elements.ideaForm)); + payload.columnId = state.activeColumnId; + try { + if (state.editingIdeaId) { + await api(`/api/ideas/${encodeURIComponent(state.editingIdeaId)}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + showToast('Idea 已保存'); + } else { + await api('/api/ideas', { method: 'POST', body: JSON.stringify(payload) }); + showToast('Idea 已进入收件箱'); + } + resetIdeaForm(); + await refreshIdeas(); + await refreshState(); + } catch (error) { + showToast(`Idea 保存失败:${error.message}`); + } +}); + +elements.ideaForm.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + elements.ideaForm.requestSubmit(); + } +}); + +elements.ideaForm.addEventListener('input', () => { + state.ideaFormDirty = true; +}); + +elements.cancelIdeaEdit.addEventListener('click', resetIdeaForm); + elements.editor.addEventListener('input', () => { state.content = elements.editor.value; + state.editRevision += 1; state.dirty = true; elements.saveStatus.textContent = '尚未保存'; updateStats(); @@ -560,12 +1428,7 @@ document.querySelectorAll('.section-tab').forEach((tab) => { document.querySelectorAll('.language-tab').forEach((tab) => { tab.addEventListener('click', async () => { - await saveCurrent(); - state.language = tab.dataset.language; - document.querySelectorAll('.language-tab').forEach((item) => { - item.classList.toggle('active', item.dataset.language === state.language); - }); - await loadSection('article', { skipSave: true }); + await loadSection('article', { language: tab.dataset.language }); }); }); @@ -583,23 +1446,98 @@ document.querySelectorAll('.view-tab').forEach((tab) => { elements.stageList.addEventListener('click', async (event) => { const button = event.target.closest('[data-stage]'); if (!button || !state.activeId) return; - const payload = await api(`/api/drafts/${encodeURIComponent(state.activeId)}/task`, { - method: 'PATCH', - body: JSON.stringify({ stage: button.dataset.stage }), - }); - state.workspace.task = payload.task; - renderStages(); - await refreshState(); - showToast(`任务阶段已更新为 ${button.textContent}`); + try { + const payload = await api(`/api/drafts/${encodeURIComponent(state.activeId)}/task`, { + method: 'PATCH', + body: JSON.stringify({ stage: button.dataset.stage }), + }); + state.workspace.task = payload.task; + renderStages(); + await refreshState(); + showToast(`任务阶段已更新为 ${button.textContent}`); + } catch (error) { + showToast(`阶段更新失败:${error.message}`); + } }); elements.draftSkillButton.addEventListener('click', async () => { - await saveCurrent(); + if (!(await saveCurrent())) return; const command = state.workspace.skills.draft.command; elements.skillCommand.textContent = command; elements.skillDialog.showModal(); }); +elements.publicationPackageButton.addEventListener('click', openPublicationPackage); + +elements.publicationPlatform.addEventListener('change', async () => { + const platform = PUBLICATION_PLATFORMS.find( + (candidate) => candidate.id === elements.publicationPlatform.value + ); + if (currentDraft()?.languages.includes(platform?.defaultLanguage)) { + elements.publicationLanguage.value = platform.defaultLanguage; + } + await refreshPublicationPackage(); +}); + +elements.publicationLanguage.addEventListener('change', refreshPublicationPackage); + +elements.copyPublicationTitle.addEventListener('click', async () => { + try { + await copyPlainText(state.publicationPackage?.metadata.title || ''); + showToast('文章标题已复制'); + } catch (error) { + showToast(`复制失败:${error.message}`); + } +}); + +elements.copyPublicationLink.addEventListener('click', async () => { + try { + await copyPlainText(state.publicationPackage?.metadata.canonicalUrl || ''); + showToast('原文链接已复制'); + } catch (error) { + showToast(`复制失败:${error.message}`); + } +}); + +elements.copyPublicationPlain.addEventListener('click', async () => { + try { + await copyPlainText(state.publicationPackage?.plainText || ''); + showToast('纯文本发布内容已复制'); + } catch (error) { + showToast(`复制失败:${error.message}`); + } +}); + +elements.copyPublicationMain.addEventListener('click', async () => { + const packageData = state.publicationPackage; + if (!packageData) return; + try { + if (packageData.platform.format === 'rich') { + await copyRichText(packageData.richHtml, packageData.plainText); + showToast('带排版正文已复制;请粘贴到平台编辑器并复核图片位置'); + } else { + await copyPlainText(packageData.plainText); + showToast('平台发布文案已复制'); + } + } catch (error) { + showToast(`复制失败:${error.message}`); + } +}); + +elements.publicationAssetList.addEventListener('click', async (event) => { + const button = event.target.closest('[data-copy-publication-image]'); + if (!button || button.disabled) return; + try { + button.disabled = true; + await copyPublicationImage(button.dataset.copyPublicationImage); + showToast(`图片 ${button.dataset.copyPublicationImage} 已复制为 PNG`); + } catch (error) { + showToast(`图片复制失败:${error.message}`); + } finally { + button.disabled = false; + } +}); + elements.copySkill.addEventListener('click', async () => { try { await navigator.clipboard.writeText(elements.skillCommand.textContent); @@ -610,7 +1548,7 @@ elements.copySkill.addEventListener('click', async () => { }); elements.validateButton.addEventListener('click', async () => { - await saveCurrent(); + if (!(await saveCurrent())) return; try { const result = await api(`/api/drafts/${encodeURIComponent(state.activeId)}/validate`, { method: 'POST', @@ -623,7 +1561,7 @@ elements.validateButton.addEventListener('click', async () => { elements.publishButton.addEventListener('click', async () => { if (!confirm('确认把正文和文章图片复制到正式站点内容目录吗?')) return; - await saveCurrent(); + if (!(await saveCurrent())) return; try { const result = await api(`/api/drafts/${encodeURIComponent(state.activeId)}/publish`, { method: 'POST', @@ -655,9 +1593,14 @@ elements.assetGrid.addEventListener('click', (event) => { }); function openNewDraftDialog() { + if (!currentColumn()?.capabilities.drafts) { + showToast('这个栏目尚未固定发布格式,请先记录 Idea'); + return; + } const today = new Date().toISOString().slice(0, 10); elements.newDraftForm.elements.year.value = today.slice(0, 4); elements.newDraftForm.elements.date.value = today; + elements.newDraftColumn.value = state.activeColumnId; elements.newDraftDialog.showModal(); elements.newDraftForm.elements.title.focus(); } @@ -669,9 +1612,14 @@ elements.closeResult.addEventListener('click', () => elements.resultDialog.close elements.confirmResult.addEventListener('click', () => elements.resultDialog.close()); elements.closeSkill.addEventListener('click', () => elements.skillDialog.close()); elements.confirmSkill.addEventListener('click', () => elements.skillDialog.close()); +elements.closePublicationPackage.addEventListener('click', () => + elements.publicationPackageDialog.close() +); elements.newDraftForm.addEventListener('submit', async (event) => { event.preventDefault(); + if (state.mode === 'ideas' && !confirmDiscardIdeaForm()) return; + const discardIdeaAfterCreate = state.mode === 'ideas'; const input = Object.fromEntries(new FormData(elements.newDraftForm)); try { const result = await api('/api/drafts', { @@ -680,8 +1628,10 @@ elements.newDraftForm.addEventListener('submit', async (event) => { }); elements.newDraftDialog.close(); elements.newDraftForm.reset(); + state.activeColumnId = input.columnId; await refreshState(); - await loadDraft(result.id, 'cn'); + if (discardIdeaAfterCreate) resetIdeaForm(); + if (await setMode('writing')) await loadDraft(result.id, 'cn'); } catch (error) { showToast(error.message); } @@ -690,5 +1640,9 @@ elements.newDraftForm.addEventListener('submit', async (event) => { window.addEventListener('focus', syncFromDisk); state.syncTimer = setInterval(syncFromDisk, 2800); +document.querySelectorAll('[role="tablist"]').forEach(enableTabKeyboardNavigation); + await refreshState(); -if (state.drafts[0]) await loadDraft(state.drafts[0].id); +const [initialDraft] = columnDrafts(); +if (initialDraft) await loadDraft(initialDraft.id); +else await setMode('writing'); diff --git a/tools/writer-studio/public/index.html b/tools/writer-studio/public/index.html index 4906005..bd2b7f0 100644 --- a/tools/writer-studio/public/index.html +++ b/tools/writer-studio/public/index.html @@ -18,6 +18,42 @@

Writer Studio

+ + +
+ + +
+ + + @@ -32,13 +68,13 @@

Writer Studio

仅限本机 -

草稿保存在 .drafts/blog

+

草稿与 Ideas 保存在 .drafts/

-
+

选择一篇草稿开始写作

Writer Studio

@@ -66,13 +102,22 @@

Writer Studio

+
-
+
单篇文章任务 等待选择任务 @@ -80,25 +125,70 @@

Writer Studio

-
+
- - -
@@ -115,7 +205,13 @@

在浏览器里安静地写作

选择左侧示例草稿,或者创建一篇新文章。Codex 对文件的修改会在这里自动显示。

-
@@ -173,6 +347,10 @@

创建文章草稿

+