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 c9877be2bf
commit 797f63b00a
8 changed files with 663 additions and 4 deletions
+192
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}`);
}
}
+109
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' }
});
}
}
+172
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' }
});
}
}