feat: post search filters

Co-authored-by: sup39 <dev@sup39.dev>
This commit is contained in:
naskya 2024-03-01 22:17:02 +09:00
parent 48e5d9de71
commit b30e68c98c
No known key found for this signature in database
GPG key ID: 712D413B3A9FED5C
15 changed files with 531 additions and 174 deletions

View file

@ -4,7 +4,12 @@ Breaking changes are indicated by the :warning: icon.
## Unreleased ## Unreleased
- `admin/search/index-all` is removed since posts are now indexed automatically. - :warning: `admin/search/index-all` is removed since posts are now indexed automatically.
- New optional parameters are added to `notes/search` endpoint:
- `sinceDate`
- `untilDate`
- `withFiles`
- `searchCwAndAlt`
## v20240301 ## v20240301

View file

@ -4,7 +4,7 @@ Critical security updates are indicated by the :warning: icon.
## Unreleased ## Unreleased
- Introduce new full-text search engine - Introduce new full-text search engine and post search filters
## v20240301 ## v20240301

View file

@ -1,6 +1,7 @@
BEGIN; BEGIN;
DELETE FROM "migrations" WHERE name IN ( DELETE FROM "migrations" WHERE name IN (
'IndexAltTextAndCw1708872574733',
'Pgroonga1698420787202', 'Pgroonga1698420787202',
'ChangeDefaultConfigs1709251460718', 'ChangeDefaultConfigs1709251460718',
'AddReplyMuting1704851359889', 'AddReplyMuting1704851359889',
@ -13,6 +14,10 @@ DELETE FROM "migrations" WHERE name IN (
'RemoveNativeUtilsMigration1705877093218' 'RemoveNativeUtilsMigration1705877093218'
); );
-- index-alt-text-and-cw
DROP INDEX "IDX_f4f7b93d05958527300d79ac82";
DROP INDEX "IDX_8e3bbbeb3df04d1a8105da4c8f";
-- pgroonga -- pgroonga
DROP INDEX "IDX_f27f5d88941e57442be75ba9c8"; DROP INDEX "IDX_f27f5d88941e57442be75ba9c8";
DROP INDEX "IDX_065d4d8f3b5adb4a08841eae3c"; DROP INDEX "IDX_065d4d8f3b5adb4a08841eae3c";

View file

@ -1181,6 +1181,14 @@ pullDownToReload: "Pull down to reload"
releaseToReload: "Release to reload" releaseToReload: "Release to reload"
reloading: "Reloading" reloading: "Reloading"
enableTimelineStreaming: "Update timelines automatically" enableTimelineStreaming: "Update timelines automatically"
searchWords: "Words to search / ID or URL to lookup"
searchWordsDescription: "To search for posts, enter the search term. Separate words with a space for an AND search, or 'OR' (without quotes) between words for an OR search.\nFor example, 'morning night' will find posts that contain both 'morning' and 'night', and 'morning OR night' will find posts that contain either 'morning' or 'night' (or both).\nYou can also combine AND/OR conditions like '(morning OR night) sleepy'.\n\nIf you want to go to a specific user page or post page, enter the ID or URL in this field and click the 'Lookup' button. Clicking 'Search' will search for posts that literally contain the ID/URL."
searchUsers: "Posted by (optional)"
searchUsersDescription: "To search for posts by a specific user/server, enter the ID (@user@example.com, or @user for a local user) or domain name (example.com).\n\nIf you enter 'me' (without quotes), all of your posts (including unlisted, followers-only, direct, and secret posts) will be searched.\n\nIf you enter 'local' (without quotes), the results will be filtered to include only posts from this server."
searchRange: "Posted within (optional)"
searchRangeDescription: "If you want to filter the time period, enter it in this format: 20220615-20231031\n\nIf you leave out the year (like 0105-0106 or 20231105-0110), it's interpreted as the current year.\n\nYou can also omit either the start or end date. For example, -0102 will filter the search results to show only posts made before 2 January this year, and 20231026- will filter the results to show only posts made after 26 October 2023."
searchPostsWithFiles: "Only posts with files"
searchCwAndAlt: "Include content warnings and file descriptions"
_emojiModPerm: _emojiModPerm:
unauthorized: "None" unauthorized: "None"

View file

@ -1001,6 +1001,14 @@ pullDownToReload: "下に引っ張って再読み込み"
releaseToReload: "離して再読み込み" releaseToReload: "離して再読み込み"
reloading: "読み込み中" reloading: "読み込み中"
enableTimelineStreaming: "タイムラインを自動で更新する" enableTimelineStreaming: "タイムラインを自動で更新する"
searchWords: "検索語句・照会するIDやURL"
searchWordsDescription: "投稿を検索するには、ここに検索語句を入力してください。空白区切りでAND検索になり、ORを挟むとOR検索になります。\n例えば「朝 夜」と入力すると「朝」と「夜」が両方含まれた投稿を検索し、「朝 OR 夜」と入力すると「朝」または「夜」(または両方)が含まれた投稿を検索します。\n「(朝 OR 夜) 眠い」のように、AND検索とOR検索を同時に行うこともできます。\n\n特定のユーザーや投稿のページに飛びたい場合には、この欄にID (@user@example.com) や投稿のURLを入力し「照会」を押してください。「検索」を押すとそのIDやURLが文字通り含まれる投稿を検索します。"
searchUsers: "投稿元(オプション)"
searchUsersDescription: "投稿検索で投稿者を絞りたい場合、@user@example.comローカルユーザーなら @userの形式で投稿者のIDを入力してください。ユーザーIDではなくドメイン名 (example.com) を指定すると、そのサーバーの投稿を検索します。\n\nme とだけ入力すると、自分の投稿を検索します。この検索結果には未収載・フォロワー限定・ダイレクト・秘密を含む全ての投稿が含まれます。\n\nlocal とだけ入力すると、ローカルサーバーの投稿を検索します。"
searchRange: "投稿期間(オプション)"
searchRangeDescription: "投稿検索で投稿期間を絞りたい場合、20220615-20231031 のような形式で投稿期間を入力してください。今年の日付を指定する場合には年の指定を省略できます0105-0106 や 20231105-0110 のように)。\n\n開始日と終了日のどちらか一方は省略可能です。例えば -0102 とすると今年1月2日までの投稿のみを、20231026- とすると2023年10月26日以降の投稿のみを検索します。"
searchPostsWithFiles: "添付ファイルのある投稿のみ"
searchCwAndAlt: "閲覧注意の注釈と添付ファイルの代替テキストも検索する"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。" description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。"

View file

@ -0,0 +1,17 @@
export class IndexAltTextAndCw1708872574733 {
name = "IndexAltTextAndCw1708872574733";
async up(queryRunner) {
await queryRunner.query(
`CREATE INDEX "IDX_8e3bbbeb3df04d1a8105da4c8f" ON "note" USING "pgroonga" ("cw" pgroonga_varchar_full_text_search_ops_v2)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_f4f7b93d05958527300d79ac82" ON "drive_file" USING "pgroonga" ("comment" pgroonga_varchar_full_text_search_ops_v2)`,
);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "IDX_f4f7b93d05958527300d79ac82"`);
await queryRunner.query(`DROP INDEX "IDX_8e3bbbeb3df04d1a8105da4c8f"`);
}
}

View file

@ -1,3 +1,4 @@
import { Brackets } from "typeorm";
import { Notes } from "@/models/index.js"; import { Notes } from "@/models/index.js";
import { Note } from "@/models/entities/note.js"; import { Note } from "@/models/entities/note.js";
import define from "@/server/api/define.js"; import define from "@/server/api/define.js";
@ -34,6 +35,8 @@ export const paramDef = {
query: { type: "string" }, query: { type: "string" },
sinceId: { type: "string", format: "misskey:id" }, sinceId: { type: "string", format: "misskey:id" },
untilId: { type: "string", format: "misskey:id" }, untilId: { type: "string", format: "misskey:id" },
sinceDate: { type: "number", nullable: true },
untilDate: { type: "number", nullable: true },
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
offset: { type: "integer", default: 0 }, offset: { type: "integer", default: 0 },
host: { host: {
@ -47,6 +50,8 @@ export const paramDef = {
nullable: true, nullable: true,
default: null, default: null,
}, },
withFiles: { type: "boolean", nullable: true },
searchCwAndAlt: { type: "boolean", nullable: true },
channelId: { channelId: {
type: "string", type: "string",
format: "misskey:id", format: "misskey:id",
@ -68,6 +73,8 @@ export default define(meta, paramDef, async (ps, me) => {
Notes.createQueryBuilder("note"), Notes.createQueryBuilder("note"),
ps.sinceId, ps.sinceId,
ps.untilId, ps.untilId,
ps.sinceDate ?? undefined,
ps.untilDate ?? undefined,
); );
if (ps.userId != null) { if (ps.userId != null) {
@ -80,11 +87,57 @@ export default define(meta, paramDef, async (ps, me) => {
}); });
} }
if (ps.query != null) {
const q = sqlLikeEscape(ps.query);
if (ps.searchCwAndAlt) {
query.andWhere(
new Brackets((qb) => {
qb.where("note.text &@~ :q", { q })
.orWhere("note.cw &@~ :q", { q })
.orWhere(
`EXISTS (
SELECT FROM "drive_file"
WHERE
comment &@~ :q
AND
drive_file."id" = ANY(note."fileIds")
)`,
{ q },
);
}),
);
} else {
query.andWhere("note.text &@~ :q", { q });
}
}
query.innerJoinAndSelect("note.user", "user");
// "from: me": search all (public, home, followers, specified) my posts
// otherwise: search public indexable posts only
if (ps.userId == null || ps.userId !== me?.id) {
query query
.andWhere("note.text &@~ :q", { q: `${sqlLikeEscape(ps.query)}` })
.andWhere("note.visibility = 'public'") .andWhere("note.visibility = 'public'")
.innerJoinAndSelect("note.user", "user") .andWhere("user.isIndexable = TRUE");
.andWhere("user.isIndexable = TRUE") }
if (ps.userId != null) {
query.andWhere("note.userId = :userId", { userId: ps.userId });
}
if (ps.host === null) {
query.andWhere("note.userHost IS NULL");
}
if (ps.host != null) {
query.andWhere("note.userHost = :userHost", { userHost: ps.host });
}
if (ps.withFiles === true) {
query.andWhere("note.fileIds != '{}'");
}
query
.leftJoinAndSelect("user.avatar", "avatar") .leftJoinAndSelect("user.avatar", "avatar")
.leftJoinAndSelect("user.banner", "banner") .leftJoinAndSelect("user.banner", "banner")
.leftJoinAndSelect("note.reply", "reply") .leftJoinAndSelect("note.reply", "reply")

View file

@ -65,6 +65,7 @@
"libopenmpt-wasm": "github:TheEssem/libopenmpt-packaging#build", "libopenmpt-wasm": "github:TheEssem/libopenmpt-packaging#build",
"matter-js": "0.19.0", "matter-js": "0.19.0",
"mfm-js": "0.24.0", "mfm-js": "0.24.0",
"moment": "2.30.1",
"photoswipe": "5.4.3", "photoswipe": "5.4.3",
"prettier": "3.2.5", "prettier": "3.2.5",
"prismjs": "1.29.0", "prismjs": "1.29.0",

View file

@ -101,15 +101,6 @@
" "
/> />
</template> </template>
<template v-if="input.type === 'search'" #suffix>
<button
v-tooltip.noDelay="i18n.ts.filter"
class="_buttonIcon"
@click.stop="openSearchFilters"
>
<i :class="iconClass('ph-funnel', false)"></i>
</button>
</template>
</MkInput> </MkInput>
<MkTextarea <MkTextarea
v-if="input && input.type === 'paragraph'" v-if="input && input.type === 'paragraph'"
@ -378,117 +369,6 @@ function appendFilter(value: string) {
); );
} }
async function openSearchFilters(ev) {
await os.popupMenu(
[
{
icon: `${iconClass("ph-user")}`,
text: i18n.ts._filters.fromUser,
action: () => {
os.selectUser().then((user) => {
inputValue.value = appendFilter(
"from:@" + acct.toString(user),
);
});
},
},
{
type: "parent",
text: i18n.ts._filters.withFile,
icon: `${iconClass("ph-paperclip")}`,
children: [
{
text: i18n.ts.image,
icon: `${iconClass("ph-image-square")}`,
action: () => {
inputValue.value = appendFilter("has:image");
},
},
{
text: i18n.ts.video,
icon: `${iconClass("ph-video-camera")}`,
action: () => {
inputValue.value = appendFilter("has:video");
},
},
{
text: i18n.ts.audio,
icon: `${iconClass("ph-music-note")}`,
action: () => {
inputValue.value = appendFilter("has:audio");
},
},
{
text: i18n.ts.file,
icon: `${iconClass("ph-file")}`,
action: () => {
inputValue.value = appendFilter("has:file");
},
},
],
},
{
icon: `${iconClass("ph-link")}`,
text: i18n.ts._filters.fromDomain,
action: () => {
inputValue.value = appendFilter("domain:");
},
},
{
icon: `${iconClass("ph-calendar-blank")}`,
text: i18n.ts._filters.notesBefore,
action: () => {
os.inputDate({
title: i18n.ts._filters.notesBefore,
}).then((res) => {
if (res.canceled) return;
inputValue.value = appendFilter(
"before:" + formatDateToYYYYMMDD(res.result),
);
});
},
},
{
icon: `${iconClass("ph-calendar-blank")}`,
text: i18n.ts._filters.notesAfter,
action: () => {
os.inputDate({
title: i18n.ts._filters.notesAfter,
}).then((res) => {
if (res.canceled) return;
inputValue.value = appendFilter(
"after:" + formatDateToYYYYMMDD(res.result),
);
});
},
},
{
icon: `${iconClass("ph-eye")}`,
text: i18n.ts._filters.followingOnly,
action: () => {
inputValue.value = appendFilter("filter:following");
},
},
{
icon: `${iconClass("ph-users-three")}`,
text: i18n.ts._filters.followersOnly,
action: () => {
inputValue.value = appendFilter("filter:followers");
},
},
],
ev.target,
{ noReturnFocus: true },
);
inputEl.value?.focus();
if (typeof inputValue.value === "string") {
inputEl.value?.selectRange(
inputValue.value.length,
inputValue.value.length,
); // cursor at end
}
}
onMounted(() => { onMounted(() => {
document.addEventListener("keydown", onKeydown); document.addEventListener("keydown", onKeydown);
}); });

View file

@ -0,0 +1,248 @@
<template>
<MkModal
ref="modal"
:prefer-type="'dialog'"
@click="done(true)"
@closed="emit('closed')"
>
<div :class="$style.root">
<header :class="$style.title">
<i :class="icon('ph-magnifying-glass', false)"></i>
{{ i18n.ts.search }}
</header>
<MkInput
v-model="searchWords"
autofocus
type="search"
:placeholder="i18n.ts.searchWords"
:class="$style.input"
@keydown="onInputKeydown"
>
<template #suffix>
<button
v-tooltip.noDelay="i18n.ts.help"
class="_buttonIcon"
@click.stop="openDescription('words')"
>
<i :class="icon('ph-question', false)"></i>
</button>
</template>
</MkInput>
<MkInput
v-model="searchUsers"
type="search"
:placeholder="i18n.ts.searchUsers"
:class="$style.input"
@keydown="onInputKeydown"
>
<template #suffix>
<button
v-tooltip.noDelay="i18n.ts.help"
class="_buttonIcon"
@click.stop="openDescription('users')"
>
<i :class="icon('ph-question', false)"></i>
</button>
</template>
</MkInput>
<MkInput
v-model="searchRange"
type="search"
:placeholder="i18n.ts.searchRange"
:class="$style.input"
@keydown="onInputKeydown"
>
<template #suffix>
<button
v-tooltip.noDelay="i18n.ts.help"
class="_buttonIcon"
@click.stop="openDescription('range')"
>
<i :class="icon('ph-question', false)"></i>
</button>
</template>
</MkInput>
<FormSwitch
v-model="searchCwAndAlt"
class="form-switch"
:class="$style.input"
>{{ i18n.ts.searchCwAndAlt }}</FormSwitch
>
<FormSwitch
v-model="searchPostsWithFiles"
class="form-switch"
:class="$style.input"
>{{ i18n.ts.searchPostsWithFiles }}</FormSwitch
>
<div :class="$style.buttons">
<MkButton inline primary @click="search"
>{{ i18n.ts.search }}
</MkButton>
<MkButton inline @click="lookup">{{ i18n.ts.lookup }}</MkButton>
<MkButton inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
</div>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import {
defineAsyncComponent,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
} from "vue";
import MkModal from "@/components/MkModal.vue";
import MkButton from "@/components/MkButton.vue";
import MkInput from "@/components/form/input.vue";
import FormSwitch from "@/components/form/switch.vue";
import { i18n } from "@/i18n";
import icon from "@/scripts/icon";
import { popup } from "@/os";
type searchQuery =
| {
action: "lookup";
query: string;
}
| {
action: "search";
query?: string;
from?: string;
range?: string;
withFiles: boolean;
searchCwAndAlt: boolean;
};
const emit = defineEmits<{
(ev: "done", v: { canceled: boolean; result?: searchQuery }): void;
(ev: "closed"): void;
}>();
const modal = shallowRef<InstanceType<typeof MkModal>>();
const searchParams = new URLSearchParams(window.location.search);
const searchWords = ref(searchParams.get("q") ?? "");
const searchUsers = ref(
searchParams.get("user") ?? searchParams.get("host") ?? "",
);
const searchRange = ref(
searchParams.has("since") || searchParams.has("until")
? `${searchParams.get("since") ?? ""}-${
searchParams.get("until") ?? ""
}`
: "",
);
const searchPostsWithFiles = ref(searchParams.get("withFiles") === "1");
const searchCwAndAlt = ref(searchParams.get("detailed") !== "0");
function done(canceled: boolean, result?: searchQuery) {
emit("done", { canceled, result });
modal.value?.close(null);
}
function search() {
if (
searchWords.value === "" &&
searchUsers.value === "" &&
searchRange.value === ""
)
return;
done(false, {
action: "search",
query: searchWords.value === "" ? undefined : searchWords.value,
from: searchUsers.value === "" ? undefined : searchUsers.value,
range: searchRange.value === "" ? undefined : searchRange.value,
withFiles: searchPostsWithFiles.value,
searchCwAndAlt: searchCwAndAlt.value,
});
}
function lookup() {
if (searchWords.value === "") return;
done(false, {
action: "lookup",
query: searchWords.value,
});
}
function cancel() {
done(true);
}
function onKeydown(evt: KeyboardEvent) {
if (evt.key === "Escape") cancel();
}
function onInputKeydown(evt: KeyboardEvent) {
if (evt.key === "Enter") {
evt.preventDefault();
evt.stopPropagation();
search();
}
}
function openDescription(kind: "words" | "users" | "range"): void {
const descriptions = {
words: i18n.ts.searchWordsDescription,
users: i18n.ts.searchUsersDescription,
range: i18n.ts.searchRangeDescription,
};
popup(
defineAsyncComponent(
() => import("@/components/MkSimpleTextWindow.vue"),
),
{
title: i18n.ts.help,
description: descriptions[kind],
},
{},
"closed",
);
}
onMounted(() => {
document.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", onKeydown);
});
</script>
<style lang="scss" module>
.root {
position: relative;
margin: auto;
padding: 32px;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
}
.input {
margin: 10px 0;
}
.title {
margin: 0 0 25px;
font-weight: bold;
font-size: 1.3em;
}
.buttons {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
</style>

View file

@ -0,0 +1,43 @@
<template>
<XWindow
:initial-width="800"
:can-resize="true"
:front="true"
@closed="emit('closed')"
class="thppypvi"
>
<template #header>
{{ title }}
</template>
<div class="zrgnubda">
{{ description }}
</div>
</XWindow>
</template>
<script lang="ts" setup>
import XWindow from "@/components/MkWindow.vue";
defineProps<{
title: string;
description: string;
}>();
const emit = defineEmits<{
(ev: "closed"): void;
}>();
</script>
<style lang="scss" scoped>
.thppypvi {
max-height: 70%;
overflow-y: scroll;
}
.zrgnubda {
white-space: pre-wrap;
font-size: 1.2em;
padding: 5px 20px 10px;
margin: 5px;
}
</style>

View file

@ -43,6 +43,7 @@
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { Virtual } from "swiper/modules"; import { Virtual } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/vue"; import { Swiper, SwiperSlide } from "swiper/vue";
import moment from "moment";
import XNotes from "@/components/MkNotes.vue"; import XNotes from "@/components/MkNotes.vue";
import XUserList from "@/components/MkUserList.vue"; import XUserList from "@/components/MkUserList.vue";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
@ -50,19 +51,37 @@ import { definePageMetadata } from "@/scripts/page-metadata";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
import { deviceKind } from "@/scripts/device-kind"; import { deviceKind } from "@/scripts/device-kind";
import icon from "@/scripts/icon"; import icon from "@/scripts/icon";
import { $i } from "@/reactiveAccount";
import "swiper/scss"; import "swiper/scss";
import "swiper/scss/virtual"; import "swiper/scss/virtual";
import { api } from "@/os";
const props = defineProps<{ const props = defineProps<{
query: string; query?: string;
user?: string;
host?: string;
since?: string;
until?: string;
channel?: string; channel?: string;
withFiles: "0" | "1";
searchCwAndAlt: "0" | "1";
}>(); }>();
const userId = props.user == null ? undefined : await getUserId(props.user);
const notesPagination = { const notesPagination = {
endpoint: "notes/search" as const, endpoint: "notes/search" as const,
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
query: props.query, query: props.query ?? undefined,
userId,
host: props.host == null ? undefined : getHost(props.host),
sinceDate:
props.since == null ? undefined : getUnixTime(props.since, false),
untilDate:
props.until == null ? undefined : getUnixTime(props.until, true),
withFiles: props.withFiles === "1",
searchCwAndAlt: props.searchCwAndAlt === "1",
channelId: props.channel, channelId: props.channel,
})), })),
}; };
@ -76,6 +95,27 @@ const usersPagination = {
})), })),
}; };
async function getUserId(user: string): Promise<string> {
if (user === "me") return $i!.id;
const split = (user.startsWith("@") ? user.slice(1) : user).split("@");
const username = split[0];
const host = split.length === 1 ? undefined : split[1];
return (await api("users/show", { username, host })).id;
}
function getHost(host: string): string | null {
if (host === "local") return null;
return host;
}
function getUnixTime(date: string, nextDay: boolean): number {
return moment(date, date.length === 4 ? "MMDD" : "YYYYMMDD")
.add(nextDay ? 1 : 0, "days")
.valueOf();
}
const tabs = ["notes", "users"]; const tabs = ["notes", "users"];
const tab = ref(tabs[0]); const tab = ref(tabs[0]);
watch(tab, () => syncSlide(tabs.indexOf(tab.value))); watch(tab, () => syncSlide(tabs.indexOf(tab.value)));

View file

@ -306,7 +306,13 @@ export const routes = [
component: page(() => import("./pages/search.vue")), component: page(() => import("./pages/search.vue")),
query: { query: {
q: "query", q: "query",
user: "user",
host: "host",
since: "since",
until: "until",
withFiles: "withFiles",
channel: "channel", channel: "channel",
detailed: "searchCwAndAlt",
}, },
}, },
{ {

View file

@ -1,55 +1,59 @@
import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { api, popup, promiseDialog } from "@/os";
import { mainRouter } from "@/router"; import { mainRouter } from "@/router";
import { instance } from "@/instance"; import MkSearchBox from "@/components/MkSearchBox.vue";
export async function search() { export async function search() {
const { canceled, result: query } = await os.inputText({ const { canceled, result } = await new Promise<
type: instance.features.searchFilters ? "search" : "text", | { canceled: true; result: undefined }
title: i18n.ts.search, | {
placeholder: i18n.ts.searchPlaceholder, canceled: false;
}); result: {
if (canceled || query == null || query === "") return; action: "lookup";
query: string;
const q = query.trim(); };
if (q.startsWith("@") && !q.includes(" ")) {
mainRouter.push(`/${q}`);
return;
} }
| {
if (q.startsWith("#")) { canceled: false;
mainRouter.push(`/tags/${encodeURIComponent(q.slice(1))}`); result: {
return; action: "search";
query?: string;
from?: string;
range?: string;
withFiles: boolean;
searchCwAndAlt: boolean;
};
} }
>((resolve, _) => {
// like 2018/03/12 popup(
if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, "/"))) { MkSearchBox,
const date = new Date(q.replace(/-/g, "/")); {},
{
// 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは done: (result) => {
// 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので resolve(result ?? { canceled: true });
// 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の },
// 結果になってしまい、2018/03/12 のコンテンツは含まれない) },
if (q.replace(/-/g, "/").match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { "closed",
date.setHours(23, 59, 59, 999); );
}
// TODO
// v.$root.$emit('warp', date);
os.alert({
type: "waiting",
});
return;
}
if (q.startsWith("https://")) {
const promise = os.api("ap/show", {
uri: q,
}); });
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); if (canceled || result == null || result.query === "") return;
if (result.action === "lookup") {
if (result.query.startsWith("#")) {
mainRouter.push(`/tags/${encodeURIComponent(result.query.slice(1))}`);
return;
}
if (result.query.startsWith("@")) {
mainRouter.push(`/${result.query}`);
return;
}
if (result.query.startsWith("https://")) {
const promise = api("ap/show", {
uri: result.query,
});
promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
const res = await promise; const res = await promise;
if (res.type === "User") { if (res.type === "User") {
@ -61,5 +65,37 @@ export async function search() {
return; return;
} }
mainRouter.push(`/search?q=${encodeURIComponent(q)}`); // fallback
mainRouter.push(`/search?q=${encodeURIComponent(result.query)}`);
}
if (result.action === "search") {
const params = new URLSearchParams();
if (result.query != null) {
params.append("q", result.query);
}
if (result.from != null) {
if (result.from === "me" || result.from.includes("@"))
params.append("user", result.from);
else params.append("host", result.from);
}
if (result.range != null) {
const split = result.range.split("-");
if (split.length === 1) {
params.append("since", result.range);
params.append("until", result.range);
} else {
if (split[0] !== "") params.append("since", split[0]);
if (split[1] !== "") params.append("until", split[1]);
}
}
params.append("detailed", result.searchCwAndAlt ? "1" : "0");
params.append("withFiles", result.withFiles ? "1" : "0");
mainRouter.push(`/search?${params.toString()}`);
}
} }

View file

@ -731,6 +731,9 @@ importers:
mfm-js: mfm-js:
specifier: 0.24.0 specifier: 0.24.0
version: 0.24.0 version: 0.24.0
moment:
specifier: 2.30.1
version: 2.30.1
photoswipe: photoswipe:
specifier: 5.4.3 specifier: 5.4.3
version: 5.4.3 version: 5.4.3
@ -12809,6 +12812,10 @@ packages:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
dev: false dev: false
/moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
dev: true
/ms@2.0.0: /ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: false dev: false