diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx index d306655..78ff585 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx +++ b/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 = ( +
+ {`${name} +
+ ); + } + else if (isImage(icon)) { const source = isUrl(icon) ? icon : `/uploads/${icon}`; iconEl = ( diff --git a/client/src/components/Bookmarks/Bookmarks.tsx b/client/src/components/Bookmarks/Bookmarks.tsx index 0905711..7311e17 100644 --- a/client/src/components/Bookmarks/Bookmarks.tsx +++ b/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} /> )} + await importFromFile(f)} + /> )} diff --git a/client/src/components/UI/Buttons/FileButton/FileButton.module.css b/client/src/components/UI/Buttons/FileButton/FileButton.module.css new file mode 100644 index 0000000..7c7dbf6 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/client/src/components/UI/Buttons/FileButton/FileButton.tsx b/client/src/components/UI/Buttons/FileButton/FileButton.tsx new file mode 100644 index 0000000..d4fe6a4 --- /dev/null +++ b/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(null); + + const handleChange = (e: ChangeEvent) => { + 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 = ( + +
+ +
+
{props.name}
+
+ ); + + if (props.link) { + return ( + + {body} + + ); + } else if (props.handler) { + return ( +
+ + {body} +
+ ); + } else { + return
{body}
; + } +}; diff --git a/client/src/components/UI/index.ts b/client/src/components/UI/index.ts index 23d5f73..a79f1a0 100644 --- a/client/src/components/UI/index.ts +++ b/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'; diff --git a/client/src/interfaces/Bookmark.ts b/client/src/interfaces/Bookmark.ts index 858101c..9781028 100644 --- a/client/src/interfaces/Bookmark.ts +++ b/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 +} diff --git a/client/src/store/action-creators/bookmark.ts b/client/src/store/action-creators/bookmark.ts index 5f077a6..15acc79 100644 --- a/client/src/store/action-creators/bookmark.ts +++ b/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) => { + try { + let formd = new FormData(); + formd.append("file", formData.file); + + const res = await axios.post>( + '/api/bookmarks/import', + formd, + { + headers: { + ...applyAuth(), + 'Content-Type': 'multipart/form-data', + }, + } + ); + + dispatch({ + type: ActionType.createNotification, + payload: { + title: 'Success', + message: `Bookmark file uploaded.`, + }, + }); + + dispatch({ + type: ActionType.importBookmark, + payload: res.data.data, + }); + dispatch(sortBookmarks(res.data.data.categoryId)); + } catch (err) { + console.log(err); + } + }; + export const pinCategory = (category: Category) => async (dispatch: Dispatch) => { try { diff --git a/client/src/store/action-types/index.ts b/client/src/store/action-types/index.ts index 4be159f..7c30712 100644 --- a/client/src/store/action-types/index.ts +++ b/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', diff --git a/client/src/store/actions/bookmark.ts b/client/src/store/actions/bookmark.ts index 7c9e1f2..2d103fe 100644 --- a/client/src/store/actions/bookmark.ts +++ b/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; diff --git a/client/src/utility/validators.ts b/client/src/utility/validators.ts index df4de72..271bbb4 100644 --- a/client/src/utility/validators.ts +++ b/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; diff --git a/controllers/bookmarks/importBookmark.js b/controllers/bookmarks/importBookmark.js new file mode 100644 index 0000000..1d8a82e --- /dev/null +++ b/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; diff --git a/controllers/bookmarks/index.js b/controllers/bookmarks/index.js index 5c1bd86..c9ca352 100644 --- a/controllers/bookmarks/index.js +++ b/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'), }; diff --git a/middleware/multer.js b/middleware/multer.js index cf5a384..e6bdd99 100644 --- a/middleware/multer.js +++ b/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'), +}; diff --git a/package.json b/package.json index 1b3c92b..4b9810f 100644 --- a/package.json +++ b/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", diff --git a/routes/apps.js b/routes/apps.js index 3fd8a6c..0ddccb3 100644 --- a/routes/apps.js +++ b/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); diff --git a/routes/bookmark.js b/routes/bookmark.js index 6bc96ad..23fc5bb 100644 --- a/routes/bookmark.js +++ b/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; diff --git a/utils/importBookmark.js b/utils/importBookmark.js new file mode 100644 index 0000000..b3c3d01 --- /dev/null +++ b/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(); +};