mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
Restores complete implementation from feat/endpoint-microsub branch: - Reader UI with views (reader.njk, channel.njk, feeds.njk, etc.) - Feed polling, parsing, and normalization - WebSub subscriber - SSE realtime updates - Redis caching - Search indexing - Media proxy - Webmention processing
199 lines
5.3 KiB
JavaScript
199 lines
5.3 KiB
JavaScript
/**
|
|
* Search query module for full-text search
|
|
* @module search/query
|
|
*/
|
|
|
|
import { ObjectId } from "mongodb";
|
|
|
|
/**
|
|
* Search items using MongoDB text search
|
|
* @param {object} application - Indiekit application
|
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
* @param {string} query - Search query string
|
|
* @param {object} options - Search options
|
|
* @param {number} [options.limit] - Max results (default 20)
|
|
* @param {number} [options.skip] - Skip results for pagination
|
|
* @param {boolean} [options.sortByScore] - Sort by relevance (default true)
|
|
* @returns {Promise<Array>} Array of matching items
|
|
*/
|
|
export async function searchItemsFullText(
|
|
application,
|
|
channelId,
|
|
query,
|
|
options = {},
|
|
) {
|
|
const collection = application.collections.get("microsub_items");
|
|
const { limit = 20, skip = 0, sortByScore = true } = options;
|
|
|
|
const channelObjectId =
|
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
|
|
// Build the search query
|
|
const searchQuery = {
|
|
channelId: channelObjectId,
|
|
$text: { $search: query },
|
|
};
|
|
|
|
// Build aggregation pipeline for scoring
|
|
const pipeline = [
|
|
{ $match: searchQuery },
|
|
{ $addFields: { score: { $meta: "textScore" } } },
|
|
];
|
|
|
|
if (sortByScore) {
|
|
pipeline.push(
|
|
{ $sort: { score: -1, published: -1 } },
|
|
{ $skip: skip },
|
|
{ $limit: limit },
|
|
);
|
|
} else {
|
|
pipeline.push(
|
|
{ $sort: { published: -1 } },
|
|
{ $skip: skip },
|
|
{ $limit: limit },
|
|
);
|
|
}
|
|
|
|
const items = await collection.aggregate(pipeline).toArray();
|
|
|
|
return items.map((item) => transformToSearchResult(item));
|
|
}
|
|
|
|
/**
|
|
* Search items using regex fallback (for partial matching)
|
|
* @param {object} application - Indiekit application
|
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
* @param {string} query - Search query string
|
|
* @param {object} options - Search options
|
|
* @returns {Promise<Array>} Array of matching items
|
|
*/
|
|
export async function searchItemsRegex(
|
|
application,
|
|
channelId,
|
|
query,
|
|
options = {},
|
|
) {
|
|
const collection = application.collections.get("microsub_items");
|
|
const { limit = 20 } = options;
|
|
|
|
const channelObjectId =
|
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
|
|
// Escape regex special characters
|
|
const escapedQuery = query.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
|
|
const regex = new RegExp(escapedQuery, "i");
|
|
|
|
const items = await collection
|
|
.find({
|
|
channelId: channelObjectId,
|
|
$or: [
|
|
{ name: regex },
|
|
{ "content.text": regex },
|
|
{ "content.html": regex },
|
|
{ summary: regex },
|
|
{ "author.name": regex },
|
|
],
|
|
})
|
|
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
|
|
.sort({ published: -1 })
|
|
.limit(limit)
|
|
.toArray();
|
|
|
|
return items.map((item) => transformToSearchResult(item));
|
|
}
|
|
|
|
/**
|
|
* Search with automatic fallback
|
|
* Uses full-text search first, falls back to regex if no results
|
|
* @param {object} application - Indiekit application
|
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
* @param {string} query - Search query string
|
|
* @param {object} options - Search options
|
|
* @returns {Promise<Array>} Array of matching items
|
|
*/
|
|
export async function searchWithFallback(
|
|
application,
|
|
channelId,
|
|
query,
|
|
options = {},
|
|
) {
|
|
// Try full-text search first
|
|
try {
|
|
const results = await searchItemsFullText(
|
|
application,
|
|
channelId,
|
|
query,
|
|
options,
|
|
);
|
|
if (results.length > 0) {
|
|
return results;
|
|
}
|
|
} catch {
|
|
// Text index might not exist, fall through to regex
|
|
}
|
|
|
|
// Fall back to regex search
|
|
return searchItemsRegex(application, channelId, query, options);
|
|
}
|
|
|
|
/**
|
|
* Transform database item to search result format
|
|
* @param {object} item - Database item
|
|
* @returns {object} Search result
|
|
*/
|
|
function transformToSearchResult(item) {
|
|
const result = {
|
|
type: item.type || "entry",
|
|
uid: item.uid,
|
|
url: item.url,
|
|
published: item.published?.toISOString(),
|
|
_id: item._id.toString(),
|
|
};
|
|
|
|
if (item.name) result.name = item.name;
|
|
if (item.content) result.content = item.content;
|
|
if (item.summary) result.summary = item.summary;
|
|
if (item.author) result.author = item.author;
|
|
if (item.photo?.length > 0) result.photo = item.photo;
|
|
if (item.score) result._score = item.score;
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get search suggestions (autocomplete)
|
|
* @param {object} application - Indiekit application
|
|
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
* @param {string} prefix - Search prefix
|
|
* @param {number} limit - Max suggestions
|
|
* @returns {Promise<Array>} Array of suggestions
|
|
*/
|
|
export async function getSearchSuggestions(
|
|
application,
|
|
channelId,
|
|
prefix,
|
|
limit = 5,
|
|
) {
|
|
const collection = application.collections.get("microsub_items");
|
|
|
|
const channelObjectId =
|
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
|
|
const escapedPrefix = prefix.replaceAll(
|
|
/[$()*+.?[\\\]^{|}]/g,
|
|
String.raw`\$&`,
|
|
);
|
|
const regex = new RegExp(`^${escapedPrefix}`, "i");
|
|
|
|
// Get unique names/titles that match prefix
|
|
const results = await collection
|
|
.aggregate([
|
|
{ $match: { channelId: channelObjectId, name: regex } },
|
|
{ $group: { _id: "$name" } },
|
|
{ $limit: limit },
|
|
])
|
|
.toArray();
|
|
|
|
return results.map((r) => r._id).filter(Boolean);
|
|
}
|