Files
docmost/apps/server/src/core/label/label.service.ts
T
SalihuandPhilipinho 917195b2f2 feat: enhanced search filters (#2398)
* advanced search filters

* improve labelId filtering in query

* index label ids on typesense

* index label ids on typesense

* filters on AI search

* refactor: detect typesense schema drift from the declared collection schema

* feat: show secondary search filters only when applied

Created by and Labels now stay hidden until they hold a value; a Filter
button at the right end of the bar lists the hidden ones and opens the
picked filter's dropdown immediately.

* fix: keep applied search filters in the order they were added

* feat: add title-only search mode

Adds a titleOnly flag to page search: the Postgres driver matches
titles via trigram-indexed unaccented LIKE ranked by word_similarity,
and a Match filter in the search spotlight switches between everything
and title-only matching.

* refactor: make title-only search a toggle chip

* fix: drop stale results when the search content type changes

* feat: add trigram index on attachment file names

* feat: extend title-only search to attachments

The titleOnly flag now matches attachment file names via the
separator-normalized trigram expression, and the toggle chip stays
available in attachment mode as File name only.

* refactor: keep the title-only label uniform across search modes

* feat: index txt attachments for content search

* feat: pin the current user at the top of the creator filter

* refactor: dim the you suffix in the creator filter

* feat: browse label-filtered pages without a query

Selecting labels now lists their pages newest-first before any text is
typed, on both the Postgres and Typesense drivers.

* fix: match any selected label instead of requiring all

* fix: use a gray spotlight selection instead of primary blue

* feat: include search results in the spotlight tab order

* feat: show last updated time on search results

* fix: stop empty-state flicker while the search query debounces

* feat: browse creator-filtered results without a query

Selecting a creator with an empty query now lists their pages (or
attachments in attachment mode) newest-first, with the same space and
page permission filtering as typed search.

* fix: show Untitled for pages without a title in search results

* quick page sharing fix

* minor search filter fixes

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-08-25 01:39:33 +01:00

155 lines
4.0 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { Label } from '@docmost/db/types/entity.types';
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { normalizeLabelName } from './utils';
import { EventEmitter2 } from "@nestjs/event-emitter";
import { EventName } from "src/common/events/event.contants";
@Injectable()
export class LabelService {
constructor(
private readonly labelRepo: LabelRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly eventEmitter: EventEmitter2,
@InjectKysely() private readonly db: KyselyDB,
) {}
async addLabelsToPage(
pageId: string,
names: string[],
workspaceId: string,
): Promise<Label[]> {
const attached: Label[] = [];
await executeTx(this.db, async (trx) => {
for (const name of names) {
const label = await this.labelRepo.findOrCreate(
name.trim(),
workspaceId,
LabelType.PAGE,
trx,
);
await this.labelRepo.addLabelToPage(pageId, label.id, trx);
attached.push(label);
}
});
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: [pageId],
workspaceId: workspaceId,
});
return attached;
}
async removeLabelFromPage(
pageId: string,
labelId: string,
workspaceId: string,
): Promise<void> {
await executeTx(this.db, async (trx) => {
const label = await this.labelRepo.findById(labelId, trx);
if (!label || label.workspaceId !== workspaceId) {
throw new NotFoundException('Label not found');
}
await this.labelRepo.removeLabelFromPage(
pageId,
labelId,
workspaceId,
trx,
);
const count = await this.labelRepo.getLabelPageCount(
labelId,
workspaceId,
trx,
);
if (count === 0) {
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
}
});
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: [pageId],
workspaceId: workspaceId,
});
}
async getPageLabels(pageId: string, pagination: PaginationOptions) {
return this.labelRepo.findLabelsByPageId(pageId, pagination);
}
async getLabels(
workspaceId: string,
userId: string,
type: LabelType,
pagination: PaginationOptions,
) {
return this.labelRepo.findLabels(
workspaceId,
userId,
type,
pagination,
);
}
async findPagesByLabel(
labelId: string,
userId: string,
opts: {
spaceId?: string;
query?: string;
pagination: PaginationOptions;
},
) {
const result = await this.labelRepo.findPagesByLabelId(labelId, userId, opts);
if (result.items.length === 0) return result;
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: result.items.map((p) => p.id),
userId,
spaceId: opts.spaceId,
});
const accessible = new Set(accessibleIds);
return {
items: result.items.filter((p) => accessible.has(p.id)),
meta: result.meta,
};
}
async getLabelInfo(
name: string,
type: LabelType,
workspaceId: string,
userId: string,
spaceId?: string,
) {
const normalized = normalizeLabelName(name);
const label = await this.labelRepo.findByNameAndWorkspace(
normalized,
workspaceId,
type,
);
// Uniform response shape.
// We don't want to expose whether the label row exists
const usageCount = label
? await this.labelRepo.getLabelPageCountForUser(
label.id,
userId,
spaceId,
)
: 0;
return {
name: normalized,
usageCount,
};
}
}