mirror of
https://github.com/MarSeventh/CloudFlare-ImgBed.git
synced 2026-01-31 09:03:19 +08:00
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:
@@ -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 (
|
||||||
|
|||||||
192
functions/api/manage/tags/[[path]].js
Normal file
192
functions/api/manage/tags/[[path]].js
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
functions/api/manage/tags/autocomplete.js
Normal file
109
functions/api/manage/tags/autocomplete.js
Normal 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
172
functions/api/manage/tags/batch.js
Normal file
172
functions/api/manage/tags/batch.js
Normal 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新进度
|
// 更新进度
|
||||||
|
|||||||
@@ -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(); // 文件扩展名
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
161
functions/utils/tagHelpers.js
Normal file
161
functions/utils/tagHelpers.js
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user