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
(内容) です。
+ デフォルト(クエリ上で、フィールドを何も指定しない状態)では title
と body
で検索されます。
+ フィールド名を指定することで、その他のカラムを用いて検索できます。`,
+ [
+ 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 (
+
+ );
+}
+
+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 (
+
+ );
+}
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");
+}