├── public
├── webpack.config.js
├── favicon.ico
├── index.html
└── electron.js
├── .gitattributes
├── src
├── index.css
├── constants.js
├── index.js
├── App.test.js
├── components
│ ├── AnnotationsPanel.css
│ ├── SearchWidget.css
│ ├── DataSourceDialog.css
│ ├── SearchResults.css
│ ├── FolderNode.css
│ ├── OpenTray.js
│ ├── MiniAnnotations.css
│ ├── ContentViewer.css
│ ├── OpenTray.css
│ ├── SearchWidget.js
│ ├── MemNode.js
│ ├── AnnotationsPanel.js
│ ├── App.css
│ ├── MemNode.css
│ ├── SearchResults.js
│ ├── FolderNode.js
│ ├── MiniAnnotations.js
│ ├── DataSourceDialog.js
│ ├── ContentViewer.js
│ └── App.js
├── DataSourceConnector.js
├── DSConnectorRegistry.js
├── DataSourceWorkPump.js
├── Util.js
├── LocalDirectoryDSC.js
├── DataStore.js
└── ChromeBookmarksDSC.js
├── media
└── screens
│ ├── screen1.png
│ ├── screen2.png
│ └── screen3.png
├── package.json
├── notes.txt
├── README.md
└── .gitignore
/public/webpack.config.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/westoncb/mymex/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/media/screens/screen1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/westoncb/mymex/HEAD/media/screens/screen1.png
--------------------------------------------------------------------------------
/media/screens/screen2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/westoncb/mymex/HEAD/media/screens/screen2.png
--------------------------------------------------------------------------------
/media/screens/screen3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/westoncb/mymex/HEAD/media/screens/screen3.png
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | class Constants {
2 | static DS_TYPE_CHROME = 'ds-type-chrome'
3 | static DS_TYPE_DIRECTORY = 'ds-type-directory'
4 | }
5 |
6 | export default Constants
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './components/App'
4 | import './index.css'
5 |
6 | ReactDOM.render(
7 | ,
8 | document.getElementById('root')
9 | );
10 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | });
9 |
--------------------------------------------------------------------------------
/src/components/AnnotationsPanel.css:
--------------------------------------------------------------------------------
1 | .annotations-panel {
2 | position: absolute;
3 | display: flex;
4 | justify-content: space-between;
5 | flex-direction: column;
6 | background-color: #192732fa;
7 | padding: 1rem;
8 | left: 0;
9 | right: 0;
10 | bottom: 0;
11 | height: 10rem;
12 | z-index: 10;
13 | box-shadow: 0 4px 10px 4px rgba(0, 0, 0, 0.25);
14 | }
--------------------------------------------------------------------------------
/src/components/SearchWidget.css:
--------------------------------------------------------------------------------
1 | .search-widget-container {
2 | background-color: #495762;
3 | display: flex;
4 | flex-direction: row;
5 | justify-content: space-between;
6 | align-items: flex-start;
7 | padding: 3rem;
8 | padding-left: 6rem;
9 | }
10 |
11 | .input-results-group {
12 | display: flex;
13 | flex-direction: column;
14 | flex-grow: 1;
15 | margin-right: 5rem;
16 | }
17 |
18 | .search-input {
19 | height: 2rem;
20 | margin: 0;
21 | font-size: 1.2rem;
22 | padding: 1.5rem;
23 | box-sizing: border-box;
24 | }
--------------------------------------------------------------------------------
/src/components/DataSourceDialog.css:
--------------------------------------------------------------------------------
1 | .data-source-control-group {
2 | margin-top: 1rem;
3 | }
4 |
5 | .data-source-list {
6 | display: flex;
7 | flex-direction: column;
8 | align-items: center;
9 | justify-content: flex-start;
10 | overflow-y: auto;
11 | max-height: 20rem;
12 | }
13 |
14 | .data-source-item {
15 | padding: 1rem;
16 | background-color: #394b59;
17 | width: 80%;
18 | height: 5rem;
19 | margin-bottom: 0.5rem;
20 | margin-top: 0.5rem;
21 | flex-shrink: 0;
22 | box-shadow: 0 0 12px 4px rgba(0, 0, 0, 0.15);
23 | }
--------------------------------------------------------------------------------
/src/components/SearchResults.css:
--------------------------------------------------------------------------------
1 | .results-panel {
2 | background-color: #394752;
3 | }
4 |
5 | .result-path {
6 | color: #ffffff66;
7 | font-size: 0.9rem;
8 | text-align: left;
9 | padding-left: 1rem;
10 | margin-bottom: 1.5rem;
11 | }
12 |
13 | .result-section {
14 | border-bottom: 4px solid #293742;
15 | padding-bottom: 1.5rem;
16 | padding-top: 0.5rem;
17 | }
18 |
19 | .folder-children {
20 |
21 | }
22 |
23 | .leaf-children {
24 | display: flex;
25 | flex-direction: row;
26 | flex-wrap: wrap;
27 | align-items: stretch;
28 | align-content: stretch;
29 | padding: 0.5rem;
30 | }
--------------------------------------------------------------------------------
/src/components/FolderNode.css:
--------------------------------------------------------------------------------
1 | .folder-node {
2 | display: flex;
3 | flex-direction: row;
4 | justify-content: space-between;
5 | font-size: 1.2rem;
6 | background-color: #474756;
7 | margin: 0.5rem;
8 | margin-left: 1rem;
9 | margin-right: 1rem;
10 | box-shadow: 0 0 10px 4px rgba(0, 0, 0, 0.2);
11 | transition: all 0.2s;
12 | min-height: 3rem;
13 | cursor: pointer;
14 | }
15 |
16 | .folder-node:hover {
17 | background-color: #676776;
18 | transition: all 0.15s;
19 | }
20 |
21 | .left-section {
22 | display: flex;
23 | flex-direction: row;
24 | align-items: center;
25 | padding: 0.5rem;
26 | padding-left: 1rem;
27 | }
28 |
29 | .node-text {
30 | margin-left: 1rem;
31 | }
--------------------------------------------------------------------------------
/src/components/OpenTray.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import './OpenTray.css';
3 |
4 | export default class OpenTray extends PureComponent {
5 |
6 | constructor(props) {
7 | super(props)
8 | }
9 |
10 | handleItemClick = (e) => {
11 |
12 | }
13 |
14 | render() {
15 | return (
16 |
17 | {
18 | this.props.openItems.map(item => (
19 |
20 |
21 |
{item.name}
22 |
23 | ))
24 | }
25 |
26 | )
27 | }
28 | }
--------------------------------------------------------------------------------
/src/components/MiniAnnotations.css:
--------------------------------------------------------------------------------
1 | .mini-annotations {
2 | display: flex;
3 | flex-direction: row;
4 | justify-content: space-between;
5 | align-items: center;
6 | padding: 0.75rem;
7 | height: 3rem;
8 | width: 100%;
9 | box-shadow: 0 0 10px 4px rgba(0, 0, 0, 0.12);
10 | background-color: rgba(255, 255, 255, 0.2);
11 | border-radius: 5px;
12 | border: 1px solid #333;
13 | margin-top: 0.25rem;
14 | will-change: transform, height, background-color;
15 | z-index: 10;
16 | }
17 |
18 | .mini-tag {
19 | margin-right: 0.25rem;
20 | margin-bottom: 0.25rem;
21 | }
22 |
23 | .tags-preview-list {
24 | display: flex;
25 | flex-direction: row;
26 | flex-wrap: wrap;
27 | flex-shrink: 0;
28 | }
29 |
30 | .notes-preview {
31 | flex-grow: 0;
32 | overflow: hidden;
33 | max-height: 6.25rem;
34 | margin-bottom: 0.5rem;
35 | }
36 |
37 | hr {
38 | border-color: rgba(255, 255, 255, 0.25);
39 | }
--------------------------------------------------------------------------------
/src/components/ContentViewer.css:
--------------------------------------------------------------------------------
1 | .content-viewer {
2 | display: flex;
3 | position: relative;
4 | flex-grow: 1;
5 | align-self: stretch;
6 | bottom: 0;
7 | }
8 |
9 | .content-iframe {
10 | position: relative;
11 | flex-grow: 1;
12 | align-self: stretch;
13 | border: 0;
14 | bottom: 0;
15 | }
16 |
17 | .content-toolbar {
18 | display: flex;
19 | justify-content: space-between;
20 | align-items: center;
21 | padding: 0.5rem;
22 | position: absolute;
23 | background-color: #293742;
24 | box-shadow: 0 4px 10px 4px rgba(0, 0, 0, 0.25);
25 | top: 0;
26 | left: 0;
27 | right: 0;
28 | z-index: 10;
29 | }
30 |
31 | .local-status-message {
32 | display: flex;
33 | flex-direction: row;
34 | justify-content: center;
35 | align-items: center;
36 | white-space: nowrap;
37 | overflow: hidden;
38 | text-overflow: ellipsis;
39 | }
40 |
41 | .toolbar-button {
42 |
43 | }
44 |
45 | .version-icon {
46 | margin-left: 1rem;
47 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mymex",
3 | "version": "0.1.0",
4 | "private": false,
5 | "main": "public/electron.js",
6 | "dependencies": {
7 | "@blueprintjs/core": "^3.23.1",
8 | "@blueprintjs/icons": "^3.13.0",
9 | "cryptiles": "^4.1.2",
10 | "electron-is-dev": "^1.1.0",
11 | "eslint": "^4.18.2",
12 | "hoek": "^4.2.1",
13 | "js-yaml": "^3.13.1",
14 | "md5": "^2.2.1",
15 | "nedb-promises": "^4.0.1",
16 | "open": "^6.0.0",
17 | "react": "^16.10.2",
18 | "react-dom": "^16.10.2",
19 | "react-scripts": "0.9.x",
20 | "react-spring": "^8.0.27",
21 | "walkdir": "^0.4.1",
22 | "webpack-dev-server": "^3.1.11"
23 | },
24 | "devDependencies": {
25 | "concurrently": "^5.1.0",
26 | "cross-env": "^7.0.0",
27 | "electron": "9.0.5",
28 | "electron-builder": "^22.3.2",
29 | "wait-on": "^4.0.0"
30 | },
31 | "scripts": {
32 | "start": "concurrently \"cross-env BROWSER=none react-scripts start\" \"wait-on http://localhost:3000 && electron .\"",
33 | "build": "react-scripts build",
34 | "test": "react-scripts test --env=jsdom",
35 | "eject": "react-scripts eject"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/OpenTray.css:
--------------------------------------------------------------------------------
1 | .main-col {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: flex-start;
5 | align-items: center;
6 | min-width: 17rem;
7 | background-color: #ededed;
8 | flex-grow: 0;
9 | padding-top: 2rem;
10 | overflow-y: scroll;
11 | }
12 |
13 | .item {
14 | position: relative;
15 | display: flex;
16 | flex-direction: column;
17 | justify-content: flex-start;
18 | align-items: center;
19 | background-color: white;
20 | padding: 0.5rem;
21 | box-shadow: 0 0 12px 6px rgba(0, 0, 0, 0.1);
22 | transition: all 0.2s;
23 | height: 14rem;
24 | width: 14rem;
25 | margin: 0.5rem;
26 | margin: 0.5rem;
27 | /* white-space: nowrap; */
28 | /* overflow: hidden; */
29 | text-overflow: ellipsis;
30 | cursor: pointer;
31 | margin-bottom: 2rem;
32 | }
33 |
34 | .item:hover {
35 | background-color: #eeffee;
36 | transition: all 0.2s;
37 | }
38 |
39 | .item-name {
40 | font-size: 0.9rem;
41 | color: #666;
42 | flex-shrink: 1;
43 | flex-grow: 0;
44 | }
45 |
46 | .item-thumbnail {
47 | min-width: 12rem;
48 | min-height: 8rem;
49 | background-color: #666;
50 | margin-bottom: 0.5rem;
51 | flex-shrink: 0;
52 | }
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
16 | Mymex
17 |
18 |
19 |
20 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/components/SearchWidget.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import DataSourceDialog from './DataSourceDialog';
3 | import './SearchWidget.css'
4 | import { Icon, Button, InputGroup } from "@blueprintjs/core";
5 | import { IconNames } from "@blueprintjs/icons";
6 |
7 | export default function SearchWidget(props) {
8 |
9 | const [isDialogOpen, setIsDialogOpen] = useState(false)
10 |
11 | const handleTextChange = e => {
12 | const string = e.target.value || ""
13 | props.updateSearchString(string)
14 | }
15 |
16 | return (
17 |
18 |
19 |
26 |
27 |
28 |
31 |
setIsDialogOpen(false)}>
32 |
33 | )
34 | }
--------------------------------------------------------------------------------
/src/DataSourceConnector.js:
--------------------------------------------------------------------------------
1 |
2 | class DataSourceConnector {
3 | async configureDataSource() {
4 | throw "All subclasses of DataSourceConnector must implement configureDataSource"
5 | }
6 |
7 | pullLatest() {
8 | throw "All subclasses of DataSourceConnector must implement pullLatest"
9 | }
10 |
11 | getName() {
12 | throw "All subclasses of DataSourceConnector must implement getName"
13 | }
14 |
15 | handleJob(job) {
16 | throw "All subclasses of DataSourceConnector must implement handleJob"
17 | }
18 |
19 | handleMemChanges(addedMems, removedMems) {
20 | throw "All subclasses of DataSourceConnector must implement handleMemChanges"
21 | }
22 |
23 | /**
24 | * Must return an object of the form:
25 | * {name: string, id: string, type: string, customData: object}
26 | * This object will later be used as the sole parameter
27 | * to constructors of implementing subclasses.
28 | */
29 | export() {
30 | throw "All subclasses of DataSourceConnector must implement export"
31 | }
32 |
33 | watch() {
34 | throw "All subclasses of DataSourceConnector must implement watch"
35 | }
36 |
37 | handleQueueEmptiedEvent() {
38 | console.log("queue emptied")
39 | }
40 | }
41 |
42 | export default DataSourceConnector
--------------------------------------------------------------------------------
/src/components/MemNode.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import './MemNode.css'
3 | import MiniAnnotations from './MiniAnnotations'
4 | import { Icon } from "@blueprintjs/core"
5 | import { IconNames } from "@blueprintjs/icons";
6 | import DSConnectorRegistry from "../DSConnectorRegistry";
7 | const path = require('path')
8 |
9 | export default function MemNode(props) {
10 |
11 | const thumbnailPath = "memexdata://" + path.join("thumbnails", props.mem._id) + ".png"
12 |
13 | const handleClick = e => {
14 | props.setAnnotationItem(null)
15 | props.openItemFunc(props.mem, false)
16 | }
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 | {props.mem.name}
26 |
27 |
28 |
29 | {(props.mem.notes || props.mem.tags) &&
30 |
31 | }
32 |
33 | )
34 | }
--------------------------------------------------------------------------------
/public/electron.js:
--------------------------------------------------------------------------------
1 | const electron = require('electron');
2 | const app = electron.app;
3 | const BrowserWindow = electron.BrowserWindow;
4 | const path = require('path');
5 | const isDev = require('electron-is-dev');
6 |
7 | let mainWindow;
8 |
9 | const protocol = electron.protocol
10 |
11 | global.NEDB = require('nedb-promises')
12 |
13 | protocol.registerSchemesAsPrivileged([{scheme: "memexdata", privileges: { standard: true, bypassCSP: true}}])
14 |
15 | function createWindow() {
16 |
17 | protocol.registerFileProtocol('memexdata', (request, callback) => {
18 | const url = request.url.substr(12)
19 | callback({ path: path.join(app.getPath('userData'), url) })
20 | }, (error) => {
21 | if (error) console.error('Failed to register protocol')
22 | })
23 |
24 | // Setting 'nodeIntegration' true is a temporary solution and could be a security issue
25 | mainWindow = new BrowserWindow({ width: 1600, height: 900, webPreferences: { nodeIntegration: true, plugins: true} })
26 |
27 | mainWindow.loadURL(isDev ? 'http://localhost:3000' : `file://${path.join(__dirname, '../build/index.html')}`)
28 | mainWindow.toggleDevTools();
29 | mainWindow.on('closed', () => mainWindow = null);
30 | }
31 |
32 | app.on('ready', createWindow);
33 |
34 | app.on('window-all-closed', () => {
35 | app.quit();
36 | });
37 |
38 | app.on('activate', () => {
39 | if (mainWindow === null) {
40 | createWindow();
41 | }
42 | });
--------------------------------------------------------------------------------
/src/components/AnnotationsPanel.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react"
2 | import './AnnotationsPanel.css'
3 | import { TagInput, EditableText} from "@blueprintjs/core"
4 | import { IconNames } from "@blueprintjs/icons";
5 | import DataStore from '../DataStore'
6 |
7 | export default function AnnotationsPanel(props) {
8 | const [tags, setTags] = useState([])
9 | const [notes, setNotes] = useState("")
10 |
11 | useEffect(() => {
12 | const func = async () => {
13 | const mem = await DataStore.getMem(props.mem._id)
14 | setTags(mem.tags || [])
15 | setNotes(mem.notes || "")
16 | }
17 | func()
18 | }, [props.mem._id])
19 |
20 | const handleTagChange = tags => {
21 | setTags(tags)
22 | DataStore.setMemTags(props.mem._id, tags)
23 | }
24 | const handleNotesTextChange = notes => {
25 | setNotes(notes)
26 | DataStore.setMemNotes(props.mem._id, notes)
27 | }
28 |
29 | return (
30 |
31 |
40 |
45 |
46 | )
47 | }
--------------------------------------------------------------------------------
/src/components/App.css:
--------------------------------------------------------------------------------
1 | @import "~normalize.css";
2 | @import "~@blueprintjs/core/lib/css/blueprint-hi-contrast.css";
3 | @import "~@blueprintjs/icons/lib/css/blueprint-icons.css";
4 |
5 | #root {
6 | position: absolute;
7 | top: 0;
8 | bottom: 0;
9 | }
10 |
11 | body {
12 | background-color: #293742;
13 | }
14 |
15 | .App {
16 | position: relative;
17 | display: flex;
18 | flex-direction: column;
19 | height: 100vh;
20 | width: 100vw;
21 | overflow-x: hidden;
22 | }
23 |
24 | .app-top {
25 | position: relative;
26 | display: flex;
27 | flex-direction: row;
28 | flex-grow: 1;
29 | width: 100vw;
30 | height: 97vh;
31 | }
32 |
33 | .status-bar {
34 | display: flex;
35 | justify-content: flex-start;
36 | align-items: center;
37 | padding: 0.5rem;
38 | background-color: #eee;
39 | border-top: 2px solid #ccc;
40 | color: #666;
41 | width: 100vw;
42 | height: 3vh;
43 | flex-grow: 0;
44 | }
45 |
46 | .download-location-text {
47 | white-space: nowrap;
48 | overflow: hidden;
49 | text-overflow: ellipsis;
50 | }
51 |
52 | .prog-container {
53 | width: 10rem;
54 | margin-right: 2rem;
55 | margin-left: 2rem;
56 | }
57 |
58 | .right-column {
59 | position: relative;
60 | display: flex;
61 | flex-direction: column;
62 | text-align: center;
63 | overflow-y: scroll;
64 | overflow-x: hidden;
65 | flex-grow: 1;
66 | }
67 |
68 | .App-header {
69 | background-color: #ededed;
70 | height: 150px;
71 | color: white;
72 | }
73 |
74 | .no-select {
75 | -webkit-user-select: none;
76 | -moz-user-select: none;
77 | -ms-user-select: none;
78 | }
--------------------------------------------------------------------------------
/src/components/MemNode.css:
--------------------------------------------------------------------------------
1 | .mem-node {
2 | position: relative;
3 | display: flex;
4 | flex-direction: column;
5 | justify-content: space-between;
6 | align-items: center;
7 | background-color: #474756;
8 | padding: 0.75rem;
9 | box-shadow: 0 0 10px 4px rgba(0, 0, 0, 0.2);
10 | transition: all 0.2s;
11 | height: 18rem;
12 | width: 20rem;
13 | margin: 1rem;
14 | /* white-space: nowrap; */
15 | /* overflow: hidden; */
16 | text-overflow: ellipsis;
17 | cursor: pointer;
18 | }
19 |
20 | .mem-node:hover {
21 | background-color: #676776;
22 | transition: all 0.15s;
23 | }
24 |
25 | .mem-text {
26 | display: flex;
27 | justify-content: flex-start;
28 | width: 100%;
29 | font-size: 0.9rem;
30 | flex-shrink: 0;
31 | flex-grow: 0;
32 | height: 3rem;
33 | text-align: left;
34 | margin-bottom: 0.5rem;
35 | overflow: hidden;
36 | }
37 |
38 | .mem-thumbnail {
39 | background-color: #ccc;
40 | margin-bottom: 0.5rem;
41 | box-shadow: 0 0 5px 2px rgba(0, 0, 0, 0.5);
42 | opacity: 0.75;
43 | width: 100%;
44 | height: 100%;
45 | flex-shrink: 2;
46 | background-size: contain;
47 | background-repeat: no-repeat;
48 | background-position: center;
49 | }
50 |
51 | .ds-icon-container {
52 | display: inline-flex;
53 | flex-shrink: 0;
54 | justify-content: center;
55 | align-items: center;
56 | background-color: rgba(255, 255, 255, 0.2);
57 | border-radius: 5px;
58 | border: 1px solid #333;
59 | width: 2rem;
60 | height: 2rem;
61 | margin-right: 0.5rem;
62 | }
--------------------------------------------------------------------------------
/src/DSConnectorRegistry.js:
--------------------------------------------------------------------------------
1 | import ChromeBookmarksDSC from "./ChromeBookmarksDSC"
2 | import LocalDirectoryDSC from "./LocalDirectoryDSC"
3 | import Const from './constants'
4 | import DataStore from './DataStore'
5 | import { IconNames } from "@blueprintjs/icons";
6 |
7 | export default class DSConnectorRegistry {
8 |
9 | static dataSourceConnectors = {}
10 | static connectorClassMap = { [Const.DS_TYPE_CHROME]: ChromeBookmarksDSC, [Const.DS_TYPE_DIRECTORY]: LocalDirectoryDSC }
11 | static connectorIconMap = { [Const.DS_TYPE_CHROME]: IconNames.BOOKMARK, [Const.DS_TYPE_DIRECTORY]: IconNames.FOLDER_OPEN }
12 | static connectorIcons = {}
13 |
14 | static async getDataSourceConnector(dataSourceId) {
15 | let dsConnector = this.dataSourceConnectors[dataSourceId]
16 |
17 | if (dsConnector)
18 | return dsConnector
19 |
20 | const dataSource = await DataStore.getDataSource(dataSourceId)
21 | const connectorClass = this.connectorClassMap[dataSource.type]
22 | dsConnector = new connectorClass(dataSource)
23 | dsConnector.type = dataSource.type
24 | this.connectorIcons[dataSource._id] = this.connectorIconMap[dsConnector.type]
25 | this.dataSourceConnectors[dataSource._id] = dsConnector
26 |
27 | return dsConnector
28 | }
29 |
30 | static getDataSourceIcon(dataSourceId) {
31 | return this.connectorIcons[dataSourceId]
32 | }
33 |
34 | static getAllDataSourceConnectors() {
35 | const keys = Object.keys(this.dataSourceConnectors)
36 | return keys.map(key => this.dataSourceConnectors[key])
37 | }
38 | }
--------------------------------------------------------------------------------
/src/DataSourceWorkPump.js:
--------------------------------------------------------------------------------
1 | import DataStore from './DataStore'
2 | import DSConnectorRegistry from './DSConnectorRegistry'
3 |
4 | export default class DataSourceWorkPump {
5 | dataSources = {}
6 | jobChangeSubscribers = []
7 | paused = false
8 |
9 | init(dataSourcesArray = []) {
10 | dataSourcesArray.forEach(dataSource => {
11 | this.dataSources[dataSource._id] = dataSource
12 | })
13 |
14 | setInterval(() => {
15 | if (!this.working && !this.paused) {
16 | this.working = true
17 |
18 | this.doQueuedWork().then(result => {
19 | if (result.didSomeWork && result.jobCount === 0) {
20 | DataStore.getAllDataSourceConnectors(dsConnector => dsConnector.handleQueueEmptiedEvent())
21 | }
22 | this.working = false
23 | })
24 | }
25 | }, 3000)
26 |
27 | this.pause()
28 | }
29 |
30 | async doQueuedWork() {
31 | let didSomeWork = false
32 | let activeJob = await DataStore.nextJob()
33 | let jobCount = await DataStore.jobCount()
34 |
35 | while (activeJob && !this.paused) {
36 | didSomeWork = true
37 | this.jobChangeSubscribers.forEach(handler => handler(activeJob, jobCount))
38 |
39 | const dsConnector = await DSConnectorRegistry.getDataSourceConnector(activeJob.dataSourceId)
40 | await dsConnector.handleJob(activeJob)
41 |
42 | await DataStore.removeJob(activeJob._id)
43 | activeJob = await DataStore.nextJob()
44 | jobCount = await DataStore.jobCount()
45 | }
46 |
47 | return { didSomeWork, jobCount}
48 | }
49 |
50 | pause() {
51 | this.paused = true
52 | }
53 |
54 | resume() {
55 | this.paused = false
56 | }
57 |
58 | subscribeToJobChangeEvents(handler) {
59 | this.jobChangeSubscribers.push(handler)
60 | }
61 | }
--------------------------------------------------------------------------------
/notes.txt:
--------------------------------------------------------------------------------
1 | - Show full trees for each data source when there is no search text
2 | - "New Items" button/count indicator/dialog
3 | List of new mems for each data source (e.g. if a new item was added to their bookmark's file)
4 | Quick UI for adding notes/tags to items from here
5 | Button to "mark all as seen"
6 |
7 | - Clean up and document code
8 | -document DataStore/DataSourceWorkPump/DataSourceConnector etc.
9 | -fix warnings
10 |
11 | - Finish LocalDirectoryDSC
12 | - Be able to actually display local files
13 | - First, if it's local, you need to prepend "file://" to the url in ContentViewer
14 | - Second, this isn't going to work unless you set 'webSecurity' to false...
15 | - Think about sync workflow: maybe just do it every 10 seconds
16 | - Show pop-up when new items are discovered during sync
17 | - Make it optional to actually delete items removed from data source (e.g. if I delete a bookmark in Chrome, I should be able to keep it in this app [perhaps it should become associated with faux/native/default "data source" at that point])
18 | - Get "download fresh version" to work
19 | - Begin TypeScript re-write
20 |
21 | Ensure database contents will be safely preserved:
22 | - Allow specifying a custom location (so it could e.g. be backed up by Dropbox)
23 | - Think about handling migrations
24 |
25 |
26 | Later:
27 |
28 | - Look into testing with Jest
29 |
30 | - Look into Storybook
31 |
32 | - Handle iframe page not loading errors
33 |
34 | - Need to either do some kind of filesystem watch for changes, or periodic re-scans or data sources to keep synced.
35 |
36 | - Highlight matched elements in search results, e.g. highlight the tags or title substrings that matched the search text.
37 |
38 |
39 | - Search results pagination
40 |
41 | For watching file system changes use https://nodejs.org/api/fs.html#fs_fs_watch_filename_options_listener
42 |
43 | For initial recursive traversal use: https://www.npmjs.com/package/walkdir
44 |
45 |
46 | Ideas:
47 | - custom scrollbar component, showing a visual overview of results grouped into categories
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mymex
2 |
3 | Mymex[0] is an application designed for quick retreival of information from a variety of (web/local) data sources. One of its main goals is to make pieces of information located on the web easier and more reliable to return to. It does this by:
4 |
5 | - automatically storing local renderings of web resources
6 | - allowing tags and notes to be attached to web resources
7 |
8 | The first feature means you won't lose access to the resource if it becomes unavailable online. The second provides a more powerful/flexible way of searching for resources later (via tags), and remembering why you wanted the resource saved in the first place (via notes).
9 |
10 | Another goal of Mymex is to "unify" web / local information resources. It does this by working with a configurable set of "data sources," some of which may point to web resources, others of which point to local resources—the distinction is erased in-app. For web resources, you could (for example) point it to a Chrome bookmarks file and it would import all the URLs etc. For local resources you can point it to a directory, and all of its recursive contents will become accessible. In both cases Mymex continues watching the "data source" for changes (i.e. new items being added, old being removed).
11 |
12 | New types of "data sources" may be incorporated by writing custom DataSourceConnectors.
13 |
14 | This is my first solo React project: part of why I'm building it is to learn React more deeply.
15 |
16 | [0] The name is still under construction. The reference in the name is to the "proto-hypertext system" Memex
17 |
18 | ## Tech
19 |
20 | Mymex is being written using JS, React, Electron, webpack, BlueprintJS, and NeDB.
21 |
22 | ## To run
23 |
24 | You will need:
25 | - A version of node >= 10.x
26 | - Yarn (NPM may work fine, but I haven't tested it)
27 |
28 | After cloning the repo:
29 |
30 | ```
31 | yarn install
32 | yarn start
33 | ```
34 | ## Screenshots
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build/
2 |
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 | lerna-debug.log*
10 |
11 | # Diagnostic reports (https://nodejs.org/api/report.html)
12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
13 |
14 | # Runtime data
15 | pids
16 | *.pid
17 | *.seed
18 | *.pid.lock
19 |
20 | # Directory for instrumented libs generated by jscoverage/JSCover
21 | lib-cov
22 |
23 | # Coverage directory used by tools like istanbul
24 | coverage
25 | *.lcov
26 |
27 | # nyc test coverage
28 | .nyc_output
29 |
30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
31 | .grunt
32 |
33 | # Bower dependency directory (https://bower.io/)
34 | bower_components
35 |
36 | # node-waf configuration
37 | .lock-wscript
38 |
39 | # Compiled binary addons (https://nodejs.org/api/addons.html)
40 | build/Release
41 |
42 | # Dependency directories
43 | node_modules/
44 | jspm_packages/
45 |
46 | # TypeScript v1 declaration files
47 | typings/
48 |
49 | # TypeScript cache
50 | *.tsbuildinfo
51 |
52 | # Optional npm cache directory
53 | .npm
54 |
55 | # Optional eslint cache
56 | .eslintcache
57 |
58 | # Microbundle cache
59 | .rpt2_cache/
60 | .rts2_cache_cjs/
61 | .rts2_cache_es/
62 | .rts2_cache_umd/
63 |
64 | # Optional REPL history
65 | .node_repl_history
66 |
67 | # Output of 'npm pack'
68 | *.tgz
69 |
70 | # Yarn Integrity file
71 | .yarn-integrity
72 |
73 | # dotenv environment variables file
74 | .env
75 | .env.test
76 |
77 | # parcel-bundler cache (https://parceljs.org/)
78 | .cache
79 |
80 | # Next.js build output
81 | .next
82 |
83 | # Nuxt.js build / generate output
84 | .nuxt
85 | dist
86 |
87 | # Gatsby files
88 | .cache/
89 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
90 | # https://nextjs.org/blog/next-9-1#public-directory-support
91 | # public
92 |
93 | # vuepress build output
94 | .vuepress/dist
95 |
96 | # Serverless directories
97 | .serverless/
98 |
99 | # FuseBox cache
100 | .fusebox/
101 |
102 | # DynamoDB Local files
103 | .dynamodb/
104 |
105 | # TernJS port file
106 | .tern-port
107 |
--------------------------------------------------------------------------------
/src/components/SearchResults.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import './SearchResults.css';
3 | import FolderNode from './FolderNode'
4 | import MemNode from './MemNode'
5 |
6 | export default function SearchResults(props) {
7 |
8 | const sections = Object.keys(props.sections).
9 | map(key => props.sections[key]).
10 | filter(section => typeof section === "object")
11 |
12 | return (
13 |
14 | {(props.visible) &&
15 |
16 |
17 | {sections.filter(section => section.folders.length > 0 || section.mems.length > 0).map(section => (
18 |
19 |
20 | {section.title}
21 |
22 |
23 |
24 | {section.folders.map(folder => (
25 |
32 | ))}
33 |
34 |
35 |
36 | {section.mems.map(child => (
37 |
44 | ))}
45 |
46 |
47 | ))}
48 |
49 | }
50 |
51 | )
52 | }
--------------------------------------------------------------------------------
/src/Util.js:
--------------------------------------------------------------------------------
1 | import md5 from 'md5'
2 |
3 | class Util {
4 |
5 | static walkDepthFirst(node, func, parent = null) {
6 | const stop = func(node, parent)
7 |
8 | if (stop) {
9 | return
10 | } else {
11 | (node.children || []).forEach(child => {
12 | Util.walkDepthFirst(child, func, node)
13 | });
14 | }
15 | }
16 |
17 | static nodeToPath(node, startOmitCount, endOmitCount) {
18 | const parts = [node.name]
19 |
20 | let curNode = node.parent
21 | while(curNode) {
22 | parts.push(curNode.name)
23 | curNode = curNode.parent
24 | }
25 |
26 | const rootPart = parts[parts.length-1]
27 | parts.length -= startOmitCount
28 | parts.reverse()
29 | parts.length = Math.max(0, parts.length - endOmitCount)
30 |
31 | if (parts.length === 0)
32 | parts.push(rootPart)
33 |
34 | const path = parts.join("/") + "/"
35 |
36 |
37 | return path
38 | }
39 |
40 | static lineageLength(node) {
41 | let length = 1
42 | let curNode = node.parent
43 | while (curNode) {
44 | length++
45 | curNode = curNode.parent
46 | }
47 |
48 | console.log("lineage length", length)
49 |
50 | return length
51 | }
52 |
53 | static isLeaf(node) {
54 | return !node.children || node.children.length === 0
55 | }
56 |
57 | static setTagNames(names) {
58 | Util.tagNames = names
59 | }
60 |
61 | static getRandomTagName() {
62 | return Util.tagNames[Math.floor((Util.tagNames.length-1)*Math.random())]
63 | }
64 |
65 | static idFromPath(path) {
66 | return md5(path.toLowerCase())
67 | }
68 |
69 | static uniq(array, propName) {
70 | const seen = {}
71 | return array.filter(element => seen.hasOwnProperty(element[propName]) ? false : (seen[element[propName]] = true))
72 | }
73 |
74 | /**
75 | * Returns the elements in array1 which are not in array2
76 | */
77 | static arrayDifference(array1, array2, propName) {
78 | const ar2Set = new Set(array2.map(element => element[propName]))
79 | return array1.filter(element => !ar2Set.has(element[propName]))
80 | }
81 | }
82 |
83 | export default Util
--------------------------------------------------------------------------------
/src/LocalDirectoryDSC.js:
--------------------------------------------------------------------------------
1 | import DataSourceConnector from './DataSourceConnector'
2 |
3 | import Const from './constants'
4 | import DataStore from './DataStore'
5 | import Util from './Util'
6 | const electron = window.require('electron').remote
7 | const dialog = electron.dialog
8 | const pathLib = electron.require('path')
9 | const fs = electron.require('fs')
10 | const walk = electron.require('walkdir')
11 |
12 | class LocalDirectoryDSC extends DataSourceConnector {
13 |
14 | name
15 | _id
16 | path
17 |
18 | constructor(config) {
19 | super()
20 |
21 | if (config) {
22 | this.name = config.name
23 | this._id = config._id
24 | this.path = config.customData.path
25 | }
26 | }
27 |
28 | async configureDataSource() {
29 | const defaultPath = electron.app.getPath('home')
30 | const result = await dialog.showOpenDialog({ title: "Select Directory", defaultPath, properties: ['openDirectory'] })
31 | const selectedPath = result.filePaths[0]
32 |
33 | if (!result.canceled) {
34 | this.name = "Local File System DS"
35 | this._id = Util.idFromPath(selectedPath)
36 | this.path = selectedPath
37 |
38 | return true
39 | } else {
40 |
41 | return false
42 | }
43 | }
44 |
45 | watch() {
46 |
47 | }
48 |
49 | async pullLatest() {
50 | return walk.async(this.path, { return_object: true }).then((result, error) => {
51 |
52 | return Object.keys(result)
53 |
54 | }).then(paths => {
55 | const memNodes = paths.map(path => {
56 | const obj = pathLib.parse(path)
57 | const memNode = { _id: Util.idFromPath(path), name: obj.name, isLeaf: true, location: path, dataSourceId: this._id }
58 |
59 | return memNode
60 | })
61 |
62 | return Util.uniq(memNodes, "_id")
63 | })
64 | }
65 |
66 | getName() {
67 | throw "All subclasses of DataSourceConnector must implement getName"
68 | }
69 |
70 | handleJob(job) {
71 | throw "All subclasses of DataSourceConnector must implement handleJob"
72 | }
73 |
74 | async handleMemChanges(addedMems, removedMems) {
75 |
76 | const jobs = addedMems.filter(mem => mem.isLeaf).map(node => ({ _id: node._id, dataSourceId: this._id, priority: 10, customData: { location: node.location } }))
77 | await DataStore.addJobs(jobs)
78 | }
79 |
80 | export() {
81 | return { name: this.name, _id: this._id, type: Const.DS_TYPE_DIRECTORY, customData: { path: this.path } }
82 | }
83 | }
84 |
85 | export default LocalDirectoryDSC
--------------------------------------------------------------------------------
/src/components/FolderNode.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import './FolderNode.css'
3 | import { Icon } from "@blueprintjs/core";
4 | import { IconNames } from "@blueprintjs/icons";
5 | import DataStore from '../DataStore'
6 | import MemNode from './MemNode'
7 |
8 | export default function FolderNode(props) {
9 |
10 | const [folderChildren, setFolderChildren] = useState([])
11 | const [memChildren, setMemChildren] = useState([])
12 | const [collapsed, setCollapsed] = useState(true)
13 |
14 | const handleClick = async e => {
15 | e.stopPropagation()
16 |
17 | if (collapsed) {
18 | const children = await DataStore.getMems(props.node.children)
19 | const memChildren = []
20 | const folderChildren = []
21 |
22 | children.forEach(child => {
23 | child.isLeaf ? memChildren.push(child) : folderChildren.push(child)
24 | })
25 |
26 | setMemChildren(memChildren)
27 | setFolderChildren(folderChildren)
28 | } else {
29 |
30 | setMemChildren([])
31 | setFolderChildren([])
32 | }
33 |
34 | setCollapsed(!collapsed)
35 | }
36 |
37 | const nodeIcon = collapsed ? :
38 |
39 | return (
40 |
41 |
42 |
43 | {nodeIcon}
44 |
{props.node.name}
45 |
46 |
47 |
48 |
49 | {folderChildren.map(folder => (
50 |
57 | ))}
58 |
59 |
60 |
61 | {memChildren.map(folder => (
62 |
69 | ))}
70 |
71 |
72 | )
73 | }
--------------------------------------------------------------------------------
/src/components/MiniAnnotations.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from "react"
2 | import './MiniAnnotations.css'
3 | import { Icon, OverflowList, Classes, Tag, Boundary } from "@blueprintjs/core"
4 | import { IconNames } from "@blueprintjs/icons"
5 | import { useSpring, animated } from 'react-spring'
6 |
7 | export default function MiniAnnotations(props) {
8 | const [hasMouse, setHasMouse] = useState(false)
9 | const [expanded, setExpanded] = useState(false)
10 | const [flexDirection, setFlexDirection] = useState("row")
11 |
12 | const panelProps = useSpring({
13 | height: hasMouse ? "100%" : "3rem",
14 | backgroundColor: hasMouse ? "rgba(220, 220, 255, 0.3)" : "rgba(255, 255, 255, 0.2)",
15 | onRest: () => { setExpanded(hasMouse); setFlexDirection(hasMouse ? "column" : "row")},
16 | config: {duration: 200}
17 | })
18 | const notes = props.mem.notes
19 | const tags = props.mem.tags || []
20 |
21 | const notesElements = expanded
22 | ?
23 | :
24 | const tagsElements = !expanded
25 | ? {
29 | return
30 | }}
31 | visibleItemRenderer={(item, index) => {
32 | return {item}
33 | }}
34 | collapseFrom={Boundary.END}
35 | />
36 | :
37 |
38 | {tags.map(tag => ({tag}))}
39 |
40 |
41 | const handleClick = e => {
42 | e.stopPropagation()
43 | props.setAnnotationItem(props.mem)
44 | }
45 |
46 | return (
47 | setHasMouse(true)}
51 | onMouseLeave={e => { setHasMouse(false); setFlexDirection("row"); setExpanded(false)}}
52 | onClick={handleClick}>
53 |
54 | {notes &&
55 | notesElements
56 | }
57 | {tags.length > 0 &&
58 | tagsElements
59 | }
60 |
61 | )
62 | }
--------------------------------------------------------------------------------
/src/components/DataSourceDialog.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import DataStore from "../DataStore"
3 | import { Icon, Button, Dialog, Classes, AnchorButton, NonIdealState, Divider, ControlGroup, RadioGroup, Radio } from "@blueprintjs/core";
4 | import { IconNames } from "@blueprintjs/icons";
5 | import './DataSourceDialog.css'
6 | import Const from '../constants'
7 | import ChromeBookmarksDSC from "../ChromeBookmarksDSC"
8 | import LocalDirectoryDSC from "../LocalDirectoryDSC"
9 |
10 | export default function DataSourceDialog(props) {
11 | const [selectedSourceType, setSelectedSourceType] = useState(Const.DS_TYPE_CHROME)
12 | const [dataSources, setDataSources] = useState([])
13 |
14 | useEffect(() => {
15 | DataStore.getAllDataSources(dataSources => {
16 | setDataSources(dataSources)
17 | })
18 | }, [])
19 |
20 | const handleSourceTypeChange = event => setSelectedSourceType(event.target.value)
21 |
22 | const handleAddSourceButton = async () => {
23 | // const connectorClass = connectorClassMap[selectedSourceType]
24 | // const dataSourceConnector = new connectorClass()
25 | // await dataSourceConnector.configureDataSource()
26 | // DataStore.addDataSource(dataSourceConnector.serialize(), dataSource => {
27 | // console.log("new data source", dataSource)
28 | // setDataSources(dataSources.concat(dataSource))
29 | // })
30 |
31 | if (selectedSourceType === Const.DS_TYPE_CHROME) {
32 | const chromeBookmarksDSC = new ChromeBookmarksDSC()
33 | const success = await chromeBookmarksDSC.configureDataSource()
34 | if (success) {
35 | DataStore.addDataSource(chromeBookmarksDSC.export(), dataSource => {
36 | console.log("dataSources", dataSource)
37 | setDataSources(dataSources.concat(dataSource))
38 | })
39 | }
40 | } else if (selectedSourceType === Const.DS_TYPE_DIRECTORY) {
41 | const localDirectoryDSC = new LocalDirectoryDSC()
42 | const success = await localDirectoryDSC.configureDataSource()
43 | if (success) {
44 | DataStore.addDataSource(localDirectoryDSC.export(), dataSource => {
45 | console.log("dataSources", dataSource)
46 | setDataSources(dataSources.concat(dataSource))
47 | })
48 | }
49 | }
50 | }
51 |
52 | let mainSection
53 |
54 | if (dataSources.length === 0) {
55 | mainSection =
60 | } else {
61 | mainSection =
62 |
63 | {dataSources.map(ds =>
{ds.name}:{ds.customData.bookmarksLocation}
)}
64 |
65 | }
66 |
67 | return (
68 |
97 | )
98 | }
--------------------------------------------------------------------------------
/src/components/ContentViewer.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import './ContentViewer.css'
3 | import AnnotationsPanel from './AnnotationsPanel'
4 | import { Icon, Button, Tooltip, Position, NonIdealState } from "@blueprintjs/core";
5 | import { IconNames } from "@blueprintjs/icons";
6 | const path = require('path')
7 | const electron = window.require('electron').remote
8 | const fs = electron.require('fs')
9 |
10 | export default function ContentViewer(props) {
11 |
12 | const [viewingLocal, setViewingLocal] = useState(true)
13 | const [showingAnnotations, setshowingAnnotations] = useState(false)
14 |
15 | const handleGoBack = () => {
16 | props.setAnnotationItem(null)
17 | props.goBackFunc()
18 | }
19 |
20 | const handleToggleAnnotations = () => {
21 | if (!showingAnnotations) {
22 | props.setAnnotationItem(props.content)
23 | } else {
24 | props.setAnnotationItem(null)
25 | }
26 |
27 | setshowingAnnotations(!showingAnnotations)
28 | }
29 | const handleSwitchVersion = () => setViewingLocal(!viewingLocal)
30 |
31 | const localPath = "memexdata://" + path.join("local-mems", props.content._id) + ".pdf"
32 | const remotePath = props.content.location
33 | const localPathAbsolute = path.join(electron.app.getPath("userData"), "local-mems", props.content._id + ".pdf")
34 | const localExists = fs.existsSync(localPathAbsolute)
35 | const url = viewingLocal ? localPath : remotePath
36 |
37 | const switchButtonMessage = `Switch to ${viewingLocal ? "live" : "local"} version`
38 | const versionIcon = viewingLocal ? IconNames.DATABASE : IconNames.GLOBE_NETWORK
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | Viewing {viewingLocal ? " local " : " live "}: {props.content.name}
67 |
68 |
69 |
70 |
71 |
72 | {(localExists || !viewingLocal) &&
73 |
74 | }
75 |
76 | {(!localExists && viewingLocal) &&
77 |
83 | }
84 |
85 |
86 | )
87 | }
--------------------------------------------------------------------------------
/src/DataStore.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import DSConnectorRegistry from './DSConnectorRegistry'
3 | import Util from './Util'
4 | const electron = window.require('electron').remote
5 | const NEDB = electron.getGlobal('NEDB')
6 |
7 | class DataStore {
8 | static memDB
9 | static dataSourceDB
10 | static workDB
11 | static activeJob = null
12 |
13 | static init() {
14 |
15 | // Note: This is using IndexedDB, not a local file: https://github.com/louischatriot/nedb/issues/531
16 | const userDataDir = electron.app.getPath("userData")
17 |
18 | this.memDB = NEDB.create({ filename: path.join(userDataDir, '/db/mems'), autoload: true })
19 | this.dataSourceDB = NEDB.create({ filename: path.join(userDataDir, '/db/dataSources'), autoload: true })
20 | this.workDB = NEDB.create({ filename: path.join(userDataDir, '/db/work'), autoload: true})
21 |
22 | // Clear data bases
23 | // this.dataSourceDB.remove({}, { multi: true })
24 | // this.memDB.remove({}, { multi: true })
25 | // this.workDB.remove({}, { multi: true })
26 |
27 | this.refreshAllDataSources()
28 | }
29 |
30 | static async refreshAllDataSources() {
31 | this.dataSourceDB.find({}).then(dataSources => {
32 | dataSources.forEach(dataSource => {
33 | this.refreshDataSource(dataSource)
34 | })
35 | })
36 | }
37 |
38 | static async refreshDataSource(dataSource) {
39 | const dsConnector = await DSConnectorRegistry.getDataSourceConnector(dataSource._id)
40 | const mems = await dsConnector.pullLatest()
41 |
42 | const changes = await this.findMemChanges(mems, dataSource._id)
43 |
44 | this.memDB.insert(changes.memsToAdd)
45 | this.memDB.remove(changes.memsToRemove)
46 |
47 | dsConnector.handleMemChanges(changes.memsToAdd, changes.memsToRemove)
48 | }
49 |
50 | static async findMemChanges(memNodes, dataSourceId) {
51 | const oldMems = await this.memDB.find({ dataSourceId })
52 |
53 | const memsToAdd = Util.arrayDifference(memNodes, oldMems, "_id")
54 | const memsToRemove = Util.arrayDifference(oldMems, memNodes, "_id")
55 |
56 | return { memsToAdd, memsToRemove }
57 | }
58 |
59 | static addDataSource(dataSource, func) {
60 | this.dataSourceDB.insert(dataSource).then(newDataSource => {
61 | func(newDataSource)
62 | this.refreshDataSource(newDataSource)
63 | }, error => console.error(error))
64 | }
65 |
66 | static getAllDataSources(func) {
67 | this.dataSourceDB.find({}).then(dataSources => {
68 | func(dataSources)
69 | }, error => console.error(error))
70 | }
71 |
72 | static getDataSource(id) {
73 | return this.dataSourceDB.findOne({_id: id})
74 | }
75 |
76 | static getMemNodesMatching(str) {
77 | const pattern = new RegExp(str, 'i')
78 | const query = { $or: [{ name: pattern },
79 | { tags: { $elemMatch: pattern}},
80 | { notes: pattern},
81 | { url: pattern}] }
82 | return this.memDB.find(query).sort({parent: 1})
83 | }
84 |
85 | static getMem(id) {
86 | return this.memDB.findOne({ _id: id})
87 | }
88 |
89 | static getMems(idArray) {
90 | return this.memDB.find({ _id: { $in: idArray } } )
91 | }
92 |
93 | static setMemNotes(id, notes) {
94 | this.memDB.update({_id: id}, { $set: { notes }})
95 | }
96 |
97 | static setMemTags(id, tags) {
98 | this.memDB.update({ _id: id }, { $set: { tags } })
99 | }
100 |
101 | static getMemNotes(id) {
102 | return this.memDB.find({_id: id})
103 | }
104 |
105 | static getMemTags(id) {
106 |
107 | }
108 |
109 | static async getNodeDepth(node) {
110 | let parentId = node.parent
111 | let parentObj
112 | let depth = 0
113 |
114 | while (parentId) {
115 | depth++
116 | parentObj = await this.getMem(parentId)
117 | parentId = parentObj.parent
118 | }
119 |
120 | return depth
121 | }
122 |
123 | static nextJob() {
124 | return this.workDB.findOne({})
125 | }
126 |
127 | static jobCount() {
128 | return this.workDB.count({})
129 | }
130 |
131 | static removeJob(id) {
132 | this.workDB.remove({ _id: id })
133 | }
134 |
135 | static addJobs(jobs) {
136 | this.workDB.insert(jobs)
137 | }
138 | }
139 |
140 | export default DataStore
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import './App.css'
3 | import SearchWidget from './SearchWidget'
4 | import SearchResults from './SearchResults'
5 | import ContentViewer from './ContentViewer'
6 | import OpenTray from './OpenTray'
7 | import DataStore from '../DataStore'
8 | import DataSourceWorkPump from '../DataSourceWorkPump'
9 | import { ProgressBar } from "@blueprintjs/core"
10 | import AnnotationsPanel from './AnnotationsPanel'
11 | import {useTransition, animated} from 'react-spring'
12 |
13 | let initialized = false
14 | let searchPromiseCount = 0
15 | const dsWorkPump = new DataSourceWorkPump()
16 |
17 | export default function App(props) {
18 |
19 | const [tabs, setTabs] = useState([])
20 | const [activeItem, setActiveItem] = useState(null)
21 | const [annotationItem, setAnnotationItem] = useState(null)
22 | const [resultSections, setResultSections] = useState({})
23 | const [activeJob, setActiveJob] = useState(null)
24 | const [jobCount, setJobCount] = useState(0)
25 |
26 | const annotationsPanelTransitions = useTransition(annotationItem, null, {
27 | from: {opacity: 0},
28 | enter: {opacity: 1},
29 | leave: {opacity: 0}
30 | })
31 | const AnimatedAnnotationsPanel = animated(AnnotationsPanel)
32 |
33 | if (!initialized) {
34 | initialized = true
35 | DataStore.init()
36 | DataStore.getAllDataSources(dataSources => {
37 | dsWorkPump.init(dataSources)
38 | })
39 | dsWorkPump.subscribeToJobChangeEvents((job, count) => {
40 | setActiveJob(job)
41 | setJobCount(count)
42 | })
43 | }
44 |
45 | const clearActiveItem = () => {
46 | setActiveItem(null)
47 | }
48 |
49 | const updateSearchString = (string) => {
50 | clearActiveItem()
51 |
52 | createResultSections(string).then(sections => {
53 | if (sections.promiseId === searchPromiseCount) {
54 | if (string !== "") {
55 | setResultSections(sections)
56 | } else {
57 | setResultSections([])
58 | }
59 | }
60 | })
61 | }
62 |
63 | const openItem = (item, tabOnly) => {
64 | setActiveItem( tabOnly ? activeItem : item )
65 | setTabs(tabs.concat(item))
66 | }
67 |
68 | return (
69 |
70 |
71 | {/*
*/}
72 |
73 |
74 |
75 |
76 | {activeItem &&
77 |
78 | }
79 |
80 | {!activeItem &&
81 |
82 | }
83 |
84 |
85 | {activeJob &&
86 |
87 |
({jobCount} remaining)
88 |
91 |
Downloading local copy: {activeJob.customData.location}
92 |
93 | }
94 |
95 | {annotationsPanelTransitions.map(({ item, key, props }) => (
96 | item &&
97 | ))}
98 |
99 | )
100 | }
101 |
102 | const createResultSections = async (string) => {
103 | const results = await DataStore.getMemNodesMatching(string)
104 | const rootTitle = results.length === 0 ? "no results" : ""
105 |
106 | const sections = { promiseId: ++searchPromiseCount }
107 | const rootSection = getSection("root", rootTitle, sections)
108 | const parentCache = {}
109 |
110 | for (const mem of results) {
111 |
112 | if (mem.parent) {
113 | const parent = await getParent(mem, parentCache)
114 | const sectionTitle = (parent.path || parent.name)
115 | const section = getSection(mem.parent, sectionTitle, sections)
116 |
117 | if (mem.isLeaf)
118 | section.mems.push(mem)
119 | else
120 | section.folders.push(mem)
121 | } else {
122 | if (mem.isLeaf) {
123 | rootSection.mems.push(mem)
124 | } else {
125 | rootSection.folders.push(mem)
126 | }
127 | }
128 | }
129 |
130 | return sections
131 | }
132 |
133 | const getParent = async (mem, cache) => {
134 | const parent = cache[mem._id] || await DataStore.getMem(mem.parent)
135 | cache[mem._id] = parent
136 |
137 | return parent
138 | }
139 |
140 | const getSection = (id, title, sections) => {
141 | const section = sections[id] || { id, title, mems: [], folders: [] }
142 | sections[id] = section
143 |
144 | return section
145 | }
--------------------------------------------------------------------------------
/src/ChromeBookmarksDSC.js:
--------------------------------------------------------------------------------
1 | import DataSourceConnector from './DataSourceConnector'
2 | import Const from './constants'
3 | import DataStore from './DataStore'
4 | import Util from './Util'
5 |
6 | const electron = window.require('electron').remote
7 | const dialog = electron.dialog
8 | const path = require('path')
9 | const fs = electron.require('fs')
10 | const BrowserWindow = electron.BrowserWindow
11 |
12 | class ChromeBookmarksDSC extends DataSourceConnector {
13 | name
14 | _id
15 | bookmarksLocation
16 | browserWindow
17 |
18 | constructor(config) {
19 | super()
20 |
21 | if (config) {
22 | this.name = config.name
23 | this._id = config._id
24 | this.bookmarksLocation = config.customData.bookmarksLocation
25 | }
26 | }
27 |
28 | async configureDataSource() {
29 | const defaultPath = path.join(electron.app.getPath('home'), "/Library/Application Support/Google/Chrome/Default")
30 | const result = await dialog.showOpenDialog({ title: "Select Chrome bookmarks file", defaultPath, properties: ['openFile'] })
31 | const selectedPath = result.filePaths[0]
32 |
33 | if (!result.canceled) {
34 | this.name = "Chrome Bookmarks DS"
35 | this._id = Util.idFromPath(selectedPath)
36 | this.bookmarksLocation = selectedPath
37 |
38 | return true
39 | } else {
40 |
41 | return false
42 | }
43 | }
44 |
45 | async pullLatest() {
46 | return await this.importChromeBookmarkData()
47 | }
48 |
49 | getName() {
50 | return this.name
51 | }
52 |
53 | async handleJob(job) {
54 | await this.copyWebResource(job)
55 | }
56 |
57 | handleQueueEmptiedEvent() {
58 | if (this.browserWindow) {
59 | this.browserWindow.destroy()
60 | this.browserWindow = null
61 | }
62 | }
63 |
64 | async handleMemChanges(addedMems, removedMems) {
65 | /**
66 | * Job object format:
67 | * {_id: string, dataSourceId: string, priority: number, customData: object}
68 | */
69 |
70 | // Make a job for each leaf not to capture the web resource it points to
71 | // as a screenshot and PDF pair
72 | const jobs = addedMems.filter(mem => mem.isLeaf).map(node => ({ _id: node._id, dataSourceId: this._id, priority: 10, customData: { location: node.location } }))
73 | await DataStore.addJobs(jobs)
74 | }
75 |
76 | export() {
77 | return {name: this.name, _id: this._id, type: Const.DS_TYPE_CHROME, customData: {bookmarksLocation: this.bookmarksLocation}}
78 | }
79 |
80 | watch() {
81 |
82 | }
83 |
84 | importChromeBookmarkData() {
85 | return fs.promises.readFile(this.bookmarksLocation, { encoding: "utf8" }).then(data => {
86 | const bookmarksJSON = JSON.parse(data)
87 | const memNodes = []
88 |
89 | this.processChromeBMNode(bookmarksJSON.roots.bookmark_bar, memNodes)
90 |
91 | return Util.uniq(memNodes, "_id")
92 | }, error => console.error(error))
93 | }
94 |
95 | processChromeBMNode(bmNode, memNodes) {
96 | const memNode = { name: bmNode.name, chrome_guid: bmNode.guid, dataSourceId: this._id }
97 |
98 | if (bmNode.type === "folder") {
99 | memNode._id = bmNode.guid
100 | memNode.isLeaf = false
101 | memNode.children = []
102 | memNodes.push(memNode)
103 | if (bmNode.children) {
104 | bmNode.children.forEach(child => {
105 | const childMemNode = this.processChromeBMNode(child, memNodes)
106 | memNode.children.push(childMemNode._id)
107 | childMemNode.parent = memNode._id
108 | })
109 | }
110 | } else if (bmNode.type === "url") {
111 | memNode._id = Util.idFromPath(bmNode.url)
112 | memNode.location = bmNode.url
113 | memNode.isLeaf = true
114 | memNodes.push(memNode)
115 | }
116 |
117 | return memNode
118 | }
119 |
120 | async copyWebResource(job) {
121 | // This is a way of avoiding downloading PDFs since they
122 | // bring up a download prompt if the PDF plugin isn't working
123 | // correctly. In most Electron versions it does not work correctly.
124 | // However, as long as it's viable to use a beta version where it
125 | // is working correctly it should be disabled.
126 | // if (job.customData.location.endsWith(".pdf")) {
127 | // return new Promise((resolve, reject) => resolve())
128 | // }
129 |
130 | // It's probably best not to reconstruct this for each page we load, however
131 | // re-constructing the BrowserWindow like this is the only way I've been able
132 | // to find that avoids an issue with webContents.printToPDF(...), where it stalls after
133 | // the first capture. There are known bugs with this Electron feature and its behavior varies across
134 | // Electron versions. Until the feature is more reliable, it seems like this reconstructing like
135 | // this will be necessary.
136 | if (!this.browserWindow || true) {
137 | this.browserWindow = new BrowserWindow({
138 | show: false, webPreferences: {
139 | plugins: true
140 | }
141 | })
142 | }
143 |
144 | this.browserWindow.webContents.loadURL(job.customData.location)
145 | this.browserWindow.webContents.audioMuted = true
146 |
147 | return new Promise((resolve, reject) => {
148 | const errorEvents = ['crashed', 'unresponsive', 'plugin-crashed', 'did-fail-load']
149 | const finish = () => {
150 | this.browserWindow.webContents.removeAllListeners('did-finish-load')
151 | errorEvents.forEach(event => this.browserWindow.webContents.removeAllListeners(event))
152 | resolve()
153 | }
154 |
155 | const timeoutCode = setTimeout(() => {
156 | console.log("web resource copy job timed out: ", job.customData.location)
157 | finish()
158 | }, 40000)
159 |
160 | const finishedLoadingHandler = () => {
161 | this.browserWindow.webContents.capturePage().then(screenshot => {
162 | const pngScreenshot = screenshot.toPNG()
163 | const thumbPath = path.join(electron.app.getPath("userData"), "/thumbnails")
164 | fs.promises.mkdir(thumbPath, { recursive: true }).catch(console.error).then(() => {
165 | fs.writeFileSync(path.join(thumbPath, job._id + ".png"), pngScreenshot)
166 |
167 | const pdfPath = path.join(electron.app.getPath("userData"), "/local-mems")
168 | fs.promises.mkdir(pdfPath, { recursive: true }).catch(console.error).then(() => {
169 | this.browserWindow.webContents.printToPDF({}).then(pdfData => {
170 | fs.writeFileSync(path.join(pdfPath, job._id + ".pdf"), pdfData)
171 | clearTimeout(timeoutCode)
172 | finish()
173 | }).catch(error => console.log(error))
174 | })
175 | })
176 | })
177 | }
178 |
179 | this.browserWindow.webContents.once('did-finish-load', finishedLoadingHandler)
180 | errorEvents.forEach(event => this.browserWindow.webContents.once(event, e => {
181 | clearTimeout(timeoutCode)
182 | console.log("Browser error event", e)
183 | finish()
184 | }))
185 | })
186 | }
187 | }
188 |
189 | export default ChromeBookmarksDSC
--------------------------------------------------------------------------------