diff --git a/Clipper/joplin-webclipper/content_scripts/index.js b/Clipper/joplin-webclipper/content_scripts/index.js index 0e43b9822..07347baec 100644 --- a/Clipper/joplin-webclipper/content_scripts/index.js +++ b/Clipper/joplin-webclipper/content_scripts/index.js @@ -94,6 +94,7 @@ title: article.title, baseUrl: baseUrl(), url: location.origin + location.pathname, + parentId: command.parentId, }; } else if (command.name === "completePageHtml") { @@ -107,6 +108,7 @@ title: pageTitle(), baseUrl: baseUrl(), url: location.origin + location.pathname, + parentId: command.parentId, }; } else if (command.name === 'screenshot') { @@ -203,6 +205,7 @@ title: pageTitle(), cropRect: selectionArea, url: location.origin + location.pathname, + parentId: command.parentId, }; browser_.runtime.sendMessage({ diff --git a/Clipper/joplin-webclipper/manifest.json b/Clipper/joplin-webclipper/manifest.json index 8e85d2171..6820581d1 100644 --- a/Clipper/joplin-webclipper/manifest.json +++ b/Clipper/joplin-webclipper/manifest.json @@ -17,7 +17,8 @@ "tabs", "http://*/", "https://*/", - "" + "", + "storage" ], "browser_action": { diff --git a/Clipper/joplin-webclipper/popup/src/App.css b/Clipper/joplin-webclipper/popup/src/App.css index f0d3b7872..348467f1f 100644 --- a/Clipper/joplin-webclipper/popup/src/App.css +++ b/Clipper/joplin-webclipper/popup/src/App.css @@ -7,12 +7,13 @@ flex-direction: column; background-color: #162b3d; font-size: 16px; + color: #5A95C7; + padding: 10px; + box-sizing: border-box; } .App h2 { font-size: 1em; - color: #5A95C7; - padding-left: 10px; margin-top: .5em; margin-bottom: .5em; font-weight: normal; @@ -28,13 +29,12 @@ .App .Controls { flex: 0; - padding-top: 10px; } .App .Controls ul { flex: 0; list-style-type: none; - padding: 0 10px; + padding-left: 0; margin: 0; display: flex; flex-direction: column; @@ -66,10 +66,8 @@ min-height: 0; flex: 1; align-items: stretch; - margin: 0 10px 0 10px; display: flex; flex-direction: column; - /*border: 2px solid red;*/ } .App .Preview .Info { @@ -102,6 +100,23 @@ flex: 0; } +.App .Folders { + display: flex; + flex-direction: row; + align-items: center; + padding: 5px 0; +} + +.App .Folders label { + flex: 0; + white-space: nowrap; +} + +.App .Folders select { + flex: 1; + margin-left: 10px; +} + .App .StatusBar { color: #5A95C7; font-size: .7em; @@ -109,8 +124,8 @@ flex: 0; flex-direction: 'row'; align-items: center; - min-height: 31px; - padding: 0px 10px 5px 10px; + min-height: 20px; + padding-top: 5px; } .App .StatusBar .Led { diff --git a/Clipper/joplin-webclipper/popup/src/App.js b/Clipper/joplin-webclipper/popup/src/App.js index a831578db..99dcda2a9 100644 --- a/Clipper/joplin-webclipper/popup/src/App.js +++ b/Clipper/joplin-webclipper/popup/src/App.js @@ -1,8 +1,8 @@ import React, { Component } from 'react'; import './App.css'; -import led_red from './led_red.png'; // Tell Webpack this JS file uses this image -import led_green from './led_green.png'; // Tell Webpack this JS file uses this image -import led_orange from './led_orange.png'; // Tell Webpack this JS file uses this image +import led_red from './led_red.png'; +import led_green from './led_green.png'; +import led_orange from './led_orange.png'; const { connect } = require('react-redux'); const { bridge } = require('./bridge'); @@ -24,16 +24,31 @@ class AppComponent extends Component { this.props.dispatch({ type: 'CLIPPED_CONTENT_TITLE_SET', text: event.currentTarget.value - }); + }); + } + + this.clipSimplified_click = () => { + bridge().sendCommandToActiveTab({ + name: 'simplifiedPageHtml', + parentId: this.props.selectedFolderId, + }); + } + + this.clipComplete_click = () => { + bridge().sendCommandToActiveTab({ + name: 'completePageHtml', + parentId: this.props.selectedFolderId, + }); } this.clipScreenshot_click = async () => { try { const baseUrl = await bridge().clipperServerBaseUrl(); - bridge().sendCommandToActiveTab({ + await bridge().sendCommandToActiveTab({ name: 'screenshot', apiBaseUrl: baseUrl, + parentId: this.props.selectedFolderId, }); window.close(); @@ -45,18 +60,13 @@ class AppComponent extends Component { this.clipperServerHelpLink_click = () => { bridge().tabsCreate({ url: 'https://joplin.cozic.net/clipper' }); } - } - clipSimplified_click() { - bridge().sendCommandToActiveTab({ - name: 'simplifiedPageHtml', - }); - } - - clipComplete_click() { - bridge().sendCommandToActiveTab({ - name: 'completePageHtml', - }); + this.folderSelect_change = (event) => { + this.props.dispatch({ + type: 'SELECTED_FOLDER_SET', + id: event.target.value, + }); + } } async loadContentScripts() { @@ -147,7 +157,37 @@ class AppComponent extends Component { msg = "Service status: " + msg return
{ msg }{ helpLink }
- } + } + + console.info(this.props.selectedFolderId); + + const foldersComp = () => { + const optionComps = []; + + const nonBreakingSpacify = (s) => { + // https://stackoverflow.com/a/24437562/561309 + return s.replace(/ /g, "\u00a0"); + } + + const addOptions = (folders, depth) => { + for (let i = 0; i < folders.length; i++) { + const folder = folders[i]; + optionComps.push() + if (folder.children) addOptions(folder.children, depth + 1); + } + } + + addOptions(this.props.folders, 0); + + return ( +
+ + +
+ ); + } return (
@@ -158,6 +198,7 @@ class AppComponent extends Component {
  • Clip screenshot
  • + { foldersComp() } { warningComponent }

    Preview:

    { previewComponent } @@ -174,6 +215,8 @@ const mapStateToProps = (state) => { clippedContent: state.clippedContent, contentUploadOperation: state.contentUploadOperation, clipperServer: state.clipperServer, + folders: state.folders, + selectedFolderId: state.selectedFolderId, }; }; diff --git a/Clipper/joplin-webclipper/popup/src/bridge.js b/Clipper/joplin-webclipper/popup/src/bridge.js index 916b6b65c..a3b49d697 100644 --- a/Clipper/joplin-webclipper/popup/src/bridge.js +++ b/Clipper/joplin-webclipper/popup/src/bridge.js @@ -27,6 +27,7 @@ class Bridge { bodyHtml: command.html, baseUrl: command.baseUrl, url: command.url, + parentId: command.parentId, }; this.dispatch({ type: 'CLIPPED_CONTENT_SET', content: content }); @@ -52,6 +53,33 @@ class Bridge { return this.dispatch_(action); } + scheduleStateSave(state) { + if (this.scheduleStateSaveIID) { + clearTimeout(this.scheduleStateSaveIID); + this.scheduleStateSaveIID = null; + } + + this.scheduleStateSaveIID = setTimeout(() => { + this.scheduleStateSaveIID = null; + + const toSave = { + selectedFolderId: state.selectedFolderId, + }; + + console.info('Popup: Saving state', toSave); + + this.storageSet(toSave); + }, 100); + } + + async restoreState() { + const s = await this.storageGet(null); + console.info('Popup: Restoring saved state:', s); + if (!s) return; + + if (s.selectedFolderId) this.dispatch({ type: 'SELECTED_FOLDER_SET', id: s.selectedFolderId }); + } + async findClipperServerPort() { this.dispatch({ type: 'CLIPPER_SERVER_SET', foundState: 'searching' }); @@ -68,6 +96,9 @@ class Bridge { this.clipperServerPortStatus_ = 'found'; this.clipperServerPort_ = state.port; this.dispatch({ type: 'CLIPPER_SERVER_SET', foundState: 'found', port: state.port }); + + const folders = await this.folderTree(); + this.dispatch({ type: 'FOLDERS_SET', folders: folders }); return; } } catch (error) { @@ -152,6 +183,37 @@ class Bridge { }); } + async folderTree() { + return this.clipperApiExec('GET', 'folders'); + } + + async storageSet(keys) { + if (this.browserSupportsPromises_) return this.browser().storage.local.set(keys); + + return new Promise((resolve, reject) => { + this.browser().storage.local.set(keys, () => { + resolve(); + }); + }); + } + + async storageGet(keys, defaultValue = null) { + if (this.browserSupportsPromises_) { + try { + const r = await this.browser().storage.local.get(keys); + return r; + } catch (error) { + return defaultValue; + } + } else { + return new Promise((resolve, reject) => { + this.browser().storage.local.get(keys, (result) => { + resolve(result); + }); + }); + } + } + async sendCommandToActiveTab(command) { const tabs = await this.tabsQuery({ active: true, currentWindow: true }); if (!tabs.length) { @@ -166,6 +228,30 @@ class Bridge { await this.tabsSendMessage(tabs[0].id, command); } + async clipperApiExec(method, path, body) { + console.info('Popup: ' + method + ' ' + path); + + const baseUrl = await this.clipperServerBaseUrl(); + + const fetchOptions = { + method: method, + headers: { + 'Content-Type': 'application/json' + }, + } + + if (body) fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); + + const response = await fetch(baseUrl + "/" + path, fetchOptions) + if (!response.ok) { + const msg = await response.text(); + throw new Error(msg); + } + + const json = await response.json(); + return json; + } + async sendContentToJoplin(content) { console.info('Popup: Sending to Joplin...'); @@ -176,20 +262,9 @@ class Bridge { const baseUrl = await this.clipperServerBaseUrl(); - const response = await fetch(baseUrl + "/notes", { - method: "POST", - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(content) - }) + await this.clipperApiExec('POST', 'notes', content); - if (!response.ok) { - this.dispatch({ type: 'CONTENT_UPLOAD', operation: { uploading: false, success: false, errorMessage: response.text() } }); - } else { - this.dispatch({ type: 'CONTENT_UPLOAD', operation: { uploading: false, success: true } }); - } + this.dispatch({ type: 'CONTENT_UPLOAD', operation: { uploading: false, success: true } }); } catch (error) { this.dispatch({ type: 'CONTENT_UPLOAD', operation: { uploading: false, success: false, errorMessage: error.message } }); } diff --git a/Clipper/joplin-webclipper/popup/src/index.js b/Clipper/joplin-webclipper/popup/src/index.js index 9f994f960..01973c784 100644 --- a/Clipper/joplin-webclipper/popup/src/index.js +++ b/Clipper/joplin-webclipper/popup/src/index.js @@ -5,7 +5,7 @@ import App from './App'; const { Provider } = require('react-redux'); const { bridge } = require('./bridge'); -const { createStore } = require('redux'); +const { createStore, applyMiddleware } = require('redux'); const defaultState = { warning: '', @@ -15,8 +15,21 @@ const defaultState = { foundState: 'idle', port: null, }, + folders: [], + selectedFolderId: null, }; +const reduxMiddleware = store => next => async (action) => { + const result = next(action); + const newState = store.getState(); + + if (['SELECTED_FOLDER_SET'].indexOf(action.type) >= 0) { + bridge().scheduleStateSave(newState); + } + + return result; +} + function reducer(state = defaultState, action) { let newState = state; @@ -42,6 +55,16 @@ function reducer(state = defaultState, action) { newState = Object.assign({}, state); newState.contentUploadOperation = action.operation; + } else if (action.type === 'FOLDERS_SET') { + + newState = Object.assign({}, state); + newState.folders = action.folders; + + } else if (action.type === 'SELECTED_FOLDER_SET') { + + newState = Object.assign({}, state); + newState.selectedFolderId = action.id; + } else if (action.type === 'CLIPPER_SERVER_SET') { newState = Object.assign({}, state); @@ -55,9 +78,10 @@ function reducer(state = defaultState, action) { return newState; } -const store = createStore(reducer); +const store = createStore(reducer, applyMiddleware(reduxMiddleware)); bridge().init(window.browser ? window.browser : window.chrome, !!window.browser, store.dispatch); +bridge().restoreState(); console.info('Popup: Creating React app...'); diff --git a/ReactNativeClient/lib/BaseModel.js b/ReactNativeClient/lib/BaseModel.js index 02c629f0c..637956920 100644 --- a/ReactNativeClient/lib/BaseModel.js +++ b/ReactNativeClient/lib/BaseModel.js @@ -161,7 +161,10 @@ class BaseModel { } static async all(options = null) { - let q = this.applySqlOptions(options, 'SELECT * FROM `' + this.tableName() + '`'); + if (!options) options = {}; + if (!options.fields) options.fields = '*'; + + let q = this.applySqlOptions(options, 'SELECT ' + this.db().escapeFields(options.fields) + ' FROM `' + this.tableName() + '`'); return this.modelSelectAll(q.sql); } diff --git a/ReactNativeClient/lib/ClipperServer.js b/ReactNativeClient/lib/ClipperServer.js index e978be0fe..637c470c2 100644 --- a/ReactNativeClient/lib/ClipperServer.js +++ b/ReactNativeClient/lib/ClipperServer.js @@ -64,14 +64,6 @@ class ClipperServer { }); } - // startState() { - // return this.startState_; - // } - - // port() { - // return this.port_; - // } - htmlToMdParser() { if (this.htmlToMdParser_) return this.htmlToMdParser_; this.htmlToMdParser_ = new HtmlToMd(); @@ -93,8 +85,8 @@ class ClipperServer { }); } - if (requestNote.parent_id) { - output.parent_id = requestNote.parent_id; + if (requestNote.parentId) { + output.parent_id = requestNote.parentId; } else { const folder = await Folder.defaultFolder(); if (!folder) throw new Error('Cannot find folder for note'); @@ -227,11 +219,11 @@ class ClipperServer { this.server_ = require('http').createServer(); - this.server_.on('request', (request, response) => { + this.server_.on('request', async (request, response) => { - const writeCorsHeaders = (code) => { + const writeCorsHeaders = (code, contentType = "application/json") => { response.writeHead(code, { - "Content-Type": "application/json", + "Content-Type": contentType, 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, PUT, PATCH, DELETE', 'Access-Control-Allow-Headers': 'X-Requested-With,content-type', @@ -245,7 +237,7 @@ class ClipperServer { } const writeResponseText = (code, text) => { - writeCorsHeaders(code); + writeCorsHeaders(code, 'text/plain'); response.write(text); response.end(); } @@ -259,6 +251,11 @@ class ClipperServer { if (url.pathname === '/ping') { return writeResponseText(200, 'JoplinClipperServer'); } + + if (url.pathname === '/folders') { + const structure = await Folder.allAsTree({ fields: ['id', 'parent_id', 'title'] }); + return writeResponseJson(200, structure); + } } else if (request.method === 'POST') { if (url.pathname === '/notes') { let body = ''; diff --git a/ReactNativeClient/lib/models/Folder.js b/ReactNativeClient/lib/models/Folder.js index 301336234..4d24e9405 100644 --- a/ReactNativeClient/lib/models/Folder.js +++ b/ReactNativeClient/lib/models/Folder.js @@ -127,6 +127,34 @@ class Folder extends BaseItem { return output; } + static async allAsTree(options = null) { + const all = await this.all(options); + + // https://stackoverflow.com/a/49387427/561309 + function getNestedChildren(models, parentId) { + const nestedTreeStructure = []; + const length = models.length; + + for (let i = 0; i < length; i++) { + const model = models[i]; + + if (model.parent_id == parentId) { + const children = getNestedChildren(models, model.id); + + if (children.length > 0) { + model.children = children; + } + + nestedTreeStructure.push(model); + } + } + + return nestedTreeStructure; + } + + return getNestedChildren(all, ''); + } + static load(id) { if (id == this.conflictFolderId()) return this.conflictFolder(); return super.load(id); diff --git a/joplin.sublime-project b/joplin.sublime-project index 340c472cb..a32b7ff36 100755 --- a/joplin.sublime-project +++ b/joplin.sublime-project @@ -61,6 +61,7 @@ "_releases", "ReactNativeClient/lib/csstojs", "Clipper/joplin-webclipper/popup/build", + "Clipper/joplin-webclipper/dist", ], "path": "." },