visuddhinanda 1 месяц назад
Родитель
Сommit
80d684a8d2
100 измененных файлов с 12793 добавлено и 111 удалено
  1. 1 1
      api-v12/app/Http/Controllers/PaliBookCategoryController.php
  2. 1 1
      dashboard-v4/dashboard/src/components/nut/users/SignIn.tsx
  3. 4 2
      dashboard-v6/backup/components/article/ArticleView.tsx
  4. 1 1
      dashboard-v6/backup/components/article/Navigate.tsx
  5. 1 1
      dashboard-v6/backup/components/article/NavigateButton.tsx
  6. 1 1
      dashboard-v6/backup/components/article/TypeArticleReader.tsx
  7. 1 1
      dashboard-v6/backup/components/article/TypePage.tsx
  8. 1 1
      dashboard-v6/backup/components/article/TypePali.tsx
  9. 1 1
      dashboard-v6/backup/components/channel/Channel.tsx
  10. 2 2
      dashboard-v6/backup/components/channel/ChannelAlert.tsx
  11. 3 3
      dashboard-v6/backup/components/channel/ChannelCreate.tsx
  12. 2 2
      dashboard-v6/backup/components/channel/ChannelListItem.tsx
  13. 9 6
      dashboard-v6/backup/components/channel/ChannelPickerTable.tsx
  14. 6 5
      dashboard-v6/backup/components/channel/ChannelSelect.tsx
  15. 4 4
      dashboard-v6/backup/components/channel/ChannelSelectWithToken.tsx
  16. 12 9
      dashboard-v6/backup/components/channel/ChannelSentDiff.tsx
  17. 11 9
      dashboard-v6/backup/components/channel/ChannelTable.tsx
  18. 3 3
      dashboard-v6/backup/components/channel/ChannelTableModal.tsx
  19. 1 1
      dashboard-v6/backup/components/channel/ProgressSvg.tsx
  20. 2 2
      dashboard-v6/backup/components/channel/StudioSelect.tsx
  21. 3 1
      dashboard-v6/backup/components/corpus/RelatedPara.tsx
  22. 4 2
      dashboard-v6/backup/components/fts/FullTextSearchResult.tsx
  23. 2 2
      dashboard-v6/backup/components/nut/users/ForgotPassword.tsx
  24. 2 2
      dashboard-v6/backup/components/nut/users/SignIn.tsx
  25. 1 1
      dashboard-v6/backup/components/tag/TagsManager.tsx
  26. 1 21
      dashboard-v6/backup/components/template/SentEdit.tsx
  27. 6 4
      dashboard-v6/backup/components/template/SentEdit/SentTab.tsx
  28. 1 1
      dashboard-v6/backup/components/template/WbwSent.tsx
  29. 1 0
      dashboard-v6/backup/components/template/utilities.ts
  30. 82 3
      dashboard-v6/src/Router.tsx
  31. 19 1
      dashboard-v6/src/api/Channel.ts
  32. 18 0
      dashboard-v6/src/api/fts.ts
  33. 36 0
      dashboard-v6/src/api/recent.ts
  34. 1 0
      dashboard-v6/src/api/search.ts
  35. 145 0
      dashboard-v6/src/assets/icon/index.tsx
  36. 65 0
      dashboard-v6/src/components/article/ArticleListModal.tsx
  37. 1 0
      dashboard-v6/src/components/auth/SignInAvatar.tsx
  38. 38 2
      dashboard-v6/src/components/auth/User.tsx
  39. 7 0
      dashboard-v6/src/components/channel/Channel.tsx
  40. 67 0
      dashboard-v6/src/components/channel/ChannelAlert.tsx
  41. 74 0
      dashboard-v6/src/components/channel/ChannelCreate.tsx
  42. 97 0
      dashboard-v6/src/components/channel/ChannelInfo.tsx
  43. 83 0
      dashboard-v6/src/components/channel/ChannelList.tsx
  44. 23 0
      dashboard-v6/src/components/channel/ChannelListItem.tsx
  45. 558 0
      dashboard-v6/src/components/channel/ChannelMy.tsx
  46. 89 0
      dashboard-v6/src/components/channel/ChannelPicker.tsx
  47. 433 0
      dashboard-v6/src/components/channel/ChannelPickerTable.tsx
  48. 136 0
      dashboard-v6/src/components/channel/ChannelSelect.tsx
  49. 115 0
      dashboard-v6/src/components/channel/ChannelSelectWithToken.tsx
  50. 328 0
      dashboard-v6/src/components/channel/ChannelSentDiff.tsx
  51. 529 0
      dashboard-v6/src/components/channel/ChannelTable.tsx
  52. 96 0
      dashboard-v6/src/components/channel/ChannelTableModal.tsx
  53. 54 0
      dashboard-v6/src/components/channel/ChannelTypeSelect.tsx
  54. 215 0
      dashboard-v6/src/components/channel/ChapterInChannelList.tsx
  55. 77 0
      dashboard-v6/src/components/channel/CopyToModal.tsx
  56. 41 0
      dashboard-v6/src/components/channel/CopyToResult.tsx
  57. 120 0
      dashboard-v6/src/components/channel/CopyToStep.tsx
  58. 134 0
      dashboard-v6/src/components/channel/Edit.tsx
  59. 95 0
      dashboard-v6/src/components/channel/ProgressSvg.tsx
  60. 64 0
      dashboard-v6/src/components/channel/StudioSelect.tsx
  61. 39 0
      dashboard-v6/src/components/channel/utils.ts
  62. 35 0
      dashboard-v6/src/components/general/BeiAn.tsx
  63. 106 0
      dashboard-v6/src/components/general/EditableLabel.tsx
  64. 49 0
      dashboard-v6/src/components/general/ErrorResult.tsx
  65. 21 0
      dashboard-v6/src/components/general/Feedback.tsx
  66. 24 0
      dashboard-v6/src/components/general/FileSize.tsx
  67. 87 0
      dashboard-v6/src/components/general/LangSelect.tsx
  68. 25 0
      dashboard-v6/src/components/general/Marked.tsx
  69. 20 0
      dashboard-v6/src/components/general/Mermaid.tsx
  70. 61 0
      dashboard-v6/src/components/general/NetStatus.tsx
  71. 5972 0
      dashboard-v6/src/components/general/PaliEnding.ts
  72. 109 0
      dashboard-v6/src/components/general/PaliText.tsx
  73. 15 0
      dashboard-v6/src/components/general/ParserError.tsx
  74. 17 0
      dashboard-v6/src/components/general/ReadonlyLabel.tsx
  75. 18 0
      dashboard-v6/src/components/general/SearchButton.tsx
  76. 21 0
      dashboard-v6/src/components/general/StatusBadge.tsx
  77. 241 0
      dashboard-v6/src/components/general/TermTextArea.tsx
  78. 138 0
      dashboard-v6/src/components/general/TermTextAreaMenu.tsx
  79. 47 0
      dashboard-v6/src/components/general/TextDiff.tsx
  80. 143 0
      dashboard-v6/src/components/general/TimeShow.tsx
  81. 57 0
      dashboard-v6/src/components/general/UiLangSelect.tsx
  82. 72 0
      dashboard-v6/src/components/general/style.css
  83. 97 0
      dashboard-v6/src/components/group/AddMember.tsx
  84. 11 0
      dashboard-v6/src/components/group/Group.tsx
  85. 68 0
      dashboard-v6/src/components/group/GroupCreate.tsx
  86. 167 0
      dashboard-v6/src/components/group/GroupFile.tsx
  87. 186 0
      dashboard-v6/src/components/group/GroupMember.tsx
  88. 53 0
      dashboard-v6/src/components/group/GroupSelect.tsx
  89. 29 0
      dashboard-v6/src/components/navigation/HeaderBreadcrumb.tsx
  90. 47 11
      dashboard-v6/src/components/navigation/MainMenu.tsx
  91. 362 0
      dashboard-v6/src/components/nissaya/NissayaAligner.tsx
  92. 60 0
      dashboard-v6/src/components/nissaya/NissayaAlignerModal.tsx
  93. 1 1
      dashboard-v6/src/components/recent/Recent.tsx
  94. 22 0
      dashboard-v6/src/components/sentence/utils.ts
  95. 2 2
      dashboard-v6/src/components/setting/SettingArticle.tsx
  96. 1 1
      dashboard-v6/src/components/setting/default.ts
  97. 223 0
      dashboard-v6/src/components/share/Collaborator.tsx
  98. 124 0
      dashboard-v6/src/components/share/CollaboratorAdd.tsx
  99. 35 0
      dashboard-v6/src/components/share/Share.tsx
  100. 60 0
      dashboard-v6/src/components/share/ShareModal.tsx

+ 1 - 1
api-v12/app/Http/Controllers/PaliBookCategoryController.php

@@ -35,7 +35,7 @@ class PaliBookCategoryController extends Controller
      */
     public function show($file)
     {
-        $data = file_get_contents(public_path("app/palicanon/category/{$file}.json"));
+        $data = file_get_contents(public_path("data/category/{$file}.json"));
         if ($data === false) {
             return $this->error('no file');
         }

+ 1 - 1
dashboard-v4/dashboard/src/components/nut/users/SignIn.tsx

@@ -1,7 +1,7 @@
 import { useIntl } from "react-intl";
 import { ProForm, ProFormText } from "@ant-design/pro-components";
 import { Alert, message } from "antd";
-import { useNavigate, useParams, useSearchParams } from "react-router-dom";
+import { useNavigate, useSearchParams } from "react-router-dom";
 import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
 
 import { useAppDispatch } from "../../../hooks";

+ 4 - 2
dashboard-v6/backup/components/article/ArticleView.tsx

@@ -1,8 +1,10 @@
 import { Typography, Divider, Skeleton, Space } from "antd";
 
 import MdView from "../template/MdView";
-import TocPath, { type ITocPathNode } from "../corpus/TocPath";
-import PaliChapterChannelList from "../corpus/PaliChapterChannelList";
+import TocPath, {
+  type ITocPathNode,
+} from "../../../src/components/tipitaka/TocPath";
+import PaliChapterChannelList from "../../../src/components/tipitaka/PaliChapterChannelList";
 import type { ArticleMode, ArticleType } from "./Article";
 import VisibleObserver from "../general/VisibleObserver";
 import type { IStudio } from "../auth/Studio";

+ 1 - 1
dashboard-v6/backup/components/article/Navigate.tsx

@@ -4,7 +4,7 @@ import { get } from "../../request";
 import type { ArticleType } from "./Article";
 import React from "react";
 import NavigateButton from "./NavigateButton";
-import type { ITocPathNode } from "../corpus/TocPath";
+import type { ITocPathNode } from "../../../src/components/tipitaka/TocPath";
 
 interface INavButton {
   title: string;

+ 1 - 1
dashboard-v6/backup/components/article/NavigateButton.tsx

@@ -2,7 +2,7 @@ import { Button, Dropdown, Modal, Space, Typography } from "antd";
 import { DoubleRightOutlined, DoubleLeftOutlined } from "@ant-design/icons";
 import { FolderOutlined } from "@ant-design/icons";
 
-import type { ITocPathNode } from "../corpus/TocPath"
+import type { ITocPathNode } from "../../../src/components/tipitaka/TocPath";
 
 const { Paragraph, Text } = Typography;
 

+ 1 - 1
dashboard-v6/backup/components/article/TypeArticleReader.tsx

@@ -12,7 +12,7 @@ import type {
 import ArticleView, { type IFirstAnthology } from "./ArticleView";
 import TocTree from "./TocTree";
 import PaliText from "../template/Wbw/PaliText";
-import type { ITocPathNode } from "../corpus/TocPath";
+import type { ITocPathNode } from "../../../src/components/tipitaka/TocPath";
 import type { ArticleMode, ArticleType } from "./Article";
 import "./article.css";
 import ArticleSkeleton from "./ArticleSkeleton";

+ 1 - 1
dashboard-v6/backup/components/article/TypePage.tsx

@@ -157,7 +157,7 @@ const TypePageWidget = ({
 
   return (
     <div>
-      {pageInfo ? <Alert message={pageInfo} type="info" closable /> : undefined}
+      {pageInfo ? <Alert title={pageInfo} type="info" closable /> : undefined}
       {paramPali ? (
         <>
           <TypePali

+ 1 - 1
dashboard-v6/backup/components/article/TypePali.tsx

@@ -12,7 +12,7 @@ import ArticleView from "./ArticleView";
 import TocTree from "./TocTree";
 import PaliText from "../template/Wbw/PaliText";
 import type { IViewRequest, IViewStoreResponse } from "../../api/view";
-import type { ITocPathNode } from "../corpus/TocPath";
+import type { ITocPathNode } from "../../../src/components/tipitaka/TocPath";
 import type { ArticleMode, ArticleType } from "./Article";
 import "./article.css";
 import ArticleSkeleton from "./ArticleSkeleton";

+ 1 - 1
dashboard-v6/backup/components/channel/Channel.tsx

@@ -1,4 +1,4 @@
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../../src/api/Channel";
 
 const ChannelWidget = ({ name }: IChannel) => {
   return <span>{name}</span>;

+ 2 - 2
dashboard-v6/backup/components/channel/ChannelAlert.tsx

@@ -3,13 +3,13 @@ import { useIntl } from "react-intl";
 import { Alert, Button } from "antd";
 
 import ChannelPicker from "./ChannelPicker";
-import type { IChannel } from "./Channel"
 import store from "../../store";
 import { openPanel } from "../../reducers/right-panel";
+import type { IChannel } from "../../api/Channel";
 
 interface IWidget {
   channels?: string | null;
-  onChannelChange?: Function;
+  onChannelChange?: (channels: IChannel[]) => void;
 }
 const ChannelAlertWidget = ({ channels, onChannelChange }: IWidget) => {
   const intl = useIntl();

+ 3 - 3
dashboard-v6/backup/components/channel/ChannelCreate.tsx

@@ -6,10 +6,10 @@ import {
 } from "@ant-design/pro-components";
 import { message } from "antd";
 
-import { post } from "../../request";
-import type { IApiResponseChannel } from "../../api/Channel";
+import { post } from "../../../src/request";
+import type { IApiResponseChannel } from "../../../src/api/Channel";
 import ChannelTypeSelect from "./ChannelTypeSelect";
-import LangSelect from "../general/LangSelect";
+import LangSelect from "../../../src/components/general/LangSelect";
 import { useRef } from "react";
 
 interface IFormData {

+ 2 - 2
dashboard-v6/backup/components/channel/ChannelListItem.tsx

@@ -1,7 +1,7 @@
 import { Space } from "antd";
 
-import type { IChannelApiData } from "../../api/Channel";
-import Studio, { type IStudio } from "../auth/Studio";
+import type { IChannelApiData } from "../../../src/api/Channel";
+import Studio, { type IStudio } from "../../../src/components/auth/Studio";
 
 interface IWidget {
   channel: IChannelApiData;

+ 9 - 6
dashboard-v6/backup/components/channel/ChannelPickerTable.tsx

@@ -13,16 +13,19 @@ import {
 
 import type {
   IApiResponseChannelList,
+  IChannel,
   IFinal,
   TChannelType,
-} from "../../api/Channel";
-import { post } from "../../request";
-import { LockIcon } from "../../assets/icon";
-import Studio, { type IStudio } from "../auth/Studio";
+} from "../../../src/api/Channel";
+import { post } from "../../../src/request";
+import { LockIcon } from "../../../src/assets/icon";
+
 import ProgressSvg from "./ProgressSvg";
-import type { IChannel } from "./Channel";
-import type { ArticleType } from "../article/Article";
+
 import CopyToModal from "./CopyToModal";
+import type { IStudio } from "../../../src/api/Auth";
+import type { ArticleType } from "../../../src/api/Corpus";
+import Studio from "../../../src/components/auth/Studio";
 
 const { Link, Text } = Typography;
 

+ 6 - 5
dashboard-v6/backup/components/channel/ChannelSelect.tsx

@@ -1,12 +1,13 @@
 import { ProFormCascader } from "@ant-design/pro-components";
 import { message } from "antd";
-import { useAppSelector } from "../../hooks";
-import { currentUser } from "../../reducers/current-user";
+import { useAppSelector } from "../../../src/hooks";
+import { currentUser } from "../../../src/reducers/current-user";
+
+import { get } from "../../../src/request";
+import type { IApiResponseChannelList } from "../../../src/api/Channel";
 
-import { get } from "../../request";
-import type { IApiResponseChannelList } from "../../api/Channel";
-import type { IStudio } from "../auth/Studio";
 import { useIntl } from "react-intl";
+import type { IStudio } from "../../../src/api/Auth";
 
 interface IOption {
   value: string;

+ 4 - 4
dashboard-v6/backup/components/channel/ChannelSelectWithToken.tsx

@@ -7,16 +7,16 @@ import {
   WarningTwoTone,
 } from "@ant-design/icons";
 
-import type { TChannelType } from "../../api/Channel";
-import { post } from "../../request";
+import type { IChannel, TChannelType } from "../../../src/api/Channel";
+import { post } from "../../../src/request";
 import ChannelTableModal from "./ChannelTableModal";
-import type { IChannel } from "./Channel";
+
 import type {
   IPayload,
   ITokenCreate,
   ITokenCreateResponse,
   TPower,
-} from "../../api/token";
+} from "../../../src/api/token";
 
 const { Text } = Typography;
 

+ 12 - 9
dashboard-v6/backup/components/channel/ChannelSentDiff.tsx

@@ -2,18 +2,19 @@ import { Button, message, Select, Table, Tooltip, Typography } from "antd";
 import { type Change, diffChars } from "diff";
 import { useEffect, useState } from "react";
 
-import { post } from "../../request";
+import { post } from "../../../src/request";
 import type {
+  ISentence,
   ISentenceDiffData,
   ISentenceDiffRequest,
   ISentenceDiffResponse,
   ISentenceListResponse,
   ISentenceNewRequest,
-} from "../../api/Corpus";
-import type { IChannel } from "./Channel";
-import { type ISentence, toISentence } from "../template/SentEdit";
-import store from "../../store";
-import { accept } from "../../reducers/accept-pr";
+} from "../../../src/api/Corpus";
+
+import store from "../../../src/store";
+import { accept } from "../../../src/reducers/accept-pr";
+import type { IChannel } from "../../../src/api/Channel";
 
 const { Text } = Typography;
 
@@ -209,7 +210,9 @@ const ChannelSentDiffWidget = ({
                     toISentence(item)
                   );
                   store.dispatch(accept(newData));
-                  onSubmit && onSubmit(json.data.count);
+                  if (onSubmit) {
+                    onSubmit(json.data.count);
+                  }
                 } else {
                   message.error(json.message);
                 }
@@ -252,7 +255,7 @@ const ChannelSentDiffWidget = ({
               title: "pali",
               width: "33%",
               dataIndex: "pali",
-              render: (_value, record, _index) => {
+              render: (_value, record) => {
                 return (
                   <Text>
                     <div
@@ -283,7 +286,7 @@ const ChannelSentDiffWidget = ({
               ),
               width: "33%",
               dataIndex: "destContent",
-              render: (_value, record, _index) => {
+              render: (_value, record) => {
                 const diff: Change[] = diffChars(
                   record.destContent ? record.destContent : "",
                   record.srcContent ? record.srcContent : ""

+ 11 - 9
dashboard-v6/backup/components/channel/ChannelTable.tsx

@@ -11,20 +11,23 @@ import {
 } from "@ant-design/icons";
 
 import ChannelCreate from "./ChannelCreate";
-import { delete_, get } from "../../request";
-import type { IApiResponseChannelList, TChannelType } from "../../api/Channel";
+import { delete_, get } from "../../../src/request";
+import type {
+  IApiResponseChannelList,
+  TChannelType,
+} from "../../../src/api/Channel";
 import { PublicityValueEnum } from "../studio/table";
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../../src/api/Article";
 import { useEffect, useRef, useState } from "react";
-import type { TRole } from "../../api/Auth";
+import type { IStudio, TRole } from "../../../src/api/Auth";
 import ShareModal from "../share/ShareModal";
 import { EResType } from "../share/Share";
-import StudioName, { type IStudio } from "../auth/Studio";
+import StudioName from "../../../src/components/auth/Studio";
 import StudioSelect from "./StudioSelect";
-import type { IChannel } from "./Channel";
-import { getSorterUrl } from "../../utils";
+
+import { getSorterUrl } from "../../../src/utils";
 import TransferCreate from "../transfer/TransferCreate";
-import { TransferOutLinedIcon } from "../../assets/icon";
+import { TransferOutLinedIcon } from "../../../src/assets/icon";
 
 const { Text } = Typography;
 
@@ -105,7 +108,6 @@ const ChannelTableWidget = ({
   studioName,
   disableChannels,
   channelType,
-  ___type,
   chapter,
   onSelect,
 }: IWidget) => {

+ 3 - 3
dashboard-v6/backup/components/channel/ChannelTableModal.tsx

@@ -3,10 +3,10 @@ import { Modal } from "antd";
 
 import type { ArticleType } from "../article/Article";
 import ChannelTable, { type IChapter } from "./ChannelTable";
-import { useAppSelector } from "../../hooks";
-import { currentUser as _currentUser } from "../../reducers/current-user";
+import { useAppSelector } from "../../../src/hooks";
+import { currentUser as _currentUser } from "../../../src/reducers/current-user";
 import type { IChannel } from "./Channel";
-import type { TChannelType } from "../../api/Channel";
+import type { TChannelType } from "../../../src/api/Channel";
 import { useIntl } from "react-intl";
 
 interface IWidget {

+ 1 - 1
dashboard-v6/backup/components/channel/ProgressSvg.tsx

@@ -1,4 +1,4 @@
-import type { IFinal } from "../../api/Channel";
+import type { IFinal } from "../../../src/api/Channel";
 
 interface IWidget {
   data?: IFinal[];

+ 2 - 2
dashboard-v6/backup/components/channel/StudioSelect.tsx

@@ -1,7 +1,7 @@
 import { Select } from "antd";
 import { useEffect, useState } from "react";
-import { get } from "../../request";
-import type { IStudio } from "../auth/Studio"
+import { get } from "../../../src/request";
+import type { IStudio } from "../../../src/components/auth/Studio";
 
 interface IStudioListResponse {
   ok: boolean;

+ 3 - 1
dashboard-v6/backup/components/corpus/RelatedPara.tsx

@@ -3,7 +3,9 @@ import { Badge, Card, List, message, Modal, Skeleton } from "antd";
 
 import { get } from "../../request";
 import { useEffect, useState, type JSX } from "react";
-import TocPath, { type ITocPathNode } from "./TocPath";
+import TocPath, {
+  type ITocPathNode,
+} from "../../../src/components/tipitaka/TocPath";
 import store from "../../store";
 import { change } from "../../reducers/para-change";
 

+ 4 - 2
dashboard-v6/backup/components/fts/FullTextSearchResult.tsx

@@ -3,8 +3,10 @@ import { _Button, List, Skeleton, Space, Tag, Typography } from "antd";
 import { Link } from "react-router";
 
 import { _get } from "../../request";
-import TocPath, { type ITocPathNode } from "../corpus/TocPath";
-import type { TContentType } from "../discussion/DiscussionCreate"
+import TocPath, {
+  type ITocPathNode,
+} from "../../../src/components/tipitaka/TocPath";
+import type { TContentType } from "../discussion/DiscussionCreate";
 import Marked from "../general/Marked";
 import PaliText from "../template/Wbw/PaliText";
 import "./search.css";

+ 2 - 2
dashboard-v6/backup/components/nut/users/ForgotPassword.tsx

@@ -1,6 +1,6 @@
 import { useIntl } from "react-intl";
 import { ProForm, ProFormText } from "@ant-design/pro-components";
-import { Alert, type AlertProps } from "antd"
+import { Alert, type AlertProps } from "antd";
 
 import { post } from "../../../request";
 import { useState } from "react";
@@ -30,7 +30,7 @@ const Widget = () => {
   const [type, setType] = useState<AlertProps["type"]>("info");
   return (
     <>
-      {notify ? <Alert message={notify} type={type} showIcon /> : <></>}
+      {notify ? <Alert title={notify} type={type} showIcon /> : <></>}
       <ProForm<IFormData>
         onFinish={async (values: IFormData) => {
           console.debug(values);

+ 2 - 2
dashboard-v6/backup/components/nut/users/SignIn.tsx

@@ -5,7 +5,7 @@ import { useNavigate, _useParams, useSearchParams } from "react-router";
 import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
 
 import { useAppDispatch } from "../../../hooks";
-import { type IUser, signIn, TO_HOME } from "../../../reducers/current-user"
+import { type IUser, signIn, TO_HOME } from "../../../reducers/current-user";
 import { get, post } from "../../../request";
 import { useState } from "react";
 
@@ -36,7 +36,7 @@ const Widget = () => {
 
   return (
     <>
-      {error ? <Alert message={error} type="error" /> : undefined}
+      {error ? <Alert title={error} type="error" /> : undefined}
       <ProForm<IFormData>
         onFinish={async (values: IFormData) => {
           setError(undefined);

+ 1 - 1
dashboard-v6/backup/components/tag/TagsManager.tsx

@@ -47,7 +47,7 @@ const TagsManagerWidget = ({
         destroyOnClose
         footer={false}
       >
-        {title ? <Alert message={title} /> : undefined}
+        {title ? <Alert title={title} /> : undefined}
         <TagsOnItem
           studioName={studioName}
           courseId={courseId}

+ 1 - 21
dashboard-v6/backup/components/template/SentEdit.tsx

@@ -5,7 +5,7 @@ import type { IStudio } from "../auth/Studio";
 import type { IUser } from "../auth/User";
 import type { IChannel } from "../channel/Channel";
 import type { TContentType } from "../discussion/DiscussionCreate";
-import type { ITocPathNode } from "../corpus/TocPath";
+import type { ITocPathNode } from "../../../src/components/tipitaka/TocPath";
 import SentContent from "./SentEdit/SentContent";
 import SentTab from "./SentEdit/SentTab";
 import type { IWbw } from "./Wbw/WbwWord";
@@ -36,26 +36,6 @@ export interface ISentenceId {
   wordEnd: number;
 }
 
-export const toISentence = (apiData: ISentenceData): ISentence => {
-  return {
-    id: apiData.id,
-    content: apiData.content,
-    contentType: apiData.content_type,
-    html: apiData.html,
-    book: apiData.book,
-    para: apiData.paragraph,
-    wordStart: apiData.word_start,
-    wordEnd: apiData.word_end,
-    editor: apiData.editor,
-    studio: apiData.studio,
-    channel: apiData.channel,
-    updateAt: apiData.updated_at,
-    acceptor: apiData.acceptor,
-    prEditAt: apiData.pr_edit_at,
-    forkAt: apiData.fork_at,
-    suggestionCount: apiData.suggestionCount,
-  };
-};
 export interface IWidgetSentEditInner {
   id: string;
   book: number;

+ 6 - 4
dashboard-v6/backup/components/template/SentEdit/SentTab.tsx

@@ -10,12 +10,14 @@ import SentTabButton from "./SentTabButton";
 import SentCanRead from "./SentCanRead";
 import SentSim from "./SentSim";
 import { useIntl } from "react-intl";
-import TocPath, { type ITocPathNode } from "../../corpus/TocPath";
-import type { IWbw } from "../Wbw/WbwWord"
+import TocPath, {
+  type ITocPathNode,
+} from "../../../../src/components/tipitaka/TocPath";
+import type { IWbw } from "../Wbw/WbwWord";
 import RelaGraphic from "../Wbw/RelaGraphic";
 import SentMenu from "./SentMenu";
-import type { ArticleMode } from "../../article/Article"
-import type { IResNumber, ISentence } from "../SentEdit"
+import type { ArticleMode } from "../../article/Article";
+import type { IResNumber, ISentence } from "../SentEdit";
 import SentTabCopy from "./SentTabCopy";
 import { fullUrl } from "../../../utils";
 import SentWbw from "./SentWbw";

+ 1 - 1
dashboard-v6/backup/components/template/WbwSent.tsx

@@ -1187,7 +1187,7 @@ export const WbwSentCtl = memo(
           </Space>
         </div>
 
-        {error && <Alert message={error} />}
+        {error && <Alert title={error} />}
 
         {isProcessing && (
           <div>

+ 1 - 0
dashboard-v6/backup/components/template/utilities.ts

@@ -16,6 +16,7 @@ export type TCodeConvertor =
   | "roman_to_thai"
   | "roman_to_taitham"
   | "roman_to_si";
+
 export function XmlToReact(
   text: string,
   wordWidget: boolean = false,

+ 82 - 3
dashboard-v6/src/Router.tsx

@@ -1,11 +1,25 @@
 import { lazy } from "react";
 import { createBrowserRouter } from "react-router";
 import { RouterProvider } from "react-router/dom";
+import { channelLoader } from "./api/Channel";
 
 const UsersSignIn = lazy(() => import("./pages/users/sign-in"));
+const UsersSignUp = lazy(() => import("./pages/users/sign-up"));
+const UsersForgotPassword = lazy(() => import("./pages/users/forgot-password"));
+const UsersResetPassword = lazy(() => import("./pages/users/reset-password"));
 const UsersPersonal = lazy(() => import("./pages/users/personal"));
 const DashboardIndex = lazy(() => import("./pages/dashboard/index"));
 const Home = lazy(() => import("./pages/home"));
+const WorkspaceChannel = lazy(() => import("./pages/workspace/channel/list"));
+const WorkspaceChannelShow = lazy(
+  () => import("./pages/workspace/channel/show")
+);
+const WorkspaceChannelSetting = lazy(
+  () => import("./pages/workspace/channel/setting")
+);
+const WorkspaceTipitaka = lazy(
+  () => import("./pages/workspace/tipitaka/bypath")
+);
 
 const RootLayout = lazy(() => import("./layouts/Root"));
 const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
@@ -22,7 +36,20 @@ const router = createBrowserRouter(
         {
           path: "anonymous",
           Component: AnonymousLayout,
-          children: [{ path: "sign-in", Component: UsersSignIn }],
+          handle: { crumb: "anonymous" },
+          children: [
+            {
+              path: "sign-in",
+              Component: UsersSignIn,
+              handle: { crumb: "sign-in" },
+            },
+            {
+              path: "sign-up",
+              Component: UsersSignUp,
+              handle: { crumb: "sign-up" },
+            },
+            { path: "forgot-password", Component: UsersForgotPassword },
+          ],
         },
         {
           path: "dashboard",
@@ -31,13 +58,16 @@ const router = createBrowserRouter(
             { index: true, Component: DashboardIndex },
             {
               path: "users",
-              children: [{ path: "personal", Component: UsersPersonal }],
+              children: [
+                { path: "reset-password", Component: UsersResetPassword },
+              ],
             },
           ],
         },
         {
           path: "workspace",
           Component: WorkspaceLayout,
+          handle: { crumb: "workspace" },
           children: [
             { index: true, Component: WorkspaceLayout },
             {
@@ -47,10 +77,59 @@ const router = createBrowserRouter(
             {
               path: "ai",
               Component: UsersPersonal,
+              handle: { crumb: "ai" },
             },
             {
               path: "tipitaka",
-              Component: UsersPersonal,
+              Component: WorkspaceTipitaka,
+              handle: { crumb: "tipitaka" },
+              children: [
+                {
+                  path: ":root",
+                  Component: WorkspaceTipitaka,
+                  children: [
+                    {
+                      path: ":path",
+                      Component: WorkspaceTipitaka,
+                      children: [
+                        {
+                          path: ":tag",
+                          Component: WorkspaceTipitaka,
+                        },
+                      ],
+                    },
+                  ],
+                },
+              ],
+            },
+            {
+              path: "channel",
+              handle: { crumb: "channel" },
+              children: [
+                {
+                  index: true,
+                  Component: WorkspaceChannel,
+                },
+                {
+                  path: ":channelId",
+                  loader: channelLoader,
+                  handle: {
+                    crumb: (match: { data: { name: string } }) =>
+                      match.data.name,
+                  },
+                  children: [
+                    {
+                      index: true,
+                      Component: WorkspaceChannelShow,
+                    },
+                    {
+                      path: "setting",
+                      Component: WorkspaceChannelSetting, // ← 新页面组件
+                      handle: { crumb: "setting" },
+                    },
+                  ],
+                },
+              ],
             },
           ],
         },

+ 19 - 1
dashboard-v6/src/api/Channel.ts

@@ -1,7 +1,9 @@
+import type { LoaderFunctionArgs } from "react-router";
 import type { IStudio, TRole } from "./Auth";
+import { get } from "../request";
 export interface IChannel {
-  name: string;
   id: string;
+  name: string;
   type?: TChannelType;
   lang?: string;
 }
@@ -77,3 +79,19 @@ export interface ISentInChapterListDataRow {
   word_begin: number;
   word_end: number;
 }
+
+export async function channelLoader({ params }: LoaderFunctionArgs) {
+  const channelId = params.channelId;
+
+  if (!channelId) {
+    throw new Response("Missing channelId", { status: 400 });
+  }
+
+  const res = await get<IApiResponseChannel>(`/api/v2/channel/${channelId}`);
+
+  if (!res.ok) {
+    throw new Response("Channel not found", { status: 404 });
+  }
+
+  return res.data;
+}

+ 18 - 0
dashboard-v6/src/api/fts.ts

@@ -0,0 +1,18 @@
+import type { ITag } from "./Tag";
+export interface IFtsData {
+  book: number;
+  paragraph: number;
+  title?: string;
+  paliTitle: string;
+  pcdBookId: number;
+  count: number;
+  tags?: ITag[];
+}
+export interface IFtsResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IFtsData[];
+    count: number;
+  };
+}

+ 36 - 0
dashboard-v6/src/api/recent.ts

@@ -0,0 +1,36 @@
+import type { ArticleType } from "./Corpus";
+
+export interface IRecentRequest {
+  type: ArticleType;
+  article_id: string;
+  param?: string;
+}
+export interface IRecentParam {
+  book?: string;
+  para?: string;
+  channel?: string;
+  mode?: string;
+}
+export interface IRecentData {
+  id: string;
+  title: string;
+  type: ArticleType;
+  article_id: string;
+  param: string | null;
+  updated_at: string;
+}
+
+export interface IRecentResponse {
+  ok: boolean;
+  message: string;
+  data: IRecentData;
+}
+
+export interface IRecent {
+  id: string;
+  title: string;
+  type: ArticleType;
+  articleId: string;
+  updatedAt: string;
+  param?: IRecentParam;
+}

+ 1 - 0
dashboard-v6/src/api/search.ts

@@ -0,0 +1 @@
+export type ISearchView = "pali" | "title" | "page" | "number";

+ 145 - 0
dashboard-v6/src/assets/icon/index.tsx

@@ -898,6 +898,131 @@ const MoreSvg = () => (
     ></path>
   </svg>
 );
+
+const TaskSvg = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="1em"
+    height="1em"
+    fill="none"
+    viewBox="0 0 24 24"
+    stroke="currentColor"
+    stroke-width="1.8"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  >
+    <rect x="2.5" y="2.5" width="19" height="19" rx="2.5" />
+    <path d="M8 2.5h8v4H8z" />
+
+    <path d="M6.5 10h11" />
+    <path d="M6.5 14h7" />
+
+    <path d="M7 18l2.5 2.5 6-6" />
+  </svg>
+);
+
+const ChannelSvg = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="1em"
+    height="1em"
+    fill="none"
+    viewBox="0 0 24 24"
+    stroke="currentColor"
+    stroke-width="1.8"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  >
+    <rect x="3" y="4" width="13" height="17" rx="2" />
+
+    <rect x="8" y="3" width="13" height="17" rx="2" />
+
+    <path d="M11 8h7" />
+    <path d="M11 12h7" />
+
+    <path d="M6 9l-2 2 2 2" />
+    <path d="M4 11h4" />
+
+    <path d="M6 15l-2 2 2 2" />
+    <path d="M4 17h4" />
+  </svg>
+);
+
+const DocumentSvg = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="1em"
+    height="1em"
+    fill="none"
+    viewBox="0 0 24 24"
+    stroke="currentColor"
+    stroke-width="1.8"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  >
+    <path d="M6 2.5h8l4 4v15a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-17a2 2 0 0 1 2-2z" />
+
+    <path d="M14 2.5v4h4" />
+
+    <path d="M8 11h8" />
+    <path d="M8 15h8" />
+    <path d="M8 19h5" />
+  </svg>
+);
+
+const TipitakaSvg = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="1em"
+    height="1em"
+    fill="none"
+    viewBox="0 0 24 24"
+    stroke="currentColor"
+    stroke-width="1.8"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  >
+    <circle cx="12" cy="12" r="9" />
+
+    <circle cx="12" cy="12" r="2" />
+    <path d="M12 5.5v4.5" />
+    <path d="M12 14v4.5" />
+    <path d="M5.5 12h4.5" />
+    <path d="M14 12h4.5" />
+
+    <path d="M7.8 7.8l3.2 3.2" />
+    <path d="M13 13l3.2 3.2" />
+    <path d="M16.2 7.8L13 11" />
+    <path d="M11 13l-3.2 3.2" />
+  </svg>
+);
+
+const RobotSvg = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="1em"
+    height="1em"
+    fill="none"
+    viewBox="0 0 24 24"
+    stroke="currentColor"
+    stroke-width="1.8"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  >
+    <rect x="4" y="5" width="16" height="12" rx="4" />
+
+    <path d="M12 2.5v2.5" />
+    <circle cx="12" cy="2.5" r="0.8" fill="currentColor" stroke="none" />
+
+    <circle cx="9" cy="11" r="1" />
+    <circle cx="15" cy="11" r="1" />
+
+    <path d="M9 15h6" />
+
+    <path d="M8 19h8" />
+  </svg>
+);
+
 export const DictIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={DictSvg} {...props} />
 );
@@ -1070,3 +1195,23 @@ export const GroupIcon = (props: Partial<CustomIconComponentProps>) => (
 export const MoreIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={MoreSvg} {...props} />
 );
+
+export const TaskIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={TaskSvg} {...props} />
+);
+
+export const ChannelIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={ChannelSvg} {...props} />
+);
+
+export const DocumentIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={DocumentSvg} {...props} />
+);
+
+export const TipitakaIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={TipitakaSvg} {...props} />
+);
+
+export const RobotIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={RobotSvg} {...props} />
+);

+ 65 - 0
dashboard-v6/src/components/article/ArticleListModal.tsx

@@ -0,0 +1,65 @@
+import { useState } from "react";
+import { Modal } from "antd";
+
+//import ArticleList from "./ArticleList";
+
+interface IWidget {
+  studioName?: string;
+  trigger?: React.ReactNode;
+  multiple?: boolean;
+  onSelect?: (id: string, title: string) => void;
+}
+const ArticleListModalWidget = ({
+  studioName,
+  trigger = "Article",
+  multiple = true,
+  onSelect,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+    //TODO remove
+    if (typeof onSelect !== "undefined") {
+      onSelect("", "title");
+    }
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  console.debug("render", studioName, multiple);
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        title="文章列表"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        {/**
+           *         <ArticleList
+          studioName={studioName}
+          editable={false}
+          multiple={multiple}
+          onSelect={(id: string, title: string) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(id, title);
+            }
+            handleOk();
+          }}
+        />
+           */}
+      </Modal>
+    </>
+  );
+};
+
+export default ArticleListModalWidget;

+ 1 - 0
dashboard-v6/src/components/auth/SignInAvatar.tsx

@@ -160,6 +160,7 @@ const SignInAvatar = ({ style, placement = "bottomRight" }: IWidget) => {
             >
               {user.nickName?.slice(0, 2)}
             </Avatar>
+            {user.nickName}
           </span>
         </Popover>
         <SettingModal

+ 38 - 2
dashboard-v6/src/components/auth/User.tsx

@@ -3,6 +3,42 @@ import { getAvatarColor } from "./utils";
 
 const { Text } = Typography;
 
+interface IUserAvatar {
+  nickName?: string;
+  userName?: string;
+  avatar?: string;
+  hideAvatar?: boolean;
+  hideNickName?: boolean;
+  showUserName?: boolean;
+}
+export const UserWithAvatar = ({
+  nickName,
+  userName,
+  avatar,
+  hideAvatar = false,
+  hideNickName = false,
+  showUserName = false,
+}: IUserAvatar) => {
+  return (
+    <span>
+      {hideAvatar ? null : (
+        <Avatar
+          size={"small"}
+          src={avatar}
+          style={
+            avatar ? undefined : { backgroundColor: getAvatarColor(nickName) }
+          }
+        >
+          {nickName?.slice(0, 2)}
+        </Avatar>
+      )}
+      {hideNickName ? null : <Text>{nickName}</Text>}
+      {!hideNickName && showUserName ? <Text>@</Text> : undefined}
+      {showUserName ? <Text>{userName}</Text> : undefined}
+    </span>
+  );
+};
+
 interface IWidget {
   id?: string;
   nickName?: string;
@@ -13,7 +49,7 @@ interface IWidget {
   showUserName?: boolean;
   hidePopover?: boolean;
 }
-const UserWidget = ({
+const User = ({
   nickName,
   userName,
   avatar,
@@ -68,4 +104,4 @@ const UserWidget = ({
   );
 };
 
-export default UserWidget;
+export default User;

+ 7 - 0
dashboard-v6/src/components/channel/Channel.tsx

@@ -0,0 +1,7 @@
+import type { IChannel } from "../../../src/api/Channel";
+
+const ChannelWidget = ({ name }: IChannel) => {
+  return <span>{name}</span>;
+};
+
+export default ChannelWidget;

+ 67 - 0
dashboard-v6/src/components/channel/ChannelAlert.tsx

@@ -0,0 +1,67 @@
+import { useIntl } from "react-intl";
+
+import { Alert, Button } from "antd";
+
+import ChannelPicker from "./ChannelPicker";
+import store from "../../store";
+import { openPanel } from "../../reducers/right-panel";
+import type { IChannel } from "../../api/Channel";
+
+interface IWidget {
+  channels?: string | null;
+  onChannelChange?: (channels: IChannel[]) => void;
+}
+const ChannelAlertWidget = ({ channels, onChannelChange }: IWidget) => {
+  const intl = useIntl();
+
+  // 获取浏览器宽度
+  const browserWidth = window.innerWidth;
+  let button = <></>;
+  if (browserWidth < 580) {
+    button = (
+      <ChannelPicker
+        trigger={
+          <Button type="primary">
+            {intl.formatMessage({
+              id: "buttons.select.channel",
+            })}
+          </Button>
+        }
+        defaultOwner="my"
+        onSelect={(channels: IChannel[]) => {
+          if (typeof onChannelChange !== "undefined") {
+            onChannelChange(channels);
+          }
+        }}
+      />
+    );
+  } else {
+    button = (
+      <Button
+        type="primary"
+        onClick={() => {
+          store.dispatch(openPanel("channel"));
+        }}
+      >
+        {intl.formatMessage({
+          id: "buttons.select.channel",
+        })}
+      </Button>
+    );
+  }
+
+  return channels ? (
+    <></>
+  ) : (
+    <Alert
+      message={intl.formatMessage({
+        id: "message.channel.empty.alert",
+      })}
+      type="warning"
+      closable
+      action={button}
+    />
+  );
+};
+
+export default ChannelAlertWidget;

+ 74 - 0
dashboard-v6/src/components/channel/ChannelCreate.tsx

@@ -0,0 +1,74 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+
+import { post } from "../../../src/request";
+import type { IApiResponseChannel } from "../../../src/api/Channel";
+import ChannelTypeSelect from "./ChannelTypeSelect";
+import LangSelect from "../../../src/components/general/LangSelect";
+import { useRef } from "react";
+
+interface IFormData {
+  name: string;
+  type: string;
+  lang: string;
+  studio: string;
+}
+
+interface IWidget {
+  studio?: string;
+  onSuccess?: () => void;
+}
+const ChannelCreateWidget = ({ studio, onSuccess }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        if (typeof studio === "undefined") {
+          return;
+        }
+        values.studio = studio;
+        const res: IApiResponseChannel = await post(`/api/v2/channel`, values);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+            formRef.current?.resetFields(["name"]);
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "channel.name" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ChannelTypeSelect />
+        <LangSelect />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default ChannelCreateWidget;

+ 97 - 0
dashboard-v6/src/components/channel/ChannelInfo.tsx

@@ -0,0 +1,97 @@
+import { Statistic, StatisticCard } from "@ant-design/pro-components";
+import { Modal } from "antd";
+import { useState } from "react";
+import type { IItem } from "./ChannelPickerTable";
+
+interface IChannelInfoModal {
+  sentenceCount: number;
+  open?: boolean;
+  channel?: IItem;
+  onClose?: () => void;
+}
+
+export const ChannelInfoModal = ({
+  sentenceCount,
+  open,
+  channel,
+  onClose,
+}: IChannelInfoModal) => {
+  const [innerOpen, setInnerOpen] = useState(false);
+
+  const isModalOpen = open ?? innerOpen;
+  return (
+    <Modal
+      destroyOnHidden={true}
+      width={300}
+      title="统计"
+      open={isModalOpen}
+      onCancel={() => {
+        setInnerOpen(false);
+        if (typeof onClose !== "undefined") {
+          onClose();
+        }
+      }}
+      footer={<></>}
+    >
+      <ChannelInfoWidget sentenceCount={sentenceCount} channel={channel} />
+    </Modal>
+  );
+};
+interface IWidget {
+  sentenceCount: number;
+  channel?: IItem;
+}
+const ChannelInfoWidget = ({ sentenceCount, channel }: IWidget) => {
+  let totalStrLen = 0;
+  let finalStrLen = 0;
+  let finalSent = 0;
+  channel?.final?.forEach((value) => {
+    totalStrLen += value[0];
+    if (value[1]) {
+      finalStrLen += value[0];
+      finalSent++;
+    }
+  });
+  const final = channel?.final ? (finalSent * 100) / channel?.final?.length : 0;
+  return (
+    <>
+      <StatisticCard
+        title={"版本:" + channel?.title}
+        statistic={{
+          value: sentenceCount,
+          suffix: "句",
+          description: (
+            <Statistic title="完成度" value={Math.round(final) + "%"} />
+          ),
+        }}
+        chart={<></>}
+        footer={
+          <>
+            <Statistic
+              value={totalStrLen}
+              title="巴利字符"
+              layout="horizontal"
+            />
+            <Statistic
+              value={finalStrLen}
+              title="译文字符"
+              layout="horizontal"
+            />
+            <Statistic
+              value={channel?.content_created_at}
+              title="创建于"
+              layout="horizontal"
+            />
+            <Statistic
+              value={channel?.content_updated_at}
+              title="最近更新"
+              layout="horizontal"
+            />
+          </>
+        }
+      />
+    </>
+  );
+};
+
+export default ChannelInfoWidget;

+ 83 - 0
dashboard-v6/src/components/channel/ChannelList.tsx

@@ -0,0 +1,83 @@
+import { useIntl } from "react-intl";
+import { useState, useEffect } from "react";
+import { Card, List, message, Space, Tag } from "antd";
+
+import type { IChannelApiData } from "../../api/Channel";
+import type { IApiResponseChannelList } from "../../api/Corpus";
+import { get } from "../../request";
+import ChannelListItem from "./ChannelListItem";
+import type { IStudio } from "../../api/Auth";
+
+export interface ChannelFilterProps {
+  chapterProgress: number;
+  lang: string;
+  channelType: string;
+}
+interface IChannelList {
+  channel: IChannelApiData;
+  studio: IStudio;
+  count: number;
+}
+interface IWidgetChannelList {
+  filter?: ChannelFilterProps;
+}
+const defaultChannelFilterProps: ChannelFilterProps = {
+  chapterProgress: 0.9,
+  lang: "zh",
+  channelType: "translation",
+};
+
+const ChannelListWidget = ({
+  filter = defaultChannelFilterProps,
+}: IWidgetChannelList) => {
+  const [tableData, setTableData] = useState<IChannelList[]>([]);
+  const intl = useIntl();
+
+  useEffect(() => {
+    const url = `/api/v2/progress?view=channel&channel_type=${filter.channelType}&lang=${filter.lang}&progress=${filter.chapterProgress}`;
+    get<IApiResponseChannelList>(url).then(function (json) {
+      if (json.ok) {
+        console.log("channel", json.data);
+        const newData: IChannelList[] = json.data.rows.map((item) => {
+          return {
+            channel: {
+              name: item.channel.name,
+              id: item.channel.uid,
+              type: item.channel.type,
+            },
+            studio: item.studio,
+            count: item.count,
+          };
+        });
+        setTableData(newData);
+      } else {
+        message.error(json.message);
+      }
+    });
+  }, [filter]);
+
+  return (
+    <Card
+      title={intl.formatMessage({
+        id: `columns.studio.channel.title`,
+      })}
+      size="small"
+    >
+      <List
+        itemLayout="vertical"
+        size="small"
+        dataSource={tableData}
+        renderItem={(item) => (
+          <List.Item>
+            <Space>
+              <ChannelListItem channel={item.channel} studio={item.studio} />
+              <Tag>{item.count}</Tag>
+            </Space>
+          </List.Item>
+        )}
+      />
+    </Card>
+  );
+};
+
+export default ChannelListWidget;

+ 23 - 0
dashboard-v6/src/components/channel/ChannelListItem.tsx

@@ -0,0 +1,23 @@
+import { Space } from "antd";
+
+import type { IChannelApiData } from "../../../src/api/Channel";
+import Studio from "../../../src/components/auth/Studio";
+import type { IStudio } from "../../api/Auth";
+
+interface IWidget {
+  channel: IChannelApiData;
+  studio: IStudio;
+  showProgress?: boolean;
+  showLike?: boolean;
+}
+
+const ChannelListItemWidget = ({ channel, studio }: IWidget) => {
+  return (
+    <Space>
+      <Studio data={studio} hideName />
+      {channel.name}
+    </Space>
+  );
+};
+
+export default ChannelListItemWidget;

+ 558 - 0
dashboard-v6/src/components/channel/ChannelMy.tsx

@@ -0,0 +1,558 @@
+import { useCallback, useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+import type { Key } from "antd/es/table/interface";
+import {
+  Badge,
+  Button,
+  Card,
+  Dropdown,
+  Input,
+  Select,
+  Skeleton,
+  Space,
+  Tag,
+  Tooltip,
+  Tree,
+} from "antd";
+import {
+  GlobalOutlined,
+  EditOutlined,
+  ReloadOutlined,
+  MoreOutlined,
+  CopyOutlined,
+  InfoCircleOutlined,
+} from "@ant-design/icons";
+
+import { get, post } from "../../request";
+import type {
+  IApiResponseChannelList,
+  IChannel,
+  ISentInChapterListResponse,
+} from "../../api/Channel";
+import type { IItem, IProgressRequest } from "./ChannelPickerTable";
+import { LockFillIcon, LockIcon } from "../../assets/icon";
+import StudioName from "../auth/Studio";
+import ProgressSvg from "./ProgressSvg";
+
+import CopyToModal from "./CopyToModal";
+
+import { ChannelInfoModal } from "./ChannelInfo";
+
+import type { ArticleType } from "../../api/Corpus";
+import { getSentIdInArticle } from "./utils";
+import TokenModal from "../token/TokenModal";
+import NissayaAlignerModal from "../nissaya/NissayaAlignerModal";
+
+const { Search } = Input;
+
+interface IToken {
+  channelId?: string;
+  articleId?: string;
+  type?: ArticleType;
+}
+
+interface ChannelTreeNode {
+  key: string;
+  title: string | React.ReactNode;
+  channel: IItem;
+  icon?: React.ReactNode;
+  children?: ChannelTreeNode[];
+}
+
+interface IWidget {
+  type?: ArticleType | "editable";
+  articleId?: string;
+  selectedKeys?: string[];
+  style?: React.CSSProperties;
+  onSelect?: (selected: IChannel[]) => void;
+}
+const ChannelMy = ({
+  type,
+  articleId,
+  selectedKeys = [],
+  style,
+  onSelect,
+}: IWidget) => {
+  const intl = useIntl();
+  const [selectedRowKeys, setSelectedRowKeys] =
+    useState<React.Key[]>(selectedKeys);
+  const [treeData, setTreeData] = useState<ChannelTreeNode[]>();
+  const [dirty, setDirty] = useState(false);
+  const [channels, setChannels] = useState<IItem[]>([]);
+  const [owner, setOwner] = useState("all");
+  const [search, setSearch] = useState<string>();
+  const [loading, setLoading] = useState(true);
+  const [copyChannel, setCopyChannel] = useState<IChannel>();
+  const [nissayaOpen, setNissayaOpen] = useState(false);
+  const [copyOpen, setCopyOpen] = useState<boolean>(false);
+  const [infoOpen, setInfoOpen] = useState<boolean>(false);
+  const [statistic, setStatistic] = useState<IItem>();
+  const [sentenceCount, setSentenceCount] = useState<number>(0);
+  const [sentencesId, setSentencesId] = useState<string[]>();
+  const [token, SetToken] = useState<IToken>();
+  const [tokenOpen, setTokenOpen] = useState(false);
+
+  console.debug("ChannelMy render", type, articleId);
+
+  //TODO remove useEffect
+  const loadChannel = useCallback(async (sentences: string[]) => {
+    setSentenceCount(sentences.length);
+    setLoading(true);
+
+    try {
+      const res = await post<IProgressRequest, IApiResponseChannelList>(
+        "/api/v2/channel-progress",
+        {
+          sentence: sentences,
+          owner: "all",
+        }
+      );
+
+      const items: IItem[] = res.data.rows
+        .filter((v) => !v.name.startsWith("_sys"))
+        .map((item, id) => {
+          const date = new Date(item.created_at);
+
+          let all = 0;
+          let finished = 0;
+
+          item.final?.forEach((v) => {
+            all += v[0];
+            if (v[1]) finished += v[0];
+          });
+
+          return {
+            id,
+            uid: item.uid,
+            title: item.name,
+            summary: item.summary,
+            studio: item.studio,
+            shareType: "my",
+            role: item.role,
+            type: item.type,
+            publicity: item.status,
+            createdAt: date.getTime(),
+            final: item.final,
+            progress: all ? finished / all : 0,
+            content_created_at: item.content_created_at,
+            content_updated_at: item.content_updated_at,
+          };
+        });
+
+      setChannels(items);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  const load = useCallback(async () => {
+    let sentList: string[] = [];
+
+    if (type === "chapter") {
+      const id = articleId?.split("-");
+      if (id?.length === 2) {
+        const url = `/api/v2/sentences-in-chapter?book=${id[0]}&para=${id[1]}`;
+
+        try {
+          const res = await get<ISentInChapterListResponse>(url);
+          if (!res?.ok) return;
+
+          sentList = res.data.rows.map(
+            (item) =>
+              `${item.book}-${item.paragraph}-${item.word_begin}-${item.word_end}`
+          );
+        } catch (err) {
+          console.error(err);
+          return;
+        }
+      }
+    } else {
+      sentList = getSentIdInArticle();
+    }
+
+    setSentencesId(sentList);
+    await loadChannel(sentList);
+  }, [type, articleId, loadChannel]);
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  useEffect(() => {
+    setSelectedRowKeys(selectedKeys);
+  }, [selectedKeys]);
+
+  useEffect(() => {
+    sortChannels(channels);
+  }, [channels, selectedRowKeys, owner]);
+
+  interface IChannelFilter {
+    key?: string;
+    owner?: string;
+    selectedRowKeys?: React.Key[];
+  }
+
+  const sortChannels = (channelList: IItem[], filter?: IChannelFilter) => {
+    const mOwner = filter?.owner ?? owner;
+    if (mOwner === "my") {
+      //我自己的
+      const myChannel = channelList.filter((value) => value.role === "owner");
+      const data = myChannel.map((item) => {
+        return { key: item.uid, title: item.title, channel: item };
+      });
+      setTreeData(data);
+    } else {
+      //当前被选择的
+      const selectedChannel: IItem[] = [];
+      const mSelectedRowKeys = filter?.selectedRowKeys ?? selectedRowKeys;
+      mSelectedRowKeys.forEach((channelId) => {
+        const channel = channelList.find((value) => value.uid === channelId);
+        if (channel) {
+          selectedChannel.push(channel);
+        }
+      });
+      let show = mSelectedRowKeys;
+      //有进度的
+      const progressing = channelList.filter(
+        (value) => value.progress > 0 && !show.includes(value.uid)
+      );
+      show = [...show, ...progressing.map((item) => item.uid)];
+      //我自己的
+      const myChannel = channelList.filter(
+        (value) => value.role === "owner" && !show.includes(value.uid)
+      );
+      show = [...show, ...myChannel.map((item) => item.uid)];
+      //其他的
+      const others = channelList.filter(
+        (value) => !show.includes(value.uid) && value.role !== "member"
+      );
+      let channelData = [
+        ...selectedChannel,
+        ...progressing,
+        ...myChannel,
+        ...others,
+      ];
+
+      const key = filter?.key ?? search;
+      if (key) {
+        channelData = channelData.filter((value) => value.title.includes(key));
+      }
+
+      const data = channelData.map((item) => {
+        return { key: item.uid, title: item.title, channel: item };
+      });
+      setTreeData(data);
+    }
+  };
+
+  return (
+    <div style={style}>
+      <TokenModal
+        {...token}
+        open={tokenOpen}
+        onClose={() => setTokenOpen(false)}
+      />
+      <Card
+        size="small"
+        title={
+          <Space>
+            <Search
+              placeholder="版本名称"
+              onSearch={(value) => {
+                console.debug(value);
+                setSearch(value);
+                sortChannels(channels, { key: value });
+              }}
+              style={{ width: 120 }}
+            />
+            <Select
+              defaultValue="all"
+              style={{ width: 80 }}
+              bordered={false}
+              options={[
+                {
+                  value: "all",
+                  label: intl.formatMessage({ id: "buttons.channel.all" }),
+                },
+                {
+                  value: "my",
+                  label: intl.formatMessage({ id: "buttons.channel.my" }),
+                },
+              ]}
+              onSelect={(value: string) => {
+                setOwner(value);
+              }}
+            />
+          </Space>
+        }
+        extra={
+          <Space size={"small"}>
+            <Button
+              size="small"
+              type="link"
+              disabled={!dirty}
+              onClick={() => {
+                if (typeof onSelect !== "undefined") {
+                  setDirty(false);
+                  const selected: IChannel[] = selectedRowKeys.map((item) => {
+                    return {
+                      id: item.toString(),
+                      name:
+                        treeData?.find((value) => value.channel.uid === item)
+                          ?.channel.title ?? "",
+                    };
+                  });
+                  onSelect(selected);
+                }
+              }}
+            >
+              {intl.formatMessage({
+                id: "buttons.ok",
+              })}
+            </Button>
+            <Button
+              size="small"
+              type="link"
+              disabled={!dirty}
+              onClick={() => {
+                setSelectedRowKeys(selectedKeys);
+                setDirty(false);
+              }}
+            >
+              {intl.formatMessage({
+                id: "buttons.cancel",
+              })}
+            </Button>
+            <Button
+              type="link"
+              size="small"
+              icon={<ReloadOutlined />}
+              onClick={() => {
+                load();
+              }}
+            />
+          </Space>
+        }
+      >
+        {loading ? (
+          <Skeleton active />
+        ) : (
+          <Tree
+            selectedKeys={selectedRowKeys}
+            multiple
+            checkedKeys={selectedRowKeys}
+            checkable
+            treeData={treeData}
+            blockNode
+            onCheck={(
+              checked: Key[] | { checked: Key[]; halfChecked: Key[] }
+            ) => {
+              setDirty(true);
+              if (Array.isArray(checked)) {
+                if (checked.length > selectedRowKeys.length) {
+                  const add = checked.filter(
+                    (value) => !selectedRowKeys.includes(value.toString())
+                  );
+                  if (add.length > 0) {
+                    setSelectedRowKeys([...selectedRowKeys, add[0]]);
+                  }
+                } else {
+                  setSelectedRowKeys(
+                    selectedRowKeys.filter((value) => checked.includes(value))
+                  );
+                }
+              }
+            }}
+            onSelect={() => {}}
+            titleRender={(node: ChannelTreeNode) => {
+              let pIcon = <></>;
+              switch (node.channel.publicity) {
+                case 5:
+                  pIcon = (
+                    <Tooltip title={"私有不可公开"}>
+                      <LockFillIcon />
+                    </Tooltip>
+                  );
+                  break;
+                case 10:
+                  pIcon = (
+                    <Tooltip title={"私有"}>
+                      <LockIcon />
+                    </Tooltip>
+                  );
+                  break;
+                case 30:
+                  pIcon = (
+                    <Tooltip title={"公开"}>
+                      <GlobalOutlined />
+                    </Tooltip>
+                  );
+                  break;
+              }
+              const badge = selectedRowKeys.findIndex(
+                (value) => value === node.channel.uid
+              );
+              return (
+                <div
+                  style={{
+                    display: "flex",
+                    justifyContent: "space-between",
+                    width: "100%",
+                  }}
+                >
+                  <div
+                    style={{
+                      width: "100%",
+                      borderRadius: 5,
+                      padding: "0 5px",
+                    }}
+                    onClick={() => {
+                      console.log(node);
+                      if (channels) {
+                        sortChannels(channels);
+                      }
+                      setDirty(false);
+                      if (typeof onSelect !== "undefined") {
+                        onSelect([
+                          {
+                            id: node.key,
+                            name: node.channel.title,
+                          },
+                        ]);
+                      }
+                    }}
+                  >
+                    <div
+                      key="info"
+                      style={{ overflowX: "clip", display: "flex" }}
+                    >
+                      <Space>
+                        {pIcon}
+                        {node.channel.role !== "member" ? (
+                          <EditOutlined />
+                        ) : undefined}
+                      </Space>
+                      <Button type="link">
+                        <Space>
+                          <StudioName data={node.channel.studio} hideName />
+                          <>{node.channel.title}</>
+                          <Tag>
+                            {intl.formatMessage({
+                              id: `channel.type.${node.channel.type}.label`,
+                            })}
+                          </Tag>
+                        </Space>
+                      </Button>
+                    </div>
+                    <div key="progress">
+                      <ProgressSvg data={node.channel.final} width={200} />
+                    </div>
+                  </div>
+                  <Badge count={dirty ? badge + 1 : 0}>
+                    <div>
+                      <Dropdown
+                        trigger={["click"]}
+                        menu={{
+                          items: [
+                            {
+                              key: "copy-to",
+                              label: intl.formatMessage({
+                                id: "buttons.copy.to",
+                              }),
+                              icon: <CopyOutlined />,
+                            },
+                            {
+                              key: "import-nissaya",
+                              label: intl.formatMessage({
+                                id: "buttons.import",
+                              }),
+                              icon: <CopyOutlined />,
+                            },
+                            {
+                              key: "statistic",
+                              label: intl.formatMessage({
+                                id: "buttons.statistic",
+                              }),
+                              icon: <InfoCircleOutlined />,
+                            },
+                            {
+                              key: "token",
+                              label: intl.formatMessage({
+                                id: "buttons.access-token.get",
+                              }),
+                              icon: <InfoCircleOutlined />,
+                            },
+                          ],
+                          onClick: (e) => {
+                            switch (e.key) {
+                              case "copy-to":
+                                setCopyChannel({
+                                  id: node.channel.uid,
+                                  name: node.channel.title,
+                                  type: node.channel.type,
+                                });
+                                setCopyOpen(true);
+                                break;
+                              case "import-nissaya":
+                                setCopyChannel({
+                                  id: node.channel.uid,
+                                  name: node.channel.title,
+                                  type: node.channel.type,
+                                });
+                                setNissayaOpen(true);
+                                break;
+                              case "statistic":
+                                setInfoOpen(true);
+                                setStatistic(node.channel);
+                                break;
+                              case "token":
+                                SetToken({
+                                  channelId: node.channel.uid,
+                                  type: type as ArticleType,
+                                  articleId: articleId,
+                                });
+                                setTokenOpen(true);
+                                break;
+                              default:
+                                break;
+                            }
+                          },
+                        }}
+                        placement="bottomRight"
+                      >
+                        <Button
+                          type="link"
+                          size="small"
+                          icon={<MoreOutlined />}
+                        ></Button>
+                      </Dropdown>
+                    </div>
+                  </Badge>
+                </div>
+              );
+            }}
+          />
+        )}
+      </Card>
+      <CopyToModal
+        sentencesId={sentencesId}
+        channel={copyChannel}
+        open={copyOpen}
+        onClose={() => setCopyOpen(false)}
+      />
+      <NissayaAlignerModal
+        sentencesId={sentencesId}
+        channel={copyChannel}
+        open={nissayaOpen}
+        onClose={() => setNissayaOpen(false)}
+      />
+      <ChannelInfoModal
+        sentenceCount={sentenceCount}
+        channel={statistic}
+        open={infoOpen}
+        onClose={() => setInfoOpen(false)}
+      />
+    </div>
+  );
+};
+export default ChannelMy;

+ 89 - 0
dashboard-v6/src/components/channel/ChannelPicker.tsx

@@ -0,0 +1,89 @@
+import React, { useEffect, useState } from "react";
+import { Modal } from "antd";
+
+import ChannelPickerTable from "./ChannelPickerTable";
+
+import { useIntl } from "react-intl";
+import type { ArticleType } from "../../api/Corpus";
+import type { IChannel } from "../../api/Channel";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  type?: ArticleType | "editable";
+  articleId?: string;
+  multiSelect?: boolean;
+  open?: boolean;
+  defaultOwner?: string;
+  onClose?: () => void;
+  onSelect?: (channels: IChannel[]) => void;
+}
+const ChannelPickerWidget = ({
+  trigger,
+  type,
+  articleId,
+  multiSelect = true,
+  open = false,
+  defaultOwner,
+  onClose,
+  onSelect,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+  const intl = useIntl();
+
+  useEffect(() => {
+    setIsModalOpen(open);
+  }, [open]);
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        style={{ maxWidth: 600 }}
+        title={intl.formatMessage({
+          id: "buttons.select.channel",
+        })}
+        footer={false}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <ChannelPickerTable
+          type={type}
+          articleId={articleId}
+          multiSelect={multiSelect}
+          defaultOwner={defaultOwner}
+          onSelect={(channels: IChannel[]) => {
+            console.log(channels);
+            handleCancel();
+            if (typeof onClose !== "undefined") {
+              onClose();
+            }
+            if (typeof onSelect !== "undefined") {
+              onSelect(channels);
+            }
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default ChannelPickerWidget;

+ 433 - 0
dashboard-v6/src/components/channel/ChannelPickerTable.tsx

@@ -0,0 +1,433 @@
+import { useEffect, useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { Alert, Button } from "antd";
+import { Badge, Dropdown, Space, Table, Typography } from "antd";
+import {
+  GlobalOutlined,
+  EditOutlined,
+  MoreOutlined,
+  CopyOutlined,
+  ReloadOutlined,
+} from "@ant-design/icons";
+
+import type {
+  IApiResponseChannelList,
+  IChannel,
+  IFinal,
+  TChannelType,
+} from "../../../src/api/Channel";
+import { post } from "../../../src/request";
+import { LockIcon } from "../../../src/assets/icon";
+
+import ProgressSvg from "./ProgressSvg";
+
+import CopyToModal from "./CopyToModal";
+import type { IStudio } from "../../../src/api/Auth";
+import type { ArticleType } from "../../../src/api/Corpus";
+import Studio from "../../../src/components/auth/Studio";
+
+const { Link, Text } = Typography;
+
+interface IParams {
+  owner?: string;
+}
+
+export interface IProgressRequest {
+  sentence: string[];
+  owner?: string;
+}
+export interface IItem {
+  id: number;
+  uid: string;
+  title: string;
+  summary: string;
+  type: TChannelType;
+  studio: IStudio;
+  shareType: string;
+  role?: string;
+  publicity: number;
+  final?: IFinal[];
+  progress: number;
+  createdAt: number;
+  content_created_at?: string;
+  content_updated_at?: string;
+}
+interface IWidget {
+  type?: ArticleType | "editable";
+  articleId?: string;
+  multiSelect?: boolean /*是否支持多选*/;
+  selectedKeys?: string[];
+  reload?: boolean;
+  disableChannelId?: string;
+  defaultOwner?: string;
+  onSelect?: (channels: IChannel[]) => void;
+}
+const ChannelPickerTableWidget = ({
+  multiSelect = true,
+  selectedKeys = [],
+  onSelect,
+  disableChannelId,
+  defaultOwner = "all",
+  reload = false,
+}: IWidget) => {
+  const intl = useIntl();
+  const [selectedRowKeys, setSelectedRowKeys] =
+    useState<React.Key[]>(selectedKeys);
+  const [showCheckBox, setShowCheckBox] = useState<boolean>(false);
+  const [copyChannel, setCopyChannel] = useState<IChannel>();
+  const [copyOpen, setCopyOpen] = useState<boolean>(false);
+  const [ownerChanged, setOwnerChanged] = useState<boolean>(false);
+
+  const ref = useRef<ActionType | null>(null);
+
+  useEffect(() => {
+    if (reload) {
+      ref.current?.reload();
+    }
+  }, [reload]);
+
+  return (
+    <Space orientation="vertical" style={{ width: "100%" }}>
+      {defaultOwner !== "all" && ownerChanged === false ? (
+        <Alert
+          message={
+            <>
+              {"目前仅显示了版本"}
+              <Text keyboard>
+                {intl.formatMessage({ id: `buttons.channel.${defaultOwner}` })}
+              </Text>
+              {"可以点"}
+              <Text keyboard>{"版本筛选"}</Text>
+              {"显示其他版本"}
+            </>
+          }
+          type="success"
+          closable
+          action={
+            <Button
+              type="link"
+              onClick={() => {
+                if (typeof onSelect !== "undefined") {
+                  onSelect([]);
+                }
+              }}
+            >
+              不选择
+            </Button>
+          }
+        />
+      ) : undefined}
+      <ProList<IItem, IParams>
+        actionRef={ref}
+        rowSelection={
+          showCheckBox
+            ? {
+                // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+                // 注释该行则默认不显示下拉选项
+                alwaysShowAlert: true,
+                selectedRowKeys: selectedRowKeys,
+                onChange: (selectedRowKeys: React.Key[]) => {
+                  setSelectedRowKeys(selectedRowKeys);
+                },
+                selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+              }
+            : undefined
+        }
+        tableAlertRender={
+          showCheckBox
+            ? ({ selectedRowKeys, onCleanSelected }) => {
+                return (
+                  <Space>
+                    {intl.formatMessage({ id: "buttons.selected" })}
+                    <Badge color="geekblue" count={selectedRowKeys.length} />
+                    <Link onClick={onCleanSelected}>
+                      {intl.formatMessage({ id: "buttons.empty" })}
+                    </Link>
+                  </Space>
+                );
+              }
+            : undefined
+        }
+        tableAlertOptionRender={
+          showCheckBox
+            ? ({ selectedRows }) => {
+                return (
+                  <Space>
+                    <Link
+                      onClick={() => {
+                        if (typeof onSelect !== "undefined") {
+                          onSelect(
+                            selectedRows.map((item) => {
+                              return {
+                                id: item.uid,
+                                name: item.title,
+                              };
+                            })
+                          );
+                          setShowCheckBox(false);
+                          ref.current?.reload();
+                        }
+                      }}
+                    >
+                      {intl.formatMessage({
+                        id: "buttons.ok",
+                      })}
+                    </Link>
+                    <Link
+                      type="danger"
+                      onClick={() => {
+                        setShowCheckBox(false);
+                      }}
+                    >
+                      {intl.formatMessage({
+                        id: "buttons.cancel",
+                      })}
+                    </Link>
+                  </Space>
+                );
+              }
+            : undefined
+        }
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          const sentElement = document.querySelectorAll(".pcd_sent");
+          const sentList: string[] = [];
+          for (let index = 0; index < sentElement.length; index++) {
+            const element = sentElement[index];
+            const id = element.id.split("_")[1];
+            sentList.push(id);
+          }
+          const currOwner = params.owner ? params.owner : defaultOwner;
+          if (params.owner) {
+            setOwnerChanged(true);
+          }
+          console.log("owner", currOwner);
+          const res = await post<IProgressRequest, IApiResponseChannelList>(
+            `/api/v2/channel-progress`,
+            {
+              sentence: sentList,
+              owner: currOwner,
+            }
+          );
+          console.debug("progress data", res.data.rows);
+          const items: IItem[] = res.data.rows
+            .filter((value) => value.name.substring(0, 4) !== "_Sys")
+            .map((item, id) => {
+              const date = new Date(item.created_at);
+              let all: number = 0;
+              let finished: number = 0;
+              item.final?.forEach((value) => {
+                all += value[0];
+                finished += value[1] ? value[0] : 0;
+              });
+              const progress = finished / all;
+              return {
+                id: id,
+                uid: item.uid,
+                title: item.name,
+                summary: item.summary,
+                studio: item.studio,
+                shareType: "my",
+                role: item.role,
+                type: item.type,
+                publicity: item.status,
+                createdAt: date.getTime(),
+                final: item.final,
+                progress: progress,
+              };
+            });
+          //当前被选择的
+          const currChannel = items.filter((value) =>
+            selectedRowKeys.includes(value.uid)
+          );
+          let show = selectedRowKeys;
+          //有进度的
+          const progressing = items.filter(
+            (value) => value.progress > 0 && !show.includes(value.uid)
+          );
+          show = [...show, ...progressing.map((item) => item.uid)];
+          //我自己的
+          const myChannel = items.filter(
+            (value) => value.role === "owner" && !show.includes(value.uid)
+          );
+          show = [...show, ...myChannel.map((item) => item.uid)];
+          //其他的
+          const others = items.filter(
+            (value) => !show.includes(value.uid) && value.role !== "member"
+          );
+          setSelectedRowKeys(selectedRowKeys);
+          const channelData = [
+            ...currChannel,
+            ...progressing,
+            ...myChannel,
+            ...others,
+          ];
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: channelData,
+          };
+        }}
+        rowKey="uid"
+        bordered
+        options={false}
+        search={{
+          filterType: "light",
+        }}
+        toolBarRender={() => [
+          multiSelect ? (
+            <Button
+              onClick={() => {
+                setShowCheckBox(true);
+              }}
+            >
+              选择
+            </Button>
+          ) : undefined,
+          <Button
+            type="link"
+            onClick={() => {
+              ref.current?.reload();
+            }}
+            icon={<ReloadOutlined />}
+          />,
+        ]}
+        metas={{
+          title: {
+            render(_dom, entity, index) {
+              let pIcon = <></>;
+              switch (entity.publicity) {
+                case 5:
+                  pIcon = <LockIcon />;
+                  break;
+                case 10:
+                  pIcon = <LockIcon />;
+                  break;
+                case 30:
+                  pIcon = <GlobalOutlined />;
+                  break;
+              }
+
+              return (
+                <div
+                  key={index}
+                  style={{
+                    width: "100%",
+                    borderRadius: 5,
+                    padding: "0 5px",
+                    background:
+                      selectedKeys.includes(entity.uid) && !showCheckBox
+                        ? "linear-gradient(to left, rgb(63 255 165 / 54%), rgba(0, 0, 0, 0))"
+                        : undefined,
+                  }}
+                >
+                  <div
+                    key="info"
+                    style={{ overflowX: "clip", display: "flex" }}
+                  >
+                    <Space>
+                      {pIcon}
+                      {entity.role !== "member" ? <EditOutlined /> : undefined}
+                    </Space>
+                    <Button
+                      type="link"
+                      disabled={disableChannelId === entity.uid}
+                      onClick={() => {
+                        if (typeof onSelect !== "undefined") {
+                          const e: IChannel = {
+                            name: entity.title,
+                            id: entity.uid,
+                          };
+                          onSelect([e]);
+                        }
+                      }}
+                    >
+                      <Space>
+                        <Studio data={entity.studio} hideName />
+                        {entity.title}
+                      </Space>
+                    </Button>
+                  </div>
+                  <div key="progress">
+                    <ProgressSvg data={entity.final} width={200} />
+                  </div>
+                </div>
+              );
+            },
+            search: false,
+          },
+          actions: {
+            render: (_dom, entity, index) => {
+              return (
+                <Dropdown
+                  key={index}
+                  trigger={["click"]}
+                  menu={{
+                    items: [
+                      {
+                        key: "copy-to",
+                        label: intl.formatMessage({
+                          id: "buttons.copy.to",
+                        }),
+                        icon: <CopyOutlined />,
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "copy-to":
+                          setCopyChannel({
+                            id: entity.uid,
+                            name: entity.title,
+                            type: entity.type,
+                          });
+                          setCopyOpen(true);
+                          break;
+
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                  placement="bottomRight"
+                >
+                  <Button
+                    type="link"
+                    size="small"
+                    icon={<MoreOutlined />}
+                  ></Button>
+                </Dropdown>
+              );
+            },
+          },
+          owner: {
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            title: "版本筛选",
+            valueType: "select",
+            valueEnum: {
+              all: { text: intl.formatMessage({ id: "buttons.channel.all" }) },
+              my: {
+                text: intl.formatMessage({ id: "buttons.channel.my" }),
+              },
+              collaborator: {
+                text: intl.formatMessage({
+                  id: "buttons.channel.collaborator",
+                }),
+              },
+              public: {
+                text: intl.formatMessage({ id: "buttons.channel.public" }),
+              },
+            },
+          },
+        }}
+      />
+      <CopyToModal
+        channel={copyChannel}
+        open={copyOpen}
+        onClose={() => setCopyOpen(false)}
+      />
+    </Space>
+  );
+};
+
+export default ChannelPickerTableWidget;

+ 136 - 0
dashboard-v6/src/components/channel/ChannelSelect.tsx

@@ -0,0 +1,136 @@
+import { ProFormCascader } from "@ant-design/pro-components";
+import { message } from "antd";
+import { useAppSelector } from "../../../src/hooks";
+import { currentUser } from "../../../src/reducers/current-user";
+
+import { get } from "../../../src/request";
+import type { IApiResponseChannelList } from "../../../src/api/Channel";
+
+import { useIntl } from "react-intl";
+import type { IStudio } from "../../../src/api/Auth";
+
+interface IOption {
+  value: string;
+  label?: string;
+  lang?: string;
+  children?: IOption[];
+}
+
+interface IWidget {
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  channelId?: string;
+  name?: string;
+  tooltip?: string;
+  label?: string;
+  allowClear?: boolean;
+  parentChannelId?: string;
+  parentStudioId?: string;
+  placeholder?: string;
+  onSelect?: (selected: string[]) => void;
+}
+const ChannelSelectWidget = ({
+  width = "md",
+  name = "channel",
+  tooltip,
+  label,
+  parentChannelId,
+  parentStudioId,
+  placeholder,
+  allowClear = true,
+}: IWidget) => {
+  const user = useAppSelector(currentUser);
+  const intl = useIntl();
+  return (
+    <ProFormCascader
+      width={width}
+      name={name}
+      tooltip={tooltip}
+      label={label}
+      allowClear={allowClear}
+      placeholder={placeholder}
+      request={async ({ keyWords }) => {
+        console.debug("keyWord", keyWords);
+        const url = `/api/v2/channel?view=user-edit&key=${keyWords}`;
+        console.info("ChannelSelect api request", url);
+        const json = await get<IApiResponseChannelList>(url);
+        console.debug("ChannelSelect api response", json);
+        if (json.ok) {
+          //获取studio list
+          const studio = new Map<string, string>();
+          for (const iterator of json.data.rows) {
+            studio.set(
+              iterator.studio.id,
+              iterator.studio.nickName ? iterator.studio.nickName : ""
+            );
+          }
+          let channels: IOption[] = [];
+
+          if (user && user.id === parentStudioId) {
+            if (!user.roles?.includes("basic")) {
+              channels.push({
+                value: "",
+                label: intl.formatMessage({
+                  id: "term.general-in-studio",
+                }),
+              });
+            }
+          }
+
+          if (typeof parentChannelId === "string") {
+            channels.push({ value: parentChannelId, label: "仅此版本" });
+          }
+
+          if (user) {
+            //自己的 studio
+            channels.push({
+              value: user.id,
+              label: user.realName,
+              children: json.data.rows
+                .filter((value) => value.studio.id === user.id)
+                .map((item) => {
+                  return { value: item.uid, label: item.name, lang: item.lang };
+                }),
+            });
+          }
+
+          const arrStudio: IStudio[] = [];
+          studio.forEach((value, key) => {
+            arrStudio.push({ id: key, nickName: value });
+          });
+
+          const others: IOption[] = arrStudio
+            .sort((a, b) =>
+              a.nickName && b.nickName ? (a.nickName > b.nickName ? 1 : -1) : 0
+            )
+            .filter((value) => value.id !== user?.id)
+            .map((item) => {
+              const node = {
+                value: item.id,
+                label: item.nickName,
+                children: json.data.rows
+                  .filter((value) => value.studio.id === item.id)
+                  .map((item) => {
+                    return {
+                      value: item.uid,
+                      label: item.name,
+                      lang: item.lang,
+                    };
+                  }),
+              };
+              return node;
+            });
+          channels = [...channels, ...others];
+
+          console.debug("ChannelSelect json", channels);
+          return channels;
+        } else {
+          message.error(json.message);
+          return [];
+        }
+      }}
+      fieldProps={{}}
+    />
+  );
+};
+
+export default ChannelSelectWidget;

+ 115 - 0
dashboard-v6/src/components/channel/ChannelSelectWithToken.tsx

@@ -0,0 +1,115 @@
+import { useState } from "react";
+import { Button, Input, Space, Tooltip, Typography } from "antd";
+import {
+  FolderOpenOutlined,
+  CheckCircleTwoTone,
+  LoadingOutlined,
+  WarningTwoTone,
+} from "@ant-design/icons";
+
+import type { IChannel, TChannelType } from "../../../src/api/Channel";
+import { post } from "../../../src/request";
+import ChannelTableModal from "./ChannelTableModal";
+
+import type {
+  IPayload,
+  ITokenCreate,
+  ITokenCreateResponse,
+  TPower,
+} from "../../../src/api/token";
+
+const { Text } = Typography;
+
+interface IData {
+  value: string;
+  label: string;
+}
+
+interface IWidget {
+  channelsId?: string[];
+  type?: TChannelType;
+  book?: number;
+  para?: number;
+  power?: TPower;
+  onChange?: (channel?: string | null) => void;
+}
+const ChannelSelectWithToken = ({
+  type,
+  book,
+  para,
+  power,
+  onChange,
+}: IWidget) => {
+  const [curr, setCurr] = useState<IData>();
+  const [access, setAccess] = useState<boolean>();
+  const [loading, setLoading] = useState(false);
+  return (
+    <Space>
+      <Input
+        allowClear
+        value={curr?.label}
+        placeholder="选择一个版本"
+        onChange={(event) => {
+          if (event.target.value.trim().length === 0) {
+            setCurr(undefined);
+            setAccess(undefined);
+            if (onChange) {
+              onChange(undefined);
+            }
+          }
+        }}
+      />
+      <ChannelTableModal
+        chapter={book && para ? { book: book, paragraph: para } : undefined}
+        channelType={type}
+        trigger={<Button icon={<FolderOpenOutlined />} type="text" />}
+        onSelect={(channel: IChannel) => {
+          setCurr({ value: channel.id, label: channel.name });
+          //验证权限
+          if (power) {
+            setLoading(true);
+            const payload: IPayload[] = [];
+            payload.push({
+              res_id: channel.id,
+              res_type: "channel",
+              power: power,
+            });
+            const url = "/api/v2/access-token";
+            const values = { payload: payload };
+            console.info("token api request", url, values);
+            post<ITokenCreate, ITokenCreateResponse>(url, values)
+              .then((json) => {
+                console.info("token api response", json);
+                if (json.ok) {
+                  if (json.data.count > 0) {
+                    setAccess(true);
+                  }
+                }
+              })
+              .finally(() => setLoading(false));
+          }
+
+          if (onChange) {
+            onChange(channel.id + (power ? "@" + power : ""));
+          }
+        }}
+      />
+      <Text type="secondary">{power}</Text>
+      {loading ? (
+        <LoadingOutlined />
+      ) : typeof access !== "undefined" ? (
+        access ? (
+          <CheckCircleTwoTone twoToneColor="#52c41a" />
+        ) : (
+          <Tooltip title="无法获取指定的权限">
+            <WarningTwoTone twoToneColor="#eb2f96" />
+          </Tooltip>
+        )
+      ) : (
+        <></>
+      )}
+    </Space>
+  );
+};
+
+export default ChannelSelectWithToken;

+ 328 - 0
dashboard-v6/src/components/channel/ChannelSentDiff.tsx

@@ -0,0 +1,328 @@
+import { Button, message, Select, Table, Tooltip, Typography } from "antd";
+import { type Change, diffChars } from "diff";
+import { useEffect, useState } from "react";
+
+import { post } from "../../../src/request";
+import type {
+  ISentence,
+  ISentenceDiffData,
+  ISentenceDiffRequest,
+  ISentenceDiffResponse,
+  ISentenceListResponse,
+  ISentenceNewRequest,
+} from "../../../src/api/Corpus";
+
+import store from "../../../src/store";
+import { accept } from "../../../src/reducers/accept-pr";
+import type { IChannel } from "../../../src/api/Channel";
+import { toISentence } from "../sentence/utils";
+
+const { Text } = Typography;
+
+interface IDataType {
+  key: React.Key;
+  sentId: string;
+  pali?: string | null;
+  srcContent?: string | null;
+  destContent?: string | null;
+}
+
+interface IWidget {
+  srcChannel?: IChannel;
+  destChannel?: IChannel;
+  sentences?: string[];
+  important?: boolean;
+  goPrev?: () => void;
+  onSubmit?: (total: number) => void;
+}
+const ChannelSentDiffWidget = ({
+  srcChannel,
+  destChannel,
+  sentences,
+  important = false,
+  goPrev,
+  onSubmit,
+}: IWidget) => {
+  const [srcApiData, setSrcApiData] = useState<ISentenceDiffData[]>([]);
+  const [diffData, setDiffData] = useState<IDataType[]>();
+  const [loading, setLoading] = useState(false);
+  const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>();
+  const [newRowKeys, setNewRowKeys] = useState<React.Key[]>();
+  const [emptyRowKeys, setEmptyRowKeys] = useState<React.Key[]>();
+
+  useEffect(() => {
+    if (sentences && srcChannel && destChannel) {
+      post<ISentenceDiffRequest, ISentenceDiffResponse>(
+        `/api/v2/sent-in-channel`,
+        {
+          sentences: sentences,
+          channels: ["_System_Pali_VRI_", srcChannel.id, destChannel.id],
+        }
+      ).then((json) => {
+        if (json.ok) {
+          const apiData = json.data.rows;
+          setSrcApiData(apiData);
+          const newRows: string[] = [];
+          const emptyRows: string[] = [];
+          const diffList: IDataType[] = sentences?.map((item) => {
+            const id: string[] = item.split("-");
+            const srcContent = apiData.find(
+              (element) =>
+                element.book_id === parseInt(id[0]) &&
+                element.paragraph === parseInt(id[1]) &&
+                element.word_start === parseInt(id[2]) &&
+                element.word_end === parseInt(id[3]) &&
+                element.channel_uid === srcChannel.id
+            );
+
+            const destContent = apiData.find(
+              (element) =>
+                element.book_id === parseInt(id[0]) &&
+                element.paragraph === parseInt(id[1]) &&
+                element.word_start === parseInt(id[2]) &&
+                element.word_end === parseInt(id[3]) &&
+                element.channel_uid === destChannel.id
+            );
+            if (srcContent && destContent) {
+              const srcDate = new Date(srcContent.updated_at);
+              const destDate = new Date(destContent.updated_at);
+              if (srcDate > destDate) {
+                newRows.push(item);
+              }
+            }
+            if (
+              typeof destContent === "undefined" ||
+              destContent.content?.trim().length === 0
+            ) {
+              emptyRows.push(item);
+            }
+            const paliContent = apiData.find(
+              (element) =>
+                element.book_id === parseInt(id[0]) &&
+                element.paragraph === parseInt(id[1]) &&
+                element.word_start === parseInt(id[2]) &&
+                element.word_end === parseInt(id[3]) &&
+                element.channel_uid !== destChannel.id &&
+                element.channel_uid !== srcChannel.id
+            );
+            return {
+              key: item,
+              sentId: item,
+              pali: paliContent?.content,
+              srcContent: srcContent?.content,
+              destContent: destContent?.content,
+            };
+          });
+          setDiffData(diffList);
+          setNewRowKeys(newRows);
+          if (important) {
+            setSelectedRowKeys(sentences);
+          } else {
+            setSelectedRowKeys(newRows);
+          }
+          setEmptyRowKeys(emptyRows);
+        }
+      });
+    }
+  }, [srcChannel, sentences, destChannel]);
+
+  return (
+    <div>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <Button
+          onClick={() => {
+            if (typeof goPrev !== "undefined") {
+              goPrev();
+            }
+          }}
+        >
+          上一步
+        </Button>
+        <Select
+          defaultValue={important ? "all" : "new"}
+          style={{ width: 180 }}
+          disabled={important}
+          onChange={(value: string) => {
+            switch (value) {
+              case "new":
+                setSelectedRowKeys(newRowKeys);
+                break;
+              case "all":
+                setSelectedRowKeys(sentences);
+                break;
+              case "empty":
+                setSelectedRowKeys(emptyRowKeys);
+                break;
+              default:
+                break;
+            }
+          }}
+          options={[
+            { value: "new", label: "仅复制较新的" },
+            { value: "empty", label: "仅复制缺失的" },
+            { value: "all", label: "全部复制" },
+          ]}
+        />
+        <Button
+          type="primary"
+          loading={loading}
+          onClick={() => {
+            if (typeof srcChannel === "undefined") {
+              return;
+            }
+            if (
+              typeof selectedRowKeys === "undefined" ||
+              selectedRowKeys.length === 0
+            ) {
+              message.warning("没有被选择的句子");
+              return;
+            }
+            setLoading(true);
+            const submitData: ISentenceDiffData[] = [];
+            selectedRowKeys?.forEach((value) => {
+              const id: string[] = value.toString().split("-");
+              const srcContent = srcApiData.find(
+                (element) =>
+                  element.book_id === parseInt(id[0]) &&
+                  element.paragraph === parseInt(id[1]) &&
+                  element.word_start === parseInt(id[2]) &&
+                  element.word_end === parseInt(id[3]) &&
+                  element.channel_uid === srcChannel.id
+              );
+              if (srcContent) {
+                submitData.push(srcContent);
+              }
+            });
+
+            if (typeof submitData === "undefined") {
+              return;
+            }
+            const url = `/api/v2/sentence`;
+            const postData = {
+              sentences: submitData,
+              channel: destChannel?.id,
+              copy: true,
+              fork_from: srcChannel.id,
+            };
+            console.info("fork post api request", url, postData);
+            post<ISentenceNewRequest, ISentenceListResponse>(url, postData)
+              .then((json) => {
+                console.info("fork api response", json);
+                if (json.ok) {
+                  //发布数据
+                  const newData: ISentence[] = json.data.rows.map((item) =>
+                    toISentence(item)
+                  );
+                  store.dispatch(accept(newData));
+                  if (onSubmit) {
+                    onSubmit(json.data.count);
+                  }
+                } else {
+                  message.error(json.message);
+                }
+              })
+              .catch((e) => {
+                console.log(e);
+                message.error("error");
+              })
+              .finally(() => {
+                setLoading(false);
+              });
+          }}
+        >
+          开始复制
+        </Button>
+      </div>
+      <div style={{ height: 400, overflowY: "scroll" }}>
+        <Table
+          pagination={false}
+          rowSelection={{
+            type: "checkbox",
+            selectedRowKeys: selectedRowKeys,
+            onChange: (
+              selectedRowKeys: React.Key[],
+              selectedRows: IDataType[]
+            ) => {
+              console.log(
+                `selectedRowKeys: ${selectedRowKeys}`,
+                "selectedRows: ",
+                selectedRows
+              );
+              setSelectedRowKeys(selectedRowKeys);
+            },
+            getCheckboxProps: (record: IDataType) => ({
+              name: record.pali ? record.pali : undefined,
+            }),
+          }}
+          columns={[
+            {
+              title: "pali",
+              width: "33%",
+              dataIndex: "pali",
+              render: (_value, record) => {
+                return (
+                  <Text>
+                    <div
+                      dangerouslySetInnerHTML={{
+                        __html: record.pali ? record.pali : "",
+                      }}
+                    />
+                  </Text>
+                );
+              },
+            },
+            {
+              title: (
+                <>
+                  {`原文-`}
+                  <Text strong>{srcChannel?.name}</Text>
+                </>
+              ),
+              width: "33%",
+              dataIndex: "srcContent",
+            },
+            {
+              title: (
+                <>
+                  {`复制到-`}
+                  <Text strong>{destChannel?.name}</Text>
+                </>
+              ),
+              width: "33%",
+              dataIndex: "destContent",
+              render: (_value, record) => {
+                const diff: Change[] = diffChars(
+                  record.destContent ? record.destContent : "",
+                  record.srcContent ? record.srcContent : ""
+                );
+                const diffResult = diff.map((item, id) => {
+                  return (
+                    <Text
+                      key={id}
+                      type={
+                        item.added
+                          ? "success"
+                          : item.removed
+                            ? "danger"
+                            : "secondary"
+                      }
+                      delete={item.removed ? true : undefined}
+                    >
+                      {item.value}
+                    </Text>
+                  );
+                });
+                return (
+                  <Tooltip title={record.destContent}>{diffResult}</Tooltip>
+                );
+              },
+            },
+          ]}
+          dataSource={diffData}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default ChannelSentDiffWidget;

+ 529 - 0
dashboard-v6/src/components/channel/ChannelTable.tsx

@@ -0,0 +1,529 @@
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
+import { Alert, message, Modal, Progress, Typography } from "antd";
+import { Button, Dropdown, Popover } from "antd";
+import {
+  PlusOutlined,
+  ExclamationCircleOutlined,
+  DeleteOutlined,
+  TeamOutlined,
+} from "@ant-design/icons";
+
+import ChannelCreate from "./ChannelCreate";
+import { delete_, get } from "../../../src/request";
+import type {
+  IApiResponseChannelList,
+  IChannel,
+  TChannelType,
+} from "../../../src/api/Channel";
+import { PublicityValueEnum } from "../studio/table";
+import type { IDeleteResponse } from "../../../src/api/Article";
+import { useEffect, useRef, useState } from "react";
+import type { IStudio, TRole } from "../../../src/api/Auth";
+import ShareModal from "../share/ShareModal";
+
+import StudioName from "../../../src/components/auth/Studio";
+import StudioSelect from "./StudioSelect";
+
+import { getSorterUrl } from "../../../src/utils";
+import TransferCreate from "../transfer/TransferCreate";
+import { TransferOutLinedIcon } from "../../../src/assets/icon";
+import { channelTypeFilter } from "./utils";
+import StatusBadge from "../general/StatusBadge";
+import { EResType } from "../share/utils";
+
+const { Text } = Typography;
+
+export interface IResNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+
+export interface IChapter {
+  book: number;
+  paragraph: number;
+}
+
+interface IChannelItem {
+  id: number;
+  uid: string;
+  title: string;
+  summary: string;
+  type: TChannelType;
+  role?: TRole;
+  studio?: IStudio;
+  publicity: number;
+  progress?: number;
+  created_at: string;
+}
+
+interface IWidget {
+  studioName?: string;
+  type?: string;
+  disableChannels?: string[];
+  channelType?: TChannelType;
+  chapter?: IChapter;
+  onSelect?: (channel: IChannel) => void;
+}
+
+const ChannelTableWidget = ({
+  studioName,
+  disableChannels,
+  channelType,
+  chapter,
+  onSelect,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("my");
+  const [myNumber, setMyNumber] = useState<number>(0);
+  const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
+  const [collaborator, setCollaborator] = useState<string>();
+  const [transfer, setTransfer] = useState<string[]>();
+  const [transferName, setTransferName] = useState<string>();
+  const [transferOpen, setTransferOpen] = useState(false);
+
+  useEffect(() => {
+    ref.current?.reload();
+  }, [disableChannels]);
+
+  useEffect(() => {
+    /**
+     * 获取各种channel的数量
+     */
+    const url = `/api/v2/channel-my-number?studio=${studioName}`;
+    console.log("url", url);
+    get<IResNumberResponse>(url).then((json) => {
+      if (json.ok) {
+        setMyNumber(json.data.my);
+        setCollaborationNumber(json.data.collaboration);
+      }
+    });
+  }, [studioName]);
+
+  const showDeleteConfirm = (id: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        const url = `/api/v2/channel/${id}`;
+        console.log("delete api request", url);
+        return delete_<IDeleteResponse>(url)
+          .then((json) => {
+            console.info("api response", json);
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      {channelType ? (
+        <Alert
+          message={`仅显示版本类型${channelType}`}
+          type="success"
+          closable
+        />
+      ) : undefined}
+      <ProTable<IChannelItem>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "id",
+            key: "id",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.title.label",
+            }),
+            dataIndex: "title",
+            width: 250,
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render: (_text, row, index) => {
+              return (
+                <>
+                  <div key={1}>
+                    <Button
+                      disabled={disableChannels?.includes(row.uid)}
+                      type="link"
+                      key={index}
+                      onClick={() => {
+                        if (typeof onSelect !== "undefined") {
+                          const channel: IChannel = {
+                            name: row.title,
+                            id: row.uid,
+                            type: row.type,
+                          };
+                          onSelect(channel);
+                        }
+                      }}
+                    >
+                      {row.title}
+                    </Button>
+                  </div>
+                  {activeKey !== "my" ? (
+                    <div key={3}>
+                      <Text type="secondary">
+                        <StudioName data={row.studio} />
+                      </Text>
+                    </div>
+                  ) : undefined}
+                </>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.created-at.label",
+            }),
+            key: "progress",
+            hideInTable: typeof chapter === "undefined",
+            render(_dom, entity) {
+              return (
+                <Progress
+                  size="small"
+                  percent={Math.floor((entity.progress ?? 0) * 100)}
+                  style={{ width: 150 }}
+                />
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.summary.label",
+            }),
+            dataIndex: "summary",
+            key: "summary",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.role.label",
+            }),
+            dataIndex: "role",
+            key: "role",
+            width: 80,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: {
+                text: intl.formatMessage({
+                  id: "channel.type.all.title",
+                }),
+                status: "Default",
+              },
+              owner: {
+                text: intl.formatMessage({
+                  id: "auth.role.owner",
+                }),
+              },
+              manager: {
+                text: intl.formatMessage({
+                  id: "auth.role.manager",
+                }),
+              },
+              editor: {
+                text: intl.formatMessage({
+                  id: "auth.role.editor",
+                }),
+              },
+              member: {
+                text: intl.formatMessage({
+                  id: "auth.role.member",
+                }),
+              },
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.type.label",
+            }),
+            dataIndex: "type",
+            key: "type",
+            width: 80,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: channelTypeFilter(intl),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.publicity.label",
+            }),
+            dataIndex: "publicity",
+            key: "publicity",
+            width: 80,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: PublicityValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.created-at.label",
+            }),
+            key: "created_at",
+            width: 100,
+            search: false,
+            dataIndex: "created_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 100,
+            valueType: "option",
+            hideInTable: activeKey !== "my",
+            render: (_text, row, index) => {
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  trigger={["click", "contextMenu"]}
+                  menu={{
+                    items: [
+                      {
+                        key: "share",
+                        label: (
+                          <ShareModal
+                            trigger={intl.formatMessage({
+                              id: "buttons.share",
+                            })}
+                            resId={row.uid}
+                            resType={EResType.channel}
+                          />
+                        ),
+                        icon: <TeamOutlined />,
+                      },
+                      {
+                        key: "transfer",
+                        label: intl.formatMessage({
+                          id: "columns.studio.transfer.title",
+                        }),
+                        icon: <TransferOutLinedIcon />,
+                      },
+                      {
+                        key: "remove",
+                        label: intl.formatMessage({
+                          id: "buttons.delete",
+                        }),
+                        icon: <DeleteOutlined />,
+                        danger: true,
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "remove":
+                          showDeleteConfirm(row.uid, row.title);
+                          break;
+                        case "transfer":
+                          setTransfer([row.uid]);
+                          setTransferName(row.title);
+                          setTransferOpen(true);
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <Link to={`/workspace/channel/${row.uid}/setting`}>
+                    {intl.formatMessage({
+                      id: "buttons.setting",
+                    })}
+                  </Link>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/api/v2/channel?`;
+          if (activeKey === "community") {
+            url += `view=public`;
+          } else {
+            url += `view=studio&view2=${activeKey}&name=${studioName}`;
+          }
+          if (chapter) {
+            url += `&book=${chapter.book}&paragraph=${chapter.paragraph}`;
+          }
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+
+          url += collaborator ? "&collaborator=" + collaborator : "";
+          url += params.keyword ? "&search=" + params.keyword : "";
+          url += channelType ? "&type=" + channelType : "";
+          if (chapter && activeKey === "community") {
+            url += "&order=progress";
+          } else {
+            url += getSorterUrl(sorter);
+          }
+
+          console.log("url", url);
+          const res: IApiResponseChannelList = await get(url);
+          const items: IChannelItem[] = res.data.rows.map((item, id) => {
+            return {
+              id: id + 1,
+              uid: item.uid,
+              title: item.name,
+              summary: item.summary,
+              type: item.type,
+              role: item.role,
+              progress: item.progress,
+              studio: item.studio,
+              publicity: item.status,
+              created_at: item.created_at,
+            };
+          });
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          activeKey !== "my" ? (
+            <StudioSelect
+              studioName={studioName}
+              onSelect={(value: string) => {
+                setCollaborator(value);
+                ref.current?.reload();
+              }}
+            />
+          ) : undefined,
+          <Popover
+            content={
+              <ChannelCreate
+                studio={studioName}
+                onSuccess={() => {
+                  setOpenCreate(false);
+                  ref.current?.reload();
+                }}
+              />
+            }
+            placement="bottomRight"
+            trigger="click"
+            open={openCreate}
+            onOpenChange={(open: boolean) => {
+              setOpenCreate(open);
+            }}
+          >
+            <Button key="button" icon={<PlusOutlined />} type="primary">
+              {intl.formatMessage({ id: "buttons.create" })}
+            </Button>
+          </Popover>,
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "my",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.this-studio" })}
+                    <StatusBadge count={myNumber} active={activeKey === "my"} />
+                  </span>
+                ),
+              },
+              {
+                key: "collaboration",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.collaboration" })}
+                    <StatusBadge
+                      count={collaborationNumber}
+                      active={activeKey === "collaboration"}
+                    />
+                  </span>
+                ),
+              },
+              {
+                key: "community",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.community" })}
+                    <StatusBadge
+                      count={collaborationNumber}
+                      active={activeKey === "community"}
+                    />
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              setCollaborator(undefined);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+      <TransferCreate
+        studioName={studioName}
+        resId={transfer}
+        resType="channel"
+        resName={transferName}
+        open={transferOpen}
+        onOpenChange={(visible: boolean) => setTransferOpen(visible)}
+      />
+    </>
+  );
+};
+
+export default ChannelTableWidget;

+ 96 - 0
dashboard-v6/src/components/channel/ChannelTableModal.tsx

@@ -0,0 +1,96 @@
+import React, { useEffect, useState } from "react";
+import { Modal } from "antd";
+
+import ChannelTable, { type IChapter } from "./ChannelTable";
+import { useAppSelector } from "../../../src/hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+
+import type { IChannel, TChannelType } from "../../api/Channel";
+import { useIntl } from "react-intl";
+import type { ArticleType } from "../../api/Corpus";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  channelType?: TChannelType;
+  type?: ArticleType | "editable";
+  articleId?: string;
+  multiSelect?: boolean;
+  disableChannels?: string[];
+  open?: boolean;
+  chapter?: IChapter;
+  onClose?: () => void;
+  onSelect?: (channel: IChannel) => void;
+}
+const ChannelTableModalWidget = ({
+  trigger,
+  type,
+  disableChannels,
+  channelType,
+  open = false,
+  chapter,
+  onClose,
+  onSelect,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+  const intl = useIntl();
+  const user = useAppSelector(_currentUser);
+
+  useEffect(() => {
+    setIsModalOpen(open);
+  }, [open]);
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"90%"}
+        title={intl.formatMessage({
+          id: "buttons.select.channel",
+        })}
+        destroyOnClose
+        footer={false}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <div style={{ overflowX: "scroll" }}>
+          <ChannelTable
+            studioName={user?.realName}
+            type={type}
+            chapter={chapter}
+            channelType={channelType}
+            disableChannels={disableChannels}
+            onSelect={(channel: IChannel) => {
+              handleCancel();
+              if (typeof onClose !== "undefined") {
+                onClose();
+              }
+              if (typeof onSelect !== "undefined") {
+                onSelect(channel);
+              }
+            }}
+          />
+        </div>
+      </Modal>
+    </>
+  );
+};
+
+export default ChannelTableModalWidget;

+ 54 - 0
dashboard-v6/src/components/channel/ChannelTypeSelect.tsx

@@ -0,0 +1,54 @@
+import { useIntl } from "react-intl";
+import { ProFormSelect } from "@ant-design/pro-components";
+
+interface IWidget {
+  readonly?: boolean;
+}
+
+const ChannelTypeSelectWidget = ({ readonly }: IWidget) => {
+  const intl = useIntl();
+
+  const channelTypeOptions = [
+    {
+      value: "translation",
+      label: intl.formatMessage({ id: "channel.type.translation.label" }),
+    },
+    {
+      value: "nissaya",
+      label: intl.formatMessage({ id: "channel.type.nissaya.label" }),
+    },
+    {
+      value: "commentary",
+      label: intl.formatMessage({ id: "channel.type.commentary.label" }),
+    },
+    {
+      value: "original",
+      label: intl.formatMessage({ id: "channel.type.original.label" }),
+    },
+    {
+      value: "similar",
+      label: intl.formatMessage({ id: "channel.type.similar.label" }),
+    },
+  ];
+  return (
+    <ProFormSelect
+      options={channelTypeOptions}
+      initialValue="translation"
+      width="xs"
+      name="type"
+      readonly={readonly}
+      allowClear={false}
+      label={intl.formatMessage({ id: "channel.type" })}
+      rules={[
+        {
+          required: true,
+          message: intl.formatMessage({
+            id: "channel.type.message.required",
+          }),
+        },
+      ]}
+    />
+  );
+};
+
+export default ChannelTypeSelectWidget;

+ 215 - 0
dashboard-v6/src/components/channel/ChapterInChannelList.tsx

@@ -0,0 +1,215 @@
+import { useIntl } from "react-intl";
+import { Progress, Typography } from "antd";
+import { ProTable } from "@ant-design/pro-components";
+import { Link } from "react-router";
+import { Button, Dropdown } from "antd";
+import { DeleteOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+
+import type { IChapterListResponse } from "../../api/Corpus";
+import type { IArticleParam } from "../../types/article";
+
+const { Text } = Typography;
+
+interface IItem {
+  sn: number;
+  title: string;
+  subTitle: string;
+  summary: string;
+  book: number;
+  paragraph: number;
+  path: string;
+  progress: number;
+  view: number;
+  created_at: string;
+  updated_at: string;
+}
+interface IWidget {
+  channelId?: string;
+  onSelect?: (
+    event: React.MouseEvent<HTMLElement, MouseEvent>,
+    chapter: IArticleParam
+  ) => void;
+}
+const ChapterInChannelListWidget = ({ channelId, onSelect }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <ProTable<IItem>
+      columns={[
+        {
+          title: intl.formatMessage({
+            id: "dict.fields.sn.label",
+          }),
+          dataIndex: "sn",
+          key: "sn",
+          width: 50,
+          search: false,
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.title.label",
+          }),
+          dataIndex: "title",
+          key: "title",
+          tooltip: "过长会自动收缩",
+          ellipsis: true,
+          render: (_text, row, index) => {
+            return (
+              <div key={index}>
+                <div key={1}>
+                  <Button
+                    type="link"
+                    onClick={(event) => {
+                      if (typeof onSelect !== "undefined") {
+                        const chapter: IArticleParam = {
+                          type: "chapter",
+                          articleId: `${row.book}-${row.paragraph}`,
+                          mode: "read",
+                          channelId: channelId,
+                        };
+                        onSelect(event, chapter);
+                      }
+                    }}
+                  >
+                    {row.title ? row.title : row.subTitle}
+                  </Button>
+                </div>
+                <Text type="secondary" key={2}>
+                  {row.subTitle}
+                </Text>
+              </div>
+            );
+          },
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.summary.label",
+          }),
+          dataIndex: "summary",
+          key: "summary",
+          tooltip: "过长会自动收缩",
+          ellipsis: true,
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.publicity.label",
+          }),
+          dataIndex: "progress",
+          key: "progress",
+          width: 100,
+          search: false,
+          render: (_text, row, index) => {
+            const per = Math.round(row.progress * 100);
+            return <Progress percent={per} size="small" key={index} />;
+          },
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.publicity.label",
+          }),
+          dataIndex: "view",
+          key: "view",
+          width: 100,
+          search: false,
+        },
+        {
+          title: intl.formatMessage({
+            id: "forms.fields.created-at.label",
+          }),
+          key: "created-at",
+          width: 100,
+          search: false,
+          dataIndex: "created_at",
+          valueType: "date",
+          sorter: false,
+        },
+        {
+          title: intl.formatMessage({ id: "buttons.option" }),
+          key: "option",
+          width: 120,
+          valueType: "option",
+          render: (_text, row, index) => {
+            let editLink = `/article/chapter/${row.book}-${row.paragraph}?mode=edit`;
+            editLink += channelId ? `&channel=${channelId}` : "";
+            return [
+              <Dropdown.Button
+                key={index}
+                type="link"
+                menu={{
+                  items: [
+                    {
+                      key: "remove",
+                      disabled: true,
+                      danger: true,
+                      label: intl.formatMessage({
+                        id: "buttons.delete",
+                      }),
+                      icon: <DeleteOutlined />,
+                    },
+                  ],
+                  onClick: (e) => {
+                    switch (e.key) {
+                      case "remove":
+                        break;
+                      default:
+                        break;
+                    }
+                  },
+                }}
+              >
+                <Link to={editLink}>
+                  {intl.formatMessage({
+                    id: "buttons.translate",
+                  })}
+                </Link>
+              </Dropdown.Button>,
+            ];
+          },
+        },
+      ]}
+      request={async (params = {}, sorter, filter) => {
+        // TODO 加排序
+        console.log(params, sorter, filter);
+        const offset = ((params.current || 1) - 1) * (params.pageSize || 20);
+        const res = await get<IChapterListResponse>(
+          `/api/v2/progress?view=chapter&channel=${channelId}&progress=0.01&offset=${offset}`
+        );
+        console.log(res.data.rows);
+        const items: IItem[] = res.data.rows.map((item, id) => {
+          return {
+            sn: id + offset + 1,
+            book: item.book,
+            paragraph: item.para,
+            view: item.view,
+            title: item.title,
+            subTitle: item.toc,
+            summary: item.summary,
+            path: item.path,
+            progress: item.progress,
+            created_at: item.created_at,
+            updated_at: item.updated_at,
+          };
+        });
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: items,
+        };
+      }}
+      rowKey="id"
+      bordered
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+      }}
+      search={false}
+      options={{
+        search: true,
+      }}
+    />
+  );
+};
+
+export default ChapterInChannelListWidget;

+ 77 - 0
dashboard-v6/src/components/channel/CopyToModal.tsx

@@ -0,0 +1,77 @@
+import { useEffect, useState, type JSX } from "react";
+import { Modal } from "antd";
+
+import CopyToStep from "./CopyToStep";
+import type { IChannel } from "../../api/Channel";
+
+interface IWidget {
+  trigger?: JSX.Element | string;
+  channel?: IChannel;
+  sentencesId?: string[];
+  open?: boolean;
+  important?: boolean;
+  onClose?: () => void;
+}
+const CopyToModal = ({
+  trigger,
+  channel,
+  sentencesId,
+  open,
+  important = false,
+  onClose,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+  const [initStep, setInitStep] = useState(0);
+
+  useEffect(() => {
+    setIsModalOpen(open);
+  }, [open]);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+    setInitStep(0);
+  };
+
+  const modalClose = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+  const handleOk = () => {
+    modalClose();
+  };
+
+  const handleCancel = () => {
+    modalClose();
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"95%"}
+        style={{ maxWidth: 1500 }}
+        title="版本间复制"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        destroyOnHidden={true}
+        footer={[]}
+      >
+        <CopyToStep
+          initStep={initStep}
+          channel={channel}
+          sentencesId={sentencesId}
+          important={important}
+          onClose={() => {
+            setIsModalOpen(false);
+            Modal.destroyAll();
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default CopyToModal;

+ 41 - 0
dashboard-v6/src/components/channel/CopyToResult.tsx

@@ -0,0 +1,41 @@
+import { Button, Result } from "antd";
+
+interface IWidget {
+  total?: number;
+  onClose?: () => void;
+  onInit?: () => void;
+}
+const CopyToResult = ({ total, onClose, onInit }: IWidget) => {
+  return (
+    <Result
+      status="success"
+      title="Successfully Copied!"
+      subTitle={`Sentence: ${total}`}
+      extra={[
+        <Button
+          key="init"
+          onClick={() => {
+            if (typeof onInit !== "undefined") {
+              onInit();
+            }
+          }}
+        >
+          从新开始
+        </Button>,
+        <Button
+          key="close"
+          type="primary"
+          onClick={() => {
+            if (typeof onClose !== "undefined") {
+              onClose();
+            }
+          }}
+        >
+          关闭
+        </Button>,
+      ]}
+    />
+  );
+};
+
+export default CopyToResult;

+ 120 - 0
dashboard-v6/src/components/channel/CopyToStep.tsx

@@ -0,0 +1,120 @@
+import { Steps } from "antd";
+import { useEffect, useState } from "react";
+
+import ChannelPickerTable from "./ChannelPickerTable";
+import ChannelSentDiff from "./ChannelSentDiff";
+import CopyToResult from "./CopyToResult";
+import type { IChannel } from "../../api/Channel";
+import type { ArticleType } from "../../api/Corpus";
+
+interface IWidget {
+  initStep?: number;
+  channel?: IChannel;
+  type?: ArticleType;
+  articleId?: string;
+  sentencesId?: string[];
+  important?: boolean;
+  onClose?: () => void;
+}
+const CopyToStepWidget = ({
+  initStep = 0,
+  channel,
+  sentencesId,
+  important = false,
+  onClose,
+}: IWidget) => {
+  const [current, setCurrent] = useState(0);
+  const [destChannel, setDestChannel] = useState<IChannel>();
+  const [copyPercent, setCopyPercent] = useState<number>();
+  const [total, setTotal] = useState<number>();
+
+  useEffect(() => {
+    setCurrent(initStep);
+  }, [initStep]);
+
+  const next = () => {
+    setCurrent(current + 1);
+  };
+
+  const prev = () => {
+    setCurrent(current - 1);
+  };
+  const contentStyle: React.CSSProperties = {
+    borderRadius: 5,
+    border: `1px dashed gray`,
+    marginTop: 16,
+    height: 400,
+    overflowY: "scroll",
+  };
+  const steps = [
+    {
+      title: "选择目标版本",
+      key: "channel",
+      content: (
+        <div style={contentStyle}>
+          <ChannelPickerTable
+            type="editable"
+            disableChannelId={channel?.id}
+            multiSelect={false}
+            onSelect={(e: IChannel[]) => {
+              console.log("channel", e);
+              if (e.length > 0) {
+                setDestChannel(e[0]);
+                setCopyPercent(100);
+                next();
+              }
+            }}
+          />
+        </div>
+      ),
+    },
+    {
+      title: "文本比对",
+      key: "diff",
+      content: (
+        <ChannelSentDiff
+          srcChannel={channel}
+          destChannel={destChannel}
+          sentences={sentencesId}
+          important={important}
+          goPrev={() => {
+            prev();
+          }}
+          onSubmit={(total: number) => {
+            setTotal(total);
+            next();
+          }}
+        />
+      ),
+    },
+    {
+      title: "完成",
+      key: "finish",
+      content: (
+        <div style={contentStyle}>
+          <CopyToResult
+            total={total}
+            onClose={() => {
+              if (typeof onClose !== "undefined") {
+                onClose();
+              }
+            }}
+            onInit={() => {
+              setCurrent(0);
+            }}
+          />
+        </div>
+      ),
+    },
+  ];
+  const items = steps.map((item) => ({ key: item.key, title: item.title }));
+
+  return (
+    <div>
+      <Steps current={current} items={items} percent={copyPercent} />
+      {steps[current].content}
+    </div>
+  );
+};
+
+export default CopyToStepWidget;

+ 134 - 0
dashboard-v6/src/components/channel/Edit.tsx

@@ -0,0 +1,134 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { Alert, message } from "antd";
+
+import type {
+  IApiResponseChannel,
+  IApiResponseChannelData,
+} from "../../api/Channel";
+import { get, put } from "../../request";
+import ChannelTypeSelect from "./ChannelTypeSelect";
+import LangSelect from "../general/LangSelect";
+import PublicitySelect from "../studio/PublicitySelect";
+import { useState } from "react";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+
+interface IFormData {
+  name: string;
+  type: string;
+  lang: string;
+  summary: string;
+  status: number;
+  studio: string;
+  isSystem: boolean;
+}
+interface IWidget {
+  studioName?: string;
+  channelId?: string;
+  onLoad?: (data: IApiResponseChannelData) => void;
+}
+const EditWidget = ({ studioName, channelId, onLoad }: IWidget) => {
+  const intl = useIntl();
+  const [isSystem, setIsSystem] = useState<boolean>();
+  const [data, setData] = useState<IApiResponseChannelData>();
+
+  const user = useAppSelector(currentUser);
+  return (
+    <>
+      {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(`/api/v2/channel/${channelId}`, values);
+          console.log(res);
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+        }}
+        formKey="channel_edit"
+        request={async () => {
+          const res = await get<IApiResponseChannel>(
+            `/api/v2/channel/${channelId}`
+          );
+          if (res.ok === false) {
+            return {
+              name: "",
+              type: "",
+              lang: "",
+              summary: "",
+              status: 0,
+              studio: "",
+              isSystem: true,
+            };
+          }
+          setData(res.data);
+          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 readonly={isSystem ? true : false} />
+          <LangSelect readonly={isSystem ? true : false} />
+        </ProForm.Group>
+        <ProForm.Group>
+          <PublicitySelect
+            readonly={
+              isSystem || user?.roles?.includes("basic") || data?.status === 5
+                ? true
+                : false
+            }
+            disable={["public_no_list"]}
+          />
+        </ProForm.Group>
+
+        <ProForm.Group>
+          <ProFormTextArea
+            readonly={isSystem ? true : false}
+            width="md"
+            name="summary"
+            label="简介"
+          />
+        </ProForm.Group>
+      </ProForm>
+    </>
+  );
+};
+
+export default EditWidget;

+ 95 - 0
dashboard-v6/src/components/channel/ProgressSvg.tsx

@@ -0,0 +1,95 @@
+import type { IFinal } from "../../../src/api/Channel";
+
+interface IWidget {
+  data?: IFinal[];
+  width?: number;
+}
+const ProgressSvgWidget = ({ data, width = 300 }: IWidget) => {
+  //绘制句子进度
+  if (typeof data === "undefined" || data.length === 0) {
+    return <></>;
+  }
+  //进度
+  let svg_width = 0;
+  if (data) {
+    for (const iterator of data) {
+      svg_width += iterator[0];
+    }
+  }
+
+  const svg_height = svg_width / 10;
+
+  let curr_x = 0;
+  let finished = 0;
+
+  const innerBar = data?.map((item, id) => {
+    const stroke_width = item[0];
+    curr_x += stroke_width;
+    finished += item[1] ? stroke_width : 0;
+    return (
+      <rect
+        key={id}
+        x={curr_x - stroke_width}
+        y={0}
+        height={svg_height}
+        width={stroke_width}
+        fill={item[1] ? "url(#grad1)" : "url(#grad2)"}
+      />
+    );
+  });
+  const finishedBar = (
+    <rect
+      key="2"
+      x={0}
+      y={svg_height / 2 - svg_height / 20}
+      width={finished}
+      height={svg_height / 10}
+      style={{ strokeWidth: 0, fill: "rgb(100, 100, 228)" }}
+    />
+  );
+  const progress = (
+    <svg viewBox={`0 0 ${svg_width} ${svg_height} `} width={"100%"}>
+      <defs>
+        <linearGradient key="1" id="grad1" x1="0%" y1="0%" x2="0%" y2="100%">
+          <stop
+            key="1"
+            offset="0%"
+            style={{ stopColor: "rgb(0,180,0)", stopOpacity: 1 }}
+          />
+          <stop
+            key="2"
+            offset="50%"
+            style={{ stopColor: "rgb(255,255,255)", stopOpacity: 0.5 }}
+          />
+          <stop
+            key="3"
+            offset="100%"
+            style={{ stopColor: "rgb(0,180,0)", stopOpacity: 1 }}
+          />
+        </linearGradient>
+        <linearGradient key="2" id="grad2" x1="0%" y1="0%" x2="0%" y2="100%">
+          <stop
+            key="1"
+            offset="0%"
+            style={{ stopColor: "rgb(180,180,180)", stopOpacity: 1 }}
+          />
+          <stop
+            key="2"
+            offset="50%"
+            style={{ stopColor: "rgb(255,255,255)", stopOpacity: 0.5 }}
+          />
+          <stop
+            key="3"
+            offset="100%"
+            style={{ stopColor: "rgb(180,180,180)", stopOpacity: 1 }}
+          />
+        </linearGradient>
+      </defs>
+      {innerBar}
+      {finishedBar}
+    </svg>
+  );
+  return <div style={{ width: width }}>{progress}</div>;
+};
+
+export default ProgressSvgWidget;

+ 64 - 0
dashboard-v6/src/components/channel/StudioSelect.tsx

@@ -0,0 +1,64 @@
+import { Select } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../../src/request";
+import type { IStudio } from "../../api/Auth";
+
+interface IStudioListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IStudio[];
+    count: number;
+  };
+}
+
+interface IOptions {
+  value: string;
+  label?: string;
+}
+interface IWidget {
+  studioName?: string;
+  onSelect?: (value: string) => void;
+}
+const StudioSelectWidget = ({ studioName, onSelect }: IWidget) => {
+  const [anthology, setAnthology] = useState<IOptions[]>([
+    { value: "all", label: "全部" },
+  ]);
+  useEffect(() => {
+    const url = `/api/v2/studio?view=collaboration-channel&studio_name=${studioName}`;
+    get<IStudioListResponse>(url).then((json) => {
+      if (json.ok) {
+        const data = json.data.rows
+          .sort((a, b) => {
+            if (a.nickName && b.nickName && a.nickName < b.nickName) {
+              return -1;
+            } else {
+              return 1;
+            }
+          })
+          .map((item) => {
+            return {
+              value: item.id,
+              label: item.nickName,
+            };
+          });
+        setAnthology([{ value: "all", label: "全部" }, ...data]);
+      }
+    });
+  }, [studioName]);
+  return (
+    <Select
+      defaultValue="all"
+      style={{ width: 180 }}
+      onChange={(value: string) => {
+        console.log(`selected ${value}`);
+        if (onSelect) {
+          onSelect(value);
+        }
+      }}
+      options={anthology}
+    />
+  );
+};
+
+export default StudioSelectWidget;

+ 39 - 0
dashboard-v6/src/components/channel/utils.ts

@@ -0,0 +1,39 @@
+import type { IntlShape } from "react-intl";
+
+import type { ProSchemaValueEnumObj } from "@ant-design/pro-components";
+
+export const getSentIdInArticle = () => {
+  const sentList: string[] = [];
+  const sentElement = document.querySelectorAll(".pcd_sent");
+  for (let index = 0; index < sentElement.length; index++) {
+    const element = sentElement[index];
+    const id = element.id.split("_")[1];
+    sentList.push(id);
+  }
+  return sentList;
+};
+
+export const channelTypeFilter = (intl: IntlShape): ProSchemaValueEnumObj => {
+  return {
+    all: {
+      text: intl.formatMessage({ id: "channel.type.all.title" }),
+      status: "default",
+    },
+    translation: {
+      text: intl.formatMessage({ id: "channel.type.translation.label" }),
+      status: "success",
+    },
+    nissaya: {
+      text: intl.formatMessage({ id: "channel.type.nissaya.label" }),
+      status: "processing",
+    },
+    commentary: {
+      text: intl.formatMessage({ id: "channel.type.commentary.label" }),
+      status: "default",
+    },
+    original: {
+      text: intl.formatMessage({ id: "channel.type.original.label" }),
+      status: "default",
+    },
+  };
+};

+ 35 - 0
dashboard-v6/src/components/general/BeiAn.tsx

@@ -0,0 +1,35 @@
+import { Space } from "antd";
+import { useIntl } from "react-intl";
+import logo_mps from "../../assets/general/images/logo_mps.png";
+
+const BeiAnWidget = () => {
+  const intl = useIntl();
+  return (
+    <>
+      {import.meta.env.VITE_REACT_APP_ICP_CODE ? (
+        <Space>
+          <span>
+            {intl.formatMessage({
+              id: `labels.icp`,
+            })}
+            <a
+              href="https://beian.miit.gov.cn/"
+              target="_blank"
+              rel="noreferrer"
+            >
+              {import.meta.env.VITE_REACT_APP_ICP_CODE}
+            </a>
+          </span>
+          <Space>
+            <img alt="code" src={logo_mps} style={{ width: 20, height: 20 }} />
+            {import.meta.env.VITE_REACT_APP_MPS_CODE
+              ? import.meta.env.VITE_REACT_APP_MPS_CODE
+              : "滇公网安备[审批中]号"}
+          </Space>
+        </Space>
+      ) : undefined}
+    </>
+  );
+};
+
+export default BeiAnWidget;

+ 106 - 0
dashboard-v6/src/components/general/EditableLabel.tsx

@@ -0,0 +1,106 @@
+import { useEffect, useRef, useState } from "react";
+import "./style.css";
+
+interface IWidget {
+  defaultValue?: string;
+  value?: string;
+  placeholder?: string;
+  style?: React.CSSProperties;
+  onChange?: (event: React.ChangeEvent<HTMLTextAreaElement, Element>) => void;
+  onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
+  onBlur?: (event: React.FocusEvent<HTMLTextAreaElement, Element>) => void;
+  onPressEnter?: (
+    event: React.KeyboardEvent<HTMLTextAreaElement>,
+    textAreaValue?: string
+  ) => void;
+}
+const EditableLabelWidget = ({
+  defaultValue,
+  value,
+  placeholder,
+  style,
+  onChange,
+  onKeyDown,
+  onBlur,
+  onPressEnter,
+}: IWidget) => {
+  const [textAreaValue, setTextAreaValue] = useState(defaultValue);
+  const [shadowText, setShadowText] = useState(defaultValue);
+  const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(31);
+  const shadowHeight = 31;
+  const refTextArea = useRef<HTMLTextAreaElement>(null);
+  const refShadow = useRef<HTMLDivElement>(null);
+  useEffect(() => {
+    setTextAreaValue(value);
+    setShadowText(value);
+  }, [value]);
+  useEffect(() => {
+    setTextAreaHeight(refShadow.current?.clientHeight);
+  }, []);
+
+  return (
+    <div className="text_input" style={style}>
+      <div
+        ref={refShadow}
+        className="textarea text_shadow"
+        style={{
+          height: shadowHeight,
+          display: "inline-block",
+          minHeight: "1em",
+          minWidth: "15em",
+        }}
+      >
+        {shadowText}
+      </div>
+      <textarea
+        className="textarea tran_sent_textarea"
+        ref={refTextArea}
+        style={{
+          height: textAreaHeight,
+          display: "inline-block",
+          minHeight: "1em",
+          minWidth: "15em",
+          resize: "none",
+          overflow: "hidden",
+          borderBottom: "1px solid",
+          borderRadius: 0,
+        }}
+        placeholder={placeholder}
+        value={textAreaValue}
+        onBlur={(event: React.FocusEvent<HTMLTextAreaElement, Element>) => {
+          if (typeof onBlur !== "undefined") {
+            onBlur(event);
+          }
+        }}
+        onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
+          setTextAreaValue(event.target.value);
+          setShadowText(event.target.value);
+          if (typeof onChange !== "undefined") {
+            onChange(event);
+          }
+        }}
+        onKeyDown={(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
+          if (typeof onKeyDown !== "undefined") {
+            onKeyDown(event);
+          }
+          if (event.key === "Enter" && typeof onPressEnter !== "undefined") {
+            onPressEnter(event, textAreaValue);
+          }
+        }}
+        onKeyUp={() => {
+          //自适应高度
+          if (
+            refShadow.current === null ||
+            refTextArea.current === null ||
+            refTextArea.current.parentElement === null
+          ) {
+            return;
+          }
+          setTextAreaHeight(refShadow.current.scrollHeight);
+        }}
+      />
+    </div>
+  );
+};
+
+export default EditableLabelWidget;

+ 49 - 0
dashboard-v6/src/components/general/ErrorResult.tsx

@@ -0,0 +1,49 @@
+import { Result } from "antd";
+import type { ResultStatusType } from "antd/lib/result"
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  code: number;
+  message?: string;
+}
+
+const ErrorResultWidget = ({ code, message }: IWidget) => {
+  const intl = useIntl();
+  let strStatus: ResultStatusType;
+  let strTitle: string = "";
+  switch (code) {
+    case 401:
+      strStatus = 403;
+      strTitle = intl.formatMessage({ id: "labels.error.401" });
+      break;
+    case 403:
+      strStatus = 403;
+      strTitle = intl.formatMessage({ id: "labels.error.403" });
+      break;
+    case 404:
+      strStatus = 404;
+      strTitle = intl.formatMessage({ id: "labels.error.404" });
+      break;
+    case 500:
+      strStatus = 500;
+      strTitle = intl.formatMessage({ id: "labels.error.500" });
+      break;
+    case 429:
+      strStatus = "error";
+      strTitle = intl.formatMessage({ id: "labels.error.429" });
+      break;
+    default:
+      strStatus = "error";
+      strTitle = "无法识别的错误代码" + code;
+      break;
+  }
+  return (
+    <Result
+      status={strStatus}
+      title={strTitle}
+      subTitle={message ? message : "Sorry, something went wrong."}
+    />
+  );
+};
+
+export default ErrorResultWidget;

+ 21 - 0
dashboard-v6/src/components/general/Feedback.tsx

@@ -0,0 +1,21 @@
+const FeedbackWidget = () => {
+  return (
+    <>
+      {import.meta.env.VITE_REACT_APP_QUESTIONNAIRE_LINK ? (
+        <span>
+          请点击
+          <a
+            href={import.meta.env.VITE_REACT_APP_QUESTIONNAIRE_LINK}
+            target="_blank"
+            rel="noreferrer"
+          >
+            腾讯问卷
+          </a>
+          填写您的反馈
+        </span>
+      ) : undefined}
+    </>
+  );
+};
+
+export default FeedbackWidget;

+ 24 - 0
dashboard-v6/src/components/general/FileSize.tsx

@@ -0,0 +1,24 @@
+interface IWidget {
+  size?: number;
+}
+const FileSizeWidget = ({ size = 0 }: IWidget) => {
+  let strSize = 0;
+  let end = "";
+  if (size > Math.pow(1024, 3)) {
+    strSize = size / Math.pow(1024, 3);
+    end = "GB";
+  } else if (size > Math.pow(1024, 2)) {
+    strSize = size / Math.pow(1024, 2);
+    end = "MB";
+  } else if (size > Math.pow(1024, 1)) {
+    strSize = size / Math.pow(1024, 1);
+    end = "KB";
+  } else {
+    strSize = size;
+    end = "B";
+  }
+  const output = strSize.toString().substring(0, 4) + end;
+  return <>{output}</>;
+};
+
+export default FileSizeWidget;

+ 87 - 0
dashboard-v6/src/components/general/LangSelect.tsx

@@ -0,0 +1,87 @@
+import { useIntl } from "react-intl";
+import { ProFormSelect } from "@ant-design/pro-components";
+
+export const LangValueEnum = () => {
+  const intl = useIntl();
+  return {
+    all: {
+      text: intl.formatMessage({
+        id: "forms.fields.publicity.all.label",
+      }),
+      status: "Default",
+    },
+    en: {
+      text: "English",
+    },
+    "zh-Hans": { text: "简体中文" },
+    "zh-Hant": {
+      text: "繁体中文",
+    },
+    my: {
+      text: "缅文",
+    },
+  };
+};
+
+interface IWidget {
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  label?: string;
+  disabled?: boolean;
+  required?: boolean;
+  name?: string;
+  readonly?: boolean;
+}
+const LangSelectWidget = ({
+  width,
+  label,
+  disabled = false,
+  required = true,
+  name = "lang",
+  readonly,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const langOptions = [
+    {
+      value: "en-US",
+      label: "English",
+    },
+    {
+      value: "zh-Hans",
+      label: "简体中文 zh-Hans",
+    },
+    {
+      value: "zh-Hant",
+      label: "繁体中文 zh-Hant",
+    },
+    {
+      value: "my",
+      label: "缅文 my",
+    },
+  ];
+  return (
+    <ProFormSelect
+      options={langOptions}
+      width={width}
+      name={name}
+      readonly={readonly}
+      showSearch
+      debounceTime={300}
+      allowClear={false}
+      disabled={disabled}
+      label={
+        label ? label : intl.formatMessage({ id: "forms.fields.lang.label" })
+      }
+      rules={[
+        {
+          required: required,
+          message: intl.formatMessage({
+            id: "forms.message.lang.required",
+          }),
+        },
+      ]}
+    />
+  );
+};
+
+export default LangSelectWidget;

+ 25 - 0
dashboard-v6/src/components/general/Marked.tsx

@@ -0,0 +1,25 @@
+import { Typography } from "antd";
+import { marked } from "marked";
+
+const { Text } = Typography;
+
+interface IWidget {
+  text?: string;
+  style?: React.CSSProperties;
+  className?: string;
+}
+const MarkedWidget = ({ text, style, className }: IWidget) => {
+  return (
+    <Text className={className}>
+      <div
+        style={style}
+        className={className}
+        dangerouslySetInnerHTML={{
+          __html: marked.parse(text ? text : ""),
+        }}
+      />
+    </Text>
+  );
+};
+
+export default MarkedWidget;

+ 20 - 0
dashboard-v6/src/components/general/Mermaid.tsx

@@ -0,0 +1,20 @@
+import lodash from "lodash";
+import mermaid from "mermaid";
+
+interface IWidget {
+  text?: string;
+}
+const MermaidWidget = ({ text }: IWidget) => {
+  const id = lodash.times(20, () => lodash.random(35).toString(36)).join("");
+  const graph = mermaid.render(`g-${id}`, text ? text : "");
+
+  return (
+    <div
+      dangerouslySetInnerHTML={{
+        __html: graph,
+      }}
+    />
+  );
+};
+
+export default MermaidWidget;

+ 61 - 0
dashboard-v6/src/components/general/NetStatus.tsx

@@ -0,0 +1,61 @@
+import { Button } from "antd";
+import { CloudOutlined } from "@ant-design/icons";
+import { useEffect } from "react";
+import { useAppSelector } from "../../hooks";
+import { netStatus } from "../../reducers/net-status";
+
+interface IWidget {
+  style?: React.CSSProperties;
+}
+const NetStatusWidget = ({ style }: IWidget) => {
+  const mNetStatus = useAppSelector(netStatus);
+
+  useEffect(() => {
+    // 监听网络连接状态变化
+    const onOnline = () => console.info("网络连接已恢复");
+    const onOffline = () => console.info("网络连接已中断");
+
+    window.addEventListener("online", onOnline);
+    window.addEventListener("offline", onOffline);
+
+    return () => {
+      window.removeEventListener("online", onOnline);
+      window.removeEventListener("offline", onOffline);
+    };
+  }, []);
+
+  let loading = false;
+  console.log("net status", mNetStatus);
+  switch (mNetStatus?.status) {
+    case "loading":
+      loading = true;
+      break;
+    case "success":
+      loading = false;
+      break;
+    case "fail":
+      loading = false;
+      break;
+    default:
+      break;
+  }
+  let label = "online";
+  if (mNetStatus?.message) {
+    label = mNetStatus?.message;
+  }
+
+  return (
+    <>
+      <Button
+        style={style}
+        type="text"
+        loading={loading}
+        icon={<CloudOutlined />}
+      >
+        {label}
+      </Button>
+    </>
+  );
+};
+
+export default NetStatusWidget;

+ 5972 - 0
dashboard-v6/src/components/general/PaliEnding.ts

@@ -0,0 +1,5972 @@
+export const getPaliBase = (word: string): string[] => {
+  const parent = new Map<string, number>();
+  paliEnding.forEach((value) => {
+    if (value.type !== ".v." && !value.grammar.includes(".voc.")) {
+      const sEnd2 = word.slice(0 - value.end2.length);
+      if (sEnd2 === value.end2) {
+        const wordParent = word.slice(0, 0 - value.end2.length) + value.end1;
+        parent.set(wordParent, wordParent.length);
+      }
+    }
+  });
+  const output: string[] = [];
+  parent.forEach((_value, key) => {
+    output.push(key);
+  });
+  output.sort((a, b) => a.length - b.length);
+  return output;
+};
+export const paliEndingType = ["n", "ti", "v", "ind", "pron", "num", "adj"];
+export const paliEndingGrammar = [
+  "nom",
+  "acc",
+  "gen",
+  "dat",
+  "inst",
+  "abl",
+  "loc",
+  "voc",
+  "sg",
+  "pl",
+  "m",
+  "nt",
+  "f",
+  "1p",
+  "2p",
+  "3p",
+  "pres",
+  "aor",
+  "fut",
+  "pf",
+  "imp",
+  "cond",
+  "adv",
+  "conj",
+  "abs",
+  "ger",
+  "inf",
+];
+export const paliEnding = [
+  {
+    end1: "a",
+    end2: "o",
+    type: ".n.",
+    grammar: ".m.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "a",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "aṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "assa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "assa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ena",
+    type: ".n.",
+    grammar: ".m.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "asmā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "amhā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ato",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "e",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "asmiṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "amhi",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "āse",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "e",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ānaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ānaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ehi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ebhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ehi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ebhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "esu",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "aṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "a",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "aṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "assa",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "assa",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ena",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "asmā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "amhā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ato",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "e",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "asmiṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "amhi",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "āni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "āni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "āni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "e",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ānaṃ",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ānaṃ",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ehi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ebhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ehi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ebhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "esu",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "assā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "assā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "enā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "amhī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ehī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ebhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ehī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ebhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "esū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "assā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "assā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "enā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "amhī",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ānī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ānī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ānī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ehī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ebhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ehī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "ebhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "a",
+    end2: "esū",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "e",
+    type: ".n.",
+    grammar: ".f.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "aṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".f.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ato",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āyaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ānaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ānaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ābhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ābhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āsu",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ābhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "ābhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ā",
+    end2: "āsū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "i",
+    type: ".n.",
+    grammar: ".m.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "i",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "issa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "issa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "inā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "inā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ismā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "imhā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ismiṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "imhi",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ayo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ayo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ayo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īsu",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "i",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "i",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "assa",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "assa",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "inā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "inā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ismā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "imhā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ismiṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "imhi",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īsu",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "i",
+    type: ".n.",
+    grammar: ".f.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "i",
+    type: ".n.",
+    grammar: ".f.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "yaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īsu",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ihi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ibhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ihi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ibhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "isu",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "issā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "issā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "imhī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īsū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "assā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "assā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "imhī",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īnī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īsū",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "īsū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ihī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ibhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ihī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "ibhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "i",
+    end2: "isū",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "issa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "issa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "inā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "inā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ismā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "imhā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ismiṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "imhi",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īsu",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ayā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ayā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ayā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ayā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ayā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ayaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "yo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "yo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ībhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īsu",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ini",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īnaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īsu",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ito",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "i",
+    type: ".n.",
+    grammar: ".f.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īyanaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īyanaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyanaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "iyanaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "isu",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "issā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "issā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "imhī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īsū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "ībhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īsū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "īsū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ī",
+    end2: "isū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "u",
+    type: ".n.",
+    grammar: ".m.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "u",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ussa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uno",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ussa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uno",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "unā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "unā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "usmā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "umhā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "usmiṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "umhi",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "avo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "avo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ave",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "avo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūsu",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "u",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "u",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ussa",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uno",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ussa",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uno",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "unā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "unā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "usmā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "umhā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "usmiṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "umhi",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūni",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūsu",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "u",
+    type: ".n.",
+    grammar: ".f.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "u",
+    type: ".n.",
+    grammar: ".f.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uto",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūvo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "uyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūsu",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ussā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ussā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "umhī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūsū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ussā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ussā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "umhī",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūnī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūsū",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "u",
+    end2: "ūsū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".m.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ussa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uno",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ussa",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uno",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "unā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "unā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "usmā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "umhā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "usmiṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "umhi",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "avo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "avo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ave",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "avo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūsu",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".f.$.sg.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".f.$.sg.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uto",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "uyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūnaṃ",
+    type: ".n.",
+    grammar: ".f.$.pl.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūbhi",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūsu",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ussā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ussā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "umhī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūsū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.inst.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūbhī",
+    type: ".n.",
+    grammar: ".f.$.pl.$.abl.",
+    confidence: "100",
+  },
+  {
+    end1: "ū",
+    end2: "ūsū",
+    type: ".n.",
+    grammar: ".f.$.pl.$.loc.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "etha",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "eraṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issati",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issanti",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issate",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issante",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issare",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "e",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "āmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "eyyāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "eyyāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "eyyaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "eyyāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issāmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "eyyāsi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "eyyātha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "etho",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "eyyavho",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issasi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issatha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issase",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "issavhe",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "etha",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "eraṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issati",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issanti",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issate",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issante",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issare",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "e",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "āmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "eyyāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "eyyāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "eyyaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "eyyāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issāmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "eyyāsi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "eyyātha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "etho",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "eyyavho",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issasi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issatha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issase",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "āti",
+    end2: "issavhe",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "etha",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "eraṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issati",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issanti",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issate",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issante",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issare",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "e",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "āmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "eyyāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "eyyāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "eyyaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "eyyāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issāmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "eyyāsi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "eyyātha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "etho",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "eyyavho",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issasi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issatha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issase",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "eti",
+    end2: "issavhe",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "etha",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "eraṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issati",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issanti",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issate",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issante",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issare",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "e",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "āmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "eyyāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "eyyāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "eyyaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "eyyāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issāmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "eyyāsi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "eyyātha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "etho",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "eyyavho",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issasi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issatha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issase",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "oti",
+    end2: "issavhe",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ti",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "nti",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "te",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "nte",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "re",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssati",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssanti",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssate",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssante",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssare",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "tu",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ntu",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "taṃ",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ntaṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "sā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssa",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssati",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssaṃsu",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssatha",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssiṃsu",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "mi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "e",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "mhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "mahe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "mha",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "mase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "mhase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssāmi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssāma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssāmhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssāmase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "mi",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ma",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssa",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssamhā",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssaṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssāmhase",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "si",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "tha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "se",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "vhe",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.pres.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssasi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssatha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssase",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssavhe",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.fut.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "hi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ta",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssu",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "vho",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.imp.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "se",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssa",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssasi",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssatha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssase",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "ssavhe",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.cond.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "eyya",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ti",
+    end2: "eyyuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.opt.",
+    confidence: "100",
+  },
+  {
+    end1: "ati",
+    end2: "i",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "ī",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "iṃsu",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "ṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "uṃū",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "a",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "tthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "atthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "iṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "aṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "ṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "a",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "imha",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "imhā",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "a",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "imhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "i",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "o",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "ttha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "se",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ati",
+    end2: "vhaṃ",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "i",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "ī",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "iṃsu",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "ṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "uṃū",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "tthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "atthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "iṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "aṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "ṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "imha",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "imhā",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "imhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "i",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "o",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "ttha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "se",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "āti",
+    end2: "vhaṃ",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "i",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "ī",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "iṃsu",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "ṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "uṃū",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "tthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "atthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "iṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "aṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "ṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "imha",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "imhā",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "imhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "i",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "o",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "ttha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "se",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "eti",
+    end2: "vhaṃ",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "i",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "ī",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "iṃsu",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "ṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "uṃū",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "tthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "atthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "iṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "aṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "ṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "imha",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "imhā",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "a",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "imhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "i",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "o",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "ā",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "ttha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "se",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "oti",
+    end2: "vhaṃ",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "si",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sī",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "siṃsu",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "suṃū",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sā",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sa",
+    type: ".v.",
+    grammar: ".3p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "stthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "satthuṃ",
+    type: ".v.",
+    grammar: ".3p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "siṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "saṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sṃ",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sa",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sā",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "simha",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "simhā",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sa",
+    type: ".v.",
+    grammar: ".1p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "simhe",
+    type: ".v.",
+    grammar: ".1p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "si",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "so",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sā",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sttha",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "sse",
+    type: ".v.",
+    grammar: ".2p.$.sg.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "ti",
+    end2: "svhaṃ",
+    type: ".v.",
+    grammar: ".2p.$.pl.$.aor.",
+    confidence: "90",
+  },
+  {
+    end1: "a",
+    end2: "e",
+    type: ".n.",
+    grammar: ".m.$.sg.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "asā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "asi",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "e",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "o",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "āse",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "o",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ān",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "e",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ato",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "e",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "asā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "asi",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "aṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "āya",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "o",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.acc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "ato",
+    type: ".n.",
+    grammar: ".nt.$.pl.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "asī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "a",
+    end2: "asī",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "ā",
+    end2: "ā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "ā",
+    end2: "āto",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "ā",
+    end2: "a",
+    type: ".n.",
+    grammar: ".f.$.sg.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "ā",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.dat.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "yo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "yo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "yo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.acc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "e",
+    type: ".n.",
+    grammar: ".m.$.sg.$.dat.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ito",
+    type: ".n.",
+    grammar: ".m.$.sg.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "e",
+    type: ".n.",
+    grammar: ".m.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ini",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "e",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "o",
+    type: ".n.",
+    grammar: ".m.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ino",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ihi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ibhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.dat.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ihi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ibhi",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "inaṃ",
+    type: ".n.",
+    grammar: ".m.$.pl.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "isu",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "iṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "i",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.acc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "e",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.dat.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ito",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "e",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ini",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "e",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "o",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "iṃ",
+    type: ".n.",
+    grammar: ".nt.$.sg.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.sg.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ito",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "myā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "o",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "āyaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "u",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ī",
+    type: ".n.",
+    grammar: ".f.$.sg.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ihī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ibhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ihī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "ibhī",
+    type: ".n.",
+    grammar: ".m.$.pl.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "i",
+    end2: "isū",
+    type: ".n.",
+    grammar: ".m.$.pl.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "inī",
+    type: ".n.",
+    grammar: ".m.$.sg.$.voc.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.gen.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.dat.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.inst.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "yā",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "yaṃ",
+    type: ".n.",
+    grammar: ".f.$.sg.$.loc.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "yo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "i",
+    type: ".n.",
+    grammar: ".m.$.sg.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "iyo",
+    type: ".n.",
+    grammar: ".m.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "iye",
+    type: ".n.",
+    grammar: ".m.$.pl.$.acc.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "iyaṃ",
+    type: ".n.",
+    grammar: ".m.$.sg.$.acc.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "ito",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "īto",
+    type: ".n.",
+    grammar: ".f.$.sg.$.abl.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "āyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "āyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "50",
+  },
+  {
+    end1: "ī",
+    end2: "āyo",
+    type: ".n.",
+    grammar: ".f.$.pl.$.nom.",
+    confidence: "50",
+  },
+];

+ 109 - 0
dashboard-v6/src/components/general/PaliText.tsx

@@ -0,0 +1,109 @@
+import { useMemo } from "react";
+import { Typography } from "antd";
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
+import { getTerm } from "../../reducers/term-vocabulary";
+import store from "../../store";
+import { lookup as _lookup } from "../../reducers/command";
+import type { BaseType } from "antd/lib/typography/Base";
+import { my_to_roman, roman_to_my } from "../../utils/code/my";
+import { roman_to_si } from "../../utils/code/si";
+import { roman_to_thai } from "../../utils/code/thai";
+import { roman_to_taitham } from "../../utils/code/tai-tham";
+import { GetUserSetting } from "../setting/default";
+import type { TCodeConvertor } from "../../types/template";
+
+const { Text } = Typography;
+
+interface IWidget {
+  style?: React.CSSProperties;
+  text?: string;
+  code?: string;
+  termToLocal?: boolean;
+  lookup?: boolean;
+  textType?: BaseType;
+}
+
+const PaliTextWidget = ({
+  text = "",
+  style,
+  code = "roman",
+  termToLocal = true,
+  lookup = false,
+  textType,
+}: IWidget) => {
+  const settings = useAppSelector(settingInfo);
+  const terms = useAppSelector(getTerm);
+
+  // 1. 核心逻辑:使用 useMemo 计算最终显示的文本
+  const displayedText = useMemo(() => {
+    if (!text) return "";
+
+    // 先统一转为罗马文,方便后续处理
+    const romanText = code === "my" ? my_to_roman(text) : text;
+
+    // 获取脚本转换配置
+    const paliConvertor = GetUserSetting(
+      "setting.pali.script.primary",
+      settings
+    ) as TCodeConvertor;
+
+    let converted: string | undefined;
+    switch (paliConvertor) {
+      case "roman_to_my":
+        converted = roman_to_my(romanText);
+        break;
+      case "my_to_roman":
+        converted = my_to_roman(romanText);
+        break;
+      case "roman_to_si":
+        converted = roman_to_si(romanText);
+        break;
+      case "roman_to_thai":
+        converted = roman_to_thai(romanText);
+        break;
+      case "roman_to_taitham":
+        converted = roman_to_taitham(romanText);
+        break;
+      default:
+        converted = romanText;
+    }
+
+    // 2. 如果开启了术语本地化,在转换后的基础上查找(注意:这里取决于你的业务逻辑是查原词还是翻译词)
+    if (termToLocal) {
+      const lowerCase = romanText?.toLowerCase();
+      const found = terms?.find((item) => item.word === lowerCase);
+      return found?.meaning || converted;
+    }
+
+    return converted;
+  }, [text, code, settings, terms, termToLocal]);
+
+  // 3. 事件处理抽离
+  const handleLookup = () => {
+    const romanText = code === "my" ? my_to_roman(text) : text;
+    if (romanText) store.dispatch(_lookup(romanText));
+  };
+
+  if (!text) return null;
+
+  if (lookup) {
+    return (
+      <Text
+        style={{ ...style, cursor: "pointer" }}
+        type={textType}
+        onClick={handleLookup}
+      >
+        {displayedText}
+      </Text>
+    );
+  }
+
+  return (
+    <Text type={textType} style={style}>
+      {displayedText}
+    </Text>
+  );
+};
+
+export default PaliTextWidget;

+ 15 - 0
dashboard-v6/src/components/general/ParserError.tsx

@@ -0,0 +1,15 @@
+import { Popover } from "antd";
+import { WarningOutlined } from "@ant-design/icons";
+
+interface IWidget {
+  children?: React.ReactNode;
+}
+const ParserErrorWidget = ({ children }: IWidget) => {
+  return (
+    <Popover content={children} placement="bottom">
+      <WarningOutlined style={{ color: "red" }} />
+    </Popover>
+  );
+};
+
+export default ParserErrorWidget;

+ 17 - 0
dashboard-v6/src/components/general/ReadonlyLabel.tsx

@@ -0,0 +1,17 @@
+import { Space, Tooltip, Typography } from "antd";
+import { QuestionCircleOutlined } from "@ant-design/icons";
+const { Text } = Typography;
+const ReadonlyLabelWidget = () => {
+  return (
+    <Tooltip placement="top" title={"您可能没有登录,或者没有修改权限。"}>
+      <Text type="warning">
+        <Space>
+          {"只读"}
+          <QuestionCircleOutlined />
+        </Space>
+      </Text>
+    </Tooltip>
+  );
+};
+
+export default ReadonlyLabelWidget;

+ 18 - 0
dashboard-v6/src/components/general/SearchButton.tsx

@@ -0,0 +1,18 @@
+import { Button } from "antd";
+import { Link } from "react-router";
+import { SearchOutlined } from "@ant-design/icons";
+
+interface IWidget {
+  style?: React.CSSProperties;
+}
+const SearchButtonWidget = ({ style }: IWidget) => {
+  return (
+    <Button type="text" size="small" style={style}>
+      <Link to="/search/home" target={"_blank"}>
+        <SearchOutlined style={{ color: "white" }} />
+      </Link>
+    </Button>
+  );
+};
+
+export default SearchButtonWidget;

+ 21 - 0
dashboard-v6/src/components/general/StatusBadge.tsx

@@ -0,0 +1,21 @@
+import { Badge } from "antd";
+
+interface IWidget {
+  count: number;
+  active?: boolean;
+}
+const Widget = ({ count, active = false }: IWidget) => {
+  return (
+    <Badge
+      count={count}
+      style={{
+        marginBlockStart: -2,
+        marginInlineStart: 4,
+        color: active ? "#1890FF" : "#999",
+        backgroundColor: active ? "#E6F7FF" : "#eee",
+      }}
+    />
+  );
+};
+
+export default Widget;

+ 241 - 0
dashboard-v6/src/components/general/TermTextArea.tsx

@@ -0,0 +1,241 @@
+import { useEffect, useRef, useState } from "react";
+import "./style.css";
+import TermTextAreaMenu from "./TermTextAreaMenu";
+
+interface IWidget {
+  value?: string;
+  menuOptions?: string[];
+  placeholder?: string;
+  onSave?: (value?: string) => void;
+  onClose?: () => void;
+  onChange?: (newValue: string) => void;
+}
+const TermTextAreaWidget = ({
+  value,
+  menuOptions,
+  placeholder,
+  onSave,
+  onClose,
+  onChange,
+}: IWidget) => {
+  const [shadowHeight, setShadowHeight] = useState<number>();
+  const [menuFocusIndex, setMenuFocusIndex] = useState(0);
+  const [menuDisplay, setMenuDisplay] = useState("none");
+  const [menuTop, setMenuTop] = useState(0);
+  const [menuLeft, setMenuLeft] = useState(0);
+  const [menuSelected, setMenuSelected] = useState<string>();
+
+  const [textAreaValue, setTextAreaValue] = useState(value);
+  const [textAreaHeight, setTextAreaHeight] = useState(100);
+  const [termSearch, setTermSearch] = useState<string>();
+
+  const _term_max_menu = 10;
+
+  const refTextArea = useRef<HTMLTextAreaElement>(null);
+  const refShadow = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!refTextArea.current) return;
+
+    const el = refTextArea.current;
+
+    const observer = new ResizeObserver(() => {
+      setShadowHeight(el.clientHeight);
+    });
+
+    observer.observe(el);
+
+    return () => observer.disconnect();
+  }, []);
+
+  function term_at_menu_hide() {
+    setMenuDisplay("none");
+    setTermSearch("");
+  }
+
+  function termInsert(strTerm: string) {
+    if (refTextArea.current === null) {
+      return;
+    }
+    const value = refTextArea.current.value;
+    const selectionStart = refTextArea.current.selectionStart;
+    let str1 = value.slice(0, selectionStart);
+    const str2 = value.slice(selectionStart);
+    const pos1 = str1.lastIndexOf("[[");
+    const pos2 = str1.lastIndexOf("]]");
+    if (pos1 !== -1) {
+      //光标前有[[
+      if (pos2 === -1 || pos2 < pos1) {
+        //光标在[[之间]]
+        str1 = str1.slice(0, str1.lastIndexOf("[[") + 2);
+      }
+    }
+    //TODO 光标会跑到最下面
+    const newValue = str1 + strTerm + "]]" + str2;
+    refTextArea.current.value = newValue;
+    setTextAreaValue(newValue);
+    if (typeof onChange !== "undefined") {
+      onChange(newValue);
+    }
+    term_at_menu_hide();
+    refTextArea.current.focus();
+  }
+  return (
+    <div className="text_input">
+      <div
+        className="menu"
+        style={{ display: menuDisplay, top: menuTop, left: menuLeft }}
+      >
+        <TermTextAreaMenu
+          currIndex={menuFocusIndex}
+          items={menuOptions}
+          visible={menuDisplay === "block"}
+          searchKey={termSearch}
+          onSelect={(value: string) => {
+            termInsert(value);
+          }}
+          onChange={(value: string) => {
+            setMenuSelected(value);
+          }}
+        />
+      </div>
+      <div
+        ref={refShadow}
+        className="textarea text_shadow"
+        style={{ height: shadowHeight }}
+      ></div>
+      <textarea
+        className="textarea tran_sent_textarea"
+        ref={refTextArea}
+        style={{ height: textAreaHeight }}
+        placeholder={placeholder}
+        value={textAreaValue}
+        onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
+          setTextAreaValue(event.target.value);
+          if (typeof onChange !== "undefined") {
+            onChange(event.target.value);
+          }
+        }}
+        onKeyDown={(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
+          switch (event.key) {
+            case "ArrowDown":
+              if (menuDisplay === "block") {
+                if (menuFocusIndex < _term_max_menu) {
+                  setMenuFocusIndex((value) => ++value);
+                }
+                event.preventDefault();
+              }
+              break;
+            case "ArrowUp":
+              if (menuDisplay === "block") {
+                if (menuFocusIndex > 0) {
+                  setMenuFocusIndex((value) => --value);
+                }
+                event.preventDefault();
+              }
+              break;
+            case "Enter":
+              if (menuDisplay === "block") {
+                console.log("enter", menuSelected);
+                if (menuSelected) {
+                  termInsert(menuSelected);
+                }
+                setMenuDisplay("none");
+                event.preventDefault();
+              }
+              if (event.ctrlKey || event.metaKey) {
+                //回车存盘
+                console.log("save", textAreaValue);
+                if (onSave) {
+                  onSave(textAreaValue);
+                }
+              }
+              break;
+            case "Escape":
+              if (menuDisplay === "block") {
+                setMenuDisplay("none");
+              } else {
+                if (typeof onClose !== "undefined") {
+                  onClose();
+                }
+              }
+              break;
+            default:
+              break;
+          }
+        }}
+        onKeyUp={() => {
+          if (
+            refShadow.current === null ||
+            refTextArea.current === null ||
+            refTextArea.current.parentElement === null
+          ) {
+            return;
+          }
+          let textHeight = refShadow.current.scrollHeight;
+          const textHeight2 = refTextArea.current.clientHeight;
+          if (textHeight2 > textHeight) {
+            textHeight = textHeight2;
+          }
+          setTextAreaHeight(textHeight);
+
+          const value = refTextArea.current.value;
+          const selectionStart = refTextArea.current.selectionStart;
+          const str1 = value.slice(0, selectionStart);
+          const str2 = value.slice(selectionStart);
+          const textNode1 = document.createTextNode(str1);
+          const textNode2 = document.createTextNode(str2);
+          const cursor = document.createElement("span");
+          cursor.innerHTML = "&nbsp;";
+          cursor.setAttribute("class", "cursor");
+          const mirror =
+            refTextArea.current.parentElement.querySelector(".text_shadow");
+          if (mirror === null) {
+            return;
+          }
+          mirror.innerHTML = "";
+          mirror.appendChild(textNode1);
+          mirror.appendChild(cursor);
+          mirror.appendChild(textNode2);
+          if (str1.slice(-2) === "[[") {
+            if (menuDisplay !== "block") {
+              setMenuFocusIndex(0);
+              setMenuDisplay("block");
+              setMenuTop(cursor.offsetTop + 20);
+              setMenuLeft(cursor.offsetLeft);
+              //menu.innerHTML = TermAtRenderMenu({ focus: 0 });
+              //term_at_menu_show(cursor);
+            }
+          } else {
+            if (menuDisplay === "block") {
+              const pos1 = str1.lastIndexOf("[[");
+              const pos2 = str1.lastIndexOf("]]");
+              if (pos1 === -1 || (pos1 !== -1 && pos2 > pos1)) {
+                //光标前没有[[ 或 光标在[[]] 之后
+                setMenuDisplay("none");
+                setTermSearch("");
+              }
+            }
+          }
+
+          if (menuDisplay === "block") {
+            const value = refTextArea.current.value;
+            const selectionStart = refTextArea.current.selectionStart;
+            const str1 = value.slice(0, selectionStart);
+            const pos1 = str1.lastIndexOf("[[");
+            const pos2 = str1.lastIndexOf("]]");
+            if (pos1 !== -1) {
+              if (pos2 === -1 || pos2 < pos1) {
+                //光标
+                const term_input = str1.slice(str1.lastIndexOf("[[") + 2);
+                setTermSearch(term_input);
+              }
+            }
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+export default TermTextAreaWidget;

+ 138 - 0
dashboard-v6/src/components/general/TermTextAreaMenu.tsx

@@ -0,0 +1,138 @@
+import { Space, Typography } from "antd";
+import { useMemo, useEffect } from "react";
+import { RobotOutlined } from "@ant-design/icons";
+
+import { TermIcon } from "../../assets/icon";
+import { useAppSelector } from "../../hooks";
+import { getTerm } from "../../reducers/term-vocabulary";
+import { PaliToEn } from "../../utils";
+import { getPaliBase } from "./PaliEnding";
+
+const { Text } = Typography;
+
+interface IWordWithEn {
+  word: string;
+  en: string;
+  isBase?: boolean;
+  isTerm?: boolean;
+}
+
+interface IWidget {
+  items?: string[];
+  searchKey?: string;
+  maxItem?: number;
+  visible?: boolean;
+  currIndex?: number;
+  onChange?: (word: string) => void;
+  onSelect?: (word: string) => void;
+}
+
+const TermTextAreaMenuWidget = ({
+  items,
+  searchKey = "",
+  maxItem = 10,
+  visible = false,
+  currIndex = 0,
+  onChange,
+  onSelect,
+}: IWidget) => {
+  const sysTerms = useAppSelector(getTerm);
+
+  /**
+   * ✅ wordList 改为 useMemo
+   */
+  const wordList: IWordWithEn[] = useMemo(() => {
+    const parents: string[] = [];
+    let mWords: IWordWithEn[] = [];
+
+    if (items) {
+      mWords = items.map((item) => ({
+        word: item,
+        en: PaliToEn(item),
+      }));
+
+      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) : [];
+
+    const parentTerm = parents.map((item) => {
+      const inSystem = term.includes(item);
+      return {
+        word: item,
+        en: PaliToEn(item),
+        isBase: !inSystem,
+        isTerm: inSystem,
+      };
+    });
+
+    const sysTerm = term
+      .filter((value) => !parents.includes(value))
+      .sort((a, b) => a.length - b.length)
+      .map((item) => ({
+        word: item,
+        en: PaliToEn(item),
+        isTerm: true,
+      }));
+
+    return [...parentTerm, ...mWords, ...sysTerm];
+  }, [items, sysTerms]);
+
+  /**
+   * ✅ filtered 改为 useMemo
+   */
+  const filtered = useMemo(() => {
+    if (!searchKey) return wordList;
+
+    return wordList.filter(
+      (value) => value.en.slice(0, searchKey.length) === searchKey
+    );
+  }, [wordList, searchKey]);
+
+  /**
+   * ✅ 只有真正副作用才用 useEffect
+   */
+  useEffect(() => {
+    if (!filtered.length || !onChange) return;
+
+    const index = currIndex < filtered.length ? currIndex : filtered.length - 1;
+
+    onChange(filtered[index].word);
+  }, [currIndex, filtered, onChange]);
+
+  if (!visible) return null;
+
+  return (
+    <>
+      <div className="term_at_menu_input">{`${searchKey}|`}</div>
+
+      <ul className="term_at_menu_ul">
+        {filtered.slice(0, maxItem).map((item, index) => (
+          <li
+            key={item.word}
+            className={index === currIndex ? "term_focus" : undefined}
+            onClick={() => onSelect?.(item.word)}
+          >
+            <Space style={{ width: "100%", justifyContent: "space-between" }}>
+              <Text strong={item.isBase || item.isTerm}>{item.word}</Text>
+
+              {item.isTerm ? (
+                <TermIcon />
+              ) : item.isBase ? (
+                <RobotOutlined />
+              ) : null}
+            </Space>
+          </li>
+        ))}
+      </ul>
+    </>
+  );
+};
+
+export default TermTextAreaMenuWidget;

+ 47 - 0
dashboard-v6/src/components/general/TextDiff.tsx

@@ -0,0 +1,47 @@
+import { Tooltip, Typography } from "antd";
+import { type Change, diffChars } from "diff"
+
+const { Text } = Typography;
+
+interface IWidget {
+  content?: string | null;
+  oldContent?: string | null;
+  showToolTip?: boolean;
+}
+const TextDiffWidget = ({
+  content,
+  oldContent,
+  showToolTip = true,
+}: IWidget) => {
+  if (content) {
+    if (oldContent) {
+      const diff: Change[] = diffChars(oldContent, content);
+      const diffResult = diff.map((item, id) => {
+        return (
+          <Text
+            key={id}
+            type={
+              item.added ? "success" : item.removed ? "danger" : "secondary"
+            }
+            delete={item.removed ? true : undefined}
+          >
+            {item.value}
+          </Text>
+        );
+      });
+      return showToolTip ? (
+        <Tooltip title={content}>
+          <div>{diffResult}</div>
+        </Tooltip>
+      ) : (
+        <div> {diffResult}</div>
+      );
+    } else {
+      return <Text>{content}</Text>;
+    }
+  } else {
+    return <></>;
+  }
+};
+
+export default TextDiffWidget;

+ 143 - 0
dashboard-v6/src/components/general/TimeShow.tsx

@@ -0,0 +1,143 @@
+import { Space, Tooltip, Typography } from "antd";
+import { useIntl } from "react-intl";
+import { FieldTimeOutlined } from "@ant-design/icons";
+import { useEffect, useReducer } from "react";
+import type { BaseType } from "antd/lib/typography/Base";
+const { Text } = Typography;
+
+interface IWidgetTimeShow {
+  showIcon?: boolean;
+  showTooltip?: boolean;
+  showLabel?: boolean;
+  createdAt?: string;
+  updatedAt?: string;
+  title?: string;
+  type?: BaseType;
+}
+
+const TimeShowWidget = ({
+  showIcon = true,
+  showLabel = true,
+  createdAt,
+  updatedAt,
+  title,
+  type,
+}: IWidgetTimeShow) => {
+  const intl = useIntl(); //i18n
+
+  let mTitle: string | undefined;
+  let showTime: string | undefined;
+  if (updatedAt && createdAt) {
+    if (updatedAt === createdAt) {
+      mTitle = intl.formatMessage({
+        id: "labels.created-at",
+      });
+      showTime = createdAt;
+    } else {
+      mTitle = intl.formatMessage({
+        id: "labels.updated-at",
+      });
+      showTime = updatedAt;
+    }
+  } else if (createdAt) {
+    mTitle = intl.formatMessage({
+      id: "labels.created-at",
+    });
+    showTime = createdAt;
+  } else if (updatedAt) {
+    mTitle = intl.formatMessage({
+      id: "labels.updated-at",
+    });
+    showTime = updatedAt;
+  } else {
+    mTitle = undefined;
+    showTime = "";
+  }
+  if (typeof title !== "undefined") {
+    mTitle = title;
+  }
+
+  const [, forceUpdate] = useReducer((x) => x + 1, 0);
+
+  useEffect(() => {
+    if (!createdAt && !updatedAt) return;
+
+    const timer = setInterval(forceUpdate, 60000);
+    return () => clearInterval(timer);
+  }, [createdAt, updatedAt]);
+
+  const passTime =
+    showTime && showTime !== "" ? getPassDataTime(showTime) : undefined;
+
+  if (typeof showTime === "undefined") {
+    return <></>;
+  }
+
+  const icon = showIcon ? <FieldTimeOutlined /> : <></>;
+
+  const tooltip: string = getFullDataTime(showTime);
+  const color = "lime";
+  function getPassDataTime(t: string): string {
+    const currDate = new Date();
+    const time = new Date(t);
+    const pass = currDate.getTime() - time.getTime();
+    let strPassTime = "";
+    if (pass < 100 * 1000) {
+      //一分钟内
+      strPassTime = intl.formatMessage({ id: "utilities.time.now" });
+    } else if (pass < 3600 * 1000) {
+      //二小时内
+      strPassTime =
+        Math.floor(pass / 1000 / 60) +
+        intl.formatMessage({ id: "utilities.time.mins_ago" });
+    } else if (pass < 3600 * 24 * 1000) {
+      //二天内
+      strPassTime =
+        Math.floor(pass / 1000 / 3600) +
+        intl.formatMessage({ id: "utilities.time.hs_ago" });
+    } else if (pass < 3600 * 24 * 14 * 1000) {
+      //二周内
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24) +
+        intl.formatMessage({ id: "utilities.time.days_ago" });
+    } else if (pass < 3600 * 24 * 30 * 1000) {
+      //二个月内
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24 / 7) +
+        intl.formatMessage({ id: "utilities.time.weeks_ago" });
+    } else if (pass < 3600 * 24 * 365 * 1000) {
+      //一年内
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24 / 30) +
+        intl.formatMessage({ id: "utilities.time.months_ago" });
+    } else if (pass < 3600 * 24 * 730 * 1000) {
+      //超过1年小于2年
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24 / 365) +
+        intl.formatMessage({ id: "utilities.time.year_ago" });
+    } else {
+      strPassTime =
+        Math.floor(pass / 1000 / 3600 / 24 / 365) +
+        intl.formatMessage({ id: "utilities.time.years_ago" });
+    }
+    return strPassTime;
+  }
+  function getFullDataTime(t: string) {
+    const inputDate = new Date(t);
+    return inputDate.toLocaleString();
+  }
+
+  return (
+    <Tooltip title={tooltip} color={color} key={color}>
+      <Text type={type}>
+        <Space>
+          {icon}
+          {showLabel ? mTitle : ""}
+          {passTime}
+        </Space>
+      </Text>
+    </Tooltip>
+  );
+};
+
+export default TimeShowWidget;

+ 57 - 0
dashboard-v6/src/components/general/UiLangSelect.tsx

@@ -0,0 +1,57 @@
+import { Button, Dropdown } from "antd";
+import type { MenuProps } from "antd";
+import { useMemo, useState } from "react";
+import { set, get } from "../../locales";
+import { GlobalOutlined } from "@ant-design/icons";
+
+interface IUiLang {
+  key: string;
+  label: string;
+}
+
+const uiLang: IUiLang[] = [
+  { key: "en-US", label: "English" },
+  { key: "zh-Hans", label: "简体中文" },
+  { key: "zh-Hant", label: "繁体中文" },
+];
+
+const UiLangSelectWidget = () => {
+  /**
+   * ✅ 初始值直接计算
+   */
+  const [curr, setCurr] = useState(() => {
+    const currLang = get();
+    return uiLang.find((i) => i.key === currLang)?.label;
+  });
+
+  /**
+   * ✅ antd menu items 必须 useMemo + 正确类型
+   */
+  const items: MenuProps["items"] = useMemo(
+    () =>
+      uiLang.map((lang) => ({
+        key: lang.key,
+        label: lang.label,
+      })),
+    []
+  );
+
+  /**
+   * ✅ 点击逻辑
+   */
+  const onClick: MenuProps["onClick"] = ({ key }) => {
+    set(key as string); // ← 只传一个参数
+    const label = uiLang.find((i) => i.key === key)?.label;
+    setCurr(label);
+  };
+
+  return (
+    <Dropdown menu={{ items, onClick }} placement="bottomRight">
+      <Button ghost style={{ border: "unset" }} icon={<GlobalOutlined />}>
+        {curr}
+      </Button>
+    </Dropdown>
+  );
+};
+
+export default UiLangSelectWidget;

+ 72 - 0
dashboard-v6/src/components/general/style.css

@@ -0,0 +1,72 @@
+/*术语输入AT弹出菜单*/
+.text_input > .textarea {
+  padding: 5px;
+  font-family: inherit;
+  width: 100%;
+  min-height: 100px;
+  resize: vertical;
+  font-size: 14px;
+  line-height: 1;
+  border: 1px solid #ddd;
+  white-space: pre-wrap;
+  word-break: break-all;
+  z-index: 1;
+  resize: vertical;
+  color: var(--main-color);
+  background-color: #e4e45c52;
+  border: unset;
+  border-radius: 8px;
+  padding: 5px;
+  line-height: 1.5em;
+}
+.text_input > .text_shadow {
+  position: absolute;
+  width: 100%;
+  visibility: hidden;
+}
+.text_input .cursor {
+  position: absolute;
+  border-left: 1px solid #000;
+}
+.text_input > .menu {
+  background-color: #b2b2b2;
+  width: 200px;
+  height: 300px;
+  box-shadow: #000;
+  position: absolute;
+  display: none;
+  z-index: 100;
+  box-shadow: 0 5px 7px rgb(0 0 0 / 25%);
+}
+.text_input > .menu ul {
+  list-style-type: none;
+  margin: 0;
+  padding: 0;
+}
+.text_input > .menu ul li {
+  cursor: pointer;
+  padding: 0;
+  margin: 5px;
+}
+.text_input > .menu ul li:hover {
+  background: linear-gradient(90deg, #40a9ff, transparent);
+}
+
+.term_at_menu_input {
+  padding: 5px;
+  border-bottom: 1px solid gray;
+}
+.text_input {
+  width: 100%;
+  position: relative;
+}
+
+.term_mean {
+  /*text-transform: capitalize;*/
+  white-space: nowrap;
+}
+.term_at_menu_ul > .term_focus {
+  border-radius: unset;
+  box-shadow: unset;
+  background: linear-gradient(90deg, #40a9ff, transparent);
+}

+ 97 - 0
dashboard-v6/src/components/group/AddMember.tsx

@@ -0,0 +1,97 @@
+import { useIntl } from "react-intl";
+import { ProForm, ProFormSelect } from "@ant-design/pro-components";
+import { Button, message, Popover } from "antd";
+import { UserAddOutlined } from "@ant-design/icons";
+import { get, post } from "../../request";
+import type { IUserListResponse } from "../../api/Auth";
+import type {
+  IGroupMemberRequest,
+  IGroupMemberResponse,
+} from "../../api/Group";
+import { useState } from "react";
+
+interface IFormData {
+  userId: string;
+}
+
+interface IWidget {
+  groupId?: string;
+  onCreated?: () => void;
+}
+const AddMemberWidget = ({ groupId, onCreated }: IWidget) => {
+  const intl = useIntl();
+  const [open, setOpen] = useState(false);
+
+  const form = (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        console.log(values);
+        if (typeof groupId !== "undefined") {
+          post<IGroupMemberRequest, IGroupMemberResponse>("/v2/group-member", {
+            user_id: values.userId,
+            group_id: groupId,
+          }).then((json) => {
+            console.log("add member", json);
+            if (json.ok) {
+              message.success(intl.formatMessage({ id: "flashes.success" }));
+              setOpen(false);
+              if (typeof onCreated !== "undefined") {
+                onCreated();
+              }
+            }
+          });
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormSelect
+          name="userId"
+          label={intl.formatMessage({ id: "forms.fields.user.label" })}
+          showSearch
+          debounceTime={300}
+          request={async ({ keyWords }) => {
+            console.log("keyWord", keyWords);
+            const json = await get<IUserListResponse>(
+              `/v2/user?view=key&key=${keyWords}`
+            );
+            const userList = json.data.rows.map((item) => {
+              return {
+                value: item.id,
+                label: `${item.userName}-${item.nickName}`,
+              };
+            });
+            console.log("json", userList);
+            return userList;
+          }}
+          placeholder={intl.formatMessage({
+            id: "forms.message.user.required",
+          })}
+          rules={[
+            {
+              required: true,
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+
+  return (
+    <Popover
+      placement="bottomLeft"
+      arrow={{ pointAtCenter: true }}
+      content={form}
+      trigger="click"
+      open={open}
+      onOpenChange={(open: boolean) => {
+        setOpen(open);
+      }}
+    >
+      <Button icon={<UserAddOutlined />} key="add" type="primary">
+        {intl.formatMessage({ id: "buttons.group.add.member" })}
+      </Button>
+    </Popover>
+  );
+};
+
+export default AddMemberWidget;

+ 11 - 0
dashboard-v6/src/components/group/Group.tsx

@@ -0,0 +1,11 @@
+import { Space } from "antd";
+import type { IGroup } from "../../api/Group";
+
+interface IWidget {
+  group?: IGroup;
+}
+const GroupWidget = ({ group }: IWidget) => {
+  return <Space>{group?.name}</Space>;
+};
+
+export default GroupWidget;

+ 68 - 0
dashboard-v6/src/components/group/GroupCreate.tsx

@@ -0,0 +1,68 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { post } from "../../request";
+import type { IGroupRequest, IGroupResponse } from "../../api/Group";
+import { useRef } from "react";
+
+interface IFormData {
+  name: string;
+}
+
+interface IWidgetGroupCreate {
+  studio?: string;
+  onCreate?: () => void;
+}
+const GroupCreateWidget = ({ studio, onCreate }: IWidgetGroupCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        if (typeof studio === "undefined") {
+          return;
+        }
+        console.log(values);
+        const res = await post<IGroupRequest, IGroupResponse>(`/v2/group`, {
+          name: values.name,
+          studio_name: studio,
+        });
+        console.log(res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+            formRef.current?.resetFields();
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "channel.name" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default GroupCreateWidget;

+ 167 - 0
dashboard-v6/src/components/group/GroupFile.tsx

@@ -0,0 +1,167 @@
+import { useIntl } from "react-intl";
+import { useRef, useState } from "react";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { Space, Tag, Button, Layout, Popconfirm } from "antd";
+import { delete_, get } from "../../request";
+import type { IShareListResponse } from "../../api/Share";
+import type { IDeleteResponse } from "../../api/Group";
+
+const { Content } = Layout;
+
+interface IRoleTag {
+  title: string;
+  color: string;
+}
+interface DataItem {
+  id?: string;
+  name?: string;
+  tag: IRoleTag[];
+  image: string;
+  description?: string;
+}
+interface IWidget {
+  groupId?: string;
+}
+const GroupFileWidget = ({ groupId }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const [canDelete, setCanDelete] = useState(false);
+  const [resCount, setResCount] = useState(0);
+  const ref = useRef<ActionType | null>(null);
+  return (
+    <Content>
+      <ProList<DataItem>
+        rowKey="id"
+        actionRef={ref}
+        headerTitle={
+          intl.formatMessage({ id: "group.files" }) + "-" + resCount.toString()
+        }
+        metas={{
+          title: {
+            dataIndex: "name",
+          },
+          avatar: {
+            dataIndex: "image",
+            editable: false,
+          },
+          description: {
+            dataIndex: "description",
+          },
+          content: {
+            dataIndex: "content",
+            editable: false,
+          },
+          subTitle: {
+            render: (_text, row, index) => {
+              const showtag = row.tag.map((item, id) => {
+                return (
+                  <Tag color={item.color} key={id}>
+                    {item.title}
+                  </Tag>
+                );
+              });
+              return (
+                <Space size={0} key={index}>
+                  {showtag}
+                </Space>
+              );
+            },
+          },
+          actions: {
+            render: (_text, row, index) => [
+              canDelete ? (
+                <Popconfirm
+                  key={index}
+                  title={intl.formatMessage({
+                    id: "forms.message.res.remove",
+                  })}
+                  onConfirm={() => {
+                    console.log("delete", row.id);
+                    delete_<IDeleteResponse>("/v2/share/" + row.id).then(
+                      (json) => {
+                        if (json.ok) {
+                          console.log("delete ok");
+                          ref.current?.reload();
+                        }
+                      }
+                    );
+                  }}
+                  okText={intl.formatMessage({ id: "buttons.ok" })}
+                  cancelText={intl.formatMessage({ id: "buttons.cancel" })}
+                >
+                  <Button size="small" type="link" danger key="link">
+                    {intl.formatMessage({ id: "buttons.remove" })}
+                  </Button>
+                </Popconfirm>
+              ) : (
+                <></>
+              ),
+            ],
+          },
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+
+          let url = `/v2/share?view=group&id=${groupId}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          if (typeof params.keyword !== "undefined") {
+            url += "&search=" + (params.keyword ? params.keyword : "");
+          }
+          const res = await get<IShareListResponse>(url);
+          if (res.ok) {
+            console.log(res.data.rows);
+            setResCount(res.data.count);
+            switch (res.data.role) {
+              case "owner":
+                setCanDelete(true);
+                break;
+              case "manager":
+                setCanDelete(true);
+                break;
+            }
+            const items: DataItem[] = res.data.rows.map((item) => {
+              const member: DataItem = {
+                id: item.id,
+                name: item.res_name,
+                tag: [],
+                image: "",
+                description: item.owner?.nickName,
+              };
+              switch (item.power) {
+                case 0:
+                  member.tag.push({ title: "拥有者", color: "success" });
+                  break;
+                case 1:
+                  member.tag.push({ title: "管理员", color: "default" });
+                  break;
+              }
+
+              return member;
+            });
+            console.log(items);
+            return {
+              total: res.data.count,
+              succcess: true,
+              data: items,
+            };
+          } else {
+            console.error(res.message);
+            return {
+              total: 0,
+              succcess: false,
+              data: [],
+            };
+          }
+        }}
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+      />
+    </Content>
+  );
+};
+
+export default GroupFileWidget;

+ 186 - 0
dashboard-v6/src/components/group/GroupMember.tsx

@@ -0,0 +1,186 @@
+import { useIntl } from "react-intl";
+import { useRef, useState } from "react";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { Space, Tag, Button, Layout, Popconfirm } from "antd";
+
+import GroupAddMember from "./AddMember";
+import { delete_, get } from "../../request";
+import type {
+  IGroupMemberDeleteResponse,
+  IGroupMemberListResponse,
+} from "../../api/Group";
+import User from "../auth/User";
+import type { IUser } from "../../api/Auth";
+
+const { Content } = Layout;
+
+interface IRoleTag {
+  title: string;
+  color: string;
+}
+interface DataItem {
+  id: number;
+  userId: string;
+  name?: string;
+  user: IUser;
+  tag: IRoleTag[];
+  image: string;
+}
+interface IWidgetGroupFile {
+  groupId?: string;
+}
+const GroupMemberWidget = ({ groupId }: IWidgetGroupFile) => {
+  const intl = useIntl(); //i18n
+  const [canManage, setCanManage] = useState(false);
+  const [memberCount, setMemberCount] = useState<number>();
+
+  const ref = useRef<ActionType | null>(null);
+  return (
+    <Content>
+      <ProList<DataItem>
+        rowKey="id"
+        actionRef={ref}
+        headerTitle={
+          intl.formatMessage({ id: "group.member" }) +
+          "-" +
+          memberCount?.toString()
+        }
+        toolBarRender={() => {
+          return [
+            canManage ? (
+              <GroupAddMember
+                groupId={groupId}
+                onCreated={() => {
+                  ref.current?.reload();
+                }}
+              />
+            ) : undefined,
+          ];
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+
+          let url = `/v2/group-member?view=group&id=${groupId}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          if (typeof params.keyword !== "undefined") {
+            url += "&search=" + (params.keyword ? params.keyword : "");
+          }
+          const res = await get<IGroupMemberListResponse>(url);
+          if (res.ok) {
+            console.log(res.data.rows);
+            setMemberCount(res.data.count);
+            switch (res.data.role) {
+              case "owner":
+                setCanManage(true);
+                break;
+              case "manager":
+                setCanManage(true);
+                break;
+            }
+            const items: DataItem[] = res.data.rows.map((item) => {
+              const member: DataItem = {
+                id: item.id ? item.id : 0,
+                userId: item.user_id,
+                name: item.user?.nickName,
+                user: item.user,
+                tag: [],
+                image: "",
+              };
+              switch (item.power) {
+                case 0:
+                  member.tag.push({ title: "拥有者", color: "success" });
+                  break;
+                case 1:
+                  member.tag.push({ title: "管理员", color: "default" });
+                  break;
+              }
+
+              return member;
+            });
+            console.log(items);
+            return {
+              total: res.data.count,
+              succcess: true,
+              data: items,
+            };
+          } else {
+            console.error(res.message);
+            return {
+              total: 0,
+              succcess: false,
+              data: [],
+            };
+          }
+        }}
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        metas={{
+          title: {
+            dataIndex: "name",
+          },
+          avatar: {
+            dataIndex: "image",
+            editable: false,
+            render(_dom, entity) {
+              return <User {...entity.user} showName={false} />;
+            },
+          },
+          subTitle: {
+            render: (_text, row, index) => {
+              const showtag = row.tag.map((item, id) => {
+                return (
+                  <Tag color={item.color} key={id}>
+                    {item.title}
+                  </Tag>
+                );
+              });
+              return (
+                <Space size={0} key={index}>
+                  {showtag}
+                </Space>
+              );
+            },
+          },
+          actions: {
+            render: (_text, row, index) => [
+              canManage ? (
+                <Popconfirm
+                  key={index}
+                  title={intl.formatMessage({
+                    id: "forms.message.member.remove",
+                  })}
+                  onConfirm={() => {
+                    console.log("delete", row.id);
+                    delete_<IGroupMemberDeleteResponse>(
+                      "/v2/group-member/" + row.id
+                    ).then((json) => {
+                      if (json.ok) {
+                        console.log("delete ok");
+                        ref.current?.reload();
+                      }
+                    });
+                  }}
+                  okText={intl.formatMessage({ id: "buttons.ok" })}
+                  cancelText={intl.formatMessage({ id: "buttons.cancel" })}
+                >
+                  <Button size="small" type="link" danger key="link">
+                    {intl.formatMessage({ id: "buttons.remove" })}
+                  </Button>
+                </Popconfirm>
+              ) : (
+                <></>
+              ),
+            ],
+          },
+        }}
+      />
+    </Content>
+  );
+};
+
+export default GroupMemberWidget;

+ 53 - 0
dashboard-v6/src/components/group/GroupSelect.tsx

@@ -0,0 +1,53 @@
+import { ProFormSelect } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import type { IGroupListResponse } from "../../api/Group";
+
+interface IWidget {
+  name?: string;
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  multiple?: boolean;
+  hidden?: boolean;
+}
+const GroupSelectWidget = ({
+  name = "user",
+  multiple = false,
+  width = "md",
+  hidden = false,
+}: IWidget) => {
+  const intl = useIntl();
+  return (
+    <ProFormSelect
+      name={name}
+      label={intl.formatMessage({ id: "group.fields.name.label" })}
+      hidden={hidden}
+      width={width}
+      showSearch
+      debounceTime={300}
+      fieldProps={{
+        mode: multiple ? "multiple" : undefined,
+      }}
+      request={async ({ keyWords }) => {
+        const url = `/v2/group?view=all&search=${keyWords}`;
+        console.log("group keyWord", url);
+        const json = await get<IGroupListResponse>(url);
+        const userList = json.data.rows.map((item) => {
+          return {
+            value: item.uid,
+            label: `${item.studio.studioName}/${item.name}`,
+          };
+        });
+
+        return userList;
+      }}
+      rules={[
+        {
+          required: true,
+        },
+      ]}
+    />
+  );
+};
+
+export default GroupSelectWidget;

+ 29 - 0
dashboard-v6/src/components/navigation/HeaderBreadcrumb.tsx

@@ -0,0 +1,29 @@
+import { Breadcrumb } from "antd";
+import { Link, useMatches } from "react-router";
+
+interface Match {
+  pathname: string;
+  params: Record<string, string>;
+  handle?: {
+    crumb?: string | ((match: Match) => React.ReactNode);
+  };
+}
+
+export default function AppBreadcrumb() {
+  const matches = useMatches() as Match[];
+
+  const items = matches
+    .filter((m) => m.handle?.crumb)
+    .map((m, i, arr) => {
+      const crumb = m.handle!.crumb!;
+      const label = typeof crumb === "function" ? crumb(m) : crumb;
+
+      const isLast = i === arr.length - 1;
+
+      return {
+        title: isLast ? label : <Link to={m.pathname}>{label}</Link>,
+      };
+    });
+
+  return <Breadcrumb items={items} />;
+}

+ 47 - 11
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -2,11 +2,19 @@ import { Menu, type MenuProps } from "antd";
 import {
   SearchOutlined,
   HomeOutlined,
-  RobotOutlined,
-  BookOutlined,
+  FieldTimeOutlined,
+  FolderOutlined,
 } from "@ant-design/icons";
 
 import { useLocation, useNavigate } from "react-router";
+import {
+  ChannelIcon,
+  CourseOutLinedIcon,
+  DocumentIcon,
+  RobotIcon,
+  TaskIcon,
+  TipitakaIcon,
+} from "../../assets/icon";
 
 interface Props {
   onSearch?: () => void;
@@ -28,12 +36,12 @@ const Widget = ({ onSearch }: Props) => {
     },
     {
       key: "/workspace/ai",
-      icon: <RobotOutlined />,
+      icon: <RobotIcon />,
       label: "AI",
     },
     {
       key: "/workspace/tipitaka",
-      icon: <BookOutlined />,
+      icon: <TipitakaIcon />,
       label: "巴利三藏",
     },
     {
@@ -42,28 +50,56 @@ const Widget = ({ onSearch }: Props) => {
     },
     {
       key: "/workspace/recent",
-      icon: <BookOutlined />,
+      icon: <FieldTimeOutlined />,
       label: "最近打开",
       children: [],
     },
     {
-      key: "/workspace/anthology",
-      icon: <BookOutlined />,
-      label: "文集",
+      key: "/workspace/articles",
+      icon: <DocumentIcon />,
+      label: "文章",
+      children: [
+        {
+          key: "/workspace/articles/uncategorized",
+          label: "未分类",
+          icon: <FolderOutlined />,
+        },
+        {
+          key: "/workspace/articles/angl",
+          label: "文集1",
+          icon: <FolderOutlined />,
+        },
+        {
+          key: "/workspace/articles",
+          label: "ALL",
+        },
+      ],
     },
     {
       key: "/workspace/channel",
-      icon: <BookOutlined />,
+      icon: <ChannelIcon />,
       label: "频道",
     },
+    {
+      key: "/workspace/course",
+      icon: <CourseOutLinedIcon />,
+      label: "course",
+    },
     {
       key: "/workspace/task",
-      icon: <BookOutlined />,
+      icon: <TaskIcon />,
       label: "task",
       children: [
+        {
+          key: "/workspace/task/pending",
+          label: "pending",
+        },
+        {
+          key: "/workspace/task/to-do-list",
+          label: "To-do List",
+        },
         {
           key: "/workspace/task/hell",
-          icon: <BookOutlined />,
           label: "task hell",
         },
       ],

+ 362 - 0
dashboard-v6/src/components/nissaya/NissayaAligner.tsx

@@ -0,0 +1,362 @@
+import { useEffect, useState } from "react";
+import {
+  Steps,
+  Upload,
+  Button,
+  Table,
+  Input,
+  message,
+  Typography,
+  Space,
+} from "antd";
+import type { UploadChangeParam, UploadFile } from "antd/es/upload";
+import {
+  InboxOutlined,
+  CopyOutlined,
+  UpSquareOutlined,
+  DownSquareOutlined,
+} from "@ant-design/icons";
+import { post } from "../../request";
+import type {
+  ISentenceDiffRequest,
+  ISentenceDiffResponse,
+} from "../../api/Corpus";
+
+const { Dragger } = Upload;
+const { TextArea } = Input;
+const { Title } = Typography;
+
+/* ------------------ 类型定义 ------------------ */
+
+interface WordData {
+  id: number;
+  pali: string;
+  nissaya: string;
+  note?: string;
+}
+
+interface SentenceData {
+  id: string;
+  content: string;
+}
+
+interface AlignResult {
+  id: string;
+  words: string;
+}
+
+interface IWidget {
+  sentencesId?: string[];
+}
+
+/* ------------------ 主组件 ------------------ */
+
+const NissayaAligner = ({ sentencesId }: IWidget) => {
+  const [current, setCurrent] = useState<number>(0);
+  const [csvData, setCsvData] = useState<WordData[]>([]);
+  const [jsonlInput, setJsonlInput] = useState<string>("");
+  const [alignResults, setAlignResults] = useState<AlignResult[]>([]);
+  const [original, setOriginal] = useState<SentenceData[]>([]);
+
+  /* ------------------ 获取句子 ------------------ */
+
+  useEffect(() => {
+    if (!sentencesId) return;
+
+    post<ISentenceDiffRequest, ISentenceDiffResponse>("/v2/sent-in-channel", {
+      sentences: sentencesId,
+      channels: ["_System_Pali_VRI_"],
+    }).then((json) => {
+      if (!json.ok) return;
+
+      const rows = [...json.data.rows].sort((a, b) => {
+        if (a.book_id !== b.book_id) return a.book_id - b.book_id;
+        if (a.paragraph !== b.paragraph) return a.paragraph - b.paragraph;
+        return a.word_start - b.word_start;
+      });
+
+      setOriginal(
+        rows.map((item) => ({
+          id: `${item.book_id}-${item.paragraph}-${item.word_start}-${item.word_end}`,
+          content: item.content ?? "",
+        }))
+      );
+    });
+  }, [sentencesId]);
+
+  /* ------------------ CSV 上传 ------------------ */
+
+  const handleUpload = (info: UploadChangeParam<UploadFile>) => {
+    const file = info.file.originFileObj;
+    if (!file) {
+      message.error("未检测到文件");
+      return;
+    }
+
+    const reader = new FileReader();
+
+    reader.onload = (e) => {
+      const text = String(e.target?.result ?? "");
+      parseCSV(text);
+    };
+
+    reader.onerror = () => message.error("读取文件失败");
+
+    reader.readAsText(file, "utf-8");
+  };
+
+  /* ------------------ CSV 解析 ------------------ */
+
+  const parseCSV = (text: string) => {
+    const delimiter = text.includes("\t") ? "\t" : ",";
+    const lines = text.trim().split(/\r?\n/);
+
+    if (lines.length === 0) return;
+
+    const headers = lines[0]
+      .split(delimiter)
+      .map((h) => h.replace(/"/g, "").trim().toLowerCase());
+
+    const findIndex = (key: string) =>
+      headers.findIndex((h) => h.includes(key));
+
+    const paliIndex = findIndex("pali");
+    const nissayaIndex = findIndex("nissaya");
+    const noteIndex = findIndex("note");
+
+    const data: WordData[] = lines.slice(1).map((line, i) => {
+      const cols = line.split(delimiter).map((c) => c.replace(/"/g, "").trim());
+
+      return {
+        id: i + 1,
+        pali: cols[paliIndex] ?? "",
+        nissaya: cols[nissayaIndex] ?? "",
+        note: cols[noteIndex] ?? "",
+      };
+    });
+
+    setCsvData(data);
+    message.success(`CSV 解析成功,共 ${data.length} 行`);
+  };
+
+  /* ------------------ Prompt 生成 ------------------ */
+
+  const generatePrompt = (): string => {
+    const sentenceJsonl = original
+      .map((s) => `{"id":"${s.id}","content":"${s.content}"}`)
+      .join("\n");
+
+    const csvText = ["id,pali,nissaya,note"]
+      .concat(
+        csvData.map(
+          (r) => `${r.id},"${r.pali}","${r.nissaya}","${r.note ?? ""}"`
+        )
+      )
+      .join("\n");
+
+    return `# 句子数据
+\`\`\`jsonl
+${sentenceJsonl}
+\`\`\`
+
+# 逐词解析数据
+\`\`\`csv
+${csvText}
+\`\`\`
+
+将逐词解析数据与句子对应,一个句子对多个逐词解析数据。
+保持顺序且不可遗漏。
+输出 jsonl 格式:
+字段 id content words
+words 为逐词 id,用逗号分隔`;
+  };
+
+  /* ------------------ JSONL 解析 ------------------ */
+
+  const parseJsonlResults = () => {
+    try {
+      const results: AlignResult[] = jsonlInput
+        .trim()
+        .split(/\r?\n/)
+        .map((line) => JSON.parse(line) as AlignResult);
+
+      setAlignResults(results);
+      message.success("解析成功");
+      setCurrent(3);
+    } catch {
+      message.error("JSONL 格式错误");
+    }
+  };
+
+  /* ------------------ 移动词 ------------------ */
+
+  const moveWord = (sentenceIndex: number, direction: "prev" | "next") => {
+    const targetIndex =
+      direction === "prev" ? sentenceIndex - 1 : sentenceIndex + 1;
+
+    if (targetIndex < 0 || targetIndex >= alignResults.length) return;
+
+    const newResults = [...alignResults];
+
+    const currentWords = newResults[sentenceIndex].words
+      .split(",")
+      .filter(Boolean);
+
+    const movingWord =
+      direction === "prev" ? currentWords.shift() : currentWords.pop();
+
+    if (!movingWord) return;
+
+    const targetWords = newResults[targetIndex].words
+      .split(",")
+      .filter(Boolean);
+
+    if (direction === "prev") {
+      targetWords.push(movingWord);
+    } else {
+      targetWords.unshift(movingWord);
+    }
+
+    newResults[sentenceIndex].words = currentWords.join(",");
+    newResults[targetIndex].words = targetWords.join(",");
+
+    setAlignResults(newResults);
+  };
+
+  /* ------------------ Steps 配置 ------------------ */
+
+  const stepItems = [
+    { title: "上传 CSV" },
+    { title: "生成提示词" },
+    { title: "粘贴 LLM 结果" },
+    { title: "对齐预览" },
+  ];
+
+  /* ------------------ 内容页面 ------------------ */
+
+  const stepContents = [
+    <>
+      <Dragger
+        accept=".csv,.tsv,.txt"
+        showUploadList={false}
+        beforeUpload={() => false}
+        onChange={handleUpload}
+      >
+        <p className="ant-upload-drag-icon">
+          <InboxOutlined />
+        </p>
+        <p>点击或拖拽上传 CSV 文件</p>
+      </Dragger>
+
+      {csvData.length > 0 && (
+        <Table<WordData>
+          dataSource={csvData}
+          rowKey="id"
+          pagination={{ pageSize: 50 }}
+          scroll={{ y: 340 }}
+          columns={[
+            { title: "行号", dataIndex: "id", width: 120 },
+            { title: "Pali", dataIndex: "pali", width: 420 },
+            { title: "Nissaya", dataIndex: "nissaya" },
+          ]}
+        />
+      )}
+    </>,
+
+    <>
+      <Title level={5}>生成提示词</Title>
+      <TextArea rows={20} value={generatePrompt()} readOnly />
+      <Button
+        icon={<CopyOutlined />}
+        onClick={() => {
+          navigator.clipboard.writeText(generatePrompt());
+          message.success("已复制");
+        }}
+      >
+        复制提示词
+      </Button>
+    </>,
+
+    <>
+      <TextArea
+        rows={12}
+        placeholder="粘贴 JSONL"
+        value={jsonlInput}
+        onChange={(e) => setJsonlInput(e.target.value)}
+      />
+      <Button type="primary" onClick={parseJsonlResults}>
+        解析
+      </Button>
+    </>,
+
+    <>
+      {alignResults.map((res, idx) => {
+        const sentence = original.find((s) => s.id === res.id);
+
+        const wordList = res.words
+          .split(",")
+          .map(Number)
+          .map((id) => csvData.find((d) => d.id === id))
+          .filter(Boolean) as WordData[];
+
+        return (
+          <div key={res.id} style={{ marginBottom: 24 }}>
+            <Title level={5}>
+              {res.id} — {sentence?.content}
+            </Title>
+
+            <Space wrap>
+              {wordList.map((w, i) => {
+                const isFirst = i === 0;
+                const isLast = i === wordList.length - 1;
+
+                return (
+                  <Button
+                    key={w.id}
+                    type={isFirst || isLast ? "primary" : "default"}
+                    icon={isFirst ? <UpSquareOutlined /> : undefined}
+                    onClick={() => {
+                      if (isFirst) moveWord(idx, "prev");
+                      if (isLast) moveWord(idx, "next");
+                    }}
+                  >
+                    {`${w.pali} (${w.nissaya})`}
+                    {isLast && <DownSquareOutlined style={{ marginLeft: 4 }} />}
+                  </Button>
+                );
+              })}
+            </Space>
+          </div>
+        );
+      })}
+    </>,
+  ];
+
+  /* ------------------ render ------------------ */
+
+  return (
+    <div style={{ padding: 24 }}>
+      <Steps current={current} items={stepItems} />
+
+      <div style={{ marginTop: 24 }}>{stepContents[current]}</div>
+
+      <div style={{ marginTop: 24 }}>
+        {current > 0 && (
+          <Button onClick={() => setCurrent(current - 1)}>上一步</Button>
+        )}
+
+        {current < stepItems.length - 1 && (
+          <Button
+            type="primary"
+            style={{ marginLeft: 8 }}
+            onClick={() => setCurrent(current + 1)}
+          >
+            下一步
+          </Button>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default NissayaAligner;

+ 60 - 0
dashboard-v6/src/components/nissaya/NissayaAlignerModal.tsx

@@ -0,0 +1,60 @@
+import { Modal } from "antd";
+import NissayaAligner from "./NissayaAligner";
+import { useState, type JSX } from "react";
+import type { IChannel } from "../../api/Channel";
+
+interface IWidget {
+  trigger?: JSX.Element | string;
+  sentencesId?: string[];
+  channel?: IChannel;
+  open?: boolean;
+  onClose?: () => void;
+}
+
+const NissayaAlignerModal = ({
+  trigger,
+  sentencesId,
+  open,
+  onClose,
+}: IWidget) => {
+  const [innerOpen, setInnerOpen] = useState(false);
+  const isModalOpen = open ?? innerOpen;
+
+  const showModal = () => {
+    setInnerOpen(true);
+  };
+
+  const modalClose = () => {
+    setInnerOpen(false);
+    if (onClose) {
+      onClose();
+    }
+  };
+  const handleOk = () => {
+    modalClose();
+  };
+
+  const handleCancel = () => {
+    modalClose();
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"95%"}
+        style={{ maxWidth: 1500 }}
+        title="版本间复制"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        destroyOnHidden={true}
+        footer={[]}
+      >
+        <NissayaAligner sentencesId={sentencesId} />
+      </Modal>
+    </>
+  );
+};
+
+export default NissayaAlignerModal;

+ 1 - 1
dashboard-v6/backup/components/corpus/Recent.tsx → dashboard-v6/src/components/recent/Recent.tsx

@@ -10,7 +10,7 @@ const RecentWidget = () => {
   const [listData, setListData] = useState<IView[]>([]);
   const intl = useIntl();
   useEffect(() => {
-    const url = `/v2/view?view=user&limit=10`;
+    const url = `/api/v2/view?view=user&limit=10`;
     get<IViewListResponse>(url).then((json) => {
       if (json.ok) {
         const items: IView[] = json.data.rows.map((item, id) => {

+ 22 - 0
dashboard-v6/src/components/sentence/utils.ts

@@ -0,0 +1,22 @@
+import type { ISentence, ISentenceData } from "../../api/Corpus";
+
+export const toISentence = (apiData: ISentenceData): ISentence => {
+  return {
+    id: apiData.id,
+    content: apiData.content,
+    contentType: apiData.content_type,
+    html: apiData.html,
+    book: apiData.book,
+    para: apiData.paragraph,
+    wordStart: apiData.word_start,
+    wordEnd: apiData.word_end,
+    editor: apiData.editor,
+    studio: apiData.studio,
+    channel: apiData.channel,
+    updateAt: apiData.updated_at,
+    acceptor: apiData.acceptor,
+    prEditAt: apiData.pr_edit_at,
+    forkAt: apiData.fork_at,
+    suggestionCount: apiData.suggestionCount,
+  };
+};

+ 2 - 2
dashboard-v6/src/components/setting/SettingArticle.tsx

@@ -1,6 +1,6 @@
 import { Divider } from "antd";
-import { useAppSelector } from "../../../hooks";
-import { settingInfo } from "../../../reducers/setting";
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
 
 import { SettingFind } from "./default";
 import SettingItem from "./SettingItem";

+ 1 - 1
dashboard-v6/src/components/setting/default.ts

@@ -1,4 +1,4 @@
-import type { ISettingItem } from "../../../reducers/setting"
+import type { ISettingItem } from "../../reducers/setting";
 
 export interface ISettingItemOption {
   label: string;

+ 223 - 0
dashboard-v6/src/components/share/Collaborator.tsx

@@ -0,0 +1,223 @@
+import { useIntl } from "react-intl";
+import { useEffect, useRef, useState } from "react";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { Tag, Button, Popconfirm, Space, Badge, message, Dropdown } from "antd";
+import { UserOutlined, TeamOutlined } from "@ant-design/icons";
+
+import { delete_, get, put } from "../../request";
+
+import type {
+  IShareDeleteResponse,
+  IShareListResponse,
+  IShareResponse,
+  IShareUpdateRequest,
+} from "../../api/Share";
+import User from "../auth/User";
+import type { IUser, TRole } from "../../api/Auth";
+
+import Group from "../group/Group";
+import type { IGroup } from "../../api/Group";
+
+interface ICollaborator {
+  sn?: number;
+  id?: string;
+  resId: string;
+  resType: string;
+  power?: number;
+  user?: IUser;
+  group?: IGroup;
+  role?: TRole;
+}
+interface IWidget {
+  resId?: string;
+  load?: boolean;
+  onReload?: () => void;
+}
+const CollaboratorWidget = ({ resId, load = false, onReload }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const [canDelete, setCanDelete] = useState(false);
+  const [memberCount, setMemberCount] = useState<number>();
+
+  useEffect(() => {
+    if (load) {
+      ref.current?.reload();
+      if (typeof onReload !== "undefined") {
+        onReload();
+      }
+    }
+  }, [load, onReload]);
+  const ref = useRef<ActionType | null>(null);
+  const roleList: TRole[] = ["editor", "reader"];
+
+  return (
+    <ProList<ICollaborator>
+      rowKey="id"
+      actionRef={ref}
+      headerTitle={
+        <Space>
+          {intl.formatMessage({ id: "labels.collaborators" })}
+          <Badge color="geekblue" count={memberCount} />
+        </Space>
+      }
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+
+        let url = `/v2/share?view=res&id=${resId}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        if (typeof params.keyword !== "undefined") {
+          url += "&search=" + (params.keyword ? params.keyword : "");
+        }
+        const res = await get<IShareListResponse>(url);
+        if (res.ok) {
+          console.log(res.data);
+          setMemberCount(res.data.count);
+          switch (res.data.role) {
+            case "owner":
+              setCanDelete(true);
+              break;
+            case "manager":
+              setCanDelete(true);
+              break;
+          }
+          const items: ICollaborator[] = res.data.rows.map((item, id) => {
+            const member: ICollaborator = {
+              sn: id + 1,
+              id: item.id,
+              resId: item.res_id,
+              resType: item.res_type,
+              power: item.power,
+              user: item.user,
+              group: item.group,
+              role: item.role,
+            };
+
+            return member;
+          });
+          console.log(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        } else {
+          console.error(res.message);
+          return {
+            total: 0,
+            succcess: false,
+            data: [],
+          };
+        }
+      }}
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+      }}
+      metas={{
+        title: {
+          render: (_text, row, index) => {
+            return row.user ? (
+              <User {...row.user} showAvatar={false} key={index} />
+            ) : (
+              <Group group={row.group} key={index} />
+            );
+          },
+        },
+        avatar: {
+          render: (_text, row, index) => {
+            return row.user ? (
+              <UserOutlined key={index} />
+            ) : (
+              <TeamOutlined key={index} />
+            );
+          },
+        },
+        subTitle: {
+          render: (_text, row, index) => {
+            let right = "";
+            switch (row.power) {
+              case 10:
+                right = intl.formatMessage({ id: "auth.role.reader" });
+                break;
+              case 20:
+                right = intl.formatMessage({ id: "auth.role.editor" });
+                break;
+              case 30:
+                right = intl.formatMessage({ id: "auth.role.manager" });
+                break;
+              default:
+                break;
+            }
+            return (
+              <Dropdown
+                key={index}
+                trigger={["click"]}
+                menu={{
+                  items: roleList.map((item) => {
+                    return {
+                      key: item,
+                      label: intl.formatMessage({ id: "auth.role." + item }),
+                    };
+                  }),
+                  onClick: (e) => {
+                    put<IShareUpdateRequest, IShareResponse>(
+                      `/v2/share/${row.id}`,
+                      {
+                        role: e.key as TRole,
+                      }
+                    ).then((json) => {
+                      console.log(json);
+                      if (json.ok) {
+                        ref.current?.reload();
+                      }
+                    });
+                  },
+                }}
+              >
+                <Tag key={index}>{right}</Tag>
+              </Dropdown>
+            );
+          },
+        },
+        actions: {
+          render: (_text, row, index) => [
+            canDelete ? (
+              <Popconfirm
+                key={index}
+                placement="bottomLeft"
+                title={intl.formatMessage({
+                  id: "forms.message.member.remove",
+                })}
+                onConfirm={() => {
+                  console.log("delete", row.id);
+                  delete_<IShareDeleteResponse>("/v2/share/" + row.id)
+                    .then((json) => {
+                      if (json.ok) {
+                        message.success("delete ok");
+                        ref.current?.reload();
+                      } else {
+                        message.error(json.message);
+                      }
+                    })
+                    .catch((e) => {
+                      message.error(e);
+                    });
+                }}
+                okText={intl.formatMessage({ id: "buttons.ok" })}
+                cancelText={intl.formatMessage({ id: "buttons.cancel" })}
+              >
+                <Button size="small" type="link" danger key="link">
+                  {intl.formatMessage({ id: "buttons.remove" })}
+                </Button>
+              </Popconfirm>
+            ) : undefined,
+          ],
+        },
+      }}
+    />
+  );
+};
+
+export default CollaboratorWidget;

+ 124 - 0
dashboard-v6/src/components/share/CollaboratorAdd.tsx

@@ -0,0 +1,124 @@
+import { message } from "antd";
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormDependency,
+  type ProFormInstance,
+  ProFormSelect,
+} from "@ant-design/pro-components";
+
+import { post } from "../../request";
+import type { TRole } from "../../api/Auth";
+import type { IShareRequest, IShareResponse } from "../../api/Share";
+import { useRef } from "react";
+
+import type { EResType } from "./utils";
+import UserSelect from "../users/UserSelect";
+import GroupSelect from "../group/GroupSelect";
+
+interface IWidget {
+  resId: string;
+  resType: EResType;
+  onSuccess?: () => void;
+}
+const CollaboratorAddWidget = ({ resId, resType, onSuccess }: IWidget) => {
+  const roleList = ["editor", "reader"];
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  interface IFormData {
+    userId: string[];
+    groupId: string[];
+    userType: string;
+    role: TRole;
+  }
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        if (typeof resId !== "undefined") {
+          const postData: IShareRequest = {
+            user_id:
+              values.userType === "user" ? values.userId : values.groupId,
+            user_type: values.userType,
+            role: values.role,
+            res_id: resId,
+            res_type: resType,
+          };
+          const url = "/v2/share";
+          console.info("share api request", url, postData);
+          post<IShareRequest, IShareResponse>(url, postData).then((json) => {
+            console.debug("share api response", json);
+            if (json.ok) {
+              if (typeof onSuccess !== "undefined") {
+                onSuccess();
+              }
+              formRef.current?.resetFields(["userId"]);
+              message.success(intl.formatMessage({ id: "flashes.success" }));
+            }
+          });
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormSelect
+          initialValue={"user"}
+          name="userType"
+          label={intl.formatMessage({ id: "forms.fields.type.label" })}
+          allowClear={false}
+          options={[
+            {
+              value: "user",
+              label: intl.formatMessage({ id: "auth.type.user" }),
+            },
+            {
+              value: "group",
+              label: intl.formatMessage({ id: "auth.type.group" }),
+            },
+          ]}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "forms.message.user.required",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormDependency name={["userType"]}>
+          {({ userType }) => {
+            if (userType === "user") {
+              return <UserSelect name="userId" multiple={true} />;
+            } else {
+              return <GroupSelect name="groupId" multiple={true} />;
+            }
+          }}
+        </ProFormDependency>
+
+        <ProFormSelect
+          name="role"
+          initialValue={"reader"}
+          label={intl.formatMessage({ id: "forms.fields.role.label" })}
+          allowClear={false}
+          options={roleList.map((item) => {
+            return {
+              value: item,
+              label: intl.formatMessage({ id: "auth.role." + item }),
+            };
+          })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "forms.message.user.required",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default CollaboratorAddWidget;

+ 35 - 0
dashboard-v6/src/components/share/Share.tsx

@@ -0,0 +1,35 @@
+import { Divider } from "antd";
+import { useState } from "react";
+
+import Collaborator from "./Collaborator";
+import CollaboratorAdd from "./CollaboratorAdd";
+import type { EResType } from "./utils";
+
+interface IWidget {
+  resId: string;
+  resType: EResType;
+}
+const ShareWidget = ({ resId, resType }: IWidget) => {
+  const [reload, setReload] = useState(false);
+  return (
+    <div>
+      <CollaboratorAdd
+        resId={resId}
+        resType={resType}
+        onSuccess={() => {
+          setReload(true);
+        }}
+      />
+      <Divider></Divider>
+      <Collaborator
+        resId={resId}
+        load={reload}
+        onReload={() => {
+          setReload(false);
+        }}
+      />
+    </div>
+  );
+};
+
+export default ShareWidget;

+ 60 - 0
dashboard-v6/src/components/share/ShareModal.tsx

@@ -0,0 +1,60 @@
+import Share from "./Share";
+import { useIntl } from "react-intl";
+import type { EResType } from "./utils";
+import { useState } from "react";
+import { Modal } from "antd";
+
+interface IWidget {
+  resId: string;
+  resType: EResType;
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: () => void;
+}
+const ShareModalWidget = ({
+  resId,
+  resType,
+  trigger,
+  open,
+  onClose,
+}: IWidget) => {
+  const [innerOpen, setInnerOpen] = useState(false);
+  const intl = useIntl();
+
+  const isModalOpen = open ?? innerOpen;
+
+  const showModal = () => {
+    setInnerOpen(true);
+  };
+
+  const handleOk = () => {
+    if (onClose) {
+      onClose();
+    } else {
+      setInnerOpen(false);
+    }
+  };
+
+  const handleCancel = () => {
+    handleOk();
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        destroyOnHidden={true}
+        width={700}
+        title={intl.formatMessage({ id: "labels.collaboration" })}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        footer={false}
+      >
+        <Share resId={resId} resType={resType} />
+      </Modal>
+    </>
+  );
+};
+
+export default ShareModalWidget;

Некоторые файлы не были показаны из-за большого количества измененных файлов