mirror of
https://github.com/ZSCGR/CloudFlare-ImgBed.git
synced 2026-08-13 04:03:42 +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_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 (
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
Label: "None",
|
||||
Directory: normalizedFolder === '' ? '' : normalizedFolder + '/',
|
||||
Tags: []
|
||||
};
|
||||
|
||||
// 更新进度
|
||||
|
||||
@@ -146,6 +146,7 @@ async function processFileUpload(context, formdata = null) {
|
||||
TimeStamp: time,
|
||||
Label: "None",
|
||||
Directory: normalizedFolder === '' ? '' : normalizedFolder + '/',
|
||||
Tags: []
|
||||
};
|
||||
|
||||
let fileExt = fileName.split('.').pop(); // 文件扩展名
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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