diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..10797c3a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,51 @@ +name: Build Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '16.x' + + - name: Install Dependencies + run: npm install --force + + - name: Build + run: npm run dev + + - name: Build and Package + run: npm run release + + - name: Inspect Build Output (Debug) + run: | + echo "Listing root directory:" + ls -F + echo "Listing dist directory (if exists):" + ls -F dist || echo "Dist folder not found" + + - name: Upload Chrome Package + uses: actions/upload-artifact@v4 + with: + name: web-clipper-chrome # 在 GitHub 页面上显示的名字 + path: release/web-clipper-chrome.zip + + - name: Upload Firefox Package + uses: actions/upload-artifact@v4 + with: + name: web-clipper-firefox + path: release/web-clipper-firefox.zip + + - name: Upload Firefox Store Package + uses: actions/upload-artifact@v4 + with: + name: web-clipper-firefox-store + path: release/web-clipper-firefox-store.zip diff --git a/.gitignore b/.gitignore index 245238dd..5f2765f4 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ webclipper.zip .now release +package-lock.json +pnpm-lock.yaml diff --git a/README.md b/README.md index 4c1bb3f6..2666adeb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ You can use Web Clipper to save anything on the web to anywhere. ### Support Site +- [Feishu (Lark)](https://www.feishu.cn/) (Requires [Self-Hosted Worker](docs/feishu-oauth-guide.md)) - [FlowUs](https://flowus.cn/) - [Obsidian](https://obsidian.md/) - [Github](https://github.com) @@ -55,10 +56,10 @@ ps: Because the review takes a week, the version will fall behind. ### Develop ```bash -$ git clone https://github.com/webclipper/web-clipper.git -$ cd web-clipper -$ npm i -$ npm run dev +git clone https://github.com/webclipper/web-clipper.git +cd web-clipper +npm i +npm run dev ``` - You should load the 'dist/chrome' folder in Chrome. @@ -68,7 +69,7 @@ $ npm run dev ### Test ```bash -$ npm run test +npm run test ``` ### Feedback diff --git a/docs/cf_woker.js b/docs/cf_woker.js new file mode 100644 index 00000000..bc446dd7 --- /dev/null +++ b/docs/cf_woker.js @@ -0,0 +1,127 @@ +/** + * Cloudflare Worker for Feishu OAuth Relay + * + * Environment Variables required: + * - APP_ID + * - APP_SECRET + */ + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const pathname = url.pathname; + + // 1. Redirect to Feishu Login + if (pathname === '/login') { + const redirectUri = `${url.origin}/callback`; + // Request permissions for Drive and Docx + const scope = 'drive:drive docx:document'; + const feishuAuthUrl = `https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=${env.APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; + return Response.redirect(feishuAuthUrl, 302); + } + + // 2. Callback: Exchange Code for Token + if (pathname === '/callback') { + const code = url.searchParams.get('code'); + if (!code) return new Response('Missing code', { status: 400 }); + + try { + // Step A: Get App Access Token (Internal) + const appTokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: env.APP_ID, app_secret: env.APP_SECRET }) + }); + const appTokenData = await appTokenRes.json(); + if (appTokenData.code !== 0) throw new Error('Failed to get app token: ' + appTokenData.msg); + const appAccessToken = appTokenData.app_access_token; + + // Step B: Get User Access Token + const userTokenRes = await fetch('https://open.feishu.cn/open-apis/authen/v1/oidc/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${appAccessToken}` + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code: code + }) + }); + + const userTokenData = await userTokenRes.json(); + if (userTokenData.code && userTokenData.code !== 0) throw new Error(userTokenData.msg || 'Auth Failed'); + + // Display Token to User (JSON) + return new Response(JSON.stringify(userTokenData.data, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + + } catch (e) { + return new Response('Error: ' + e.message, { status: 500 }); + } + } + + // 3. Refresh Token + if (pathname === '/refresh') { + if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); + const { refresh_token } = await request.json(); + + if (!refresh_token) return new Response('Missing refresh_token', { status: 400 }); + + try { + // Step A: Get App Access Token + const appTokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: env.APP_ID, app_secret: env.APP_SECRET }) + }); + const appTokenData = await appTokenRes.json(); + const appAccessToken = appTokenData.app_access_token; + + // Step B: Refresh User Token + const refreshRes = await fetch('https://open.feishu.cn/open-apis/authen/v1/oidc/refresh_access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${appAccessToken}` + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refresh_token + }) + }); + + const refreshData = await refreshRes.json(); + + // Add CORS headers so plugin can call this + return new Response(JSON.stringify(refreshData.data), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST' + } + }); + + } catch (e) { + return new Response(JSON.stringify({ error: e.message }), { + status: 500, + headers: { 'Access-Control-Allow-Origin': '*' } + }); + } + } + + // Handle OPTIONS for CORS + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type' + } + }); + } + + return new Response('Feishu OAuth Relay Worker is Running!'); + } +}; diff --git a/docs/feishu-oauth-guide.md b/docs/feishu-oauth-guide.md new file mode 100644 index 00000000..d30c6f92 --- /dev/null +++ b/docs/feishu-oauth-guide.md @@ -0,0 +1,86 @@ +# 飞书剪藏插件后端 (Cloudflare Worker) 部署指南 + +本指南将帮助你部署一个基于 Cloudflare Workers 的轻量级后端服务,用于处理飞书 (Feishu/Lark) 的 OAuth 2.0 授权流程。配合 Web Clipper 插件,你可以将网页内容直接保存到自己的飞书个人空间。 + +## 为什么需要这个 Worker? + +飞书的开放平台要求 OAuth 授权必须通过服务器端交换 Token(出于安全考虑,Client Secret 不能暴露在前端)。 +Cloudflare Workers 提供了一个免费、高性能且无需维护服务器的解决方案,非常适合个人用户托管此类鉴权服务。 + +## 准备工作 + +1. 一个 [Cloudflare](https://www.cloudflare.com/) 账号(免费版即可)。 +2. 一个 [飞书](https://www.feishu.cn/) 账号(需注册一个个人组织,免费)。 + +--- + +## 第一步:创建飞书应用 + +1. 登录 [飞书开放平台](https://open.feishu.cn/app)。 +2. 点击 **"创建企业自建应用"**。 +3. 填写应用名称(如 "Web Clipper")和描述,点击创建。 +4. 在应用详情页,左侧菜单选择 **"凭证与基础信息"**。 + * 记录下 **App ID** 和 **App Secret**,稍后会用到。 +5. 左侧菜单选择 **"开发配置" -> "安全设置"**。 + * 在 **"重定向 URL"** 中,添加你的 Worker URL(格式为 `https://<你的Worker名>.<你的子域名>.workers.dev/callback`)。 + * *注意:如果你还没部署 Worker,可以先跳过这一步,等部署完拿到 URL 后再回来填。* +6. 左侧菜单选择 **"权限管理"**。 + * 切换到 **"应用身份"** 标签页(其实这里主要是为了开通 API 能力,User Token 的权限通常是动态请求的,但建议预先配置)。 + * 搜索并开通以下权限: + * `docx:document` (编辑新版文档) + * `drive:drive:readonly` (查看云空间目录) + * `drive:drive` (查看、评论、编辑和管理云空间所有文件) +7. 左侧菜单选择 **"版本管理与发布"**。 + * 点击 **"创建版本"**。 + * 在 **"可用范围"** 中选择 **"所有员工"**。 + * 点击 **"保存并发布"**。 + +--- + +## 第二步:部署 Cloudflare Worker + +1. 登录 Cloudflare Dashboard,进入 **"Workers & Pages"**。 +2. 点击 **"Create Application"** -> **"Create Worker"**。 +3. 给 Worker 起个名字(例如 `feishu-oauth-relay`),点击 **"Deploy"**。 +4. 部署成功后,点击 **"Edit code"**。 +5. 将cf_worker.js代码完整复制粘贴到编辑器中(覆盖原有代码): +6. 点击右上角的 **"Save and deploy"**。 + +--- + +## 第三步:配置环境变量 + +1. 在 Worker 编辑页面,点击左上角的 Worker 名字返回 Worker 详情页。 +2. 点击 **"Settings"** 标签页。 +3. 点击 **"Variables"**。 +4. 在 **"Environment Variables"** 部分,点击 **"Add variable"**,添加以下两个变量: + * `APP_ID`: (填入你在第一步获取的飞书 App ID) + * `APP_SECRET`: (填入你在第一步获取的飞书 App Secret) +5. 点击 **"Save and deploy"**。 + +--- + +## 第四步:完成飞书配置 + +1. 回到 Worker 详情页,找到你的 Worker URL(例如 `https://feishu-oauth-relay.yourname.workers.dev`)。 +2. 回到 [飞书开放平台](https://open.feishu.cn/app) 的应用配置页面。 +3. 进入 **"安全设置"** -> **"重定向 URL"**。 +4. 点击 **"添加重定向 URL"**,填入 `/callback`。 + * 例如:`https://feishu-oauth-relay.yourname.workers.dev/callback` +5. 点击 **"保存"**。 + +--- + +## 第五步:在 Web Clipper 插件中使用 + +1. 打开 Web Clipper 插件设置页。 +2. 选择 **"账户"** -> **"添加账户"** -> 选择 **"飞书"**。 +3. 在 **"Worker URL"** 中填入你的 Worker 链接(例如 `https://feishu-oauth-relay.yourname.workers.dev`)。 +4. 点击 **"登录飞书"** 按钮。 +5. 在弹出的窗口中完成飞书授权。 +6. 授权成功后,页面会显示一段 JSON 代码(包含 `access_token` 等信息)。 +7. **复制整段 JSON 代码**。 +8. 回到插件设置页,将 JSON 粘贴到 **"Token JSON"** 输入框中。 +9. 点击保存,完成配置! + +现在,插件将自动使用这个 Token 访问你的飞书空间,并在 Token 过期时自动通过 Worker 进行刷新。 diff --git a/package.json b/package.json index 4b891891..10d573f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-clipper", - "version": "1.42.0", + "version": "1.42.1", "description": "Universal open source web clipper.", "bin": { "web-clipper": "bin/index.js" diff --git a/src/common/backend/services/feishu/form.tsx b/src/common/backend/services/feishu/form.tsx new file mode 100644 index 00000000..0f5fc674 --- /dev/null +++ b/src/common/backend/services/feishu/form.tsx @@ -0,0 +1,109 @@ +import { Form } from '@ant-design/compatible'; +import '@ant-design/compatible/assets/index.less'; +import { Input, Button, Alert } from 'antd'; +import { FormComponentProps } from '@ant-design/compatible/lib/form'; +import React, { Component, Fragment } from 'react'; +import { FeishuBackendServiceConfig } from './interface'; +import { FormattedMessage } from 'react-intl'; + +interface FeishuFormProps { + verified?: boolean; + info?: FeishuBackendServiceConfig; +} + +export default class FeishuForm extends Component { + handleLogin = () => { + const workerUrl = this.props.form.getFieldValue('workerUrl'); + if (workerUrl) { + window.open(`${workerUrl.replace(/\/$/, '')}/login`, '_blank'); + } + }; + + render() { + const { + form: { getFieldDecorator, getFieldValue }, + info, + verified, + } = this.props; + + let initData: Partial = {}; + if (info) { + initData = info; + } + let editMode = info ? true : false; + const workerUrl = getFieldValue('workerUrl') || initData.workerUrl; + + return ( + + + + } + type="info" + /> + + + {getFieldDecorator('workerUrl', { + initialValue: initData.workerUrl, + rules: [ + { + required: true, + message: 'Worker URL is required!', + }, + { + type: 'url', + message: 'Invalid URL format', + } + ], + })()} + + + + + + { + try { + const data = JSON.parse(e.target.value); + if (data.access_token && data.refresh_token) { + const expiresAt = Math.floor(Date.now() / 1000) + (Number(data.expires_in) || 7200); + + // Set fields silently + this.props.form.setFieldsValue({ + 'accessToken': data.access_token, + 'refreshToken': data.refresh_token, + 'expiresAt': expiresAt + }); + } + } catch (err) { + // Ignore parse error, maybe user is typing manual token + } + }} + /> + + {/* Hidden fields to store parsed values */} + + {getFieldDecorator('accessToken', { + initialValue: initData.accessToken, + rules: [{ required: true, message: 'Access Token is required' }] + })()} + + + {getFieldDecorator('refreshToken', { initialValue: initData.refreshToken })()} + + + {getFieldDecorator('expiresAt', { initialValue: initData.expiresAt })()} + + + ); + } +} + diff --git a/src/common/backend/services/feishu/index.ts b/src/common/backend/services/feishu/index.ts new file mode 100644 index 00000000..661432a8 --- /dev/null +++ b/src/common/backend/services/feishu/index.ts @@ -0,0 +1,21 @@ +import Service from './service'; +import Form from './form'; +import localeService from '@/common/locales'; + +export default () => { + return { + name: localeService.format({ + id: 'backend.services.feishu.name', + defaultMessage: 'Feishu (OAuth)', + }), + icon: 'feishu', + type: 'feishu', + service: Service, + form: Form, + homePage: 'https://www.feishu.cn/drive/home/', + permission: { + origins: ['https://open.feishu.cn/*', 'https://*.workers.dev/*'], + }, + }; +}; + diff --git a/src/common/backend/services/feishu/interface.ts b/src/common/backend/services/feishu/interface.ts new file mode 100644 index 00000000..e2372b54 --- /dev/null +++ b/src/common/backend/services/feishu/interface.ts @@ -0,0 +1,36 @@ +export interface FeishuBackendServiceConfig { + workerUrl: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; // timestamp in seconds +} + +export interface FeishuUserInfoResponse { + code: number; + msg: string; + data: { + avatar_url: string; + name: string; + open_id: string; + union_id: string; + }; +} + +export interface FeishuCreateDocumentRequest { + title: string; + content: string; + repositoryId: string; +} + +export interface FeishuCompleteStatus { + href: string; + repositoryId: string; + documentId: string; +} + +export interface FeishuTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; +} + diff --git a/src/common/backend/services/feishu/service.ts b/src/common/backend/services/feishu/service.ts new file mode 100644 index 00000000..5dc54ddf --- /dev/null +++ b/src/common/backend/services/feishu/service.ts @@ -0,0 +1,324 @@ +import { DocumentService } from './../../index'; +import { + FeishuBackendServiceConfig, + FeishuUserInfoResponse, + FeishuCompleteStatus, + FeishuCreateDocumentRequest, +} from './interface'; +import md5 from '@web-clipper/shared/lib/md5'; + +const OPEN_API = 'https://open.feishu.cn'; + +export default class FeishuDocumentService implements DocumentService { + private userInfo?: any; + private config: FeishuBackendServiceConfig; + + constructor(config: FeishuBackendServiceConfig) { + this.config = config; + } + + getId = () => md5(this.config.workerUrl); + + refreshToken = async (info: FeishuBackendServiceConfig): Promise => { + console.log('Feishu Refresh Token Called', { + hasRefreshToken: !!info.refreshToken, + workerUrl: info.workerUrl + }); + if (!info.refreshToken || !info.workerUrl) { + throw new Error('Missing refresh token or worker url'); + } + const workerUrl = info.workerUrl.trim(); + const workerRefreshUrl = `${workerUrl.replace(/\/$/, '')}/refresh`; + console.log('Feishu Refresh URL:', workerRefreshUrl); + + try { + const response = await fetch(workerRefreshUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: info.refreshToken }) + }); + + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + + return { + ...info, + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Math.floor(Date.now() / 1000) + (Number(data.expires_in) || 7200) + }; + } catch (e: any) { + console.error('RefreshToken Error:', e); + throw new Error(`Failed to refresh token: ${e.message}`); + } + } + + private getAccessToken = async () => { + // Check expiration + const expiresAt = Number(this.config.expiresAt); + const now = Date.now() / 1000; + const shouldRefresh = expiresAt && now > expiresAt - 300; + + console.log('Feishu GetAccessToken:', { + expiresAt, + now, + shouldRefresh, + accessToken: this.config.accessToken ? 'exists' : 'missing' + }); + + if (shouldRefresh) { + // Token expired or about to expire (5 mins buffer) + try { + const newConfig = await this.refreshToken(this.config); + this.config = newConfig; + // Note: In a real app, we should persist this new config back to storage. + // But DocumentService interface doesn't easily support saving back config unless called by upper layer. + // However, typical usage is: upper layer checks if `refreshToken` method exists, call it, and save result. + // Current Clipper architecture might not auto-save refreshed token easily. + // We'll rely on in-memory update for this session. + // If the architecture supports `refreshToken` hook (interface line 81), it will work. + } catch (e) { + console.error('Refresh token failed', e); + throw new Error('Session expired, please login again via Worker.'); + } + } + return this.config.accessToken; + }; + + private requestWithToken = async (path: string, method: 'GET' | 'POST' | 'PATCH', data?: any) => { + const token = await this.getAccessToken(); + if (!token) { + throw new Error('Access token is missing. Please re-login.'); + } + const url = `${OPEN_API}${path}`; + // Aggressively clean token: remove all whitespace including internal newlines + const cleanToken = token.replace(/\s+/g, ''); + + console.log('Feishu Request:', { + url, + method, + tokenLength: cleanToken.length, + data: data ? JSON.stringify(data) : undefined + }); + + try { + const headers = new Headers({ + 'Authorization': `Bearer ${cleanToken}`, + }); + + if (method === 'POST' || method === 'PATCH') { + headers.append('Content-Type', 'application/json'); + } + + const options: RequestInit = { + method, + headers, + }; + + if ((method === 'POST' || method === 'PATCH') && data) { + options.body = JSON.stringify(data); + } + + const response = await fetch(url, options); + const json = await response.json(); + + if (json.code !== 0) { + console.error('Feishu API Error:', json); + throw new Error(json.msg || `Feishu API Error code: ${json.code}`); + } + + return json.data as T; + } catch (e: any) { + console.error('Feishu Request Failed:', e); + // Explicitly catch Headers error + if (e.message && e.message.includes('Headers')) { + throw new Error(`Invalid Token Format: Contains illegal characters. Length: ${cleanToken.length}`); + } + throw e; + } + }; + + getUserInfo = async () => { + if (!this.userInfo) { + // Get User Info + // Endpoint: GET https://open.feishu.cn/open-apis/authen/v1/user_info + this.userInfo = await this.requestWithToken('/open-apis/authen/v1/user_info', 'GET'); + } + const { avatar_url, name, en_name } = this.userInfo; + return { + avatar: avatar_url, + name: name || en_name, + homePage: 'https://www.feishu.cn/drive/home/', + description: 'Feishu User', + }; + }; + + getRepositories = async () => { + // List "My Space" root folder children? + // User Access Token allows access to user's files. + // Let's just return a "Root" repository which represents "My Space". + // Or we can list folders in root. + + // For User Identity, we can use Explorer API + try { + const rootMeta = await this.requestWithToken('/open-apis/drive/explorer/v2/root_folder/meta', 'GET'); + return [{ + id: rootMeta.token, + name: '我的空间 (My Space)', + groupId: 'me', + groupName: 'Personal', + }]; + } catch (e) { + return [{ + id: 'root', + name: '我的空间 (My Space)', + groupId: 'me', + groupName: 'Personal', + }]; + } + }; + + private uploadImage = async (url: string, parentNode: string): Promise => { + try { + console.log('Downloading image:', url); + const imgRes = await fetch(url); + if (!imgRes.ok) { + console.error('Image download failed:', imgRes.status); + return null; + } + const blob = await imgRes.blob(); + + const formData = new FormData(); + formData.append('file_name', 'image.png'); + formData.append('parent_type', 'docx_image'); + formData.append('parent_node', parentNode); + formData.append('size', String(blob.size)); + formData.append('file', blob); + + const token = await this.getAccessToken(); + const uploadRes = await fetch(`${OPEN_API}/open-apis/drive/v1/medias/upload_all`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + const json = await uploadRes.json(); + if (json.code !== 0) { + console.error('Feishu Image Upload Error:', json); + return null; + } + console.log('Image uploaded, token:', json.data.file_token); + return json.data.file_token; + } catch (e) { + console.error('Image upload exception:', e); + return null; + } + } + + private batchUpdateBlocks = async (documentId: string, updates: { blockId: string, token: string }[]) => { + if (updates.length === 0) return; + try { + const requests = updates.map(u => ({ + block_id: u.blockId, + replace_image: { token: u.token } + })); + await this.requestWithToken(`/open-apis/docx/v1/documents/${documentId}/blocks/batch_update`, 'PATCH', { requests }); + console.log(`Batch updated ${updates.length} images.`); + } catch (e) { + console.error('Failed to batch update blocks:', e); + } + } + + createDocument = async (info: FeishuCreateDocumentRequest): Promise => { + const { title, content, repositoryId } = info; + + // 1. Create Document + const createRes = await this.requestWithToken('/open-apis/docx/v1/documents', 'POST', { + folder_token: repositoryId, + title: title, + }); + + const documentId = createRes.document.document_id; + + // 2. Parse Content into Segments + const parts = content.split(/(!\[.*?\]\(.*?\))/g); + const segments: { type: 'text' | 'image', data: string }[] = []; + + parts.forEach(part => { + const imageMatch = part.match(/!\[.*?\]\((.*?)\)/); + if (imageMatch) { + segments.push({ type: 'image', data: imageMatch[1] }); + } else if (part) { + // Split text by newlines to avoid "invalid param" for huge text blocks + const lines = part.split(/\r?\n/); + lines.forEach(line => { + segments.push({ type: 'text', data: line }); + }); + } + }); + + // 3. Create Blocks in Chunks + if (segments.length > 0) { + const chunkSize = 50; + for (let i = 0; i < segments.length; i += chunkSize) { + const chunk = segments.slice(i, i + chunkSize); + + // Create blocks with empty image tokens + const childrenPayload = chunk.map(seg => { + if (seg.type === 'image') { + return { + block_type: 27, + image: { token: "" } // Placeholder + }; + } else { + return { + block_type: 2, + text: { elements: [{ text_run: { content: seg.data } }] } + }; + } + }); + + const createChildrenRes = await this.requestWithToken( + `/open-apis/docx/v1/documents/${documentId}/blocks/${documentId}/children`, + 'POST', + { children: childrenPayload, index: -1 } + ); + + // 4. Process Images: Upload and Collect Updates + const createdChildren = createChildrenRes.children; + const uploadPromises: Promise<{ blockId: string, token: string | null }>[] = []; + + for (let j = 0; j < chunk.length; j++) { + if (chunk[j].type === 'image' && createdChildren[j]) { + const blockId = createdChildren[j].block_id; + const imageUrl = chunk[j].data; + + // Initiate upload + uploadPromises.push( + this.uploadImage(imageUrl, blockId) + .then(token => ({ blockId, token })) + ); + } + } + + // Wait for all uploads in this chunk + if (uploadPromises.length > 0) { + const results = await Promise.all(uploadPromises); + const updates = results + .filter(r => r.token !== null) + .map(r => ({ blockId: r.blockId, token: r.token as string })); + + // Batch update images + await this.batchUpdateBlocks(documentId, updates); + } + } + } + + return { + href: `https://feishu.cn/docx/${documentId}`, + repositoryId, + documentId, + }; + }; +} diff --git a/src/common/locales/data/en-US.json b/src/common/locales/data/en-US.json index 37e661da..12d0b076 100644 --- a/src/common/locales/data/en-US.json +++ b/src/common/locales/data/en-US.json @@ -36,6 +36,11 @@ "backend.services.dida365.name": "Dida365", "backend.services.dida365.rootGroup": "", "backend.services.dida365.unauthorizedErrorMessage": "", + "backend.services.feishu.loginAlert": "Please login to Feishu Web first. This plugin uses your browser cookie to access Feishu.", + "backend.services.feishu.form.tip": "Please create a custom app in Feishu Open Platform, enable docx and drive permissions, and share the target folder with the bot.", + "backend.services.feishu.form.tip_worker": "Please deploy the Cloudflare Worker first. Enter the Worker URL, click Login, and copy the returned JSON.", + "backend.services.feishu.form.login": "Login to Feishu", + "backend.services.feishu.name": "Feishu (OAuth)", "backend.services.flomo.login": "Unauthorized! Please Login Flomo Web.", "backend.services.github.form.GenerateNewToken": "", "backend.services.github.form.storageLocation": "", diff --git a/src/common/locales/data/zh-CN.json b/src/common/locales/data/zh-CN.json index 3b6ade14..1e20c2ed 100644 --- a/src/common/locales/data/zh-CN.json +++ b/src/common/locales/data/zh-CN.json @@ -25,18 +25,16 @@ "backend.imageHosting.wiznote.name": "为知笔记", "backend.imageHosting.wiznote.builtInRemark": "为知笔记内置图床", "backend.not.unavailable": "暂时无法剪辑此类型的页面。\n\n刷新页面可以解决。", - "backend.services.memos.name": "Memos", - "backend.services.memos.form.hostTest": "检验", + "backend.services.memos.form.hostTest": "检验", "backend.services.memos.accessToken.message": "请输入 AccessToken", "backend.services.memos.form.authentication": "请输入服务器地址", - "backend.services.memos.headerForm.tag": "请输入标签名称,多个标签用英文逗号分隔,如 tag1,tag2...", - "backend.services.memos.headerForm.visibility": "文档类型", - "backend.services.memos.headerForm.VisibilityType.private": "私人", - "backend.services.memos.headerForm.VisibilityType.public": "公开", - "backend.services.memos.headerForm.tag_error": "标签格式错误,请检查", - - "backend.services.baklib.form.hostTest": "测试", + "backend.services.memos.headerForm.tag": "请输入标签名称,多个标签用英文逗号分隔,如 tag1,tag2...", + "backend.services.memos.headerForm.visibility": "文档类型", + "backend.services.memos.headerForm.VisibilityType.private": "私人", + "backend.services.memos.headerForm.VisibilityType.public": "公开", + "backend.services.memos.headerForm.tag_error": "标签格式错误,请检查", + "backend.services.baklib.form.hostTest": "测试", "backend.services.baklib.form.authentication": "授权", "backend.services.baklib.headerForm.channel": "栏目", "backend.services.baklib.headerForm.description": "描述", @@ -50,6 +48,11 @@ "backend.services.dida365.name": "滴答清单", "backend.services.dida365.rootGroup": "根目录", "backend.services.dida365.unauthorizedErrorMessage": "授权失败,请登录网页版滴答清单。", + "backend.services.feishu.loginAlert": "授权失败,请登录网页版飞书。", + "backend.services.feishu.form.tip": "请先到飞书开放平台创建“企业自建应用”,开启 docx 和 drive 权限,并将目标文件夹分享给该应用(机器人)。", + "backend.services.feishu.form.tip_worker": "请先部署 Cloudflare Worker。输入 Worker URL,点击登录,然后复制返回的 JSON。", + "backend.services.feishu.form.login": "登录飞书", + "backend.services.feishu.name": "飞书 (OAuth)", "backend.services.flomo.login": "授权失败,请登录网页版浮墨笔记。", "backend.services.github.form.GenerateNewToken": "生成新 Token", "backend.services.github.form.storageLocation": "保存位置", diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 2e8ccf58..d42aa8f9 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -12,7 +12,10 @@ function resolve(dir) { return path.join(__dirname, '..', dir); } -const distFiles = fs.readdirSync(resolve('dist')).filter((o) => o !== '.gitkeep'); +const distPath = resolve('dist'); +const distFiles = fs.existsSync(distPath) + ? fs.readdirSync(distPath).filter((o) => o !== '.gitkeep') + : []; module.exports = { entry: {