@wirecat/cli-core 0.390.0 → 0.392.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/dist/cli/bot/command.js +33 -4
- package/dist/cli/bot/port.d.ts +35 -1
- package/dist/cli/bot/token.d.ts +8 -1
- package/dist/cli/bot/token.js +16 -3
- package/dist/cli/bot/watch.d.ts +2 -0
- package/dist/cli/bot/watch.js +54 -12
- package/dist/cli/bot/webhook-receiver.d.ts +22 -0
- package/dist/cli/bot/webhook-receiver.js +68 -0
- package/dist/cli/effects.d.ts +2 -0
- package/dist/cli/effects.js +8 -2
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/messenger/backfill-command.js +5 -1
- package/dist/cli/messenger/catch-up.d.ts +0 -19
- package/dist/cli/messenger/catch-up.js +0 -43
- package/dist/cli/messenger/context.js +6 -3
- package/dist/cli/messenger/conversations-command.js +4 -1
- package/dist/cli/messenger/download-command.js +4 -1
- package/dist/cli/messenger/inbox.js +6 -11
- package/dist/cli/messenger/lacks.d.ts +1 -0
- package/dist/cli/messenger/lacks.js +6 -1
- package/dist/cli/messenger/mcp-command.js +6 -28
- package/dist/cli/messenger/messages-command.js +17 -18
- package/dist/cli/messenger/messages-comments-command.js +4 -1
- package/dist/cli/messenger/messages-delete-command.js +4 -1
- package/dist/cli/messenger/messages-edit-command.js +4 -1
- package/dist/cli/messenger/messages-evidence-command.js +4 -1
- package/dist/cli/messenger/messages-forward-command.js +4 -1
- package/dist/cli/messenger/messages-link-command.js +4 -1
- package/dist/cli/messenger/messages-scheduled-command.js +4 -1
- package/dist/cli/messenger/messages-send-command.js +4 -1
- package/dist/cli/messenger/review.d.ts +1 -1
- package/dist/cli/messenger/review.js +5 -10
- package/dist/cli/messenger/tags-command.js +2 -2
- package/dist/cli/messenger/transcribe-command.js +4 -1
- package/dist/cli/settings.d.ts +0 -4
- package/dist/cli/settings.js +7 -5
- package/dist/domain/providers.js +2 -0
- package/dist/{mcp/http/node.d.ts → http/listen.d.ts} +3 -2
- package/dist/{mcp/http/node.js → http/listen.js} +5 -5
- package/dist/http/public-url.d.ts +10 -0
- package/dist/http/public-url.js +32 -0
- package/dist/mcp/http/serve.d.ts +1 -1
- package/dist/mcp/http/serve.js +1 -1
- package/dist/meetings/index.d.ts +2 -0
- package/dist/meetings/index.js +2 -0
- package/dist/meetings/links.d.ts +18 -0
- package/dist/meetings/links.js +1 -0
- package/dist/meetings/review.d.ts +45 -0
- package/dist/meetings/review.js +1 -0
- package/dist/search/lucene/registry.d.ts +5 -1
- package/dist/search/lucene/registry.js +10 -4
- package/dist/search/lucene/types.d.ts +8 -0
- package/dist/search/lucene/types.js +4 -0
- package/dist/search/question-plan.js +3 -3
- package/dist/services/excerpt.js +4 -1
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meeting-links.d.ts +33 -0
- package/dist/services/meeting-links.js +41 -0
- package/dist/services/meeting-review.d.ts +12 -0
- package/dist/services/meeting-review.js +8 -0
- package/dist/services/messages-search.js +2 -2
- package/dist/services/searches.js +3 -3
- package/dist/store/sqlite/lucene-fields.d.ts +10 -3
- package/dist/store/sqlite/lucene-fields.js +85 -13
- package/dist/store/sqlite/lucene.js +16 -11
- package/dist/store/sqlite/meeting-chat-lines.d.ts +3 -0
- package/dist/store/sqlite/meeting-chat-lines.js +34 -0
- package/dist/store/sqlite/meeting-review.d.ts +4 -0
- package/dist/store/sqlite/meeting-review.js +97 -0
- package/dist/store/sqlite/meetings.d.ts +2 -2
- package/dist/store/sqlite/meetings.js +4 -0
- package/dist/store/sqlite/mention-drain.js +3 -2
- package/package.json +1 -1
- package/parity.json +339 -118
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
const EXAMPLE = "--public-url https://<name>.ts.net";
|
|
3
|
+
/**
|
|
4
|
+
* The tunnel's address a public listener is reached at, checked one way for every command that opens
|
|
5
|
+
* one. `missing` says what needs it; `reaches` who calls through it, for the https refusal.
|
|
6
|
+
*/
|
|
7
|
+
export const publicUrlOf = (given, { missing, reaches }) => {
|
|
8
|
+
if (!given)
|
|
9
|
+
throw new CliError("configuration_error", `${missing} needs the tunnel's address: ${EXAMPLE}`);
|
|
10
|
+
let url;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(given);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new CliError("validation_error", `--public-url is not an address: ${EXAMPLE}`);
|
|
16
|
+
}
|
|
17
|
+
const local = url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname);
|
|
18
|
+
if (url.protocol !== "https:" && !local)
|
|
19
|
+
throw new CliError("validation_error", `--public-url must be https — ${reaches} through it`);
|
|
20
|
+
if (url.pathname !== "/" || url.search || url.hash)
|
|
21
|
+
throw new CliError("validation_error", `--public-url is the tunnel's address only, without a path: ${EXAMPLE}`);
|
|
22
|
+
return url;
|
|
23
|
+
};
|
|
24
|
+
/** A local port from `--port`, or the command's own default. */
|
|
25
|
+
export const portOf = (given, fallback) => {
|
|
26
|
+
if (given === undefined)
|
|
27
|
+
return fallback;
|
|
28
|
+
const port = Number(given);
|
|
29
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
30
|
+
throw new CliError("validation_error", "--port takes 1–65535");
|
|
31
|
+
return port;
|
|
32
|
+
};
|
package/dist/mcp/http/serve.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type McpServer } from "@modelcontextprotocol/server";
|
|
2
|
-
import { type Listening } from "
|
|
2
|
+
import { type Listening } from "../../http/listen.js";
|
|
3
3
|
export declare const MCP_PATH = "/mcp";
|
|
4
4
|
export interface HttpOptions {
|
|
5
5
|
/** The tunnel's address, as the browser apps reach it — `https://name.ts.net`. */
|
package/dist/mcp/http/serve.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createMcpHandler, getOAuthProtectedResourceMetadataUrl, hostHeaderValidationResponse, isLegacyRequest, oauthMetadataResponse, requireBearerAuth, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
|
|
3
|
-
import { listen } from "
|
|
3
|
+
import { listen } from "../../http/listen.js";
|
|
4
4
|
import { ownerLogin } from "./oauth.js";
|
|
5
5
|
export const MCP_PATH = "/mcp";
|
|
6
6
|
const LOCAL = ["localhost", "127.0.0.1"];
|
package/dist/meetings/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { type Finding, type FindingsInput, type FindingsReceipt, type FindingsRu
|
|
|
9
9
|
export { MEETING_HIT_QUERY, meetingWordsQuery, parseMeetingQuery } from "./hit-query.js";
|
|
10
10
|
export { type ImportFile, importFiles } from "./import.js";
|
|
11
11
|
export { MEETING_ISSUES, MEETING_QUERY, type MeetingIssue, type MeetingItem, type MeetingOwner, meeting, meetingIssuesOf, meetingItem, } from "./item.js";
|
|
12
|
+
export { CHAT_MESSAGES_READ, type MeetingChatLine, type MeetingChatLinesInput, type MeetingChatLinesStore, } from "./links.js";
|
|
12
13
|
export { createMeetingContext, MEETING_FACT_TYPES, type MeetingContextAction, type MeetingContextChange, type MeetingContextOperation, type MeetingContextPorts, type MeetingContextReceipt, validateMeetingContextChange, } from "./meeting-context.js";
|
|
13
14
|
export { createMeetingManagement, dispatchMeetingMutation, type ManagedMeeting, type ManagedMeetingChange, type MeetingManagement, type MeetingManagementPorts, type MeetingMutationOptions, type MeetingMutationProposal, type MeetingMutationReceipt, type MeetingOperationOptions, type MeetingProviderContext, validateMeetingChange, } from "./meeting-management.js";
|
|
14
15
|
export type { Attachment, ChatLine, Event, EventSeries, JsonValue, Meeting, MeetingSeries, Metadata, Occurrence, Participant, RecordTimes, SourceChatLine, SourceMeeting, Summary, SummaryContent, Transcript, TranscriptLine, TranscriptRow, } from "./model.js";
|
|
@@ -32,6 +33,7 @@ export { parseRecordingSyncCheckpoint, type RecordingSyncCheckpoint, type Record
|
|
|
32
33
|
export { type RecordingPartial, RecordingTransferError, type RecordingTransferResponse, type RecordingTransferSource, type RecordingTransferStorage, type RecordingTransferWriter, strongRecordingValidator, transferRecording, } from "./recording-transfer.js";
|
|
33
34
|
export { canonicalMeetingReference, formatMeetingReference, type MeetingReference, parseMeetingReference, } from "./reference.js";
|
|
34
35
|
export { type MeetingRelatedHistoryEntry, type MeetingRelatedHistoryRequest, type MeetingRelatedHistoryStore, validateMeetingRelatedHistory, } from "./related-history.js";
|
|
36
|
+
export { type MeetingReview, type MeetingReviewInput, type MeetingReviewItem, type MeetingReviewSpeaker, type MeetingReviewStore, REVIEW_LIMIT, } from "./review.js";
|
|
35
37
|
export { SAVE_HELP, SAVE_INSTRUCTION } from "./save-hints.js";
|
|
36
38
|
export type { MeetingHitSearchRequest, MeetingSearchIndexInput, MeetingSearchIndexReceipt, MeetingSearchIndexStore, MeetingSearchOptions, MeetingSearchPage, MeetingSearchStore, } from "./search-store.js";
|
|
37
39
|
export { validateMeetingHitSearch } from "./search-validation.js";
|
package/dist/meetings/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { findingFingerprint, validateFindings, } from "./findings-rules.js";
|
|
|
7
7
|
export { MEETING_HIT_QUERY, meetingWordsQuery, parseMeetingQuery } from "./hit-query.js";
|
|
8
8
|
export { importFiles } from "./import.js";
|
|
9
9
|
export { MEETING_ISSUES, MEETING_QUERY, meeting, meetingIssuesOf, meetingItem, } from "./item.js";
|
|
10
|
+
export { CHAT_MESSAGES_READ, } from "./links.js";
|
|
10
11
|
export { createMeetingContext, MEETING_FACT_TYPES, validateMeetingContextChange, } from "./meeting-context.js";
|
|
11
12
|
export { createMeetingManagement, dispatchMeetingMutation, validateMeetingChange, } from "./meeting-management.js";
|
|
12
13
|
export { normalizeMeetingParticipantQuery, validateMeetingParticipantSearch, } from "./participant-search.js";
|
|
@@ -23,6 +24,7 @@ export { parseRecordingSyncCheckpoint, RecordingSyncError, recordingAssetFingerp
|
|
|
23
24
|
export { RecordingTransferError, strongRecordingValidator, transferRecording, } from "./recording-transfer.js";
|
|
24
25
|
export { canonicalMeetingReference, formatMeetingReference, parseMeetingReference, } from "./reference.js";
|
|
25
26
|
export { validateMeetingRelatedHistory, } from "./related-history.js";
|
|
27
|
+
export { REVIEW_LIMIT, } from "./review.js";
|
|
26
28
|
export { SAVE_HELP, SAVE_INSTRUCTION } from "./save-hints.js";
|
|
27
29
|
export { validateMeetingHitSearch } from "./search-validation.js";
|
|
28
30
|
export { validateMeetingSeriesHistory, } from "./series-history.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MeetingStatsSelection } from "./stats.js";
|
|
2
|
+
export interface MeetingChatLinesInput extends MeetingStatsSelection {
|
|
3
|
+
/** Most chat messages to read (default and at most 100000); more is an error, never a silent cut. */
|
|
4
|
+
maxMessages?: number;
|
|
5
|
+
}
|
|
6
|
+
/** One stored chat message with the meeting it was sent in. */
|
|
7
|
+
export interface MeetingChatLine {
|
|
8
|
+
meetingId: number;
|
|
9
|
+
accountId: number;
|
|
10
|
+
title: string | null;
|
|
11
|
+
sentAt: number;
|
|
12
|
+
sender: string | null;
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
export interface MeetingChatLinesStore {
|
|
16
|
+
meetingChatLines(input: MeetingChatLinesInput): Promise<MeetingChatLine[]>;
|
|
17
|
+
}
|
|
18
|
+
export declare const CHAT_MESSAGES_READ = 100000;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CHAT_MESSAGES_READ = 100_000;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MeetingStatsSelection } from "./stats.js";
|
|
2
|
+
export interface MeetingReviewInput extends MeetingStatsSelection {
|
|
3
|
+
/** Most meetings, newest first (default 50, at most 200). */
|
|
4
|
+
limit?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface MeetingReviewSpeaker {
|
|
7
|
+
name: string | null;
|
|
8
|
+
talkMs: number;
|
|
9
|
+
}
|
|
10
|
+
/** One meeting as a review shows it: what the store holds about it, never transcript text. */
|
|
11
|
+
export interface MeetingReviewItem {
|
|
12
|
+
meetingId: number;
|
|
13
|
+
accountId: number;
|
|
14
|
+
title: string | null;
|
|
15
|
+
startedAt: number | null;
|
|
16
|
+
durationMs: number | null;
|
|
17
|
+
participantsCount: number | null;
|
|
18
|
+
projects: string[];
|
|
19
|
+
/** `null`: no current transcript. */
|
|
20
|
+
transcript: {
|
|
21
|
+
lines: number;
|
|
22
|
+
words: number;
|
|
23
|
+
} | null;
|
|
24
|
+
/** The three who talked longest, from the transcript. */
|
|
25
|
+
speakers: MeetingReviewSpeaker[];
|
|
26
|
+
/** The newest stored summary; `null` when the meeting has none. */
|
|
27
|
+
summary: {
|
|
28
|
+
source: string;
|
|
29
|
+
title: string | null;
|
|
30
|
+
overview: string | null;
|
|
31
|
+
nextSteps: string[];
|
|
32
|
+
url: string | null;
|
|
33
|
+
} | null;
|
|
34
|
+
}
|
|
35
|
+
export interface MeetingReview {
|
|
36
|
+
items: MeetingReviewItem[];
|
|
37
|
+
hasMore: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface MeetingReviewStore {
|
|
40
|
+
meetingReview(input: MeetingReviewInput): Promise<MeetingReview>;
|
|
41
|
+
}
|
|
42
|
+
export declare const REVIEW_LIMIT: {
|
|
43
|
+
readonly default: 50;
|
|
44
|
+
readonly most: 200;
|
|
45
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const REVIEW_LIMIT = { default: 50, most: 200 };
|
|
@@ -70,7 +70,11 @@ export declare const QUERY_OPERATORS: readonly [{
|
|
|
70
70
|
readonly example: "text:/pass(port)?/";
|
|
71
71
|
readonly semantics: "Полный term; bounded subset Lucene RegExp; буквы text приводятся как в индексе";
|
|
72
72
|
}, {
|
|
73
|
-
readonly name: "
|
|
73
|
+
readonly name: "proximity";
|
|
74
|
+
readonly example: '"invoice paid"~3';
|
|
75
|
+
readonly semantics: "Слова одного сообщения, между ними не больше N других слов, в любом порядке; N до 50";
|
|
76
|
+
}, {
|
|
77
|
+
readonly name: "fuzzy";
|
|
74
78
|
readonly example: "invoice~1";
|
|
75
79
|
readonly semantics: "unsupported_operator; формы слова ловит prefix `word*`";
|
|
76
80
|
}, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { queryAstSchema, validateQuery, walkQuery, } from "@wirecat/cli-base/query";
|
|
2
2
|
import * as v from "valibot";
|
|
3
3
|
import { tagOf } from "../../domain/tags.js";
|
|
4
|
-
import { QUERY_LIMITS, SEARCH_QUERY, searchError } from "./types.js";
|
|
4
|
+
import { MOST_WORDS_APART, QUERY_LIMITS, SEARCH_QUERY, searchError } from "./types.js";
|
|
5
5
|
export const FIELD_VERSION = 2;
|
|
6
6
|
const UNITS = {
|
|
7
7
|
"": 1,
|
|
@@ -398,6 +398,7 @@ export const QUERY_FIELDS = [
|
|
|
398
398
|
},
|
|
399
399
|
];
|
|
400
400
|
const MESSAGES = { entity: "message", backend: "sql", fields: QUERY_FIELDS };
|
|
401
|
+
const NEAR_FIELDS = new Set(["text", "exact", "transcript", "content"]);
|
|
401
402
|
/** Field, operator and value of every term, against the message fields. */
|
|
402
403
|
export const validateFields = (ast) => {
|
|
403
404
|
for (const node of walkQuery(ast.root)) {
|
|
@@ -406,6 +407,10 @@ export const validateFields = (ast) => {
|
|
|
406
407
|
const plain = ["term", "phrase"].includes(node.operator) && /^[\w:.+-]+$/u.test(node.value);
|
|
407
408
|
searchError("legacy_date_field", node.span, plain ? `use date${node.field === "after" ? ">=" : "<"}${node.value}` : "use date>=YYYY-MM-DD or date:[a TO b]");
|
|
408
409
|
}
|
|
410
|
+
if (node.slop !== undefined && !NEAR_FIELDS.has(node.field))
|
|
411
|
+
searchError("query.unsupported-operator", node.span, `"a b"~N takes words: ${[...NEAR_FIELDS].join(", ")}`);
|
|
412
|
+
if (node.slop !== undefined && node.slop > MOST_WORDS_APART)
|
|
413
|
+
searchError("query.invalid-number", node.span, `words at most ${MOST_WORDS_APART} apart: "a b"~${MOST_WORDS_APART}`);
|
|
409
414
|
// The message compilers read `field:*` as a pattern, never as "has a value".
|
|
410
415
|
if (field && node.operator === "wildcard" && !field.operators.includes("wildcard"))
|
|
411
416
|
searchError("query.unsupported-operator", node.span, `${field.name} takes ${field.operators.join(", ")}`);
|
|
@@ -499,9 +504,10 @@ export const QUERY_OPERATORS = [
|
|
|
499
504
|
semantics: "Полный term; bounded subset Lucene RegExp; буквы text приводятся как в индексе",
|
|
500
505
|
},
|
|
501
506
|
{
|
|
502
|
-
name: "
|
|
503
|
-
example: "invoice~
|
|
504
|
-
semantics: "
|
|
507
|
+
name: "proximity",
|
|
508
|
+
example: '"invoice paid"~3',
|
|
509
|
+
semantics: "Слова одного сообщения, между ними не больше N других слов, в любом порядке; N до 50",
|
|
505
510
|
},
|
|
511
|
+
{ name: "fuzzy", example: "invoice~1", semantics: "unsupported_operator; формы слова ловит prefix `word*`" },
|
|
506
512
|
{ name: "boost / minimum / intervals", example: "invoice^2", semantics: "query.unsupported-operator" },
|
|
507
513
|
];
|
|
@@ -13,5 +13,13 @@ export declare const QUERY_LIMITS: {
|
|
|
13
13
|
readonly work: 10000000;
|
|
14
14
|
};
|
|
15
15
|
export declare const SEARCH_QUERY: QueryContext;
|
|
16
|
+
/** How message search parses typed text: `"a b"~N` is words at most N apart; notes still refuse `~`. */
|
|
17
|
+
export declare const MESSAGE_PARSING: {
|
|
18
|
+
command?: string;
|
|
19
|
+
guide?: string;
|
|
20
|
+
proximity: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** Further apart than this, two words are rarely about one thing, even in a long message. */
|
|
23
|
+
export declare const MOST_WORDS_APART = 50;
|
|
16
24
|
export declare function searchError(reason: string, span: Span, alternative?: string): never;
|
|
17
25
|
export declare function exhausted(budget: string): never;
|
|
@@ -15,6 +15,10 @@ export const SEARCH_QUERY = {
|
|
|
15
15
|
command: "search",
|
|
16
16
|
guide: "https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md",
|
|
17
17
|
};
|
|
18
|
+
/** How message search parses typed text: `"a b"~N` is words at most N apart; notes still refuse `~`. */
|
|
19
|
+
export const MESSAGE_PARSING = { ...SEARCH_QUERY, proximity: true };
|
|
20
|
+
/** Further apart than this, two words are rarely about one thing, even in a long message. */
|
|
21
|
+
export const MOST_WORDS_APART = 50;
|
|
18
22
|
export function searchError(reason, span, alternative) {
|
|
19
23
|
queryError(reason, span, alternative, SEARCH_QUERY);
|
|
20
24
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { implicitTextTerms, parseLucene, walkQuery } from "@wirecat/cli-base/query";
|
|
2
2
|
import { normalize } from "../store/normalize.js";
|
|
3
|
-
import {
|
|
3
|
+
import { MESSAGE_PARSING } from "./lucene/types.js";
|
|
4
4
|
export const words = (text) => normalize(text).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
5
5
|
const scaffolding = new Set(words("what which how many much who where when is are was were do does did can could should we the a an for of to under from in on at has have сколько какой какая какие какое кто кому кем где когда как что во по на в к с у через ли должен должна нужно идёт идет можно может могут"));
|
|
6
6
|
const questionWord = /\b(?:what|which|how|who|where|when|can|could|should)\b|(?:^|\s)(?:сколько|какой|какая|какие|какое|кто|кому|кем|где|когда|как|что|можно|может|могут|ли)(?:\s|$)/iu;
|
|
@@ -27,7 +27,7 @@ export function planQuestion(text, strict = false) {
|
|
|
27
27
|
removed: [],
|
|
28
28
|
aliases: [],
|
|
29
29
|
};
|
|
30
|
-
const inputAst = parseLucene(text,
|
|
30
|
+
const inputAst = parseLucene(text, MESSAGE_PARSING);
|
|
31
31
|
const rawTextual = walkQuery(inputAst.root).filter((node) => node.field === "text" || node.field === "exact");
|
|
32
32
|
const body = rawTextual.map((node) => node.value).join(" ");
|
|
33
33
|
const prefix = words(body).slice(0, 2);
|
|
@@ -66,7 +66,7 @@ export function planQuestion(text, strict = false) {
|
|
|
66
66
|
if (last?.operator === "wildcard" && text[mark] === "?")
|
|
67
67
|
cleaned = text.slice(0, mark) + text.slice(mark + 1);
|
|
68
68
|
}
|
|
69
|
-
const ast = cleaned === text ? inputAst : parseLucene(cleaned,
|
|
69
|
+
const ast = cleaned === text ? inputAst : parseLucene(cleaned, MESSAGE_PARSING);
|
|
70
70
|
const predicates = walkQuery(ast.root);
|
|
71
71
|
const textual = predicates.filter((p) => p.field === "text" || p.field === "exact");
|
|
72
72
|
const fields = predicates.filter((p) => p.field !== "text" && p.field !== "exact");
|
package/dist/services/excerpt.js
CHANGED
|
@@ -3,7 +3,10 @@ import { fold } from "../store/normalize.js";
|
|
|
3
3
|
export const queryWords = (query) => query
|
|
4
4
|
.toLowerCase()
|
|
5
5
|
.split(/\s+/)
|
|
6
|
-
.map((word) => word
|
|
6
|
+
.map((word) => word
|
|
7
|
+
.replace(/^[\p{L}_]+:/u, "")
|
|
8
|
+
.replace(/~\d+$/u, "")
|
|
9
|
+
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
|
|
7
10
|
.filter((word) => word.length > 2 && !["and", "or", "not"].includes(word));
|
|
8
11
|
/**
|
|
9
12
|
* About `size` characters of `text` on one line, around the first word of `query` it holds, or its start when it
|
package/dist/services/index.d.ts
CHANGED
|
@@ -65,8 +65,10 @@ export type { InboxService } from "./inbox.js";
|
|
|
65
65
|
export { inboxService } from "./inbox.js";
|
|
66
66
|
export { readMail } from "./mail-search.js";
|
|
67
67
|
export { type MeetingHitSearchRequest, refreshMeetingIndex, searchMeetingHits } from "./meeting-hit-search.js";
|
|
68
|
+
export { type MeetingLink, type MeetingLinksRequest, readMeetingLinks } from "./meeting-links.js";
|
|
68
69
|
export { createMeetingProjectCandidateReader } from "./meeting-project-candidates.js";
|
|
69
70
|
export { readMeetingProjectDiscovery } from "./meeting-project-discovery.js";
|
|
71
|
+
export { type MeetingReviewRequest, readMeetingReview } from "./meeting-review.js";
|
|
70
72
|
export { type MeetingStatsRequest, readMeetingStats } from "./meeting-stats.js";
|
|
71
73
|
export { chatIdIn, storedChatId } from "./message-resolution.js";
|
|
72
74
|
export type { AroundWindow, FoundMessage, ListWindow, MessagesService, MessageTarget, Pinned, Reacted, SearchFound, SearchQuery, SendRequest, Starred, } from "./messages.js";
|
package/dist/services/index.js
CHANGED
|
@@ -46,8 +46,10 @@ export { foldersService } from "./folders.js";
|
|
|
46
46
|
export { inboxService } from "./inbox.js";
|
|
47
47
|
export { readMail } from "./mail-search.js";
|
|
48
48
|
export { refreshMeetingIndex, searchMeetingHits } from "./meeting-hit-search.js";
|
|
49
|
+
export { readMeetingLinks } from "./meeting-links.js";
|
|
49
50
|
export { createMeetingProjectCandidateReader } from "./meeting-project-candidates.js";
|
|
50
51
|
export { readMeetingProjectDiscovery } from "./meeting-project-discovery.js";
|
|
52
|
+
export { readMeetingReview } from "./meeting-review.js";
|
|
51
53
|
export { readMeetingStats } from "./meeting-stats.js";
|
|
52
54
|
export { chatIdIn, storedChatId } from "./message-resolution.js";
|
|
53
55
|
export { DELETE_AT_ONCE, messagesService } from "./messages.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MeetingChatLinesInput, MeetingHitSearchRequest } from "../meetings/index.js";
|
|
2
|
+
import type { MessageStore } from "../store/store.js";
|
|
3
|
+
export type MeetingLinksRequest = Omit<MeetingHitSearchRequest, "limit" | "after" | "maxReadBytes"> & Pick<MeetingChatLinesInput, "maxMessages">;
|
|
4
|
+
/** One link as the meetings' chat holds it: where and how often it was shared. */
|
|
5
|
+
export interface MeetingLink {
|
|
6
|
+
url: string;
|
|
7
|
+
domain: string | null;
|
|
8
|
+
/** The Google Docs, Sheets, Slides, Forms or Drive file it names. */
|
|
9
|
+
documentId: string | null;
|
|
10
|
+
count: number;
|
|
11
|
+
firstAt: number;
|
|
12
|
+
lastAt: number;
|
|
13
|
+
senders: string[];
|
|
14
|
+
meetings: {
|
|
15
|
+
meetingId: number;
|
|
16
|
+
accountId: number;
|
|
17
|
+
title: string | null;
|
|
18
|
+
}[];
|
|
19
|
+
}
|
|
20
|
+
/** Links shared in the chat of the selected meetings, most shared first; found when read, not stored. */
|
|
21
|
+
export declare const readMeetingLinks: (store: Pick<MessageStore, "meetings"> & {
|
|
22
|
+
knowledge?: MessageStore["knowledge"];
|
|
23
|
+
}, request: MeetingLinksRequest) => Promise<{
|
|
24
|
+
items: MeetingLink[];
|
|
25
|
+
messages: number;
|
|
26
|
+
meetings: number;
|
|
27
|
+
selection: {
|
|
28
|
+
kind: import("../store/index.js").VocabularyKind;
|
|
29
|
+
id: number;
|
|
30
|
+
name: string;
|
|
31
|
+
by: "explicit-link";
|
|
32
|
+
}[];
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { googleDocumentId } from "../domain/google-documents.js";
|
|
2
|
+
import { findMentions } from "../domain/mentions.js";
|
|
3
|
+
import { resolveMeetingSelection } from "./meeting-hit-search.js";
|
|
4
|
+
/** Links shared in the chat of the selected meetings, most shared first; found when read, not stored. */
|
|
5
|
+
export const readMeetingLinks = async (store, request) => {
|
|
6
|
+
const { maxMessages, ...selecting } = request;
|
|
7
|
+
const { input, selection } = await resolveMeetingSelection(store, selecting);
|
|
8
|
+
const lines = await store.meetings.meetingChatLines({
|
|
9
|
+
...input,
|
|
10
|
+
...(maxMessages === undefined ? {} : { maxMessages }),
|
|
11
|
+
});
|
|
12
|
+
const links = new Map();
|
|
13
|
+
for (const line of lines)
|
|
14
|
+
for (const found of findMentions(line.text)) {
|
|
15
|
+
if (found.kind !== "url")
|
|
16
|
+
continue;
|
|
17
|
+
const link = links.get(found.value) ?? {
|
|
18
|
+
url: found.value,
|
|
19
|
+
domain: found.domain,
|
|
20
|
+
documentId: googleDocumentId(found.value),
|
|
21
|
+
count: 0,
|
|
22
|
+
firstAt: line.sentAt,
|
|
23
|
+
lastAt: line.sentAt,
|
|
24
|
+
senders: [],
|
|
25
|
+
meetings: [],
|
|
26
|
+
};
|
|
27
|
+
link.count += 1;
|
|
28
|
+
link.lastAt = line.sentAt;
|
|
29
|
+
if (line.sender !== null && !link.senders.includes(line.sender))
|
|
30
|
+
link.senders.push(line.sender);
|
|
31
|
+
if (!link.meetings.some((one) => one.meetingId === line.meetingId))
|
|
32
|
+
link.meetings.push({ meetingId: line.meetingId, accountId: line.accountId, title: line.title });
|
|
33
|
+
links.set(found.value, link);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
items: [...links.values()].sort((a, b) => b.count - a.count || b.lastAt - a.lastAt),
|
|
37
|
+
messages: lines.length,
|
|
38
|
+
meetings: new Set(lines.map((line) => line.meetingId)).size,
|
|
39
|
+
selection,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MeetingHitSearchRequest, MeetingReview } from "../meetings/index.js";
|
|
2
|
+
import type { MessageStore } from "../store/store.js";
|
|
3
|
+
import { resolveMeetingSelection } from "./meeting-hit-search.js";
|
|
4
|
+
export type MeetingReviewRequest = Omit<MeetingHitSearchRequest, "limit" | "after" | "maxReadBytes"> & {
|
|
5
|
+
limit?: number;
|
|
6
|
+
};
|
|
7
|
+
/** The newest meetings of the selection with their summaries, talk and projects; names resolved as search does. */
|
|
8
|
+
export declare const readMeetingReview: (store: Pick<MessageStore, "meetings"> & {
|
|
9
|
+
knowledge?: MessageStore["knowledge"];
|
|
10
|
+
}, request: MeetingReviewRequest) => Promise<MeetingReview & {
|
|
11
|
+
selection: Awaited<ReturnType<typeof resolveMeetingSelection>>["selection"];
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { resolveMeetingSelection } from "./meeting-hit-search.js";
|
|
2
|
+
/** The newest meetings of the selection with their summaries, talk and projects; names resolved as search does. */
|
|
3
|
+
export const readMeetingReview = async (store, request) => {
|
|
4
|
+
const { limit, ...selecting } = request;
|
|
5
|
+
const { input, selection } = await resolveMeetingSelection(store, selecting);
|
|
6
|
+
const review = await store.meetings.meetingReview({ ...input, ...(limit === undefined ? {} : { limit }) });
|
|
7
|
+
return { ...review, selection };
|
|
8
|
+
};
|
|
@@ -14,7 +14,7 @@ import { chatAmong, senderAmong } from "./message-resolution.js";
|
|
|
14
14
|
import { accountsOfKind, listedAccounts, MAIL } from "./search-kind.js";
|
|
15
15
|
const MAIL_FIELDS = new Set(["to", "cc", "bcc", "subject", "mailbox", "label", "is"]);
|
|
16
16
|
import { dateRange, parseLucene, timezoneOf, walkQuery } from "@wirecat/cli-base/query";
|
|
17
|
-
import {
|
|
17
|
+
import { MESSAGE_PARSING, searchError } from "../search/lucene/types.js";
|
|
18
18
|
const positiveSources = (node) => node.kind === "predicate"
|
|
19
19
|
? node.field === "in"
|
|
20
20
|
? [node.value.toLowerCase()]
|
|
@@ -47,7 +47,7 @@ export const prepareLucene = async (store, account, request, messenger) => {
|
|
|
47
47
|
span: { start: 0, end: 0 },
|
|
48
48
|
},
|
|
49
49
|
}
|
|
50
|
-
: validateFields(parseLucene(request.text, { ...
|
|
50
|
+
: validateFields(parseLucene(request.text, { ...MESSAGE_PARSING, defaultField: request.exact ? "exact" : "text" }));
|
|
51
51
|
const timezone = timezoneOf(request.timezone);
|
|
52
52
|
const stored = await store.accounts.accounts();
|
|
53
53
|
// A fresh store has no row yet for the account it runs as; "every account" still means that one.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError, singleLine } from "@wirecat/cli-base";
|
|
2
2
|
import { parseLucene } from "@wirecat/cli-base/query";
|
|
3
3
|
import { validateAst, validateFields } from "../search/lucene/registry.js";
|
|
4
|
-
import {
|
|
4
|
+
import { MESSAGE_PARSING } from "../search/lucene/types.js";
|
|
5
5
|
import { isAdminSelection, readAdminSelection } from "./admin-statistics.js";
|
|
6
6
|
import { searchRecordOf } from "./search-record.js";
|
|
7
7
|
/** A saved search or a run as every searches command answers it. */
|
|
@@ -31,7 +31,7 @@ const checked = (params) => {
|
|
|
31
31
|
if (params.ast !== undefined)
|
|
32
32
|
validateAst(params.ast);
|
|
33
33
|
else if (params.text !== undefined)
|
|
34
|
-
validateFields(parseLucene(params.text, { ...
|
|
34
|
+
validateFields(parseLucene(params.text, { ...MESSAGE_PARSING, defaultField: params.exact ? "exact" : "text" }));
|
|
35
35
|
return params;
|
|
36
36
|
};
|
|
37
37
|
const named = (search, check) => {
|
|
@@ -52,7 +52,7 @@ const both = (stored, words) => ({
|
|
|
52
52
|
kind: "boolean",
|
|
53
53
|
clauses: [
|
|
54
54
|
{ occur: "must", node: stored.root },
|
|
55
|
-
{ occur: "must", node: parseLucene(words,
|
|
55
|
+
{ occur: "must", node: parseLucene(words, MESSAGE_PARSING).root },
|
|
56
56
|
],
|
|
57
57
|
span: { start: 0, end: 0 },
|
|
58
58
|
},
|
|
@@ -29,9 +29,16 @@ export interface Corpus {
|
|
|
29
29
|
item: string;
|
|
30
30
|
thread: string;
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* A voice note's transcript is its attachment's text, whoever wrote it: speech recognition (`speech:<model>`) or an
|
|
34
|
+
* agent, which writes the text of a voice attachment as `agent`. The voice attachment is the one
|
|
35
|
+
* `intoVoiceAttachment` picks.
|
|
36
|
+
*/
|
|
37
|
+
export declare const speech: (alias: string) => string;
|
|
38
|
+
export declare const SPEECH: string;
|
|
34
39
|
export declare const quoted: (value: string) => string;
|
|
40
|
+
/** A leaf's words as an FTS5 match: a phrase, or with `~N` its words at most N apart, in any order. */
|
|
41
|
+
export declare const ftsMatch: (node: ResolvedPredicate) => string;
|
|
35
42
|
export declare const combine: (parts: Fragment[], operator: "AND" | "OR") => Fragment;
|
|
36
43
|
export declare const prefixOf: (pattern: string) => string;
|
|
37
44
|
export declare const wordMatch: (corpus: Corpus, match: string) => Fragment;
|
|
@@ -50,7 +57,7 @@ export interface FieldScope {
|
|
|
50
57
|
sql: string;
|
|
51
58
|
params: SqlValue[];
|
|
52
59
|
};
|
|
53
|
-
everywhere: (node: ResolvedPredicate,
|
|
60
|
+
everywhere: (node: ResolvedPredicate, fragment: Fragment) => Fragment;
|
|
54
61
|
/** Counts the patterns' expanded words against the query's budget. */
|
|
55
62
|
expand: (count: number) => number;
|
|
56
63
|
}
|
|
@@ -8,12 +8,25 @@ import { parseBytes } from "../../search/lucene/registry.js";
|
|
|
8
8
|
import { isStemmed } from "../../search/lucene/resolved.js";
|
|
9
9
|
import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
|
|
10
10
|
import { inSource } from "../../search/query.js";
|
|
11
|
+
import { STEM_SEPARATOR } from "../../search/stem.js";
|
|
11
12
|
import { normalize } from "../normalize.js";
|
|
12
13
|
import { linkMatch } from "./mention-links.js";
|
|
13
14
|
import { valueMatch } from "./phone-countries.js";
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* A voice note's transcript is its attachment's text, whoever wrote it: speech recognition (`speech:<model>`) or an
|
|
17
|
+
* agent, which writes the text of a voice attachment as `agent`. The voice attachment is the one
|
|
18
|
+
* `intoVoiceAttachment` picks.
|
|
19
|
+
*/
|
|
20
|
+
export const speech = (alias) => `(coalesce(${alias}.extractor,'') LIKE 'speech:%' OR ${alias}.kind IN ('voice','video_note') OR coalesce(${alias}.mime,'')='audio/ogg')`;
|
|
21
|
+
export const SPEECH = speech("att");
|
|
16
22
|
export const quoted = (value) => `"${value.replaceAll('"', '""')}"`;
|
|
23
|
+
/** A leaf's words as an FTS5 match: a phrase, or with `~N` its words at most N apart, in any order. */
|
|
24
|
+
export const ftsMatch = (node) => {
|
|
25
|
+
const text = normalize(node.value);
|
|
26
|
+
const words = node.slop === undefined ? [] : (text.match(/[\p{L}\p{N}\p{Co}\p{M}]+/gu) ?? []);
|
|
27
|
+
return words.length > 1 ? nearMatch(words, node.slop) : quoted(text);
|
|
28
|
+
};
|
|
29
|
+
const nearMatch = (words, apart) => `NEAR(${words.map(quoted).join(" ")}, ${apart})`;
|
|
17
30
|
export const combine = (parts, operator) => ({
|
|
18
31
|
sql: parts.length ? `(${parts.map(({ sql }) => sql).join(` ${operator} `)})` : operator === "AND" ? "1" : "0",
|
|
19
32
|
params: parts.flatMap(({ params }) => params),
|
|
@@ -59,20 +72,79 @@ const mentioned = ({ mail }, where, params) => mail
|
|
|
59
72
|
: bound(`m.id IN (SELECT mn.source_id FROM mentions mn WHERE mn.source_type='message' AND ${where} ` +
|
|
60
73
|
"UNION SELECT a.attachable_id FROM mentions mn JOIN attachments a ON a.id=mn.source_id " +
|
|
61
74
|
`AND a.attachable_type='message' WHERE mn.source_type='attachment' AND ${where})`, ...params, ...params);
|
|
62
|
-
const stemsOf = ({ execution },
|
|
75
|
+
const stemsOf = ({ execution: { stemmer } }, node) => {
|
|
76
|
+
if (node.slop === undefined)
|
|
77
|
+
return stemmer?.phrases(node.value);
|
|
78
|
+
const sequences = (stemmer?.stemSequences(node.value) ?? []).filter((sequence) => sequence.length);
|
|
79
|
+
return sequences.length
|
|
80
|
+
? sequences
|
|
81
|
+
.map((sequence) => (sequence.length > 1 ? nearMatch(sequence, node.slop) : quoted(sequence.join(""))))
|
|
82
|
+
.join(" OR ")
|
|
83
|
+
: undefined;
|
|
84
|
+
};
|
|
85
|
+
/** Whether some stem sequence of a text holds every stem of `wanted` with at most `apart` other words between. */
|
|
86
|
+
const near = (sequence, wanted, apart) => {
|
|
87
|
+
const events = sequence.flatMap((stem, index) => (wanted.includes(stem) ? [{ index, stem }] : []));
|
|
88
|
+
const held = new Map();
|
|
89
|
+
const distinct = new Set(wanted).size;
|
|
90
|
+
let left = 0;
|
|
91
|
+
for (const event of events) {
|
|
92
|
+
held.set(event.stem, (held.get(event.stem) ?? 0) + 1);
|
|
93
|
+
while (held.size === distinct) {
|
|
94
|
+
const first = events[left];
|
|
95
|
+
if (event.index - first.index + 1 - distinct <= apart)
|
|
96
|
+
return true;
|
|
97
|
+
const count = (held.get(first.stem) ?? 1) - 1;
|
|
98
|
+
if (count)
|
|
99
|
+
held.set(first.stem, count);
|
|
100
|
+
else
|
|
101
|
+
held.delete(first.stem);
|
|
102
|
+
left++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* `NEAR` over the stems can bridge `STEM_SEPARATOR`, which parts a text's Latin readings: the end of one and the
|
|
109
|
+
* start of the next are one word apart. A row without the separator is the index's answer; one with it is read.
|
|
110
|
+
*/
|
|
111
|
+
const nearStems = (scope, node, stems) => {
|
|
112
|
+
const { database, corpus, execution } = scope;
|
|
113
|
+
const stemmer = execution.stemmer;
|
|
114
|
+
const rows = database
|
|
115
|
+
.prepare(`SELECT m.id AS id, m.text AS text FROM ${corpus.rows} m WHERE m.id IN (SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ?) LIMIT ?`)
|
|
116
|
+
.all(`stems : ((${stems}) AND ${quoted(STEM_SEPARATOR)})`, QUERY_LIMITS.candidates + 1);
|
|
117
|
+
if (rows.length > QUERY_LIMITS.candidates)
|
|
118
|
+
exhausted("candidate rows");
|
|
119
|
+
const wanted = stemmer.stemSequences(node.value).filter((sequence) => sequence.length > 1);
|
|
120
|
+
const kept = rows.flatMap(({ id, text }) => {
|
|
121
|
+
scope.check();
|
|
122
|
+
const sequences = stemmer.stemSequences(String(text));
|
|
123
|
+
return wanted.some((query) => sequences.some((sequence) => near(sequence, query, node.slop)))
|
|
124
|
+
? [Number(id)]
|
|
125
|
+
: [];
|
|
126
|
+
});
|
|
127
|
+
return bound(`m.id IN (SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ?) OR m.id IN (SELECT value FROM json_each(?))`, `stems : ((${stems}) NOT ${quoted(STEM_SEPARATOR)})`, JSON.stringify(kept));
|
|
128
|
+
};
|
|
63
129
|
const stemmedText = (node, scope) => {
|
|
64
130
|
const { execution, corpus } = scope;
|
|
65
131
|
if (!execution.stemmer && !execution.unstemmed)
|
|
66
132
|
throw new Error("a stemmed leaf reached the compiler without the store's stemmer");
|
|
67
133
|
const text = normalize(node.value);
|
|
68
|
-
const
|
|
134
|
+
const match = ftsMatch(node);
|
|
135
|
+
const stems = stemsOf(scope, node);
|
|
136
|
+
const byStems = stems && node.slop !== undefined
|
|
137
|
+
? nearStems(scope, node, stems)
|
|
138
|
+
: stems
|
|
139
|
+
? bound(`m.id IN (SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ?)`, `stems : (${stems})`)
|
|
140
|
+
: undefined;
|
|
69
141
|
// Words OR stems: two spellings can fold one word to different stems, and every exact hit must stay.
|
|
70
142
|
return always(/[\p{L}\p{N}]/u.test(text)
|
|
71
|
-
? scope.everywhere(node,
|
|
72
|
-
sql: `m.id IN (SELECT rowid FROM ${corpus.words} WHERE ${corpus.words} MATCH ?)${
|
|
73
|
-
params: [`normalized_text : (${
|
|
143
|
+
? scope.everywhere(node, {
|
|
144
|
+
sql: `m.id IN (SELECT rowid FROM ${corpus.words} WHERE ${corpus.words} MATCH ?)${byStems ? ` OR ${byStems.sql}` : ""}`,
|
|
145
|
+
params: [`normalized_text : (${match})`, ...(byStems?.params ?? [])],
|
|
74
146
|
exact: true,
|
|
75
|
-
fts:
|
|
147
|
+
fts: match,
|
|
76
148
|
...(stems ? { stems } : {}),
|
|
77
149
|
})
|
|
78
150
|
: bound("0"));
|
|
@@ -86,9 +158,9 @@ const words = (node, scope) => {
|
|
|
86
158
|
return stemmedText(node, scope);
|
|
87
159
|
if (operator === "term" || operator === "phrase") {
|
|
88
160
|
const text = normalize(value);
|
|
89
|
-
const stems = stemsOf(scope,
|
|
161
|
+
const stems = stemsOf(scope, node);
|
|
90
162
|
return always(/[\p{L}\p{N}]/u.test(text)
|
|
91
|
-
? scope.everywhere(node,
|
|
163
|
+
? scope.everywhere(node, { ...wordMatch(corpus, ftsMatch(node)), ...(stems ? { stems } : {}) })
|
|
92
164
|
: bound("0"));
|
|
93
165
|
}
|
|
94
166
|
const pattern = operator === "wildcard" ? wildcardPattern(normalize(value)) : foldRegex(value, node.span);
|
|
@@ -181,10 +253,10 @@ const size = (node, { corpus }) => {
|
|
|
181
253
|
const range = combine(conditions, "AND");
|
|
182
254
|
return always(bound(`EXISTS (SELECT 1 FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id=m.id AND att.size IS NOT NULL AND ${range.sql})`, ...range.params));
|
|
183
255
|
};
|
|
184
|
-
const attachmentText = (which) => (
|
|
185
|
-
const text = normalize(value);
|
|
256
|
+
const attachmentText = (which) => (node, scope) => {
|
|
257
|
+
const text = normalize(node.value);
|
|
186
258
|
// Never `fts`: the ranking ANDs every required word into message_words, where a file's words are not.
|
|
187
|
-
const set = scope.attachmentWords(
|
|
259
|
+
const set = scope.attachmentWords(ftsMatch(node), which);
|
|
188
260
|
return always(/[\p{L}\p{N}]/u.test(text) ? bound(`m.id IN (${set.sql})`, ...set.params) : bound("0"));
|
|
189
261
|
};
|
|
190
262
|
const date = (node) => {
|