Feat: Add tag management system

- Add tag CRUD APIs (single file and batch operations)
  - Add tag autocomplete endpoint
  - Add tag search support in file listing
  - Update database schema with tags column
  - Add tag validation and normalization utilities
  - Initialize Tags:[] for all new uploads
This commit is contained in:
sean908
2025-10-10 14:43:11 +08:00
parent aea62a3387
commit a86e8b522d
8 changed files with 663 additions and 4 deletions

View File

@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS files (
tg_chat_id TEXT, -- Telegram聊天ID (从metadata中提取) tg_chat_id TEXT, -- Telegram聊天ID (从metadata中提取)
tg_bot_token TEXT, -- Telegram Bot Token (从metadata中提取) tg_bot_token TEXT, -- Telegram Bot Token (从metadata中提取)
is_chunked BOOLEAN DEFAULT FALSE, -- 是否为分块文件 is_chunked BOOLEAN DEFAULT FALSE, -- 是否为分块文件
tags TEXT, -- 标签 (从metadata中提取JSON数组格式)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_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_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_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_created_at ON files(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_files_tags ON files(tags);
-- 2. 系统配置表 - 存储各种系统配置 -- 2. 系统配置表 - 存储各种系统配置
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (

View File

@@ -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}`);
}
}

View File

@@ -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' }
});
}
}

View File

@@ -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' }
});
}
}

View File

@@ -217,6 +217,7 @@ async function handleChannelBasedMerge(context, uploadId, totalChunks, originalF
TimeStamp: Date.now(), TimeStamp: Date.now(),
Label: "None", Label: "None",
Directory: normalizedFolder === '' ? '' : normalizedFolder + '/', Directory: normalizedFolder === '' ? '' : normalizedFolder + '/',
Tags: []
}; };
// 更新进度 // 更新进度

View File

@@ -146,6 +146,7 @@ async function processFileUpload(context, formdata = null) {
TimeStamp: time, TimeStamp: time,
Label: "None", Label: "None",
Directory: normalizedFolder === '' ? '' : normalizedFolder + '/', Directory: normalizedFolder === '' ? '' : normalizedFolder + '/',
Tags: []
}; };
let fileExt = fileName.split('.').pop(); // 文件扩展名 let fileExt = fileName.split('.').pop(); // 文件扩展名

View File

@@ -38,6 +38,7 @@
*/ */
import { getDatabase } from './databaseAdapter.js'; import { getDatabase } from './databaseAdapter.js';
import { parseSearchQuery, matchesTags } from './tagHelpers.js';
const INDEX_KEY = 'manage@index'; const INDEX_KEY = 'manage@index';
const INDEX_META_KEY = 'manage@index@meta'; // 索引元数据键 const INDEX_META_KEY = 'manage@index@meta'; // 索引元数据键
@@ -520,12 +521,32 @@ export async function readIndex(context, options = {}) {
); );
} }
// 搜索过滤 // 搜索过滤(支持标签和关键字混合搜索)
if (search) { if (search) {
const searchLower = search.toLowerCase(); // 解析搜索查询,提取标签和关键字
const { keywords, tags } = parseSearchQuery(search);
filteredFiles = filteredFiles.filter(file => { 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;
}); });
} }

View File

@@ -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);
}