소스 검색

Bookmark import.

Jackson D 3 년 전
부모
커밋
1aedb87bf4

+ 13 - 2
client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx

@@ -12,7 +12,7 @@ import { Bookmark, Category } from '../../../interfaces';
 // Other
 import classes from './BookmarkCard.module.css';
 import { Icon } from '../../UI';
-import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
+import { iconParser, isImage, isBase64Image, isSvg, isUrl, urlParser } from '../../../utility';
 
 interface Props {
   category: Category;
@@ -54,7 +54,18 @@ export const BookmarkCard = (props: Props): JSX.Element => {
           if (bookmark.icon) {
             const { icon, name } = bookmark;
 
-            if (isImage(icon)) {
+            if(isBase64Image(icon)){
+              iconEl = (
+                <div className={classes.BookmarkIcon}>
+                  <img
+                    src={icon}
+                    alt={`${name} icon`}
+                    className={classes.CustomIcon}
+                  />
+                </div>
+              );
+            }
+            else if (isImage(icon)) {
               const source = isUrl(icon) ? icon : `/uploads/${icon}`;
 
               iconEl = (

+ 17 - 0
client/src/components/Bookmarks/Bookmarks.tsx

@@ -7,6 +7,8 @@ import { State } from '../../store/reducers';
 import { bindActionCreators } from 'redux';
 import { actionCreators } from '../../store';
 
+import { importBookmark } from '../../store/action-creators';
+
 // Typescript
 import { Category, Bookmark } from '../../interfaces';
 
@@ -21,6 +23,7 @@ import {
   Spinner,
   Modal,
   Message,
+  FileButton,
 } from '../UI';
 
 // Components
@@ -49,6 +52,11 @@ export const Bookmarks = (props: Props): JSX.Element => {
   const { getCategories, setEditCategory, setEditBookmark } =
     bindActionCreators(actionCreators, dispatch);
 
+    const { importBookmark } = bindActionCreators(
+      actionCreators,
+      dispatch
+    );
+
   // Load categories if array is empty
   useEffect(() => {
     if (!categories.length) {
@@ -128,6 +136,10 @@ export const Bookmarks = (props: Props): JSX.Element => {
     }
   };
 
+  const importFromFile = async (file: File) => {
+    await importBookmark({file});
+  };
+
   const finishEditing = () => {
     setShowTable(false);
     setEditCategory(null);
@@ -169,6 +181,11 @@ export const Bookmarks = (props: Props): JSX.Element => {
               handler={finishEditing}
             />
           )}
+          <FileButton
+            name="Import From File"
+            icon="mdiPlusBox"
+            handler={async (f) => await importFromFile(f)}
+          />
         </div>
       )}
 

+ 31 - 0
client/src/components/UI/Buttons/FileButton/FileButton.module.css

@@ -0,0 +1,31 @@
+.ActionButton {
+  /* background-color: var(--color-accent); */
+  border: 1.5px solid var(--color-accent);
+  border-radius: 4px;
+  color: var(--color-primary);
+  padding: 5px 15px;
+  transition: all 0.3s;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  max-width: 250px;
+  margin-right: 10px;
+  margin-bottom: 20px;
+}
+
+.ActionButton:hover {
+  background-color: var(--color-accent);
+  color: var(--color-background);
+  cursor: pointer;
+}
+
+.ActionButtonIcon {
+  --size: 20px;
+  width: var(--size);
+  height: var(--size);
+  /* display: flex; */
+}
+
+.ActionButtonName {
+  display: flex;
+}

+ 64 - 0
client/src/components/UI/Buttons/FileButton/FileButton.tsx

@@ -0,0 +1,64 @@
+import { ChangeEvent, Fragment, useRef } from 'react';
+import { Link } from 'react-router-dom';
+
+import classes from './FileButton.module.css';
+import { Icon } from '../..';
+
+interface Props {
+  name: string;
+  icon: string;
+  link?: string;
+  handler?: (file: File) => void;
+}
+
+export const FileButton = (props: Props): JSX.Element => {
+  const imageRef = useRef<HTMLInputElement>(null);
+
+  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
+    let target = e.target as HTMLInputElement;
+
+    if (target?.files && target?.files[0]) {
+      if(props.handler) props.handler(target?.files[0]);
+    }
+  };
+
+  const open = () => {
+    imageRef?.current?.click();
+  };
+
+  const body = (
+    <Fragment>
+      <div className={classes.ActionButtonIcon}>
+        <Icon icon={props.icon} />
+      </div>
+      <div className={classes.ActionButtonName}>{props.name}</div>
+    </Fragment>
+  );
+
+  if (props.link) {
+    return (
+      <Link to={props.link} tabIndex={0}>
+        {body}
+      </Link>
+    );
+  } else if (props.handler) {
+    return (
+      <div
+        className={classes.ActionButton}
+        onClick={open}
+        tabIndex={0}
+      >
+        <input
+          ref={imageRef}
+          type="file"
+          style={{ display: 'none' }}
+          accept="image/*"
+          onChange={handleChange}
+        />
+        {body}
+      </div>
+    );
+  } else {
+    return <div className={classes.ActionButton}>{body}</div>;
+  }
+};

+ 1 - 0
client/src/components/UI/index.ts

@@ -12,4 +12,5 @@ export * from './Forms/InputGroup/InputGroup';
 export * from './Forms/ModalForm/ModalForm';
 export * from './Buttons/ActionButton/ActionButton';
 export * from './Buttons/Button/Button';
+export * from './Buttons/FileButton/FileButton';
 export * from './Text/Message/Message';

+ 4 - 0
client/src/interfaces/Bookmark.ts

@@ -11,3 +11,7 @@ export interface NewBookmark {
 export interface Bookmark extends Model, NewBookmark {
   orderId: number;
 }
+
+export interface BookmarkImport {
+  file: File
+}

+ 38 - 0
client/src/store/action-creators/bookmark.ts

@@ -6,6 +6,7 @@ import { ActionType } from '../action-types';
 import {
   ApiResponse,
   Bookmark,
+  BookmarkImport,
   Category,
   Config,
   NewBookmark,
@@ -18,6 +19,7 @@ import {
   DeleteBookmarkAction,
   DeleteCategoryAction,
   GetCategoriesAction,
+  ImportBookmarkAction,
   PinCategoryAction,
   ReorderBookmarksAction,
   ReorderCategoriesAction,
@@ -108,6 +110,42 @@ export const addBookmark =
     }
   };
 
+export const importBookmark =
+  (formData: BookmarkImport) =>
+  async (dispatch: Dispatch<ImportBookmarkAction>) => {
+    try {
+      let formd = new FormData();
+      formd.append("file", formData.file);
+
+      const res = await axios.post<ApiResponse<Bookmark>>(
+        '/api/bookmarks/import',
+        formd,
+        {
+          headers: {
+            ...applyAuth(),
+            'Content-Type': 'multipart/form-data',
+          },
+        }
+      );
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `Bookmark file uploaded.`,
+        },
+      });
+
+      dispatch({
+        type: ActionType.importBookmark,
+        payload: res.data.data,
+      });
+      dispatch<any>(sortBookmarks(res.data.data.categoryId));
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
 export const pinCategory =
   (category: Category) => async (dispatch: Dispatch<PinCategoryAction>) => {
     try {

+ 1 - 0
client/src/store/action-types/index.ts

@@ -42,6 +42,7 @@ export enum ActionType {
   setEditBookmark = 'SET_EDIT_BOOKMARK',
   reorderBookmarks = 'REORDER_BOOKMARKS',
   sortBookmarks = 'SORT_BOOKMARKS',
+  importBookmark = 'IMPORT_BOOKMARK',
   // AUTH
   login = 'LOGIN',
   logout = 'LOGOUT',

+ 5 - 0
client/src/store/actions/bookmark.ts

@@ -19,6 +19,11 @@ export interface AddBookmarkAction {
   payload: Bookmark;
 }
 
+export interface ImportBookmarkAction {
+  type: ActionType.importBookmark;
+  payload: Bookmark;
+}
+
 export interface PinCategoryAction {
   type: ActionType.pinCategory;
   payload: Category;

+ 4 - 0
client/src/utility/validators.ts

@@ -18,6 +18,10 @@ export const isImage = (data: string): boolean => {
   return regex.test(data);
 };
 
+export const isBase64Image = (data: string): boolean => {
+  return data.startsWith("data:image/")
+};
+
 export const isSvg = (data: string): boolean => {
   const regex = /.(svg)$/i;
 

+ 22 - 0
controllers/bookmarks/importBookmark.js

@@ -0,0 +1,22 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const ErrorResponse = require('../../utils/ErrorResponse');
+const importBookmarkData = require('../../utils/importBookmark');
+const Bookmark = require('../../models/Bookmark');
+
+// @desc      Import bookmarks from file.
+// @route     POST /api/bookmarks/import
+// @access    Public
+const importBookmark = asyncWrapper(async (req, res, next) => {
+  importBookmarkData(req.file.path);
+
+  if(next){
+    next();
+  } else {
+    res.status(200).json({
+      success: true,
+      data: bookmark,
+    });
+  }
+});
+
+module.exports = importBookmark;

+ 1 - 0
controllers/bookmarks/index.js

@@ -3,6 +3,7 @@ module.exports = {
   getAllBookmarks: require('./getAllBookmarks'),
   getSingleBookmark: require('./getSingleBookmark'),
   updateBookmark: require('./updateBookmark'),
+  importBookmark: require('./importBookmark'),
   deleteBookmark: require('./deleteBookmark'),
   reorderBookmarks: require('./reorderBookmarks'),
 };

+ 13 - 2
middleware/multer.js

@@ -14,7 +14,15 @@ const storage = multer.diskStorage({
   },
 });
 
-const supportedTypes = ['jpg', 'jpeg', 'png', 'svg', 'svg+xml', 'x-icon'];
+const supportedTypes = [
+  'jpg',
+  'jpeg',
+  'png',
+  'svg',
+  'svg+xml',
+  'x-icon',
+  'html',
+];
 
 const fileFilter = (req, file, cb) => {
   if (supportedTypes.includes(file.mimetype.split('/')[1])) {
@@ -26,4 +34,7 @@ const fileFilter = (req, file, cb) => {
 
 const upload = multer({ storage, fileFilter });
 
-module.exports = upload.single('icon');
+module.exports = {
+  icon: upload.single('icon'),
+  bookmark: upload.single('file'),
+};

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
     "express": "^4.17.1",
     "jsonwebtoken": "^8.5.1",
     "multer": "^1.4.3",
+    "node-bookmarks-parser": "^2.0.0",
     "node-schedule": "^2.0.0",
     "sequelize": "^6.9.0",
     "sqlite3": "^5.0.2",

+ 2 - 2
routes/apps.js

@@ -15,13 +15,13 @@ const {
 
 router
   .route('/')
-  .post(auth, requireAuth, upload, createApp)
+  .post(auth, requireAuth, upload.icon, createApp)
   .get(auth, getAllApps);
 
 router
   .route('/:id')
   .get(auth, getSingleApp)
-  .put(auth, requireAuth, upload, updateApp)
+  .put(auth, requireAuth, upload.icon, updateApp)
   .delete(auth, requireAuth, deleteApp);
 
 router.route('/0/reorder').put(auth, requireAuth, reorderApps);

+ 7 - 2
routes/bookmark.js

@@ -11,19 +11,24 @@ const {
   updateBookmark,
   deleteBookmark,
   reorderBookmarks,
+  importBookmark,
 } = require('../controllers/bookmarks');
 
 router
   .route('/')
-  .post(auth, requireAuth, upload, createBookmark)
+  .post(auth, requireAuth, upload.icon, createBookmark)
   .get(auth, getAllBookmarks);
 
 router
   .route('/:id')
   .get(auth, getSingleBookmark)
-  .put(auth, requireAuth, upload, updateBookmark)
+  .put(auth, requireAuth, upload.icon, updateBookmark)
   .delete(auth, requireAuth, deleteBookmark);
 
+  router
+  .route('/import')
+  .post(auth, requireAuth, upload.bookmark, importBookmark, getAllBookmarks)
+
 router.route('/0/reorder').put(auth, requireAuth, reorderBookmarks);
 
 module.exports = router;

+ 103 - 0
utils/importBookmark.js

@@ -0,0 +1,103 @@
+const parse = require('node-bookmarks-parser');
+var sqlite3 = require('sqlite3').verbose();
+const File = require('./File');
+const databaseFilePath = 'data/db.sqlite';
+
+let bookmarks = [];
+
+function saveBookmarks() {
+  const db = new sqlite3.Database(databaseFilePath);
+  const importDate = new Date().toISOString();
+
+  db.serialize(() => {
+    db.all(`SELECT id, name FROM categories`, (err, data) => {
+      if (err) {
+      }
+
+      for (const bookmark of bookmarks) {
+        // Found in db.
+        let stmt = db.prepare(
+          'INSERT INTO bookmarks (name, url, categoryId, icon, createdAt, updatedAt) VALUES(?,?,?,?,?,?)'
+        );
+        stmt.run(
+          bookmark.title,
+          bookmark.url,
+          data.find(
+            (r) => r.name.toLowerCase() === bookmark.category.toLowerCase()
+          )?.id,
+          bookmark.icon,
+          importDate,
+          importDate,
+          (err) => {
+            if (err) {
+              console.error(err);
+            }
+          }
+        );
+        stmt.finalize();
+      }
+    });
+  });
+}
+
+function saveCategories() {
+  let db = new sqlite3.Database(databaseFilePath);
+  let uniqueCats = [...new Set(bookmarks.map((r) => r.category))];
+  const importDate = new Date().toString();
+
+  db.serialize(() => {
+    db.all(`SELECT * FROM categories`, (err, data) => {
+      if (err) {
+        console.error(err);
+      }
+
+      for (const newCaterory of uniqueCats) {
+        for (const category of data) {
+          if (category.name.toLowerCase() === newCaterory.toLowerCase()) {
+            continue;
+          }
+        }
+
+        let stmt = db.prepare(
+          'INSERT INTO categories (name, createdAt, updatedAt) VALUES(?,?,?)'
+        );
+        stmt.run(newCaterory, importDate, importDate, (err) => {
+          if (err) {
+            console.error(err);
+          }
+        });
+        stmt.finalize();
+      }
+
+      saveBookmarks();
+    });
+  });
+}
+
+function crawlBookmarks(bookmark, category) {
+  if (bookmark.type === 'bookmark') {
+    bookmarks.push({
+      title: bookmark.title,
+      url: bookmark.url,
+      icon: bookmark.icon,
+      category: category,
+    });
+  }
+
+  if (bookmark.type === 'folder') {
+    for (const child of bookmark.children) {
+      crawlBookmarks(child, bookmark.title);
+    }
+  }
+}
+
+module.exports = function importBookmark(path) {
+  const fileContent = new File(path).read();
+  const bookmarkData = parse(fileContent);
+
+  for (const bookmark of bookmarkData) {
+    crawlBookmarks(bookmark);
+  }
+
+  saveCategories();
+};