feat: post search filters
Co-authored-by: sup39 <dev@sup39.dev>
This commit is contained in:
parent
48e5d9de71
commit
b30e68c98c
15 changed files with 531 additions and 174 deletions
|
@ -4,7 +4,12 @@ Breaking changes are indicated by the :warning: icon.
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ Critical security updates are indicated by the :warning: icon.
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Introduce new full-text search engine
|
||||
- Introduce new full-text search engine and post search filters
|
||||
|
||||
## v20240301
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
BEGIN;
|
||||
|
||||
DELETE FROM "migrations" WHERE name IN (
|
||||
'IndexAltTextAndCw1708872574733',
|
||||
'Pgroonga1698420787202',
|
||||
'ChangeDefaultConfigs1709251460718',
|
||||
'AddReplyMuting1704851359889',
|
||||
|
@ -13,6 +14,10 @@ DELETE FROM "migrations" WHERE name IN (
|
|||
'RemoveNativeUtilsMigration1705877093218'
|
||||
);
|
||||
|
||||
-- index-alt-text-and-cw
|
||||
DROP INDEX "IDX_f4f7b93d05958527300d79ac82";
|
||||
DROP INDEX "IDX_8e3bbbeb3df04d1a8105da4c8f";
|
||||
|
||||
-- pgroonga
|
||||
DROP INDEX "IDX_f27f5d88941e57442be75ba9c8";
|
||||
DROP INDEX "IDX_065d4d8f3b5adb4a08841eae3c";
|
||||
|
|
|
@ -1181,6 +1181,14 @@ pullDownToReload: "Pull down to reload"
|
|||
releaseToReload: "Release to reload"
|
||||
reloading: "Reloading"
|
||||
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:
|
||||
unauthorized: "None"
|
||||
|
|
|
@ -1001,6 +1001,14 @@ pullDownToReload: "下に引っ張って再読み込み"
|
|||
releaseToReload: "離して再読み込み"
|
||||
reloading: "読み込み中"
|
||||
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:
|
||||
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。"
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import { Brackets } from "typeorm";
|
||||
import { Notes } from "@/models/index.js";
|
||||
import { Note } from "@/models/entities/note.js";
|
||||
import define from "@/server/api/define.js";
|
||||
|
@ -34,6 +35,8 @@ export const paramDef = {
|
|||
query: { type: "string" },
|
||||
sinceId: { 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 },
|
||||
offset: { type: "integer", default: 0 },
|
||||
host: {
|
||||
|
@ -47,6 +50,8 @@ export const paramDef = {
|
|||
nullable: true,
|
||||
default: null,
|
||||
},
|
||||
withFiles: { type: "boolean", nullable: true },
|
||||
searchCwAndAlt: { type: "boolean", nullable: true },
|
||||
channelId: {
|
||||
type: "string",
|
||||
format: "misskey:id",
|
||||
|
@ -68,6 +73,8 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
Notes.createQueryBuilder("note"),
|
||||
ps.sinceId,
|
||||
ps.untilId,
|
||||
ps.sinceDate ?? undefined,
|
||||
ps.untilDate ?? undefined,
|
||||
);
|
||||
|
||||
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
|
||||
.andWhere("note.visibility = 'public'")
|
||||
.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
|
||||
.andWhere("note.text &@~ :q", { q: `${sqlLikeEscape(ps.query)}` })
|
||||
.andWhere("note.visibility = 'public'")
|
||||
.innerJoinAndSelect("note.user", "user")
|
||||
.andWhere("user.isIndexable = TRUE")
|
||||
.leftJoinAndSelect("user.avatar", "avatar")
|
||||
.leftJoinAndSelect("user.banner", "banner")
|
||||
.leftJoinAndSelect("note.reply", "reply")
|
||||
|
|
|
@ -65,6 +65,7 @@
|
|||
"libopenmpt-wasm": "github:TheEssem/libopenmpt-packaging#build",
|
||||
"matter-js": "0.19.0",
|
||||
"mfm-js": "0.24.0",
|
||||
"moment": "2.30.1",
|
||||
"photoswipe": "5.4.3",
|
||||
"prettier": "3.2.5",
|
||||
"prismjs": "1.29.0",
|
||||
|
|
|
@ -101,15 +101,6 @@
|
|||
"
|
||||
/>
|
||||
</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>
|
||||
<MkTextarea
|
||||
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(() => {
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
|
248
packages/client/src/components/MkSearchBox.vue
Normal file
248
packages/client/src/components/MkSearchBox.vue
Normal 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>
|
43
packages/client/src/components/MkSimpleTextWindow.vue
Normal file
43
packages/client/src/components/MkSimpleTextWindow.vue
Normal 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>
|
|
@ -43,6 +43,7 @@
|
|||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { Virtual } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/vue";
|
||||
import moment from "moment";
|
||||
import XNotes from "@/components/MkNotes.vue";
|
||||
import XUserList from "@/components/MkUserList.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
|
@ -50,19 +51,37 @@ import { definePageMetadata } from "@/scripts/page-metadata";
|
|||
import { defaultStore } from "@/store";
|
||||
import { deviceKind } from "@/scripts/device-kind";
|
||||
import icon from "@/scripts/icon";
|
||||
import { $i } from "@/reactiveAccount";
|
||||
import "swiper/scss";
|
||||
import "swiper/scss/virtual";
|
||||
import { api } from "@/os";
|
||||
|
||||
const props = defineProps<{
|
||||
query: string;
|
||||
query?: string;
|
||||
user?: string;
|
||||
host?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
channel?: string;
|
||||
withFiles: "0" | "1";
|
||||
searchCwAndAlt: "0" | "1";
|
||||
}>();
|
||||
|
||||
const userId = props.user == null ? undefined : await getUserId(props.user);
|
||||
|
||||
const notesPagination = {
|
||||
endpoint: "notes/search" as const,
|
||||
limit: 10,
|
||||
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,
|
||||
})),
|
||||
};
|
||||
|
@ -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 tab = ref(tabs[0]);
|
||||
watch(tab, () => syncSlide(tabs.indexOf(tab.value)));
|
||||
|
|
|
@ -306,7 +306,13 @@ export const routes = [
|
|||
component: page(() => import("./pages/search.vue")),
|
||||
query: {
|
||||
q: "query",
|
||||
user: "user",
|
||||
host: "host",
|
||||
since: "since",
|
||||
until: "until",
|
||||
withFiles: "withFiles",
|
||||
channel: "channel",
|
||||
detailed: "searchCwAndAlt",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,65 +1,101 @@
|
|||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
import { api, popup, promiseDialog } from "@/os";
|
||||
import { mainRouter } from "@/router";
|
||||
import { instance } from "@/instance";
|
||||
import MkSearchBox from "@/components/MkSearchBox.vue";
|
||||
|
||||
export async function search() {
|
||||
const { canceled, result: query } = await os.inputText({
|
||||
type: instance.features.searchFilters ? "search" : "text",
|
||||
title: i18n.ts.search,
|
||||
placeholder: i18n.ts.searchPlaceholder,
|
||||
const { canceled, result } = await new Promise<
|
||||
| { canceled: true; result: undefined }
|
||||
| {
|
||||
canceled: false;
|
||||
result: {
|
||||
action: "lookup";
|
||||
query: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
canceled: false;
|
||||
result: {
|
||||
action: "search";
|
||||
query?: string;
|
||||
from?: string;
|
||||
range?: string;
|
||||
withFiles: boolean;
|
||||
searchCwAndAlt: boolean;
|
||||
};
|
||||
}
|
||||
>((resolve, _) => {
|
||||
popup(
|
||||
MkSearchBox,
|
||||
{},
|
||||
{
|
||||
done: (result) => {
|
||||
resolve(result ?? { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
);
|
||||
});
|
||||
if (canceled || query == null || query === "") return;
|
||||
|
||||
const q = query.trim();
|
||||
if (canceled || result == null || result.query === "") return;
|
||||
|
||||
if (q.startsWith("@") && !q.includes(" ")) {
|
||||
mainRouter.push(`/${q}`);
|
||||
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,
|
||||
});
|
||||
|
||||
if (q.startsWith("#")) {
|
||||
mainRouter.push(`/tags/${encodeURIComponent(q.slice(1))}`);
|
||||
return;
|
||||
}
|
||||
promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
|
||||
const res = await promise;
|
||||
|
||||
// like 2018/03/12
|
||||
if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, "/"))) {
|
||||
const date = new Date(q.replace(/-/g, "/"));
|
||||
if (res.type === "User") {
|
||||
mainRouter.push(`/@${res.object.username}@${res.object.host}`);
|
||||
} else if (res.type === "Note") {
|
||||
mainRouter.push(`/notes/${res.object.id}`);
|
||||
}
|
||||
|
||||
// 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは
|
||||
// 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので
|
||||
// 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}$/)) {
|
||||
date.setHours(23, 59, 59, 999);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO
|
||||
// v.$root.$emit('warp', date);
|
||||
os.alert({
|
||||
type: "waiting",
|
||||
});
|
||||
return;
|
||||
// fallback
|
||||
mainRouter.push(`/search?q=${encodeURIComponent(result.query)}`);
|
||||
}
|
||||
|
||||
if (q.startsWith("https://")) {
|
||||
const promise = os.api("ap/show", {
|
||||
uri: q,
|
||||
});
|
||||
if (result.action === "search") {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
|
||||
|
||||
const res = await promise;
|
||||
|
||||
if (res.type === "User") {
|
||||
mainRouter.push(`/@${res.object.username}@${res.object.host}`);
|
||||
} else if (res.type === "Note") {
|
||||
mainRouter.push(`/notes/${res.object.id}`);
|
||||
if (result.query != null) {
|
||||
params.append("q", result.query);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (result.from != null) {
|
||||
if (result.from === "me" || result.from.includes("@"))
|
||||
params.append("user", result.from);
|
||||
else params.append("host", result.from);
|
||||
}
|
||||
|
||||
mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
|
||||
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()}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -731,6 +731,9 @@ importers:
|
|||
mfm-js:
|
||||
specifier: 0.24.0
|
||||
version: 0.24.0
|
||||
moment:
|
||||
specifier: 2.30.1
|
||||
version: 2.30.1
|
||||
photoswipe:
|
||||
specifier: 5.4.3
|
||||
version: 5.4.3
|
||||
|
@ -12809,6 +12812,10 @@ packages:
|
|||
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
|
||||
dev: false
|
||||
|
||||
/moment@2.30.1:
|
||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||
dev: true
|
||||
|
||||
/ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
dev: false
|
||||
|
|
Loading…
Reference in a new issue