diff --git a/package.json b/package.json index 26e75e093..99350d4ad 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "axios": "^0.21.4", "bootstrap": "^5.1.1", "bootstrap-icons": "^1.5.0", + "canvas": "2.6.1", + "cheerio": "^1.0.0-rc.10", "date-fns": "^2.24.0", "emoji-mart": "^3.0.1", "mongodb": "^3.6.9", diff --git a/src/interfaces/importBookmark.ts b/src/interfaces/importBookmark.ts new file mode 100644 index 000000000..8f716b65b --- /dev/null +++ b/src/interfaces/importBookmark.ts @@ -0,0 +1,11 @@ +export interface PageForImport { + url: string; + title: string; +} + +export interface DirForImport { + id: string; + name: string; + childrenDirIds: string[]; + childrenPages: PageForImport[]; +} diff --git a/src/libs/locales/en.ts b/src/libs/locales/en.ts index 7328c3f98..8060bf8c2 100644 --- a/src/libs/locales/en.ts +++ b/src/libs/locales/en.ts @@ -40,6 +40,7 @@ export const en = { toastr_success_put_back: 'Put Back Home!', toastr_success_add_directory: 'Add Directory', toastr_success_send_inquiry: 'Send Inquiry', + toastr_success_import_bookmark: 'Complete importing bookmark!', home: 'Home', read: 'Read', @@ -73,6 +74,7 @@ export const en = { create_directory: 'Create New Directory', manage_directory: 'Manage Directory', rename_directory: 'Rename Directory', + import: 'Import', save_page_to_directory: 'Add Page to Directory', add_page_already_saved: 'Add Page Saved Already', remove_page_from_directory: 'Remove Page from Directory', @@ -94,6 +96,7 @@ export const en = { development: 'Development (We are looking for help)', others: 'Others', inquiry_submit: 'Send', + file_is_not_selected: 'File is not selected', maintenance: 'The management screen is under maintenance', diff --git a/src/libs/locales/ja.ts b/src/libs/locales/ja.ts index 28682c066..e7dd18381 100644 --- a/src/libs/locales/ja.ts +++ b/src/libs/locales/ja.ts @@ -40,6 +40,7 @@ export const ja = { toastr_success_put_back: 'ホームに戻しました!', toastr_success_add_directory: 'ディレクトリに追加しました', toastr_success_send_inquiry: '送信しました', + toastr_success_import_bookmark: 'インポートが完了しました。', home: 'ホーム', read: '読んだ記事', @@ -73,6 +74,7 @@ export const ja = { create_directory: 'ディレクトリの新規作成', manage_directory: 'ディレクトリの編集', rename_directory: 'ディレクトリの名前変更', + import: 'インポート', save_page_to_directory: 'ディレクトリにページを追加する', add_page_already_saved: 'すでに保存しているページを追加する', remove_page_from_directory: 'ディレクトリからページを除外する', @@ -94,6 +96,7 @@ export const ja = { development: '開発に関すること(お手伝い募集中です)', others: 'その他', inquiry_submit: '送信', + file_is_not_selected: 'ファイルが指定されていません。', maintenance: '管理画面はメンテナンス中です', diff --git a/src/pages/user/settings.tsx b/src/pages/user/settings.tsx index 8e5fc8496..81d12ab11 100644 --- a/src/pages/user/settings.tsx +++ b/src/pages/user/settings.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, useState } from 'react'; import Loader from 'react-loader-spinner'; import { useDebouncedCallback } from 'use-debounce'; import { CopyToClipboard } from 'react-copy-to-clipboard'; @@ -18,12 +18,17 @@ import { EditableInput } from '~/components/case/molecules/EditableInput'; import { EditableTextarea } from '~/components/case/molecules/EditableTextarea'; import { UserIcon } from '~/components/domain/User/atoms/UserIcon'; +import { convertFromHtmlToDirs, convertFromHtmlToPageUrls, convertFromHtmlToPages } from '~/utils/importBookmarkService'; + const Page: WebevNextPage = () => { const { t } = useLocale(); const { data: currentUser, mutate: mutateCurrentUser } = useCurrentUser(); const { data: apiToken, mutate: mutateApiToken, isValidating: isValidatingApiToken } = useApiToken(); + const [uploadedFile, setUploadedFile] = useState(null); + const [importOption, setImportOption] = useState('withoutTitle'); + const updateProfile = (newObject: Partial): void => { try { restClient.apiPut('/users/me', { property: newObject }); @@ -51,6 +56,41 @@ const Page: WebevNextPage = () => { } }; + const handleUploadFile = (e: React.ChangeEvent) => { + setUploadedFile(e.target.files !== null ? e.target.files[0] : null); + }; + + const handleImportBookmark = async () => { + if (uploadedFile == null) { + toastError(new Error(t.file_is_not_selected)); + return; + } + + const html = await uploadedFile.text(); + let params = {}; + let url = ''; + switch (importOption) { + case 'withoutTitle': + params = { url: convertFromHtmlToPageUrls(html) }; + url = '/pages'; + break; + case 'withTitle': + params = { pages: convertFromHtmlToPages(html) }; + url = '/pages/from-page-object'; + toastError(new Error('タイトル付きインポートは現在利用できません。')); + break; + case 'withDirectories': + params = { dirs: convertFromHtmlToDirs(html) }; + url = '/pages/from-dir-object'; + toastError(new Error('ディレクトリ付きインポートは現在利用できません。')); + break; + } + + const { data } = await restClient.apiPost(url, params); + console.log(data); + toastSuccess(t.toastr_success_import_bookmark); + }; + const changeProfile = (newObject: Partial): void => { mutateCurrentUser({ ...currentUser, ...newObject }, false); debounceUpdateProfile(newObject); @@ -84,6 +124,61 @@ const Page: WebevNextPage = () => { +
+ +
+
+ + +
+
+
+ setImportOption('withoutTitle')} + /> + +
+
+ + +
+
+ + +
+
+
+
); diff --git a/src/utils/importBookmarkService.ts b/src/utils/importBookmarkService.ts new file mode 100644 index 000000000..eb3a0c7bf --- /dev/null +++ b/src/utils/importBookmarkService.ts @@ -0,0 +1,109 @@ +import * as cheerio from 'cheerio'; +import { PageForImport, DirForImport } from '~/interfaces/importBookmark'; + +/* +ブラウザからエクスポートしたブックマークhtmlの内容をスクレイピング +ディレクトリとファイルの階層構造を以下のような Object の配列で再現 +dirs: {id, name, childrenDirIds: string[], childrenPages: {url, title}[]}[] +dirs の各要素は一つのディレクトリを示し、以下の key をもつ +- id: 識別子 +- name: ディレクトリ名 +- childrenDirIds: 子ディレクトリ識別子の配列 +- childrenPages: 子ページオブジェクトの配列 + +ex. +インポートするブックマークの階層構造 +- (Root Dir)Bookmarks + - (Page)[example](http://example.com) + - (Dir)hoge + - (page)[hogefuga])http://hogefuga.com) +出力結果 +[ + {id: '', name: '', childrenDirIds: ['1591786320hoge'], childrenPages: [{url: 'http://example.com', title: 'example'}] }, + {id: '1591786320hoge', name: 'hoge', childrenDirIds: [], childrenPages: [{url: 'http://hogefuga.com', title: 'hogefuga'}] }, +] +*/ +export const convertFromHtmlToDirs = (html: string): DirForImport[] => { + const root = cheerio.load(html); + const result: DirForImport[] = []; + + /* + ディレクトリを表す要素を一件一件見ていく + */ + root('dl').map((_: number, nodeDL: any): void => { + const dir: DirForImport = { + id: '', + name: '', + childrenDirIds: [], + childrenPages: [], + }; + + /* + id と name の取得 + 同名ディレクトリ区別のため、ディレクトリ作成日+ディレクトリ名を id として設定(safari には ADD_DATE がないため、同名ディレクトリは区別できない) + ディレクトリ作成日とディレクトリ名は同階層の二つ上の要素にあるため、nodeDL.previousSibling.previousSibling を取得 + */ + const previousNode = cheerio.load(nodeDL.previousSibling.previousSibling); + const dirCreateAt = previousNode('h3').attr('add_date') || ''; + const dirName = previousNode('h3').text() || ''; + dir.id = dirCreateAt + dirName; + dir.name = dirName; + + /* + 子要素から配下ディレクトリと配下ページを取得 + */ + nodeDL.childNodes.forEach((node: any) => { + const $ = cheerio.load(node); + + /* + 配下ページ + */ + const childPage = $('a'); + if (childPage.length > 0) { + const page: PageForImport = { url: '', title: '' }; + page.url = childPage.attr('href') || ''; + page.title = childPage.text(); + dir.childrenPages.push(page); + } + + /* + 配下ディレクトリ + */ + const childDir = $('h3'); + if (childDir.length > 0) { + const childDirCreateAt = childDir.attr('add_date'); + const childDirName = childDir.text(); + dir.childrenDirIds.push(childDirCreateAt + childDirName); + } + }); + + result.push(dir); + }); + + return result; +}; + +export const convertFromHtmlToPageUrls = (html: string): string[] => { + const root = cheerio.load(html); + const result: string[] = []; + + root('a').map((_: number, node: any): void => { + result.push(node.attribs.href); + }); + + return result; +}; + +export const convertFromHtmlToPages = (html: string): PageForImport[] => { + const root = cheerio.load(html); + const result: PageForImport[] = []; + + root('a').map((_: number, node: any): void => { + const $ = cheerio.load(node); + const url = $('a').attr('href') || ''; + const title = $('a').text(); + result.push({ url, title }); + }); + + return result; +}; diff --git a/yarn.lock b/yarn.lock index d3cb52072..a8086560b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3317,7 +3317,7 @@ cheerio-select@^1.5.0: domhandler "^4.2.0" domutils "^2.7.0" -cheerio@^1.0.0-rc.3: +cheerio@^1.0.0-rc.10, cheerio@^1.0.0-rc.3: version "1.0.0-rc.10" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.10.tgz#2ba3dcdfcc26e7956fc1f440e61d51c643379f3e" integrity sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw==