diff --git a/database/schema.sql b/database/schema.sql index 595845d..e785cf4 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS files ( tg_chat_id TEXT, -- Telegram聊天ID (从metadata中提取) tg_bot_token TEXT, -- Telegram Bot Token (从metadata中提取) is_chunked BOOLEAN DEFAULT FALSE, -- 是否为分块文件 + tags TEXT, -- 标签 (从metadata中提取,JSON数组格式) created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -32,6 +33,7 @@ CREATE INDEX IF NOT EXISTS idx_files_channel ON files(channel); CREATE INDEX IF NOT EXISTS idx_files_file_type ON files(file_type); CREATE INDEX IF NOT EXISTS idx_files_upload_ip ON files(upload_ip); CREATE INDEX IF NOT EXISTS idx_files_created_at ON files(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_files_tags ON files(tags); -- 2. 系统配置表 - 存储各种系统配置 CREATE TABLE IF NOT EXISTS settings ( diff --git a/functions/api/manage/tags/[[path]].js b/functions/api/manage/tags/[[path]].js new file mode 100644 index 0000000..008de0c --- /dev/null +++ b/functions/api/manage/tags/[[path]].js @@ -0,0 +1,192 @@ +import { purgeCFCache } from "../../../utils/purgeCache.js"; +import { addFileToIndex } from "../../../utils/indexManager.js"; +import { getDatabase } from "../../../utils/databaseAdapter.js"; +import { mergeTags, normalizeTags, validateTag } from "../../../utils/tagHelpers.js"; + +/** + * Tag Management API for Single Files + * + * GET /api/manage/tags/{fileId} - Get tags for a file + * POST /api/manage/tags/{fileId} - Update tags for a file + * + * POST body format: + * { + * action: "set" | "add" | "remove", + * tags: ["tag1", "tag2", ...] + * } + */ +export async function onRequest(context) { + const { + request, + env, + params, + waitUntil, + } = context; + + const url = new URL(request.url); + + // Parse file path + if (params.path) { + params.path = String(params.path).split(',').join('/'); + } + + // Decode file path + const fileId = decodeURIComponent(params.path); + + const db = getDatabase(env); + + try { + if (request.method === 'GET') { + // Get tags for file + return await handleGetTags(db, fileId); + } else if (request.method === 'POST') { + // Update tags for file + return await handleUpdateTags(context, db, fileId, url.hostname); + } else { + return new Response(JSON.stringify({ + error: 'Method not allowed', + allowedMethods: ['GET', 'POST'] + }), { + status: 405, + headers: { 'Content-Type': 'application/json' } + }); + } + } catch (error) { + console.error(`Error in tag management for ${fileId}:`, error); + return new Response(JSON.stringify({ + error: 'Internal server error', + message: error.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +} + +/** + * Handle GET request - Get tags for a file + */ +async function handleGetTags(db, fileId) { + try { + const fileData = await db.getWithMetadata(fileId); + + if (!fileData || !fileData.metadata) { + return new Response(JSON.stringify({ + error: 'File not found', + fileId: fileId + }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + + const tags = fileData.metadata.Tags || []; + + return new Response(JSON.stringify({ + success: true, + fileId: fileId, + tags: tags + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + throw new Error(`Failed to get tags: ${error.message}`); + } +} + +/** + * Handle POST request - Update tags for a file + */ +async function handleUpdateTags(context, db, fileId, hostname) { + const { request, waitUntil } = context; + + try { + // Parse request body + const body = await request.json(); + const { action = 'set', tags = [] } = body; + + // Validate action + if (!['set', 'add', 'remove'].includes(action)) { + return new Response(JSON.stringify({ + error: 'Invalid action', + message: 'Action must be one of: set, add, remove' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Validate tags array + if (!Array.isArray(tags)) { + return new Response(JSON.stringify({ + error: 'Invalid tags format', + message: 'Tags must be an array of strings' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Validate each tag + const invalidTags = tags.filter(tag => !validateTag(tag)); + if (invalidTags.length > 0) { + return new Response(JSON.stringify({ + error: 'Invalid tag format', + message: 'Tags must contain only alphanumeric characters, underscores, hyphens, and CJK characters', + invalidTags: invalidTags + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get file metadata + const fileData = await db.getWithMetadata(fileId); + + if (!fileData || !fileData.metadata) { + return new Response(JSON.stringify({ + error: 'File not found', + fileId: fileId + }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get existing tags + const existingTags = fileData.metadata.Tags || []; + + // Merge tags based on action + const updatedTags = mergeTags(existingTags, tags, action); + + // Update metadata + fileData.metadata.Tags = updatedTags; + + // Save to database + await db.put(fileId, fileData.value, { + metadata: fileData.metadata + }); + + // Clear CDN cache + const cdnUrl = `https://${hostname}/file/${fileId}`; + await purgeCFCache(context.env, cdnUrl); + + // Update file index asynchronously + waitUntil(addFileToIndex(context, fileId, fileData.metadata)); + + return new Response(JSON.stringify({ + success: true, + fileId: fileId, + action: action, + tags: updatedTags, + metadata: fileData.metadata + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error) { + throw new Error(`Failed to update tags: ${error.message}`); + } +} diff --git a/functions/api/manage/tags/autocomplete.js b/functions/api/manage/tags/autocomplete.js new file mode 100644 index 0000000..d36e9ac --- /dev/null +++ b/functions/api/manage/tags/autocomplete.js @@ -0,0 +1,109 @@ +import { getDatabase } from "../../../utils/databaseAdapter.js"; +import { extractUniqueTags, filterTagsByPrefix } from "../../../utils/tagHelpers.js"; + +/** + * Tag Autocomplete API + * + * GET /api/manage/tags/autocomplete?prefix=ph - Get tag suggestions + * + * Returns all tags matching the given prefix, useful for autocomplete functionality + */ +export async function onRequest(context) { + const { request, env } = context; + + const url = new URL(request.url); + + if (request.method !== 'GET') { + return new Response(JSON.stringify({ + error: 'Method not allowed', + allowedMethods: ['GET'] + }), { + status: 405, + headers: { 'Content-Type': 'application/json' } + }); + } + + const db = getDatabase(env); + + try { + // Get prefix from query parameters + const prefix = url.searchParams.get('prefix') || ''; + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + + // Validate limit + if (limit < 1 || limit > 100) { + return new Response(JSON.stringify({ + error: 'Invalid limit', + message: 'Limit must be between 1 and 100' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get all files from database + const allTags = new Set(); + let cursor = null; + + while (true) { + const response = await db.list({ + limit: 1000, + cursor: cursor + }); + + for (const item of response.keys) { + // Skip non-file entries + if (item.name.startsWith('manage@') || item.name.startsWith('chunk_')) { + continue; + } + + // Extract tags from metadata + if (item.metadata && Array.isArray(item.metadata.Tags)) { + item.metadata.Tags.forEach(tag => { + if (tag && typeof tag === 'string') { + allTags.add(tag.toLowerCase().trim()); + } + }); + } + } + + cursor = response.cursor; + if (!cursor) break; + + // Limit iterations for performance + if (allTags.size > 10000) break; + } + + // Convert to array and sort + const tagsArray = Array.from(allTags).sort(); + + // Filter by prefix + const filteredTags = prefix + ? filterTagsByPrefix(tagsArray, prefix, limit) + : tagsArray.slice(0, limit); + + return new Response(JSON.stringify({ + success: true, + prefix: prefix, + tags: filteredTags, + total: filteredTags.length, + hasMore: tagsArray.length > filteredTags.length + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=60' // Cache for 1 minute + } + }); + + } catch (error) { + console.error('Error in tag autocomplete:', error); + return new Response(JSON.stringify({ + error: 'Internal server error', + message: error.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +} diff --git a/functions/api/manage/tags/batch.js b/functions/api/manage/tags/batch.js new file mode 100644 index 0000000..4648239 --- /dev/null +++ b/functions/api/manage/tags/batch.js @@ -0,0 +1,172 @@ +import { purgeCFCache } from "../../../utils/purgeCache.js"; +import { batchAddFilesToIndex } from "../../../utils/indexManager.js"; +import { getDatabase } from "../../../utils/databaseAdapter.js"; +import { mergeTags, validateTag } from "../../../utils/tagHelpers.js"; + +/** + * Batch Tag Management API + * + * POST /api/manage/tags/batch - Update tags for multiple files + * + * Request body format: + * { + * fileIds: ["file1", "file2", ...], + * action: "set" | "add" | "remove", + * tags: ["tag1", "tag2", ...] + * } + */ +export async function onRequest(context) { + const { + request, + env, + waitUntil, + } = context; + + const url = new URL(request.url); + + if (request.method !== 'POST') { + return new Response(JSON.stringify({ + error: 'Method not allowed', + allowedMethods: ['POST'] + }), { + status: 405, + headers: { 'Content-Type': 'application/json' } + }); + } + + const db = getDatabase(env); + + try { + // Parse request body + const body = await request.json(); + const { fileIds = [], action = 'set', tags = [] } = body; + + // Validate fileIds + if (!Array.isArray(fileIds) || fileIds.length === 0) { + return new Response(JSON.stringify({ + error: 'Invalid fileIds', + message: 'fileIds must be a non-empty array of file identifiers' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Validate action + if (!['set', 'add', 'remove'].includes(action)) { + return new Response(JSON.stringify({ + error: 'Invalid action', + message: 'Action must be one of: set, add, remove' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Validate tags array + if (!Array.isArray(tags)) { + return new Response(JSON.stringify({ + error: 'Invalid tags format', + message: 'Tags must be an array of strings' + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Validate each tag + const invalidTags = tags.filter(tag => !validateTag(tag)); + if (invalidTags.length > 0) { + return new Response(JSON.stringify({ + error: 'Invalid tag format', + message: 'Tags must contain only alphanumeric characters, underscores, hyphens, and CJK characters', + invalidTags: invalidTags + }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Process files in batch + const results = { + success: true, + total: fileIds.length, + updated: 0, + errors: [] + }; + + const updatedFiles = []; + + for (const fileId of fileIds) { + try { + // Get file metadata + const fileData = await db.getWithMetadata(fileId); + + if (!fileData || !fileData.metadata) { + results.errors.push({ + fileId: fileId, + error: 'File not found' + }); + continue; + } + + // Get existing tags + const existingTags = fileData.metadata.Tags || []; + + // Merge tags based on action + const updatedTags = mergeTags(existingTags, tags, action); + + // Update metadata + fileData.metadata.Tags = updatedTags; + + // Save to database + await db.put(fileId, fileData.value, { + metadata: fileData.metadata + }); + + // Clear CDN cache (async) + const cdnUrl = `https://${url.hostname}/file/${fileId}`; + waitUntil(purgeCFCache(env, cdnUrl)); + + // Track updated file for batch index update + updatedFiles.push({ + fileId: fileId, + metadata: fileData.metadata + }); + + results.updated++; + + } catch (error) { + results.errors.push({ + fileId: fileId, + error: error.message + }); + } + } + + // Batch update file index asynchronously + if (updatedFiles.length > 0) { + waitUntil(batchAddFilesToIndex(context, updatedFiles, { skipExisting: false })); + } + + // Set success to false if there were any errors + if (results.errors.length > 0) { + results.success = false; + } + + return new Response(JSON.stringify(results), { + status: results.success ? 200 : 207, // 207 = Multi-Status (partial success) + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error) { + console.error('Error in batch tag update:', error); + return new Response(JSON.stringify({ + error: 'Internal server error', + message: error.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +} diff --git a/functions/upload/chunkMerge.js b/functions/upload/chunkMerge.js index 8e7f476..c0ae7c4 100644 --- a/functions/upload/chunkMerge.js +++ b/functions/upload/chunkMerge.js @@ -217,6 +217,7 @@ async function handleChannelBasedMerge(context, uploadId, totalChunks, originalF TimeStamp: Date.now(), Label: "None", Directory: normalizedFolder === '' ? '' : normalizedFolder + '/', + Tags: [] }; // 更新进度 diff --git a/functions/upload/index.js b/functions/upload/index.js index 84c404d..413033d 100644 --- a/functions/upload/index.js +++ b/functions/upload/index.js @@ -146,6 +146,7 @@ async function processFileUpload(context, formdata = null) { TimeStamp: time, Label: "None", Directory: normalizedFolder === '' ? '' : normalizedFolder + '/', + Tags: [] }; let fileExt = fileName.split('.').pop(); // 文件扩展名 diff --git a/functions/utils/indexManager.js b/functions/utils/indexManager.js index ed6c329..a67d249 100644 --- a/functions/utils/indexManager.js +++ b/functions/utils/indexManager.js @@ -38,6 +38,7 @@ */ import { getDatabase } from './databaseAdapter.js'; +import { parseSearchQuery, matchesTags } from './tagHelpers.js'; const INDEX_KEY = 'manage@index'; const INDEX_META_KEY = 'manage@index@meta'; // 索引元数据键 @@ -520,12 +521,32 @@ export async function readIndex(context, options = {}) { ); } - // 搜索过滤 + // 搜索过滤(支持标签和关键字混合搜索) if (search) { - const searchLower = search.toLowerCase(); + // 解析搜索查询,提取标签和关键字 + const { keywords, tags } = parseSearchQuery(search); + filteredFiles = filteredFiles.filter(file => { - return file.metadata.FileName?.toLowerCase().includes(searchLower) || - file.id.toLowerCase().includes(searchLower); + // 标签过滤(必须包含所有指定的标签) + if (tags.length > 0) { + const fileTags = file.metadata.Tags || []; + if (!matchesTags(fileTags, tags)) { + return false; + } + } + + // 关键字过滤(匹配文件名或文件ID) + if (keywords) { + const keywordsLower = keywords.toLowerCase(); + const matchesKeyword = + file.metadata.FileName?.toLowerCase().includes(keywordsLower) || + file.id.toLowerCase().includes(keywordsLower); + if (!matchesKeyword) { + return false; + } + } + + return true; }); } diff --git a/functions/utils/tagHelpers.js b/functions/utils/tagHelpers.js new file mode 100644 index 0000000..13ec1a7 --- /dev/null +++ b/functions/utils/tagHelpers.js @@ -0,0 +1,161 @@ +/** + * Tag Management Helper Functions + * Provides utilities for validating, normalizing, and managing tags + */ + +/** + * Validate tag format + * Tags must contain only alphanumeric characters, underscores, and hyphens + * @param {string} tag - The tag to validate + * @returns {boolean} - Whether the tag is valid + */ +export function validateTag(tag) { + if (!tag || typeof tag !== 'string') { + return false; + } + + // Allow alphanumeric, underscore, hyphen, and Chinese/Japanese/Korean characters + return /^[\w\u4e00-\u9fa5\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af-]+$/.test(tag); +} + +/** + * Normalize tags + * - Convert to lowercase + * - Trim whitespace + * - Remove duplicates + * - Filter out invalid tags + * @param {string[]} tags - Array of tags to normalize + * @returns {string[]} - Normalized array of unique tags + */ +export function normalizeTags(tags) { + if (!Array.isArray(tags)) { + return []; + } + + const normalized = tags + .filter(tag => tag && typeof tag === 'string') + .map(tag => tag.toLowerCase().trim()) + .filter(tag => validateTag(tag)); + + // Remove duplicates while preserving order + return [...new Set(normalized)]; +} + +/** + * Merge tags based on action + * @param {string[]} existingTags - Current tags on the file + * @param {string[]} newTags - Tags to add/remove/set + * @param {string} action - 'set', 'add', or 'remove' + * @returns {string[]} - Merged tags array + */ +export function mergeTags(existingTags, newTags, action) { + const existing = Array.isArray(existingTags) ? existingTags : []; + const normalized = normalizeTags(newTags); + + switch (action) { + case 'set': + // Replace all tags with new tags + return normalized; + + case 'add': + // Add new tags to existing, remove duplicates + return normalizeTags([...existing, ...normalized]); + + case 'remove': + // Remove specified tags from existing + const toRemove = new Set(normalized); + return existing.filter(tag => !toRemove.has(tag.toLowerCase())); + + default: + throw new Error(`Invalid action: ${action}. Must be 'set', 'add', or 'remove'`); + } +} + +/** + * Parse search query to extract tags and keywords + * Input: "vacation #photo #2024" + * Output: { keywords: "vacation", tags: ["photo", "2024"] } + * @param {string} searchString - The search query string + * @returns {Object} - Object with keywords and tags arrays + */ +export function parseSearchQuery(searchString) { + if (!searchString || typeof searchString !== 'string') { + return { keywords: '', tags: [] }; + } + + const tagRegex = /#([\w\u4e00-\u9fa5\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af-]+)/g; + const tags = []; + let match; + + while ((match = tagRegex.exec(searchString)) !== null) { + tags.push(match[1].toLowerCase()); + } + + // Remove tags from search string to get keywords + const keywords = searchString.replace(/#[\w\u4e00-\u9fa5\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af-]+/g, '').trim(); + + return { keywords, tags: normalizeTags(tags) }; +} + +/** + * Check if a file matches tag filter + * @param {string[]} fileTags - Tags on the file + * @param {string[]} requiredTags - Tags that must be present + * @returns {boolean} - Whether file has all required tags + */ +export function matchesTags(fileTags, requiredTags) { + if (!Array.isArray(requiredTags) || requiredTags.length === 0) { + return true; // No tag filter + } + + if (!Array.isArray(fileTags) || fileTags.length === 0) { + return false; // File has no tags but filter requires tags + } + + const fileTagsLower = fileTags.map(t => t.toLowerCase()); + return requiredTags.every(tag => fileTagsLower.includes(tag.toLowerCase())); +} + +/** + * Extract all unique tags from an array of files + * @param {Array} files - Array of file objects with metadata.Tags + * @returns {string[]} - Sorted array of unique tags + */ +export function extractUniqueTags(files) { + if (!Array.isArray(files)) { + return []; + } + + const allTags = new Set(); + + files.forEach(file => { + if (file && file.metadata && Array.isArray(file.metadata.Tags)) { + file.metadata.Tags.forEach(tag => { + if (tag && typeof tag === 'string') { + allTags.add(tag.toLowerCase().trim()); + } + }); + } + }); + + return Array.from(allTags).sort(); +} + +/** + * Filter tags by prefix (for autocomplete) + * @param {string[]} tags - Array of all available tags + * @param {string} prefix - Prefix to filter by + * @param {number} limit - Maximum number of results + * @returns {string[]} - Filtered tags + */ +export function filterTagsByPrefix(tags, prefix, limit = 20) { + if (!Array.isArray(tags) || !prefix || typeof prefix !== 'string') { + return []; + } + + const prefixLower = prefix.toLowerCase().trim(); + + return tags + .filter(tag => tag.toLowerCase().startsWith(prefixLower)) + .slice(0, limit); +}