diff --git a/.env.example b/.env.example index db01771..077cbaf 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,11 @@ HEINEKEN_ELASTIC_SEARCH_URL="http://host.docker.internal:9200/" HEINEKEN_ELASTIC_SEARCH_PUKIWIKI_INDEX="pukiwiki" HEINEKEN_ELASTIC_SEARCH_MAIL_INDEX="mail" +HEINEKEN_ELASTIC_SEARCH_SCRAPBOX_INDEX="scrapbox" HEINEKEN_PUKIWIKI_BASE_URL="https://inside.kmc.gr.jp/wiki/" HEINEKEN_MAIL_DEFAULT_CATEGORIES="info," HEINEKEN_MAIL_BASE_URL="https://inside.kmc.gr.jp/m2w/" + +HEINEKEN_SCRAPBOX_BASE_URL="https://scrapbox.io/kmc/" diff --git a/app/navbar.tsx b/app/navbar.tsx index dcd1a3d..d340801 100644 --- a/app/navbar.tsx +++ b/app/navbar.tsx @@ -28,6 +28,9 @@ export default function Navbar() { Mail + + ScrapBox + Help diff --git a/app/routes/help/route.tsx b/app/routes/help/route.tsx index 4e808af..db99aaa 100644 --- a/app/routes/help/route.tsx +++ b/app/routes/help/route.tsx @@ -176,6 +176,42 @@ export default function Help() { `, ], + [false, `

Scrapbox

`], + `検索できるカラムは title (タイトル)、 modified (更新日時)、 body (内容) です。 + デフォルト(クエリ上で、フィールドを何も指定しない状態)では titlebody で検索されます。 + フィールド名を指定することで、その他のカラムを用いて検索できます。`, + [ + false, + `
+
+ body:"環境構築" title:"kubernetes" +
+
`, + ], + [ + false, + `
+
+ "Kubernetes" modified:[2021-01-01 TO 2023-04-04] +
+
`, + ], + [ + false, + `
+
+ ("Kubernetes" OR "k8s") AND "metallb" +
+
`, + ], + [ + false, + `
+
+ "Kubernetes" -body:"k8s" +
+
`, + ], ]} /> createPageFromResponse(item)), + totalCount: json.hits.total.value, + }; +} + +export function buildScrapBoxSearch( + order: string, + page: number, + useRawQuery: boolean, + query?: string, +) { + let queryString: string; + if (query == null || query == "") { + queryString = "*"; + } else { + queryString = useRawQuery ? query : toQueryString(query); + } + return { + query: queryString, + size: SEARCH_SIZE, + from: (page - 1) * SEARCH_SIZE, + order: order, + }; +} + +interface ScrapBoxSearch { + query: string; + size: number; + from: number; + order: string; +} + +export async function requestSearch({ + query, + size, + from, + order, +}: ScrapBoxSearch) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const queryJson: any = { + query: { + function_score: { + query: { + query_string: { + // boost by title + fields: ["title^5", "body"], + query: query, + default_operator: "AND", + }, + }, + }, + }, + _source: ["modified", "title"], + size: size, + from: from, + }; + + // eslint-disable-next-line default-case + switch (order) { + case "m": + queryJson["sort"] = { modified: "desc" }; + break; + case "ta": + queryJson["sort"] = { "title.keyword": "asc" }; + break; + case "td": + queryJson["sort"] = { "title.keyword": "desc" }; + break; + case "s": + queryJson["sort"] = ["_score", { modified: "desc" }]; + queryJson["query"]["function_score"]["functions"] = [ + { + // boost by date + exp: { + modified: { + // tekitou iikanji values~~ + offset: "150d", + scale: "500d", + decay: 0.75, + }, + }, + }, + { + script_score: { + // boost by title length (jakkan) + script: { + inline: + "_score / Math.sqrt(Math.log1p(doc['title.keyword'].value.length()))", + }, + }, + }, + ]; + break; + } + + queryJson["highlight"] = { + // html escape + encoder: "html", + fields: { + body: { + pre_tags: [""], + post_tags: [""], + fragment_size: 220, + no_match_size: 220, + number_of_fragments: 1, + }, + }, + }; + + const url = new URL( + "_search", + process.env.HEINEKEN_ELASTIC_SEARCH_URL! + + process.env.HEINEKEN_ELASTIC_SEARCH_SCRAPBOX_INDEX! + + "/", + ); + url.searchParams.append("source", JSON.stringify(queryJson)); + url.searchParams.append("source_content_type", "application/json"); + + const response = await fetch(url); + if (!response.ok) { + throw new Response( + `Invalid response from the backend elasticsearch server: ${response.statusText}`, + { status: 500 }, + ); + } + const json = await response.json(); + return createPageResultFromResponse(json); +} diff --git a/app/routes/search.scrapbox/models.ts b/app/routes/search.scrapbox/models.ts new file mode 100644 index 0000000..6a001f0 --- /dev/null +++ b/app/routes/search.scrapbox/models.ts @@ -0,0 +1,12 @@ +// scrapbox page +export interface Page { + id: string; + title: string; + modified: number; + body: string; +} + +export interface PageResult { + pages: Page[]; + totalCount: number; +} diff --git a/app/routes/search.scrapbox/page-list.css b/app/routes/search.scrapbox/page-list.css new file mode 100644 index 0000000..1316e14 --- /dev/null +++ b/app/routes/search.scrapbox/page-list.css @@ -0,0 +1,15 @@ +.PageListItem a h3 { + display: inline-block; + text-decoration: none; + max-width: 100%; +} + +.PageListItem a h3:hover { + text-decoration: underline; +} + +.PageListItem p.small { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/routes/search.scrapbox/page-list.tsx b/app/routes/search.scrapbox/page-list.tsx new file mode 100644 index 0000000..78c0c24 --- /dev/null +++ b/app/routes/search.scrapbox/page-list.tsx @@ -0,0 +1,53 @@ +import { Page, PageResult } from "./models"; +import styles from "./page-list.css"; +import { createScrapBoxUrl } from "./utils"; +import { faCalendar } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { LinksFunction } from "@remix-run/node"; + +export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; + +interface PageListItemProps { + page: Page; + scrapboxBaseURL: string; +} + +function PageListItem(props: PageListItemProps) { + // scrapboxのmodifiedはunix timestamp + const dateStr = new Date(props.page.modified * 1000).toLocaleString("ja-JP", { + timeZone: "Asia/Tokyo", + }); + return ( +
+
+ +

{props.page.title}

+
+

+ +  {dateStr} +

+

+

+
+ ); +} + +interface PageListProps { + pageResult: PageResult; + scrapboxBaseURL: string; +} + +export function PageList(props: PageListProps) { + return ( +
+ {props.pageResult.pages.map((item) => ( + + ))} +
+ ); +} diff --git a/app/routes/search.scrapbox/route.tsx b/app/routes/search.scrapbox/route.tsx new file mode 100644 index 0000000..632fdea --- /dev/null +++ b/app/routes/search.scrapbox/route.tsx @@ -0,0 +1,190 @@ +import { SEARCH_SIZE, buildScrapBoxSearch, requestSearch } from "./els-client"; +import { PageResult } from "./models"; +import { PageList, links as pageListLinks } from "./page-list"; +import { SearchBox, links as searchBoxLinks } from "./search-box"; +import { parseSearchParams, setNewOrder, setNewPage } from "./utils"; +import { + LinksFunction, + LoaderFunctionArgs, + MetaFunction, + defer, +} from "@remix-run/node"; +import { + Await, + isRouteErrorResponse, + useLoaderData, + useNavigation, + useRouteError, + useSearchParams, +} from "@remix-run/react"; +import { Suspense } from "react"; +import HeinekenError from "~/components/heineken-error"; +import { Pager, links as pagerLinks } from "~/components/pager"; +import SortButton from "~/components/sort-button"; +import { StatusIndicator } from "~/components/status-indicator"; +import { ELASTIC_SEARCH_MAX_SEARCH_WINDOW } from "~/utils"; + +const sortOrderOptions = [ + { value: "s", label: "Score" }, + { value: "m", label: "Modified" }, + { value: "ta", label: "Title asc" }, + { value: "td", label: "Title desc" }, +]; + +export const meta: MetaFunction = ({ location }) => { + const { query } = parseSearchParams(new URLSearchParams(location.search)); + return [{ title: `${query ? `${query} - ` : ""}ScrapBox - Heineken` }]; +}; + +export const links: LinksFunction = () => [ + ...searchBoxLinks(), + ...pagerLinks(), + ...pageListLinks(), +]; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const searchParams = new URL(request.url).searchParams; + const { query, page, order, advanced } = parseSearchParams(searchParams); + + // async 内で throw Response すると Errorboundary の Error がうまくとれないのでパースは non-async でやる + const search = buildScrapBoxSearch(order, page, advanced, query); + const pageResult = requestSearch(search); + + const scrapboxBaseURL = process.env.HEINEKEN_SCRAPBOX_BASE_URL!; + + return defer({ pageResult, scrapboxBaseURL }); +}; + +const createSearchBox = (params: URLSearchParams) => { + const { query, order, advanced } = parseSearchParams(params); + return ( + + ); +}; + +// @ts-expect-error SetURLSearchParams type is not exported +const createOrderSelect = (params: URLSearchParams, setSearchParams) => { + const { order } = parseSearchParams(params); + + const onNewOrder = (order: string) => { + // @ts-expect-error SetURLSearchParams type is not exported + setSearchParams((prev) => { + setNewOrder(prev, order); + return prev; + }); + }; + + return ( + + ); +}; + +const createRequestingStatusIndicator = (params: URLSearchParams) => { + const { page } = parseSearchParams(params); + return ( + + ); +}; + +export function ErrorBoundary() { + const err = useRouteError(); + console.error(err); + const [searchParams, setSearchParams] = useSearchParams(); + + const msg = isRouteErrorResponse(err) + ? err.data + : err instanceof Error + ? err.message + : String(err); + + return ( +
+ {createSearchBox(searchParams)} +
+ {createRequestingStatusIndicator(searchParams)} + {createOrderSelect(searchParams, setSearchParams)} +
+
+ +
+
+ ); +} + +export default function ScrapBox() { + const navigation = useNavigation(); + // Susponse の fallback は search params の変化では起こらないので、 + // ページ変更などのときは 自前で navigation.state を見る必要がある + // https://github.com/remix-run/react-router/discussions/8914#discussioncomment-5774149 + const requesting = navigation.state !== "idle"; + + const [searchParams, setSearchParams] = useSearchParams(); + const { page } = parseSearchParams(searchParams); + const { pageResult, scrapboxBaseURL } = useLoaderData(); + + const onNewPage = (page: number) => { + setSearchParams((prev) => { + setNewPage(prev, page); + return prev; + }); + }; + + const render = (pageResult: PageResult) => { + // We cannot search over the window limit of elasticsearch. + const totalPages = Math.min( + Math.ceil(pageResult.totalCount / SEARCH_SIZE), + Math.floor(ELASTIC_SEARCH_MAX_SEARCH_WINDOW / SEARCH_SIZE), + ); + return ( +
+ + +
+ ); + }; + + return ( +
+ {createSearchBox(searchParams)} +
+ + + {(pr) => ( + + )} + + + + {createOrderSelect(searchParams, setSearchParams)} +
+ + {requesting ? null : ( + + {(pr) => render(pr)} + + )} +
+ ); +} diff --git a/app/routes/search.scrapbox/search-box.css b/app/routes/search.scrapbox/search-box.css new file mode 100644 index 0000000..a227657 --- /dev/null +++ b/app/routes/search.scrapbox/search-box.css @@ -0,0 +1,18 @@ +.SearchBox input { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + /* menu's position is absolute */ + float: "none"; +} + +#questionMark { + vertical-align: middle; + padding-bottom: 2px; +} + +#questionMarkLink, +#questionMarkLink:hover, +#questionMarkLink:visited { + color: inherit; + text-decoration: none; +} diff --git a/app/routes/search.scrapbox/search-box.tsx b/app/routes/search.scrapbox/search-box.tsx new file mode 100644 index 0000000..db5aeb7 --- /dev/null +++ b/app/routes/search.scrapbox/search-box.tsx @@ -0,0 +1,70 @@ +import styles from "./search-box.css"; +import { + faCircleQuestion, + faMagnifyingGlass, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { LinksFunction } from "@remix-run/node"; +import { Form, Link } from "@remix-run/react"; +import { useEffect, useState } from "react"; + +export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; + +interface SearchBoxProps { + order: string; + defaultAdvanced: boolean; + defaultQuery: string; +} + +export function SearchBox(props: SearchBoxProps) { + const [advanced, setAdvanced] = useState(props.defaultAdvanced); + const [query, setQuery] = useState(props.defaultQuery); + + useEffect(() => setQuery(props.defaultQuery), [props.defaultQuery]); + useEffect(() => setAdvanced(props.defaultAdvanced), [props.defaultAdvanced]); + + return ( +
+
+
+
+ setQuery(e.target.value)} + /> + + +
+
+ setAdvanced(e.target.checked)} + /> + +
+
+
+
+ ); +} diff --git a/app/routes/search.scrapbox/utils.ts b/app/routes/search.scrapbox/utils.ts new file mode 100644 index 0000000..56f3166 --- /dev/null +++ b/app/routes/search.scrapbox/utils.ts @@ -0,0 +1,22 @@ +export function createScrapBoxUrl(baseUrl: string, title: string) { + const titleUrlEncoded = encodeURIComponent(title); + const url = new URL(titleUrlEncoded, baseUrl); + return url.toString(); +} + +export function parseSearchParams(params: URLSearchParams) { + const query = params.get("query") ?? undefined; + const page = parseInt(params.get("page") ?? "1"); + const order = params.get("order") ?? "s"; + const advanced = params.get("advanced") === "on"; + return { query, page, order, advanced }; +} + +export function setNewPage(params: URLSearchParams, page: number) { + params.set("page", page.toString()); +} + +export function setNewOrder(params: URLSearchParams, order: string) { + params.set("order", order); + params.delete("page"); +}