Răsfoiți Sursa

Merge branch 'agile' of github.com:iapt-platform/mint into agile

Jeremy Zheng 2 ani în urmă
părinte
comite
f7ae6d03a6
100 a modificat fișierele cu 8638 adăugiri și 874 ștergeri
  1. 1 0
      .gitignore
  2. 94 0
      dashboard/src/assets/icon/index.tsx
  3. 13 2
      dashboard/src/components/anthology/AnthologyModal.tsx
  4. 5 3
      dashboard/src/components/anthology/AnthologyTocTree.tsx
  5. 6 1
      dashboard/src/components/anthology/EditableTocTree.tsx
  6. 8 0
      dashboard/src/components/api/Article.ts
  7. 1 0
      dashboard/src/components/api/Channel.ts
  8. 14 3
      dashboard/src/components/article/AddToAnthology.tsx
  9. 72 0
      dashboard/src/components/article/AnthologiesAtArticle.tsx
  10. 49 23
      dashboard/src/components/article/AnthologyDetail.tsx
  11. 66 13
      dashboard/src/components/article/AnthologyInfoEdit.tsx
  12. 59 375
      dashboard/src/components/article/Article.tsx
  13. 2 0
      dashboard/src/components/article/ArticleDrawer.tsx
  14. 22 7
      dashboard/src/components/article/ArticleEdit.tsx
  15. 3 10
      dashboard/src/components/article/ArticleEditDrawer.tsx
  16. 5 1
      dashboard/src/components/article/ArticleEditTools.tsx
  17. 2 1
      dashboard/src/components/article/ArticleList.tsx
  18. 6 20
      dashboard/src/components/article/ArticleView.tsx
  19. 5 0
      dashboard/src/components/article/EditableTree.tsx
  20. 2 2
      dashboard/src/components/article/EditableTreeNode.tsx
  21. 4 0
      dashboard/src/components/article/ToolButtonToc.tsx
  22. 59 0
      dashboard/src/components/article/TypeAnthology.tsx
  23. 204 0
      dashboard/src/components/article/TypeArticle.tsx
  24. 269 0
      dashboard/src/components/article/TypeCourse.tsx
  25. 269 0
      dashboard/src/components/article/TypePali.tsx
  26. 110 0
      dashboard/src/components/article/TypeTerm.tsx
  27. 3 0
      dashboard/src/components/auth/setting/SettingItem.tsx
  28. 6 1
      dashboard/src/components/channel/ChannelTypeSelect.tsx
  29. 72 49
      dashboard/src/components/channel/Edit.tsx
  30. 15 4
      dashboard/src/components/corpus/ChapterCard.tsx
  31. 36 5
      dashboard/src/components/corpus/TocPath.tsx
  32. 132 30
      dashboard/src/components/dict/CaseList.tsx
  33. 286 0
      dashboard/src/components/export/ExportModal.tsx
  34. 20 0
      dashboard/src/components/export/ExportSettingLayout.tsx
  35. 111 0
      dashboard/src/components/export/ShareButton.tsx
  36. 28 2
      dashboard/src/components/fts/FtsBookList.tsx
  37. 38 2
      dashboard/src/components/fts/FullTextSearchResult.tsx
  38. 11 0
      dashboard/src/components/fts/search.css
  39. 43 0
      dashboard/src/components/general/ErrorResult.tsx
  40. 3 0
      dashboard/src/components/general/LangSelect.tsx
  41. 38 7
      dashboard/src/components/general/NissayaCard.tsx
  42. 2 0
      dashboard/src/components/general/TermTextArea.tsx
  43. 21 15
      dashboard/src/components/general/TermTextAreaMenu.tsx
  44. 3 1
      dashboard/src/components/studio/PublicitySelect.tsx
  45. 1 1
      dashboard/src/components/template/MdView.tsx
  46. 2 1
      dashboard/src/components/template/SentEdit.tsx
  47. 1 1
      dashboard/src/components/template/SentEdit/SentCell.tsx
  48. 1 1
      dashboard/src/components/template/SentEdit/SentContent.tsx
  49. 13 7
      dashboard/src/components/template/utilities.ts
  50. 25 10
      dashboard/src/components/term/TermCommunity.tsx
  51. 1 0
      dashboard/src/components/term/TermModal.tsx
  52. 26 26
      dashboard/src/pages/library/article/show.tsx
  53. 2 2
      dashboard/src/pages/library/nissaya/show.tsx
  54. 20 2
      dashboard/src/pages/library/search/search.tsx
  55. 1 0
      dashboard/src/pages/studio/anthology/edit.tsx
  56. 86 0
      dashboard/src/protocols/TulipServiceClientPb.ts
  57. 127 0
      dashboard/src/protocols/tulip_pb.d.ts
  58. 1000 0
      dashboard/src/protocols/tulip_pb.js
  59. 3 0
      dashboard/src/request.ts
  60. 1 0
      deploy/group_vars/all.yml
  61. 44 4
      deploy/mint.yml
  62. 17 56
      deploy/roles/mint-v2/tasks/laravel.yml
  63. 25 0
      deploy/roles/mint-v2/tasks/lily.yml
  64. 13 0
      deploy/roles/mint-v2/tasks/morus.yml
  65. 54 0
      deploy/roles/mint-v2/tasks/queue-workers.yml
  66. 25 0
      deploy/roles/mint-v2/tasks/tulip.yml
  67. 1 1
      deploy/roles/mint-v2/templates/v1/config.js.j2
  68. 1 1
      deploy/roles/mint-v2/templates/v1/config.php.j2
  69. 8 1
      deploy/roles/mint-v2/templates/v2/env.j2
  70. 1 0
      deploy/roles/mint-v2/templates/v2/lily/config.toml.j2
  71. 15 0
      deploy/roles/mint-v2/templates/v2/lily/services/server.service.j2
  72. 15 0
      deploy/roles/mint-v2/templates/v2/lily/services/worker.service.j2
  73. 15 0
      deploy/roles/mint-v2/templates/v2/morus.service.j2
  74. 13 0
      deploy/roles/mint-v2/templates/v2/tulip/config.php.j2
  75. 1 0
      deploy/roles/mint-v2/templates/v2/tulip/db/env.j2
  76. 15 0
      deploy/roles/mint-v2/templates/v2/tulip/service.conf.j2
  77. 78 9
      deploy/staging/hosts
  78. 5 2
      rpc/lily/.gitignore
  79. 14 14
      rpc/lily/Dockerfile
  80. 10 0
      rpc/lily/README.md
  81. 2 2
      rpc/lily/build.sh
  82. 0 69
      rpc/lily/client.php
  83. 22 0
      rpc/lily/lily.sh
  84. 3 0
      rpc/lily/lily/.gitignore
  85. 7 13
      rpc/lily/lily/README.md
  86. 26 5
      rpc/lily/lily/__main__.py
  87. 140 46
      rpc/lily/lily/palm/__init__.py
  88. 13 0
      rpc/lily/lily/palm/s3.py
  89. 55 0
      rpc/lily/lily/palm/server.py
  90. 69 17
      rpc/lily/lily/palm/tex.py
  91. 0 0
      rpc/lily/lily/palm/worker.py
  92. 3 3
      rpc/lily/start.sh
  93. 1 2
      rpc/morus/morus/composer.json
  94. 85 0
      rpc/protocols/tulip.proto
  95. 24 1
      rpc/schema.sh
  96. 90 0
      rpc/sdk/cpp/tulip.grpc.pb.cc
  97. 244 0
      rpc/sdk/cpp/tulip.grpc.pb.h
  98. 1283 0
      rpc/sdk/cpp/tulip.pb.cc
  99. 1514 0
      rpc/sdk/cpp/tulip.pb.h
  100. 1159 0
      rpc/sdk/csharp/Tulip.cs

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@ tmp
 *.log
 /.VSCodeCounter
 config.toml
+/.vscode/launch.json

+ 94 - 0
dashboard/src/assets/icon/index.tsx

@@ -492,6 +492,88 @@ const PasteOutLined = () => (
     ></path>
   </svg>
 );
+
+const EpubOutLined = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="6403"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M518.144 29.696v100.352H236.544c-6.144 0-8.192 2.048-8.192 8.192v666.624h566.272V27.648c12.288 0 23.552 2.048 34.816 6.144 33.792 13.312 55.296 37.888 64.512 73.728 1.024 5.12 2.048 10.24 2.048 16.384v776.192c0 23.552-9.216 44.032-25.6 61.44-13.312 15.36-29.696 25.6-49.152 30.72-8.192 2.048-16.384 4.096-24.576 4.096-189.44 0-378.88 1.024-569.344 0-48.128 0-87.04-33.792-98.304-80.896-1.024-6.144-1.024-12.288-1.024-18.432V132.096c0-36.864 16.384-65.536 46.08-86.016 15.36-10.24 32.768-15.36 52.224-15.36h282.624c3.072-1.024 6.144-1.024 9.216-1.024z m-49.152 868.352c0 25.6 20.48 44.032 43.008 44.032 24.576 0 41.984-19.456 43.008-41.984 1.024-24.576-22.528-44.032-43.008-45.056-22.528-1.024-44.032 21.504-43.008 43.008z"
+      fill="#0079C1"
+      p-id="6404"
+    ></path>
+    <path
+      d="M741.376 29.696V368.64l-1.024 1.024c-35.84-48.128-71.68-96.256-107.52-145.408-19.456 21.504-38.912 43.008-59.392 64.512V29.696h167.936z"
+      fill="#FFD204"
+      p-id="6405"
+    ></path>
+    <path
+      d="M284.672 584.704v-77.824h456.704v77.824H284.672z m456.704 76.8v67.584H284.672v-67.584h456.704zM284.672 351.232h344.064v77.824H284.672v-77.824z m0-65.536v-78.848h233.472v78.848H284.672z"
+      fill="#BABABA"
+      p-id="6406"
+    ></path>
+  </svg>
+);
+
+const HtmlOutLined = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="6553"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M89.088 59.392l62.464 803.84c1.024 12.288 9.216 22.528 20.48 25.6L502.784 993.28c6.144 2.048 12.288 2.048 18.432 0l330.752-104.448c11.264-4.096 19.456-14.336 20.48-25.6l62.464-803.84c1.024-17.408-12.288-31.744-29.696-31.744H118.784c-17.408 0-31.744 14.336-29.696 31.744z"
+      fill="#FC490B"
+      p-id="6554"
+    ></path>
+    <path
+      d="M774.144 309.248h-409.6l12.288 113.664h388.096l-25.6 325.632-227.328 71.68-227.328-71.68-13.312-169.984h118.784v82.944l124.928 33.792 123.904-33.792 10.24-132.096H267.264L241.664 204.8h540.672z"
+      fill="#FFFFFF"
+      p-id="6555"
+    ></path>
+  </svg>
+);
+
+const DocOutLined = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="6702"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M950.272 843.776H527.36c-16.384 0-29.696-13.312-29.696-29.696V210.944c0-16.384 13.312-29.696 29.696-29.696h422.912c16.384 0 29.696 13.312 29.696 29.696v603.136c0 16.384-13.312 29.696-29.696 29.696z"
+      fill="#E8E8E8"
+      p-id="6703"
+    ></path>
+    <path
+      d="M829.44 361.472H527.36c-16.384 0-29.696-13.312-29.696-29.696s13.312-29.696 29.696-29.696H829.44c16.384 0 29.696 13.312 29.696 29.696 0 15.36-13.312 29.696-29.696 29.696z m0 120.832H527.36c-16.384 0-29.696-13.312-29.696-29.696s13.312-29.696 29.696-29.696H829.44c16.384 0 29.696 13.312 29.696 29.696s-13.312 29.696-29.696 29.696z m0 119.808H527.36c-16.384 0-29.696-13.312-29.696-29.696s13.312-29.696 29.696-29.696H829.44c16.384 0 29.696 13.312 29.696 29.696s-13.312 29.696-29.696 29.696z m0 120.832H527.36c-16.384 0-29.696-13.312-29.696-29.696s13.312-29.696 29.696-29.696H829.44c16.384 0 29.696 13.312 29.696 29.696s-13.312 29.696-29.696 29.696z"
+      fill="#B2B2B2"
+      p-id="6704"
+    ></path>
+    <path
+      d="M607.232 995.328l-563.2-107.52V135.168l563.2-107.52v967.68z"
+      fill="#0D47A1"
+      p-id="6705"
+    ></path>
+    <path
+      d="M447.488 696.32h-71.68l-47.104-236.544c-3.072-13.312-4.096-27.648-4.096-40.96h-1.024c-1.024 16.384-3.072 30.72-5.12 40.96L269.312 696.32H194.56l-74.752-368.64h70.656l39.936 245.76c2.048 10.24 3.072 24.576 4.096 41.984h1.024c0-13.312 3.072-27.648 6.144-43.008l51.2-244.736h68.608l47.104 247.808c2.048 9.216 3.072 22.528 4.096 39.936h1.024c1.024-13.312 2.048-26.624 4.096-40.96l39.936-245.76H522.24L447.488 696.32z"
+      fill="#FFFFFF"
+      p-id="6706"
+    ></path>
+  </svg>
+);
+
 export const DictIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={DictSvg} {...props} />
 );
@@ -589,3 +671,15 @@ export const TransferOutLinedIcon = (
 export const PasteOutLinedIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={PasteOutLined} {...props} />
 );
+
+export const EpubIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={EpubOutLined} {...props} />
+);
+
+export const HtmlIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={HtmlOutLined} {...props} />
+);
+
+export const DocIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={DocOutLined} {...props} />
+);

+ 13 - 2
dashboard/src/components/anthology/AnthologyModal.tsx

@@ -1,30 +1,41 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { Modal } from "antd";
 import AnthologyList from "./AnthologyList";
 
 interface IWidget {
   studioName?: string;
   trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: Function;
   onSelect?: Function;
   onCancel?: Function;
 }
 const AnthologyModalWidget = ({
   studioName,
   trigger,
+  open = false,
+  onClose,
   onSelect,
   onCancel,
 }: IWidget) => {
-  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [isModalOpen, setIsModalOpen] = useState(open);
 
+  useEffect(() => setIsModalOpen(open), [open]);
   const showModal = () => {
     setIsModalOpen(true);
   };
 
   const handleOk = () => {
+    if (typeof onClose !== "undefined") {
+      onClose(false);
+    }
     setIsModalOpen(false);
   };
 
   const handleCancel = () => {
+    if (typeof onClose !== "undefined") {
+      onClose(false);
+    }
     setIsModalOpen(false);
   };
 

+ 5 - 3
dashboard/src/components/anthology/AnthologyTocTree.tsx

@@ -1,5 +1,4 @@
 import { useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
 
 import { get } from "../../request";
 import { IArticleMapListResponse } from "../api/Article";
@@ -8,11 +7,13 @@ import TocTree from "../article/TocTree";
 
 interface IWidget {
   anthologyId?: string;
+  channels?: string[];
   onSelect?: Function;
   onArticleSelect?: Function;
 }
 const AnthologyTocTreeWidget = ({
   anthologyId,
+  channels,
   onSelect,
   onArticleSelect,
 }: IWidget) => {
@@ -23,14 +24,15 @@ const AnthologyTocTreeWidget = ({
     if (typeof anthologyId === "undefined") {
       return;
     }
-    const url = `/v2/article-map?view=anthology&id=${anthologyId}`;
+    let url = `/v2/article-map?view=anthology&id=${anthologyId}`;
+    url += channels && channels.length > 0 ? "&channel=" + channels[0] : "";
     console.log("url", url);
     get<IArticleMapListResponse>(url).then((json) => {
       if (json.ok) {
         const toc: ListNodeData[] = json.data.rows.map((item) => {
           return {
             key: item.article_id ? item.article_id : item.title,
-            title: item.title,
+            title: item.title_text ? item.title_text : item.title,
             level: item.level,
             deletedAt: item.deleted_at,
           };

+ 6 - 1
dashboard/src/components/anthology/EditableTocTree.tsx

@@ -74,7 +74,7 @@ const EditableTocTreeWidget = ({
           message.error(json.message);
         }
       })
-      .catch((e) => console.error(e));
+      .catch((e) => message.error(e));
   };
 
   useEffect(() => {
@@ -86,6 +86,7 @@ const EditableTocTreeWidget = ({
           return {
             key: item.article_id ? item.article_id : item.title,
             title: item.title,
+            title_text: item.title_text ? item.title_text : item.title,
             level: item.level,
             deletedAt: item.deleted_at,
           };
@@ -111,6 +112,7 @@ const EditableTocTreeWidget = ({
                 key: randomString(),
                 id: id,
                 title: title,
+                title_text: title,
                 children: [],
                 level: 1,
               };
@@ -148,6 +150,7 @@ const EditableTocTreeWidget = ({
               key: randomString(),
               id: res.data.uid,
               title: res.data.title,
+              title_text: res.data.title,
               children: [],
               level: node.level + 1,
             };
@@ -172,6 +175,7 @@ const EditableTocTreeWidget = ({
         }}
       />
       <ArticleEditDrawer
+        anthologyId={anthologyId}
         articleId={articleId}
         open={openEditor}
         onClose={() => setOpenEditor(false)}
@@ -181,6 +185,7 @@ const EditableTocTreeWidget = ({
             key: randomString(),
             id: data.uid,
             title: data.title,
+            title_text: data.title_text ? data.title_text : data.title,
             level: 0,
             children: [],
           });

+ 8 - 0
dashboard/src/components/api/Article.ts

@@ -1,5 +1,6 @@
 import { IStudio } from "../auth/StudioName";
 import { IUser } from "../auth/User";
+import { IChannel } from "../channel/Channel";
 import { ITocPathNode } from "../corpus/TocPath";
 import type { IStudioApiResponse, TRole } from "./Auth";
 
@@ -16,6 +17,7 @@ export interface IAnthologyDataRequest {
   article_list?: IArticleListApiResponse[];
   lang: string;
   status: number;
+  default_channel?: string | null;
 }
 export interface IAnthologyDataResponse {
   uid: string;
@@ -24,6 +26,7 @@ export interface IAnthologyDataResponse {
   summary: string;
   article_list: IArticleListApiResponse[];
   studio: IStudioApiResponse;
+  default_channel?: IChannel;
   lang: string;
   status: number;
   childrenNumber: number;
@@ -66,6 +69,8 @@ export interface IArticleDataRequest {
   content_type?: string;
   status: number;
   lang: string;
+  to_tpl?: boolean;
+  anthology_id?: string;
 }
 export interface IChapterToc {
   key?: string;
@@ -79,6 +84,7 @@ export interface IChapterToc {
 export interface IArticleDataResponse {
   uid: string;
   title: string;
+  title_text?: string;
   subtitle: string;
   summary: string | null;
   _summary?: string;
@@ -134,9 +140,11 @@ export interface IAnthologyCreateRequest {
 export interface IArticleMapRequest {
   id?: string;
   collect_id?: string;
+  collection?: { id: string; title: string };
   article_id?: string;
   level: number;
   title: string;
+  title_text?: string;
   editor?: IUser;
   children?: number;
   deleted_at?: string | null;

+ 1 - 0
dashboard/src/components/api/Channel.ts

@@ -31,6 +31,7 @@ export interface IApiResponseChannelData {
   studio: IStudio;
   lang: string;
   status: number;
+  is_system: boolean;
   created_at: string;
   updated_at: string;
   role?: TRole;

+ 14 - 3
dashboard/src/components/article/AddToAnthology.tsx

@@ -1,24 +1,35 @@
 import { Button, message } from "antd";
-import React from "react";
+import React, { useEffect, useState } from "react";
 import { post } from "../../request";
 import AnthologyModal from "../anthology/AnthologyModal";
 import { IArticleMapAddRequest, IArticleMapAddResponse } from "../api/Article";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
 interface IWidget {
   trigger?: React.ReactNode;
   studioName?: string;
   articleIds?: string[];
+  open?: boolean;
+  onClose?: Function;
   onFinally?: Function;
 }
 const AddToAnthologyWidget = ({
   trigger,
   studioName,
+  open = false,
+  onClose,
   articleIds,
   onFinally,
 }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+  const user = useAppSelector(currentUser);
+  useEffect(() => setIsModalOpen(open), [open]);
   return (
     <AnthologyModal
-      studioName={studioName}
-      trigger={trigger ? trigger : <Button type="link">加入文集</Button>}
+      studioName={studioName ? studioName : user?.realName}
+      trigger={trigger}
+      open={isModalOpen}
+      onClose={(isOpen: boolean) => setIsModalOpen(isOpen)}
       onSelect={(id: string) => {
         if (typeof articleIds !== "undefined") {
           post<IArticleMapAddRequest, IArticleMapAddResponse>(

+ 72 - 0
dashboard/src/components/article/AnthologiesAtArticle.tsx

@@ -0,0 +1,72 @@
+import { Space, Typography, message } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import { IArticleMapListResponse } from "../api/Article";
+
+const { Link, Paragraph } = Typography;
+interface IList {
+  key?: string;
+  label?: string;
+}
+interface IWidget {
+  articleId?: string;
+  anthologyId?: string | null;
+  onClick?: Function;
+}
+const AnthologiesAtArticleWidget = ({
+  articleId,
+  anthologyId,
+  onClick,
+}: IWidget) => {
+  const [list, setList] = useState<IList[]>();
+  useEffect(() => {
+    //查询这个article 有多少文集
+    let url = `/v2/article-map?view=article&id=${articleId}`;
+    console.log("url", url);
+    get<IArticleMapListResponse>(url).then((json) => {
+      if (json.ok) {
+        const anthologies: IList[] = json.data.rows.map((item) => {
+          return {
+            key: item.collection?.id,
+            label: item.collection?.title,
+          };
+        });
+        console.log("anthologies", anthologies);
+        setList(anthologies.filter((value) => value.key !== anthologyId));
+      } else {
+        message.error("获取文集列表失败");
+      }
+    });
+  }, [articleId]);
+
+  let title = "";
+  if (anthologyId) {
+    title = "其他文集";
+  } else {
+    title = "文集列表";
+  }
+
+  return (
+    <Paragraph style={{ display: list && list.length > 0 ? "block" : "none" }}>
+      <Space>
+        {title}
+        {list?.map((item, index) => {
+          return (
+            <Link
+              key={index}
+              onClick={(e) => {
+                if (typeof onClick !== "undefined") {
+                  onClick(item.key, e);
+                }
+              }}
+            >
+              {item.label}
+            </Link>
+          );
+        })}
+      </Space>
+    </Paragraph>
+  );
+};
+
+export default AnthologiesAtArticleWidget;

+ 49 - 23
dashboard/src/components/article/AnthologyDetail.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from "react";
-import { Space, Typography } from "antd";
+import { Space, Typography, message } from "antd";
 
 import { get } from "../../request";
 import type {
@@ -17,49 +17,74 @@ const { Title, Text, Paragraph } = Typography;
 interface IWidgetAnthologyDetail {
   aid?: string;
   channels?: string[];
+  visible?: boolean;
   onArticleSelect?: Function;
+  onLoad?: Function;
+  onTitle?: Function;
+  onLoading?: Function;
+  onError?: Function;
 }
 const AnthologyDetailWidget = ({
   aid,
   channels,
+  visible = true,
   onArticleSelect,
+  onLoading,
+  onTitle,
+  onError,
 }: IWidgetAnthologyDetail) => {
   const [tableData, setTableData] = useState<IAnthologyData>();
 
   useEffect(() => {
-    console.log("useEffect");
     fetchData(aid);
   }, [aid]);
 
   function fetchData(id?: string) {
-    get<IAnthologyResponse>(`/v2/anthology/${id}`)
+    const url = `/v2/anthology/${id}`;
+    console.log("url", url);
+    if (typeof onLoading !== "undefined") {
+      onLoading(true);
+    }
+    get<IAnthologyResponse>(url)
       .then((response) => {
-        const item: IAnthologyDataResponse = response.data;
-        let newTree: IAnthologyData = {
-          id: item.uid,
-          title: item.title,
-          subTitle: item.subtitle,
-          summary: item.summary,
-          articles: item.article_list.map((al) => {
-            return {
-              key: al.article,
-              title: al.title,
-              level: parseInt(al.level),
-            };
-          }),
-          studio: item.studio,
-          created_at: item.created_at,
-          updated_at: item.updated_at,
-        };
-        setTableData(newTree);
-        console.log("toc", newTree.articles);
+        if (response.ok) {
+          const item: IAnthologyDataResponse = response.data;
+          let newTree: IAnthologyData = {
+            id: item.uid,
+            title: item.title,
+            subTitle: item.subtitle,
+            summary: item.summary,
+            articles: [],
+            studio: item.studio,
+            created_at: item.created_at,
+            updated_at: item.updated_at,
+          };
+          setTableData(newTree);
+          if (typeof onTitle !== "undefined") {
+            onTitle(item.title);
+          }
+          console.log("toc", newTree.articles);
+        } else {
+          if (typeof onError !== "undefined") {
+            onError(response.data, response.message);
+          }
+          message.error(response.message);
+        }
+      })
+      .finally(() => {
+        if (typeof onLoading !== "undefined") {
+          onLoading(false);
+        }
       })
       .catch((error) => {
         console.error(error);
+        if (typeof onError !== "undefined") {
+          onError(error, "");
+        }
       });
   }
   return (
-    <div style={{ padding: 12 }}>
+    <div style={{ padding: 12, visibility: visible ? "visible" : "hidden" }}>
       <Title level={4}>{tableData?.title}</Title>
       <div>
         <Text type="secondary">{tableData?.subTitle}</Text>
@@ -76,6 +101,7 @@ const AnthologyDetailWidget = ({
       <Title level={5}>目录</Title>
       <AnthologyTocTree
         anthologyId={aid}
+        channels={channels}
         onArticleSelect={(anthologyId: string, keys: string[]) => {
           if (typeof onArticleSelect !== "undefined") {
             onArticleSelect(anthologyId, keys);

+ 66 - 13
dashboard/src/components/article/AnthologyInfoEdit.tsx

@@ -1,12 +1,20 @@
 import { Form, message } from "antd";
 import { useIntl } from "react-intl";
-import { ProForm, ProFormText } from "@ant-design/pro-components";
+import {
+  ProForm,
+  ProFormSelect,
+  ProFormText,
+  RequestOptionsType,
+} from "@ant-design/pro-components";
 import MDEditor from "@uiw/react-md-editor";
 
 import { get, put } from "../../request";
 import { IAnthologyDataRequest, IAnthologyResponse } from "../api/Article";
 import LangSelect from "../general/LangSelect";
 import PublicitySelect from "../studio/PublicitySelect";
+import { useState } from "react";
+import { DefaultOptionType } from "antd/lib/select";
+import { IApiResponseChannelList } from "../api/Channel";
 
 interface IFormData {
   title: string;
@@ -14,29 +22,37 @@ interface IFormData {
   summary?: string;
   lang: string;
   status: number;
+  defaultChannel?: string;
 }
 
 interface IWidget {
   anthologyId?: string;
+  studioName?: string;
   onLoad?: Function;
 }
-const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
+const AnthologyInfoEditWidget = ({
+  studioName,
+  anthologyId,
+  onLoad,
+}: IWidget) => {
   const intl = useIntl();
+  const [channelOption, setChannelOption] = useState<DefaultOptionType[]>([]);
+  const [currChannel, setCurrChannel] = useState<RequestOptionsType>();
 
   return anthologyId ? (
     <ProForm<IFormData>
       onFinish={async (values: IFormData) => {
-        console.log(values);
-        const res = await put<IAnthologyDataRequest, IAnthologyResponse>(
-          `/v2/anthology/${anthologyId}`,
-          {
-            title: values.title,
-            subtitle: values.subtitle,
-            summary: values.summary,
-            status: values.status,
-            lang: values.lang,
-          }
-        );
+        const url = `/v2/anthology/${anthologyId}`;
+        console.log("url", url);
+        console.log("values", values);
+        const res = await put<IAnthologyDataRequest, IAnthologyResponse>(url, {
+          title: values.title,
+          subtitle: values.subtitle,
+          summary: values.summary,
+          status: values.status,
+          lang: values.lang,
+          default_channel: values.defaultChannel,
+        });
         console.log(res);
         if (res.ok) {
           if (typeof onLoad !== "undefined") {
@@ -60,6 +76,14 @@ const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
           if (typeof onLoad !== "undefined") {
             onLoad(res.data);
           }
+          if (res.data.default_channel) {
+            const channel = {
+              value: res.data.default_channel.id,
+              label: res.data.default_channel.name,
+            };
+            setCurrChannel(channel);
+            setChannelOption([channel]);
+          }
 
           return {
             title: res.data.title,
@@ -67,6 +91,7 @@ const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
             summary: res.data.summary ? res.data.summary : undefined,
             lang: res.data.lang,
             status: res.data.status,
+            defaultChannel: res.data.default_channel?.id,
           };
         } else {
           return {
@@ -75,6 +100,7 @@ const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
             summary: "",
             lang: "",
             status: 0,
+            defaultChannel: "",
           };
         }
       }}
@@ -109,6 +135,33 @@ const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
         <LangSelect width="md" />
         <PublicitySelect width="md" />
       </ProForm.Group>
+      <ProForm.Group>
+        <ProFormSelect
+          options={channelOption}
+          width="md"
+          name="defaultChannel"
+          label={"默认版本"}
+          showSearch
+          debounceTime={300}
+          request={async ({ keyWords }) => {
+            console.log("keyWord", keyWords);
+            if (typeof keyWords === "undefined") {
+              return currChannel ? [currChannel] : [];
+            }
+            const url = `/v2/channel?view=studio&name=${studioName}`;
+            console.log("url", url);
+            const json = await get<IApiResponseChannelList>(url);
+            const textbookList = json.data.rows.map((item) => {
+              return {
+                value: item.uid,
+                label: `${item.studio.nickName}/${item.name}`,
+              };
+            });
+            console.log("json", textbookList);
+            return textbookList;
+          }}
+        />
+      </ProForm.Group>
       <ProForm.Group>
         <Form.Item
           name="summary"

+ 59 - 375
dashboard/src/components/article/Article.tsx

@@ -1,31 +1,15 @@
-import { useEffect, useState } from "react";
-import { Divider, message, Result, Space, Tag } from "antd";
-
-import { get, post } from "../../request";
-import store from "../../store";
-import { IArticleDataResponse, IArticleResponse } from "../api/Article";
-import ArticleView, { IFirstAnthology } from "./ArticleView";
-import { ICourseCurrUserResponse } from "../api/Course";
-import { ICourseUser, signIn } from "../../reducers/course-user";
-import { ITextbook, refresh } from "../../reducers/current-course";
-import ExerciseList from "./ExerciseList";
-import ExerciseAnswer from "../course/ExerciseAnswer";
+import { IArticleDataResponse } from "../api/Article";
+import TypeArticle from "./TypeArticle";
+import TypeAnthology from "./TypeAnthology";
+import TypeTerm from "./TypeTerm";
+import TypePali from "./TypePali";
 import "./article.css";
-import TocTree from "./TocTree";
-import PaliText from "../template/Wbw/PaliText";
-import ArticleSkeleton from "./ArticleSkeleton";
-import { IViewRequest, IViewStoreResponse } from "../api/view";
-import {
-  IRecentRequest,
-  IRecentResponse,
-} from "../../pages/studio/recent/list";
-import { ITocPathNode } from "../corpus/TocPath";
-import { useSearchParams } from "react-router-dom";
-import { ITermResponse } from "../api/Term";
 
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
+  | "anthology"
   | "article"
+  | "series"
   | "chapter"
   | "para"
   | "cs-para"
@@ -56,14 +40,14 @@ export type ArticleType =
 interface IWidget {
   type?: ArticleType;
   articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
   book?: string | null;
   para?: string | null;
-  channelId?: string | null;
   anthologyId?: string | null;
   courseId?: string;
   exerciseId?: string;
   userName?: string;
-  mode?: ArticleMode | null;
   active?: boolean;
   onArticleChange?: Function;
   onFinal?: Function;
@@ -87,359 +71,24 @@ const ArticleWidget = ({
   onLoad,
   onAnthologySelect,
 }: IWidget) => {
-  const [articleData, setArticleData] = useState<IArticleDataResponse>();
-  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
-  const [extra, setExtra] = useState(<></>);
-  const [showSkeleton, setShowSkeleton] = useState(true);
-  const [unauthorized, setUnauthorized] = useState(false);
-  const [remains, setRemains] = useState(false);
-  const [searchParams] = useSearchParams();
-
-  const channels = channelId?.split("_");
-
-  useEffect(() => {
-    /**
-     * 由课本进入查询当前用户的权限和channel
-     */
-    if (
-      type === "textbook" ||
-      type === "exercise" ||
-      type === "exercise-list"
-    ) {
-      if (typeof articleId !== "undefined") {
-        const id = articleId.split("_");
-        get<ICourseCurrUserResponse>(`/v2/course-curr?course_id=${id[0]}`).then(
-          (response) => {
-            console.log("course user", response);
-            if (response.ok) {
-              const it: ICourseUser = {
-                channelId: response.data.channel_id,
-                role: response.data.role,
-              };
-              store.dispatch(signIn(it));
-              /**
-               * redux发布课程信息
-               */
-              const ic: ITextbook = {
-                courseId: id[0],
-                articleId: id[1],
-              };
-              store.dispatch(refresh(ic));
-            }
-          }
-        );
-      }
-    }
-  }, [articleId, type]);
-
-  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
-  useEffect(() => {
-    console.log("srcDataMode", srcDataMode);
-    if (!active) {
-      return;
-    }
-
-    if (typeof type !== "undefined") {
-      const debug = searchParams.get("debug");
-      let url = "";
-      switch (type) {
-        case "chapter":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/corpus-chapter/${articleId}?mode=${srcDataMode}`;
-            url += channelId ? `&channels=${channelId}` : "";
-          }
-          break;
-        case "para":
-          const _book = book ? book : articleId;
-          url = `/v2/corpus?view=para&book=${_book}&par=${para}&mode=${srcDataMode}`;
-          url += channelId ? `&channels=${channelId}` : "";
-          break;
-        case "article":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/article/${articleId}?mode=${srcDataMode}`;
-            url += channelId ? `&channel=${channelId}` : "";
-            url += anthologyId ? `&anthology=${anthologyId}` : "";
-          }
-          break;
-        case "textbook":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/article/${articleId}?view=textbook&course=${courseId}&mode=${srcDataMode}`;
-          }
-          break;
-        case "exercise":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}&user=${userName}`;
-            setExtra(
-              <ExerciseAnswer
-                courseId={courseId}
-                articleId={articleId}
-                exerciseId={exerciseId}
-              />
-            );
-          }
-          break;
-        case "exercise-list":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}`;
-
-            setExtra(
-              <ExerciseList
-                courseId={courseId}
-                articleId={articleId}
-                exerciseId={exerciseId}
-              />
-            );
-          }
-          break;
-        case "term":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/terms/${articleId}?mode=${srcDataMode}`;
-            url += channelId ? `&channel=${channelId}` : "";
-          }
-          break;
-        default:
-          if (typeof articleId !== "undefined") {
-            url = `/v2/corpus/${type}/${articleId}/${srcDataMode}?mode=${srcDataMode}`;
-            url += channelId ? `&channel=${channelId}` : "";
-          }
-          break;
-      }
-      if (debug) {
-        url += `&debug=${debug}`;
-      }
-      console.log("article url", url);
-      setShowSkeleton(true);
-      if (typeof articleId !== "undefined") {
-        const param = {
-          mode: srcDataMode,
-          channel: channelId !== null ? channelId : undefined,
-          book: book !== null ? book : undefined,
-          para: para !== null ? para : undefined,
-        };
-        post<IRecentRequest, IRecentResponse>("/v2/recent", {
-          type: type,
-          article_id: articleId,
-          param: JSON.stringify(param),
-        }).then((json) => {
-          console.log("recent", json);
-        });
-      }
-
-      if (type === "term") {
-        get<ITermResponse>(url)
-          .then((json) => {
-            if (json.ok) {
-              setArticleData({
-                uid: json.data.guid,
-                title: json.data.meaning,
-                subtitle: json.data.word,
-                summary: json.data.note,
-                content: json.data.note ? json.data.note : "",
-                content_type: "markdown",
-                html: json.data.html,
-                path: [],
-                status: 30,
-                lang: json.data.language,
-                created_at: json.data.created_at,
-                updated_at: json.data.updated_at,
-              });
-              if (json.data.html) {
-                setArticleHtml([json.data.html]);
-              } else if (json.data.note) {
-                setArticleHtml([json.data.note]);
-              }
-              setShowSkeleton(false);
-            }
-          })
-          .catch((error) => {
-            console.error(error);
-          });
-      } else {
-        get<IArticleResponse>(url)
-          .then((json) => {
-            console.log("article", json);
-            if (json.ok) {
-              setArticleData(json.data);
-              if (json.data.html) {
-                setArticleHtml([json.data.html]);
-              } else if (json.data.content) {
-                setArticleHtml([json.data.content]);
-              }
-              if (json.data.from) {
-                setRemains(true);
-              }
-              setShowSkeleton(false);
-
-              setExtra(
-                <TocTree
-                  treeData={json.data.toc?.map((item) => {
-                    const strTitle = item.title ? item.title : item.pali_title;
-                    const key = item.key
-                      ? item.key
-                      : `${item.book}-${item.paragraph}`;
-                    const progress = item.progress?.map((item, id) => (
-                      <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
-                    ));
-                    return {
-                      key: key,
-                      title: (
-                        <Space>
-                          <PaliText
-                            text={strTitle === "" ? "[unnamed]" : strTitle}
-                          />
-                          {progress}
-                        </Space>
-                      ),
-                      level: item.level,
-                    };
-                  })}
-                  onSelect={(keys: string[]) => {
-                    console.log(keys);
-                    if (
-                      typeof onArticleChange !== "undefined" &&
-                      keys.length > 0
-                    ) {
-                      onArticleChange(keys[0]);
-                    }
-                  }}
-                />
-              );
-
-              switch (type) {
-                case "chapter":
-                  if (typeof articleId === "string" && channelId) {
-                    const [book, para] = articleId?.split("-");
-                    post<IViewRequest, IViewStoreResponse>("/v2/view", {
-                      target_type: type,
-                      book: parseInt(book),
-                      para: parseInt(para),
-                      channel: channelId,
-                      mode: srcDataMode,
-                    }).then((json) => {
-                      console.log("view", json.data);
-                    });
-                  }
-                  break;
-                default:
-                  break;
-              }
-
-              if (typeof onLoad !== "undefined") {
-                onLoad(json.data);
-              }
-            } else {
-              setShowSkeleton(false);
-              setUnauthorized(true);
-              message.error(json.message);
-            }
-          })
-          .catch((e) => {
-            console.error(e);
-          });
-      }
-    }
-  }, [
-    active,
-    type,
-    articleId,
-    srcDataMode,
-    book,
-    para,
-    channelId,
-    anthologyId,
-    courseId,
-    exerciseId,
-    userName,
-  ]);
-
-  const getNextPara = (next: IArticleDataResponse): void => {
-    if (
-      typeof next.paraId === "undefined" ||
-      typeof next.mode === "undefined" ||
-      typeof next.from === "undefined" ||
-      typeof next.to === "undefined"
-    ) {
-      setRemains(false);
-      return;
-    }
-    let url = `/v2/corpus-chapter/${next.paraId}?mode=${next.mode}`;
-    url += `&from=${next.from}`;
-    url += `&to=${next.to}`;
-    url += channels ? `&channels=${channels}` : "";
-    console.log("lazy load", url);
-    get<IArticleResponse>(url).then((json) => {
-      if (json.ok) {
-        if (typeof json.data.content === "string") {
-          const content: string = json.data.content;
-          setArticleData((origin) => {
-            if (origin) {
-              origin.from = json.data.from;
-            }
-            return origin;
-          });
-          setArticleHtml((origin) => {
-            return [...origin, content];
-          });
-        }
-
-        //getNextPara(json.data);
-      }
-    });
-    return;
-  };
-
-  //const comment = <CommentListCard resId={articleData?.uid} resType="article" />
-  let anthology: IFirstAnthology | undefined;
-  if (articleData?.anthology_count && articleData.anthology_first) {
-    anthology = {
-      id: articleData.anthology_first.uid,
-      title: articleData.anthology_first.title,
-      count: articleData?.anthology_count,
-    };
-  }
-
   return (
     <div>
-      {showSkeleton ? (
-        <ArticleSkeleton />
-      ) : unauthorized ? (
-        <Result
-          status="403"
-          title="无权访问"
-          subTitle="您无权访问该内容。您可能没有登录,或者内容的所有者没有给您所需的权限。"
-          extra={<></>}
-        />
-      ) : (
-        <ArticleView
-          id={articleData?.uid}
-          title={articleData?.title}
-          subTitle={articleData?.subtitle}
-          summary={articleData?.summary}
-          content={articleData ? articleData.content : ""}
-          html={articleHtml}
-          path={articleData?.path}
-          created_at={articleData?.created_at}
-          updated_at={articleData?.updated_at}
-          channels={channels}
+      {type === "article" ? (
+        <TypeArticle
           type={type}
           articleId={articleId}
-          remains={remains}
-          anthology={anthology}
-          onEnd={() => {
-            if (type === "chapter" && articleData) {
-              getNextPara(articleData);
+          channelId={channelId}
+          mode={mode}
+          anthologyId={anthologyId}
+          active={active}
+          onArticleChange={(type: ArticleType, id: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id);
             }
           }}
-          onPathChange={(
-            node: ITocPathNode,
-            e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
-          ) => {
-            if (typeof onArticleChange !== "undefined") {
-              const newArticle = node.key
-                ? node.key
-                : `${node.book}-${node.paragraph}`;
-              const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
-              onArticleChange(newArticle, target);
+          onLoad={(data: IArticleDataResponse) => {
+            if (typeof onLoad !== "undefined") {
+              onLoad(data);
             }
           }}
           onAnthologySelect={(id: string) => {
@@ -448,10 +97,45 @@ const ArticleWidget = ({
             }
           }}
         />
+      ) : type === "anthology" ? (
+        <TypeAnthology
+          articleId={articleId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id);
+            }
+          }}
+        />
+      ) : type === "term" ? (
+        <TypeTerm
+          articleId={articleId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id);
+            }
+          }}
+        />
+      ) : type === "chapter" || type === "para" ? (
+        <TypePali
+          type={type}
+          articleId={articleId}
+          channelId={channelId}
+          mode={mode}
+          book={book}
+          para={para}
+          onArticleChange={(type: ArticleType, id: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id);
+            }
+          }}
+        />
+      ) : (
+        <></>
       )}
-      <Divider />
-      {extra}
-      <Divider />
     </div>
   );
 };

+ 2 - 0
dashboard/src/components/article/ArticleDrawer.tsx

@@ -12,6 +12,7 @@ interface IWidget {
   para?: string;
   channelId?: string;
   articleId?: string;
+  anthologyId?: string;
   mode?: ArticleMode;
   open?: boolean;
   onClose?: Function;
@@ -25,6 +26,7 @@ const ArticleDrawerWidget = ({
   para,
   channelId,
   articleId,
+  anthologyId,
   mode,
   open,
   onClose,

+ 22 - 7
dashboard/src/components/article/ArticleEdit.tsx

@@ -1,8 +1,10 @@
-import { useState } from "react";
+import { useRef, useState } from "react";
 
 import { useIntl } from "react-intl";
 import {
   ProForm,
+  ProFormInstance,
+  ProFormSwitch,
   ProFormText,
   ProFormTextArea,
 } from "@ant-design/pro-components";
@@ -30,11 +32,13 @@ interface IFormData {
   content_type?: string;
   status: number;
   lang: string;
+  to_tpl?: boolean;
 }
 
 interface IWidget {
   studioName?: string;
   articleId?: string;
+  anthologyId?: string;
   onReady?: Function;
   onLoad?: Function;
   onChange?: Function;
@@ -43,6 +47,7 @@ interface IWidget {
 const ArticleEditWidget = ({
   studioName,
   articleId,
+  anthologyId,
   onReady,
   onLoad,
   onChange,
@@ -52,6 +57,7 @@ const ArticleEditWidget = ({
   const [readonly, setReadonly] = useState(false);
   const [content, setContent] = useState<string>();
   const [owner, setOwner] = useState<IStudio>();
+  const formRef = useRef<ProFormInstance>();
 
   return unauthorized ? (
     <Result
@@ -75,8 +81,9 @@ const ArticleEditWidget = ({
         />
       ) : undefined}
       <ProForm<IFormData>
+        formRef={formRef}
         onFinish={async (values: IFormData) => {
-          const request = {
+          const request: IArticleDataRequest = {
             uid: articleId ? articleId : "",
             title: values.title,
             subtitle: values.subtitle,
@@ -85,18 +92,19 @@ const ArticleEditWidget = ({
             content_type: "markdown",
             status: values.status,
             lang: values.lang,
+            to_tpl: values.to_tpl,
+            anthology_id: anthologyId,
           };
-          console.log("save", request);
-          put<IArticleDataRequest, IArticleResponse>(
-            `/v2/article/${articleId}`,
-            request
-          )
+          const url = `/v2/article/${articleId}`;
+          console.log("save", url, request);
+          put<IArticleDataRequest, IArticleResponse>(url, request)
             .then((res) => {
               console.log("save response", res);
               if (res.ok) {
                 if (typeof onChange !== "undefined") {
                   onChange(res.data);
                 }
+                formRef.current?.setFieldValue("content", res.data.content);
                 message.success(intl.formatMessage({ id: "flashes.success" }));
               } else {
                 message.error(res.message);
@@ -205,6 +213,13 @@ const ArticleEditWidget = ({
             />
           </Form.Item>
         </ProForm.Group>
+        <ProForm.Group>
+          <ProFormSwitch
+            name="to_tpl"
+            label="转换为模版"
+            disabled={anthologyId ? false : true}
+          />
+        </ProForm.Group>
       </ProForm>
     </>
   );

+ 3 - 10
dashboard/src/components/article/ArticleEditDrawer.tsx

@@ -8,6 +8,7 @@ import ArticleEditTools from "./ArticleEditTools";
 interface IWidget {
   trigger?: React.ReactNode;
   articleId?: string;
+  anthologyId?: string;
   open?: boolean;
   onClose?: Function;
   onChange?: Function;
@@ -16,6 +17,7 @@ interface IWidget {
 const ArticleEditDrawerWidget = ({
   trigger,
   articleId,
+  anthologyId,
   open,
   onClose,
   onChange,
@@ -39,16 +41,6 @@ const ArticleEditDrawerWidget = ({
       onClose();
     }
   };
-  /*
-  const getUrl = (openMode?: string): string => {
-    let url = `/article/${type}/${articleId}?mode=`;
-    url += openMode ? openMode : mode ? mode : "read";
-    url += channelId ? `&channel=${channelId}` : "";
-    url += book ? `&book=${book}` : "";
-    url += para ? `&par=${para}` : "";
-    return url;
-  };
-*/
 
   return (
     <>
@@ -69,6 +61,7 @@ const ArticleEditDrawerWidget = ({
         }
       >
         <ArticleEdit
+          anthologyId={anthologyId}
           articleId={articleId}
           onReady={(title: string, readonly: boolean, studio?: string) => {
             setTitle(title);

+ 5 - 1
dashboard/src/components/article/ArticleEditTools.tsx

@@ -22,7 +22,11 @@ const ArticleEditToolsWidget = ({
   return (
     <Space>
       {articleId ? (
-        <AddToAnthology studioName={studioName} articleIds={[articleId]} />
+        <AddToAnthology
+          trigger={<Button type="link">加入文集</Button>}
+          studioName={studioName}
+          articleIds={[articleId]}
+        />
       ) : undefined}
       {articleId ? (
         <ShareModal

+ 2 - 1
dashboard/src/components/article/ArticleList.tsx

@@ -306,7 +306,7 @@ const ArticleListWidget = ({
                         key: "addToAnthology",
                         label: (
                           <AddToAnthology
-                            trigger="加入文集"
+                            trigger={<Button type="link">加入文集</Button>}
                             studioName={studioName}
                             articleIds={[row.id]}
                           />
@@ -407,6 +407,7 @@ const ArticleListWidget = ({
               </Button>
               <AddToAnthology
                 studioName={studioName}
+                trigger={<Button type="link">加入文集</Button>}
                 articleIds={selectedRowKeys.map((item) => item.toString())}
                 onFinally={() => {
                   onCleanSelected();

+ 6 - 20
dashboard/src/components/article/ArticleView.tsx

@@ -6,6 +6,7 @@ import TocPath, { ITocPathNode } from "../corpus/TocPath";
 import PaliChapterChannelList from "../corpus/PaliChapterChannelList";
 import { ArticleType } from "./Article";
 import VisibleObserver from "../general/VisibleObserver";
+import { useEffect, useState } from "react";
 
 const { Paragraph, Title, Text } = Typography;
 export interface IFirstAnthology {
@@ -30,7 +31,6 @@ export interface IWidgetArticleData {
   anthology?: IFirstAnthology;
   onEnd?: Function;
   onPathChange?: Function;
-  onAnthologySelect?: Function;
 }
 
 const ArticleViewWidget = ({
@@ -50,8 +50,11 @@ const ArticleViewWidget = ({
   onEnd,
   remains,
   onPathChange,
-  onAnthologySelect,
 }: IWidgetArticleData) => {
+  const [currPath, setCurrPath] = useState(path);
+
+  useEffect(() => setCurrPath(path), [path]);
+
   let currChannelList = <></>;
   switch (type) {
     case "chapter":
@@ -86,25 +89,8 @@ const ArticleViewWidget = ({
       </div>
 
       <Space direction="vertical">
-        <Text>
-          {path.length === 0 && anthology ? (
-            <>
-              <Text>{"文集:"}</Text>
-              <Button
-                type="link"
-                onClick={() => {
-                  if (typeof onAnthologySelect !== "undefined") {
-                    onAnthologySelect(anthology.id);
-                  }
-                }}
-              >
-                {anthology.title}
-              </Button>
-            </>
-          ) : undefined}
-        </Text>
         <TocPath
-          data={path}
+          data={currPath}
           channel={channels}
           onChange={(
             node: ITocPathNode,

+ 5 - 0
dashboard/src/components/article/EditableTree.tsx

@@ -15,6 +15,7 @@ export interface TreeNodeData {
   key: string;
   id: string;
   title: string | React.ReactNode;
+  title_text?: string | React.ReactNode;
   icon?: React.ReactNode;
   children: TreeNodeData[];
   deletedAt?: string | null;
@@ -23,6 +24,7 @@ export interface TreeNodeData {
 export type ListNodeData = {
   key: string;
   title: string | React.ReactNode;
+  title_text?: string | React.ReactNode;
   level: number;
   children?: number;
   deletedAt?: string | null;
@@ -38,6 +40,7 @@ function tocGetTreeData(articles: ListNodeData[], active = "") {
     key: randomString(),
     id: "0",
     title: "root",
+    title_text: "root",
     level: 0,
     children: [],
   };
@@ -53,6 +56,7 @@ function tocGetTreeData(articles: ListNodeData[], active = "") {
       key: randomString(),
       id: element.key,
       title: element.title,
+      title_text: element.title_text,
       children: [],
       icon: keys.includes(element.key) ? <LinkOutlined /> : undefined,
       level: element.level,
@@ -113,6 +117,7 @@ function treeToList(treeNode: TreeNodeData[]): ListNodeData[] {
     arrTocTree.push({
       key: node.id,
       title: node.title,
+      title_text: node.title_text,
       level: iTocTreeCurrLevel,
       children: children,
       deletedAt: node.deletedAt,

+ 2 - 2
dashboard/src/components/article/EditableTreeNode.tsx

@@ -22,7 +22,7 @@ const EditableTreeNodeWidget = ({
 
   const title = node.deletedAt ? (
     <Text delete disabled>
-      {node.title}
+      {node.title_text ? node.title_text : node.title}
     </Text>
   ) : (
     <Text
@@ -32,7 +32,7 @@ const EditableTreeNodeWidget = ({
         }
       }}
     >
-      {node.title}
+      {node.title_text ? node.title_text : node.title}
     </Text>
   );
   const menu = (

+ 4 - 0
dashboard/src/components/article/ToolButtonToc.tsx

@@ -10,14 +10,17 @@ interface IWidget {
   type?: ArticleType;
   articleId?: string;
   anthologyId?: string | null;
+  channels?: string[];
   onSelect?: Function;
 }
 const ToolButtonTocWidget = ({
   type,
   articleId,
   anthologyId,
+  channels,
   onSelect,
 }: IWidget) => {
+  //TODO 都放return里面
   let tocWidget = <></>;
   if (type === "chapter" || type === "para") {
     if (articleId) {
@@ -41,6 +44,7 @@ const ToolButtonTocWidget = ({
       tocWidget = (
         <AnthologyTocTree
           anthologyId={anthologyId}
+          channels={channels}
           onArticleSelect={(anthologyId: string, keys: string[]) => {
             if (typeof onSelect !== "undefined" && keys.length > 0) {
               onSelect(keys[0]);

+ 59 - 0
dashboard/src/components/article/TypeAnthology.tsx

@@ -0,0 +1,59 @@
+import { ArticleMode, ArticleType } from "./Article";
+import AnthologyDetail from "./AnthologyDetail";
+import "./article.css";
+import { useState } from "react";
+import ErrorResult from "../general/ErrorResult";
+import ArticleSkeleton from "./ArticleSkeleton";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+}
+const TypeAnthologyWidget = ({
+  type,
+  channelId,
+  articleId,
+  mode = "read",
+  onArticleChange,
+}: IWidget) => {
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+
+  const channels = channelId?.split("_");
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <></>
+      )}
+      <AnthologyDetail
+        visible={!loading}
+        onArticleSelect={(anthologyId: string, keys: string[]) => {
+          if (typeof onArticleChange !== "undefined" && keys.length > 0) {
+            onArticleChange("article", keys[0], {
+              anthologyId: anthologyId,
+            });
+          }
+        }}
+        onLoading={(loading: boolean) => {
+          setLoading(loading);
+        }}
+        onError={(code: number, message: string) => {
+          setErrorCode(code);
+        }}
+        channels={channels}
+        aid={articleId}
+      />
+    </div>
+  );
+};
+
+export default TypeAnthologyWidget;

+ 204 - 0
dashboard/src/components/article/TypeArticle.tsx

@@ -0,0 +1,204 @@
+import { useEffect, useState } from "react";
+import { Divider, message, Space, Tag } from "antd";
+
+import { get } from "../../request";
+import { IArticleDataResponse, IArticleResponse } from "../api/Article";
+import ArticleView, { IFirstAnthology } from "./ArticleView";
+import TocTree from "./TocTree";
+import PaliText from "../template/Wbw/PaliText";
+import { ITocPathNode } from "../corpus/TocPath";
+import { ArticleMode, ArticleType } from "./Article";
+import "./article.css";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+import AnthologiesAtArticle from "./AnthologiesAtArticle";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  anthologyId?: string | null;
+  active?: boolean;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+  onAnthologySelect?: Function;
+}
+const TypeArticleWidget = ({
+  type,
+  channelId,
+  articleId,
+  anthologyId,
+  mode = "read",
+  active = false,
+  onArticleChange,
+  onFinal,
+  onLoad,
+  onAnthologySelect,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [extra, setExtra] = useState(<></>);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+  const [currPath, setCurrPath] = useState<ITocPathNode[]>();
+
+  const channels = channelId?.split("_");
+
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+  useEffect(() => {
+    console.log("srcDataMode", srcDataMode);
+    if (!active) {
+      return;
+    }
+
+    if (typeof type === "undefined") {
+      return;
+    }
+
+    let url = `/v2/article/${articleId}?mode=${srcDataMode}`;
+    url += channelId ? `&channel=${channelId}` : "";
+    url += anthologyId ? `&anthology=${anthologyId}` : "";
+    console.log("url", url);
+    setLoading(true);
+    console.log("url", url);
+    get<IArticleResponse>(url)
+      .then((json) => {
+        console.log("article", json);
+        if (json.ok) {
+          setArticleData(json.data);
+          setCurrPath(json.data.path);
+          if (json.data.html) {
+            setArticleHtml([json.data.html]);
+          } else if (json.data.content) {
+            setArticleHtml([json.data.content]);
+          } else {
+            setArticleHtml([""]);
+          }
+          setExtra(
+            <TocTree
+              treeData={json.data.toc?.map((item) => {
+                const strTitle = item.title ? item.title : item.pali_title;
+                const key = item.key
+                  ? item.key
+                  : `${item.book}-${item.paragraph}`;
+                const progress = item.progress?.map((item, id) => (
+                  <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
+                ));
+                return {
+                  key: key,
+                  title: (
+                    <Space>
+                      <PaliText
+                        text={strTitle === "" ? "[unnamed]" : strTitle}
+                      />
+                      {progress}
+                    </Space>
+                  ),
+                  level: item.level,
+                };
+              })}
+              onSelect={(keys: string[]) => {
+                console.log(keys);
+                if (typeof onArticleChange !== "undefined" && keys.length > 0) {
+                  onArticleChange("article", keys[0]);
+                }
+              }}
+            />
+          );
+
+          if (typeof onLoad !== "undefined") {
+            onLoad(json.data);
+          }
+        } else {
+          console.error("json", json);
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+      })
+      .catch((e) => {
+        console.error(e);
+        setErrorCode(e);
+      });
+  }, [active, type, articleId, srcDataMode, channelId, anthologyId]);
+
+  let anthology: IFirstAnthology | undefined;
+  if (articleData?.anthology_count && articleData.anthology_first) {
+    anthology = {
+      id: articleData.anthology_first.uid,
+      title: articleData.anthology_first.title,
+      count: articleData?.anthology_count,
+    };
+  }
+
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <>
+          <AnthologiesAtArticle
+            articleId={articleId}
+            anthologyId={anthologyId}
+            onClick={(
+              id: string,
+              e: React.MouseEvent<HTMLElement, MouseEvent>
+            ) => {
+              if (typeof onAnthologySelect !== "undefined") {
+                onAnthologySelect(id, e);
+              }
+            }}
+          />
+          <ArticleView
+            id={articleData?.uid}
+            title={
+              articleData?.title_text
+                ? articleData?.title_text
+                : articleData?.title
+            }
+            subTitle={articleData?.subtitle}
+            summary={articleData?.summary}
+            content={articleData ? articleData.content : ""}
+            html={articleHtml}
+            path={currPath}
+            created_at={articleData?.created_at}
+            updated_at={articleData?.updated_at}
+            channels={channels}
+            type={type}
+            articleId={articleId}
+            anthology={anthology}
+            onPathChange={(
+              node: ITocPathNode,
+              e: React.MouseEvent<
+                HTMLSpanElement | HTMLAnchorElement,
+                MouseEvent
+              >
+            ) => {
+              let newType = type;
+              if (node.level === 0) {
+                newType = "anthology";
+              } else {
+                newType = "article";
+              }
+              if (typeof onArticleChange !== "undefined") {
+                const newArticleId = node.key;
+                const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+                onArticleChange(newType, newArticleId, target);
+              }
+            }}
+          />
+          <Divider />
+          {extra}
+          <Divider />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default TypeArticleWidget;

+ 269 - 0
dashboard/src/components/article/TypeCourse.tsx

@@ -0,0 +1,269 @@
+import { useEffect, useState } from "react";
+import { Divider, message, Result, Space, Tag } from "antd";
+
+import { get } from "../../request";
+import store from "../../store";
+import { IArticleDataResponse, IArticleResponse } from "../api/Article";
+import ArticleView from "./ArticleView";
+import { ICourseCurrUserResponse } from "../api/Course";
+import { ICourseUser, signIn } from "../../reducers/course-user";
+import { ITextbook, refresh } from "../../reducers/current-course";
+import ExerciseList from "./ExerciseList";
+import ExerciseAnswer from "../course/ExerciseAnswer";
+import "./article.css";
+import TocTree from "./TocTree";
+import PaliText from "../template/Wbw/PaliText";
+import { ITocPathNode } from "../corpus/TocPath";
+import { ArticleMode, ArticleType } from "./Article";
+
+/**
+ * 每种article type 对应的路由参数
+ * article/id?anthology=id&channel=id1,id2&mode=ArticleMode
+ * chapter/book-para?channel=id1,id2&mode=ArticleMode
+ * para/book?par=para1,para2&channel=id1,id2&mode=ArticleMode
+ * cs-para/book-para?channel=id1,id2&mode=ArticleMode
+ * sent/id?channel=id1,id2&mode=ArticleMode
+ * sim/id?channel=id1,id2&mode=ArticleMode
+ * textbook/articleId?course=id&mode=ArticleMode
+ * exercise/articleId?course=id&exercise=id&username=name&mode=ArticleMode
+ * exercise-list/articleId?course=id&exercise=id&mode=ArticleMode
+ * sent-original/id
+ */
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+  courseId?: string;
+  exerciseId?: string;
+  userName?: string;
+  active?: boolean;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+  onLoading?: Function;
+  onError?: Function;
+}
+const TypeCourseWidget = ({
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  courseId,
+  exerciseId,
+  userName,
+  mode = "read",
+  active = false,
+  onArticleChange,
+  onFinal,
+  onLoad,
+  onLoading,
+  onError,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [extra, setExtra] = useState(<></>);
+
+  const channels = channelId?.split("_");
+
+  useEffect(() => {
+    /**
+     * 由课本进入查询当前用户的权限和channel
+     */
+    if (
+      type === "textbook" ||
+      type === "exercise" ||
+      type === "exercise-list"
+    ) {
+      if (typeof articleId !== "undefined") {
+        const id = articleId.split("_");
+        get<ICourseCurrUserResponse>(`/v2/course-curr?course_id=${id[0]}`).then(
+          (response) => {
+            console.log("course user", response);
+            if (response.ok) {
+              const it: ICourseUser = {
+                channelId: response.data.channel_id,
+                role: response.data.role,
+              };
+              store.dispatch(signIn(it));
+              /**
+               * redux发布课程信息
+               */
+              const ic: ITextbook = {
+                courseId: id[0],
+                articleId: id[1],
+              };
+              store.dispatch(refresh(ic));
+            }
+          }
+        );
+      }
+    }
+  }, [articleId, type]);
+
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+  useEffect(() => {
+    console.log("srcDataMode", srcDataMode);
+    if (!active) {
+      return;
+    }
+
+    if (typeof type !== "undefined") {
+      let url = "";
+      switch (type) {
+        case "textbook":
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?view=textbook&course=${courseId}&mode=${srcDataMode}`;
+          }
+          break;
+        case "exercise":
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}&user=${userName}`;
+            setExtra(
+              <ExerciseAnswer
+                courseId={courseId}
+                articleId={articleId}
+                exerciseId={exerciseId}
+              />
+            );
+          }
+          break;
+        case "exercise-list":
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}`;
+
+            setExtra(
+              <ExerciseList
+                courseId={courseId}
+                articleId={articleId}
+                exerciseId={exerciseId}
+              />
+            );
+          }
+          break;
+      }
+
+      console.log("url", url);
+      if (typeof onLoading !== "undefined") {
+        onLoading(true);
+      }
+
+      console.log("url", url);
+
+      get<IArticleResponse>(url)
+        .then((json) => {
+          console.log("article", json);
+          if (json.ok) {
+            setArticleData(json.data);
+            if (json.data.html) {
+              setArticleHtml([json.data.html]);
+            } else if (json.data.content) {
+              setArticleHtml([json.data.content]);
+            }
+            setExtra(
+              <TocTree
+                treeData={json.data.toc?.map((item) => {
+                  const strTitle = item.title ? item.title : item.pali_title;
+                  const key = item.key
+                    ? item.key
+                    : `${item.book}-${item.paragraph}`;
+                  const progress = item.progress?.map((item, id) => (
+                    <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
+                  ));
+                  return {
+                    key: key,
+                    title: (
+                      <Space>
+                        <PaliText
+                          text={strTitle === "" ? "[unnamed]" : strTitle}
+                        />
+                        {progress}
+                      </Space>
+                    ),
+                    level: item.level,
+                  };
+                })}
+                onSelect={(keys: string[]) => {
+                  console.log(keys);
+                  if (
+                    typeof onArticleChange !== "undefined" &&
+                    keys.length > 0
+                  ) {
+                    onArticleChange(keys[0]);
+                  }
+                }}
+              />
+            );
+
+            if (typeof onLoad !== "undefined") {
+              onLoad(json.data);
+            }
+          } else {
+            if (typeof onError !== "undefined") {
+              onError(json.data, json.message);
+            }
+            message.error(json.message);
+          }
+        })
+        .finally(() => {
+          if (typeof onLoading !== "undefined") {
+            onLoading(false);
+          }
+        })
+        .catch((e) => {
+          console.error(e);
+        });
+    }
+  }, [
+    active,
+    type,
+    articleId,
+    srcDataMode,
+    channelId,
+    courseId,
+    exerciseId,
+    userName,
+  ]);
+
+  return (
+    <div>
+      <ArticleView
+        id={articleData?.uid}
+        title={
+          articleData?.title_text ? articleData?.title_text : articleData?.title
+        }
+        subTitle={articleData?.subtitle}
+        summary={articleData?.summary}
+        content={articleData ? articleData.content : ""}
+        html={articleHtml}
+        path={articleData?.path}
+        created_at={articleData?.created_at}
+        updated_at={articleData?.updated_at}
+        channels={channels}
+        type={type}
+        articleId={articleId}
+        onPathChange={(
+          node: ITocPathNode,
+          e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
+        ) => {
+          let newType = type;
+          if (typeof onArticleChange !== "undefined") {
+            const newArticleId = node.key
+              ? node.key
+              : `${node.book}-${node.paragraph}`;
+            const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+            onArticleChange(newType, newArticleId, target);
+          }
+        }}
+      />
+      <Divider />
+      {extra}
+      <Divider />
+    </div>
+  );
+};
+
+export default TypeCourseWidget;

+ 269 - 0
dashboard/src/components/article/TypePali.tsx

@@ -0,0 +1,269 @@
+import { useEffect, useState } from "react";
+import { Divider, message, Result, Space, Tag } from "antd";
+
+import { get, post } from "../../request";
+import { IArticleDataResponse, IArticleResponse } from "../api/Article";
+import ArticleView from "./ArticleView";
+import TocTree from "./TocTree";
+import PaliText from "../template/Wbw/PaliText";
+import { IViewRequest, IViewStoreResponse } from "../api/view";
+import { ITocPathNode } from "../corpus/TocPath";
+import { ArticleMode, ArticleType } from "./Article";
+import "./article.css";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+  active?: boolean;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+}
+const TypePaliWidget = ({
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  mode = "read",
+  active = true,
+  onArticleChange,
+  onFinal,
+  onLoad,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [extra, setExtra] = useState(<></>);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+
+  const [remains, setRemains] = useState(false);
+
+  const channels = channelId?.split("_");
+
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+  useEffect(() => {
+    console.log("srcDataMode", srcDataMode);
+    if (!active) {
+      return;
+    }
+    if (typeof type === "undefined") {
+      return;
+    }
+
+    let url = "";
+    switch (type) {
+      case "chapter":
+        if (typeof articleId !== "undefined") {
+          url = `/v2/corpus-chapter/${articleId}?mode=${srcDataMode}`;
+          url += channelId ? `&channels=${channelId}` : "";
+        }
+        break;
+      case "para":
+        const _book = book ? book : articleId;
+        url = `/v2/corpus?view=para&book=${_book}&par=${para}&mode=${srcDataMode}`;
+        url += channelId ? `&channels=${channelId}` : "";
+        break;
+      default:
+        if (typeof articleId !== "undefined") {
+          url = `/v2/corpus/${type}/${articleId}/${srcDataMode}?mode=${srcDataMode}`;
+          url += channelId ? `&channel=${channelId}` : "";
+        }
+        break;
+    }
+
+    setLoading(true);
+    console.log("url", url);
+    get<IArticleResponse>(url)
+      .then((json) => {
+        console.log("article", json);
+        if (json.ok) {
+          setArticleData(json.data);
+          if (json.data.html) {
+            setArticleHtml([json.data.html]);
+          } else if (json.data.content) {
+            setArticleHtml([json.data.content]);
+          }
+          if (json.data.from) {
+            setRemains(true);
+          }
+          setExtra(
+            <TocTree
+              treeData={json.data.toc?.map((item) => {
+                const strTitle = item.title ? item.title : item.pali_title;
+                const key = item.key
+                  ? item.key
+                  : `${item.book}-${item.paragraph}`;
+                const progress = item.progress?.map((item, id) => (
+                  <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
+                ));
+                return {
+                  key: key,
+                  title: (
+                    <Space>
+                      <PaliText
+                        text={strTitle === "" ? "[unnamed]" : strTitle}
+                      />
+                      {progress}
+                    </Space>
+                  ),
+                  level: item.level,
+                };
+              })}
+              onSelect={(keys: string[]) => {
+                console.log(keys);
+                if (typeof onArticleChange !== "undefined" && keys.length > 0) {
+                  onArticleChange(keys[0]);
+                }
+              }}
+            />
+          );
+
+          switch (type) {
+            case "chapter":
+              if (typeof articleId === "string" && channelId) {
+                const [book, para] = articleId?.split("-");
+                post<IViewRequest, IViewStoreResponse>("/v2/view", {
+                  target_type: type,
+                  book: parseInt(book),
+                  para: parseInt(para),
+                  channel: channelId,
+                  mode: srcDataMode,
+                }).then((json) => {
+                  console.log("view", json.data);
+                });
+              }
+              break;
+            default:
+              break;
+          }
+
+          if (typeof onLoad !== "undefined") {
+            onLoad(json.data);
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+      })
+      .catch((e) => {
+        console.error(e);
+        setErrorCode(e);
+      });
+  }, [active, type, articleId, srcDataMode, book, para, channelId]);
+
+  const getNextPara = (next: IArticleDataResponse): void => {
+    if (
+      typeof next.paraId === "undefined" ||
+      typeof next.mode === "undefined" ||
+      typeof next.from === "undefined" ||
+      typeof next.to === "undefined"
+    ) {
+      setRemains(false);
+      return;
+    }
+    let url = `/v2/corpus-chapter/${next.paraId}?mode=${next.mode}`;
+    url += `&from=${next.from}`;
+    url += `&to=${next.to}`;
+    url += channels ? `&channels=${channels}` : "";
+    console.log("lazy load", url);
+    get<IArticleResponse>(url).then((json) => {
+      if (json.ok) {
+        if (typeof json.data.content === "string") {
+          const content: string = json.data.content;
+          setArticleData((origin) => {
+            if (origin) {
+              origin.from = json.data.from;
+            }
+            return origin;
+          });
+          setArticleHtml((origin) => {
+            return [...origin, content];
+          });
+        }
+
+        //getNextPara(json.data);
+      }
+    });
+    return;
+  };
+
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <>
+          <ArticleView
+            id={articleData?.uid}
+            title={
+              articleData?.title_text
+                ? articleData?.title_text
+                : articleData?.title
+            }
+            subTitle={articleData?.subtitle}
+            summary={articleData?.summary}
+            content={articleData ? articleData.content : ""}
+            html={articleHtml}
+            path={articleData?.path}
+            created_at={articleData?.created_at}
+            updated_at={articleData?.updated_at}
+            channels={channels}
+            type={type}
+            articleId={articleId}
+            remains={remains}
+            onEnd={() => {
+              if (type === "chapter" && articleData) {
+                getNextPara(articleData);
+              }
+            }}
+            onPathChange={(
+              node: ITocPathNode,
+              e: React.MouseEvent<
+                HTMLSpanElement | HTMLAnchorElement,
+                MouseEvent
+              >
+            ) => {
+              let newType = type;
+              if (node.level === 0) {
+                switch (type) {
+                  case "article":
+                    newType = "anthology";
+                    break;
+                  case "chapter":
+                    newType = "series";
+                    break;
+                  default:
+                    break;
+                }
+              }
+
+              if (typeof onArticleChange !== "undefined") {
+                const newArticle = node.key
+                  ? node.key
+                  : `${node.book}-${node.paragraph}`;
+                const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+                onArticleChange(newType, newArticle, target);
+              }
+            }}
+          />
+          <Divider />
+          {extra}
+          <Divider />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default TypePaliWidget;

+ 110 - 0
dashboard/src/components/article/TypeTerm.tsx

@@ -0,0 +1,110 @@
+import { useEffect, useState } from "react";
+
+import { get } from "../../request";
+import { IArticleDataResponse } from "../api/Article";
+import ArticleView from "./ArticleView";
+import { ITermResponse } from "../api/Term";
+import { ArticleMode } from "./Article";
+import "./article.css";
+import { message } from "antd";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+
+interface IWidget {
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+}
+const TypeTermWidget = ({
+  channelId,
+  articleId,
+  mode = "read",
+  onArticleChange,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+
+  const channels = channelId?.split("_");
+
+  useEffect(() => {
+    if (typeof articleId === "undefined") {
+      return;
+    }
+    const queryMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+    let url = "";
+    url = `/v2/terms/${articleId}?mode=${queryMode}`;
+    url += channelId ? `&channel=${channelId}` : "";
+
+    console.log("article url", url);
+    setLoading(true);
+    console.log("url", url);
+    get<ITermResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          setArticleData({
+            uid: json.data.guid,
+            title: json.data.meaning,
+            subtitle: json.data.word,
+            summary: json.data.note,
+            content: json.data.note ? json.data.note : "",
+            content_type: "markdown",
+            html: json.data.html,
+            path: [],
+            status: 30,
+            lang: json.data.language,
+            created_at: json.data.created_at,
+            updated_at: json.data.updated_at,
+          });
+          if (json.data.html) {
+            setArticleHtml([json.data.html]);
+          } else if (json.data.note) {
+            setArticleHtml([json.data.note]);
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+      })
+      .catch((e) => {
+        console.error(e);
+        setErrorCode(e);
+      });
+  }, [articleId, channelId, mode]);
+
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <>
+          {" "}
+          <ArticleView
+            id={articleData?.uid}
+            title={articleData?.title}
+            subTitle={articleData?.subtitle}
+            summary={articleData?.summary}
+            content={articleData ? articleData.content : ""}
+            html={articleHtml}
+            path={articleData?.path}
+            created_at={articleData?.created_at}
+            updated_at={articleData?.updated_at}
+            channels={channels}
+            type={"term"}
+            articleId={articleId}
+          />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default TypeTermWidget;

+ 3 - 0
dashboard/src/components/auth/setting/SettingItem.tsx

@@ -24,10 +24,12 @@ const { Text } = Typography;
 interface IWidgetSettingItem {
   data?: ISetting;
   autoSave?: boolean;
+  bordered?: boolean;
   onChange?: Function;
 }
 const SettingItemWidget = ({
   data,
+  bordered = true,
   onChange,
   autoSave = true,
 }: IWidgetSettingItem) => {
@@ -146,6 +148,7 @@ const SettingItemWidget = ({
                   <Select
                     defaultValue={data.defaultValue}
                     style={{ width: 120 }}
+                    bordered={bordered}
                     onChange={(value: string) => {
                       console.log(`selected ${value}`);
                       if (autoSave) {

+ 6 - 1
dashboard/src/components/channel/ChannelTypeSelect.tsx

@@ -1,7 +1,11 @@
 import { useIntl } from "react-intl";
 import { ProFormSelect } from "@ant-design/pro-components";
 
-const ChannelTypeSelectWidget = () => {
+interface IWidget {
+  readonly?: boolean;
+}
+
+const ChannelTypeSelectWidget = ({ readonly }: IWidget) => {
   const intl = useIntl();
 
   const channelTypeOptions = [
@@ -32,6 +36,7 @@ const ChannelTypeSelectWidget = () => {
       initialValue="translation"
       width="xs"
       name="type"
+      readonly={readonly}
       allowClear={false}
       label={intl.formatMessage({ id: "channel.type" })}
       rules={[

+ 72 - 49
dashboard/src/components/channel/Edit.tsx

@@ -4,13 +4,14 @@ import {
   ProFormText,
   ProFormTextArea,
 } from "@ant-design/pro-components";
-import { message } from "antd";
+import { Alert, message } from "antd";
 
 import { IApiResponseChannel } from "../../components/api/Channel";
 import { get, put } from "../../request";
 import ChannelTypeSelect from "../../components/channel/ChannelTypeSelect";
 import LangSelect from "../../components/general/LangSelect";
 import PublicitySelect from "../../components/studio/PublicitySelect";
+import { useState } from "react";
 
 interface IFormData {
   name: string;
@@ -19,6 +20,7 @@ interface IFormData {
   summary: string;
   status: number;
   studio: string;
+  isSystem: boolean;
 }
 interface IWidget {
   studioName?: string;
@@ -27,57 +29,78 @@ interface IWidget {
 }
 const EditWidget = ({ studioName, channelId, onLoad }: IWidget) => {
   const intl = useIntl();
-
+  const [isSystem, setIsSystem] = useState<Boolean>();
   return (
-    <ProForm<IFormData>
-      onFinish={async (values: IFormData) => {
-        console.log(values);
-        const res = await put(`/v2/channel/${channelId}`, values);
-        console.log(res);
-        message.success(intl.formatMessage({ id: "flashes.success" }));
-      }}
-      formKey="channel_edit"
-      request={async () => {
-        const res = await get<IApiResponseChannel>(`/v2/channel/${channelId}`);
-        if (typeof onLoad !== "undefined") {
-          onLoad(res.data);
-        }
-        return {
-          name: res.data.name,
-          type: res.data.type,
-          lang: res.data.lang,
-          summary: res.data.summary,
-          status: res.data.status,
-          studio: studioName ? studioName : "",
-        };
-      }}
-    >
-      <ProForm.Group>
-        <ProFormText
-          width="md"
-          name="name"
-          required
-          label={intl.formatMessage({ id: "channel.name" })}
-          rules={[
-            {
-              required: true,
-            },
-          ]}
-        />
-      </ProForm.Group>
+    <>
+      {isSystem ? (
+        <Alert type="warning" message={"此版本为系统建立。不能修改,删除。"} />
+      ) : (
+        <></>
+      )}
+      <ProForm<IFormData>
+        submitter={{
+          resetButtonProps: { disabled: isSystem ? true : false },
+          submitButtonProps: { disabled: isSystem ? true : false },
+        }}
+        onFinish={async (values: IFormData) => {
+          console.log(values);
+          const res = await put(`/v2/channel/${channelId}`, values);
+          console.log(res);
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+        }}
+        formKey="channel_edit"
+        request={async () => {
+          const res = await get<IApiResponseChannel>(
+            `/v2/channel/${channelId}`
+          );
+          if (typeof onLoad !== "undefined") {
+            onLoad(res.data);
+          }
+          setIsSystem(res.data.is_system);
+          return {
+            name: res.data.name,
+            type: res.data.type,
+            lang: res.data.lang,
+            summary: res.data.summary,
+            status: res.data.status,
+            studio: studioName ? studioName : "",
+            isSystem: res.data.is_system,
+          };
+        }}
+      >
+        <ProForm.Group>
+          <ProFormText
+            width="md"
+            name="name"
+            readonly={isSystem ? true : false}
+            required
+            label={intl.formatMessage({ id: "channel.name" })}
+            rules={[
+              {
+                required: true,
+              },
+            ]}
+          />
+        </ProForm.Group>
 
-      <ProForm.Group>
-        <ChannelTypeSelect />
-        <LangSelect />
-      </ProForm.Group>
-      <ProForm.Group>
-        <PublicitySelect />
-      </ProForm.Group>
+        <ProForm.Group>
+          <ChannelTypeSelect readonly={isSystem ? true : false} />
+          <LangSelect readonly={isSystem ? true : false} />
+        </ProForm.Group>
+        <ProForm.Group>
+          <PublicitySelect readonly={isSystem ? true : false} />
+        </ProForm.Group>
 
-      <ProForm.Group>
-        <ProFormTextArea width="md" name="summary" label="简介" />
-      </ProForm.Group>
-    </ProForm>
+        <ProForm.Group>
+          <ProFormTextArea
+            readonly={isSystem ? true : false}
+            width="md"
+            name="summary"
+            label="简介"
+          />
+        </ProForm.Group>
+      </ProForm>
+    </>
   );
 };
 

+ 15 - 4
dashboard/src/components/corpus/ChapterCard.tsx

@@ -1,4 +1,3 @@
-import { useIntl } from "react-intl";
 import { Link } from "react-router-dom";
 import { Row, Col, Progress, Space } from "antd";
 import { Typography } from "antd";
@@ -37,7 +36,6 @@ interface IWidget {
 }
 
 const ChapterCardWidget = ({ data, onTagClick }: IWidget) => {
-  const intl = useIntl();
   const path = JSON.parse(data.path);
   let url = `/article/chapter/${data.book}-${data.paragraph}`;
   url += data.channel.id ? `?channel=${data.channel.id}` : "";
@@ -71,7 +69,13 @@ const ChapterCardWidget = ({ data, onTagClick }: IWidget) => {
             </Paragraph>
           </Col>
         </Row>
-        <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <div
+          style={{
+            display: "flex",
+            flexWrap: "wrap",
+            justifyContent: "space-between",
+          }}
+        >
           <div>
             <TagArea
               data={data.tag}
@@ -82,7 +86,14 @@ const ChapterCardWidget = ({ data, onTagClick }: IWidget) => {
               }}
             />
           </div>
-          <Space>
+          <Space
+            style={{
+              flexWrap: "wrap",
+              justifyContent: "flex-end",
+              marginLeft: "auto",
+              fontSize: 12,
+            }}
+          >
             <ChannelListItem channel={data.channel} studio={data.studio} />
             <TimeShow updatedAt={data.updatedAt} />
           </Space>

+ 36 - 5
dashboard/src/components/corpus/TocPath.tsx

@@ -1,8 +1,8 @@
 import { useNavigate, useSearchParams } from "react-router-dom";
-import { Breadcrumb, Popover, Tag, Typography } from "antd";
+import { Breadcrumb, MenuProps, Popover, Tag, Typography } from "antd";
 
 import PaliText from "../template/Wbw/PaliText";
-import React from "react";
+import React, { useEffect, useState } from "react";
 import { fullUrl } from "../../utils";
 
 export interface ITocPathNode {
@@ -12,6 +12,7 @@ export interface ITocPathNode {
   title: string;
   paliTitle?: string;
   level: number;
+  menu?: MenuProps["items"];
 }
 
 export declare type ELinkType = "none" | "blank" | "self";
@@ -23,6 +24,7 @@ interface IWidgetTocPath {
   channel?: string[];
   style?: React.CSSProperties;
   onChange?: Function;
+  onMenuClick?: Function;
 }
 const TocPathWidget = ({
   data = [],
@@ -31,16 +33,33 @@ const TocPathWidget = ({
   channel,
   style,
   onChange,
+  onMenuClick,
 }: IWidgetTocPath): JSX.Element => {
+  const [currData, setCurrData] = useState(data);
   const navigate = useNavigate();
-  const [searchParams, setSearchParams] = useSearchParams();
+  const [searchParams] = useSearchParams();
+
+  console.log("data", data);
+  useEffect(() => setCurrData(data), [data]);
   const fullPath = (
     <Breadcrumb
       style={{ whiteSpace: "nowrap", width: "100%", fontSize: style?.fontSize }}
     >
-      {data.map((item, id) => {
+      {currData.map((item, id) => {
         return (
           <Breadcrumb.Item
+            menu={
+              item.menu
+                ? {
+                    items: item.menu,
+                    onClick: (e) => {
+                      if (typeof onMenuClick !== "undefined") {
+                        onMenuClick(e.key);
+                      }
+                    },
+                  }
+                : undefined
+            }
             onClick={(
               e: React.MouseEvent<
                 HTMLSpanElement | HTMLAnchorElement,
@@ -73,7 +92,19 @@ const TocPathWidget = ({
           >
             <Typography.Link>
               {item.level < 99 ? (
-                <PaliText text={item.title} />
+                <span
+                  style={
+                    item.level === 0
+                      ? {
+                          padding: "0 4px",
+                          backgroundColor: "rgba(128, 128, 128, 0.2)",
+                          borderRadius: 4,
+                        }
+                      : undefined
+                  }
+                >
+                  <PaliText text={item.title} />
+                </span>
               ) : (
                 <Tag>{item.title}</Tag>
               )}

+ 132 - 30
dashboard/src/components/dict/CaseList.tsx

@@ -1,7 +1,11 @@
-import { Button, List, Tag, Typography } from "antd";
+import { Badge, Button, Card, Checkbox, Select, Space, Typography } from "antd";
+import { DownOutlined, UpOutlined } from "@ant-design/icons";
 import { useEffect, useState } from "react";
+
 import { get } from "../../request";
-import { ICaseListResponse } from "../api/Dict";
+import { ICaseItem, ICaseListResponse } from "../api/Dict";
+import { CheckboxValueType } from "antd/lib/checkbox/Group";
+import { CheckboxChangeEvent } from "antd/es/checkbox";
 const { Text } = Typography;
 
 export interface ICaseListData {
@@ -12,12 +16,37 @@ export interface ICaseListData {
 interface IWidget {
   word?: string;
   lines?: number;
+  onChange?: Function;
 }
-const CaseListWidget = ({ word, lines }: IWidget) => {
+const CaseListWidget = ({ word, lines, onChange }: IWidget) => {
   const [caseData, setCaseData] = useState<ICaseListData[]>();
-  const [first, setFirst] = useState<string>();
-  const [count, setCount] = useState<number>();
   const [showAll, setShowAll] = useState(lines ? false : true);
+  const [words, setWords] = useState<ICaseItem[]>();
+  const [currWord, setCurrWord] = useState<string>();
+  const [checkedList, setCheckedList] = useState<CheckboxValueType[]>([]);
+
+  useEffect(() => {
+    setCaseData(
+      words
+        ?.find((value) => value.word === currWord)
+        ?.case.sort((a, b) => b.count - a.count)
+    );
+  }, [currWord, words]);
+
+  useEffect(() => {
+    if (typeof onChange !== "undefined" && checkedList.length > 0) {
+      onChange(checkedList);
+    }
+  }, [checkedList]);
+
+  useEffect(() => {
+    if (caseData) {
+      setCheckedList(caseData?.map((item) => item.word));
+    } else {
+      setCheckedList([]);
+    }
+  }, [caseData]);
+
   useEffect(() => {
     if (typeof word === "undefined") {
       return;
@@ -25,41 +54,114 @@ const CaseListWidget = ({ word, lines }: IWidget) => {
     get<ICaseListResponse>(`/v2/case/${word}`).then((json) => {
       console.log("case", json);
       if (json.ok && json.data.rows.length > 0) {
+        setWords(json.data.rows);
         const first = json.data.rows.sort((a, b) => b.count - a.count)[0];
-        setCaseData(first.case.sort((a, b) => b.count - a.count));
-        setCount(first.count);
-        setFirst(first.word);
+        setCurrWord(first.word);
       }
     });
   }, [word]);
+
+  let checkAll = true;
+  let indeterminate = false;
+  if (caseData && checkedList) {
+    checkAll = caseData?.length === checkedList?.length;
+    indeterminate =
+      checkedList.length > 0 && checkedList.length < caseData.length;
+  }
+
+  const onWordChange = (list: CheckboxValueType[]) => {
+    setCheckedList(list);
+  };
+
+  const onCheckAllChange = (e: CheckboxChangeEvent) => {
+    if (caseData) {
+      setCheckedList(
+        e.target.checked ? caseData?.map((item) => item.word) : []
+      );
+    } else {
+      setCheckedList([]);
+    }
+  };
+
+  const showWords = showAll ? caseData : caseData?.slice(0, lines);
   return (
     <div style={{ padding: 4 }}>
-      <List
-        header={`${first}:${count}`}
-        footer={
+      <Card
+        size="small"
+        extra={
           lines ? (
             <Button type="link" onClick={() => setShowAll(!showAll)}>
-              {showAll ? "折叠" : "展开"}
+              {showAll ? (
+                <Space>
+                  {"折叠"}
+                  <UpOutlined />
+                </Space>
+              ) : (
+                <Space>
+                  {"展开"}
+                  <DownOutlined />
+                </Space>
+              )}
             </Button>
-          ) : undefined
+          ) : (
+            <></>
+          )
         }
-        size="small"
-        dataSource={showAll ? caseData : caseData?.slice(0, lines)}
-        renderItem={(item) => (
-          <List.Item>
-            <div
-              style={{
-                display: "flex",
-                justifyContent: "space-between",
-                width: "100%",
-              }}
-            >
-              <Text strong={item.bold > 0 ? true : false}>{item.word}</Text>
-              <Tag>{item.count}</Tag>
-            </div>
-          </List.Item>
-        )}
-      />
+        title={
+          <Select
+            value={currWord}
+            bordered={false}
+            onChange={(value: string) => {
+              setCurrWord(value);
+            }}
+            options={words?.map((item, id) => {
+              return {
+                label: (
+                  <Space>
+                    {item.word}
+                    <Badge
+                      count={item.count}
+                      color={"lime"}
+                      status="default"
+                      size="small"
+                    />
+                  </Space>
+                ),
+                value: item.word,
+              };
+            })}
+          />
+        }
+      >
+        <Checkbox
+          indeterminate={indeterminate}
+          onChange={onCheckAllChange}
+          checked={checkAll}
+        >
+          Check all
+        </Checkbox>
+        <Checkbox.Group
+          style={{ display: "grid" }}
+          options={showWords?.map((item, id) => {
+            return {
+              label: (
+                <Space>
+                  <Text strong={item.bold > 0 ? true : false}>{item.word}</Text>
+                  <Badge
+                    size="small"
+                    count={item.count}
+                    overflowCount={9999}
+                    status="default"
+                  />
+                </Space>
+              ),
+              value: item.word,
+            };
+          })}
+          value={checkedList}
+          onChange={onWordChange}
+        />
+      </Card>
     </div>
   );
 };

+ 286 - 0
dashboard/src/components/export/ExportModal.tsx

@@ -0,0 +1,286 @@
+import {
+  Collapse,
+  Modal,
+  Progress,
+  Select,
+  Switch,
+  Typography,
+  message,
+} from "antd";
+import { useEffect, useRef, useState } from "react";
+import { get } from "../../request";
+import { ArticleType } from "../article/Article";
+import ExportSettingLayout from "./ExportSettingLayout";
+
+const { Text } = Typography;
+
+interface IExportResponse {
+  ok: boolean;
+  message?: string;
+  data: string;
+}
+
+interface IStatus {
+  progress: number;
+  message: string;
+  log?: string[];
+}
+interface IExportStatusResponse {
+  ok: boolean;
+  message?: string;
+  data: {
+    url?: string;
+    status: IStatus;
+  };
+}
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  book?: string | null;
+  para?: string | null;
+  channelId?: string | null;
+  anthologyId?: string | null;
+  open?: boolean;
+  onClose?: Function;
+}
+
+const ExportModalWidget = ({
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  anthologyId,
+  open = false,
+  onClose,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+  const [filename, setFilename] = useState<string>();
+  const [url, setUrl] = useState<string>();
+  const [format, setFormat] = useState<string>("html");
+  const [exportStatus, setExportStatus] = useState<IStatus>();
+  const [exportStart, setExportStart] = useState(false);
+  const [hasOrigin, setHasOrigin] = useState(false);
+  const [hasTranslation, setHasTranslation] = useState(true);
+
+  const filenameRef = useRef(filename);
+
+  useEffect(() => {
+    // 及时更新 count 值
+    filenameRef.current = filename;
+  });
+  const queryStatus = () => {
+    console.log("timer", filenameRef.current);
+    if (typeof filenameRef.current === "undefined") {
+      return;
+    }
+    const url = `/v2/export/${filenameRef.current}`;
+    console.log("url", url);
+    get<IExportStatusResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          console.log("filename", json);
+          setExportStatus(json.data.status);
+          if (json.data.status.progress === 1) {
+            setFilename(undefined);
+            setUrl(json.data.url);
+          }
+        } else {
+          console.error(json.message);
+        }
+      })
+      .catch((e) => console.error(e));
+  };
+
+  useEffect(() => {
+    const interval = setInterval(() => queryStatus(), 3000);
+    return () => clearInterval(interval);
+  }, []);
+
+  const getUrl = (): string => {
+    if (!articleId) {
+      throw new Error("id error");
+    }
+    let url = `/v2/export?type=${type}&id=${articleId}&format=${format}`;
+    url += channelId ? `&channel=${channelId}` : "";
+    url += "&origin=" + (hasOrigin ? "true" : "false");
+    url += "&translation=" + (hasTranslation ? "true" : "false");
+    switch (type) {
+      case "chapter":
+        const para = articleId?.split("-").map((item) => parseInt(item));
+        if (para?.length === 2) {
+          url += `&book=${para[0]}&par=${para[1]}`;
+        } else {
+          throw new Error("段落编号错误");
+        }
+        if (!channelId) {
+          throw new Error("请选择版本");
+        }
+        break;
+      case "article":
+        url += `&id=${articleId}`;
+        url += anthologyId ? `&anthology=${anthologyId}` : "";
+        break;
+      default:
+        throw new Error("此类型暂时无法导出" + type);
+        break;
+    }
+    return url;
+  };
+  const exportRun = (): void => {
+    const url = getUrl();
+    console.log("url", url);
+    setExportStart(true);
+    get<IExportResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          const filename = json.data;
+          console.log("filename", filename);
+          setFilename(filename);
+        } else {
+        }
+      })
+      .catch((e) => {});
+  };
+
+  const closeModal = () => {
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+  useEffect(() => setIsModalOpen(open), [open]);
+  return (
+    <Modal
+      destroyOnClose
+      title="导出"
+      width={400}
+      open={isModalOpen}
+      onOk={() => {
+        console.log("type", type);
+        try {
+          exportRun();
+        } catch (error) {
+          message.error((error as Error).message);
+          console.error(error);
+        }
+      }}
+      onCancel={closeModal}
+      okText={"导出"}
+      okButtonProps={{ disabled: exportStart }}
+    >
+      <ExportSettingLayout
+        label="格式"
+        content={
+          <Select
+            defaultValue={format}
+            bordered={false}
+            options={[
+              {
+                value: "pdf",
+                label: "PDF",
+                disabled: true,
+              },
+              {
+                value: "word",
+                label: "Word",
+                disabled: true,
+              },
+              {
+                value: "html",
+                label: "Html",
+              },
+            ]}
+            onSelect={(value) => setFormat(value)}
+          />
+        }
+      />
+      <ExportSettingLayout
+        label="原文"
+        content={
+          <Switch
+            disabled={!hasTranslation}
+            size="small"
+            defaultChecked={hasOrigin}
+            onChange={(checked) => setHasOrigin(checked)}
+          />
+        }
+      />
+      <ExportSettingLayout
+        label="译文"
+        content={
+          <Switch
+            disabled={!hasOrigin}
+            size="small"
+            defaultChecked={hasTranslation}
+            onChange={(checked) => setHasTranslation(checked)}
+          />
+        }
+      />
+      <ExportSettingLayout
+        label="对照方式"
+        content={
+          <Select
+            disabled
+            defaultValue={"auto"}
+            bordered={false}
+            options={[
+              {
+                value: "auto",
+                label: "自动",
+              },
+              {
+                value: "col",
+                label: "分栏",
+              },
+              {
+                value: "row",
+                label: "纵列",
+              },
+            ]}
+            onSelect={(value) => setFormat(value)}
+          />
+        }
+      />
+
+      <div style={{ display: exportStart ? "block" : "none" }}>
+        <Progress
+          percent={exportStatus ? Math.round(exportStatus?.progress * 100) : 0}
+          status={
+            exportStatus
+              ? exportStatus.progress === 1
+                ? "success"
+                : "active"
+              : "normal"
+          }
+        />
+        <Collapse collapsible="icon" ghost>
+          <Collapse.Panel
+            header={
+              <div style={{ display: "flex", justifyContent: "space-between" }}>
+                <Text>
+                  {exportStatus ? exportStatus.message : "正在生成……"}
+                </Text>
+                {url ? (
+                  <a href={url} target="_blank" rel="noreferrer">
+                    {"下载"}
+                  </a>
+                ) : (
+                  <></>
+                )}
+              </div>
+            }
+            key="1"
+          >
+            <div style={{ height: 200, overflowY: "auto" }}>
+              {exportStatus?.log?.map((item, id) => {
+                return <div key={id}>{item}</div>;
+              })}
+            </div>
+          </Collapse.Panel>
+        </Collapse>
+      </div>
+    </Modal>
+  );
+};
+
+export default ExportModalWidget;

+ 20 - 0
dashboard/src/components/export/ExportSettingLayout.tsx

@@ -0,0 +1,20 @@
+interface IWidget {
+  label?: string;
+  content?: React.ReactNode;
+}
+const ExportSettingLayoutWidget = ({ label, content }: IWidget) => {
+  return (
+    <div
+      style={{
+        display: "flex",
+        justifyContent: "space-between",
+        marginBottom: 4,
+      }}
+    >
+      <span>{label}</span>
+      <span>{content}</span>
+    </div>
+  );
+};
+
+export default ExportSettingLayoutWidget;

+ 111 - 0
dashboard/src/components/export/ShareButton.tsx

@@ -0,0 +1,111 @@
+import { useState } from "react";
+import { Button, Dropdown, Space, Typography } from "antd";
+import {
+  ShareAltOutlined,
+  ExportOutlined,
+  ForkOutlined,
+  InboxOutlined,
+} from "@ant-design/icons";
+
+import ExportModal from "./ExportModal";
+import { ArticleType } from "../article/Article";
+import AddToAnthology from "../article/AddToAnthology";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { fullUrl } from "../../utils";
+
+const { Text } = Typography;
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  book?: string | null;
+  para?: string | null;
+  channelId?: string | null;
+  anthologyId?: string | null;
+}
+const ShareButtonWidget = ({
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  anthologyId,
+}: IWidget) => {
+  const [exportOpen, setExportOpen] = useState(false);
+  const [addToAnthologyOpen, setAddToAnthologyOpen] = useState(false);
+  const user = useAppSelector(currentUser);
+
+  return (
+    <>
+      <Dropdown
+        trigger={["click"]}
+        menu={{
+          items: [
+            {
+              label: (
+                <Space>
+                  {"Export"}
+                  <Text type="secondary" style={{ fontSize: "80%" }}>
+                    {"PDF,Word,Html"}
+                  </Text>
+                </Space>
+              ),
+              key: "export",
+              icon: <ExportOutlined />,
+            },
+            {
+              label: "添加到文集",
+              key: "add_to_anthology",
+              icon: <InboxOutlined />,
+              disabled: type === "article" ? false : true,
+            },
+            {
+              label: "创建副本",
+              key: "fork",
+              icon: <ForkOutlined />,
+              disabled: user && type === "article" ? false : true,
+            },
+          ],
+          onClick: ({ key }) => {
+            switch (key) {
+              case "export":
+                setExportOpen(true);
+                break;
+              case "add_to_anthology":
+                setAddToAnthologyOpen(true);
+                break;
+              case "fork":
+                const url = `/studio/${user?.nickName}/article/create?parent=${articleId}`;
+                window.open(fullUrl(url), "_blank");
+                break;
+              default:
+                break;
+            }
+          },
+        }}
+      >
+        <Button type="text" icon={<ShareAltOutlined color="#fff" />} />
+      </Dropdown>
+      <ExportModal
+        type={type}
+        articleId={articleId}
+        book={book}
+        para={para}
+        channelId={channelId}
+        anthologyId={anthologyId}
+        open={exportOpen}
+        onClose={() => setExportOpen(false)}
+      />
+      {articleId ? (
+        <AddToAnthology
+          open={addToAnthologyOpen}
+          onClose={(isOpen: boolean) => setAddToAnthologyOpen(isOpen)}
+          articleIds={[articleId]}
+        />
+      ) : undefined}
+    </>
+  );
+};
+
+export default ShareButtonWidget;

+ 28 - 2
dashboard/src/components/fts/FtsBookList.tsx

@@ -29,8 +29,10 @@ interface IFtsItem {
 }
 interface IWidget {
   keyWord?: string;
+  keyWords?: string[];
+  engin?: "wbw" | "tulip";
   tags?: string[];
-  bookId?: number;
+  bookId?: string | null;
   book?: number;
   para?: number;
   match?: string | null;
@@ -41,6 +43,8 @@ interface IWidget {
 
 const FtsBookListWidget = ({
   keyWord,
+  keyWords,
+  engin = "wbw",
   tags,
   bookId,
   book,
@@ -53,8 +57,24 @@ const FtsBookListWidget = ({
   const [ftsData, setFtsData] = useState<IFtsItem[]>();
   const [total, setTotal] = useState<number>();
 
+  const focusBooks = bookId?.split(",");
+  console.log("focusBooks", focusBooks);
   useEffect(() => {
-    let url = `/v2/search-book-list?view=${view}&key=${keyWord}`;
+    let words;
+    let api = "";
+    switch (engin) {
+      case "wbw":
+        api = "search-pali-wbw-books";
+        words = keyWords?.join();
+        break;
+      case "tulip":
+        api = "search-book-list";
+        words = keyWord;
+        break;
+      default:
+        break;
+    }
+    let url = `/v2/${api}?view=${view}&key=${words}`;
     if (typeof tags !== "undefined") {
       url += `&tags=${tags}`;
     }
@@ -64,6 +84,7 @@ const FtsBookListWidget = ({
     console.log("url", url);
     get<IFtsResponse>(url).then((json) => {
       if (json.ok) {
+        console.log("data", json.data.rows);
         let totalResult = 0;
         for (const iterator of json.data.rows) {
           totalResult += iterator.count;
@@ -95,9 +116,14 @@ const FtsBookListWidget = ({
         <List.Item>
           <div
             style={{
+              padding: 4,
+              borderRadius: 4,
               display: "flex",
               justifyContent: "space-between",
               cursor: "pointer",
+              backgroundColor: focusBooks?.includes(item.pcdBookId.toString())
+                ? "lightblue"
+                : "unset",
             }}
             onClick={() => {
               if (typeof onSelect !== "undefined") {

+ 38 - 2
dashboard/src/components/fts/FullTextSearchResult.tsx

@@ -37,11 +37,14 @@ interface IFtsItem {
   paliTitle?: string;
   content?: string;
   path?: ITocPathNode[];
+  rank?: number;
 }
 
 export type ISearchView = "pali" | "title" | "page";
 interface IWidget {
   keyWord?: string;
+  keyWords?: string[];
+  engin?: "wbw" | "tulip";
   tags?: string[];
   bookId?: string | null;
   book?: number;
@@ -54,6 +57,8 @@ interface IWidget {
 }
 const FullTxtSearchResultWidget = ({
   keyWord,
+  keyWords,
+  engin = "wbw",
   tags,
   bookId,
   book,
@@ -69,8 +74,27 @@ const FullTxtSearchResultWidget = ({
   const [loading, setLoading] = useState(false);
   const [currPage, setCurrPage] = useState<number>(1);
 
+  useEffect(
+    () => setCurrPage(1),
+    [view, keyWord, keyWords, tags, bookId, match, pageType]
+  );
+
   useEffect(() => {
-    let url = `/v2/search?view=${view}&key=${keyWord}`;
+    let words;
+    let api = "";
+    switch (engin) {
+      case "wbw":
+        api = "search-pali-wbw";
+        words = keyWords?.join();
+        break;
+      case "tulip":
+        api = "search";
+        words = keyWord;
+        break;
+      default:
+        break;
+    }
+    let url = `/v2/${api}?view=${view}&key=${words}`;
     if (typeof tags !== "undefined") {
       url += `&tags=${tags}`;
     }
@@ -93,6 +117,7 @@ const FullTxtSearchResultWidget = ({
     get<IFtsResponse>(url)
       .then((json) => {
         if (json.ok) {
+          console.log("data", json.data);
           const result: IFtsItem[] = json.data.rows.map((item) => {
             return {
               book: item.book,
@@ -103,6 +128,7 @@ const FullTxtSearchResultWidget = ({
                 ? item.highlight.replaceAll("** ti ", "**ti ")
                 : item.content,
               path: item.path,
+              rank: item.rank,
             };
           });
           setFtsData(result);
@@ -112,7 +138,17 @@ const FullTxtSearchResultWidget = ({
         }
       })
       .finally(() => setLoading(false));
-  }, [bookId, currPage, keyWord, match, orderBy, pageType, tags, view]);
+  }, [
+    bookId,
+    currPage,
+    keyWord,
+    keyWords,
+    match,
+    orderBy,
+    pageType,
+    tags,
+    view,
+  ]);
   return (
     <List
       style={{ width: "100%" }}

+ 11 - 0
dashboard/src/components/fts/search.css

@@ -13,3 +13,14 @@
   color: #177ddc;
   font-style: unset;
 }
+
+.bld {
+  font-weight: 700;
+}
+
+.hl {
+  background-color: yellow;
+}
+.note {
+  color: #177ddc;
+}

+ 43 - 0
dashboard/src/components/general/ErrorResult.tsx

@@ -0,0 +1,43 @@
+import { Result } from "antd";
+import { ResultStatusType } from "antd/lib/result";
+
+interface IWidget {
+  code: number;
+  message?: string;
+}
+
+const ErrorResultWidget = ({ code, message }: IWidget) => {
+  let strStatus: ResultStatusType;
+  let strTitle: string = "";
+  switch (code) {
+    case 401:
+      strStatus = 403;
+      strTitle = "未登录";
+      break;
+    case 403:
+      strStatus = 403;
+      strTitle = "没有权限";
+      break;
+    case 404:
+      strStatus = 404;
+      strTitle = "没有找到指定的资源";
+      break;
+    case 500:
+      strStatus = 500;
+      strTitle = "服务器内部错误";
+      break;
+    default:
+      strStatus = "error";
+      strTitle = "无法识别的错误代码" + code;
+      break;
+  }
+  return (
+    <Result
+      status={strStatus}
+      title={strTitle}
+      subTitle="Sorry, something went wrong."
+    />
+  );
+};
+
+export default ErrorResultWidget;

+ 3 - 0
dashboard/src/components/general/LangSelect.tsx

@@ -29,6 +29,7 @@ interface IWidget {
   disabled?: boolean;
   required?: boolean;
   name?: string;
+  readonly?: boolean;
 }
 const LangSelectWidget = ({
   width,
@@ -36,6 +37,7 @@ const LangSelectWidget = ({
   disabled = false,
   required = true,
   name = "lang",
+  readonly,
 }: IWidget) => {
   const intl = useIntl();
 
@@ -62,6 +64,7 @@ const LangSelectWidget = ({
       options={langOptions}
       width={width}
       name={name}
+      readonly={readonly}
       showSearch
       debounceTime={300}
       allowClear={false}

+ 38 - 7
dashboard/src/components/general/NissayaCard.tsx

@@ -1,5 +1,6 @@
-import { CSSProperties, useEffect, useState } from "react";
-import { message, Modal, Popover, Skeleton, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { Button, message, Modal, Popover, Skeleton, Typography } from "antd";
+import { EditOutlined, ReloadOutlined } from "@ant-design/icons";
 
 import { get } from "../../request";
 import { get as getLang } from "../../locales";
@@ -7,6 +8,8 @@ import { get as getLang } from "../../locales";
 import NissayaCardTable, { INissayaRelation } from "./NissayaCardTable";
 import { ITerm } from "../term/TermEdit";
 import { Link } from "react-router-dom";
+import TermModal from "../term/TermModal";
+import { ITermDataResponse } from "../api/Term";
 
 const { Paragraph, Title } = Typography;
 
@@ -77,10 +80,13 @@ const NissayaCardWidget = ({ text, cache = false }: IWidget) => {
   const [term, setTerm] = useState<ITerm>();
   const [loading, setLoading] = useState(false);
 
-  useEffect(() => {
+  useEffect(() => load(), [cache, text]);
+
+  const load = () => {
     const uiLang = getLang();
+    const key = `nissaya-ending/${uiLang}/${text}`;
     if (cache) {
-      const value = sessionStorage.getItem(`nissaya-ending/${uiLang}/${text}`);
+      const value = sessionStorage.getItem(key);
       if (value !== null) {
         const valueJson: INissayaCardData = JSON.parse(value);
         setCardData(valueJson.row);
@@ -110,15 +116,40 @@ const NissayaCardWidget = ({ text, cache = false }: IWidget) => {
       })
       .finally(() => setLoading(false))
       .catch((e: INissayaCardResponse) => message.error(e.message));
-  }, [cache, text]);
+  };
 
+  const reload = () => {
+    const uiLang = getLang();
+    const key = `nissaya-ending/${uiLang}/${text}`;
+    sessionStorage.removeItem(key);
+    load();
+  };
   return loading ? (
     <Skeleton title={{ width: 200 }} paragraph={{ rows: 4 }} active />
   ) : (
     <div style={{ maxWidth: 750 }}>
       <div style={{ display: "flex", justifyContent: "space-between" }}>
-        <Title level={4}>{term?.word}</Title>
-        <Link to={`/nissaya/ending/${term?.word}`}>在新窗口打开</Link>
+        <Title level={4}>
+          {term?.word}
+          <TermModal
+            id={term?.id}
+            onUpdate={(value: ITermDataResponse) => {
+              //onModalClose();
+            }}
+            onClose={() => {
+              //onModalClose();
+            }}
+            trigger={<Button type="link" icon={<EditOutlined />} />}
+          />
+        </Title>
+        <div>
+          <Link to={`/nissaya/ending/${term?.word}`}>在新窗口打开</Link>
+          <Button
+            type="link"
+            icon={<ReloadOutlined />}
+            onClick={() => reload()}
+          />
+        </div>
       </div>
       <Paragraph>{term?.meaning}</Paragraph>
       <Paragraph>{term?.note}</Paragraph>

+ 2 - 0
dashboard/src/components/general/TermTextArea.tsx

@@ -34,6 +34,8 @@ const TermTextAreaWidget = ({
   const refTextArea = useRef<HTMLTextAreaElement>(null);
   const refShadow = useRef<HTMLDivElement>(null);
 
+  console.log("render");
+
   function term_at_menu_hide() {
     setMenuDisplay("none");
     setTermSearch("");

+ 21 - 15
dashboard/src/components/general/TermTextAreaMenu.tsx

@@ -27,7 +27,7 @@ interface IWidget {
   onSelect?: Function;
 }
 const TermTextAreaMenuWidget = ({
-  items = [],
+  items,
   searchKey = "",
   maxItem = 10,
   visible = false,
@@ -39,24 +39,29 @@ const TermTextAreaMenuWidget = ({
   const [wordList, setWordList] = useState<IWordWithEn[]>();
   const sysTerms = useAppSelector(getTerm);
   console.log("items", items);
+
   useEffect(() => {
+    let parents: string[] = [];
+    let mWords: IWordWithEn[] = [];
     //本句单词
-    const mWords = items?.map((item) => {
-      return {
-        word: item,
-        en: PaliToEn(item),
-      };
-    });
+    if (items) {
+      mWords = items?.map((item) => {
+        return {
+          word: item,
+          en: PaliToEn(item),
+        };
+      });
 
-    //计算这些单词的base
-    let parents: string[] = [];
-    items?.forEach((value) => {
-      getPaliBase(value).forEach((base) => {
-        if (!parents.includes(base) && !items.includes(base)) {
-          parents.push(base);
-        }
+      //计算这些单词的base
+
+      items?.forEach((value) => {
+        getPaliBase(value).forEach((base) => {
+          if (!parents.includes(base) && !items.includes(base)) {
+            parents.push(base);
+          }
+        });
       });
-    });
+    }
 
     const term = sysTerms ? sysTerms?.map((item) => item.word) : [];
     //本句单词parent
@@ -81,6 +86,7 @@ const TermTextAreaMenuWidget = ({
           isTerm: true,
         };
       });
+
     setWordList([...parentTerm, ...mWords, ...sysTerm]);
 
     //此处千万不能加其他dependency 否则会引起无限循环

+ 3 - 1
dashboard/src/components/studio/PublicitySelect.tsx

@@ -2,8 +2,9 @@ import { ProFormSelect } from "@ant-design/pro-components";
 import { useIntl } from "react-intl";
 interface IWidget {
   width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  readonly?: boolean;
 }
-const PublicitySelectWidget = ({ width }: IWidget) => {
+const PublicitySelectWidget = ({ width, readonly }: IWidget) => {
   const intl = useIntl();
 
   const options = [
@@ -29,6 +30,7 @@ const PublicitySelectWidget = ({ width }: IWidget) => {
   return (
     <ProFormSelect
       options={options}
+      readonly={readonly}
       width={width}
       name="status"
       allowClear={false}

+ 1 - 1
dashboard/src/components/template/MdView.tsx

@@ -3,7 +3,7 @@ import { TCodeConvertor, XmlToReact } from "./utilities";
 const { Paragraph, Text } = Typography;
 
 interface IWidget {
-  html?: string;
+  html?: string | null;
   className?: string;
   placeholder?: string;
   wordWidget?: boolean;

+ 2 - 1
dashboard/src/components/template/SentEdit.tsx

@@ -113,9 +113,10 @@ export const SentEditInner = ({
       setLoadedRes(res);
     }
   }, [translation]);
+
   useEffect(() => {
     const content = origin?.find(
-      (value) => value.channel.type === "wbw"
+      (value) => value.contentType === "json"
     )?.content;
     if (content) {
       setWbwData(JSON.parse(content));

+ 1 - 1
dashboard/src/components/template/SentEdit/SentCell.tsx

@@ -301,7 +301,7 @@ const SentCellWidget = ({
                   marginBottom: 0,
                 }}
                 placeholder="请输入"
-                html={sentData.html}
+                html={sentData.html ? sentData.html : sentData.content}
                 wordWidget={wordWidget}
               />
             )}

+ 1 - 1
dashboard/src/components/template/SentEdit/SentContent.tsx

@@ -121,7 +121,7 @@ const SentContentWidget = ({
       />
       <div style={{ flex: layoutFlex.left, color: "#9f3a01" }}>
         {origin?.map((item, id) => {
-          if (item.channel.type === "wbw") {
+          if (item.contentType === "json") {
             return (
               <WbwSentCtl
                 key={id}

+ 13 - 7
dashboard/src/components/template/utilities.ts

@@ -93,13 +93,19 @@ export function XmlToReact(
               );
               break;
             default:
-              output.push(
-                React.createElement(
-                  tagName,
-                  getAttr(value, i),
-                  convert(value, wordWidget, convertor)
-                )
-              );
+              try {
+                output.push(
+                  React.createElement(
+                    tagName,
+                    getAttr(value, i),
+                    convert(value, wordWidget, convertor)
+                  )
+                );
+              } catch (error) {
+                console.log("ParserError", tagName);
+                output.push(React.createElement(ParserError, { key: i }, []));
+              }
+
               break;
           }
 

+ 25 - 10
dashboard/src/components/term/TermCommunity.tsx

@@ -13,8 +13,9 @@ import { useIntl } from "react-intl";
 import { get } from "../../request";
 import { IUser } from "../auth/User";
 import { ITermListResponse } from "../api/Term";
+import { Link } from "react-router-dom";
 
-const { Title, Link, Text } = Typography;
+const { Title, Text } = Typography;
 
 interface IItem<R> {
   value: R;
@@ -133,7 +134,12 @@ const TermCommunityWidget = ({ word }: IWidget) => {
     return (
       <Space key={id}>
         {name}
-        <Badge color="geekblue" size="small" count={score} />
+        <Badge
+          style={{ display: "none" }}
+          color="geekblue"
+          size="small"
+          count={score}
+        />
       </Space>
     );
   };
@@ -148,23 +154,27 @@ const TermCommunityWidget = ({ word }: IWidget) => {
   const more = wordData ? (
     wordData.editor.length > mainCollaboratorNum ? (
       <Dropdown menu={{ items }}>
-        <Link>
+        <Typography.Link>
           <Space>
             {intl.formatMessage({
               id: `buttons.more`,
             })}
             <DownOutlined />
           </Space>
-        </Link>
+        </Typography.Link>
       </Dropdown>
     ) : undefined
   ) : undefined;
 
   return show ? (
     <Card>
-      <Title level={5} id={`community`}>
-        {"社区术语"}
-      </Title>
+      <Space>
+        <Title level={5} id={`community`}>
+          {"社区术语"}
+        </Title>
+        <Link to={`/term/list/${word}`}>详情</Link>
+      </Space>
+
       <div key="meaning">
         <Space style={{ flexWrap: "wrap" }}>
           <Text strong>{"意思:"}</Text>
@@ -174,20 +184,25 @@ const TermCommunityWidget = ({ word }: IWidget) => {
               return (
                 <Space key={id}>
                   {item.value}
-                  <Badge color="geekblue" size="small" count={item.score} />
+                  <Badge
+                    style={{ display: "none" }}
+                    color="geekblue"
+                    size="small"
+                    count={item.score}
+                  />
                 </Space>
               );
             })}
           {meaningLow && meaningLow.length > 0 ? (
             <Popover content={<Space>{meaningExtra}</Space>} placement="bottom">
-              <Link>
+              <Typography.Link>
                 <Space>
                   {intl.formatMessage({
                     id: `buttons.more`,
                   })}
                   <DownOutlined />
                 </Space>
-              </Link>
+              </Typography.Link>
             </Popover>
           ) : undefined}
         </Space>

+ 1 - 0
dashboard/src/components/term/TermModal.tsx

@@ -68,6 +68,7 @@ const TermModalWidget = ({
     <>
       <span onClick={showModal}>{trigger}</span>
       <Modal
+        style={{ top: 20 }}
         width={760}
         title={
           <Space>

+ 26 - 26
dashboard/src/pages/library/article/show.tsx

@@ -36,7 +36,7 @@ import { paraParam } from "../../../reducers/para-change";
 import { get } from "../../../request";
 import store from "../../../store";
 import { IRecent } from "../../../components/recent/RecentList";
-import { convertToPlain, fullUrl } from "../../../utils";
+import { fullUrl } from "../../../utils";
 import ThemeSelect from "../../../components/general/ThemeSelect";
 import {
   IShowDiscussion,
@@ -50,6 +50,7 @@ import SearchButton from "../../../components/general/SearchButton";
 import ToStudio from "../../../components/auth/ToStudio";
 import { currentUser as _currentUser } from "../../../reducers/current-user";
 import LoginAlertModal from "../../../components/auth/LoginAlertModal";
+import ShareButton from "../../../components/export/ShareButton";
 
 /**
  * type:
@@ -63,11 +64,11 @@ import LoginAlertModal from "../../../components/auth/LoginAlertModal";
  * @returns
  */
 const Widget = () => {
-  const { type, id, mode = "read" } = useParams(); //url 参数
+  const { type, id } = useParams(); //url 参数
+  const [searchParams, setSearchParams] = useSearchParams();
+
   const navigate = useNavigate();
-  console.log("mode", mode);
   const [rightPanel, setRightPanel] = useState<TPanelName>("close");
-  const [searchParams, setSearchParams] = useSearchParams();
   const [anchorNavOpen, setAnchorNavOpen] = useState(false);
   const [anchorNavShow, setAnchorNavShow] = useState(true);
   const [recentModalOpen, setRecentModalOpen] = useState(false);
@@ -171,22 +172,16 @@ const Widget = () => {
                 >
                   Edit
                 </Button>
-                <Button
-                  disabled={user ? false : true}
-                  ghost
-                  onClick={(event) => {
-                    const url = `/studio/${user?.nickName}/article/create?parent=${loadedArticleData.uid}`;
-                    if (event.ctrlKey || event.metaKey) {
-                      window.open(fullUrl(url), "_blank");
-                    } else {
-                      navigate(url);
-                    }
-                  }}
-                >
-                  Fork
-                </Button>
               </>
             ) : undefined}
+            <ShareButton
+              type={type as ArticleType}
+              book={searchParams.get("book")}
+              para={searchParams.get("par")}
+              channelId={searchParams.get("channel")}
+              articleId={id}
+              anthologyId={searchParams.get("anthology")}
+            />
             <SearchButton />
             <Divider type="vertical" />
             <ToStudio />
@@ -291,6 +286,7 @@ const Widget = () => {
                 <ToolButtonToc
                   type={type as ArticleType}
                   articleId={id}
+                  channels={searchParams.get("channel")?.split("_")}
                   anthologyId={searchParams.get("anthology")}
                   onSelect={(key: Key) => {
                     console.log("toc click", key);
@@ -338,13 +334,14 @@ const Widget = () => {
               articleId={id}
               anthologyId={searchParams.get("anthology")}
               mode={searchParams.get("mode") as ArticleMode}
-              onArticleChange={(article: string, target?: string) => {
-                console.log("article change", article, target);
-                let mType = type;
-                if (article.split("-").length === 2) {
-                  mType = "chapter";
-                }
-                let url = `/article/${mType}/${article}?mode=${currMode}`;
+              onArticleChange={(
+                newType: ArticleType,
+                article: string,
+                target?: string
+              ) => {
+                console.log("article change", newType, article, target);
+
+                let url = `/article/${newType}/${article}?mode=${currMode}`;
                 searchParams.forEach((value, key) => {
                   console.log(value, key);
                   if (key !== "mode") {
@@ -359,7 +356,10 @@ const Widget = () => {
               }}
               onLoad={(article: IArticleDataResponse) => {
                 setLoadedArticleData(article);
-                document.title = convertToPlain(article.title).slice(0, 128);
+                const windowTitle = article.title_text
+                  ? article.title_text
+                  : article.title;
+                document.title = windowTitle.slice(0, 128);
                 const paramTopic = searchParams.get("topic");
                 const paramComment = searchParams.get("comment");
                 const paramType = searchParams.get("dis_type");

+ 2 - 2
dashboard/src/pages/library/nissaya/show.tsx

@@ -1,6 +1,6 @@
 import { Col, Row } from "antd";
 import { useParams } from "react-router-dom";
-import NissayaCardWidget from "../../../components/general/NissayaCard";
+import NissayaCard from "../../../components/general/NissayaCard";
 
 const Widget = () => {
   const { ending } = useParams(); //url 参数
@@ -8,7 +8,7 @@ const Widget = () => {
     <Row>
       <Col flex={"auto"}></Col>
       <Col flex={"960px"}>
-        <NissayaCardWidget text={ending} cache={false} />
+        <NissayaCard text={ending} cache={false} />
       </Col>
       <Col flex={"auto"}></Col>
     </Row>

+ 20 - 2
dashboard/src/pages/library/search/search.tsx

@@ -1,6 +1,6 @@
 import { useNavigate, useParams, useSearchParams } from "react-router-dom";
 import { useEffect, useState } from "react";
-import { Row, Col, Breadcrumb, Space, Tabs } from "antd";
+import { Row, Col, Breadcrumb, Space, Tabs, Select } from "antd";
 import FullSearchInput from "../../../components/fts/FullSearchInput";
 import BookTree from "../../../components/corpus/BookTree";
 import FullTextSearchResult, {
@@ -18,6 +18,7 @@ const Widget = () => {
   const navigate = useNavigate();
   const [pageType, setPageType] = useState("P");
   const [view, setView] = useState<ISearchView | undefined>("pali");
+  const [caseWord, setCaseWord] = useState<string[]>();
 
   useEffect(() => {
     const v = searchParams.get("view");
@@ -100,6 +101,16 @@ const Widget = () => {
                     setSearchParams(searchParams);
                   }}
                   size="small"
+                  tabBarExtraContent={
+                    <Select
+                      defaultValue="wbw"
+                      bordered={false}
+                      options={[
+                        { value: "wbw", label: "wbw" },
+                        { value: "tulip", label: "tulip(beta)" },
+                      ]}
+                    />
+                  }
                   items={[
                     {
                       label: `巴利原文`,
@@ -122,6 +133,7 @@ const Widget = () => {
                   view={view as ISearchView}
                   pageType={pageType}
                   keyWord={key}
+                  keyWords={caseWord}
                   tags={searchParams.get("tags")?.split(",")}
                   bookId={searchParams.get("book")}
                   orderBy={searchParams.get("orderby")}
@@ -130,12 +142,18 @@ const Widget = () => {
               </Space>
             </Col>
             <Col xs={0} sm={0} md={5}>
-              <CaseList word={key} lines={5} />
+              <CaseList
+                word={key}
+                lines={5}
+                onChange={(value: string[]) => setCaseWord(value)}
+              />
               <FtsBookList
                 view={view}
                 keyWord={key}
+                keyWords={caseWord}
                 tags={searchParams.get("tags")?.split(",")}
                 match={searchParams.get("match")}
+                bookId={searchParams.get("book")}
                 onSelect={(bookId: number) => {
                   if (bookId !== 0) {
                     searchParams.set("book", bookId.toString());

+ 1 - 0
dashboard/src/pages/studio/anthology/edit.tsx

@@ -50,6 +50,7 @@ const Widget = () => {
               label: intl.formatMessage({ id: "buttons.basic.information" }),
               children: (
                 <AnthologyInfoEdit
+                  studioName={studioname}
                   anthologyId={anthology_id}
                   onLoad={(value: IAnthologyDataResponse) => {
                     setTitle(value.title);

+ 86 - 0
dashboard/src/protocols/TulipServiceClientPb.ts

@@ -0,0 +1,86 @@
+/**
+ * @fileoverview gRPC-Web generated client stub for mint.tulip.v1
+ * @enhanceable
+ * @public
+ */
+
+// Code generated by protoc-gen-grpc-web. DO NOT EDIT.
+// versions:
+// 	protoc-gen-grpc-web v1.4.2
+// 	protoc              v4.24.3
+// source: tulip.proto
+
+
+/* eslint-disable */
+// @ts-nocheck
+
+
+import * as grpcWeb from 'grpc-web';
+
+import * as tulip_pb from './tulip_pb';
+
+
+export class SearchClient {
+  client_: grpcWeb.AbstractClientBase;
+  hostname_: string;
+  credentials_: null | { [index: string]: string; };
+  options_: null | { [index: string]: any; };
+
+  constructor (hostname: string,
+               credentials?: null | { [index: string]: string; },
+               options?: null | { [index: string]: any; }) {
+    if (!options) options = {};
+    if (!credentials) credentials = {};
+    options['format'] = 'binary';
+
+    this.client_ = new grpcWeb.GrpcWebClientBase(options);
+    this.hostname_ = hostname.replace(/\/+$/, '');
+    this.credentials_ = credentials;
+    this.options_ = options;
+  }
+
+  methodDescriptorPali = new grpcWeb.MethodDescriptor(
+    '/mint.tulip.v1.Search/Pali',
+    grpcWeb.MethodType.UNARY,
+    tulip_pb.SearchRequest,
+    tulip_pb.SearchResponse,
+    (request: tulip_pb.SearchRequest) => {
+      return request.serializeBinary();
+    },
+    tulip_pb.SearchResponse.deserializeBinary
+  );
+
+  pali(
+    request: tulip_pb.SearchRequest,
+    metadata: grpcWeb.Metadata | null): Promise<tulip_pb.SearchResponse>;
+
+  pali(
+    request: tulip_pb.SearchRequest,
+    metadata: grpcWeb.Metadata | null,
+    callback: (err: grpcWeb.RpcError,
+               response: tulip_pb.SearchResponse) => void): grpcWeb.ClientReadableStream<tulip_pb.SearchResponse>;
+
+  pali(
+    request: tulip_pb.SearchRequest,
+    metadata: grpcWeb.Metadata | null,
+    callback?: (err: grpcWeb.RpcError,
+               response: tulip_pb.SearchResponse) => void) {
+    if (callback !== undefined) {
+      return this.client_.rpcCall(
+        this.hostname_ +
+          '/mint.tulip.v1.Search/Pali',
+        request,
+        metadata || {},
+        this.methodDescriptorPali,
+        callback);
+    }
+    return this.client_.unaryCall(
+    this.hostname_ +
+      '/mint.tulip.v1.Search/Pali',
+    request,
+    metadata || {},
+    this.methodDescriptorPali);
+  }
+
+}
+

+ 127 - 0
dashboard/src/protocols/tulip_pb.d.ts

@@ -0,0 +1,127 @@
+import * as jspb from 'google-protobuf'
+
+
+
+export class SearchRequest extends jspb.Message {
+  getKeywordsList(): Array<string>;
+  setKeywordsList(value: Array<string>): SearchRequest;
+  clearKeywordsList(): SearchRequest;
+  addKeywords(value: string, index?: number): SearchRequest;
+
+  getBook(): number;
+  setBook(value: number): SearchRequest;
+
+  getPage(): SearchRequest.Page | undefined;
+  setPage(value?: SearchRequest.Page): SearchRequest;
+  hasPage(): boolean;
+  clearPage(): SearchRequest;
+
+  serializeBinary(): Uint8Array;
+  toObject(includeInstance?: boolean): SearchRequest.AsObject;
+  static toObject(includeInstance: boolean, msg: SearchRequest): SearchRequest.AsObject;
+  static serializeBinaryToWriter(message: SearchRequest, writer: jspb.BinaryWriter): void;
+  static deserializeBinary(bytes: Uint8Array): SearchRequest;
+  static deserializeBinaryFromReader(message: SearchRequest, reader: jspb.BinaryReader): SearchRequest;
+}
+
+export namespace SearchRequest {
+  export type AsObject = {
+    keywordsList: Array<string>,
+    book: number,
+    page?: SearchRequest.Page.AsObject,
+  }
+
+  export class Page extends jspb.Message {
+    getIndex(): number;
+    setIndex(value: number): Page;
+
+    getSize(): number;
+    setSize(value: number): Page;
+
+    serializeBinary(): Uint8Array;
+    toObject(includeInstance?: boolean): Page.AsObject;
+    static toObject(includeInstance: boolean, msg: Page): Page.AsObject;
+    static serializeBinaryToWriter(message: Page, writer: jspb.BinaryWriter): void;
+    static deserializeBinary(bytes: Uint8Array): Page;
+    static deserializeBinaryFromReader(message: Page, reader: jspb.BinaryReader): Page;
+  }
+
+  export namespace Page {
+    export type AsObject = {
+      index: number,
+      size: number,
+    }
+  }
+
+
+  export enum PageCase { 
+    _PAGE_NOT_SET = 0,
+    PAGE = 99,
+  }
+}
+
+export class SearchResponse extends jspb.Message {
+  getItemsList(): Array<SearchResponse.Item>;
+  setItemsList(value: Array<SearchResponse.Item>): SearchResponse;
+  clearItemsList(): SearchResponse;
+  addItems(value?: SearchResponse.Item, index?: number): SearchResponse.Item;
+
+  getPage(): SearchRequest.Page | undefined;
+  setPage(value?: SearchRequest.Page): SearchResponse;
+  hasPage(): boolean;
+  clearPage(): SearchResponse;
+
+  getTotal(): number;
+  setTotal(value: number): SearchResponse;
+
+  serializeBinary(): Uint8Array;
+  toObject(includeInstance?: boolean): SearchResponse.AsObject;
+  static toObject(includeInstance: boolean, msg: SearchResponse): SearchResponse.AsObject;
+  static serializeBinaryToWriter(message: SearchResponse, writer: jspb.BinaryWriter): void;
+  static deserializeBinary(bytes: Uint8Array): SearchResponse;
+  static deserializeBinaryFromReader(message: SearchResponse, reader: jspb.BinaryReader): SearchResponse;
+}
+
+export namespace SearchResponse {
+  export type AsObject = {
+    itemsList: Array<SearchResponse.Item.AsObject>,
+    page?: SearchRequest.Page.AsObject,
+    total: number,
+  }
+
+  export class Item extends jspb.Message {
+    getRank(): number;
+    setRank(value: number): Item;
+
+    getHighlight(): string;
+    setHighlight(value: string): Item;
+
+    getBook(): number;
+    setBook(value: number): Item;
+
+    getParagraph(): number;
+    setParagraph(value: number): Item;
+
+    getContent(): string;
+    setContent(value: string): Item;
+
+    serializeBinary(): Uint8Array;
+    toObject(includeInstance?: boolean): Item.AsObject;
+    static toObject(includeInstance: boolean, msg: Item): Item.AsObject;
+    static serializeBinaryToWriter(message: Item, writer: jspb.BinaryWriter): void;
+    static deserializeBinary(bytes: Uint8Array): Item;
+    static deserializeBinaryFromReader(message: Item, reader: jspb.BinaryReader): Item;
+  }
+
+  export namespace Item {
+    export type AsObject = {
+      rank: number,
+      highlight: string,
+      book: number,
+      paragraph: number,
+      content: string,
+    }
+  }
+
+}
+

+ 1000 - 0
dashboard/src/protocols/tulip_pb.js

@@ -0,0 +1,1000 @@
+// source: tulip.proto
+/**
+ * @fileoverview
+ * @enhanceable
+ * @suppress {missingRequire} reports error on implicit type usages.
+ * @suppress {messageConventions} JS Compiler reports an error if a variable or
+ *     field starts with 'MSG_' and isn't a translatable message.
+ * @public
+ */
+// GENERATED CODE -- DO NOT EDIT!
+/* eslint-disable */
+// @ts-nocheck
+
+var jspb = require('google-protobuf');
+var goog = jspb;
+var global =
+    (typeof globalThis !== 'undefined' && globalThis) ||
+    (typeof window !== 'undefined' && window) ||
+    (typeof global !== 'undefined' && global) ||
+    (typeof self !== 'undefined' && self) ||
+    (function () { return this; }).call(null) ||
+    Function('return this')();
+
+goog.exportSymbol('proto.mint.tulip.v1.SearchRequest', null, global);
+goog.exportSymbol('proto.mint.tulip.v1.SearchRequest.Page', null, global);
+goog.exportSymbol('proto.mint.tulip.v1.SearchResponse', null, global);
+goog.exportSymbol('proto.mint.tulip.v1.SearchResponse.Item', null, global);
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.mint.tulip.v1.SearchRequest = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, proto.mint.tulip.v1.SearchRequest.repeatedFields_, null);
+};
+goog.inherits(proto.mint.tulip.v1.SearchRequest, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.mint.tulip.v1.SearchRequest.displayName = 'proto.mint.tulip.v1.SearchRequest';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.mint.tulip.v1.SearchRequest.Page = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.mint.tulip.v1.SearchRequest.Page, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.mint.tulip.v1.SearchRequest.Page.displayName = 'proto.mint.tulip.v1.SearchRequest.Page';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.mint.tulip.v1.SearchResponse = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, proto.mint.tulip.v1.SearchResponse.repeatedFields_, null);
+};
+goog.inherits(proto.mint.tulip.v1.SearchResponse, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.mint.tulip.v1.SearchResponse.displayName = 'proto.mint.tulip.v1.SearchResponse';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.mint.tulip.v1.SearchResponse.Item = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.mint.tulip.v1.SearchResponse.Item, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.mint.tulip.v1.SearchResponse.Item.displayName = 'proto.mint.tulip.v1.SearchResponse.Item';
+}
+
+/**
+ * List of repeated fields within this message type.
+ * @private {!Array<number>}
+ * @const
+ */
+proto.mint.tulip.v1.SearchRequest.repeatedFields_ = [1];
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.toObject = function(opt_includeInstance) {
+  return proto.mint.tulip.v1.SearchRequest.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.mint.tulip.v1.SearchRequest} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.mint.tulip.v1.SearchRequest.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    keywordsList: (f = jspb.Message.getRepeatedField(msg, 1)) == null ? undefined : f,
+    book: jspb.Message.getFieldWithDefault(msg, 2, 0),
+    page: (f = msg.getPage()) && proto.mint.tulip.v1.SearchRequest.Page.toObject(includeInstance, f)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.mint.tulip.v1.SearchRequest}
+ */
+proto.mint.tulip.v1.SearchRequest.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.mint.tulip.v1.SearchRequest;
+  return proto.mint.tulip.v1.SearchRequest.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.mint.tulip.v1.SearchRequest} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.mint.tulip.v1.SearchRequest}
+ */
+proto.mint.tulip.v1.SearchRequest.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {string} */ (reader.readString());
+      msg.addKeywords(value);
+      break;
+    case 2:
+      var value = /** @type {number} */ (reader.readInt32());
+      msg.setBook(value);
+      break;
+    case 99:
+      var value = new proto.mint.tulip.v1.SearchRequest.Page;
+      reader.readMessage(value,proto.mint.tulip.v1.SearchRequest.Page.deserializeBinaryFromReader);
+      msg.setPage(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.mint.tulip.v1.SearchRequest.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.mint.tulip.v1.SearchRequest} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.mint.tulip.v1.SearchRequest.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getKeywordsList();
+  if (f.length > 0) {
+    writer.writeRepeatedString(
+      1,
+      f
+    );
+  }
+  f = message.getBook();
+  if (f !== 0) {
+    writer.writeInt32(
+      2,
+      f
+    );
+  }
+  f = message.getPage();
+  if (f != null) {
+    writer.writeMessage(
+      99,
+      f,
+      proto.mint.tulip.v1.SearchRequest.Page.serializeBinaryToWriter
+    );
+  }
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.mint.tulip.v1.SearchRequest.Page.prototype.toObject = function(opt_includeInstance) {
+  return proto.mint.tulip.v1.SearchRequest.Page.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.mint.tulip.v1.SearchRequest.Page} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.mint.tulip.v1.SearchRequest.Page.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    index: jspb.Message.getFieldWithDefault(msg, 1, 0),
+    size: jspb.Message.getFieldWithDefault(msg, 2, 0)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.mint.tulip.v1.SearchRequest.Page}
+ */
+proto.mint.tulip.v1.SearchRequest.Page.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.mint.tulip.v1.SearchRequest.Page;
+  return proto.mint.tulip.v1.SearchRequest.Page.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.mint.tulip.v1.SearchRequest.Page} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.mint.tulip.v1.SearchRequest.Page}
+ */
+proto.mint.tulip.v1.SearchRequest.Page.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {number} */ (reader.readInt32());
+      msg.setIndex(value);
+      break;
+    case 2:
+      var value = /** @type {number} */ (reader.readInt32());
+      msg.setSize(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.mint.tulip.v1.SearchRequest.Page.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.mint.tulip.v1.SearchRequest.Page.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.mint.tulip.v1.SearchRequest.Page} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.mint.tulip.v1.SearchRequest.Page.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getIndex();
+  if (f !== 0) {
+    writer.writeInt32(
+      1,
+      f
+    );
+  }
+  f = message.getSize();
+  if (f !== 0) {
+    writer.writeInt32(
+      2,
+      f
+    );
+  }
+};
+
+
+/**
+ * optional int32 index = 1;
+ * @return {number}
+ */
+proto.mint.tulip.v1.SearchRequest.Page.prototype.getIndex = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.mint.tulip.v1.SearchRequest.Page} returns this
+ */
+proto.mint.tulip.v1.SearchRequest.Page.prototype.setIndex = function(value) {
+  return jspb.Message.setProto3IntField(this, 1, value);
+};
+
+
+/**
+ * optional int32 size = 2;
+ * @return {number}
+ */
+proto.mint.tulip.v1.SearchRequest.Page.prototype.getSize = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.mint.tulip.v1.SearchRequest.Page} returns this
+ */
+proto.mint.tulip.v1.SearchRequest.Page.prototype.setSize = function(value) {
+  return jspb.Message.setProto3IntField(this, 2, value);
+};
+
+
+/**
+ * repeated string keywords = 1;
+ * @return {!Array<string>}
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.getKeywordsList = function() {
+  return /** @type {!Array<string>} */ (jspb.Message.getRepeatedField(this, 1));
+};
+
+
+/**
+ * @param {!Array<string>} value
+ * @return {!proto.mint.tulip.v1.SearchRequest} returns this
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.setKeywordsList = function(value) {
+  return jspb.Message.setField(this, 1, value || []);
+};
+
+
+/**
+ * @param {string} value
+ * @param {number=} opt_index
+ * @return {!proto.mint.tulip.v1.SearchRequest} returns this
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.addKeywords = function(value, opt_index) {
+  return jspb.Message.addToRepeatedField(this, 1, value, opt_index);
+};
+
+
+/**
+ * Clears the list making it empty but non-null.
+ * @return {!proto.mint.tulip.v1.SearchRequest} returns this
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.clearKeywordsList = function() {
+  return this.setKeywordsList([]);
+};
+
+
+/**
+ * optional int32 book = 2;
+ * @return {number}
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.getBook = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.mint.tulip.v1.SearchRequest} returns this
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.setBook = function(value) {
+  return jspb.Message.setProto3IntField(this, 2, value);
+};
+
+
+/**
+ * optional Page page = 99;
+ * @return {?proto.mint.tulip.v1.SearchRequest.Page}
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.getPage = function() {
+  return /** @type{?proto.mint.tulip.v1.SearchRequest.Page} */ (
+    jspb.Message.getWrapperField(this, proto.mint.tulip.v1.SearchRequest.Page, 99));
+};
+
+
+/**
+ * @param {?proto.mint.tulip.v1.SearchRequest.Page|undefined} value
+ * @return {!proto.mint.tulip.v1.SearchRequest} returns this
+*/
+proto.mint.tulip.v1.SearchRequest.prototype.setPage = function(value) {
+  return jspb.Message.setWrapperField(this, 99, value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.mint.tulip.v1.SearchRequest} returns this
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.clearPage = function() {
+  return this.setPage(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.mint.tulip.v1.SearchRequest.prototype.hasPage = function() {
+  return jspb.Message.getField(this, 99) != null;
+};
+
+
+
+/**
+ * List of repeated fields within this message type.
+ * @private {!Array<number>}
+ * @const
+ */
+proto.mint.tulip.v1.SearchResponse.repeatedFields_ = [1];
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.toObject = function(opt_includeInstance) {
+  return proto.mint.tulip.v1.SearchResponse.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.mint.tulip.v1.SearchResponse} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.mint.tulip.v1.SearchResponse.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    itemsList: jspb.Message.toObjectList(msg.getItemsList(),
+    proto.mint.tulip.v1.SearchResponse.Item.toObject, includeInstance),
+    page: (f = msg.getPage()) && proto.mint.tulip.v1.SearchRequest.Page.toObject(includeInstance, f),
+    total: jspb.Message.getFieldWithDefault(msg, 99, 0)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.mint.tulip.v1.SearchResponse}
+ */
+proto.mint.tulip.v1.SearchResponse.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.mint.tulip.v1.SearchResponse;
+  return proto.mint.tulip.v1.SearchResponse.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.mint.tulip.v1.SearchResponse} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.mint.tulip.v1.SearchResponse}
+ */
+proto.mint.tulip.v1.SearchResponse.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = new proto.mint.tulip.v1.SearchResponse.Item;
+      reader.readMessage(value,proto.mint.tulip.v1.SearchResponse.Item.deserializeBinaryFromReader);
+      msg.addItems(value);
+      break;
+    case 98:
+      var value = new proto.mint.tulip.v1.SearchRequest.Page;
+      reader.readMessage(value,proto.mint.tulip.v1.SearchRequest.Page.deserializeBinaryFromReader);
+      msg.setPage(value);
+      break;
+    case 99:
+      var value = /** @type {number} */ (reader.readInt32());
+      msg.setTotal(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.mint.tulip.v1.SearchResponse.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.mint.tulip.v1.SearchResponse} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.mint.tulip.v1.SearchResponse.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getItemsList();
+  if (f.length > 0) {
+    writer.writeRepeatedMessage(
+      1,
+      f,
+      proto.mint.tulip.v1.SearchResponse.Item.serializeBinaryToWriter
+    );
+  }
+  f = message.getPage();
+  if (f != null) {
+    writer.writeMessage(
+      98,
+      f,
+      proto.mint.tulip.v1.SearchRequest.Page.serializeBinaryToWriter
+    );
+  }
+  f = message.getTotal();
+  if (f !== 0) {
+    writer.writeInt32(
+      99,
+      f
+    );
+  }
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.toObject = function(opt_includeInstance) {
+  return proto.mint.tulip.v1.SearchResponse.Item.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.mint.tulip.v1.SearchResponse.Item} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.mint.tulip.v1.SearchResponse.Item.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    rank: jspb.Message.getFieldWithDefault(msg, 1, 0),
+    highlight: jspb.Message.getFieldWithDefault(msg, 2, ""),
+    book: jspb.Message.getFieldWithDefault(msg, 3, 0),
+    paragraph: jspb.Message.getFieldWithDefault(msg, 4, 0),
+    content: jspb.Message.getFieldWithDefault(msg, 5, "")
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.mint.tulip.v1.SearchResponse.Item}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.mint.tulip.v1.SearchResponse.Item;
+  return proto.mint.tulip.v1.SearchResponse.Item.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.mint.tulip.v1.SearchResponse.Item} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.mint.tulip.v1.SearchResponse.Item}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {number} */ (reader.readInt32());
+      msg.setRank(value);
+      break;
+    case 2:
+      var value = /** @type {string} */ (reader.readString());
+      msg.setHighlight(value);
+      break;
+    case 3:
+      var value = /** @type {number} */ (reader.readInt32());
+      msg.setBook(value);
+      break;
+    case 4:
+      var value = /** @type {number} */ (reader.readInt32());
+      msg.setParagraph(value);
+      break;
+    case 5:
+      var value = /** @type {string} */ (reader.readString());
+      msg.setContent(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.mint.tulip.v1.SearchResponse.Item.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.mint.tulip.v1.SearchResponse.Item} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.mint.tulip.v1.SearchResponse.Item.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getRank();
+  if (f !== 0) {
+    writer.writeInt32(
+      1,
+      f
+    );
+  }
+  f = message.getHighlight();
+  if (f.length > 0) {
+    writer.writeString(
+      2,
+      f
+    );
+  }
+  f = message.getBook();
+  if (f !== 0) {
+    writer.writeInt32(
+      3,
+      f
+    );
+  }
+  f = message.getParagraph();
+  if (f !== 0) {
+    writer.writeInt32(
+      4,
+      f
+    );
+  }
+  f = message.getContent();
+  if (f.length > 0) {
+    writer.writeString(
+      5,
+      f
+    );
+  }
+};
+
+
+/**
+ * optional int32 rank = 1;
+ * @return {number}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.getRank = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.mint.tulip.v1.SearchResponse.Item} returns this
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.setRank = function(value) {
+  return jspb.Message.setProto3IntField(this, 1, value);
+};
+
+
+/**
+ * optional string highlight = 2;
+ * @return {string}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.getHighlight = function() {
+  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.mint.tulip.v1.SearchResponse.Item} returns this
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.setHighlight = function(value) {
+  return jspb.Message.setProto3StringField(this, 2, value);
+};
+
+
+/**
+ * optional int32 book = 3;
+ * @return {number}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.getBook = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 3, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.mint.tulip.v1.SearchResponse.Item} returns this
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.setBook = function(value) {
+  return jspb.Message.setProto3IntField(this, 3, value);
+};
+
+
+/**
+ * optional int32 paragraph = 4;
+ * @return {number}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.getParagraph = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 4, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.mint.tulip.v1.SearchResponse.Item} returns this
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.setParagraph = function(value) {
+  return jspb.Message.setProto3IntField(this, 4, value);
+};
+
+
+/**
+ * optional string content = 5;
+ * @return {string}
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.getContent = function() {
+  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 5, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.mint.tulip.v1.SearchResponse.Item} returns this
+ */
+proto.mint.tulip.v1.SearchResponse.Item.prototype.setContent = function(value) {
+  return jspb.Message.setProto3StringField(this, 5, value);
+};
+
+
+/**
+ * repeated Item items = 1;
+ * @return {!Array<!proto.mint.tulip.v1.SearchResponse.Item>}
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.getItemsList = function() {
+  return /** @type{!Array<!proto.mint.tulip.v1.SearchResponse.Item>} */ (
+    jspb.Message.getRepeatedWrapperField(this, proto.mint.tulip.v1.SearchResponse.Item, 1));
+};
+
+
+/**
+ * @param {!Array<!proto.mint.tulip.v1.SearchResponse.Item>} value
+ * @return {!proto.mint.tulip.v1.SearchResponse} returns this
+*/
+proto.mint.tulip.v1.SearchResponse.prototype.setItemsList = function(value) {
+  return jspb.Message.setRepeatedWrapperField(this, 1, value);
+};
+
+
+/**
+ * @param {!proto.mint.tulip.v1.SearchResponse.Item=} opt_value
+ * @param {number=} opt_index
+ * @return {!proto.mint.tulip.v1.SearchResponse.Item}
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.addItems = function(opt_value, opt_index) {
+  return jspb.Message.addToRepeatedWrapperField(this, 1, opt_value, proto.mint.tulip.v1.SearchResponse.Item, opt_index);
+};
+
+
+/**
+ * Clears the list making it empty but non-null.
+ * @return {!proto.mint.tulip.v1.SearchResponse} returns this
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.clearItemsList = function() {
+  return this.setItemsList([]);
+};
+
+
+/**
+ * optional SearchRequest.Page page = 98;
+ * @return {?proto.mint.tulip.v1.SearchRequest.Page}
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.getPage = function() {
+  return /** @type{?proto.mint.tulip.v1.SearchRequest.Page} */ (
+    jspb.Message.getWrapperField(this, proto.mint.tulip.v1.SearchRequest.Page, 98));
+};
+
+
+/**
+ * @param {?proto.mint.tulip.v1.SearchRequest.Page|undefined} value
+ * @return {!proto.mint.tulip.v1.SearchResponse} returns this
+*/
+proto.mint.tulip.v1.SearchResponse.prototype.setPage = function(value) {
+  return jspb.Message.setWrapperField(this, 98, value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.mint.tulip.v1.SearchResponse} returns this
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.clearPage = function() {
+  return this.setPage(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.hasPage = function() {
+  return jspb.Message.getField(this, 98) != null;
+};
+
+
+/**
+ * optional int32 total = 99;
+ * @return {number}
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.getTotal = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 99, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.mint.tulip.v1.SearchResponse} returns this
+ */
+proto.mint.tulip.v1.SearchResponse.prototype.setTotal = function(value) {
+  return jspb.Message.setProto3IntField(this, 99, value);
+};
+
+
+goog.object.extend(exports, proto.mint.tulip.v1);

+ 3 - 0
dashboard/src/request.ts

@@ -54,6 +54,9 @@ export const options = (method: string): RequestInit => {
 
 export const get = async <R>(path: string): Promise<R> => {
   const response = await fetch(backend(path), options("GET"));
+  if (!response.ok) {
+    throw response.status;
+  }
   const res: R = await response.json();
   return res;
 };

+ 1 - 0
deploy/group_vars/all.yml

@@ -7,3 +7,4 @@ app_php_version: "8.1"
 app_downloads: "{{ ansible_env.HOME }}/downloads"
 app_backup: "{{ ansible_env.HOME }}/backups"
 app_dashboard_base_path: "/pcd"
+app_postgresql_version: "14"

+ 44 - 4
deploy/mint.yml

@@ -1,6 +1,5 @@
 - name: "local compile"
-  hosts: localhost
-  connection: local
+  hosts: building
   tasks:
     - name: "install dashboard dependencies"
       ansible.builtin.shell:
@@ -18,13 +17,54 @@
         REACT_APP_ENABLE_LOCAL_TOKEN: "true"
         REACT_APP_TOKEN_KEY: "token.20230919"
         REACT_APP_DOCUMENTS_SERVER: "{{ app_documents_server }}"
-        REACT_APP_RPC_SERVER: "{{ app_rpc_server }}"
+        REACT_APP_RPC_SERVER: "{{ app_grpc_web_server }}"
         REACT_APP_ASSETS_SERVER: "{{ app_assets_server }}"
         REACT_APP_API_SERVER: "https://{{ app_domain }}"
         REACT_APP_ICP_CODE: "{{ app_icp_code }}"
         REACT_APP_QUESTIONNAIRE_LINK: "{{ app_questionnaire_link }}"
 
-- hosts:
+- name: deploy php-fpm servers
+  hosts:
     - php_fpm
   roles:
     - mint-v2
+
+- name: run db migration
+  hosts:
+    - dbm
+  tasks:
+    - name: run db-migrate
+      become: true
+      ansible.builtin.shell:
+        cmd: su www-data -pc "php artisan migrate"
+        chdir: "{{ app_deploy_root }}/htdocs"
+
+- name: setup tulip
+  hosts:
+    - tulip
+  tasks:
+    - name: upload pali.stop
+      become: true
+      ansible.builtin.copy:
+        dest: "/usr/share/postgresql/{{ app_postgresql_version }}/tsearch_data/"
+        src: "{{ playbook_dir }}/../rpc/tulip/tulip/dictionary/pali.stop"
+        owner: root
+        group: root
+        mode: "0644"
+    - name: upload pali.syn
+      become: true
+      ansible.builtin.copy:
+        dest: "/usr/share/postgresql/{{ app_postgresql_version }}/tsearch_data/"
+        src: "{{ playbook_dir }}/../rpc/tulip/tulip/dictionary/pali.syn"
+        owner: root
+        group: root
+        mode: "0644"
+    - name: restrat postgresql server
+      become: true
+      ansible.builtin.systemd:
+        state: restarted
+        name: postgresql
+    - name: run db migrate
+      ansible.builtin.shell:
+        cmd: dbmate up
+        chdir: "{{ app_deploy_root }}/agile/rpc/tulip/tulip/db"

+ 17 - 56
deploy/roles/mint-v2/tasks/laravel.yml

@@ -1,6 +1,6 @@
 # https://laravel.com/docs/10.x/deployment
 
-- name: clone source code
+- name: clone source code(laravel)
   ansible.builtin.git:
     repo: "https://github.com/iapt-platform/mint.git"
     dest: "{{ app_deploy_root }}/htdocs"
@@ -95,6 +95,13 @@
     cmd: su www-data -pc "php artisan view:cache"
     chdir: "{{ app_deploy_root }}/htdocs"
 
+- name: setup sqlite3 db for v1
+  become: true
+  ansible.builtin.file:
+    src: /var/www/shared/appdata
+    dest: "{{ app_deploy_root }}/htdocs/storage/app/data"
+    state: link
+
 # https://laravel.com/docs/10.x/scheduling#running-the-scheduler
 - name: upload scheduler service
   become: true
@@ -114,60 +121,14 @@
     group: root
     mode: "0644"
 
-- name: upload {{ action }} worker service
-  become: true
-  template:
-    src: v2/queue-worker.service.j2
-    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
-    owner: root
-    group: root
-    mode: "0644"
-  vars:
-    action: "discussion"
-
-- name: upload {{ action }} worker service
-  become: true
-  template:
-    src: v2/queue-worker.service.j2
-    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
-    owner: root
-    group: root
-    mode: "0644"
-  vars:
-    action: "pr"
-
-- name: upload {{ action }} worker service
-  become: true
-  template:
-    src: v2/queue-worker.service.j2
-    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
-    owner: root
-    group: root
-    mode: "0644"
-  vars:
-    action: "progress"
+- import_tasks: queue-workers.yml
 
-- name: upload {{ action }} worker service
-  become: true
-  template:
-    src: v2/queue-worker.service.j2
-    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
-    owner: root
-    group: root
-    mode: "0644"
-  vars:
-    action: "wbw.analyses"
-
-# - name: run db:migrate
-#   run_once: true
-#   become: true
-#   ansible.builtin.shell:
-#     cmd: su www-data -pc "php artisan migrate"
-#     chdir: "{{ app_deploy_root }}/htdocs"
+- name: clone source code(agile)
+  ansible.builtin.git:
+    repo: "https://github.com/iapt-platform/mint.git"
+    dest: "{{ app_deploy_root }}/agile"
+    version: "agile"
 
-- name: setup sqlite3 db for v1
-  become: true
-  ansible.builtin.file:
-    src: /var/www/shared/appdata
-    dest: "{{ app_deploy_root }}/htdocs/storage/app/data"
-    state: link
+- import_tasks: morus.yml
+- import_tasks: lily.yml
+- import_tasks: tulip.yml

+ 25 - 0
deploy/roles/mint-v2/tasks/lily.yml

@@ -0,0 +1,25 @@
+- name: upload lily config.toml
+  template:
+    src: v2/lily/config.toml.j2
+    dest: "{{ app_deploy_root }}/agile/rpc/lily/config.toml"
+    mode: "0644"
+
+- name: upload lily rpc server service
+  become: true
+  template:
+    src: v2/lily/services/server.service.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-lily.service
+    owner: root
+    group: root
+    mode: "0644"
+
+- name: upload lily rpc worker({{ queue }}) service
+  become: true
+  template:
+    src: v2/lily/services/worker.service.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-worker-{{ queue }}.service
+    owner: root
+    group: root
+    mode: "0644"
+  vars:
+    queue: "palm.lily.tex-to-pdf"

+ 13 - 0
deploy/roles/mint-v2/tasks/morus.yml

@@ -0,0 +1,13 @@
+- name: auto-loader optimization for morus
+  ansible.builtin.shell:
+    cmd: composer install --optimize-autoloader --no-dev
+    chdir: "{{ app_deploy_root }}/agile/rpc/morus/morus"
+
+- name: upload morus rpc service
+  become: true
+  template:
+    src: v2/morus.service.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-morus.service
+    owner: root
+    group: root
+    mode: "0644"

+ 54 - 0
deploy/roles/mint-v2/tasks/queue-workers.yml

@@ -0,0 +1,54 @@
+- name: upload {{ action }} worker service
+  become: true
+  template:
+    src: v2/queue-worker.service.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
+    owner: root
+    group: root
+    mode: "0644"
+  vars:
+    action: "discussion"
+
+- name: upload {{ action }} worker service
+  become: true
+  template:
+    src: v2/queue-worker.service.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
+    owner: root
+    group: root
+    mode: "0644"
+  vars:
+    action: "pr"
+
+- name: upload {{ action }} worker service
+  become: true
+  template:
+    src: v2/queue-worker.service.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
+    owner: root
+    group: root
+    mode: "0644"
+  vars:
+    action: "progress"
+
+- name: upload {{ action }} worker service
+  become: true
+  template:
+    src: v2/queue-worker.service.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
+    owner: root
+    group: root
+    mode: "0644"
+  vars:
+    action: "wbw.analyses"
+
+- name: upload {{ action }} worker service
+  become: true
+  template:
+    src: v2/queue-worker.service.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-{{ action }}-worker.service
+    owner: root
+    group: root
+    mode: "0644"
+  vars:
+    action: "export.pali.chapter"

+ 25 - 0
deploy/roles/mint-v2/tasks/tulip.yml

@@ -0,0 +1,25 @@
+- name: auto-loader optimization for tulip
+  ansible.builtin.shell:
+    cmd: composer update --optimize-autoloader --no-dev
+    chdir: "{{ app_deploy_root }}/agile/rpc/tulip/tulip"
+
+- name: upload tulip db/.env
+  template:
+    src: v2/tulip/db/env.j2
+    dest: "{{ app_deploy_root }}/agile/rpc/tulip/tulip/db/.env"
+    mode: "0644"
+
+- name: upload tulip config.php
+  template:
+    src: v2/tulip/config.php.j2
+    dest: "{{ app_deploy_root }}/agile/rpc/tulip/tulip/config.php"
+    mode: "0644"
+
+- name: upload tulip rpc service
+  become: true
+  template:
+    src: v2/tulip/service.conf.j2
+    dest: /usr/lib/systemd/system/mint-{{ app_deploy_env }}-tulip.service
+    owner: root
+    group: root
+    mode: "0644"

+ 1 - 1
deploy/roles/mint-v2/templates/v1/config.js.j2

@@ -1,5 +1,5 @@
 var ICP_CODE = "{{ app_icp_code }}"
-var RPC_SERVER = "{{ app_rpc_server }}";
+var GRPC_WEB_SERVER = "{{ app_grpc_web_server }}";
 var DOCUMENTS_SERVER = "{{ app_documents_server }}";
 var REACT_APP_QUESTIONNAIRE_LINK = "{{ app_questionnaire_link }}";
 var DASHBOARD_BASE_PATH = "{{ app_dashboard_base_path }}";

+ 1 - 1
deploy/roles/mint-v2/templates/v1/config.php.j2

@@ -1,7 +1,7 @@
 <?php
 
 #域名设置
-define("RPC_SERVER","{{ app_rpc_server}}");
+define("GRPC_WEB_SERVER","{{ app_grpc_web_server}}");
 define("ASSETS_SERVER","{{ app_assets_server }}");
 define("DOCUMENTS_SERVER","{{ app_documents_server }}");
 define('APP_KEY','{{ app_secret_key }}');

+ 8 - 1
deploy/roles/mint-v2/templates/v2/env.j2

@@ -65,10 +65,17 @@ MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
 
 DASHBOARD_BASE_PATH="{{ app_dashboard_base_path }}"
 ASSETS_SERVER="{{ app_assets_server }}"
-RPC_SERVER="{{ app_rpc_server }}"
+GRPC_WEB_SERVER="{{ app_grpc_web_server }}"
 DOCUMENTS_SERVER="{{ app_documents_server }}"
 CORS_ALLOWED_ORIGINS="{{ app_cors_allowed_origins }}"
 
+MORUS_GRPC_HOST="{{ app_morus_grpc_host }}"
+MORUS_GRPC_PORT="{{ app_morus_listen_port }}"
+LILY_GRPC_HOST="{{ app_lily_grpc_host }}"
+LILY_GRPC_PORT="{{ app_lily_listen_port }}"
+TULIP_GRPC_HOST="{{ app_tulip_grpc_host }}"
+TULIP_GRPC_PORT="{{ app_tulip_listen_port }}"
+
 SNOWFLAKE_DATA_CENTER_ID={{ app_snowflake_data_center_id }}
 SNOWFLAKE_WORKER_ID={{ app_snowflake_worker_id }}
 

+ 1 - 0
deploy/roles/mint-v2/templates/v2/lily/config.toml.j2

@@ -0,0 +1 @@
+port = {{ app_lily_listen_port }}

+ 15 - 0
deploy/roles/mint-v2/templates/v2/lily/services/server.service.j2

@@ -0,0 +1,15 @@
+[Unit]
+Description=Mint lily rpc service for {{ app_deploy_env }}
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/bin/bash lily.sh -d -c config.toml
+WorkingDirectory={{ app_deploy_root }}/agile/rpc/lily
+User=www-data
+Group=www-data
+Restart=always
+RestartSec=30s
+
+[Install]
+WantedBy=multi-user.target

+ 15 - 0
deploy/roles/mint-v2/templates/v2/lily/services/worker.service.j2

@@ -0,0 +1,15 @@
+[Unit]
+Description=Mint lily worker service for {{ app_deploy_env }}
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/bin/bash lily.sh -d -c config.toml --worker {{ queue }}
+WorkingDirectory={{ app_deploy_root }}/agile/rpc/lily
+User=www-data
+Group=www-data
+Restart=always
+RestartSec=30s
+
+[Install]
+WantedBy=multi-user.target

+ 15 - 0
deploy/roles/mint-v2/templates/v2/morus.service.j2

@@ -0,0 +1,15 @@
+[Unit]
+Description=Mint morus rpc service for {{ app_deploy_env }}
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/php server.php -p {{ app_morus_listen_port }}
+WorkingDirectory={{ app_deploy_root }}/agile/rpc/morus/morus
+User=www-data
+Group=www-data
+Restart=always
+RestartSec=30s
+
+[Install]
+WantedBy=multi-user.target

+ 13 - 0
deploy/roles/mint-v2/templates/v2/tulip/config.php.j2

@@ -0,0 +1,13 @@
+<?php
+
+define("Config", [
+    'port' => {{ app_tulip_listen_port }},
+    "database" => [
+        "driver" => "pgsql",
+        "host" => "{{ app_tulip_db_host }}",
+        "port" => {{ app_tulip_db_port }},
+        "name" => "{{ app_tulip_db_name }}",
+        "user" => "{{ app_tulip_db_user }}",
+        "password" => "{{ app_tulip_db_password }}",
+    ],
+]);

+ 1 - 0
deploy/roles/mint-v2/templates/v2/tulip/db/env.j2

@@ -0,0 +1 @@
+DATABASE_URL="postgres://{{ app_tulip_db_user }}:{{ app_tulip_db_password }}@{{ app_tulip_db_host}}:{{ app_tulip_db_port }}/{{ app_tulip_db_name }}?sslmode=disable"

+ 15 - 0
deploy/roles/mint-v2/templates/v2/tulip/service.conf.j2

@@ -0,0 +1,15 @@
+[Unit]
+Description=Mint tulip rpc service for {{ app_deploy_env }}
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/php server.php
+WorkingDirectory={{ app_deploy_root }}/agile/rpc/tulip/tulip
+User=www-data
+Group=www-data
+Restart=always
+RestartSec=30s
+
+[Install]
+WantedBy=multi-user.target

+ 78 - 9
deploy/staging/hosts

@@ -1,12 +1,81 @@
-[www]
+[php_fpm]
+192.168.0.11
+192.168.0.12
 
+[db]
+192.168.0.21
+
+[redis]
+192.168.0.22 
+
+[others]
+192.168.1.99
+
+
+
+
+
+
+
+[redis:vars]
+
+[php_fpm:vars]
+app_php_version="8.2"
+app_php_fpm_port=9090
+
+[building]
+localhost ansible_connection="local"
 
 [all:vars]
-; openssl rand -base64 32
-app_secret_key="4i3WKUvKtSGl59htK7XjUNsjalhkG5s7RJCmZruT2m4="
-; php artisan key:generate --show
-app_laravel_key="base64:IQD4vkmAN1oEsc8rVu1nRadQWPUX/LH/jaSbyFbmf/w="
-app_postgresql_host="db-hk-1.wikipali.org"
-app_postgresql_port=5433
-app_postgresql_password=change-me
-app_redis_host="ch-hk-1.wikipali.org"
+app_deploy_env="staging"
+app_deploy_root="/var/www/mint-{{ app_deploy_env }}"
+
+# su www-data -pc "php artisan key:generate"
+# su www-data -pc "php artisan config:cache"
+app_secret_key="change-me"
+
+app_domain="staging.change-me.org"
+app_postgresql_host="192.168.0.21"
+app_postgresql_port=5432
+app_postgresql_user=www
+app_postgresql_password="change-me"
+app_postgresql_dbname="mint_staging_20231108"
+
+app_snowflake_worker_id=1
+app_snowflake_data_center_id=1
+
+app_redis_host="192.168.0.31"
+app_redis_port=6371
+app_redis_cache_host="192.168.0.39"
+app_redis_cache_port=6379
+app_redis_cache_db=3
+
+app_smtp_host="smtp.gmail.com"
+app_smtp_port=465
+app_smtp_user="who-am-i@gmail.com"
+app_smtp_password="change-me"
+
+app_assets_server="https://assets.change-me.org"
+app_grpc_web_server="https://rpc.change-me.org"
+app_documents_server="https://documents.change-me.org"
+app_morus_grpc_server="https://rpc.change-me.org"
+app_lily_grpc_server="https://rpc.change-me.org"
+app_tulip_grpc_server="https://rpc.change-me.org"
+
+app_rabbitmq_host="192.168.0.41"
+app_rabbitmq_port=5672
+app_rabbitmq_user="www"
+app_rabbitmq_password="change-me"
+app_rabbitmq_virtual_host="mint-staging"
+
+app_aws_access_key="change-me"
+app_aws_secret_key="change-me"
+app_aws_region="us-east-1"
+app_aws_endpoint_domain="change-me.org"
+
+app_icp_code=""
+app_cors_allowed_origins=https://www.change-me.org
+app_questionnaire_link="https://wj.qq.com/change-me/"
+
+app_cdn_urls=https://www.change-me.org/downloads
+app_attachments_bucket_name=attachments-staging

+ 5 - 2
rpc/lily/.gitignore

@@ -1,2 +1,5 @@
-__pycache__/
-/tmp/
+/python/
+/config.toml
+
+*.tar
+*.md5

+ 14 - 14
rpc/lily/Dockerfile

@@ -5,6 +5,12 @@ ENV DEBIAN_FRONTEND noninteractive
 
 RUN apt update
 RUN apt -y upgrade
+
+RUN apt install -y software-properties-common
+ENV PYTHON_VERSION "3.12"
+# https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa
+RUN add-apt-repository -y ppa:deadsnakes/ppa
+RUN apt update
 RUN apt -y install build-essential \
     imagemagick ffmpeg fonts-dejavu-extra texlive-full pandoc \
     fonts-arphic-ukai fonts-arphic-uming \
@@ -14,20 +20,13 @@ RUN apt -y install build-essential \
     fonts-cns11643-kai fonts-cns11643-sung \
     fonts-moe-standard-kai fonts-moe-standard-song \
     fonts-ipafont-nonfree-jisx0208 \
-    python3-full python3-dev 
+    python${PYTHON_VERSION}-full python${PYTHON_VERSION}-dev \
+    libpq5
+RUN apt -y clean
 
-# https://getcomposer.org/download/
-RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/76a7060ccb93902cd7576b67264ad91c8a2700e2/web/installer -O - -q | php -- --quiet --install-dir=/usr/local/bin --filename=composer
-
-RUN useradd -s /bin/bash -m deploy
-RUN passwd -l deploy
-RUN echo 'deploy ALL=(ALL:ALL) NOPASSWD: ALL' > /etc/sudoers.d/101-deploy
 RUN mkdir /opt/lily
-RUN chown deploy:deploy /opt/lily
-USER deploy
-
 # https://pip.pypa.io/en/stable/installation/
-RUN bash -c "python3 -m venv $HOME/python3 \
+RUN bash -c "python${PYTHON_VERSION} -m venv $HOME/python3 \
     && . $HOME/python3/bin/activate \
     && pip install --upgrade pip \
     && pip install cmake \
@@ -38,9 +37,10 @@ RUN bash -c "python3 -m venv $HOME/python3 \
 RUN echo 'source $HOME/python3/bin/activate' >> $HOME/.bashrc
 
 COPY lily /opt/lily/
+COPY config.toml /etc/lily.toml
 
-RUN echo "$(date -u +%4Y%m%d%H%M%S)" | sudo tee /VERSION
+RUN echo "$(date -u +%4Y%m%d%H%M%S)" | tee /VERSION
 
-WORKDIR /opt/morus
+WORKDIR /opt/lily
 
-CMD ["/bin/bash"]
+CMD ["/bin/bash", "-l"]

+ 10 - 0
rpc/lily/README.md

@@ -0,0 +1,10 @@
+# Usage
+
+```bash
+# show help
+./start.sh -h
+# start a tex2pdf worker
+./start.sh -c /etc/lily.toml --worker
+# start a rpc server
+./start.sh -c /etc/lily.toml
+```

+ 2 - 2
rpc/lily/build.sh

@@ -3,12 +3,12 @@
 set -e
 
 export VERSION=$(date "+%4Y%m%d%H%M%S")
-export CODE="mint-lily"
+export CODE="palm-lily"
 
 buildah pull ubuntu:latest
 buildah bud --layers -t $CODE .
 podman save --format=oci-archive -o $CODE-$VERSION.tar $CODE
-md5sum $CODE-$VERSION.tar > md5.txt
+md5sum $CODE-$VERSION.tar > $CODE-$VERSION.md5
 
 echo "done($CODE-$VERSION.tar)."
 

+ 0 - 69
rpc/lily/client.php

@@ -1,69 +0,0 @@
-<?php
-
-require dirname(__FILE__) . '/vendor/autoload.php';
-
-function tex2pdf($host, $request)
-{
-    $client = new \Palm\Lily\V1\TexClient($host, [
-        'credentials' => Grpc\ChannelCredentials::createInsecure(),
-    ]);
-
-    list($response, $status) = $client->ToPdf($request)->wait();
-    if ($status->code !== Grpc\STATUS_OK) {
-        echo "ERROR: " . $status->code . ", " . $status->details . PHP_EOL;
-        exit(1);
-    }
-    echo $response->getContentType() . '(' . strlen($response->getPayload()) . ' bytes)' . PHP_EOL;
-}
-
-$request = new \Palm\Lily\V1\TexToRequest();
-
-$request->getFiles()['main.tex'] = <<<'EOF'
-% 导言区
-\documentclass[a4paper, 12pt, fontset=ubuntu]{article} % book, report, letter
-\usepackage{ctex} % Use chinese package
-
-\title{\heiti 一级标题}
-\author{\kaishu 半闲}
-\date{\today}
-
-% 正文区
-
-\begin{document}
-    \maketitle % 头部信息在正文显示
-    \tableofcontents % 显示索引列
-
-    \include{section-1.tex}
-    \include{section-2.tex}
-
-\end{document}
-
-EOF;
-
-$request->getFiles()['section-1.tex'] = <<<'EOF'
-\section{章节1 标题}
-章节1 正文
-\subsection{子章节1.1 标题}
-子章节1-1 正文
-
-
-\newline This is another \verb|\newline| .
-
-\par This is a new paragraph.
-
-\newpage This is a new page.
-
-\subsection{子章节1.2 标题}
-子章节1-2 正文
-EOF;
-
-$request->getFiles()['section-2.tex'] = <<<'EOF'
-\section{章节2 标题}
-章节2 正文
-\subsection{子章节2.1 标题}
-子章节2-1 正文
-\subsection{子章节2.2 标题}
-子章节2-2 正文
-EOF;
-
-tex2pdf('192.168.43.100:9000', $request);

+ 22 - 0
rpc/lily/lily.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+
+set -e
+
+export PYTHON_HOME=$PWD/python
+
+if [ ! -d $PYTHON_HOME ]
+then
+    python -m venv $PYTHON_HOME
+fi
+
+source $PWD/python/bin/activate
+
+if [ ! -f $PYTHON_HOME/bin/ttx ]
+then
+    pip install psycopg minio redis[hiredis] \
+        pika msgpack matplotlib ebooklib \
+        grpcio protobuf grpcio-health-checking \
+        pandas openpyxl xlrd pyxlsb
+fi
+
+python lily $*

+ 3 - 0
rpc/lily/lily/.gitignore

@@ -0,0 +1,3 @@
+__pycache__/
+/tmp/
+*.toml

+ 7 - 13
rpc/lily/lily/README.md

@@ -5,9 +5,9 @@
 - Install [Python3.11+](https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa)
 
   ```bash
-  $ sudo add-apt-repository ppa:deadsnakes/ppa
-  $ sudo apt update
-  $ sudo apt install python3.12-full python3.12-dev
+  sudo add-apt-repository ppa:deadsnakes/ppa
+  sudo apt update
+  sudo apt install python3.12-full python3.12-dev
   ```
 
 - Install libraries
@@ -16,22 +16,15 @@
   $ sudo apt install imagemagick ffmpeg fonts-dejavu-extra texlive-full
   $ python3.12 -m venv $HOME/local/python3
   $ source $HOME/local/python3/bin/activate
-  $ pip install psycopg pika matplotlib ebooklib \
+  $ pip install psycopg minio redis[hiredis] \
+    pika msgpack matplotlib ebooklib \
     grpcio protobuf grpcio-health-checking \
     pandas openpyxl xlrd pyxlsb
   ```
 
 ## Start
 
-- create config.toml
-
-  ```toml
-  [rpc]
-  port = 9999
-  workers = 8
-  ```
-
-- run `python lily -d`
+- run `python lily -d -c config.toml`
 
 ## Documents
 
@@ -39,3 +32,4 @@
 - [https://graphviz.org/](Graphviz)
 - [EbookLib](https://github.com/aerkalov/ebooklib)
 - [Excel files](https://pandas.pydata.org/docs/user_guide/io.html#excel-files)
+- [Data types used by Excel](https://learn.microsoft.com/en-us/office/client-developer/excel/data-types-used-by-excel)

+ 26 - 5
rpc/lily/lily/__main__.py

@@ -2,16 +2,18 @@ import logging
 import argparse
 import sys
 import tomllib
-import pika
 
-from palm import VERSION, start_server
+
+from palm import VERSION,  RedisClient, MinioClient, RabbitMqClient, is_stopped
+from palm.tex import TEX2PDF_QUEUE, create_tex2pdf_queue_callback
+from palm.server import Rpc as RpcServer
 
 NAME = 'lily'
 
 if __name__ == '__main__':
     parser = argparse.ArgumentParser(
         prog=NAME,
-        description='Background worker for palm',
+        description='A tex to pdf/word converter',
         epilog='https://github.com/saturn-xiv/palm')
     parser.add_argument('-c', '--config',
                         type=argparse.FileType(mode='rb'),
@@ -20,6 +22,8 @@ if __name__ == '__main__':
     parser.add_argument('-d', '--debug',
                         action='store_true',
                         help='run on debug mode')
+    parser.add_argument('-w', '--worker',
+                        help='run queue worker %s' % (TEX2PDF_QUEUE))
     parser.add_argument('-v', '--version',
                         action='store_true',
                         help=('print %s version' % NAME))
@@ -30,8 +34,25 @@ if __name__ == '__main__':
     logging.basicConfig(level=(logging.DEBUG if args.debug else logging.INFO))
     if args.debug:
         logging.debug('run on debug mode with %s', args)
+
+    if is_stopped():
+        logging.error('.stop file existed, quit...')
+        sys.exit(1)
+
     logging.info('load configuration from %s', args.config.name)
 
     config = tomllib.load(args.config)
-    rpc_params = config['rpc']
-    start_server('0.0.0.0:%d' % (rpc_params['port']), rpc_params['workers'])
+    redis_client = RedisClient(config['redis'])
+    minio_client = MinioClient(config['minio'])
+    rabbitmq_client = RabbitMqClient(config['rabbitmq'])
+    if args.worker:
+        if args.worker == TEX2PDF_QUEUE:
+            callback = create_tex2pdf_queue_callback(minio_client)
+            rabbitmq_client.start_consuming(TEX2PDF_QUEUE, callback)
+        else:
+            logging.error('unimplemented queue %s', args.worker)
+            sys.exit(1)
+        sys.exit()
+    rpc_server = RpcServer(
+        config['rpc'], minio_client, redis_client, rabbitmq_client)
+    rpc_server.start()

+ 140 - 46
rpc/lily/lily/palm/__init__.py

@@ -1,68 +1,162 @@
-
 import logging
-from time import sleep
-from concurrent import futures
-import threading
+import json
+import uuid
+import os.path
+
+
+from datetime import timedelta
+from datetime import datetime
+
 
 import psycopg
 import pika
-import grpc
-from grpc_health.v1 import health_pb2, health, health_pb2_grpc
+from redis import Redis
+from redis.cluster import RedisCluster
+from minio import Minio
+from minio.versioningconfig import VersioningConfig as MinioVersioningConfig
+from minio.commonconfig import ENABLED as MinioEnabled, Tags as MinioTags
+
+
+VERSION = '2023.11.10'
+
+
+def is_stopped():
+    return os.path.isfile('.stop')
+
+
+class RedisClient:
+    def __init__(self, config):
+        self.namespace = config['namespace']
+        if config["db"]:
+            self.connection = self.connection = Redis(
+                host=config['host'], port=config['port'], db=config['db'])
+            logging.info('connect redis single node tcp://%s:%d/%d with namespace(%s)',
+                         config['host'], config['port'], config['db'], self.namespace)
+        else:
+            self.connection = RedisCluster(
+                host=config['host'], port=config['port'])
+            logging.info('connect redis cluster nodes with namespace(%s) in %s', self.namespace, list(
+                map(lambda x: '%s:%d(%s)' % (x.host, x.port, x.server_type), self.connection.get_nodes())))
+        self.connection.ping()
+
+    def set(self, key, val, ttl=0):
+        self.connection.setex(self._key(key), timedelta(seconds=ttl), val)
 
-from . import lily_pb2_grpc, excel, tex
+    def get(self, key):
+        return self.connection.get(self._key(key))
 
+    def _key(self, k):
+        return '%s://%s' % (self.namespace, k)
 
-VERSION = '2023.9.29'
 
+class MinioClient:
+    def __init__(self, config):
+        logging.debug("connect to minio %s", config['endpoint'])
+        self.namespace = config['namespace']
+        self.connection = Minio(
+            config['endpoint'],
+            access_key=config['access-key'],
+            secret_key=config['secret-key'],
+            secure=config['secure'])
+        logging.debug('found buckets: %s', self.list_buckets())
 
-def _health_checker(servicer, name):
-    while True:
-        servicer.set(name, health_pb2.HealthCheckResponse.SERVING)
-        sleep(5)
+    def put_object(self, bucket, name, data, length, content_type):
+        logging.debug("try to upload(%s, %s, %s) with %d bytes",
+                      bucket, name, content_type, length)
+        result = self.connection.put_object(
+            bucket, name, data, length, content_type=content_type)
+        logging.info("uploaded %s, etag: %s, version-id: %s",
+                     result.object_name, result.etag, result.version_id)
 
+    def get_object_url(self, bucket, name, ttl=60*60*24*7):
+        return self.connection.presigned_get_object(bucket, name, expires=timedelta(seconds=ttl))
 
-def _setup_health_thread(server):
-    servicer = health.HealthServicer(
-        experimental_non_blocking=True,
-        experimental_thread_pool=futures.ThreadPoolExecutor(max_workers=2)
-    )
-    health_pb2_grpc.add_HealthServicer_to_server(servicer, server)
-    health_checker_thread = threading.Thread(
-        target=_health_checker,
-        args=(servicer, 'palm.lily'),
-        daemon=True
-    )
-    health_checker_thread.start()
+    def set_object_tags(self, bucket, name, tags):
+        tmp = MinioTags.new_object_tags()
+        for k, v in tags:
+            tmp[k] = v
+        self.connection.set_object_tags(bucket, name, tmp)
 
+    def bucket_exists(self, bucket, published=False):
+        ok = self.connection.bucket_exists(bucket)
+        if not ok:
+            logging.warning("bucket %s isn't existed, try to create it")
+            self.connection.make_bucket(bucket)
+            self.connection.set_bucket_versioning(
+                bucket, MinioVersioningConfig(MinioEnabled))
 
-def start_server(addr, workers):
-    server = grpc.server(futures.ThreadPoolExecutor(max_workers=workers))
-    lily_pb2_grpc.add_ExcelServicer_to_server(excel.Service(), server)
-    lily_pb2_grpc.add_TexServicer_to_server(tex.Service(), server)
-    _setup_health_thread(server)
-    server.add_insecure_port(addr)
-    server.start()
-    logging.info(
-        "Lily gRPC server started, listening on %s with %d threads", addr, workers)
-    try:
-        server.wait_for_termination()
-    except KeyboardInterrupt:
-        logging.warn('exited...')
-        server.stop(0)
+        if published:
+            policy = {
+                "Version": "2023-10-06",
+                "Statement": [
+                    {
+                        "Effect": "Allow",
+                        "Principal": {"AWS": "*"},
+                        "Action": [
+                            "s3:GetObject"
+                        ],
+                        "Resource": "arn:aws:s3:::%s/*" % bucket,
+                    },
+                ],
+            }
+            self.connection.set_bucket_policy(bucket, json.dumps(policy))
+
+    def list_buckets(self):
+        return list(map(lambda x: x.name, self.connection.list_buckets()))
+
+    def current_bucket(self, published):
+        return '-' .join([self.namespace, datetime.now().strftime("%Y"), ('o' if published else 'p')])
+
+    def random_filename(ext=''):
+        return str(uuid.uuid4())+ext
 
 
 # https://pika.readthedocs.io/en/stable/modules/parameters.html
-def rabbitmq_parameters(config):
-    credentials = pika.PlainCredentials(config['user'], config['password'])
-    parameters = pika.ConnectionParameters(
-        config['host'],
-        config['port'],
-        config['virtual-host'],
-        credentials)
-    return parameters
+class RabbitMqClient:
+    def __init__(self, config):
+        credentials = pika.PlainCredentials(config['user'], config['password'])
+        self.parameters = pika.ConnectionParameters(
+            config['host'],
+            config['port'],
+            config['virtual-host'],
+            credentials)
+
+    def produce(self, queue, id, message):
+        logging.info("publish message(%s) to (%s) with %d bytes",
+                     id, queue, len(message))
+        with pika.BlockingConnection(self.parameters) as con:
+            ch = con.channel()
+            ch.queue_declare(queue=queue, durable=True)
+            ch.basic_publish(exchange='', routing_key=queue,
+                             body=message, properties=pika.BasicProperties(message_id=id, delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE))
+
+    def start_consuming(self, queue, callback):
+        logging.info("start consumer for %s", queue)
+
+        def handler(ch, method, properties, body):
+            callback(ch, method, properties, body)
+            if is_stopped():
+                logging.warning("stop consumer")
+                ch.stop_consuming()
+
+        with pika.BlockingConnection(self.parameters) as con:
+            ch = con.channel()
+            ch.queue_declare(queue=queue, durable=True)
+            ch.basic_qos(prefetch_count=1)
+            ch.basic_consume(queue=queue, on_message_callback=handler)
+            try:
+                ch.start_consuming()
+            except KeyboardInterrupt:
+                logging.warning("quit consumer...")
+                ch.stop_consuming()
+
+
+# -----------------------------------------------------------------------------
 
 
 # https://www.postgresql.org/docs/current/libpq-connect.html
+
+
 def postgresql_url(config):
     logging.debug('open postgresql://%s@%s:%d/%s',
                   config['user'], config['host'], config['port'], config['name'])

+ 13 - 0
rpc/lily/lily/palm/s3.py

@@ -0,0 +1,13 @@
+from . import lily_pb2, lily_pb2_grpc
+
+
+class Service(lily_pb2_grpc.S3Servicer):
+    def __init__(self, s3):
+        super().__init__()
+        self.s3 = s3
+
+    def GetFile(self, request, context):
+        response = lily_pb2.S3GetFileResponse()
+        response.url = self.s3.get_object_url(
+            request.bucket, request.name, request.ttl.seconds)
+        return response

+ 55 - 0
rpc/lily/lily/palm/server.py

@@ -0,0 +1,55 @@
+import threading
+import logging
+
+from time import sleep
+from concurrent import futures
+
+import grpc
+from grpc_health.v1 import health_pb2, health, health_pb2_grpc
+
+from . import lily_pb2_grpc, excel, tex, s3
+
+
+class Rpc:
+    def __init__(self, config, s3, cache, queue):
+        self.addr = '0.0.0.0:%d' % (config['port'])
+        self.max_workers = config['max-workers']
+        self.s3 = s3
+        self.cache = cache
+        self.queue = queue
+
+    def start(self):
+        server = grpc.server(futures.ThreadPoolExecutor(
+            max_workers=self.max_workers))
+        lily_pb2_grpc.add_ExcelServicer_to_server(excel.Service(), server)
+        lily_pb2_grpc.add_TexServicer_to_server(
+            tex.Service(self.s3, self.cache, self.queue), server)
+        lily_pb2_grpc.add_S3Servicer_to_server(s3.Service(self.s3), server)
+        Rpc._rpc_setup_health_thread(server)
+        server.add_insecure_port(self.addr)
+        server.start()
+        logging.info(
+            "Lily gRPC server started, listening on %s with %d threads", self.addr, self.max_workers)
+        try:
+            server.wait_for_termination()
+        except KeyboardInterrupt:
+            logging.warning('exited...')
+            server.stop(0)
+
+    def _rpc_health_checker(servicer, name):
+        while True:
+            servicer.set(name, health_pb2.HealthCheckResponse.SERVING)
+            sleep(5)
+
+    def _rpc_setup_health_thread(server):
+        servicer = health.HealthServicer(
+            experimental_non_blocking=True,
+            experimental_thread_pool=futures.ThreadPoolExecutor(max_workers=2)
+        )
+        health_pb2_grpc.add_HealthServicer_to_server(servicer, server)
+        health_checker_thread = threading.Thread(
+            target=Rpc._rpc_health_checker,
+            args=(servicer, 'palm.lily'),
+            daemon=True
+        )
+        health_checker_thread.start()

+ 69 - 17
rpc/lily/lily/palm/tex.py

@@ -3,31 +3,83 @@ import tempfile
 import os.path
 import subprocess
 
+
+from io import BytesIO
+
+
+import msgpack
+
 from . import lily_pb2, lily_pb2_grpc
 
+from . import MinioClient
 
-def _tmp_root():
-    return os.path.join(tempfile.tempdir())
+TEX2PDF_QUEUE = 'palm.lily.tex-to-pdf'
 
 
 class Service(lily_pb2_grpc.TexServicer):
+    def __init__(self, s3, cache, queue):
+        super().__init__()
+        self.s3 = s3
+        self.cache = cache
+        self.queue = queue
+
     def ToPdf(self, request, context):
-        logging.info("convert tex to pdf(%d)" % len(request.files))
-        with tempfile.TemporaryDirectory(prefix='tex-') as root:
-            for name in request.files:
-                with open(os.path.join(root, name), mode='wb') as fd:
-                    logging.debug("generate file %s/%s", root, name)
-                    fd.write(request.files[name])
-            for x in range(2):
-                subprocess.run(
-                    ['xelatex', '-halt-on-error', 'main.tex'], cwd=root)
-            with open(os.path.join(root, 'main.pdf'), mode="rb") as fd:
-                response = lily_pb2.File()
-                response.content_type = 'application/pdf'
-                response.payload = fd.read()
-                return response
+        response = lily_pb2.S3File()
+        response.content_type = 'application/pdf'
+        response.name = MinioClient.random_filename('.pdf')
+        response.bucket = self.s3.current_bucket(request.published)
+
+        task = msgpack.packb(
+            [request.SerializeToString(), response.SerializeToString()], use_bin_type=True)
+        self.queue.produce(TEX2PDF_QUEUE, response.name, task)
+        return response
 
     def ToWord(self, request, context):
         logging.info("convert tex to word %s" % request.content_type)
-        response = lily_pb2.File()
+        response = lily_pb2.S3File()
+        # TODO
         return response
+
+
+def create_tex2pdf_queue_callback(s3):
+    def it(ch, method, properties, body):
+        logging.info("receive message %s", properties.message_id)
+        _handle_tex2pdf_message(body, s3)
+        ch.basic_ack(delivery_tag=method.delivery_tag)
+    return it
+
+
+def _handle_tex2pdf_message(message, s3):
+    (request_b, response_b) = msgpack.unpackb(
+        message, use_list=False, raw=False)
+    request = lily_pb2.TexToRequest()
+    request.ParseFromString(request_b)
+    response = lily_pb2.S3File()
+    response.ParseFromString(response_b)
+    logging.info("convert tex to pdf(%d) ", len(request.files))
+    with tempfile.TemporaryDirectory(prefix='tex-') as root:
+        for name in request.files:
+            with open(os.path.join(root, name), mode='wb') as fd:
+                logging.debug("generate file %s/%s", root, name)
+                fd.write(request.files[name])
+        for _ in range(2):
+            try:
+                subprocess.run(
+                    ['xelatex', '-halt-on-error', 'main.tex'],  check=True, cwd=root)
+            except subprocess.CalledProcessError as e:
+                logging.error("%s", e)
+                return
+
+        pdf_file = os.path.join(root, 'main.pdf')
+        pdf_size = os.path.getsize(pdf_file)
+        with open(pdf_file, mode="rb") as fd:
+            pdf_data = BytesIO(fd.read())
+            s3.bucket_exists(response.bucket, request.published)
+            s3.put_object(response.bucket, response.name,
+                          pdf_data, pdf_size, response.content_type)
+            tags = {'title': request.title}
+            if request.has_owner:
+                tags['owner'] = request.owner
+            if request.has_ttl:
+                tags['ttl'] = request.ttl.seconds
+            s3.set_object_tags(response.bucket, response.name, tags)

+ 0 - 0
rpc/lily/lily/palm/worker.py


+ 3 - 3
rpc/lily/start.sh

@@ -1,5 +1,5 @@
-#!/bin/sh
+#!/bin/bash
 
-export CODE="mint-lily"
+export CODE="palm-lily"
 
-podman run -it --rm --events-backend=file --hostname=palm --network host $CODE
+podman run --rm --events-backend=file --hostname=palm --network host $CODE /bin/bash -i -c "python . $*"

+ 1 - 2
rpc/morus/morus/composer.json

@@ -9,8 +9,7 @@
       "GPBMetadata\\": [
         "GPBMetadata/"
       ],
-      "Mint\\": "Mint/",
-      "Palm\\": "Palm/"
+      "Mint\\": "Mint/"
     }
   }
 }

+ 85 - 0
rpc/protocols/tulip.proto

@@ -0,0 +1,85 @@
+syntax = "proto3";
+option java_multiple_files = true;
+option java_package = "com.github.iapt_platform.mint.plugins.tulip.v1";
+package mint.tulip.v1;
+
+// ----------------------------------------------------------------------------
+message SearchRequest {
+  repeated string keywords = 1;
+  repeated int32 books = 2;
+  string match_mode = 3;
+
+  message Page {
+    int32 index = 1;
+    int32 size = 2;
+  }
+  optional Page page = 99;
+}
+
+message SearchResponse {
+  message Item {
+    float rank = 1;
+    string highlight = 2;
+    int32 book = 3;
+    int32 paragraph = 4;
+    string content = 5;
+  }
+  repeated Item items = 1;
+
+  SearchRequest.Page page = 98;
+  int32 total = 99;
+}
+
+message BookListResponse {
+  message Item {
+    int32 book = 1;
+    int32 count = 2;
+  }
+  repeated Item items = 1;
+}
+
+message UpdateRequest {
+  int32 book = 1;
+  int32 paragraph = 2;
+  int32 level = 3;
+  string bold1 = 4;
+  string bold2 = 5;
+  string bold3 = 6;
+  string content = 7;
+  int32 pcd_book_id = 8;
+}
+message UpdateResponse{
+  int32 count = 1;
+}
+
+message UpdateIndexRequest{
+  int32 book = 1;
+  optional int32 paragraph = 2;
+}
+
+message UpdateIndexResponse{
+  int32 error = 1;
+}
+
+message UploadDictionaryRequest{
+  string data = 1;
+}
+
+message UploadDictionaryResponse{
+  int32 error = 1;
+}
+
+service Search {
+  rpc Pali(SearchRequest) returns (SearchResponse) {}
+
+  rpc BookList(SearchRequest) returns (BookListResponse) {}
+
+  rpc Update(UpdateRequest) returns (UpdateResponse) {}
+
+  rpc UpdateIndex(UpdateIndexRequest) returns (UpdateIndexResponse) {}
+
+  rpc UploadDictionary(UploadDictionaryRequest) returns (UploadDictionaryResponse) {}
+
+}
+
+// ----------------------------------------------------------------------------

+ 24 - 1
rpc/schema.sh

@@ -50,7 +50,6 @@ function generate_for_morus() {
     local -a folders=(
         "GPBMetadata"
         "Mint"
-        "Palm"
     )
     for f in "${folders[@]}"
     do
@@ -90,6 +89,29 @@ function generate_for_lily() {
     sed -i 's/import lily_/from . import lily_/g' $target/lily_pb2_grpc.py
 }
 
+
+function generate_for_tulip() {
+    echo "generate code for tulip project"
+    local target=$WORKSPACE/tulip/tulip
+    local -a folders=(
+        "GPBMetadata"
+        "Mint"        
+    )
+    for f in "${folders[@]}"
+    do
+        local t=$target/$f
+        if [ -d $t ]
+        then
+            rm -r $t
+        fi
+    done
+    $PROTOBUF_ROOT/bin/protoc -I $WORKSPACE/protocols \
+        -I $PROTOBUF_ROOT/include/google/protobuf \
+        --php_out=$target --grpc_out=generate_server:$target \
+        --plugin=protoc-gen-grpc=$PROTOBUF_ROOT/bin/grpc_php_plugin \
+        $WORKSPACE/protocols/tulip.proto    
+}
+
 function generate_grpc_for_php() {
     if [ -d $1 ]
     then
@@ -121,6 +143,7 @@ generate_grpc_for_php $WORKSPACE/sdk/php
 
 generate_for_morus
 generate_for_lily
+generate_for_tulip
 
 generate_grpc_web $WORKSPACE/../dashboard
 

+ 90 - 0
rpc/sdk/cpp/tulip.grpc.pb.cc

@@ -0,0 +1,90 @@
+// Generated by the gRPC C++ plugin.
+// If you make any local change, they will be lost.
+// source: tulip.proto
+
+#include "tulip.pb.h"
+#include "tulip.grpc.pb.h"
+
+#include <functional>
+#include <grpcpp/support/async_stream.h>
+#include <grpcpp/support/async_unary_call.h>
+#include <grpcpp/impl/channel_interface.h>
+#include <grpcpp/impl/client_unary_call.h>
+#include <grpcpp/support/client_callback.h>
+#include <grpcpp/support/message_allocator.h>
+#include <grpcpp/support/method_handler.h>
+#include <grpcpp/impl/rpc_service_method.h>
+#include <grpcpp/support/server_callback.h>
+#include <grpcpp/impl/server_callback_handlers.h>
+#include <grpcpp/server_context.h>
+#include <grpcpp/impl/service_type.h>
+#include <grpcpp/support/sync_stream.h>
+namespace mint {
+namespace tulip {
+namespace v1 {
+
+static const char* Search_method_names[] = {
+  "/mint.tulip.v1.Search/Pali",
+};
+
+std::unique_ptr< Search::Stub> Search::NewStub(const std::shared_ptr< ::grpc::ChannelInterface>& channel, const ::grpc::StubOptions& options) {
+  (void)options;
+  std::unique_ptr< Search::Stub> stub(new Search::Stub(channel, options));
+  return stub;
+}
+
+Search::Stub::Stub(const std::shared_ptr< ::grpc::ChannelInterface>& channel, const ::grpc::StubOptions& options)
+  : channel_(channel), rpcmethod_Pali_(Search_method_names[0], options.suffix_for_stats(),::grpc::internal::RpcMethod::NORMAL_RPC, channel)
+  {}
+
+::grpc::Status Search::Stub::Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::mint::tulip::v1::SearchResponse* response) {
+  return ::grpc::internal::BlockingUnaryCall< ::mint::tulip::v1::SearchRequest, ::mint::tulip::v1::SearchResponse, ::grpc::protobuf::MessageLite, ::grpc::protobuf::MessageLite>(channel_.get(), rpcmethod_Pali_, context, request, response);
+}
+
+void Search::Stub::async::Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response, std::function<void(::grpc::Status)> f) {
+  ::grpc::internal::CallbackUnaryCall< ::mint::tulip::v1::SearchRequest, ::mint::tulip::v1::SearchResponse, ::grpc::protobuf::MessageLite, ::grpc::protobuf::MessageLite>(stub_->channel_.get(), stub_->rpcmethod_Pali_, context, request, response, std::move(f));
+}
+
+void Search::Stub::async::Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response, ::grpc::ClientUnaryReactor* reactor) {
+  ::grpc::internal::ClientCallbackUnaryFactory::Create< ::grpc::protobuf::MessageLite, ::grpc::protobuf::MessageLite>(stub_->channel_.get(), stub_->rpcmethod_Pali_, context, request, response, reactor);
+}
+
+::grpc::ClientAsyncResponseReader< ::mint::tulip::v1::SearchResponse>* Search::Stub::PrepareAsyncPaliRaw(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) {
+  return ::grpc::internal::ClientAsyncResponseReaderHelper::Create< ::mint::tulip::v1::SearchResponse, ::mint::tulip::v1::SearchRequest, ::grpc::protobuf::MessageLite, ::grpc::protobuf::MessageLite>(channel_.get(), cq, rpcmethod_Pali_, context, request);
+}
+
+::grpc::ClientAsyncResponseReader< ::mint::tulip::v1::SearchResponse>* Search::Stub::AsyncPaliRaw(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) {
+  auto* result =
+    this->PrepareAsyncPaliRaw(context, request, cq);
+  result->StartCall();
+  return result;
+}
+
+Search::Service::Service() {
+  AddMethod(new ::grpc::internal::RpcServiceMethod(
+      Search_method_names[0],
+      ::grpc::internal::RpcMethod::NORMAL_RPC,
+      new ::grpc::internal::RpcMethodHandler< Search::Service, ::mint::tulip::v1::SearchRequest, ::mint::tulip::v1::SearchResponse, ::grpc::protobuf::MessageLite, ::grpc::protobuf::MessageLite>(
+          [](Search::Service* service,
+             ::grpc::ServerContext* ctx,
+             const ::mint::tulip::v1::SearchRequest* req,
+             ::mint::tulip::v1::SearchResponse* resp) {
+               return service->Pali(ctx, req, resp);
+             }, this)));
+}
+
+Search::Service::~Service() {
+}
+
+::grpc::Status Search::Service::Pali(::grpc::ServerContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response) {
+  (void) context;
+  (void) request;
+  (void) response;
+  return ::grpc::Status(::grpc::StatusCode::UNIMPLEMENTED, "");
+}
+
+
+}  // namespace mint
+}  // namespace tulip
+}  // namespace v1
+

+ 244 - 0
rpc/sdk/cpp/tulip.grpc.pb.h

@@ -0,0 +1,244 @@
+// Generated by the gRPC C++ plugin.
+// If you make any local change, they will be lost.
+// source: tulip.proto
+#ifndef GRPC_tulip_2eproto__INCLUDED
+#define GRPC_tulip_2eproto__INCLUDED
+
+#include "tulip.pb.h"
+
+#include <functional>
+#include <grpcpp/generic/async_generic_service.h>
+#include <grpcpp/support/async_stream.h>
+#include <grpcpp/support/async_unary_call.h>
+#include <grpcpp/support/client_callback.h>
+#include <grpcpp/client_context.h>
+#include <grpcpp/completion_queue.h>
+#include <grpcpp/support/message_allocator.h>
+#include <grpcpp/support/method_handler.h>
+#include <grpcpp/impl/proto_utils.h>
+#include <grpcpp/impl/rpc_method.h>
+#include <grpcpp/support/server_callback.h>
+#include <grpcpp/impl/server_callback_handlers.h>
+#include <grpcpp/server_context.h>
+#include <grpcpp/impl/service_type.h>
+#include <grpcpp/support/status.h>
+#include <grpcpp/support/stub_options.h>
+#include <grpcpp/support/sync_stream.h>
+
+namespace mint {
+namespace tulip {
+namespace v1 {
+
+class Search final {
+ public:
+  static constexpr char const* service_full_name() {
+    return "mint.tulip.v1.Search";
+  }
+  class StubInterface {
+   public:
+    virtual ~StubInterface() {}
+    virtual ::grpc::Status Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::mint::tulip::v1::SearchResponse* response) = 0;
+    std::unique_ptr< ::grpc::ClientAsyncResponseReaderInterface< ::mint::tulip::v1::SearchResponse>> AsyncPali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) {
+      return std::unique_ptr< ::grpc::ClientAsyncResponseReaderInterface< ::mint::tulip::v1::SearchResponse>>(AsyncPaliRaw(context, request, cq));
+    }
+    std::unique_ptr< ::grpc::ClientAsyncResponseReaderInterface< ::mint::tulip::v1::SearchResponse>> PrepareAsyncPali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) {
+      return std::unique_ptr< ::grpc::ClientAsyncResponseReaderInterface< ::mint::tulip::v1::SearchResponse>>(PrepareAsyncPaliRaw(context, request, cq));
+    }
+    class async_interface {
+     public:
+      virtual ~async_interface() {}
+      virtual void Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response, std::function<void(::grpc::Status)>) = 0;
+      virtual void Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response, ::grpc::ClientUnaryReactor* reactor) = 0;
+    };
+    typedef class async_interface experimental_async_interface;
+    virtual class async_interface* async() { return nullptr; }
+    class async_interface* experimental_async() { return async(); }
+   private:
+    virtual ::grpc::ClientAsyncResponseReaderInterface< ::mint::tulip::v1::SearchResponse>* AsyncPaliRaw(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) = 0;
+    virtual ::grpc::ClientAsyncResponseReaderInterface< ::mint::tulip::v1::SearchResponse>* PrepareAsyncPaliRaw(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) = 0;
+  };
+  class Stub final : public StubInterface {
+   public:
+    Stub(const std::shared_ptr< ::grpc::ChannelInterface>& channel, const ::grpc::StubOptions& options = ::grpc::StubOptions());
+    ::grpc::Status Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::mint::tulip::v1::SearchResponse* response) override;
+    std::unique_ptr< ::grpc::ClientAsyncResponseReader< ::mint::tulip::v1::SearchResponse>> AsyncPali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) {
+      return std::unique_ptr< ::grpc::ClientAsyncResponseReader< ::mint::tulip::v1::SearchResponse>>(AsyncPaliRaw(context, request, cq));
+    }
+    std::unique_ptr< ::grpc::ClientAsyncResponseReader< ::mint::tulip::v1::SearchResponse>> PrepareAsyncPali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) {
+      return std::unique_ptr< ::grpc::ClientAsyncResponseReader< ::mint::tulip::v1::SearchResponse>>(PrepareAsyncPaliRaw(context, request, cq));
+    }
+    class async final :
+      public StubInterface::async_interface {
+     public:
+      void Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response, std::function<void(::grpc::Status)>) override;
+      void Pali(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response, ::grpc::ClientUnaryReactor* reactor) override;
+     private:
+      friend class Stub;
+      explicit async(Stub* stub): stub_(stub) { }
+      Stub* stub() { return stub_; }
+      Stub* stub_;
+    };
+    class async* async() override { return &async_stub_; }
+
+   private:
+    std::shared_ptr< ::grpc::ChannelInterface> channel_;
+    class async async_stub_{this};
+    ::grpc::ClientAsyncResponseReader< ::mint::tulip::v1::SearchResponse>* AsyncPaliRaw(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) override;
+    ::grpc::ClientAsyncResponseReader< ::mint::tulip::v1::SearchResponse>* PrepareAsyncPaliRaw(::grpc::ClientContext* context, const ::mint::tulip::v1::SearchRequest& request, ::grpc::CompletionQueue* cq) override;
+    const ::grpc::internal::RpcMethod rpcmethod_Pali_;
+  };
+  static std::unique_ptr<Stub> NewStub(const std::shared_ptr< ::grpc::ChannelInterface>& channel, const ::grpc::StubOptions& options = ::grpc::StubOptions());
+
+  class Service : public ::grpc::Service {
+   public:
+    Service();
+    virtual ~Service();
+    virtual ::grpc::Status Pali(::grpc::ServerContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response);
+  };
+  template <class BaseClass>
+  class WithAsyncMethod_Pali : public BaseClass {
+   private:
+    void BaseClassMustBeDerivedFromService(const Service* /*service*/) {}
+   public:
+    WithAsyncMethod_Pali() {
+      ::grpc::Service::MarkMethodAsync(0);
+    }
+    ~WithAsyncMethod_Pali() override {
+      BaseClassMustBeDerivedFromService(this);
+    }
+    // disable synchronous version of this method
+    ::grpc::Status Pali(::grpc::ServerContext* /*context*/, const ::mint::tulip::v1::SearchRequest* /*request*/, ::mint::tulip::v1::SearchResponse* /*response*/) override {
+      abort();
+      return ::grpc::Status(::grpc::StatusCode::UNIMPLEMENTED, "");
+    }
+    void RequestPali(::grpc::ServerContext* context, ::mint::tulip::v1::SearchRequest* request, ::grpc::ServerAsyncResponseWriter< ::mint::tulip::v1::SearchResponse>* response, ::grpc::CompletionQueue* new_call_cq, ::grpc::ServerCompletionQueue* notification_cq, void *tag) {
+      ::grpc::Service::RequestAsyncUnary(0, context, request, response, new_call_cq, notification_cq, tag);
+    }
+  };
+  typedef WithAsyncMethod_Pali<Service > AsyncService;
+  template <class BaseClass>
+  class WithCallbackMethod_Pali : public BaseClass {
+   private:
+    void BaseClassMustBeDerivedFromService(const Service* /*service*/) {}
+   public:
+    WithCallbackMethod_Pali() {
+      ::grpc::Service::MarkMethodCallback(0,
+          new ::grpc::internal::CallbackUnaryHandler< ::mint::tulip::v1::SearchRequest, ::mint::tulip::v1::SearchResponse>(
+            [this](
+                   ::grpc::CallbackServerContext* context, const ::mint::tulip::v1::SearchRequest* request, ::mint::tulip::v1::SearchResponse* response) { return this->Pali(context, request, response); }));}
+    void SetMessageAllocatorFor_Pali(
+        ::grpc::MessageAllocator< ::mint::tulip::v1::SearchRequest, ::mint::tulip::v1::SearchResponse>* allocator) {
+      ::grpc::internal::MethodHandler* const handler = ::grpc::Service::GetHandler(0);
+      static_cast<::grpc::internal::CallbackUnaryHandler< ::mint::tulip::v1::SearchRequest, ::mint::tulip::v1::SearchResponse>*>(handler)
+              ->SetMessageAllocator(allocator);
+    }
+    ~WithCallbackMethod_Pali() override {
+      BaseClassMustBeDerivedFromService(this);
+    }
+    // disable synchronous version of this method
+    ::grpc::Status Pali(::grpc::ServerContext* /*context*/, const ::mint::tulip::v1::SearchRequest* /*request*/, ::mint::tulip::v1::SearchResponse* /*response*/) override {
+      abort();
+      return ::grpc::Status(::grpc::StatusCode::UNIMPLEMENTED, "");
+    }
+    virtual ::grpc::ServerUnaryReactor* Pali(
+      ::grpc::CallbackServerContext* /*context*/, const ::mint::tulip::v1::SearchRequest* /*request*/, ::mint::tulip::v1::SearchResponse* /*response*/)  { return nullptr; }
+  };
+  typedef WithCallbackMethod_Pali<Service > CallbackService;
+  typedef CallbackService ExperimentalCallbackService;
+  template <class BaseClass>
+  class WithGenericMethod_Pali : public BaseClass {
+   private:
+    void BaseClassMustBeDerivedFromService(const Service* /*service*/) {}
+   public:
+    WithGenericMethod_Pali() {
+      ::grpc::Service::MarkMethodGeneric(0);
+    }
+    ~WithGenericMethod_Pali() override {
+      BaseClassMustBeDerivedFromService(this);
+    }
+    // disable synchronous version of this method
+    ::grpc::Status Pali(::grpc::ServerContext* /*context*/, const ::mint::tulip::v1::SearchRequest* /*request*/, ::mint::tulip::v1::SearchResponse* /*response*/) override {
+      abort();
+      return ::grpc::Status(::grpc::StatusCode::UNIMPLEMENTED, "");
+    }
+  };
+  template <class BaseClass>
+  class WithRawMethod_Pali : public BaseClass {
+   private:
+    void BaseClassMustBeDerivedFromService(const Service* /*service*/) {}
+   public:
+    WithRawMethod_Pali() {
+      ::grpc::Service::MarkMethodRaw(0);
+    }
+    ~WithRawMethod_Pali() override {
+      BaseClassMustBeDerivedFromService(this);
+    }
+    // disable synchronous version of this method
+    ::grpc::Status Pali(::grpc::ServerContext* /*context*/, const ::mint::tulip::v1::SearchRequest* /*request*/, ::mint::tulip::v1::SearchResponse* /*response*/) override {
+      abort();
+      return ::grpc::Status(::grpc::StatusCode::UNIMPLEMENTED, "");
+    }
+    void RequestPali(::grpc::ServerContext* context, ::grpc::ByteBuffer* request, ::grpc::ServerAsyncResponseWriter< ::grpc::ByteBuffer>* response, ::grpc::CompletionQueue* new_call_cq, ::grpc::ServerCompletionQueue* notification_cq, void *tag) {
+      ::grpc::Service::RequestAsyncUnary(0, context, request, response, new_call_cq, notification_cq, tag);
+    }
+  };
+  template <class BaseClass>
+  class WithRawCallbackMethod_Pali : public BaseClass {
+   private:
+    void BaseClassMustBeDerivedFromService(const Service* /*service*/) {}
+   public:
+    WithRawCallbackMethod_Pali() {
+      ::grpc::Service::MarkMethodRawCallback(0,
+          new ::grpc::internal::CallbackUnaryHandler< ::grpc::ByteBuffer, ::grpc::ByteBuffer>(
+            [this](
+                   ::grpc::CallbackServerContext* context, const ::grpc::ByteBuffer* request, ::grpc::ByteBuffer* response) { return this->Pali(context, request, response); }));
+    }
+    ~WithRawCallbackMethod_Pali() override {
+      BaseClassMustBeDerivedFromService(this);
+    }
+    // disable synchronous version of this method
+    ::grpc::Status Pali(::grpc::ServerContext* /*context*/, const ::mint::tulip::v1::SearchRequest* /*request*/, ::mint::tulip::v1::SearchResponse* /*response*/) override {
+      abort();
+      return ::grpc::Status(::grpc::StatusCode::UNIMPLEMENTED, "");
+    }
+    virtual ::grpc::ServerUnaryReactor* Pali(
+      ::grpc::CallbackServerContext* /*context*/, const ::grpc::ByteBuffer* /*request*/, ::grpc::ByteBuffer* /*response*/)  { return nullptr; }
+  };
+  template <class BaseClass>
+  class WithStreamedUnaryMethod_Pali : public BaseClass {
+   private:
+    void BaseClassMustBeDerivedFromService(const Service* /*service*/) {}
+   public:
+    WithStreamedUnaryMethod_Pali() {
+      ::grpc::Service::MarkMethodStreamed(0,
+        new ::grpc::internal::StreamedUnaryHandler<
+          ::mint::tulip::v1::SearchRequest, ::mint::tulip::v1::SearchResponse>(
+            [this](::grpc::ServerContext* context,
+                   ::grpc::ServerUnaryStreamer<
+                     ::mint::tulip::v1::SearchRequest, ::mint::tulip::v1::SearchResponse>* streamer) {
+                       return this->StreamedPali(context,
+                         streamer);
+                  }));
+    }
+    ~WithStreamedUnaryMethod_Pali() override {
+      BaseClassMustBeDerivedFromService(this);
+    }
+    // disable regular version of this method
+    ::grpc::Status Pali(::grpc::ServerContext* /*context*/, const ::mint::tulip::v1::SearchRequest* /*request*/, ::mint::tulip::v1::SearchResponse* /*response*/) override {
+      abort();
+      return ::grpc::Status(::grpc::StatusCode::UNIMPLEMENTED, "");
+    }
+    // replace default version of method with streamed unary
+    virtual ::grpc::Status StreamedPali(::grpc::ServerContext* context, ::grpc::ServerUnaryStreamer< ::mint::tulip::v1::SearchRequest,::mint::tulip::v1::SearchResponse>* server_unary_streamer) = 0;
+  };
+  typedef WithStreamedUnaryMethod_Pali<Service > StreamedUnaryService;
+  typedef Service SplitStreamedService;
+  typedef WithStreamedUnaryMethod_Pali<Service > StreamedService;
+};
+
+}  // namespace v1
+}  // namespace tulip
+}  // namespace mint
+
+
+#endif  // GRPC_tulip_2eproto__INCLUDED

+ 1283 - 0
rpc/sdk/cpp/tulip.pb.cc

@@ -0,0 +1,1283 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: tulip.proto
+
+#include "tulip.pb.h"
+
+#include <algorithm>
+#include "google/protobuf/io/coded_stream.h"
+#include "google/protobuf/extension_set.h"
+#include "google/protobuf/wire_format_lite.h"
+#include "google/protobuf/descriptor.h"
+#include "google/protobuf/generated_message_reflection.h"
+#include "google/protobuf/reflection_ops.h"
+#include "google/protobuf/wire_format.h"
+#include "google/protobuf/generated_message_tctable_impl.h"
+// @@protoc_insertion_point(includes)
+
+// Must be included last.
+#include "google/protobuf/port_def.inc"
+PROTOBUF_PRAGMA_INIT_SEG
+namespace _pb = ::google::protobuf;
+namespace _pbi = ::google::protobuf::internal;
+namespace _fl = ::google::protobuf::internal::field_layout;
+namespace mint {
+namespace tulip {
+namespace v1 {
+        template <typename>
+PROTOBUF_CONSTEXPR SearchRequest_Page::SearchRequest_Page(::_pbi::ConstantInitialized)
+    : _impl_{
+      /*decltype(_impl_.index_)*/ 0,
+      /*decltype(_impl_.size_)*/ 0,
+      /*decltype(_impl_._cached_size_)*/ {},
+    } {}
+struct SearchRequest_PageDefaultTypeInternal {
+  PROTOBUF_CONSTEXPR SearchRequest_PageDefaultTypeInternal() : _instance(::_pbi::ConstantInitialized{}) {}
+  ~SearchRequest_PageDefaultTypeInternal() {}
+  union {
+    SearchRequest_Page _instance;
+  };
+};
+
+PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT
+    PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 SearchRequest_PageDefaultTypeInternal _SearchRequest_Page_default_instance_;
+        template <typename>
+PROTOBUF_CONSTEXPR SearchRequest::SearchRequest(::_pbi::ConstantInitialized)
+    : _impl_{
+      /*decltype(_impl_._has_bits_)*/ {},
+      /*decltype(_impl_._cached_size_)*/ {},
+      /*decltype(_impl_.keywords_)*/ {},
+      /*decltype(_impl_.page_)*/ nullptr,
+      /*decltype(_impl_.book_)*/ 0,
+    } {}
+struct SearchRequestDefaultTypeInternal {
+  PROTOBUF_CONSTEXPR SearchRequestDefaultTypeInternal() : _instance(::_pbi::ConstantInitialized{}) {}
+  ~SearchRequestDefaultTypeInternal() {}
+  union {
+    SearchRequest _instance;
+  };
+};
+
+PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT
+    PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 SearchRequestDefaultTypeInternal _SearchRequest_default_instance_;
+        template <typename>
+PROTOBUF_CONSTEXPR SearchResponse_Item::SearchResponse_Item(::_pbi::ConstantInitialized)
+    : _impl_{
+      /*decltype(_impl_.highlight_)*/ {
+          &::_pbi::fixed_address_empty_string,
+          ::_pbi::ConstantInitialized{},
+      },
+      /*decltype(_impl_.content_)*/ {
+          &::_pbi::fixed_address_empty_string,
+          ::_pbi::ConstantInitialized{},
+      },
+      /*decltype(_impl_.rank_)*/ 0,
+      /*decltype(_impl_.book_)*/ 0,
+      /*decltype(_impl_.paragraph_)*/ 0,
+      /*decltype(_impl_._cached_size_)*/ {},
+    } {}
+struct SearchResponse_ItemDefaultTypeInternal {
+  PROTOBUF_CONSTEXPR SearchResponse_ItemDefaultTypeInternal() : _instance(::_pbi::ConstantInitialized{}) {}
+  ~SearchResponse_ItemDefaultTypeInternal() {}
+  union {
+    SearchResponse_Item _instance;
+  };
+};
+
+PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT
+    PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 SearchResponse_ItemDefaultTypeInternal _SearchResponse_Item_default_instance_;
+        template <typename>
+PROTOBUF_CONSTEXPR SearchResponse::SearchResponse(::_pbi::ConstantInitialized)
+    : _impl_{
+      /*decltype(_impl_._has_bits_)*/ {},
+      /*decltype(_impl_._cached_size_)*/ {},
+      /*decltype(_impl_.items_)*/ {},
+      /*decltype(_impl_.page_)*/ nullptr,
+      /*decltype(_impl_.total_)*/ 0,
+    } {}
+struct SearchResponseDefaultTypeInternal {
+  PROTOBUF_CONSTEXPR SearchResponseDefaultTypeInternal() : _instance(::_pbi::ConstantInitialized{}) {}
+  ~SearchResponseDefaultTypeInternal() {}
+  union {
+    SearchResponse _instance;
+  };
+};
+
+PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT
+    PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 SearchResponseDefaultTypeInternal _SearchResponse_default_instance_;
+}  // namespace v1
+}  // namespace tulip
+}  // namespace mint
+static ::_pb::Metadata file_level_metadata_tulip_2eproto[4];
+static constexpr const ::_pb::EnumDescriptor**
+    file_level_enum_descriptors_tulip_2eproto = nullptr;
+static constexpr const ::_pb::ServiceDescriptor**
+    file_level_service_descriptors_tulip_2eproto = nullptr;
+const ::uint32_t TableStruct_tulip_2eproto::offsets[] PROTOBUF_SECTION_VARIABLE(
+    protodesc_cold) = {
+    ~0u,  // no _has_bits_
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchRequest_Page, _internal_metadata_),
+    ~0u,  // no _extensions_
+    ~0u,  // no _oneof_case_
+    ~0u,  // no _weak_field_map_
+    ~0u,  // no _inlined_string_donated_
+    ~0u,  // no _split_
+    ~0u,  // no sizeof(Split)
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchRequest_Page, _impl_.index_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchRequest_Page, _impl_.size_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchRequest, _impl_._has_bits_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchRequest, _internal_metadata_),
+    ~0u,  // no _extensions_
+    ~0u,  // no _oneof_case_
+    ~0u,  // no _weak_field_map_
+    ~0u,  // no _inlined_string_donated_
+    ~0u,  // no _split_
+    ~0u,  // no sizeof(Split)
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchRequest, _impl_.keywords_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchRequest, _impl_.book_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchRequest, _impl_.page_),
+    ~0u,
+    ~0u,
+    0,
+    ~0u,  // no _has_bits_
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse_Item, _internal_metadata_),
+    ~0u,  // no _extensions_
+    ~0u,  // no _oneof_case_
+    ~0u,  // no _weak_field_map_
+    ~0u,  // no _inlined_string_donated_
+    ~0u,  // no _split_
+    ~0u,  // no sizeof(Split)
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse_Item, _impl_.rank_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse_Item, _impl_.highlight_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse_Item, _impl_.book_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse_Item, _impl_.paragraph_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse_Item, _impl_.content_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse, _impl_._has_bits_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse, _internal_metadata_),
+    ~0u,  // no _extensions_
+    ~0u,  // no _oneof_case_
+    ~0u,  // no _weak_field_map_
+    ~0u,  // no _inlined_string_donated_
+    ~0u,  // no _split_
+    ~0u,  // no sizeof(Split)
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse, _impl_.items_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse, _impl_.page_),
+    PROTOBUF_FIELD_OFFSET(::mint::tulip::v1::SearchResponse, _impl_.total_),
+    ~0u,
+    0,
+    ~0u,
+};
+
+static const ::_pbi::MigrationSchema
+    schemas[] PROTOBUF_SECTION_VARIABLE(protodesc_cold) = {
+        {0, -1, -1, sizeof(::mint::tulip::v1::SearchRequest_Page)},
+        {10, 21, -1, sizeof(::mint::tulip::v1::SearchRequest)},
+        {24, -1, -1, sizeof(::mint::tulip::v1::SearchResponse_Item)},
+        {37, 48, -1, sizeof(::mint::tulip::v1::SearchResponse)},
+};
+
+static const ::_pb::Message* const file_default_instances[] = {
+    &::mint::tulip::v1::_SearchRequest_Page_default_instance_._instance,
+    &::mint::tulip::v1::_SearchRequest_default_instance_._instance,
+    &::mint::tulip::v1::_SearchResponse_Item_default_instance_._instance,
+    &::mint::tulip::v1::_SearchResponse_default_instance_._instance,
+};
+const char descriptor_table_protodef_tulip_2eproto[] PROTOBUF_SECTION_VARIABLE(protodesc_cold) = {
+    "\n\013tulip.proto\022\rmint.tulip.v1\"\223\001\n\rSearchR"
+    "equest\022\020\n\010keywords\030\001 \003(\t\022\014\n\004book\030\002 \001(\005\0224"
+    "\n\004page\030c \001(\0132!.mint.tulip.v1.SearchReque"
+    "st.PageH\000\210\001\001\032#\n\004Page\022\r\n\005index\030\001 \001(\005\022\014\n\004s"
+    "ize\030\002 \001(\005B\007\n\005_page\"\336\001\n\016SearchResponse\0221\n"
+    "\005items\030\001 \003(\0132\".mint.tulip.v1.SearchRespo"
+    "nse.Item\022/\n\004page\030b \001(\0132!.mint.tulip.v1.S"
+    "earchRequest.Page\022\r\n\005total\030c \001(\005\032Y\n\004Item"
+    "\022\014\n\004rank\030\001 \001(\005\022\021\n\thighlight\030\002 \001(\t\022\014\n\004boo"
+    "k\030\003 \001(\005\022\021\n\tparagraph\030\004 \001(\005\022\017\n\007content\030\005 "
+    "\001(\t2O\n\006Search\022E\n\004Pali\022\034.mint.tulip.v1.Se"
+    "archRequest\032\035.mint.tulip.v1.SearchRespon"
+    "se\"\000B2\n.com.github.iapt_platform.mint.pl"
+    "ugins.tulip.v1P\001b\006proto3"
+};
+static ::absl::once_flag descriptor_table_tulip_2eproto_once;
+const ::_pbi::DescriptorTable descriptor_table_tulip_2eproto = {
+    false,
+    false,
+    544,
+    descriptor_table_protodef_tulip_2eproto,
+    "tulip.proto",
+    &descriptor_table_tulip_2eproto_once,
+    nullptr,
+    0,
+    4,
+    schemas,
+    file_default_instances,
+    TableStruct_tulip_2eproto::offsets,
+    file_level_metadata_tulip_2eproto,
+    file_level_enum_descriptors_tulip_2eproto,
+    file_level_service_descriptors_tulip_2eproto,
+};
+
+// This function exists to be marked as weak.
+// It can significantly speed up compilation by breaking up LLVM's SCC
+// in the .pb.cc translation units. Large translation units see a
+// reduction of more than 35% of walltime for optimized builds. Without
+// the weak attribute all the messages in the file, including all the
+// vtables and everything they use become part of the same SCC through
+// a cycle like:
+// GetMetadata -> descriptor table -> default instances ->
+//   vtables -> GetMetadata
+// By adding a weak function here we break the connection from the
+// individual vtables back into the descriptor table.
+PROTOBUF_ATTRIBUTE_WEAK const ::_pbi::DescriptorTable* descriptor_table_tulip_2eproto_getter() {
+  return &descriptor_table_tulip_2eproto;
+}
+// Force running AddDescriptors() at dynamic initialization time.
+PROTOBUF_ATTRIBUTE_INIT_PRIORITY2
+static ::_pbi::AddDescriptorsRunner dynamic_init_dummy_tulip_2eproto(&descriptor_table_tulip_2eproto);
+namespace mint {
+namespace tulip {
+namespace v1 {
+// ===================================================================
+
+class SearchRequest_Page::_Internal {
+ public:
+};
+
+SearchRequest_Page::SearchRequest_Page(::google::protobuf::Arena* arena)
+    : ::google::protobuf::Message(arena) {
+  SharedCtor(arena);
+  // @@protoc_insertion_point(arena_constructor:mint.tulip.v1.SearchRequest.Page)
+}
+SearchRequest_Page::SearchRequest_Page(const SearchRequest_Page& from)
+    : ::google::protobuf::Message(), _impl_(from._impl_) {
+  _internal_metadata_.MergeFrom<::google::protobuf::UnknownFieldSet>(
+      from._internal_metadata_);
+  // @@protoc_insertion_point(copy_constructor:mint.tulip.v1.SearchRequest.Page)
+}
+inline void SearchRequest_Page::SharedCtor(::_pb::Arena* arena) {
+  (void)arena;
+  new (&_impl_) Impl_{
+      decltype(_impl_.index_){0},
+      decltype(_impl_.size_){0},
+      /*decltype(_impl_._cached_size_)*/ {},
+  };
+}
+SearchRequest_Page::~SearchRequest_Page() {
+  // @@protoc_insertion_point(destructor:mint.tulip.v1.SearchRequest.Page)
+  _internal_metadata_.Delete<::google::protobuf::UnknownFieldSet>();
+  SharedDtor();
+}
+inline void SearchRequest_Page::SharedDtor() {
+  ABSL_DCHECK(GetArenaForAllocation() == nullptr);
+}
+void SearchRequest_Page::SetCachedSize(int size) const {
+  _impl_._cached_size_.Set(size);
+}
+
+PROTOBUF_NOINLINE void SearchRequest_Page::Clear() {
+// @@protoc_insertion_point(message_clear_start:mint.tulip.v1.SearchRequest.Page)
+  ::uint32_t cached_has_bits = 0;
+  // Prevent compiler warnings about cached_has_bits being unused
+  (void) cached_has_bits;
+
+  ::memset(&_impl_.index_, 0, static_cast<::size_t>(
+      reinterpret_cast<char*>(&_impl_.size_) -
+      reinterpret_cast<char*>(&_impl_.index_)) + sizeof(_impl_.size_));
+  _internal_metadata_.Clear<::google::protobuf::UnknownFieldSet>();
+}
+
+const char* SearchRequest_Page::_InternalParse(
+    const char* ptr, ::_pbi::ParseContext* ctx) {
+  ptr = ::_pbi::TcParser::ParseLoop(this, ptr, ctx, &_table_.header);
+  return ptr;
+}
+
+
+PROTOBUF_CONSTINIT PROTOBUF_ATTRIBUTE_INIT_PRIORITY1
+const ::_pbi::TcParseTable<1, 2, 0, 0, 2> SearchRequest_Page::_table_ = {
+  {
+    0,  // no _has_bits_
+    0, // no _extensions_
+    2, 8,  // max_field_number, fast_idx_mask
+    offsetof(decltype(_table_), field_lookup_table),
+    4294967292,  // skipmap
+    offsetof(decltype(_table_), field_entries),
+    2,  // num_field_entries
+    0,  // num_aux_entries
+    offsetof(decltype(_table_), field_names),  // no aux_entries
+    &_SearchRequest_Page_default_instance_._instance,
+    ::_pbi::TcParser::GenericFallback,  // fallback
+  }, {{
+    // int32 size = 2;
+    {::_pbi::TcParser::SingularVarintNoZag1<::uint32_t, offsetof(SearchRequest_Page, _impl_.size_), 63>(),
+     {16, 63, 0, PROTOBUF_FIELD_OFFSET(SearchRequest_Page, _impl_.size_)}},
+    // int32 index = 1;
+    {::_pbi::TcParser::SingularVarintNoZag1<::uint32_t, offsetof(SearchRequest_Page, _impl_.index_), 63>(),
+     {8, 63, 0, PROTOBUF_FIELD_OFFSET(SearchRequest_Page, _impl_.index_)}},
+  }}, {{
+    65535, 65535
+  }}, {{
+    // int32 index = 1;
+    {PROTOBUF_FIELD_OFFSET(SearchRequest_Page, _impl_.index_), 0, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kInt32)},
+    // int32 size = 2;
+    {PROTOBUF_FIELD_OFFSET(SearchRequest_Page, _impl_.size_), 0, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kInt32)},
+  }},
+  // no aux_entries
+  {{
+  }},
+};
+
+::uint8_t* SearchRequest_Page::_InternalSerialize(
+    ::uint8_t* target,
+    ::google::protobuf::io::EpsCopyOutputStream* stream) const {
+  // @@protoc_insertion_point(serialize_to_array_start:mint.tulip.v1.SearchRequest.Page)
+  ::uint32_t cached_has_bits = 0;
+  (void)cached_has_bits;
+
+  // int32 index = 1;
+  if (this->_internal_index() != 0) {
+    target = ::google::protobuf::internal::WireFormatLite::
+        WriteInt32ToArrayWithField<1>(
+            stream, this->_internal_index(), target);
+  }
+
+  // int32 size = 2;
+  if (this->_internal_size() != 0) {
+    target = ::google::protobuf::internal::WireFormatLite::
+        WriteInt32ToArrayWithField<2>(
+            stream, this->_internal_size(), target);
+  }
+
+  if (PROTOBUF_PREDICT_FALSE(_internal_metadata_.have_unknown_fields())) {
+    target =
+        ::_pbi::WireFormat::InternalSerializeUnknownFieldsToArray(
+            _internal_metadata_.unknown_fields<::google::protobuf::UnknownFieldSet>(::google::protobuf::UnknownFieldSet::default_instance), target, stream);
+  }
+  // @@protoc_insertion_point(serialize_to_array_end:mint.tulip.v1.SearchRequest.Page)
+  return target;
+}
+
+::size_t SearchRequest_Page::ByteSizeLong() const {
+// @@protoc_insertion_point(message_byte_size_start:mint.tulip.v1.SearchRequest.Page)
+  ::size_t total_size = 0;
+
+  ::uint32_t cached_has_bits = 0;
+  // Prevent compiler warnings about cached_has_bits being unused
+  (void) cached_has_bits;
+
+  // int32 index = 1;
+  if (this->_internal_index() != 0) {
+    total_size += ::_pbi::WireFormatLite::Int32SizePlusOne(
+        this->_internal_index());
+  }
+
+  // int32 size = 2;
+  if (this->_internal_size() != 0) {
+    total_size += ::_pbi::WireFormatLite::Int32SizePlusOne(
+        this->_internal_size());
+  }
+
+  return MaybeComputeUnknownFieldsSize(total_size, &_impl_._cached_size_);
+}
+
+const ::google::protobuf::Message::ClassData SearchRequest_Page::_class_data_ = {
+    ::google::protobuf::Message::CopyWithSourceCheck,
+    SearchRequest_Page::MergeImpl
+};
+const ::google::protobuf::Message::ClassData*SearchRequest_Page::GetClassData() const { return &_class_data_; }
+
+
+void SearchRequest_Page::MergeImpl(::google::protobuf::Message& to_msg, const ::google::protobuf::Message& from_msg) {
+  auto* const _this = static_cast<SearchRequest_Page*>(&to_msg);
+  auto& from = static_cast<const SearchRequest_Page&>(from_msg);
+  // @@protoc_insertion_point(class_specific_merge_from_start:mint.tulip.v1.SearchRequest.Page)
+  ABSL_DCHECK_NE(&from, _this);
+  ::uint32_t cached_has_bits = 0;
+  (void) cached_has_bits;
+
+  if (from._internal_index() != 0) {
+    _this->_internal_set_index(from._internal_index());
+  }
+  if (from._internal_size() != 0) {
+    _this->_internal_set_size(from._internal_size());
+  }
+  _this->_internal_metadata_.MergeFrom<::google::protobuf::UnknownFieldSet>(from._internal_metadata_);
+}
+
+void SearchRequest_Page::CopyFrom(const SearchRequest_Page& from) {
+// @@protoc_insertion_point(class_specific_copy_from_start:mint.tulip.v1.SearchRequest.Page)
+  if (&from == this) return;
+  Clear();
+  MergeFrom(from);
+}
+
+PROTOBUF_NOINLINE bool SearchRequest_Page::IsInitialized() const {
+  return true;
+}
+
+void SearchRequest_Page::InternalSwap(SearchRequest_Page* other) {
+  using std::swap;
+  _internal_metadata_.InternalSwap(&other->_internal_metadata_);
+  ::google::protobuf::internal::memswap<
+      PROTOBUF_FIELD_OFFSET(SearchRequest_Page, _impl_.size_)
+      + sizeof(SearchRequest_Page::_impl_.size_)
+      - PROTOBUF_FIELD_OFFSET(SearchRequest_Page, _impl_.index_)>(
+          reinterpret_cast<char*>(&_impl_.index_),
+          reinterpret_cast<char*>(&other->_impl_.index_));
+}
+
+::google::protobuf::Metadata SearchRequest_Page::GetMetadata() const {
+  return ::_pbi::AssignDescriptors(
+      &descriptor_table_tulip_2eproto_getter, &descriptor_table_tulip_2eproto_once,
+      file_level_metadata_tulip_2eproto[0]);
+}
+// ===================================================================
+
+class SearchRequest::_Internal {
+ public:
+  using HasBits = decltype(std::declval<SearchRequest>()._impl_._has_bits_);
+  static constexpr ::int32_t kHasBitsOffset =
+    8 * PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_._has_bits_);
+  static const ::mint::tulip::v1::SearchRequest_Page& page(const SearchRequest* msg);
+  static void set_has_page(HasBits* has_bits) {
+    (*has_bits)[0] |= 1u;
+  }
+};
+
+const ::mint::tulip::v1::SearchRequest_Page& SearchRequest::_Internal::page(const SearchRequest* msg) {
+  return *msg->_impl_.page_;
+}
+SearchRequest::SearchRequest(::google::protobuf::Arena* arena)
+    : ::google::protobuf::Message(arena) {
+  SharedCtor(arena);
+  // @@protoc_insertion_point(arena_constructor:mint.tulip.v1.SearchRequest)
+}
+SearchRequest::SearchRequest(const SearchRequest& from) : ::google::protobuf::Message() {
+  SearchRequest* const _this = this;
+  (void)_this;
+  new (&_impl_) Impl_{
+      decltype(_impl_._has_bits_){from._impl_._has_bits_},
+      /*decltype(_impl_._cached_size_)*/ {},
+      decltype(_impl_.keywords_){from._impl_.keywords_},
+      decltype(_impl_.page_){nullptr},
+      decltype(_impl_.book_){},
+  };
+  _internal_metadata_.MergeFrom<::google::protobuf::UnknownFieldSet>(
+      from._internal_metadata_);
+  if ((from._impl_._has_bits_[0] & 0x00000001u) != 0) {
+    _this->_impl_.page_ = new ::mint::tulip::v1::SearchRequest_Page(*from._impl_.page_);
+  }
+  _this->_impl_.book_ = from._impl_.book_;
+
+  // @@protoc_insertion_point(copy_constructor:mint.tulip.v1.SearchRequest)
+}
+inline void SearchRequest::SharedCtor(::_pb::Arena* arena) {
+  (void)arena;
+  new (&_impl_) Impl_{
+      decltype(_impl_._has_bits_){},
+      /*decltype(_impl_._cached_size_)*/ {},
+      decltype(_impl_.keywords_){arena},
+      decltype(_impl_.page_){nullptr},
+      decltype(_impl_.book_){0},
+  };
+}
+SearchRequest::~SearchRequest() {
+  // @@protoc_insertion_point(destructor:mint.tulip.v1.SearchRequest)
+  _internal_metadata_.Delete<::google::protobuf::UnknownFieldSet>();
+  SharedDtor();
+}
+inline void SearchRequest::SharedDtor() {
+  ABSL_DCHECK(GetArenaForAllocation() == nullptr);
+  _internal_mutable_keywords()->~RepeatedPtrField();
+  if (this != internal_default_instance()) delete _impl_.page_;
+}
+void SearchRequest::SetCachedSize(int size) const {
+  _impl_._cached_size_.Set(size);
+}
+
+PROTOBUF_NOINLINE void SearchRequest::Clear() {
+// @@protoc_insertion_point(message_clear_start:mint.tulip.v1.SearchRequest)
+  ::uint32_t cached_has_bits = 0;
+  // Prevent compiler warnings about cached_has_bits being unused
+  (void) cached_has_bits;
+
+  _internal_mutable_keywords()->Clear();
+  cached_has_bits = _impl_._has_bits_[0];
+  if (cached_has_bits & 0x00000001u) {
+    ABSL_DCHECK(_impl_.page_ != nullptr);
+    _impl_.page_->Clear();
+  }
+  _impl_.book_ = 0;
+  _impl_._has_bits_.Clear();
+  _internal_metadata_.Clear<::google::protobuf::UnknownFieldSet>();
+}
+
+const char* SearchRequest::_InternalParse(
+    const char* ptr, ::_pbi::ParseContext* ctx) {
+  ptr = ::_pbi::TcParser::ParseLoop(this, ptr, ctx, &_table_.header);
+  return ptr;
+}
+
+
+PROTOBUF_CONSTINIT PROTOBUF_ATTRIBUTE_INIT_PRIORITY1
+const ::_pbi::TcParseTable<2, 3, 1, 44, 7> SearchRequest::_table_ = {
+  {
+    PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_._has_bits_),
+    0, // no _extensions_
+    99, 24,  // max_field_number, fast_idx_mask
+    offsetof(decltype(_table_), field_lookup_table),
+    4294967292,  // skipmap
+    offsetof(decltype(_table_), field_entries),
+    3,  // num_field_entries
+    1,  // num_aux_entries
+    offsetof(decltype(_table_), aux_entries),
+    &_SearchRequest_default_instance_._instance,
+    ::_pbi::TcParser::GenericFallback,  // fallback
+  }, {{
+    {::_pbi::TcParser::MiniParse, {}},
+    // repeated string keywords = 1;
+    {::_pbi::TcParser::FastUR1,
+     {10, 63, 0, PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_.keywords_)}},
+    // int32 book = 2;
+    {::_pbi::TcParser::SingularVarintNoZag1<::uint32_t, offsetof(SearchRequest, _impl_.book_), 63>(),
+     {16, 63, 0, PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_.book_)}},
+    // optional .mint.tulip.v1.SearchRequest.Page page = 99;
+    {::_pbi::TcParser::FastMtS2,
+     {1690, 0, 0, PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_.page_)}},
+  }}, {{
+    99, 0, 1,
+    65534, 2,
+    65535, 65535
+  }}, {{
+    // repeated string keywords = 1;
+    {PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_.keywords_), -1, 0,
+    (0 | ::_fl::kFcRepeated | ::_fl::kUtf8String | ::_fl::kRepSString)},
+    // int32 book = 2;
+    {PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_.book_), -1, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kInt32)},
+    // optional .mint.tulip.v1.SearchRequest.Page page = 99;
+    {PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_.page_), _Internal::kHasBitsOffset + 0, 0,
+    (0 | ::_fl::kFcOptional | ::_fl::kMessage | ::_fl::kTvTable)},
+  }}, {{
+    {::_pbi::TcParser::GetTable<::mint::tulip::v1::SearchRequest_Page>()},
+  }}, {{
+    "\33\10\0\0\0\0\0\0"
+    "mint.tulip.v1.SearchRequest"
+    "keywords"
+  }},
+};
+
+::uint8_t* SearchRequest::_InternalSerialize(
+    ::uint8_t* target,
+    ::google::protobuf::io::EpsCopyOutputStream* stream) const {
+  // @@protoc_insertion_point(serialize_to_array_start:mint.tulip.v1.SearchRequest)
+  ::uint32_t cached_has_bits = 0;
+  (void)cached_has_bits;
+
+  // repeated string keywords = 1;
+  for (int i = 0, n = this->_internal_keywords_size(); i < n; ++i) {
+    const auto& s = this->_internal_keywords().Get(i);
+    ::google::protobuf::internal::WireFormatLite::VerifyUtf8String(
+        s.data(), static_cast<int>(s.length()), ::google::protobuf::internal::WireFormatLite::SERIALIZE, "mint.tulip.v1.SearchRequest.keywords");
+    target = stream->WriteString(1, s, target);
+  }
+
+  // int32 book = 2;
+  if (this->_internal_book() != 0) {
+    target = ::google::protobuf::internal::WireFormatLite::
+        WriteInt32ToArrayWithField<2>(
+            stream, this->_internal_book(), target);
+  }
+
+  cached_has_bits = _impl_._has_bits_[0];
+  // optional .mint.tulip.v1.SearchRequest.Page page = 99;
+  if (cached_has_bits & 0x00000001u) {
+    target = ::google::protobuf::internal::WireFormatLite::
+      InternalWriteMessage(99, _Internal::page(this),
+        _Internal::page(this).GetCachedSize(), target, stream);
+  }
+
+  if (PROTOBUF_PREDICT_FALSE(_internal_metadata_.have_unknown_fields())) {
+    target =
+        ::_pbi::WireFormat::InternalSerializeUnknownFieldsToArray(
+            _internal_metadata_.unknown_fields<::google::protobuf::UnknownFieldSet>(::google::protobuf::UnknownFieldSet::default_instance), target, stream);
+  }
+  // @@protoc_insertion_point(serialize_to_array_end:mint.tulip.v1.SearchRequest)
+  return target;
+}
+
+::size_t SearchRequest::ByteSizeLong() const {
+// @@protoc_insertion_point(message_byte_size_start:mint.tulip.v1.SearchRequest)
+  ::size_t total_size = 0;
+
+  ::uint32_t cached_has_bits = 0;
+  // Prevent compiler warnings about cached_has_bits being unused
+  (void) cached_has_bits;
+
+  // repeated string keywords = 1;
+  total_size += 1 * ::google::protobuf::internal::FromIntSize(_internal_keywords().size());
+  for (int i = 0, n = _internal_keywords().size(); i < n; ++i) {
+    total_size += ::google::protobuf::internal::WireFormatLite::StringSize(
+        _internal_keywords().Get(i));
+  }
+  // optional .mint.tulip.v1.SearchRequest.Page page = 99;
+  cached_has_bits = _impl_._has_bits_[0];
+  if (cached_has_bits & 0x00000001u) {
+    total_size += 2 +
+      ::google::protobuf::internal::WireFormatLite::MessageSize(
+        *_impl_.page_);
+  }
+
+  // int32 book = 2;
+  if (this->_internal_book() != 0) {
+    total_size += ::_pbi::WireFormatLite::Int32SizePlusOne(
+        this->_internal_book());
+  }
+
+  return MaybeComputeUnknownFieldsSize(total_size, &_impl_._cached_size_);
+}
+
+const ::google::protobuf::Message::ClassData SearchRequest::_class_data_ = {
+    ::google::protobuf::Message::CopyWithSourceCheck,
+    SearchRequest::MergeImpl
+};
+const ::google::protobuf::Message::ClassData*SearchRequest::GetClassData() const { return &_class_data_; }
+
+
+void SearchRequest::MergeImpl(::google::protobuf::Message& to_msg, const ::google::protobuf::Message& from_msg) {
+  auto* const _this = static_cast<SearchRequest*>(&to_msg);
+  auto& from = static_cast<const SearchRequest&>(from_msg);
+  // @@protoc_insertion_point(class_specific_merge_from_start:mint.tulip.v1.SearchRequest)
+  ABSL_DCHECK_NE(&from, _this);
+  ::uint32_t cached_has_bits = 0;
+  (void) cached_has_bits;
+
+  _this->_internal_mutable_keywords()->MergeFrom(from._internal_keywords());
+  if ((from._impl_._has_bits_[0] & 0x00000001u) != 0) {
+    _this->_internal_mutable_page()->::mint::tulip::v1::SearchRequest_Page::MergeFrom(
+        from._internal_page());
+  }
+  if (from._internal_book() != 0) {
+    _this->_internal_set_book(from._internal_book());
+  }
+  _this->_internal_metadata_.MergeFrom<::google::protobuf::UnknownFieldSet>(from._internal_metadata_);
+}
+
+void SearchRequest::CopyFrom(const SearchRequest& from) {
+// @@protoc_insertion_point(class_specific_copy_from_start:mint.tulip.v1.SearchRequest)
+  if (&from == this) return;
+  Clear();
+  MergeFrom(from);
+}
+
+PROTOBUF_NOINLINE bool SearchRequest::IsInitialized() const {
+  return true;
+}
+
+void SearchRequest::InternalSwap(SearchRequest* other) {
+  using std::swap;
+  _internal_metadata_.InternalSwap(&other->_internal_metadata_);
+  swap(_impl_._has_bits_[0], other->_impl_._has_bits_[0]);
+  _impl_.keywords_.InternalSwap(&other->_impl_.keywords_);
+  ::google::protobuf::internal::memswap<
+      PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_.book_)
+      + sizeof(SearchRequest::_impl_.book_)
+      - PROTOBUF_FIELD_OFFSET(SearchRequest, _impl_.page_)>(
+          reinterpret_cast<char*>(&_impl_.page_),
+          reinterpret_cast<char*>(&other->_impl_.page_));
+}
+
+::google::protobuf::Metadata SearchRequest::GetMetadata() const {
+  return ::_pbi::AssignDescriptors(
+      &descriptor_table_tulip_2eproto_getter, &descriptor_table_tulip_2eproto_once,
+      file_level_metadata_tulip_2eproto[1]);
+}
+// ===================================================================
+
+class SearchResponse_Item::_Internal {
+ public:
+};
+
+SearchResponse_Item::SearchResponse_Item(::google::protobuf::Arena* arena)
+    : ::google::protobuf::Message(arena) {
+  SharedCtor(arena);
+  // @@protoc_insertion_point(arena_constructor:mint.tulip.v1.SearchResponse.Item)
+}
+SearchResponse_Item::SearchResponse_Item(const SearchResponse_Item& from) : ::google::protobuf::Message() {
+  SearchResponse_Item* const _this = this;
+  (void)_this;
+  new (&_impl_) Impl_{
+      decltype(_impl_.highlight_){},
+      decltype(_impl_.content_){},
+      decltype(_impl_.rank_){},
+      decltype(_impl_.book_){},
+      decltype(_impl_.paragraph_){},
+      /*decltype(_impl_._cached_size_)*/ {},
+  };
+  _internal_metadata_.MergeFrom<::google::protobuf::UnknownFieldSet>(
+      from._internal_metadata_);
+  _impl_.highlight_.InitDefault();
+  #ifdef PROTOBUF_FORCE_COPY_DEFAULT_STRING
+        _impl_.highlight_.Set("", GetArenaForAllocation());
+  #endif  // PROTOBUF_FORCE_COPY_DEFAULT_STRING
+  if (!from._internal_highlight().empty()) {
+    _this->_impl_.highlight_.Set(from._internal_highlight(), _this->GetArenaForAllocation());
+  }
+  _impl_.content_.InitDefault();
+  #ifdef PROTOBUF_FORCE_COPY_DEFAULT_STRING
+        _impl_.content_.Set("", GetArenaForAllocation());
+  #endif  // PROTOBUF_FORCE_COPY_DEFAULT_STRING
+  if (!from._internal_content().empty()) {
+    _this->_impl_.content_.Set(from._internal_content(), _this->GetArenaForAllocation());
+  }
+  ::memcpy(&_impl_.rank_, &from._impl_.rank_,
+    static_cast<::size_t>(reinterpret_cast<char*>(&_impl_.paragraph_) -
+    reinterpret_cast<char*>(&_impl_.rank_)) + sizeof(_impl_.paragraph_));
+
+  // @@protoc_insertion_point(copy_constructor:mint.tulip.v1.SearchResponse.Item)
+}
+inline void SearchResponse_Item::SharedCtor(::_pb::Arena* arena) {
+  (void)arena;
+  new (&_impl_) Impl_{
+      decltype(_impl_.highlight_){},
+      decltype(_impl_.content_){},
+      decltype(_impl_.rank_){0},
+      decltype(_impl_.book_){0},
+      decltype(_impl_.paragraph_){0},
+      /*decltype(_impl_._cached_size_)*/ {},
+  };
+  _impl_.highlight_.InitDefault();
+  #ifdef PROTOBUF_FORCE_COPY_DEFAULT_STRING
+        _impl_.highlight_.Set("", GetArenaForAllocation());
+  #endif  // PROTOBUF_FORCE_COPY_DEFAULT_STRING
+  _impl_.content_.InitDefault();
+  #ifdef PROTOBUF_FORCE_COPY_DEFAULT_STRING
+        _impl_.content_.Set("", GetArenaForAllocation());
+  #endif  // PROTOBUF_FORCE_COPY_DEFAULT_STRING
+}
+SearchResponse_Item::~SearchResponse_Item() {
+  // @@protoc_insertion_point(destructor:mint.tulip.v1.SearchResponse.Item)
+  _internal_metadata_.Delete<::google::protobuf::UnknownFieldSet>();
+  SharedDtor();
+}
+inline void SearchResponse_Item::SharedDtor() {
+  ABSL_DCHECK(GetArenaForAllocation() == nullptr);
+  _impl_.highlight_.Destroy();
+  _impl_.content_.Destroy();
+}
+void SearchResponse_Item::SetCachedSize(int size) const {
+  _impl_._cached_size_.Set(size);
+}
+
+PROTOBUF_NOINLINE void SearchResponse_Item::Clear() {
+// @@protoc_insertion_point(message_clear_start:mint.tulip.v1.SearchResponse.Item)
+  ::uint32_t cached_has_bits = 0;
+  // Prevent compiler warnings about cached_has_bits being unused
+  (void) cached_has_bits;
+
+  _impl_.highlight_.ClearToEmpty();
+  _impl_.content_.ClearToEmpty();
+  ::memset(&_impl_.rank_, 0, static_cast<::size_t>(
+      reinterpret_cast<char*>(&_impl_.paragraph_) -
+      reinterpret_cast<char*>(&_impl_.rank_)) + sizeof(_impl_.paragraph_));
+  _internal_metadata_.Clear<::google::protobuf::UnknownFieldSet>();
+}
+
+const char* SearchResponse_Item::_InternalParse(
+    const char* ptr, ::_pbi::ParseContext* ctx) {
+  ptr = ::_pbi::TcParser::ParseLoop(this, ptr, ctx, &_table_.header);
+  return ptr;
+}
+
+
+PROTOBUF_CONSTINIT PROTOBUF_ATTRIBUTE_INIT_PRIORITY1
+const ::_pbi::TcParseTable<3, 5, 0, 58, 2> SearchResponse_Item::_table_ = {
+  {
+    0,  // no _has_bits_
+    0, // no _extensions_
+    5, 56,  // max_field_number, fast_idx_mask
+    offsetof(decltype(_table_), field_lookup_table),
+    4294967264,  // skipmap
+    offsetof(decltype(_table_), field_entries),
+    5,  // num_field_entries
+    0,  // num_aux_entries
+    offsetof(decltype(_table_), field_names),  // no aux_entries
+    &_SearchResponse_Item_default_instance_._instance,
+    ::_pbi::TcParser::GenericFallback,  // fallback
+  }, {{
+    {::_pbi::TcParser::MiniParse, {}},
+    // int32 rank = 1;
+    {::_pbi::TcParser::SingularVarintNoZag1<::uint32_t, offsetof(SearchResponse_Item, _impl_.rank_), 63>(),
+     {8, 63, 0, PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.rank_)}},
+    // string highlight = 2;
+    {::_pbi::TcParser::FastUS1,
+     {18, 63, 0, PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.highlight_)}},
+    // int32 book = 3;
+    {::_pbi::TcParser::SingularVarintNoZag1<::uint32_t, offsetof(SearchResponse_Item, _impl_.book_), 63>(),
+     {24, 63, 0, PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.book_)}},
+    // int32 paragraph = 4;
+    {::_pbi::TcParser::SingularVarintNoZag1<::uint32_t, offsetof(SearchResponse_Item, _impl_.paragraph_), 63>(),
+     {32, 63, 0, PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.paragraph_)}},
+    // string content = 5;
+    {::_pbi::TcParser::FastUS1,
+     {42, 63, 0, PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.content_)}},
+    {::_pbi::TcParser::MiniParse, {}},
+    {::_pbi::TcParser::MiniParse, {}},
+  }}, {{
+    65535, 65535
+  }}, {{
+    // int32 rank = 1;
+    {PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.rank_), 0, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kInt32)},
+    // string highlight = 2;
+    {PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.highlight_), 0, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kUtf8String | ::_fl::kRepAString)},
+    // int32 book = 3;
+    {PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.book_), 0, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kInt32)},
+    // int32 paragraph = 4;
+    {PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.paragraph_), 0, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kInt32)},
+    // string content = 5;
+    {PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.content_), 0, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kUtf8String | ::_fl::kRepAString)},
+  }},
+  // no aux_entries
+  {{
+    "\41\0\11\0\0\7\0\0"
+    "mint.tulip.v1.SearchResponse.Item"
+    "highlight"
+    "content"
+  }},
+};
+
+::uint8_t* SearchResponse_Item::_InternalSerialize(
+    ::uint8_t* target,
+    ::google::protobuf::io::EpsCopyOutputStream* stream) const {
+  // @@protoc_insertion_point(serialize_to_array_start:mint.tulip.v1.SearchResponse.Item)
+  ::uint32_t cached_has_bits = 0;
+  (void)cached_has_bits;
+
+  // int32 rank = 1;
+  if (this->_internal_rank() != 0) {
+    target = ::google::protobuf::internal::WireFormatLite::
+        WriteInt32ToArrayWithField<1>(
+            stream, this->_internal_rank(), target);
+  }
+
+  // string highlight = 2;
+  if (!this->_internal_highlight().empty()) {
+    const std::string& _s = this->_internal_highlight();
+    ::google::protobuf::internal::WireFormatLite::VerifyUtf8String(
+        _s.data(), static_cast<int>(_s.length()), ::google::protobuf::internal::WireFormatLite::SERIALIZE, "mint.tulip.v1.SearchResponse.Item.highlight");
+    target = stream->WriteStringMaybeAliased(2, _s, target);
+  }
+
+  // int32 book = 3;
+  if (this->_internal_book() != 0) {
+    target = ::google::protobuf::internal::WireFormatLite::
+        WriteInt32ToArrayWithField<3>(
+            stream, this->_internal_book(), target);
+  }
+
+  // int32 paragraph = 4;
+  if (this->_internal_paragraph() != 0) {
+    target = ::google::protobuf::internal::WireFormatLite::
+        WriteInt32ToArrayWithField<4>(
+            stream, this->_internal_paragraph(), target);
+  }
+
+  // string content = 5;
+  if (!this->_internal_content().empty()) {
+    const std::string& _s = this->_internal_content();
+    ::google::protobuf::internal::WireFormatLite::VerifyUtf8String(
+        _s.data(), static_cast<int>(_s.length()), ::google::protobuf::internal::WireFormatLite::SERIALIZE, "mint.tulip.v1.SearchResponse.Item.content");
+    target = stream->WriteStringMaybeAliased(5, _s, target);
+  }
+
+  if (PROTOBUF_PREDICT_FALSE(_internal_metadata_.have_unknown_fields())) {
+    target =
+        ::_pbi::WireFormat::InternalSerializeUnknownFieldsToArray(
+            _internal_metadata_.unknown_fields<::google::protobuf::UnknownFieldSet>(::google::protobuf::UnknownFieldSet::default_instance), target, stream);
+  }
+  // @@protoc_insertion_point(serialize_to_array_end:mint.tulip.v1.SearchResponse.Item)
+  return target;
+}
+
+::size_t SearchResponse_Item::ByteSizeLong() const {
+// @@protoc_insertion_point(message_byte_size_start:mint.tulip.v1.SearchResponse.Item)
+  ::size_t total_size = 0;
+
+  ::uint32_t cached_has_bits = 0;
+  // Prevent compiler warnings about cached_has_bits being unused
+  (void) cached_has_bits;
+
+  // string highlight = 2;
+  if (!this->_internal_highlight().empty()) {
+    total_size += 1 + ::google::protobuf::internal::WireFormatLite::StringSize(
+                                    this->_internal_highlight());
+  }
+
+  // string content = 5;
+  if (!this->_internal_content().empty()) {
+    total_size += 1 + ::google::protobuf::internal::WireFormatLite::StringSize(
+                                    this->_internal_content());
+  }
+
+  // int32 rank = 1;
+  if (this->_internal_rank() != 0) {
+    total_size += ::_pbi::WireFormatLite::Int32SizePlusOne(
+        this->_internal_rank());
+  }
+
+  // int32 book = 3;
+  if (this->_internal_book() != 0) {
+    total_size += ::_pbi::WireFormatLite::Int32SizePlusOne(
+        this->_internal_book());
+  }
+
+  // int32 paragraph = 4;
+  if (this->_internal_paragraph() != 0) {
+    total_size += ::_pbi::WireFormatLite::Int32SizePlusOne(
+        this->_internal_paragraph());
+  }
+
+  return MaybeComputeUnknownFieldsSize(total_size, &_impl_._cached_size_);
+}
+
+const ::google::protobuf::Message::ClassData SearchResponse_Item::_class_data_ = {
+    ::google::protobuf::Message::CopyWithSourceCheck,
+    SearchResponse_Item::MergeImpl
+};
+const ::google::protobuf::Message::ClassData*SearchResponse_Item::GetClassData() const { return &_class_data_; }
+
+
+void SearchResponse_Item::MergeImpl(::google::protobuf::Message& to_msg, const ::google::protobuf::Message& from_msg) {
+  auto* const _this = static_cast<SearchResponse_Item*>(&to_msg);
+  auto& from = static_cast<const SearchResponse_Item&>(from_msg);
+  // @@protoc_insertion_point(class_specific_merge_from_start:mint.tulip.v1.SearchResponse.Item)
+  ABSL_DCHECK_NE(&from, _this);
+  ::uint32_t cached_has_bits = 0;
+  (void) cached_has_bits;
+
+  if (!from._internal_highlight().empty()) {
+    _this->_internal_set_highlight(from._internal_highlight());
+  }
+  if (!from._internal_content().empty()) {
+    _this->_internal_set_content(from._internal_content());
+  }
+  if (from._internal_rank() != 0) {
+    _this->_internal_set_rank(from._internal_rank());
+  }
+  if (from._internal_book() != 0) {
+    _this->_internal_set_book(from._internal_book());
+  }
+  if (from._internal_paragraph() != 0) {
+    _this->_internal_set_paragraph(from._internal_paragraph());
+  }
+  _this->_internal_metadata_.MergeFrom<::google::protobuf::UnknownFieldSet>(from._internal_metadata_);
+}
+
+void SearchResponse_Item::CopyFrom(const SearchResponse_Item& from) {
+// @@protoc_insertion_point(class_specific_copy_from_start:mint.tulip.v1.SearchResponse.Item)
+  if (&from == this) return;
+  Clear();
+  MergeFrom(from);
+}
+
+PROTOBUF_NOINLINE bool SearchResponse_Item::IsInitialized() const {
+  return true;
+}
+
+void SearchResponse_Item::InternalSwap(SearchResponse_Item* other) {
+  using std::swap;
+  auto* lhs_arena = GetArenaForAllocation();
+  auto* rhs_arena = other->GetArenaForAllocation();
+  _internal_metadata_.InternalSwap(&other->_internal_metadata_);
+  ::_pbi::ArenaStringPtr::InternalSwap(&_impl_.highlight_, lhs_arena,
+                                       &other->_impl_.highlight_, rhs_arena);
+  ::_pbi::ArenaStringPtr::InternalSwap(&_impl_.content_, lhs_arena,
+                                       &other->_impl_.content_, rhs_arena);
+  ::google::protobuf::internal::memswap<
+      PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.paragraph_)
+      + sizeof(SearchResponse_Item::_impl_.paragraph_)
+      - PROTOBUF_FIELD_OFFSET(SearchResponse_Item, _impl_.rank_)>(
+          reinterpret_cast<char*>(&_impl_.rank_),
+          reinterpret_cast<char*>(&other->_impl_.rank_));
+}
+
+::google::protobuf::Metadata SearchResponse_Item::GetMetadata() const {
+  return ::_pbi::AssignDescriptors(
+      &descriptor_table_tulip_2eproto_getter, &descriptor_table_tulip_2eproto_once,
+      file_level_metadata_tulip_2eproto[2]);
+}
+// ===================================================================
+
+class SearchResponse::_Internal {
+ public:
+  using HasBits = decltype(std::declval<SearchResponse>()._impl_._has_bits_);
+  static constexpr ::int32_t kHasBitsOffset =
+    8 * PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_._has_bits_);
+  static const ::mint::tulip::v1::SearchRequest_Page& page(const SearchResponse* msg);
+  static void set_has_page(HasBits* has_bits) {
+    (*has_bits)[0] |= 1u;
+  }
+};
+
+const ::mint::tulip::v1::SearchRequest_Page& SearchResponse::_Internal::page(const SearchResponse* msg) {
+  return *msg->_impl_.page_;
+}
+SearchResponse::SearchResponse(::google::protobuf::Arena* arena)
+    : ::google::protobuf::Message(arena) {
+  SharedCtor(arena);
+  // @@protoc_insertion_point(arena_constructor:mint.tulip.v1.SearchResponse)
+}
+SearchResponse::SearchResponse(const SearchResponse& from) : ::google::protobuf::Message() {
+  SearchResponse* const _this = this;
+  (void)_this;
+  new (&_impl_) Impl_{
+      decltype(_impl_._has_bits_){from._impl_._has_bits_},
+      /*decltype(_impl_._cached_size_)*/ {},
+      decltype(_impl_.items_){from._impl_.items_},
+      decltype(_impl_.page_){nullptr},
+      decltype(_impl_.total_){},
+  };
+  _internal_metadata_.MergeFrom<::google::protobuf::UnknownFieldSet>(
+      from._internal_metadata_);
+  if ((from._impl_._has_bits_[0] & 0x00000001u) != 0) {
+    _this->_impl_.page_ = new ::mint::tulip::v1::SearchRequest_Page(*from._impl_.page_);
+  }
+  _this->_impl_.total_ = from._impl_.total_;
+
+  // @@protoc_insertion_point(copy_constructor:mint.tulip.v1.SearchResponse)
+}
+inline void SearchResponse::SharedCtor(::_pb::Arena* arena) {
+  (void)arena;
+  new (&_impl_) Impl_{
+      decltype(_impl_._has_bits_){},
+      /*decltype(_impl_._cached_size_)*/ {},
+      decltype(_impl_.items_){arena},
+      decltype(_impl_.page_){nullptr},
+      decltype(_impl_.total_){0},
+  };
+}
+SearchResponse::~SearchResponse() {
+  // @@protoc_insertion_point(destructor:mint.tulip.v1.SearchResponse)
+  _internal_metadata_.Delete<::google::protobuf::UnknownFieldSet>();
+  SharedDtor();
+}
+inline void SearchResponse::SharedDtor() {
+  ABSL_DCHECK(GetArenaForAllocation() == nullptr);
+  _impl_.items_.~RepeatedPtrField();
+  if (this != internal_default_instance()) delete _impl_.page_;
+}
+void SearchResponse::SetCachedSize(int size) const {
+  _impl_._cached_size_.Set(size);
+}
+
+PROTOBUF_NOINLINE void SearchResponse::Clear() {
+// @@protoc_insertion_point(message_clear_start:mint.tulip.v1.SearchResponse)
+  ::uint32_t cached_has_bits = 0;
+  // Prevent compiler warnings about cached_has_bits being unused
+  (void) cached_has_bits;
+
+  _internal_mutable_items()->Clear();
+  cached_has_bits = _impl_._has_bits_[0];
+  if (cached_has_bits & 0x00000001u) {
+    ABSL_DCHECK(_impl_.page_ != nullptr);
+    _impl_.page_->Clear();
+  }
+  _impl_.total_ = 0;
+  _impl_._has_bits_.Clear();
+  _internal_metadata_.Clear<::google::protobuf::UnknownFieldSet>();
+}
+
+const char* SearchResponse::_InternalParse(
+    const char* ptr, ::_pbi::ParseContext* ctx) {
+  ptr = ::_pbi::TcParser::ParseLoop(this, ptr, ctx, &_table_.header);
+  return ptr;
+}
+
+
+PROTOBUF_CONSTINIT PROTOBUF_ATTRIBUTE_INIT_PRIORITY1
+const ::_pbi::TcParseTable<2, 3, 2, 0, 7> SearchResponse::_table_ = {
+  {
+    PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_._has_bits_),
+    0, // no _extensions_
+    99, 24,  // max_field_number, fast_idx_mask
+    offsetof(decltype(_table_), field_lookup_table),
+    4294967294,  // skipmap
+    offsetof(decltype(_table_), field_entries),
+    3,  // num_field_entries
+    2,  // num_aux_entries
+    offsetof(decltype(_table_), aux_entries),
+    &_SearchResponse_default_instance_._instance,
+    ::_pbi::TcParser::GenericFallback,  // fallback
+  }, {{
+    {::_pbi::TcParser::MiniParse, {}},
+    // repeated .mint.tulip.v1.SearchResponse.Item items = 1;
+    {::_pbi::TcParser::FastMtR1,
+     {10, 63, 0, PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_.items_)}},
+    // .mint.tulip.v1.SearchRequest.Page page = 98;
+    {::_pbi::TcParser::FastMtS2,
+     {1682, 0, 1, PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_.page_)}},
+    // int32 total = 99;
+    {::_pbi::TcParser::FastV32S2,
+     {1688, 63, 0, PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_.total_)}},
+  }}, {{
+    98, 0, 1,
+    65532, 1,
+    65535, 65535
+  }}, {{
+    // repeated .mint.tulip.v1.SearchResponse.Item items = 1;
+    {PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_.items_), -1, 0,
+    (0 | ::_fl::kFcRepeated | ::_fl::kMessage | ::_fl::kTvTable)},
+    // .mint.tulip.v1.SearchRequest.Page page = 98;
+    {PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_.page_), _Internal::kHasBitsOffset + 0, 1,
+    (0 | ::_fl::kFcOptional | ::_fl::kMessage | ::_fl::kTvTable)},
+    // int32 total = 99;
+    {PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_.total_), -1, 0,
+    (0 | ::_fl::kFcSingular | ::_fl::kInt32)},
+  }}, {{
+    {::_pbi::TcParser::GetTable<::mint::tulip::v1::SearchResponse_Item>()},
+    {::_pbi::TcParser::GetTable<::mint::tulip::v1::SearchRequest_Page>()},
+  }}, {{
+  }},
+};
+
+::uint8_t* SearchResponse::_InternalSerialize(
+    ::uint8_t* target,
+    ::google::protobuf::io::EpsCopyOutputStream* stream) const {
+  // @@protoc_insertion_point(serialize_to_array_start:mint.tulip.v1.SearchResponse)
+  ::uint32_t cached_has_bits = 0;
+  (void)cached_has_bits;
+
+  // repeated .mint.tulip.v1.SearchResponse.Item items = 1;
+  for (unsigned i = 0,
+      n = static_cast<unsigned>(this->_internal_items_size()); i < n; i++) {
+    const auto& repfield = this->_internal_items().Get(i);
+    target = ::google::protobuf::internal::WireFormatLite::
+        InternalWriteMessage(1, repfield, repfield.GetCachedSize(), target, stream);
+  }
+
+  cached_has_bits = _impl_._has_bits_[0];
+  // .mint.tulip.v1.SearchRequest.Page page = 98;
+  if (cached_has_bits & 0x00000001u) {
+    target = ::google::protobuf::internal::WireFormatLite::
+      InternalWriteMessage(98, _Internal::page(this),
+        _Internal::page(this).GetCachedSize(), target, stream);
+  }
+
+  // int32 total = 99;
+  if (this->_internal_total() != 0) {
+    target = stream->EnsureSpace(target);
+    target = ::_pbi::WireFormatLite::WriteInt32ToArray(
+        99, this->_internal_total(), target);
+  }
+
+  if (PROTOBUF_PREDICT_FALSE(_internal_metadata_.have_unknown_fields())) {
+    target =
+        ::_pbi::WireFormat::InternalSerializeUnknownFieldsToArray(
+            _internal_metadata_.unknown_fields<::google::protobuf::UnknownFieldSet>(::google::protobuf::UnknownFieldSet::default_instance), target, stream);
+  }
+  // @@protoc_insertion_point(serialize_to_array_end:mint.tulip.v1.SearchResponse)
+  return target;
+}
+
+::size_t SearchResponse::ByteSizeLong() const {
+// @@protoc_insertion_point(message_byte_size_start:mint.tulip.v1.SearchResponse)
+  ::size_t total_size = 0;
+
+  ::uint32_t cached_has_bits = 0;
+  // Prevent compiler warnings about cached_has_bits being unused
+  (void) cached_has_bits;
+
+  // repeated .mint.tulip.v1.SearchResponse.Item items = 1;
+  total_size += 1UL * this->_internal_items_size();
+  for (const auto& msg : this->_internal_items()) {
+    total_size +=
+      ::google::protobuf::internal::WireFormatLite::MessageSize(msg);
+  }
+  // .mint.tulip.v1.SearchRequest.Page page = 98;
+  cached_has_bits = _impl_._has_bits_[0];
+  if (cached_has_bits & 0x00000001u) {
+    total_size += 2 +
+      ::google::protobuf::internal::WireFormatLite::MessageSize(
+        *_impl_.page_);
+  }
+
+  // int32 total = 99;
+  if (this->_internal_total() != 0) {
+    total_size += 2 + ::_pbi::WireFormatLite::Int32Size(
+                                    this->_internal_total());
+  }
+
+  return MaybeComputeUnknownFieldsSize(total_size, &_impl_._cached_size_);
+}
+
+const ::google::protobuf::Message::ClassData SearchResponse::_class_data_ = {
+    ::google::protobuf::Message::CopyWithSourceCheck,
+    SearchResponse::MergeImpl
+};
+const ::google::protobuf::Message::ClassData*SearchResponse::GetClassData() const { return &_class_data_; }
+
+
+void SearchResponse::MergeImpl(::google::protobuf::Message& to_msg, const ::google::protobuf::Message& from_msg) {
+  auto* const _this = static_cast<SearchResponse*>(&to_msg);
+  auto& from = static_cast<const SearchResponse&>(from_msg);
+  // @@protoc_insertion_point(class_specific_merge_from_start:mint.tulip.v1.SearchResponse)
+  ABSL_DCHECK_NE(&from, _this);
+  ::uint32_t cached_has_bits = 0;
+  (void) cached_has_bits;
+
+  _this->_internal_mutable_items()->MergeFrom(from._internal_items());
+  if ((from._impl_._has_bits_[0] & 0x00000001u) != 0) {
+    _this->_internal_mutable_page()->::mint::tulip::v1::SearchRequest_Page::MergeFrom(
+        from._internal_page());
+  }
+  if (from._internal_total() != 0) {
+    _this->_internal_set_total(from._internal_total());
+  }
+  _this->_internal_metadata_.MergeFrom<::google::protobuf::UnknownFieldSet>(from._internal_metadata_);
+}
+
+void SearchResponse::CopyFrom(const SearchResponse& from) {
+// @@protoc_insertion_point(class_specific_copy_from_start:mint.tulip.v1.SearchResponse)
+  if (&from == this) return;
+  Clear();
+  MergeFrom(from);
+}
+
+PROTOBUF_NOINLINE bool SearchResponse::IsInitialized() const {
+  return true;
+}
+
+void SearchResponse::InternalSwap(SearchResponse* other) {
+  using std::swap;
+  _internal_metadata_.InternalSwap(&other->_internal_metadata_);
+  swap(_impl_._has_bits_[0], other->_impl_._has_bits_[0]);
+  _impl_.items_.InternalSwap(&other->_impl_.items_);
+  ::google::protobuf::internal::memswap<
+      PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_.total_)
+      + sizeof(SearchResponse::_impl_.total_)
+      - PROTOBUF_FIELD_OFFSET(SearchResponse, _impl_.page_)>(
+          reinterpret_cast<char*>(&_impl_.page_),
+          reinterpret_cast<char*>(&other->_impl_.page_));
+}
+
+::google::protobuf::Metadata SearchResponse::GetMetadata() const {
+  return ::_pbi::AssignDescriptors(
+      &descriptor_table_tulip_2eproto_getter, &descriptor_table_tulip_2eproto_once,
+      file_level_metadata_tulip_2eproto[3]);
+}
+// @@protoc_insertion_point(namespace_scope)
+}  // namespace v1
+}  // namespace tulip
+}  // namespace mint
+namespace google {
+namespace protobuf {
+}  // namespace protobuf
+}  // namespace google
+// @@protoc_insertion_point(global_scope)
+#include "google/protobuf/port_undef.inc"

+ 1514 - 0
rpc/sdk/cpp/tulip.pb.h

@@ -0,0 +1,1514 @@
+// Generated by the protocol buffer compiler.  DO NOT EDIT!
+// source: tulip.proto
+
+#ifndef GOOGLE_PROTOBUF_INCLUDED_tulip_2eproto_2epb_2eh
+#define GOOGLE_PROTOBUF_INCLUDED_tulip_2eproto_2epb_2eh
+
+#include <limits>
+#include <string>
+#include <type_traits>
+
+#include "google/protobuf/port_def.inc"
+#if PROTOBUF_VERSION < 4024000
+#error "This file was generated by a newer version of protoc which is"
+#error "incompatible with your Protocol Buffer headers. Please update"
+#error "your headers."
+#endif  // PROTOBUF_VERSION
+
+#if 4024003 < PROTOBUF_MIN_PROTOC_VERSION
+#error "This file was generated by an older version of protoc which is"
+#error "incompatible with your Protocol Buffer headers. Please"
+#error "regenerate this file with a newer version of protoc."
+#endif  // PROTOBUF_MIN_PROTOC_VERSION
+#include "google/protobuf/port_undef.inc"
+#include "google/protobuf/io/coded_stream.h"
+#include "google/protobuf/arena.h"
+#include "google/protobuf/arenastring.h"
+#include "google/protobuf/generated_message_tctable_decl.h"
+#include "google/protobuf/generated_message_util.h"
+#include "google/protobuf/metadata_lite.h"
+#include "google/protobuf/generated_message_reflection.h"
+#include "google/protobuf/message.h"
+#include "google/protobuf/repeated_field.h"  // IWYU pragma: export
+#include "google/protobuf/extension_set.h"  // IWYU pragma: export
+#include "google/protobuf/unknown_field_set.h"
+// @@protoc_insertion_point(includes)
+
+// Must be included last.
+#include "google/protobuf/port_def.inc"
+
+#define PROTOBUF_INTERNAL_EXPORT_tulip_2eproto
+
+namespace google {
+namespace protobuf {
+namespace internal {
+class AnyMetadata;
+}  // namespace internal
+}  // namespace protobuf
+}  // namespace google
+
+// Internal implementation detail -- do not use these members.
+struct TableStruct_tulip_2eproto {
+  static const ::uint32_t offsets[];
+};
+extern const ::google::protobuf::internal::DescriptorTable
+    descriptor_table_tulip_2eproto;
+namespace mint {
+namespace tulip {
+namespace v1 {
+class SearchRequest;
+struct SearchRequestDefaultTypeInternal;
+extern SearchRequestDefaultTypeInternal _SearchRequest_default_instance_;
+class SearchRequest_Page;
+struct SearchRequest_PageDefaultTypeInternal;
+extern SearchRequest_PageDefaultTypeInternal _SearchRequest_Page_default_instance_;
+class SearchResponse;
+struct SearchResponseDefaultTypeInternal;
+extern SearchResponseDefaultTypeInternal _SearchResponse_default_instance_;
+class SearchResponse_Item;
+struct SearchResponse_ItemDefaultTypeInternal;
+extern SearchResponse_ItemDefaultTypeInternal _SearchResponse_Item_default_instance_;
+}  // namespace v1
+}  // namespace tulip
+}  // namespace mint
+namespace google {
+namespace protobuf {
+}  // namespace protobuf
+}  // namespace google
+
+namespace mint {
+namespace tulip {
+namespace v1 {
+
+// ===================================================================
+
+
+// -------------------------------------------------------------------
+
+class SearchRequest_Page final :
+    public ::google::protobuf::Message /* @@protoc_insertion_point(class_definition:mint.tulip.v1.SearchRequest.Page) */ {
+ public:
+  inline SearchRequest_Page() : SearchRequest_Page(nullptr) {}
+  ~SearchRequest_Page() override;
+  template<typename = void>
+  explicit PROTOBUF_CONSTEXPR SearchRequest_Page(::google::protobuf::internal::ConstantInitialized);
+
+  SearchRequest_Page(const SearchRequest_Page& from);
+  SearchRequest_Page(SearchRequest_Page&& from) noexcept
+    : SearchRequest_Page() {
+    *this = ::std::move(from);
+  }
+
+  inline SearchRequest_Page& operator=(const SearchRequest_Page& from) {
+    CopyFrom(from);
+    return *this;
+  }
+  inline SearchRequest_Page& operator=(SearchRequest_Page&& from) noexcept {
+    if (this == &from) return *this;
+    if (GetOwningArena() == from.GetOwningArena()
+  #ifdef PROTOBUF_FORCE_COPY_IN_MOVE
+        && GetOwningArena() != nullptr
+  #endif  // !PROTOBUF_FORCE_COPY_IN_MOVE
+    ) {
+      InternalSwap(&from);
+    } else {
+      CopyFrom(from);
+    }
+    return *this;
+  }
+
+  inline const ::google::protobuf::UnknownFieldSet& unknown_fields() const {
+    return _internal_metadata_.unknown_fields<::google::protobuf::UnknownFieldSet>(::google::protobuf::UnknownFieldSet::default_instance);
+  }
+  inline ::google::protobuf::UnknownFieldSet* mutable_unknown_fields() {
+    return _internal_metadata_.mutable_unknown_fields<::google::protobuf::UnknownFieldSet>();
+  }
+
+  static const ::google::protobuf::Descriptor* descriptor() {
+    return GetDescriptor();
+  }
+  static const ::google::protobuf::Descriptor* GetDescriptor() {
+    return default_instance().GetMetadata().descriptor;
+  }
+  static const ::google::protobuf::Reflection* GetReflection() {
+    return default_instance().GetMetadata().reflection;
+  }
+  static const SearchRequest_Page& default_instance() {
+    return *internal_default_instance();
+  }
+  static inline const SearchRequest_Page* internal_default_instance() {
+    return reinterpret_cast<const SearchRequest_Page*>(
+               &_SearchRequest_Page_default_instance_);
+  }
+  static constexpr int kIndexInFileMessages =
+    0;
+
+  friend void swap(SearchRequest_Page& a, SearchRequest_Page& b) {
+    a.Swap(&b);
+  }
+  inline void Swap(SearchRequest_Page* other) {
+    if (other == this) return;
+  #ifdef PROTOBUF_FORCE_COPY_IN_SWAP
+    if (GetOwningArena() != nullptr &&
+        GetOwningArena() == other->GetOwningArena()) {
+   #else  // PROTOBUF_FORCE_COPY_IN_SWAP
+    if (GetOwningArena() == other->GetOwningArena()) {
+  #endif  // !PROTOBUF_FORCE_COPY_IN_SWAP
+      InternalSwap(other);
+    } else {
+      ::google::protobuf::internal::GenericSwap(this, other);
+    }
+  }
+  void UnsafeArenaSwap(SearchRequest_Page* other) {
+    if (other == this) return;
+    ABSL_DCHECK(GetOwningArena() == other->GetOwningArena());
+    InternalSwap(other);
+  }
+
+  // implements Message ----------------------------------------------
+
+  SearchRequest_Page* New(::google::protobuf::Arena* arena = nullptr) const final {
+    return CreateMaybeMessage<SearchRequest_Page>(arena);
+  }
+  using ::google::protobuf::Message::CopyFrom;
+  void CopyFrom(const SearchRequest_Page& from);
+  using ::google::protobuf::Message::MergeFrom;
+  void MergeFrom( const SearchRequest_Page& from) {
+    SearchRequest_Page::MergeImpl(*this, from);
+  }
+  private:
+  static void MergeImpl(::google::protobuf::Message& to_msg, const ::google::protobuf::Message& from_msg);
+  public:
+  PROTOBUF_ATTRIBUTE_REINITIALIZES void Clear() final;
+  bool IsInitialized() const final;
+
+  ::size_t ByteSizeLong() const final;
+  const char* _InternalParse(const char* ptr, ::google::protobuf::internal::ParseContext* ctx) final;
+  ::uint8_t* _InternalSerialize(
+      ::uint8_t* target, ::google::protobuf::io::EpsCopyOutputStream* stream) const final;
+  int GetCachedSize() const final { return _impl_._cached_size_.Get(); }
+
+  private:
+  void SharedCtor(::google::protobuf::Arena* arena);
+  void SharedDtor();
+  void SetCachedSize(int size) const final;
+  void InternalSwap(SearchRequest_Page* other);
+
+  private:
+  friend class ::google::protobuf::internal::AnyMetadata;
+  static ::absl::string_view FullMessageName() {
+    return "mint.tulip.v1.SearchRequest.Page";
+  }
+  protected:
+  explicit SearchRequest_Page(::google::protobuf::Arena* arena);
+  public:
+
+  static const ClassData _class_data_;
+  const ::google::protobuf::Message::ClassData*GetClassData() const final;
+
+  ::google::protobuf::Metadata GetMetadata() const final;
+
+  // nested types ----------------------------------------------------
+
+  // accessors -------------------------------------------------------
+
+  enum : int {
+    kIndexFieldNumber = 1,
+    kSizeFieldNumber = 2,
+  };
+  // int32 index = 1;
+  void clear_index() ;
+  ::int32_t index() const;
+  void set_index(::int32_t value);
+
+  private:
+  ::int32_t _internal_index() const;
+  void _internal_set_index(::int32_t value);
+
+  public:
+  // int32 size = 2;
+  void clear_size() ;
+  ::int32_t size() const;
+  void set_size(::int32_t value);
+
+  private:
+  ::int32_t _internal_size() const;
+  void _internal_set_size(::int32_t value);
+
+  public:
+  // @@protoc_insertion_point(class_scope:mint.tulip.v1.SearchRequest.Page)
+ private:
+  class _Internal;
+
+  friend class ::google::protobuf::internal::TcParser;
+  static const ::google::protobuf::internal::TcParseTable<1, 2, 0, 0, 2> _table_;
+  template <typename T> friend class ::google::protobuf::Arena::InternalHelper;
+  typedef void InternalArenaConstructable_;
+  typedef void DestructorSkippable_;
+  struct Impl_ {
+    ::int32_t index_;
+    ::int32_t size_;
+    mutable ::google::protobuf::internal::CachedSize _cached_size_;
+    PROTOBUF_TSAN_DECLARE_MEMBER
+  };
+  union { Impl_ _impl_; };
+  friend struct ::TableStruct_tulip_2eproto;
+};// -------------------------------------------------------------------
+
+class SearchRequest final :
+    public ::google::protobuf::Message /* @@protoc_insertion_point(class_definition:mint.tulip.v1.SearchRequest) */ {
+ public:
+  inline SearchRequest() : SearchRequest(nullptr) {}
+  ~SearchRequest() override;
+  template<typename = void>
+  explicit PROTOBUF_CONSTEXPR SearchRequest(::google::protobuf::internal::ConstantInitialized);
+
+  SearchRequest(const SearchRequest& from);
+  SearchRequest(SearchRequest&& from) noexcept
+    : SearchRequest() {
+    *this = ::std::move(from);
+  }
+
+  inline SearchRequest& operator=(const SearchRequest& from) {
+    CopyFrom(from);
+    return *this;
+  }
+  inline SearchRequest& operator=(SearchRequest&& from) noexcept {
+    if (this == &from) return *this;
+    if (GetOwningArena() == from.GetOwningArena()
+  #ifdef PROTOBUF_FORCE_COPY_IN_MOVE
+        && GetOwningArena() != nullptr
+  #endif  // !PROTOBUF_FORCE_COPY_IN_MOVE
+    ) {
+      InternalSwap(&from);
+    } else {
+      CopyFrom(from);
+    }
+    return *this;
+  }
+
+  inline const ::google::protobuf::UnknownFieldSet& unknown_fields() const {
+    return _internal_metadata_.unknown_fields<::google::protobuf::UnknownFieldSet>(::google::protobuf::UnknownFieldSet::default_instance);
+  }
+  inline ::google::protobuf::UnknownFieldSet* mutable_unknown_fields() {
+    return _internal_metadata_.mutable_unknown_fields<::google::protobuf::UnknownFieldSet>();
+  }
+
+  static const ::google::protobuf::Descriptor* descriptor() {
+    return GetDescriptor();
+  }
+  static const ::google::protobuf::Descriptor* GetDescriptor() {
+    return default_instance().GetMetadata().descriptor;
+  }
+  static const ::google::protobuf::Reflection* GetReflection() {
+    return default_instance().GetMetadata().reflection;
+  }
+  static const SearchRequest& default_instance() {
+    return *internal_default_instance();
+  }
+  static inline const SearchRequest* internal_default_instance() {
+    return reinterpret_cast<const SearchRequest*>(
+               &_SearchRequest_default_instance_);
+  }
+  static constexpr int kIndexInFileMessages =
+    1;
+
+  friend void swap(SearchRequest& a, SearchRequest& b) {
+    a.Swap(&b);
+  }
+  inline void Swap(SearchRequest* other) {
+    if (other == this) return;
+  #ifdef PROTOBUF_FORCE_COPY_IN_SWAP
+    if (GetOwningArena() != nullptr &&
+        GetOwningArena() == other->GetOwningArena()) {
+   #else  // PROTOBUF_FORCE_COPY_IN_SWAP
+    if (GetOwningArena() == other->GetOwningArena()) {
+  #endif  // !PROTOBUF_FORCE_COPY_IN_SWAP
+      InternalSwap(other);
+    } else {
+      ::google::protobuf::internal::GenericSwap(this, other);
+    }
+  }
+  void UnsafeArenaSwap(SearchRequest* other) {
+    if (other == this) return;
+    ABSL_DCHECK(GetOwningArena() == other->GetOwningArena());
+    InternalSwap(other);
+  }
+
+  // implements Message ----------------------------------------------
+
+  SearchRequest* New(::google::protobuf::Arena* arena = nullptr) const final {
+    return CreateMaybeMessage<SearchRequest>(arena);
+  }
+  using ::google::protobuf::Message::CopyFrom;
+  void CopyFrom(const SearchRequest& from);
+  using ::google::protobuf::Message::MergeFrom;
+  void MergeFrom( const SearchRequest& from) {
+    SearchRequest::MergeImpl(*this, from);
+  }
+  private:
+  static void MergeImpl(::google::protobuf::Message& to_msg, const ::google::protobuf::Message& from_msg);
+  public:
+  PROTOBUF_ATTRIBUTE_REINITIALIZES void Clear() final;
+  bool IsInitialized() const final;
+
+  ::size_t ByteSizeLong() const final;
+  const char* _InternalParse(const char* ptr, ::google::protobuf::internal::ParseContext* ctx) final;
+  ::uint8_t* _InternalSerialize(
+      ::uint8_t* target, ::google::protobuf::io::EpsCopyOutputStream* stream) const final;
+  int GetCachedSize() const final { return _impl_._cached_size_.Get(); }
+
+  private:
+  void SharedCtor(::google::protobuf::Arena* arena);
+  void SharedDtor();
+  void SetCachedSize(int size) const final;
+  void InternalSwap(SearchRequest* other);
+
+  private:
+  friend class ::google::protobuf::internal::AnyMetadata;
+  static ::absl::string_view FullMessageName() {
+    return "mint.tulip.v1.SearchRequest";
+  }
+  protected:
+  explicit SearchRequest(::google::protobuf::Arena* arena);
+  public:
+
+  static const ClassData _class_data_;
+  const ::google::protobuf::Message::ClassData*GetClassData() const final;
+
+  ::google::protobuf::Metadata GetMetadata() const final;
+
+  // nested types ----------------------------------------------------
+
+  typedef SearchRequest_Page Page;
+
+  // accessors -------------------------------------------------------
+
+  enum : int {
+    kKeywordsFieldNumber = 1,
+    kPageFieldNumber = 99,
+    kBookFieldNumber = 2,
+  };
+  // repeated string keywords = 1;
+  int keywords_size() const;
+  private:
+  int _internal_keywords_size() const;
+
+  public:
+  void clear_keywords() ;
+  const std::string& keywords(int index) const;
+  std::string* mutable_keywords(int index);
+  void set_keywords(int index, const std::string& value);
+  void set_keywords(int index, std::string&& value);
+  void set_keywords(int index, const char* value);
+  void set_keywords(int index, const char* value, std::size_t size);
+  void set_keywords(int index, absl::string_view value);
+  std::string* add_keywords();
+  void add_keywords(const std::string& value);
+  void add_keywords(std::string&& value);
+  void add_keywords(const char* value);
+  void add_keywords(const char* value, std::size_t size);
+  void add_keywords(absl::string_view value);
+  const ::google::protobuf::RepeatedPtrField<std::string>& keywords() const;
+  ::google::protobuf::RepeatedPtrField<std::string>* mutable_keywords();
+
+  private:
+  const ::google::protobuf::RepeatedPtrField<std::string>& _internal_keywords() const;
+  ::google::protobuf::RepeatedPtrField<std::string>* _internal_mutable_keywords();
+
+  public:
+  // optional .mint.tulip.v1.SearchRequest.Page page = 99;
+  bool has_page() const;
+  void clear_page() ;
+  const ::mint::tulip::v1::SearchRequest_Page& page() const;
+  PROTOBUF_NODISCARD ::mint::tulip::v1::SearchRequest_Page* release_page();
+  ::mint::tulip::v1::SearchRequest_Page* mutable_page();
+  void set_allocated_page(::mint::tulip::v1::SearchRequest_Page* value);
+  void unsafe_arena_set_allocated_page(::mint::tulip::v1::SearchRequest_Page* value);
+  ::mint::tulip::v1::SearchRequest_Page* unsafe_arena_release_page();
+
+  private:
+  const ::mint::tulip::v1::SearchRequest_Page& _internal_page() const;
+  ::mint::tulip::v1::SearchRequest_Page* _internal_mutable_page();
+
+  public:
+  // int32 book = 2;
+  void clear_book() ;
+  ::int32_t book() const;
+  void set_book(::int32_t value);
+
+  private:
+  ::int32_t _internal_book() const;
+  void _internal_set_book(::int32_t value);
+
+  public:
+  // @@protoc_insertion_point(class_scope:mint.tulip.v1.SearchRequest)
+ private:
+  class _Internal;
+
+  friend class ::google::protobuf::internal::TcParser;
+  static const ::google::protobuf::internal::TcParseTable<2, 3, 1, 44, 7> _table_;
+  template <typename T> friend class ::google::protobuf::Arena::InternalHelper;
+  typedef void InternalArenaConstructable_;
+  typedef void DestructorSkippable_;
+  struct Impl_ {
+    ::google::protobuf::internal::HasBits<1> _has_bits_;
+    mutable ::google::protobuf::internal::CachedSize _cached_size_;
+    ::google::protobuf::RepeatedPtrField<std::string> keywords_;
+    ::mint::tulip::v1::SearchRequest_Page* page_;
+    ::int32_t book_;
+    PROTOBUF_TSAN_DECLARE_MEMBER
+  };
+  union { Impl_ _impl_; };
+  friend struct ::TableStruct_tulip_2eproto;
+};// -------------------------------------------------------------------
+
+class SearchResponse_Item final :
+    public ::google::protobuf::Message /* @@protoc_insertion_point(class_definition:mint.tulip.v1.SearchResponse.Item) */ {
+ public:
+  inline SearchResponse_Item() : SearchResponse_Item(nullptr) {}
+  ~SearchResponse_Item() override;
+  template<typename = void>
+  explicit PROTOBUF_CONSTEXPR SearchResponse_Item(::google::protobuf::internal::ConstantInitialized);
+
+  SearchResponse_Item(const SearchResponse_Item& from);
+  SearchResponse_Item(SearchResponse_Item&& from) noexcept
+    : SearchResponse_Item() {
+    *this = ::std::move(from);
+  }
+
+  inline SearchResponse_Item& operator=(const SearchResponse_Item& from) {
+    CopyFrom(from);
+    return *this;
+  }
+  inline SearchResponse_Item& operator=(SearchResponse_Item&& from) noexcept {
+    if (this == &from) return *this;
+    if (GetOwningArena() == from.GetOwningArena()
+  #ifdef PROTOBUF_FORCE_COPY_IN_MOVE
+        && GetOwningArena() != nullptr
+  #endif  // !PROTOBUF_FORCE_COPY_IN_MOVE
+    ) {
+      InternalSwap(&from);
+    } else {
+      CopyFrom(from);
+    }
+    return *this;
+  }
+
+  inline const ::google::protobuf::UnknownFieldSet& unknown_fields() const {
+    return _internal_metadata_.unknown_fields<::google::protobuf::UnknownFieldSet>(::google::protobuf::UnknownFieldSet::default_instance);
+  }
+  inline ::google::protobuf::UnknownFieldSet* mutable_unknown_fields() {
+    return _internal_metadata_.mutable_unknown_fields<::google::protobuf::UnknownFieldSet>();
+  }
+
+  static const ::google::protobuf::Descriptor* descriptor() {
+    return GetDescriptor();
+  }
+  static const ::google::protobuf::Descriptor* GetDescriptor() {
+    return default_instance().GetMetadata().descriptor;
+  }
+  static const ::google::protobuf::Reflection* GetReflection() {
+    return default_instance().GetMetadata().reflection;
+  }
+  static const SearchResponse_Item& default_instance() {
+    return *internal_default_instance();
+  }
+  static inline const SearchResponse_Item* internal_default_instance() {
+    return reinterpret_cast<const SearchResponse_Item*>(
+               &_SearchResponse_Item_default_instance_);
+  }
+  static constexpr int kIndexInFileMessages =
+    2;
+
+  friend void swap(SearchResponse_Item& a, SearchResponse_Item& b) {
+    a.Swap(&b);
+  }
+  inline void Swap(SearchResponse_Item* other) {
+    if (other == this) return;
+  #ifdef PROTOBUF_FORCE_COPY_IN_SWAP
+    if (GetOwningArena() != nullptr &&
+        GetOwningArena() == other->GetOwningArena()) {
+   #else  // PROTOBUF_FORCE_COPY_IN_SWAP
+    if (GetOwningArena() == other->GetOwningArena()) {
+  #endif  // !PROTOBUF_FORCE_COPY_IN_SWAP
+      InternalSwap(other);
+    } else {
+      ::google::protobuf::internal::GenericSwap(this, other);
+    }
+  }
+  void UnsafeArenaSwap(SearchResponse_Item* other) {
+    if (other == this) return;
+    ABSL_DCHECK(GetOwningArena() == other->GetOwningArena());
+    InternalSwap(other);
+  }
+
+  // implements Message ----------------------------------------------
+
+  SearchResponse_Item* New(::google::protobuf::Arena* arena = nullptr) const final {
+    return CreateMaybeMessage<SearchResponse_Item>(arena);
+  }
+  using ::google::protobuf::Message::CopyFrom;
+  void CopyFrom(const SearchResponse_Item& from);
+  using ::google::protobuf::Message::MergeFrom;
+  void MergeFrom( const SearchResponse_Item& from) {
+    SearchResponse_Item::MergeImpl(*this, from);
+  }
+  private:
+  static void MergeImpl(::google::protobuf::Message& to_msg, const ::google::protobuf::Message& from_msg);
+  public:
+  PROTOBUF_ATTRIBUTE_REINITIALIZES void Clear() final;
+  bool IsInitialized() const final;
+
+  ::size_t ByteSizeLong() const final;
+  const char* _InternalParse(const char* ptr, ::google::protobuf::internal::ParseContext* ctx) final;
+  ::uint8_t* _InternalSerialize(
+      ::uint8_t* target, ::google::protobuf::io::EpsCopyOutputStream* stream) const final;
+  int GetCachedSize() const final { return _impl_._cached_size_.Get(); }
+
+  private:
+  void SharedCtor(::google::protobuf::Arena* arena);
+  void SharedDtor();
+  void SetCachedSize(int size) const final;
+  void InternalSwap(SearchResponse_Item* other);
+
+  private:
+  friend class ::google::protobuf::internal::AnyMetadata;
+  static ::absl::string_view FullMessageName() {
+    return "mint.tulip.v1.SearchResponse.Item";
+  }
+  protected:
+  explicit SearchResponse_Item(::google::protobuf::Arena* arena);
+  public:
+
+  static const ClassData _class_data_;
+  const ::google::protobuf::Message::ClassData*GetClassData() const final;
+
+  ::google::protobuf::Metadata GetMetadata() const final;
+
+  // nested types ----------------------------------------------------
+
+  // accessors -------------------------------------------------------
+
+  enum : int {
+    kHighlightFieldNumber = 2,
+    kContentFieldNumber = 5,
+    kRankFieldNumber = 1,
+    kBookFieldNumber = 3,
+    kParagraphFieldNumber = 4,
+  };
+  // string highlight = 2;
+  void clear_highlight() ;
+  const std::string& highlight() const;
+  template <typename Arg_ = const std::string&, typename... Args_>
+  void set_highlight(Arg_&& arg, Args_... args);
+  std::string* mutable_highlight();
+  PROTOBUF_NODISCARD std::string* release_highlight();
+  void set_allocated_highlight(std::string* ptr);
+
+  private:
+  const std::string& _internal_highlight() const;
+  inline PROTOBUF_ALWAYS_INLINE void _internal_set_highlight(
+      const std::string& value);
+  std::string* _internal_mutable_highlight();
+
+  public:
+  // string content = 5;
+  void clear_content() ;
+  const std::string& content() const;
+  template <typename Arg_ = const std::string&, typename... Args_>
+  void set_content(Arg_&& arg, Args_... args);
+  std::string* mutable_content();
+  PROTOBUF_NODISCARD std::string* release_content();
+  void set_allocated_content(std::string* ptr);
+
+  private:
+  const std::string& _internal_content() const;
+  inline PROTOBUF_ALWAYS_INLINE void _internal_set_content(
+      const std::string& value);
+  std::string* _internal_mutable_content();
+
+  public:
+  // int32 rank = 1;
+  void clear_rank() ;
+  ::int32_t rank() const;
+  void set_rank(::int32_t value);
+
+  private:
+  ::int32_t _internal_rank() const;
+  void _internal_set_rank(::int32_t value);
+
+  public:
+  // int32 book = 3;
+  void clear_book() ;
+  ::int32_t book() const;
+  void set_book(::int32_t value);
+
+  private:
+  ::int32_t _internal_book() const;
+  void _internal_set_book(::int32_t value);
+
+  public:
+  // int32 paragraph = 4;
+  void clear_paragraph() ;
+  ::int32_t paragraph() const;
+  void set_paragraph(::int32_t value);
+
+  private:
+  ::int32_t _internal_paragraph() const;
+  void _internal_set_paragraph(::int32_t value);
+
+  public:
+  // @@protoc_insertion_point(class_scope:mint.tulip.v1.SearchResponse.Item)
+ private:
+  class _Internal;
+
+  friend class ::google::protobuf::internal::TcParser;
+  static const ::google::protobuf::internal::TcParseTable<3, 5, 0, 58, 2> _table_;
+  template <typename T> friend class ::google::protobuf::Arena::InternalHelper;
+  typedef void InternalArenaConstructable_;
+  typedef void DestructorSkippable_;
+  struct Impl_ {
+    ::google::protobuf::internal::ArenaStringPtr highlight_;
+    ::google::protobuf::internal::ArenaStringPtr content_;
+    ::int32_t rank_;
+    ::int32_t book_;
+    ::int32_t paragraph_;
+    mutable ::google::protobuf::internal::CachedSize _cached_size_;
+    PROTOBUF_TSAN_DECLARE_MEMBER
+  };
+  union { Impl_ _impl_; };
+  friend struct ::TableStruct_tulip_2eproto;
+};// -------------------------------------------------------------------
+
+class SearchResponse final :
+    public ::google::protobuf::Message /* @@protoc_insertion_point(class_definition:mint.tulip.v1.SearchResponse) */ {
+ public:
+  inline SearchResponse() : SearchResponse(nullptr) {}
+  ~SearchResponse() override;
+  template<typename = void>
+  explicit PROTOBUF_CONSTEXPR SearchResponse(::google::protobuf::internal::ConstantInitialized);
+
+  SearchResponse(const SearchResponse& from);
+  SearchResponse(SearchResponse&& from) noexcept
+    : SearchResponse() {
+    *this = ::std::move(from);
+  }
+
+  inline SearchResponse& operator=(const SearchResponse& from) {
+    CopyFrom(from);
+    return *this;
+  }
+  inline SearchResponse& operator=(SearchResponse&& from) noexcept {
+    if (this == &from) return *this;
+    if (GetOwningArena() == from.GetOwningArena()
+  #ifdef PROTOBUF_FORCE_COPY_IN_MOVE
+        && GetOwningArena() != nullptr
+  #endif  // !PROTOBUF_FORCE_COPY_IN_MOVE
+    ) {
+      InternalSwap(&from);
+    } else {
+      CopyFrom(from);
+    }
+    return *this;
+  }
+
+  inline const ::google::protobuf::UnknownFieldSet& unknown_fields() const {
+    return _internal_metadata_.unknown_fields<::google::protobuf::UnknownFieldSet>(::google::protobuf::UnknownFieldSet::default_instance);
+  }
+  inline ::google::protobuf::UnknownFieldSet* mutable_unknown_fields() {
+    return _internal_metadata_.mutable_unknown_fields<::google::protobuf::UnknownFieldSet>();
+  }
+
+  static const ::google::protobuf::Descriptor* descriptor() {
+    return GetDescriptor();
+  }
+  static const ::google::protobuf::Descriptor* GetDescriptor() {
+    return default_instance().GetMetadata().descriptor;
+  }
+  static const ::google::protobuf::Reflection* GetReflection() {
+    return default_instance().GetMetadata().reflection;
+  }
+  static const SearchResponse& default_instance() {
+    return *internal_default_instance();
+  }
+  static inline const SearchResponse* internal_default_instance() {
+    return reinterpret_cast<const SearchResponse*>(
+               &_SearchResponse_default_instance_);
+  }
+  static constexpr int kIndexInFileMessages =
+    3;
+
+  friend void swap(SearchResponse& a, SearchResponse& b) {
+    a.Swap(&b);
+  }
+  inline void Swap(SearchResponse* other) {
+    if (other == this) return;
+  #ifdef PROTOBUF_FORCE_COPY_IN_SWAP
+    if (GetOwningArena() != nullptr &&
+        GetOwningArena() == other->GetOwningArena()) {
+   #else  // PROTOBUF_FORCE_COPY_IN_SWAP
+    if (GetOwningArena() == other->GetOwningArena()) {
+  #endif  // !PROTOBUF_FORCE_COPY_IN_SWAP
+      InternalSwap(other);
+    } else {
+      ::google::protobuf::internal::GenericSwap(this, other);
+    }
+  }
+  void UnsafeArenaSwap(SearchResponse* other) {
+    if (other == this) return;
+    ABSL_DCHECK(GetOwningArena() == other->GetOwningArena());
+    InternalSwap(other);
+  }
+
+  // implements Message ----------------------------------------------
+
+  SearchResponse* New(::google::protobuf::Arena* arena = nullptr) const final {
+    return CreateMaybeMessage<SearchResponse>(arena);
+  }
+  using ::google::protobuf::Message::CopyFrom;
+  void CopyFrom(const SearchResponse& from);
+  using ::google::protobuf::Message::MergeFrom;
+  void MergeFrom( const SearchResponse& from) {
+    SearchResponse::MergeImpl(*this, from);
+  }
+  private:
+  static void MergeImpl(::google::protobuf::Message& to_msg, const ::google::protobuf::Message& from_msg);
+  public:
+  PROTOBUF_ATTRIBUTE_REINITIALIZES void Clear() final;
+  bool IsInitialized() const final;
+
+  ::size_t ByteSizeLong() const final;
+  const char* _InternalParse(const char* ptr, ::google::protobuf::internal::ParseContext* ctx) final;
+  ::uint8_t* _InternalSerialize(
+      ::uint8_t* target, ::google::protobuf::io::EpsCopyOutputStream* stream) const final;
+  int GetCachedSize() const final { return _impl_._cached_size_.Get(); }
+
+  private:
+  void SharedCtor(::google::protobuf::Arena* arena);
+  void SharedDtor();
+  void SetCachedSize(int size) const final;
+  void InternalSwap(SearchResponse* other);
+
+  private:
+  friend class ::google::protobuf::internal::AnyMetadata;
+  static ::absl::string_view FullMessageName() {
+    return "mint.tulip.v1.SearchResponse";
+  }
+  protected:
+  explicit SearchResponse(::google::protobuf::Arena* arena);
+  public:
+
+  static const ClassData _class_data_;
+  const ::google::protobuf::Message::ClassData*GetClassData() const final;
+
+  ::google::protobuf::Metadata GetMetadata() const final;
+
+  // nested types ----------------------------------------------------
+
+  typedef SearchResponse_Item Item;
+
+  // accessors -------------------------------------------------------
+
+  enum : int {
+    kItemsFieldNumber = 1,
+    kPageFieldNumber = 98,
+    kTotalFieldNumber = 99,
+  };
+  // repeated .mint.tulip.v1.SearchResponse.Item items = 1;
+  int items_size() const;
+  private:
+  int _internal_items_size() const;
+
+  public:
+  void clear_items() ;
+  ::mint::tulip::v1::SearchResponse_Item* mutable_items(int index);
+  ::google::protobuf::RepeatedPtrField< ::mint::tulip::v1::SearchResponse_Item >*
+      mutable_items();
+  private:
+  const ::google::protobuf::RepeatedPtrField<::mint::tulip::v1::SearchResponse_Item>& _internal_items() const;
+  ::google::protobuf::RepeatedPtrField<::mint::tulip::v1::SearchResponse_Item>* _internal_mutable_items();
+  public:
+  const ::mint::tulip::v1::SearchResponse_Item& items(int index) const;
+  ::mint::tulip::v1::SearchResponse_Item* add_items();
+  const ::google::protobuf::RepeatedPtrField< ::mint::tulip::v1::SearchResponse_Item >&
+      items() const;
+  // .mint.tulip.v1.SearchRequest.Page page = 98;
+  bool has_page() const;
+  void clear_page() ;
+  const ::mint::tulip::v1::SearchRequest_Page& page() const;
+  PROTOBUF_NODISCARD ::mint::tulip::v1::SearchRequest_Page* release_page();
+  ::mint::tulip::v1::SearchRequest_Page* mutable_page();
+  void set_allocated_page(::mint::tulip::v1::SearchRequest_Page* value);
+  void unsafe_arena_set_allocated_page(::mint::tulip::v1::SearchRequest_Page* value);
+  ::mint::tulip::v1::SearchRequest_Page* unsafe_arena_release_page();
+
+  private:
+  const ::mint::tulip::v1::SearchRequest_Page& _internal_page() const;
+  ::mint::tulip::v1::SearchRequest_Page* _internal_mutable_page();
+
+  public:
+  // int32 total = 99;
+  void clear_total() ;
+  ::int32_t total() const;
+  void set_total(::int32_t value);
+
+  private:
+  ::int32_t _internal_total() const;
+  void _internal_set_total(::int32_t value);
+
+  public:
+  // @@protoc_insertion_point(class_scope:mint.tulip.v1.SearchResponse)
+ private:
+  class _Internal;
+
+  friend class ::google::protobuf::internal::TcParser;
+  static const ::google::protobuf::internal::TcParseTable<2, 3, 2, 0, 7> _table_;
+  template <typename T> friend class ::google::protobuf::Arena::InternalHelper;
+  typedef void InternalArenaConstructable_;
+  typedef void DestructorSkippable_;
+  struct Impl_ {
+    ::google::protobuf::internal::HasBits<1> _has_bits_;
+    mutable ::google::protobuf::internal::CachedSize _cached_size_;
+    ::google::protobuf::RepeatedPtrField< ::mint::tulip::v1::SearchResponse_Item > items_;
+    ::mint::tulip::v1::SearchRequest_Page* page_;
+    ::int32_t total_;
+    PROTOBUF_TSAN_DECLARE_MEMBER
+  };
+  union { Impl_ _impl_; };
+  friend struct ::TableStruct_tulip_2eproto;
+};
+
+// ===================================================================
+
+
+
+
+// ===================================================================
+
+
+#ifdef __GNUC__
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wstrict-aliasing"
+#endif  // __GNUC__
+// -------------------------------------------------------------------
+
+// SearchRequest_Page
+
+// int32 index = 1;
+inline void SearchRequest_Page::clear_index() {
+  _impl_.index_ = 0;
+}
+inline ::int32_t SearchRequest_Page::index() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchRequest.Page.index)
+  return _internal_index();
+}
+inline void SearchRequest_Page::set_index(::int32_t value) {
+  _internal_set_index(value);
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchRequest.Page.index)
+}
+inline ::int32_t SearchRequest_Page::_internal_index() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.index_;
+}
+inline void SearchRequest_Page::_internal_set_index(::int32_t value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.index_ = value;
+}
+
+// int32 size = 2;
+inline void SearchRequest_Page::clear_size() {
+  _impl_.size_ = 0;
+}
+inline ::int32_t SearchRequest_Page::size() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchRequest.Page.size)
+  return _internal_size();
+}
+inline void SearchRequest_Page::set_size(::int32_t value) {
+  _internal_set_size(value);
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchRequest.Page.size)
+}
+inline ::int32_t SearchRequest_Page::_internal_size() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.size_;
+}
+inline void SearchRequest_Page::_internal_set_size(::int32_t value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.size_ = value;
+}
+
+// -------------------------------------------------------------------
+
+// SearchRequest
+
+// repeated string keywords = 1;
+inline int SearchRequest::_internal_keywords_size() const {
+  return _internal_keywords().size();
+}
+inline int SearchRequest::keywords_size() const {
+  return _internal_keywords_size();
+}
+inline void SearchRequest::clear_keywords() {
+  _internal_mutable_keywords()->Clear();
+}
+inline std::string* SearchRequest::add_keywords() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  std::string* _s = _internal_mutable_keywords()->Add();
+  // @@protoc_insertion_point(field_add_mutable:mint.tulip.v1.SearchRequest.keywords)
+  return _s;
+}
+inline const std::string& SearchRequest::keywords(int index) const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchRequest.keywords)
+  return _internal_keywords().Get(index);
+}
+inline std::string* SearchRequest::mutable_keywords(int index) {
+  // @@protoc_insertion_point(field_mutable:mint.tulip.v1.SearchRequest.keywords)
+  return _internal_mutable_keywords()->Mutable(index);
+}
+inline void SearchRequest::set_keywords(int index, const std::string& value) {
+  _internal_mutable_keywords()->Mutable(index)->assign(value);
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::set_keywords(int index, std::string&& value) {
+  _internal_mutable_keywords()->Mutable(index)->assign(std::move(value));
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::set_keywords(int index, const char* value) {
+  ABSL_DCHECK(value != nullptr);
+  _internal_mutable_keywords()->Mutable(index)->assign(value);
+  // @@protoc_insertion_point(field_set_char:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::set_keywords(int index, const char* value,
+                              std::size_t size) {
+  _internal_mutable_keywords()->Mutable(index)->assign(
+      reinterpret_cast<const char*>(value), size);
+  // @@protoc_insertion_point(field_set_pointer:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::set_keywords(int index, absl::string_view value) {
+  _internal_mutable_keywords()->Mutable(index)->assign(value.data(),
+                                                     value.size());
+  // @@protoc_insertion_point(field_set_string_piece:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::add_keywords(const std::string& value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _internal_mutable_keywords()->Add()->assign(value);
+  // @@protoc_insertion_point(field_add:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::add_keywords(std::string&& value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _internal_mutable_keywords()->Add(std::move(value));
+  // @@protoc_insertion_point(field_add:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::add_keywords(const char* value) {
+  ABSL_DCHECK(value != nullptr);
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _internal_mutable_keywords()->Add()->assign(value);
+  // @@protoc_insertion_point(field_add_char:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::add_keywords(const char* value, std::size_t size) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _internal_mutable_keywords()->Add()->assign(
+      reinterpret_cast<const char*>(value), size);
+  // @@protoc_insertion_point(field_add_pointer:mint.tulip.v1.SearchRequest.keywords)
+}
+inline void SearchRequest::add_keywords(absl::string_view value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _internal_mutable_keywords()->Add()->assign(value.data(), value.size());
+  // @@protoc_insertion_point(field_add_string_piece:mint.tulip.v1.SearchRequest.keywords)
+}
+inline const ::google::protobuf::RepeatedPtrField<std::string>&
+SearchRequest::keywords() const {
+  // @@protoc_insertion_point(field_list:mint.tulip.v1.SearchRequest.keywords)
+  return _internal_keywords();
+}
+inline ::google::protobuf::RepeatedPtrField<std::string>* SearchRequest::mutable_keywords() {
+  // @@protoc_insertion_point(field_mutable_list:mint.tulip.v1.SearchRequest.keywords)
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  return _internal_mutable_keywords();
+}
+inline const ::google::protobuf::RepeatedPtrField<std::string>&
+SearchRequest::_internal_keywords() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.keywords_;
+}
+inline ::google::protobuf::RepeatedPtrField<std::string>*
+SearchRequest::_internal_mutable_keywords() {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return &_impl_.keywords_;
+}
+
+// int32 book = 2;
+inline void SearchRequest::clear_book() {
+  _impl_.book_ = 0;
+}
+inline ::int32_t SearchRequest::book() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchRequest.book)
+  return _internal_book();
+}
+inline void SearchRequest::set_book(::int32_t value) {
+  _internal_set_book(value);
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchRequest.book)
+}
+inline ::int32_t SearchRequest::_internal_book() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.book_;
+}
+inline void SearchRequest::_internal_set_book(::int32_t value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.book_ = value;
+}
+
+// optional .mint.tulip.v1.SearchRequest.Page page = 99;
+inline bool SearchRequest::has_page() const {
+  bool value = (_impl_._has_bits_[0] & 0x00000001u) != 0;
+  PROTOBUF_ASSUME(!value || _impl_.page_ != nullptr);
+  return value;
+}
+inline void SearchRequest::clear_page() {
+  if (_impl_.page_ != nullptr) _impl_.page_->Clear();
+  _impl_._has_bits_[0] &= ~0x00000001u;
+}
+inline const ::mint::tulip::v1::SearchRequest_Page& SearchRequest::_internal_page() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  const ::mint::tulip::v1::SearchRequest_Page* p = _impl_.page_;
+  return p != nullptr ? *p : reinterpret_cast<const ::mint::tulip::v1::SearchRequest_Page&>(::mint::tulip::v1::_SearchRequest_Page_default_instance_);
+}
+inline const ::mint::tulip::v1::SearchRequest_Page& SearchRequest::page() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchRequest.page)
+  return _internal_page();
+}
+inline void SearchRequest::unsafe_arena_set_allocated_page(::mint::tulip::v1::SearchRequest_Page* value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  if (GetArenaForAllocation() == nullptr) {
+    delete reinterpret_cast<::google::protobuf::MessageLite*>(_impl_.page_);
+  }
+  _impl_.page_ = reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(value);
+  if (value != nullptr) {
+    _impl_._has_bits_[0] |= 0x00000001u;
+  } else {
+    _impl_._has_bits_[0] &= ~0x00000001u;
+  }
+  // @@protoc_insertion_point(field_unsafe_arena_set_allocated:mint.tulip.v1.SearchRequest.page)
+}
+inline ::mint::tulip::v1::SearchRequest_Page* SearchRequest::release_page() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+
+  _impl_._has_bits_[0] &= ~0x00000001u;
+  ::mint::tulip::v1::SearchRequest_Page* released = _impl_.page_;
+  _impl_.page_ = nullptr;
+#ifdef PROTOBUF_FORCE_COPY_IN_RELEASE
+  auto* old = reinterpret_cast<::google::protobuf::MessageLite*>(released);
+  released = ::google::protobuf::internal::DuplicateIfNonNull(released);
+  if (GetArenaForAllocation() == nullptr) {
+    delete old;
+  }
+#else   // PROTOBUF_FORCE_COPY_IN_RELEASE
+  if (GetArenaForAllocation() != nullptr) {
+    released = ::google::protobuf::internal::DuplicateIfNonNull(released);
+  }
+#endif  // !PROTOBUF_FORCE_COPY_IN_RELEASE
+  return released;
+}
+inline ::mint::tulip::v1::SearchRequest_Page* SearchRequest::unsafe_arena_release_page() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  // @@protoc_insertion_point(field_release:mint.tulip.v1.SearchRequest.page)
+
+  _impl_._has_bits_[0] &= ~0x00000001u;
+  ::mint::tulip::v1::SearchRequest_Page* temp = _impl_.page_;
+  _impl_.page_ = nullptr;
+  return temp;
+}
+inline ::mint::tulip::v1::SearchRequest_Page* SearchRequest::_internal_mutable_page() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _impl_._has_bits_[0] |= 0x00000001u;
+  if (_impl_.page_ == nullptr) {
+    auto* p = CreateMaybeMessage<::mint::tulip::v1::SearchRequest_Page>(GetArenaForAllocation());
+    _impl_.page_ = reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(p);
+  }
+  return _impl_.page_;
+}
+inline ::mint::tulip::v1::SearchRequest_Page* SearchRequest::mutable_page() {
+  ::mint::tulip::v1::SearchRequest_Page* _msg = _internal_mutable_page();
+  // @@protoc_insertion_point(field_mutable:mint.tulip.v1.SearchRequest.page)
+  return _msg;
+}
+inline void SearchRequest::set_allocated_page(::mint::tulip::v1::SearchRequest_Page* value) {
+  ::google::protobuf::Arena* message_arena = GetArenaForAllocation();
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  if (message_arena == nullptr) {
+    delete reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(_impl_.page_);
+  }
+
+  if (value != nullptr) {
+    ::google::protobuf::Arena* submessage_arena =
+        ::google::protobuf::Arena::InternalGetOwningArena(reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(value));
+    if (message_arena != submessage_arena) {
+      value = ::google::protobuf::internal::GetOwnedMessage(message_arena, value, submessage_arena);
+    }
+    _impl_._has_bits_[0] |= 0x00000001u;
+  } else {
+    _impl_._has_bits_[0] &= ~0x00000001u;
+  }
+
+  _impl_.page_ = reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(value);
+  // @@protoc_insertion_point(field_set_allocated:mint.tulip.v1.SearchRequest.page)
+}
+
+// -------------------------------------------------------------------
+
+// SearchResponse_Item
+
+// int32 rank = 1;
+inline void SearchResponse_Item::clear_rank() {
+  _impl_.rank_ = 0;
+}
+inline ::int32_t SearchResponse_Item::rank() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchResponse.Item.rank)
+  return _internal_rank();
+}
+inline void SearchResponse_Item::set_rank(::int32_t value) {
+  _internal_set_rank(value);
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchResponse.Item.rank)
+}
+inline ::int32_t SearchResponse_Item::_internal_rank() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.rank_;
+}
+inline void SearchResponse_Item::_internal_set_rank(::int32_t value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.rank_ = value;
+}
+
+// string highlight = 2;
+inline void SearchResponse_Item::clear_highlight() {
+  _impl_.highlight_.ClearToEmpty();
+}
+inline const std::string& SearchResponse_Item::highlight() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchResponse.Item.highlight)
+  return _internal_highlight();
+}
+template <typename Arg_, typename... Args_>
+inline PROTOBUF_ALWAYS_INLINE void SearchResponse_Item::set_highlight(Arg_&& arg,
+                                                     Args_... args) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.highlight_.Set(static_cast<Arg_&&>(arg), args..., GetArenaForAllocation());
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchResponse.Item.highlight)
+}
+inline std::string* SearchResponse_Item::mutable_highlight() {
+  std::string* _s = _internal_mutable_highlight();
+  // @@protoc_insertion_point(field_mutable:mint.tulip.v1.SearchResponse.Item.highlight)
+  return _s;
+}
+inline const std::string& SearchResponse_Item::_internal_highlight() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.highlight_.Get();
+}
+inline void SearchResponse_Item::_internal_set_highlight(const std::string& value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.highlight_.Set(value, GetArenaForAllocation());
+}
+inline std::string* SearchResponse_Item::_internal_mutable_highlight() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  return _impl_.highlight_.Mutable( GetArenaForAllocation());
+}
+inline std::string* SearchResponse_Item::release_highlight() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  // @@protoc_insertion_point(field_release:mint.tulip.v1.SearchResponse.Item.highlight)
+  return _impl_.highlight_.Release();
+}
+inline void SearchResponse_Item::set_allocated_highlight(std::string* value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _impl_.highlight_.SetAllocated(value, GetArenaForAllocation());
+  #ifdef PROTOBUF_FORCE_COPY_DEFAULT_STRING
+        if (_impl_.highlight_.IsDefault()) {
+          _impl_.highlight_.Set("", GetArenaForAllocation());
+        }
+  #endif  // PROTOBUF_FORCE_COPY_DEFAULT_STRING
+  // @@protoc_insertion_point(field_set_allocated:mint.tulip.v1.SearchResponse.Item.highlight)
+}
+
+// int32 book = 3;
+inline void SearchResponse_Item::clear_book() {
+  _impl_.book_ = 0;
+}
+inline ::int32_t SearchResponse_Item::book() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchResponse.Item.book)
+  return _internal_book();
+}
+inline void SearchResponse_Item::set_book(::int32_t value) {
+  _internal_set_book(value);
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchResponse.Item.book)
+}
+inline ::int32_t SearchResponse_Item::_internal_book() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.book_;
+}
+inline void SearchResponse_Item::_internal_set_book(::int32_t value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.book_ = value;
+}
+
+// int32 paragraph = 4;
+inline void SearchResponse_Item::clear_paragraph() {
+  _impl_.paragraph_ = 0;
+}
+inline ::int32_t SearchResponse_Item::paragraph() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchResponse.Item.paragraph)
+  return _internal_paragraph();
+}
+inline void SearchResponse_Item::set_paragraph(::int32_t value) {
+  _internal_set_paragraph(value);
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchResponse.Item.paragraph)
+}
+inline ::int32_t SearchResponse_Item::_internal_paragraph() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.paragraph_;
+}
+inline void SearchResponse_Item::_internal_set_paragraph(::int32_t value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.paragraph_ = value;
+}
+
+// string content = 5;
+inline void SearchResponse_Item::clear_content() {
+  _impl_.content_.ClearToEmpty();
+}
+inline const std::string& SearchResponse_Item::content() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchResponse.Item.content)
+  return _internal_content();
+}
+template <typename Arg_, typename... Args_>
+inline PROTOBUF_ALWAYS_INLINE void SearchResponse_Item::set_content(Arg_&& arg,
+                                                     Args_... args) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.content_.Set(static_cast<Arg_&&>(arg), args..., GetArenaForAllocation());
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchResponse.Item.content)
+}
+inline std::string* SearchResponse_Item::mutable_content() {
+  std::string* _s = _internal_mutable_content();
+  // @@protoc_insertion_point(field_mutable:mint.tulip.v1.SearchResponse.Item.content)
+  return _s;
+}
+inline const std::string& SearchResponse_Item::_internal_content() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.content_.Get();
+}
+inline void SearchResponse_Item::_internal_set_content(const std::string& value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.content_.Set(value, GetArenaForAllocation());
+}
+inline std::string* SearchResponse_Item::_internal_mutable_content() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  return _impl_.content_.Mutable( GetArenaForAllocation());
+}
+inline std::string* SearchResponse_Item::release_content() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  // @@protoc_insertion_point(field_release:mint.tulip.v1.SearchResponse.Item.content)
+  return _impl_.content_.Release();
+}
+inline void SearchResponse_Item::set_allocated_content(std::string* value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _impl_.content_.SetAllocated(value, GetArenaForAllocation());
+  #ifdef PROTOBUF_FORCE_COPY_DEFAULT_STRING
+        if (_impl_.content_.IsDefault()) {
+          _impl_.content_.Set("", GetArenaForAllocation());
+        }
+  #endif  // PROTOBUF_FORCE_COPY_DEFAULT_STRING
+  // @@protoc_insertion_point(field_set_allocated:mint.tulip.v1.SearchResponse.Item.content)
+}
+
+// -------------------------------------------------------------------
+
+// SearchResponse
+
+// repeated .mint.tulip.v1.SearchResponse.Item items = 1;
+inline int SearchResponse::_internal_items_size() const {
+  return _internal_items().size();
+}
+inline int SearchResponse::items_size() const {
+  return _internal_items_size();
+}
+inline void SearchResponse::clear_items() {
+  _internal_mutable_items()->Clear();
+}
+inline ::mint::tulip::v1::SearchResponse_Item* SearchResponse::mutable_items(int index) {
+  // @@protoc_insertion_point(field_mutable:mint.tulip.v1.SearchResponse.items)
+  return _internal_mutable_items()->Mutable(index);
+}
+inline ::google::protobuf::RepeatedPtrField< ::mint::tulip::v1::SearchResponse_Item >*
+SearchResponse::mutable_items() {
+  // @@protoc_insertion_point(field_mutable_list:mint.tulip.v1.SearchResponse.items)
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  return _internal_mutable_items();
+}
+inline const ::mint::tulip::v1::SearchResponse_Item& SearchResponse::items(int index) const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchResponse.items)
+    return _internal_items().Get(index);
+}
+inline ::mint::tulip::v1::SearchResponse_Item* SearchResponse::add_items() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ::mint::tulip::v1::SearchResponse_Item* _add = _internal_mutable_items()->Add();
+  // @@protoc_insertion_point(field_add:mint.tulip.v1.SearchResponse.items)
+  return _add;
+}
+inline const ::google::protobuf::RepeatedPtrField< ::mint::tulip::v1::SearchResponse_Item >&
+SearchResponse::items() const {
+  // @@protoc_insertion_point(field_list:mint.tulip.v1.SearchResponse.items)
+  return _internal_items();
+}
+inline const ::google::protobuf::RepeatedPtrField<::mint::tulip::v1::SearchResponse_Item>&
+SearchResponse::_internal_items() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.items_;
+}
+inline ::google::protobuf::RepeatedPtrField<::mint::tulip::v1::SearchResponse_Item>*
+SearchResponse::_internal_mutable_items() {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return &_impl_.items_;
+}
+
+// .mint.tulip.v1.SearchRequest.Page page = 98;
+inline bool SearchResponse::has_page() const {
+  bool value = (_impl_._has_bits_[0] & 0x00000001u) != 0;
+  PROTOBUF_ASSUME(!value || _impl_.page_ != nullptr);
+  return value;
+}
+inline void SearchResponse::clear_page() {
+  if (_impl_.page_ != nullptr) _impl_.page_->Clear();
+  _impl_._has_bits_[0] &= ~0x00000001u;
+}
+inline const ::mint::tulip::v1::SearchRequest_Page& SearchResponse::_internal_page() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  const ::mint::tulip::v1::SearchRequest_Page* p = _impl_.page_;
+  return p != nullptr ? *p : reinterpret_cast<const ::mint::tulip::v1::SearchRequest_Page&>(::mint::tulip::v1::_SearchRequest_Page_default_instance_);
+}
+inline const ::mint::tulip::v1::SearchRequest_Page& SearchResponse::page() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchResponse.page)
+  return _internal_page();
+}
+inline void SearchResponse::unsafe_arena_set_allocated_page(::mint::tulip::v1::SearchRequest_Page* value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  if (GetArenaForAllocation() == nullptr) {
+    delete reinterpret_cast<::google::protobuf::MessageLite*>(_impl_.page_);
+  }
+  _impl_.page_ = reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(value);
+  if (value != nullptr) {
+    _impl_._has_bits_[0] |= 0x00000001u;
+  } else {
+    _impl_._has_bits_[0] &= ~0x00000001u;
+  }
+  // @@protoc_insertion_point(field_unsafe_arena_set_allocated:mint.tulip.v1.SearchResponse.page)
+}
+inline ::mint::tulip::v1::SearchRequest_Page* SearchResponse::release_page() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+
+  _impl_._has_bits_[0] &= ~0x00000001u;
+  ::mint::tulip::v1::SearchRequest_Page* released = _impl_.page_;
+  _impl_.page_ = nullptr;
+#ifdef PROTOBUF_FORCE_COPY_IN_RELEASE
+  auto* old = reinterpret_cast<::google::protobuf::MessageLite*>(released);
+  released = ::google::protobuf::internal::DuplicateIfNonNull(released);
+  if (GetArenaForAllocation() == nullptr) {
+    delete old;
+  }
+#else   // PROTOBUF_FORCE_COPY_IN_RELEASE
+  if (GetArenaForAllocation() != nullptr) {
+    released = ::google::protobuf::internal::DuplicateIfNonNull(released);
+  }
+#endif  // !PROTOBUF_FORCE_COPY_IN_RELEASE
+  return released;
+}
+inline ::mint::tulip::v1::SearchRequest_Page* SearchResponse::unsafe_arena_release_page() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  // @@protoc_insertion_point(field_release:mint.tulip.v1.SearchResponse.page)
+
+  _impl_._has_bits_[0] &= ~0x00000001u;
+  ::mint::tulip::v1::SearchRequest_Page* temp = _impl_.page_;
+  _impl_.page_ = nullptr;
+  return temp;
+}
+inline ::mint::tulip::v1::SearchRequest_Page* SearchResponse::_internal_mutable_page() {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  _impl_._has_bits_[0] |= 0x00000001u;
+  if (_impl_.page_ == nullptr) {
+    auto* p = CreateMaybeMessage<::mint::tulip::v1::SearchRequest_Page>(GetArenaForAllocation());
+    _impl_.page_ = reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(p);
+  }
+  return _impl_.page_;
+}
+inline ::mint::tulip::v1::SearchRequest_Page* SearchResponse::mutable_page() {
+  ::mint::tulip::v1::SearchRequest_Page* _msg = _internal_mutable_page();
+  // @@protoc_insertion_point(field_mutable:mint.tulip.v1.SearchResponse.page)
+  return _msg;
+}
+inline void SearchResponse::set_allocated_page(::mint::tulip::v1::SearchRequest_Page* value) {
+  ::google::protobuf::Arena* message_arena = GetArenaForAllocation();
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  if (message_arena == nullptr) {
+    delete reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(_impl_.page_);
+  }
+
+  if (value != nullptr) {
+    ::google::protobuf::Arena* submessage_arena =
+        ::google::protobuf::Arena::InternalGetOwningArena(reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(value));
+    if (message_arena != submessage_arena) {
+      value = ::google::protobuf::internal::GetOwnedMessage(message_arena, value, submessage_arena);
+    }
+    _impl_._has_bits_[0] |= 0x00000001u;
+  } else {
+    _impl_._has_bits_[0] &= ~0x00000001u;
+  }
+
+  _impl_.page_ = reinterpret_cast<::mint::tulip::v1::SearchRequest_Page*>(value);
+  // @@protoc_insertion_point(field_set_allocated:mint.tulip.v1.SearchResponse.page)
+}
+
+// int32 total = 99;
+inline void SearchResponse::clear_total() {
+  _impl_.total_ = 0;
+}
+inline ::int32_t SearchResponse::total() const {
+  // @@protoc_insertion_point(field_get:mint.tulip.v1.SearchResponse.total)
+  return _internal_total();
+}
+inline void SearchResponse::set_total(::int32_t value) {
+  _internal_set_total(value);
+  // @@protoc_insertion_point(field_set:mint.tulip.v1.SearchResponse.total)
+}
+inline ::int32_t SearchResponse::_internal_total() const {
+  PROTOBUF_TSAN_READ(&_impl_._tsan_detect_race);
+  return _impl_.total_;
+}
+inline void SearchResponse::_internal_set_total(::int32_t value) {
+  PROTOBUF_TSAN_WRITE(&_impl_._tsan_detect_race);
+  ;
+  _impl_.total_ = value;
+}
+
+#ifdef __GNUC__
+#pragma GCC diagnostic pop
+#endif  // __GNUC__
+
+// @@protoc_insertion_point(namespace_scope)
+}  // namespace v1
+}  // namespace tulip
+}  // namespace mint
+
+
+// @@protoc_insertion_point(global_scope)
+
+#include "google/protobuf/port_undef.inc"
+
+#endif  // GOOGLE_PROTOBUF_INCLUDED_tulip_2eproto_2epb_2eh

+ 1159 - 0
rpc/sdk/csharp/Tulip.cs

@@ -0,0 +1,1159 @@
+// <auto-generated>
+//     Generated by the protocol buffer compiler.  DO NOT EDIT!
+//     source: tulip.proto
+// </auto-generated>
+#pragma warning disable 1591, 0612, 3021, 8981
+#region Designer generated code
+
+using pb = global::Google.Protobuf;
+using pbc = global::Google.Protobuf.Collections;
+using pbr = global::Google.Protobuf.Reflection;
+using scg = global::System.Collections.Generic;
+namespace Mint.Tulip.V1 {
+
+  /// <summary>Holder for reflection information generated from tulip.proto</summary>
+  public static partial class TulipReflection {
+
+    #region Descriptor
+    /// <summary>File descriptor for tulip.proto</summary>
+    public static pbr::FileDescriptor Descriptor {
+      get { return descriptor; }
+    }
+    private static pbr::FileDescriptor descriptor;
+
+    static TulipReflection() {
+      byte[] descriptorData = global::System.Convert.FromBase64String(
+          string.Concat(
+            "Cgt0dWxpcC5wcm90bxINbWludC50dWxpcC52MSKTAQoNU2VhcmNoUmVxdWVz",
+            "dBIQCghrZXl3b3JkcxgBIAMoCRIMCgRib29rGAIgASgFEjQKBHBhZ2UYYyAB",
+            "KAsyIS5taW50LnR1bGlwLnYxLlNlYXJjaFJlcXVlc3QuUGFnZUgAiAEBGiMK",
+            "BFBhZ2USDQoFaW5kZXgYASABKAUSDAoEc2l6ZRgCIAEoBUIHCgVfcGFnZSLe",
+            "AQoOU2VhcmNoUmVzcG9uc2USMQoFaXRlbXMYASADKAsyIi5taW50LnR1bGlw",
+            "LnYxLlNlYXJjaFJlc3BvbnNlLkl0ZW0SLwoEcGFnZRhiIAEoCzIhLm1pbnQu",
+            "dHVsaXAudjEuU2VhcmNoUmVxdWVzdC5QYWdlEg0KBXRvdGFsGGMgASgFGlkK",
+            "BEl0ZW0SDAoEcmFuaxgBIAEoBRIRCgloaWdobGlnaHQYAiABKAkSDAoEYm9v",
+            "axgDIAEoBRIRCglwYXJhZ3JhcGgYBCABKAUSDwoHY29udGVudBgFIAEoCTJP",
+            "CgZTZWFyY2gSRQoEUGFsaRIcLm1pbnQudHVsaXAudjEuU2VhcmNoUmVxdWVz",
+            "dBodLm1pbnQudHVsaXAudjEuU2VhcmNoUmVzcG9uc2UiAEIyCi5jb20uZ2l0",
+            "aHViLmlhcHRfcGxhdGZvcm0ubWludC5wbHVnaW5zLnR1bGlwLnYxUAFiBnBy",
+            "b3RvMw=="));
+      descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
+          new pbr::FileDescriptor[] { },
+          new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] {
+            new pbr::GeneratedClrTypeInfo(typeof(global::Mint.Tulip.V1.SearchRequest), global::Mint.Tulip.V1.SearchRequest.Parser, new[]{ "Keywords", "Book", "Page" }, new[]{ "Page" }, null, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(typeof(global::Mint.Tulip.V1.SearchRequest.Types.Page), global::Mint.Tulip.V1.SearchRequest.Types.Page.Parser, new[]{ "Index", "Size" }, null, null, null, null)}),
+            new pbr::GeneratedClrTypeInfo(typeof(global::Mint.Tulip.V1.SearchResponse), global::Mint.Tulip.V1.SearchResponse.Parser, new[]{ "Items", "Page", "Total" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(typeof(global::Mint.Tulip.V1.SearchResponse.Types.Item), global::Mint.Tulip.V1.SearchResponse.Types.Item.Parser, new[]{ "Rank", "Highlight", "Book", "Paragraph", "Content" }, null, null, null, null)})
+          }));
+    }
+    #endregion
+
+  }
+  #region Messages
+  /// <summary>
+  /// ----------------------------------------------------------------------------
+  /// </summary>
+  public sealed partial class SearchRequest : pb::IMessage<SearchRequest>
+  #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+      , pb::IBufferMessage
+  #endif
+  {
+    private static readonly pb::MessageParser<SearchRequest> _parser = new pb::MessageParser<SearchRequest>(() => new SearchRequest());
+    private pb::UnknownFieldSet _unknownFields;
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public static pb::MessageParser<SearchRequest> Parser { get { return _parser; } }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public static pbr::MessageDescriptor Descriptor {
+      get { return global::Mint.Tulip.V1.TulipReflection.Descriptor.MessageTypes[0]; }
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    pbr::MessageDescriptor pb::IMessage.Descriptor {
+      get { return Descriptor; }
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public SearchRequest() {
+      OnConstruction();
+    }
+
+    partial void OnConstruction();
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public SearchRequest(SearchRequest other) : this() {
+      keywords_ = other.keywords_.Clone();
+      book_ = other.book_;
+      page_ = other.page_ != null ? other.page_.Clone() : null;
+      _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public SearchRequest Clone() {
+      return new SearchRequest(this);
+    }
+
+    /// <summary>Field number for the "keywords" field.</summary>
+    public const int KeywordsFieldNumber = 1;
+    private static readonly pb::FieldCodec<string> _repeated_keywords_codec
+        = pb::FieldCodec.ForString(10);
+    private readonly pbc::RepeatedField<string> keywords_ = new pbc::RepeatedField<string>();
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public pbc::RepeatedField<string> Keywords {
+      get { return keywords_; }
+    }
+
+    /// <summary>Field number for the "book" field.</summary>
+    public const int BookFieldNumber = 2;
+    private int book_;
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public int Book {
+      get { return book_; }
+      set {
+        book_ = value;
+      }
+    }
+
+    /// <summary>Field number for the "page" field.</summary>
+    public const int PageFieldNumber = 99;
+    private global::Mint.Tulip.V1.SearchRequest.Types.Page page_;
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public global::Mint.Tulip.V1.SearchRequest.Types.Page Page {
+      get { return page_; }
+      set {
+        page_ = value;
+      }
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public override bool Equals(object other) {
+      return Equals(other as SearchRequest);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public bool Equals(SearchRequest other) {
+      if (ReferenceEquals(other, null)) {
+        return false;
+      }
+      if (ReferenceEquals(other, this)) {
+        return true;
+      }
+      if(!keywords_.Equals(other.keywords_)) return false;
+      if (Book != other.Book) return false;
+      if (!object.Equals(Page, other.Page)) return false;
+      return Equals(_unknownFields, other._unknownFields);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public override int GetHashCode() {
+      int hash = 1;
+      hash ^= keywords_.GetHashCode();
+      if (Book != 0) hash ^= Book.GetHashCode();
+      if (page_ != null) hash ^= Page.GetHashCode();
+      if (_unknownFields != null) {
+        hash ^= _unknownFields.GetHashCode();
+      }
+      return hash;
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public override string ToString() {
+      return pb::JsonFormatter.ToDiagnosticString(this);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public void WriteTo(pb::CodedOutputStream output) {
+    #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+      output.WriteRawMessage(this);
+    #else
+      keywords_.WriteTo(output, _repeated_keywords_codec);
+      if (Book != 0) {
+        output.WriteRawTag(16);
+        output.WriteInt32(Book);
+      }
+      if (page_ != null) {
+        output.WriteRawTag(154, 6);
+        output.WriteMessage(Page);
+      }
+      if (_unknownFields != null) {
+        _unknownFields.WriteTo(output);
+      }
+    #endif
+    }
+
+    #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+      keywords_.WriteTo(ref output, _repeated_keywords_codec);
+      if (Book != 0) {
+        output.WriteRawTag(16);
+        output.WriteInt32(Book);
+      }
+      if (page_ != null) {
+        output.WriteRawTag(154, 6);
+        output.WriteMessage(Page);
+      }
+      if (_unknownFields != null) {
+        _unknownFields.WriteTo(ref output);
+      }
+    }
+    #endif
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public int CalculateSize() {
+      int size = 0;
+      size += keywords_.CalculateSize(_repeated_keywords_codec);
+      if (Book != 0) {
+        size += 1 + pb::CodedOutputStream.ComputeInt32Size(Book);
+      }
+      if (page_ != null) {
+        size += 2 + pb::CodedOutputStream.ComputeMessageSize(Page);
+      }
+      if (_unknownFields != null) {
+        size += _unknownFields.CalculateSize();
+      }
+      return size;
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public void MergeFrom(SearchRequest other) {
+      if (other == null) {
+        return;
+      }
+      keywords_.Add(other.keywords_);
+      if (other.Book != 0) {
+        Book = other.Book;
+      }
+      if (other.page_ != null) {
+        if (page_ == null) {
+          Page = new global::Mint.Tulip.V1.SearchRequest.Types.Page();
+        }
+        Page.MergeFrom(other.Page);
+      }
+      _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public void MergeFrom(pb::CodedInputStream input) {
+    #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+      input.ReadRawMessage(this);
+    #else
+      uint tag;
+      while ((tag = input.ReadTag()) != 0) {
+        switch(tag) {
+          default:
+            _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+            break;
+          case 10: {
+            keywords_.AddEntriesFrom(input, _repeated_keywords_codec);
+            break;
+          }
+          case 16: {
+            Book = input.ReadInt32();
+            break;
+          }
+          case 794: {
+            if (page_ == null) {
+              Page = new global::Mint.Tulip.V1.SearchRequest.Types.Page();
+            }
+            input.ReadMessage(Page);
+            break;
+          }
+        }
+      }
+    #endif
+    }
+
+    #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+      uint tag;
+      while ((tag = input.ReadTag()) != 0) {
+        switch(tag) {
+          default:
+            _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+            break;
+          case 10: {
+            keywords_.AddEntriesFrom(ref input, _repeated_keywords_codec);
+            break;
+          }
+          case 16: {
+            Book = input.ReadInt32();
+            break;
+          }
+          case 794: {
+            if (page_ == null) {
+              Page = new global::Mint.Tulip.V1.SearchRequest.Types.Page();
+            }
+            input.ReadMessage(Page);
+            break;
+          }
+        }
+      }
+    }
+    #endif
+
+    #region Nested types
+    /// <summary>Container for nested types declared in the SearchRequest message type.</summary>
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public static partial class Types {
+      public sealed partial class Page : pb::IMessage<Page>
+      #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+          , pb::IBufferMessage
+      #endif
+      {
+        private static readonly pb::MessageParser<Page> _parser = new pb::MessageParser<Page>(() => new Page());
+        private pb::UnknownFieldSet _unknownFields;
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public static pb::MessageParser<Page> Parser { get { return _parser; } }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public static pbr::MessageDescriptor Descriptor {
+          get { return global::Mint.Tulip.V1.SearchRequest.Descriptor.NestedTypes[0]; }
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        pbr::MessageDescriptor pb::IMessage.Descriptor {
+          get { return Descriptor; }
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public Page() {
+          OnConstruction();
+        }
+
+        partial void OnConstruction();
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public Page(Page other) : this() {
+          index_ = other.index_;
+          size_ = other.size_;
+          _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public Page Clone() {
+          return new Page(this);
+        }
+
+        /// <summary>Field number for the "index" field.</summary>
+        public const int IndexFieldNumber = 1;
+        private int index_;
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public int Index {
+          get { return index_; }
+          set {
+            index_ = value;
+          }
+        }
+
+        /// <summary>Field number for the "size" field.</summary>
+        public const int SizeFieldNumber = 2;
+        private int size_;
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public int Size {
+          get { return size_; }
+          set {
+            size_ = value;
+          }
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public override bool Equals(object other) {
+          return Equals(other as Page);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public bool Equals(Page other) {
+          if (ReferenceEquals(other, null)) {
+            return false;
+          }
+          if (ReferenceEquals(other, this)) {
+            return true;
+          }
+          if (Index != other.Index) return false;
+          if (Size != other.Size) return false;
+          return Equals(_unknownFields, other._unknownFields);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public override int GetHashCode() {
+          int hash = 1;
+          if (Index != 0) hash ^= Index.GetHashCode();
+          if (Size != 0) hash ^= Size.GetHashCode();
+          if (_unknownFields != null) {
+            hash ^= _unknownFields.GetHashCode();
+          }
+          return hash;
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public override string ToString() {
+          return pb::JsonFormatter.ToDiagnosticString(this);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public void WriteTo(pb::CodedOutputStream output) {
+        #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+          output.WriteRawMessage(this);
+        #else
+          if (Index != 0) {
+            output.WriteRawTag(8);
+            output.WriteInt32(Index);
+          }
+          if (Size != 0) {
+            output.WriteRawTag(16);
+            output.WriteInt32(Size);
+          }
+          if (_unknownFields != null) {
+            _unknownFields.WriteTo(output);
+          }
+        #endif
+        }
+
+        #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+          if (Index != 0) {
+            output.WriteRawTag(8);
+            output.WriteInt32(Index);
+          }
+          if (Size != 0) {
+            output.WriteRawTag(16);
+            output.WriteInt32(Size);
+          }
+          if (_unknownFields != null) {
+            _unknownFields.WriteTo(ref output);
+          }
+        }
+        #endif
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public int CalculateSize() {
+          int size = 0;
+          if (Index != 0) {
+            size += 1 + pb::CodedOutputStream.ComputeInt32Size(Index);
+          }
+          if (Size != 0) {
+            size += 1 + pb::CodedOutputStream.ComputeInt32Size(Size);
+          }
+          if (_unknownFields != null) {
+            size += _unknownFields.CalculateSize();
+          }
+          return size;
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public void MergeFrom(Page other) {
+          if (other == null) {
+            return;
+          }
+          if (other.Index != 0) {
+            Index = other.Index;
+          }
+          if (other.Size != 0) {
+            Size = other.Size;
+          }
+          _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public void MergeFrom(pb::CodedInputStream input) {
+        #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+          input.ReadRawMessage(this);
+        #else
+          uint tag;
+          while ((tag = input.ReadTag()) != 0) {
+            switch(tag) {
+              default:
+                _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+                break;
+              case 8: {
+                Index = input.ReadInt32();
+                break;
+              }
+              case 16: {
+                Size = input.ReadInt32();
+                break;
+              }
+            }
+          }
+        #endif
+        }
+
+        #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+          uint tag;
+          while ((tag = input.ReadTag()) != 0) {
+            switch(tag) {
+              default:
+                _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+                break;
+              case 8: {
+                Index = input.ReadInt32();
+                break;
+              }
+              case 16: {
+                Size = input.ReadInt32();
+                break;
+              }
+            }
+          }
+        }
+        #endif
+
+      }
+
+    }
+    #endregion
+
+  }
+
+  public sealed partial class SearchResponse : pb::IMessage<SearchResponse>
+  #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+      , pb::IBufferMessage
+  #endif
+  {
+    private static readonly pb::MessageParser<SearchResponse> _parser = new pb::MessageParser<SearchResponse>(() => new SearchResponse());
+    private pb::UnknownFieldSet _unknownFields;
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public static pb::MessageParser<SearchResponse> Parser { get { return _parser; } }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public static pbr::MessageDescriptor Descriptor {
+      get { return global::Mint.Tulip.V1.TulipReflection.Descriptor.MessageTypes[1]; }
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    pbr::MessageDescriptor pb::IMessage.Descriptor {
+      get { return Descriptor; }
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public SearchResponse() {
+      OnConstruction();
+    }
+
+    partial void OnConstruction();
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public SearchResponse(SearchResponse other) : this() {
+      items_ = other.items_.Clone();
+      page_ = other.page_ != null ? other.page_.Clone() : null;
+      total_ = other.total_;
+      _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public SearchResponse Clone() {
+      return new SearchResponse(this);
+    }
+
+    /// <summary>Field number for the "items" field.</summary>
+    public const int ItemsFieldNumber = 1;
+    private static readonly pb::FieldCodec<global::Mint.Tulip.V1.SearchResponse.Types.Item> _repeated_items_codec
+        = pb::FieldCodec.ForMessage(10, global::Mint.Tulip.V1.SearchResponse.Types.Item.Parser);
+    private readonly pbc::RepeatedField<global::Mint.Tulip.V1.SearchResponse.Types.Item> items_ = new pbc::RepeatedField<global::Mint.Tulip.V1.SearchResponse.Types.Item>();
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public pbc::RepeatedField<global::Mint.Tulip.V1.SearchResponse.Types.Item> Items {
+      get { return items_; }
+    }
+
+    /// <summary>Field number for the "page" field.</summary>
+    public const int PageFieldNumber = 98;
+    private global::Mint.Tulip.V1.SearchRequest.Types.Page page_;
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public global::Mint.Tulip.V1.SearchRequest.Types.Page Page {
+      get { return page_; }
+      set {
+        page_ = value;
+      }
+    }
+
+    /// <summary>Field number for the "total" field.</summary>
+    public const int TotalFieldNumber = 99;
+    private int total_;
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public int Total {
+      get { return total_; }
+      set {
+        total_ = value;
+      }
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public override bool Equals(object other) {
+      return Equals(other as SearchResponse);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public bool Equals(SearchResponse other) {
+      if (ReferenceEquals(other, null)) {
+        return false;
+      }
+      if (ReferenceEquals(other, this)) {
+        return true;
+      }
+      if(!items_.Equals(other.items_)) return false;
+      if (!object.Equals(Page, other.Page)) return false;
+      if (Total != other.Total) return false;
+      return Equals(_unknownFields, other._unknownFields);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public override int GetHashCode() {
+      int hash = 1;
+      hash ^= items_.GetHashCode();
+      if (page_ != null) hash ^= Page.GetHashCode();
+      if (Total != 0) hash ^= Total.GetHashCode();
+      if (_unknownFields != null) {
+        hash ^= _unknownFields.GetHashCode();
+      }
+      return hash;
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public override string ToString() {
+      return pb::JsonFormatter.ToDiagnosticString(this);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public void WriteTo(pb::CodedOutputStream output) {
+    #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+      output.WriteRawMessage(this);
+    #else
+      items_.WriteTo(output, _repeated_items_codec);
+      if (page_ != null) {
+        output.WriteRawTag(146, 6);
+        output.WriteMessage(Page);
+      }
+      if (Total != 0) {
+        output.WriteRawTag(152, 6);
+        output.WriteInt32(Total);
+      }
+      if (_unknownFields != null) {
+        _unknownFields.WriteTo(output);
+      }
+    #endif
+    }
+
+    #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+      items_.WriteTo(ref output, _repeated_items_codec);
+      if (page_ != null) {
+        output.WriteRawTag(146, 6);
+        output.WriteMessage(Page);
+      }
+      if (Total != 0) {
+        output.WriteRawTag(152, 6);
+        output.WriteInt32(Total);
+      }
+      if (_unknownFields != null) {
+        _unknownFields.WriteTo(ref output);
+      }
+    }
+    #endif
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public int CalculateSize() {
+      int size = 0;
+      size += items_.CalculateSize(_repeated_items_codec);
+      if (page_ != null) {
+        size += 2 + pb::CodedOutputStream.ComputeMessageSize(Page);
+      }
+      if (Total != 0) {
+        size += 2 + pb::CodedOutputStream.ComputeInt32Size(Total);
+      }
+      if (_unknownFields != null) {
+        size += _unknownFields.CalculateSize();
+      }
+      return size;
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public void MergeFrom(SearchResponse other) {
+      if (other == null) {
+        return;
+      }
+      items_.Add(other.items_);
+      if (other.page_ != null) {
+        if (page_ == null) {
+          Page = new global::Mint.Tulip.V1.SearchRequest.Types.Page();
+        }
+        Page.MergeFrom(other.Page);
+      }
+      if (other.Total != 0) {
+        Total = other.Total;
+      }
+      _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+    }
+
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public void MergeFrom(pb::CodedInputStream input) {
+    #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+      input.ReadRawMessage(this);
+    #else
+      uint tag;
+      while ((tag = input.ReadTag()) != 0) {
+        switch(tag) {
+          default:
+            _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+            break;
+          case 10: {
+            items_.AddEntriesFrom(input, _repeated_items_codec);
+            break;
+          }
+          case 786: {
+            if (page_ == null) {
+              Page = new global::Mint.Tulip.V1.SearchRequest.Types.Page();
+            }
+            input.ReadMessage(Page);
+            break;
+          }
+          case 792: {
+            Total = input.ReadInt32();
+            break;
+          }
+        }
+      }
+    #endif
+    }
+
+    #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+      uint tag;
+      while ((tag = input.ReadTag()) != 0) {
+        switch(tag) {
+          default:
+            _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+            break;
+          case 10: {
+            items_.AddEntriesFrom(ref input, _repeated_items_codec);
+            break;
+          }
+          case 786: {
+            if (page_ == null) {
+              Page = new global::Mint.Tulip.V1.SearchRequest.Types.Page();
+            }
+            input.ReadMessage(Page);
+            break;
+          }
+          case 792: {
+            Total = input.ReadInt32();
+            break;
+          }
+        }
+      }
+    }
+    #endif
+
+    #region Nested types
+    /// <summary>Container for nested types declared in the SearchResponse message type.</summary>
+    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+    public static partial class Types {
+      public sealed partial class Item : pb::IMessage<Item>
+      #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+          , pb::IBufferMessage
+      #endif
+      {
+        private static readonly pb::MessageParser<Item> _parser = new pb::MessageParser<Item>(() => new Item());
+        private pb::UnknownFieldSet _unknownFields;
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public static pb::MessageParser<Item> Parser { get { return _parser; } }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public static pbr::MessageDescriptor Descriptor {
+          get { return global::Mint.Tulip.V1.SearchResponse.Descriptor.NestedTypes[0]; }
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        pbr::MessageDescriptor pb::IMessage.Descriptor {
+          get { return Descriptor; }
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public Item() {
+          OnConstruction();
+        }
+
+        partial void OnConstruction();
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public Item(Item other) : this() {
+          rank_ = other.rank_;
+          highlight_ = other.highlight_;
+          book_ = other.book_;
+          paragraph_ = other.paragraph_;
+          content_ = other.content_;
+          _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public Item Clone() {
+          return new Item(this);
+        }
+
+        /// <summary>Field number for the "rank" field.</summary>
+        public const int RankFieldNumber = 1;
+        private int rank_;
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public int Rank {
+          get { return rank_; }
+          set {
+            rank_ = value;
+          }
+        }
+
+        /// <summary>Field number for the "highlight" field.</summary>
+        public const int HighlightFieldNumber = 2;
+        private string highlight_ = "";
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public string Highlight {
+          get { return highlight_; }
+          set {
+            highlight_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+          }
+        }
+
+        /// <summary>Field number for the "book" field.</summary>
+        public const int BookFieldNumber = 3;
+        private int book_;
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public int Book {
+          get { return book_; }
+          set {
+            book_ = value;
+          }
+        }
+
+        /// <summary>Field number for the "paragraph" field.</summary>
+        public const int ParagraphFieldNumber = 4;
+        private int paragraph_;
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public int Paragraph {
+          get { return paragraph_; }
+          set {
+            paragraph_ = value;
+          }
+        }
+
+        /// <summary>Field number for the "content" field.</summary>
+        public const int ContentFieldNumber = 5;
+        private string content_ = "";
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public string Content {
+          get { return content_; }
+          set {
+            content_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
+          }
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public override bool Equals(object other) {
+          return Equals(other as Item);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public bool Equals(Item other) {
+          if (ReferenceEquals(other, null)) {
+            return false;
+          }
+          if (ReferenceEquals(other, this)) {
+            return true;
+          }
+          if (Rank != other.Rank) return false;
+          if (Highlight != other.Highlight) return false;
+          if (Book != other.Book) return false;
+          if (Paragraph != other.Paragraph) return false;
+          if (Content != other.Content) return false;
+          return Equals(_unknownFields, other._unknownFields);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public override int GetHashCode() {
+          int hash = 1;
+          if (Rank != 0) hash ^= Rank.GetHashCode();
+          if (Highlight.Length != 0) hash ^= Highlight.GetHashCode();
+          if (Book != 0) hash ^= Book.GetHashCode();
+          if (Paragraph != 0) hash ^= Paragraph.GetHashCode();
+          if (Content.Length != 0) hash ^= Content.GetHashCode();
+          if (_unknownFields != null) {
+            hash ^= _unknownFields.GetHashCode();
+          }
+          return hash;
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public override string ToString() {
+          return pb::JsonFormatter.ToDiagnosticString(this);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public void WriteTo(pb::CodedOutputStream output) {
+        #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+          output.WriteRawMessage(this);
+        #else
+          if (Rank != 0) {
+            output.WriteRawTag(8);
+            output.WriteInt32(Rank);
+          }
+          if (Highlight.Length != 0) {
+            output.WriteRawTag(18);
+            output.WriteString(Highlight);
+          }
+          if (Book != 0) {
+            output.WriteRawTag(24);
+            output.WriteInt32(Book);
+          }
+          if (Paragraph != 0) {
+            output.WriteRawTag(32);
+            output.WriteInt32(Paragraph);
+          }
+          if (Content.Length != 0) {
+            output.WriteRawTag(42);
+            output.WriteString(Content);
+          }
+          if (_unknownFields != null) {
+            _unknownFields.WriteTo(output);
+          }
+        #endif
+        }
+
+        #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
+          if (Rank != 0) {
+            output.WriteRawTag(8);
+            output.WriteInt32(Rank);
+          }
+          if (Highlight.Length != 0) {
+            output.WriteRawTag(18);
+            output.WriteString(Highlight);
+          }
+          if (Book != 0) {
+            output.WriteRawTag(24);
+            output.WriteInt32(Book);
+          }
+          if (Paragraph != 0) {
+            output.WriteRawTag(32);
+            output.WriteInt32(Paragraph);
+          }
+          if (Content.Length != 0) {
+            output.WriteRawTag(42);
+            output.WriteString(Content);
+          }
+          if (_unknownFields != null) {
+            _unknownFields.WriteTo(ref output);
+          }
+        }
+        #endif
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public int CalculateSize() {
+          int size = 0;
+          if (Rank != 0) {
+            size += 1 + pb::CodedOutputStream.ComputeInt32Size(Rank);
+          }
+          if (Highlight.Length != 0) {
+            size += 1 + pb::CodedOutputStream.ComputeStringSize(Highlight);
+          }
+          if (Book != 0) {
+            size += 1 + pb::CodedOutputStream.ComputeInt32Size(Book);
+          }
+          if (Paragraph != 0) {
+            size += 1 + pb::CodedOutputStream.ComputeInt32Size(Paragraph);
+          }
+          if (Content.Length != 0) {
+            size += 1 + pb::CodedOutputStream.ComputeStringSize(Content);
+          }
+          if (_unknownFields != null) {
+            size += _unknownFields.CalculateSize();
+          }
+          return size;
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public void MergeFrom(Item other) {
+          if (other == null) {
+            return;
+          }
+          if (other.Rank != 0) {
+            Rank = other.Rank;
+          }
+          if (other.Highlight.Length != 0) {
+            Highlight = other.Highlight;
+          }
+          if (other.Book != 0) {
+            Book = other.Book;
+          }
+          if (other.Paragraph != 0) {
+            Paragraph = other.Paragraph;
+          }
+          if (other.Content.Length != 0) {
+            Content = other.Content;
+          }
+          _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
+        }
+
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        public void MergeFrom(pb::CodedInputStream input) {
+        #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+          input.ReadRawMessage(this);
+        #else
+          uint tag;
+          while ((tag = input.ReadTag()) != 0) {
+            switch(tag) {
+              default:
+                _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
+                break;
+              case 8: {
+                Rank = input.ReadInt32();
+                break;
+              }
+              case 18: {
+                Highlight = input.ReadString();
+                break;
+              }
+              case 24: {
+                Book = input.ReadInt32();
+                break;
+              }
+              case 32: {
+                Paragraph = input.ReadInt32();
+                break;
+              }
+              case 42: {
+                Content = input.ReadString();
+                break;
+              }
+            }
+          }
+        #endif
+        }
+
+        #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
+        [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
+        void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) {
+          uint tag;
+          while ((tag = input.ReadTag()) != 0) {
+            switch(tag) {
+              default:
+                _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
+                break;
+              case 8: {
+                Rank = input.ReadInt32();
+                break;
+              }
+              case 18: {
+                Highlight = input.ReadString();
+                break;
+              }
+              case 24: {
+                Book = input.ReadInt32();
+                break;
+              }
+              case 32: {
+                Paragraph = input.ReadInt32();
+                break;
+              }
+              case 42: {
+                Content = input.ReadString();
+                break;
+              }
+            }
+          }
+        }
+        #endif
+
+      }
+
+    }
+    #endregion
+
+  }
+
+  #endregion
+
+}
+
+#endregion Designer generated code

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff