feat: Search shared documents (#3126)
* provide a type-ahead search input on shared document pages that allow search of child document tree * improve keyboard navigation handling of all search views * improve coloring on dark mode list selection states * refactor PaginatedList component to eliminate edge cases
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
NotFoundError,
|
||||
InvalidRequestError,
|
||||
AuthorizationError,
|
||||
AuthenticationError,
|
||||
} from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import {
|
||||
@@ -386,7 +387,7 @@ async function loadDocument({
|
||||
}: {
|
||||
id?: string;
|
||||
shareId?: string;
|
||||
user: User;
|
||||
user?: User;
|
||||
}): Promise<{
|
||||
document: Document;
|
||||
share?: Share;
|
||||
@@ -396,6 +397,10 @@ async function loadDocument({
|
||||
let collection;
|
||||
let share;
|
||||
|
||||
if (!shareId && !(id && user)) {
|
||||
throw AuthenticationError(`Authentication or shareId required`);
|
||||
}
|
||||
|
||||
if (shareId) {
|
||||
share = await Share.findOne({
|
||||
where: {
|
||||
@@ -454,7 +459,7 @@ async function loadDocument({
|
||||
|
||||
// If the user has access to read the document, we can just update
|
||||
// the last access date and return the document without additional checks.
|
||||
const canReadDocument = can(user, "read", document);
|
||||
const canReadDocument = user && can(user, "read", document);
|
||||
|
||||
if (canReadDocument) {
|
||||
await share.update({
|
||||
@@ -519,9 +524,9 @@ async function loadDocument({
|
||||
|
||||
if (document.deletedAt) {
|
||||
// don't send data if user cannot restore deleted doc
|
||||
authorize(user, "restore", document);
|
||||
user && authorize(user, "restore", document);
|
||||
} else {
|
||||
authorize(user, "read", document);
|
||||
user && authorize(user, "read", document);
|
||||
}
|
||||
|
||||
collection = document.collection;
|
||||
@@ -739,82 +744,133 @@ router.post("documents.search_titles", auth(), pagination(), async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.search", auth(), pagination(), async (ctx) => {
|
||||
const {
|
||||
query,
|
||||
includeArchived,
|
||||
includeDrafts,
|
||||
collectionId,
|
||||
userId,
|
||||
dateFilter,
|
||||
} = ctx.body;
|
||||
const { offset, limit } = ctx.state.pagination;
|
||||
const { user } = ctx.state;
|
||||
|
||||
assertNotEmpty(query, "query is required");
|
||||
|
||||
if (collectionId) {
|
||||
assertUuid(collectionId, "collectionId must be a UUID");
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
}
|
||||
|
||||
let collaboratorIds = undefined;
|
||||
|
||||
if (userId) {
|
||||
assertUuid(userId, "userId must be a UUID");
|
||||
collaboratorIds = [userId];
|
||||
}
|
||||
|
||||
if (dateFilter) {
|
||||
assertIn(
|
||||
dateFilter,
|
||||
["day", "week", "month", "year"],
|
||||
"dateFilter must be one of day,week,month,year"
|
||||
);
|
||||
}
|
||||
|
||||
const { results, totalCount } = await Document.searchForUser(user, query, {
|
||||
includeArchived: includeArchived === "true",
|
||||
includeDrafts: includeDrafts === "true",
|
||||
collaboratorIds,
|
||||
collectionId,
|
||||
dateFilter,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
|
||||
const documents = results.map((result) => result.document);
|
||||
|
||||
const data = await Promise.all(
|
||||
results.map(async (result) => {
|
||||
const document = await presentDocument(result.document);
|
||||
return { ...result, document };
|
||||
})
|
||||
);
|
||||
|
||||
// When requesting subsequent pages of search results we don't want to record
|
||||
// duplicate search query records
|
||||
if (offset === 0) {
|
||||
SearchQuery.create({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
source: ctx.state.authType,
|
||||
router.post(
|
||||
"documents.search",
|
||||
auth({
|
||||
required: false,
|
||||
}),
|
||||
pagination(),
|
||||
async (ctx) => {
|
||||
const {
|
||||
query,
|
||||
results: totalCount,
|
||||
});
|
||||
includeArchived,
|
||||
includeDrafts,
|
||||
collectionId,
|
||||
userId,
|
||||
dateFilter,
|
||||
shareId,
|
||||
} = ctx.body;
|
||||
assertNotEmpty(query, "query is required");
|
||||
|
||||
const { offset, limit } = ctx.state.pagination;
|
||||
const snippetMinWords = parseInt(ctx.body.snippetMinWords || 20, 10);
|
||||
const snippetMaxWords = parseInt(ctx.body.snippetMaxWords || 30, 10);
|
||||
|
||||
// this typing is a bit ugly, would be better to use a type like ContextWithState
|
||||
// but that doesn't adequately handle cases when auth is optional
|
||||
const { user }: { user: User | undefined } = ctx.state;
|
||||
|
||||
let teamId;
|
||||
let response;
|
||||
|
||||
if (shareId) {
|
||||
const { share, document } = await loadDocument({
|
||||
shareId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!share?.includeChildDocuments) {
|
||||
throw InvalidRequestError("Child documents cannot be searched");
|
||||
}
|
||||
|
||||
teamId = share.teamId;
|
||||
const team = await Team.findByPk(teamId);
|
||||
invariant(team, "Share must belong to a team");
|
||||
|
||||
response = await Document.searchForTeam(team, query, {
|
||||
includeArchived: includeArchived === "true",
|
||||
includeDrafts: includeDrafts === "true",
|
||||
collectionId: document.collectionId,
|
||||
share,
|
||||
dateFilter,
|
||||
offset,
|
||||
limit,
|
||||
snippetMinWords,
|
||||
snippetMaxWords,
|
||||
});
|
||||
} else {
|
||||
if (!user) {
|
||||
throw AuthenticationError("Authentication error");
|
||||
}
|
||||
|
||||
teamId = user.teamId;
|
||||
|
||||
if (collectionId) {
|
||||
assertUuid(collectionId, "collectionId must be a UUID");
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
}
|
||||
|
||||
let collaboratorIds = undefined;
|
||||
|
||||
if (userId) {
|
||||
assertUuid(userId, "userId must be a UUID");
|
||||
collaboratorIds = [userId];
|
||||
}
|
||||
|
||||
if (dateFilter) {
|
||||
assertIn(
|
||||
dateFilter,
|
||||
["day", "week", "month", "year"],
|
||||
"dateFilter must be one of day,week,month,year"
|
||||
);
|
||||
}
|
||||
|
||||
response = await Document.searchForUser(user, query, {
|
||||
includeArchived: includeArchived === "true",
|
||||
includeDrafts: includeDrafts === "true",
|
||||
collaboratorIds,
|
||||
collectionId,
|
||||
dateFilter,
|
||||
offset,
|
||||
limit,
|
||||
snippetMinWords,
|
||||
snippetMaxWords,
|
||||
});
|
||||
}
|
||||
|
||||
const { results, totalCount } = response;
|
||||
const documents = results.map((result) => result.document);
|
||||
|
||||
const data = await Promise.all(
|
||||
results.map(async (result) => {
|
||||
const document = await presentDocument(result.document);
|
||||
return { ...result, document };
|
||||
})
|
||||
);
|
||||
|
||||
// When requesting subsequent pages of search results we don't want to record
|
||||
// duplicate search query records
|
||||
if (offset === 0) {
|
||||
SearchQuery.create({
|
||||
userId: user?.id,
|
||||
teamId,
|
||||
shareId,
|
||||
source: ctx.state.authType || "app", // we'll consider anything that isn't "api" to be "app"
|
||||
query,
|
||||
results: totalCount,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
policies: user ? presentPolicies(user, documents) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const policies = presentPolicies(user, documents);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
policies,
|
||||
};
|
||||
});
|
||||
);
|
||||
|
||||
// Deprecated – use stars.create instead
|
||||
router.post("documents.star", auth(), async (ctx) => {
|
||||
|
||||
Reference in New Issue
Block a user