├── .gitignore
├── LICENSE
├── README.md
├── app
├── .gitignore
├── App.jsx
├── __redirects
├── bookmarklet.min.js
├── bookmarklet
│ └── bookmarklet.js
├── components
│ ├── BookmarkletLink.jsx
│ ├── CaptureForm.jsx
│ ├── ConfirmClear.jsx
│ ├── IntegrationIcons.jsx
│ ├── SensitiveInput.jsx
│ ├── SettingsForm.jsx
│ ├── SettingsModal.jsx
│ └── SettingsToggler.jsx
├── favicon
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ ├── favicon.png
│ ├── favicon.svg
│ ├── mstile-150x150.png
│ ├── safari-pinned-tab.svg
│ └── site.webmanifest
├── functions
│ └── send.js
├── hooks
│ ├── useInput.jsx
│ └── useLocalStorage.jsx
├── icons.jsx
├── index.css
├── index.html
├── index.jsx
├── logo.png
├── logo.svg
├── netlify.toml
├── package.json
├── robots.txt
├── theme.js
├── vite.config.js
└── yarn.lock
├── core
├── capture.js
├── index.js
├── package.json
├── utils.js
└── wf-cli.js
├── package.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | aliases.json
2 |
3 | # Logs
4 | logs
5 | *.log
6 |
7 | # Runtime data
8 | pids
9 | *.pid
10 | *.seed
11 |
12 | # Directory for instrumented libs generated by jscoverage/JSCover
13 | lib-cov
14 |
15 | # Coverage directory used by tools like istanbul
16 | coverage
17 |
18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
19 | .grunt
20 |
21 | # Compiled binary addons (http://nodejs.org/api/addons.html)
22 | build/Release
23 |
24 | # Dependency directory
25 | # Commenting this out is preferred by some people, see
26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
27 | node_modules
28 |
29 | # Users Environment Variables
30 | .lock-wscript
31 | .env
32 | # Local Netlify folder
33 | .netlify
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Original Copyright (c) 2014 Mike Robertson
4 | Modifications Copyright (c) 2016 Malcolm Ocean
5 | Modifications Copyright (c) 2021 Christian Miles
6 |
7 | Permission is hereby granted, free of charge, to any person obtaining a copy
8 | of this software and associated documentation files (the "Software"), to deal
9 | in the Software without restriction, including without limitation the rights
10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the Software is
12 | furnished to do so, subject to the following conditions:
13 |
14 | The above copyright notice and this permission notice shall be included in all
15 | copies or substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 | SOFTWARE.
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | An unofficial way to send to WorkFlowy from anywhere.
5 |
6 | ## Intro
7 |
8 | This repository allows you to send to your WorkFlowy account from a variety of different sources:
9 |
10 | - 🕸 Website
11 | - 🔖 Bookmarklet
12 | - 📱 iOS Shortcut
13 | - 📱 Android (coming soon!)
14 | - ⌨️ Command Line
15 |
16 | It helps you save links and text for later so you can concentrate on the task in hand.
17 |
18 | ## ⚠️ Disclaimer ⚠️
19 |
20 | WorkFlowy doesn't have an official API so `send-to-workflowy` needs some configuration to talk to WorkFlowy. It doesn't store any of your login information or WorkFlowy data but it's up to you to keep your Session ID secure.
21 |
22 | ## How to use
23 |
24 | For most users `send-to-workflowy` is a web application hosted at https://send-to-workflowy.netlify.app/
25 |
26 | Simply add your Session ID and Parent ID on the Settings panel to send to your WorkFlowy. These IDs are stored in your browser cache for convenience. They are used to communicate with your WorkFlowy but otherwise are not tracked by `send-to-workflowy`. See [Configuration](#configuration) for details on finding these IDs.
27 |
28 |
29 |
30 | ## Configuration
31 |
32 | ### Session ID
33 |
34 | `send-to-workflowy` uses a session ID to talk to your WorkFlowy account. The easiest way to do this is to copy it from the browser with the following steps:
35 |
36 | - Open WorkFlowy in a browser
37 | - Open the Developer Tools (right click anywhere and click "Inspect")
38 | - Go to the Application tab (on Chrome) or the Storage tab (on FireFox)
39 | - Chrome: In the left hand side bar under "Storage", open up "Cookies"
40 | - In Chrome and FireFox select https://workflowy.com – `sessionid` should be listed
41 | - e.g. `ulz0qzfzv1l1izs0oa2wuol69jdwtvdj`
42 |
43 | Note that you should treat this session ID as if it were a password. It should not be shared with anyone or posted publicly. Each Session ID expires after a few months.
44 |
45 | ### Parent ID
46 |
47 | All nodes in WorkFlowy have a parent ID. You can provide this to `send-to-workflowy` to always place new nodes under a node with a particular parent ID.
48 |
49 | To find your parent ID:
50 |
51 | - Open WorkFlowy in a browser
52 | - Find the node you wish to send to
53 | - Open the developer tools. (With Chrome: Ctrl + Shift + J on Windows, Command + Opt + J on macOS)
54 | - Run `copy(WF.currentItem().data.id)`
55 | - Your parent ID will now be on your clipboard ready to be pasted into `send-to-workflowy`
56 | - It will look something like `d4d9fe09-f770-41ef-d826-cb8fa483424f`
57 |
58 | ## Integrations
59 |
60 | The Settings panel also provides a link to the iOS shortcut and bookmarklet to make it even easier to send to WorkFlowy from your phone or without going to the site. Once you've configured your Session ID and Parent ID the integration icons on the Settings page are ready to use.
61 |
62 | ### iOS Shortcut
63 |
64 | If you select the Apple link you'll be redirected to an iOS shortcut hosted in iCloud. Due to limitations this is the easiest way to create your new `send-to-workflowy` shortcut.
65 |
66 | Scroll all the way down to accept the shortcut. On the next page you'll be prompted to add your Session ID and Parent ID. The shortcut expects these in the following form
67 |
68 | ```
69 |
70 |
71 | ```
72 |
73 | The `send-to-workflowy` web app should have already put these on the clipboard for you so just paste them into the prompt. Note these will not be leaving your device, just used to configure the shortcut.
74 |
75 | ### Bookmarklet
76 |
77 | This bookmarklet can be dragged to your bookmark bar. Again it's configured using the Session and Parent IDs provided in the webapp (locally) – it will send the current URL to your WorkFlowy.
78 |
79 | ### Android
80 |
81 | This integration isn't ready yet. Please let me know if there's a preferred way to do this on Android.
82 |
83 | In the meantime it's possible to add bookmarklets to [Chrome on Android](https://paul.kinlan.me/use-bookmarklets-on-chrome-on-android/).
84 |
85 | ## Self-hosted
86 |
87 | The most secure way to use `send-to-workflowy` is to host it yourself. This repository can be deployed to Netlify with the following URL.
88 |
89 | The environment variables `SESSIONID` and `PARENTID` can be configured in the Netlify configuration to avoid the need to provide these to the web application in the browser.
90 |
91 | [](https://app.netlify.com/start/deploy?repository=https://github.com/cjlm/send-to-workflowy)
92 |
93 | ## Future Roadmap
94 |
95 | - [ ] Android integration
96 | - [ ] Date support
97 | - [ ] Multiple parent support
98 | - [ ] Bookmarklet: send page selection if highlighted
99 | - [ ] Bookmarklet: visual confirmation of success
100 | - [ ] email-to-workflowy?
101 | - [ ] text-to-workflowy?
102 |
103 | ### Dev Improvements
104 |
105 | - [ ] Add tests
106 | - [x] Move top-level code to workspaces
107 |
108 | ## Acknowledgements
109 |
110 | There's a decade of previous attempts at an unofficial API of sorts for WorkFlowy. I am most indebted to Malcolm Ocean's [opusfluxus](https://github.com/malcolmocean/opusfluxus) for the original code forked to make this repository. Thanks to him and his collaborators.
111 |
112 | Thanks to Jesse Patel & team for WorkFlowy ❤️
113 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | .snowpack
2 | build
3 | node_modules
4 | dist
5 |
--------------------------------------------------------------------------------
/app/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Flex, Box, Heading } from '@chakra-ui/react';
3 |
4 | import { ArrowIcon } from './icons';
5 |
6 | import CaptureForm from './components/CaptureForm';
7 | import SettingsToggler from './components/SettingsToggler';
8 | import SettingsModal from './components/SettingsModal';
9 |
10 | import useInput from './hooks/useInput';
11 | import useLocalStorage from './hooks/useLocalStorage';
12 | import { useDisclosure } from '@chakra-ui/react';
13 |
14 | function App() {
15 | const {
16 | value: sessionId,
17 | bind: bindSessionId,
18 | setValue: setSessionId,
19 | } = useInput('', 'sessionId');
20 | const {
21 | value: parentId,
22 | bind: bindParentId,
23 | setValue: setParentId,
24 | } = useInput('', 'parentId');
25 | const [top, setTop] = useLocalStorage('addToTop', true);
26 |
27 | const { isOpen, onOpen, onClose } = useDisclosure();
28 |
29 | const modalProps = {
30 | sessionId,
31 | bindSessionId,
32 | setSessionId,
33 | parentId,
34 | bindParentId,
35 | setParentId,
36 | top,
37 | setTop,
38 | onClose,
39 | isOpen,
40 | };
41 |
42 | return (
43 |
49 |
58 |
59 |
60 |
61 |
62 |
63 | {'Send to WorkFlowy'}
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | }
74 | export default App;
75 |
--------------------------------------------------------------------------------
/app/__redirects:
--------------------------------------------------------------------------------
1 | /robots.txt /robots.txt 200
--------------------------------------------------------------------------------
/app/bookmarklet.min.js:
--------------------------------------------------------------------------------
1 | post("!URL",{sessionId:"!SESSION_ID",parentId:"!PARENT_ID",text:`${document.title} `,note:""});function post(t,{sessionId:e,parentId:o,text:n,note:d,priority:i=0}){fetch(t,{method:"POST",mode:"no-cors",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:n,note:d,sessionId:e,parentId:o,priority:i})})}
2 |
--------------------------------------------------------------------------------
/app/bookmarklet/bookmarklet.js:
--------------------------------------------------------------------------------
1 | post('!URL', {
2 | sessionId: '!SESSION_ID',
3 | parentId: '!PARENT_ID',
4 | text: `${document.title} `,
5 | note: '',
6 | });
7 |
8 | function post(url, { sessionId, parentId, text, note, priority = 0 }) {
9 | fetch(url, {
10 | method: 'POST',
11 | mode: 'no-cors',
12 | headers: {
13 | 'Content-Type': 'application/json',
14 | },
15 | body: JSON.stringify({ text, note, sessionId, parentId, priority }),
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/app/components/BookmarkletLink.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Link } from '@chakra-ui/react';
3 |
4 | import bookmarkletRaw from '/bookmarklet.min.js?raw'
5 |
6 | export default function BookmarkletLink(props) {
7 | const { sessionId, parentId, children } = props;
8 | const [link, setLink] = useState('');
9 |
10 | useEffect(() => {
11 | setLink(
12 | `javascript:${bookmarkletRaw
13 | .replace(`!SESSION_ID`, sessionId)
14 | .replace(`!PARENT_ID`, parentId)
15 | .replace(`!URL`, `${document.URL}send`)}`
16 | );
17 | }, [sessionId, parentId]);
18 |
19 | return {children};
20 | }
21 |
--------------------------------------------------------------------------------
/app/components/CaptureForm.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import useInput from '../hooks/useInput';
4 | import useLocalStorage from '../hooks/useLocalStorage';
5 | import { FormControl, FormLabel, FormHelperText } from '@chakra-ui/react';
6 |
7 | import { Button, CircularProgress, Checkbox } from '@chakra-ui/react';
8 | import { Input, Textarea, Box, Collapse } from '@chakra-ui/react';
9 |
10 | import { useToast } from '@chakra-ui/react';
11 |
12 | export default function CaptureForm(props) {
13 | const { parentId, sessionId, top } = props;
14 |
15 | const priority = top ? 0 : 10000000;
16 |
17 | const { value: text, bind: bindText, reset: resetText } = useInput('');
18 | const { value: note, bind: bindNote, reset: resetNote } = useInput('');
19 |
20 | const [status, setStatus] = useState('');
21 |
22 | const [isNoteShown, toggleNote] = useLocalStorage('notes', false);
23 |
24 | const toast = useToast();
25 |
26 | const showToast = (text, status) => {
27 | toast({
28 | title: text,
29 | description: '',
30 | status,
31 | isClosable: false,
32 | duration: 4000,
33 | position: 'bottom',
34 | });
35 | };
36 |
37 | const keyboardSubmit = (evt) => {
38 | if (evt.keyCode === 13 && (evt.ctrlKey || evt.metaKey)) {
39 | handleSubmit(evt);
40 | }
41 | };
42 |
43 | const handleSubmit = async (evt) => {
44 | evt.preventDefault();
45 | setStatus('loading');
46 |
47 | try {
48 | const response = await fetch('/send', {
49 | method: 'POST',
50 | mode: 'no-cors',
51 | headers: {
52 | 'Content-Type': 'application/json',
53 | },
54 | body: JSON.stringify({ text, note, sessionId, parentId, priority }),
55 | });
56 | if (response.ok) {
57 | setStatus('success');
58 |
59 | resetText();
60 | resetNote();
61 |
62 | showToast('Sent!', 'success');
63 | } else {
64 | throw new Error(`${response.status} - ${response.statusText}`);
65 | }
66 | } catch (err) {
67 | console.error(err);
68 | setStatus('error');
69 | showToast(
70 | 'Error connecting to WorkFlowy, please check your configuration.',
71 | 'error'
72 | );
73 | }
74 | };
75 | return (
76 | <>
77 |
119 | >
120 | );
121 | }
122 |
--------------------------------------------------------------------------------
/app/components/ConfirmClear.jsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import {
3 | AlertDialog,
4 | AlertDialogBody,
5 | AlertDialogFooter,
6 | AlertDialogHeader,
7 | AlertDialogContent,
8 | AlertDialogOverlay,
9 | Button,
10 | } from '@chakra-ui/react';
11 |
12 | export default function ConfirmClear({
13 | isConfirmOpen,
14 | setConfirmClosed,
15 | clear,
16 | }) {
17 | const cancelRef = useRef();
18 |
19 | return (
20 | <>
21 |
26 |
27 |
28 |
29 | Clear Settings
30 |
31 |
32 |
33 | Are you sure you want to clear your session ID and parent ID from
34 | this browser? You can't undo this.
35 |
36 |
37 |
38 |
39 | Cancel
40 |
41 | {
44 | clear();
45 | setConfirmClosed();
46 | }}
47 | ml={3}
48 | >
49 | Clear
50 |
51 |
52 |
53 |
54 |
55 | >
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/app/components/IntegrationIcons.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import {
3 | Tooltip,
4 | Spacer,
5 | Flex,
6 | Link,
7 | Text,
8 | useClipboard,
9 | } from '@chakra-ui/react';
10 |
11 | import { AndroidIcon, AppleIcon, BookmarkIcon } from '../icons';
12 |
13 | import BookmarkletLink from './BookmarkletLink';
14 |
15 | export default function IntegrationIcons({ sessionId, parentId }) {
16 | const [configForShortcut, setConfigForShortcut] = useState('');
17 |
18 | useEffect(() => {
19 | setConfigForShortcut(`${sessionId}\n${parentId}`);
20 | }, [sessionId, parentId]);
21 |
22 | const { onCopy } = useClipboard(configForShortcut);
23 |
24 | return (
25 |
26 |
27 |
28 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Send to WorkFlowy
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/app/components/SensitiveInput.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import { InputGroup, InputRightElement, Input } from '@chakra-ui/input';
4 |
5 | import { Button } from '@chakra-ui/react';
6 |
7 | export default function SensitiveInput({ value, onChange }) {
8 | const [show, setShow] = useState(false);
9 | const handleClick = () => setShow(!show);
10 |
11 | return (
12 |
13 |
21 |
22 |
23 | {show ? 'Hide' : 'Show'}
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/app/components/SettingsForm.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | FormControl,
4 | FormLabel,
5 | FormHelperText,
6 | Link,
7 | ButtonGroup,
8 | IconButton,
9 | Input,
10 | Text,
11 | } from '@chakra-ui/react';
12 |
13 | import { ArrowUpIcon, ArrowDownIcon } from '@chakra-ui/icons';
14 |
15 | import SensitiveInput from './SensitiveInput';
16 |
17 | export default function SettingsForm(props) {
18 | const { bindSessionId, bindParentId, top, setTop } = props;
19 |
20 | const handleSubmit = async (evt) => {
21 | evt.preventDefault();
22 | };
23 | return (
24 | <>
25 |
26 | Check the{' '}
27 |
31 | docs
32 | {' '}
33 | for more info on the below settings.
34 |
35 |
36 |
37 | Session ID:
38 |
39 | Required to send to your WorkFlowy.
40 |
41 |
42 | Parent ID:
43 |
44 | The location to send to in WorkFlowy.
45 |
46 |
47 |
48 | Add new item to top or bottom:
49 |
50 |
51 | }
54 | isActive={top}
55 | onClick={() => {
56 | setTop(true);
57 | }}
58 | />
59 | }
62 | isActive={!top}
63 | onClick={() => {
64 | setTop(false);
65 | }}
66 | />
67 |
68 |
69 | >
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/app/components/SettingsModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import {
4 | Modal,
5 | ModalOverlay,
6 | ModalContent,
7 | ModalHeader,
8 | ModalFooter,
9 | ModalBody,
10 | ModalCloseButton,
11 | Button,
12 | IconButton,
13 | useDisclosure,
14 | } from '@chakra-ui/react';
15 |
16 | import { DeleteIcon } from '@chakra-ui/icons';
17 |
18 | import SettingsForm from './SettingsForm';
19 | import IntegrationIcons from './IntegrationIcons';
20 | import ConfirmClear from './ConfirmClear';
21 |
22 | export default function SettingsModal(props) {
23 | const { sessionId, parentId, onClose, isOpen, setSessionId, setParentId } =
24 | props;
25 | const [scrollBehavior] = useState('inside');
26 |
27 | const clear = () => {
28 | setSessionId('');
29 | setParentId('');
30 | };
31 |
32 | const {
33 | isOpen: isConfirmOpen,
34 | onOpen: setConfirmOpen,
35 | onClose: setConfirmClosed,
36 | } = useDisclosure(false);
37 |
38 | return (
39 |
46 |
47 |
48 | Settings
49 |
50 |
51 |
52 |
53 |
54 |
55 | {
57 | setConfirmOpen(true);
58 | }}
59 | aria-label="Clear settings from browser"
60 | mr="1em"
61 | colorScheme="red"
62 | icon={ }
63 | >
64 | Clear
65 |
66 |
72 | Save
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/app/components/SettingsToggler.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, IconButton } from '@chakra-ui/react';
3 | import { SettingsIcon, ArrowIcon } from '../icons';
4 |
5 | export default function SettingsToggler({ settingsShown, toggleSettings }) {
6 | return (
7 |
8 | }
11 | onClick={toggleSettings}
12 | isRound={true}
13 | colorScheme={settingsShown ? 'teal' : ''}
14 | >
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/app/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/app/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/app/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #344659
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/app/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/app/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/favicon/favicon.ico
--------------------------------------------------------------------------------
/app/favicon/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/favicon/favicon.png
--------------------------------------------------------------------------------
/app/favicon/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/favicon/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/favicon/mstile-150x150.png
--------------------------------------------------------------------------------
/app/favicon/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
9 |
10 |
12 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/favicon/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/favicon/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/app/functions/send.js:
--------------------------------------------------------------------------------
1 | const { capture } = require('../../core/capture');
2 |
3 | exports.handler = async (event, context) => {
4 | if (event.httpMethod !== 'POST') {
5 | return { statusCode: 405, body: 'Method Not Allowed' };
6 | }
7 |
8 | const { text = '', note = '', priority = 0, ...rest } = JSON.parse(
9 | event.body
10 | );
11 |
12 | let { parentId, sessionId } = rest;
13 |
14 | if (!parentId || parentId.length === 0) {
15 | parentId = process.env.PARENTID;
16 | }
17 |
18 | if (!sessionId || sessionId.length === 0) {
19 | sessionId = process.env.SESSIONID;
20 | }
21 |
22 | const headers = {
23 | 'Access-Control-Allow-Origin': '*',
24 | 'Access-Control-Allow-Headers': 'Content-Type',
25 | 'Access-Control-Allow-Methods': 'POST, OPTION',
26 | };
27 |
28 | try {
29 | await capture({ parentId, sessionId, text, note, priority });
30 | return {
31 | headers,
32 | statusCode: 200,
33 | body: 'Sent!',
34 | };
35 | } catch (err) {
36 | return {
37 | headers,
38 | statusCode: 500,
39 | body: `Error ${err.status}:${err.message}`,
40 | };
41 | }
42 | };
43 |
--------------------------------------------------------------------------------
/app/hooks/useInput.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import useLocalStorage from './useLocalStorage';
4 |
5 | const useInput = (initialValue, key) => {
6 | const [value, setValue] = key
7 | ? useLocalStorage(key, initialValue)
8 | : useState(initialValue);
9 |
10 | return {
11 | value,
12 | setValue,
13 | reset: () => setValue(''),
14 | bind: {
15 | value,
16 | onChange: (event) => {
17 | setValue(event.target.value);
18 | },
19 | },
20 | };
21 | };
22 |
23 | export default useInput;
24 |
--------------------------------------------------------------------------------
/app/hooks/useLocalStorage.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | function useLocalStorage(key, initialValue) {
4 | // State to store our value
5 | // Pass initial state function to useState so logic is only executed once
6 | const [storedValue, setStoredValue] = useState(() => {
7 | try {
8 | // Get from local storage by key
9 | const item = window.localStorage.getItem(key);
10 | // Parse stored json or if none return initialValue
11 | return item ? JSON.parse(item) : initialValue;
12 | } catch (error) {
13 | // If error also return initialValue
14 | console.log(error);
15 | return initialValue;
16 | }
17 | });
18 |
19 | // Return a wrapped version of useState's setter function that ...
20 | // ... persists the new value to localStorage.
21 | const setValue = (value) => {
22 | try {
23 | // Allow value to be a function so we have same API as useState
24 | const valueToStore =
25 | value instanceof Function ? value(storedValue) : value;
26 | // Save state
27 | setStoredValue(valueToStore);
28 | // Save to local storage
29 | window.localStorage.setItem(key, JSON.stringify(valueToStore));
30 | } catch (error) {
31 | // A more advanced implementation would handle the error case
32 | console.log(error);
33 | }
34 | };
35 |
36 | return [storedValue, setValue];
37 | }
38 |
39 | export default useLocalStorage;
40 |
--------------------------------------------------------------------------------
/app/icons.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const gray = 'gray.400';
4 |
5 | import { createIcon } from '@chakra-ui/icons';
6 |
7 | export const ArrowIcon = createIcon({
8 | displayName: 'ArrowIcon',
9 | viewBox: '0 0 24 24',
10 | defaultProps: { fill: 'none' },
11 | path: [
12 | ,
13 | ,
14 | ,
15 | ],
16 | });
17 |
18 | const defaultProps = {
19 | fill: 'none',
20 | stroke: '#2c3e50',
21 | strokeLinecap: 'round',
22 | strokeLinejoin: 'round',
23 | strokeWidth: '1.5',
24 | };
25 |
26 | export const SettingsIcon = createIcon({
27 | displayName: 'SettingsIcon',
28 | viewBox: '0 0 24 24',
29 | defaultProps,
30 | path: [
31 | ,
32 | ,
36 | ,
37 | ],
38 | });
39 |
40 | const integrationStyles = { ...defaultProps, stroke: gray };
41 |
42 | export const AppleIcon = createIcon({
43 | displayName: 'AppleIcon',
44 | viewBox: '0 0 24 24',
45 | defaultProps: integrationStyles,
46 | path: [
47 | ,
48 | ,
52 | ],
53 | });
54 |
55 | export const AndroidIcon = createIcon({
56 | displayName: 'AndroidIcon',
57 | viewBox: '0 0 24 24',
58 | defaultProps: integrationStyles,
59 | path: [
60 | ,
61 | ,
62 | ,
63 | ,
67 | ,
68 | ,
69 | ,
70 | ,
71 | ],
72 | });
73 |
74 | export const BookmarkIcon = createIcon({
75 | displayName: 'BookmarkIcon',
76 | viewBox: '0 0 24 24',
77 | defaultProps: integrationStyles,
78 | path: [
79 | ,
80 | ,
81 | ],
82 | });
83 |
--------------------------------------------------------------------------------
/app/index.css:
--------------------------------------------------------------------------------
1 | /* Add CSS styles here! */
2 | body {
3 | font-family: sans-serif;
4 | }
5 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
15 |
21 |
27 |
28 |
33 |
34 |
35 |
36 |
37 |
42 |
43 |
48 |
49 | Send to Workflowy
50 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/app/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
5 | import theme from './theme';
6 |
7 | import App from './App.jsx';
8 |
9 | ReactDOM.render(
10 | <>
11 |
12 |
13 |
14 |
15 | >,
16 | document.getElementById('root')
17 | );
18 |
--------------------------------------------------------------------------------
/app/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/logo.png
--------------------------------------------------------------------------------
/app/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/app/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | command = "yarn build"
3 | functions = "functions"
4 | publish = "dist"
5 | base = "app/"
6 |
7 | [dev]
8 | command = "yarn start"
9 | targetPort = 3000
10 | functionsPort = 8081
11 | framework = "#custom"
12 |
13 | [[redirects]]
14 | from = "/send/*"
15 | to = "/.netlify/functions/send/"
16 | status = 200
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "app",
3 | "version": "0.3.1",
4 | "license": "MIT",
5 | "scripts": {
6 | "start": "vite",
7 | "build": "vite build",
8 | "server": "vite preview",
9 | "dev": "netlify dev",
10 | "bookmarklet": "esbuild --minify bookmarklet/bookmarklet.js --outfile=bookmarklet.min.js"
11 | },
12 | "devDependencies": {
13 | "@vitejs/plugin-react": "^1.0.2",
14 | "esbuild": "^0.12.24",
15 | "vite": "^2.6.3",
16 | "netlify-cli": "^6.14.16"
17 | },
18 | "dependencies": {
19 | "@chakra-ui/icons": "^1.0.15",
20 | "@chakra-ui/react": "^1.6.6",
21 | "@emotion/react": "^11",
22 | "@emotion/styled": "^11",
23 | "framer-motion": "^4",
24 | "react": "^17.0.2",
25 | "react-dom": "^17.0.2"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: * Disallow: /
--------------------------------------------------------------------------------
/app/theme.js:
--------------------------------------------------------------------------------
1 | import { extendTheme } from '@chakra-ui/react';
2 |
3 | const config = {
4 | initialColorMode: 'light',
5 | useSystemColorMode: true,
6 | colors: { highlight: '#597e8d' },
7 | };
8 |
9 | const theme = extendTheme({ config });
10 |
11 | export default theme;
12 |
--------------------------------------------------------------------------------
/app/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | });
8 |
--------------------------------------------------------------------------------
/app/yarn.lock:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cjlm/send-to-workflowy/ddef0e421c04981bdcb60e44702e33c6776b63a0/app/yarn.lock
--------------------------------------------------------------------------------
/core/capture.js:
--------------------------------------------------------------------------------
1 | const WorkflowyClient = require('./index');
2 | require('dotenv').config();
3 |
4 | const capture = async ({ sessionId, parentId, text, note, priority } = {}) => {
5 | try {
6 | console.log('⦿ new workflowy connection');
7 | let wf = new WorkflowyClient({
8 | sessionid: process.env.SESSIONID || sessionId,
9 | // includeSharedProjects: config.includeSharedProjects,
10 | });
11 | console.log('⦿ refresh workflowy');
12 | await wf.refresh();
13 | console.log('⦿ creating workflowy node');
14 |
15 | let result;
16 | if (text && text.includes('\n')) {
17 | result = await wf.createNested(
18 | parentId || process.env.PARENTID,
19 | text,
20 | priority,
21 | note
22 | );
23 | } else {
24 | result = await wf.create(parentId, text, priority, note);
25 | }
26 |
27 | console.log('⦿ created!');
28 | return result;
29 | } catch (err) {
30 | if (err.status == 404) {
31 | console.log('It seems your sessionid has expired.');
32 | } else {
33 | console.error(`Error ${err.status}:${err.message}`);
34 | throw err;
35 | }
36 | }
37 | };
38 |
39 | if (require.main === module) {
40 | capture();
41 | } else {
42 | exports.capture = capture;
43 | }
44 |
--------------------------------------------------------------------------------
/core/index.js:
--------------------------------------------------------------------------------
1 | const uuidv4 = require('uuid/v4');
2 |
3 | const utils = require('./utils');
4 |
5 | const { Readable } = require('stream');
6 | const { FormData } = require('formdata-node');
7 | const { FormDataEncoder } = require('form-data-encoder');
8 | const fetch = require('node-fetch');
9 |
10 | const WF_URL = 'https://workflowy.com';
11 | const CLIENT_VERSION = 23;
12 |
13 | const URLS = {
14 | newAuth: `${WF_URL}/api/auth?client_version=${CLIENT_VERSION}`,
15 | ajaxLogin: `${WF_URL}/ajax_login`,
16 | login: `${WF_URL}/accounts/login/`,
17 | meta: `${WF_URL}/get_initialization_data?client_version=${CLIENT_VERSION}`,
18 | update: `${WF_URL}/push_and_poll`,
19 | };
20 |
21 | module.exports = class WorkflowyClient {
22 | constructor(auth) {
23 | this.sessionid = auth.sessionid;
24 | this.includeSharedProjects = auth.includeSharedProjects;
25 | this.resolveMirrors = auth.resolveMirrors !== false; // default true, since mirrors are new so there's no expected behavior and most users will want this
26 | if (!this.sessionid) {
27 | this.username = auth.username || auth.email;
28 | this.password = auth.password || '';
29 | this.code = auth.code || '';
30 | this._lastTransactionId = null;
31 | }
32 | }
33 | async getAuthType(email, options = {}) {
34 | try {
35 | const form = new FormData();
36 | form.set('email', email);
37 | form.set('allowSignup', options.allowSignup || false);
38 |
39 | const encoder = new FormDataEncoder(form);
40 | const response = await fetch(URLS.newAuth, {
41 | method: 'POST',
42 | body: Readable.from(encoder),
43 | headers: encoder.headers,
44 | });
45 | const body = await response.json();
46 | utils.httpAbove299toError({ response, body });
47 | return body.authType;
48 | } catch (err) {
49 | console.log(err);
50 | }
51 | }
52 | async login() {
53 | try {
54 | if (!this.sessionid) {
55 | const form = new FormData();
56 | form.set('username', this.username);
57 | form.set('password', this.password || '');
58 | form.set('code', this.code || '');
59 |
60 | const encoder = new FormDataEncoder(form);
61 | const response = await fetch(URLS.ajaxLogin, {
62 | method: 'POST',
63 | body: Readable.from(encoder),
64 | headers: encoder.headers,
65 | });
66 |
67 | const body = await response.json();
68 | const cookies = utils.parseCookies(response.headers.get('set-cookie'));
69 |
70 | this.sessionid = cookies.sessionid;
71 |
72 | utils.httpAbove299toError({ response, body });
73 |
74 | if (/Please enter a correct username and password./.test(body)) {
75 | throw Error('Incorrect login info');
76 | }
77 |
78 | return body;
79 | }
80 | } catch (err) {
81 | throw err;
82 | }
83 | }
84 | async meta() {
85 | if (!this.metadata) {
86 | try {
87 | const Cookie = `sessionid=${this.sessionid}`;
88 |
89 | const response = await fetch(URLS.meta, {
90 | method: 'GET',
91 | headers: this.sessionid ? { Cookie } : {},
92 | });
93 |
94 | const body = await response.json();
95 |
96 | utils.httpAbove299toError({ response, body });
97 | this.metadata = body;
98 | } catch (err) {
99 | throw err;
100 | }
101 | }
102 | }
103 | async refresh() {
104 | if (!this.sessionid) {
105 | await this.login();
106 | }
107 |
108 | await this.meta();
109 |
110 | try {
111 | if (this.includeSharedProjects) {
112 | WorkflowyClient.transcludeShares(this.metadata);
113 | }
114 | const mpti = this.metadata.projectTreeData.mainProjectTreeInfo;
115 | this._lastTransactionId = mpti.initialMostRecentOperationTransactionId;
116 | this.outline = mpti.rootProjectChildren;
117 |
118 | if (this.resolveMirrors) {
119 | WorkflowyClient.transcludeMirrors(this.outline);
120 | }
121 |
122 | return this.outline;
123 | } catch (err) {
124 | console.error(err);
125 | throw err;
126 | }
127 | }
128 | async _update(operations) {
129 | await this.meta();
130 |
131 | const clientId = this.metadata.projectTreeData.clientId;
132 | const timestamp = utils.getTimestamp(this.metadata);
133 |
134 | operations.forEach((operation) => {
135 | operation.client_timestamp = timestamp;
136 | });
137 |
138 | const pushPollData = JSON.stringify([
139 | {
140 | most_recent_operation_transaction_id: this._lastTransactionId,
141 | operations,
142 | },
143 | ]);
144 |
145 | const form = new FormData();
146 | form.set('client_id', clientId);
147 | form.set('client_version', CLIENT_VERSION);
148 | form.set('push_poll_id', utils.makePollId());
149 | form.set('push_poll_data', pushPollData);
150 |
151 | const encoder = new FormDataEncoder(form);
152 |
153 | try {
154 | const response = await fetch(URLS.update, {
155 | method: 'POST',
156 | body: Readable.from(encoder),
157 | headers: {
158 | ...encoder.headers,
159 | Cookie: `sessionid=${this.sessionid}`,
160 | },
161 | });
162 | const body = await response.json();
163 |
164 | utils.httpAbove299toError({ response, body });
165 | this._lastTransactionId =
166 | body.results[0].new_most_recent_operation_transaction_id;
167 |
168 | return { response, body, timestamp };
169 | } catch (err) {
170 | console.error(err);
171 | throw err;
172 | }
173 | }
174 |
175 | // @returns an array of nodes that match the given string, regex or function
176 | find(search, completed, parentCompleted) {
177 | let condition, originalCondition, originalCondition2;
178 | if (typeof search == 'function') {
179 | condition = search;
180 | } else if (typeof search == 'string') {
181 | condition = (node) => node.nm === search;
182 | } else if (search instanceof RegExp) {
183 | condition = (node) => search.test(node.nm);
184 | } else if (search) {
185 | throw new Error('unknown search type');
186 | }
187 | if (typeof completed == 'boolean') {
188 | originalCondition = condition;
189 | condition = (node) =>
190 | Boolean(node.cp) === !!completed && originalCondition(node);
191 | }
192 | if (typeof parentCompleted == 'boolean') {
193 | originalCondition2 = condition;
194 | condition = (node) =>
195 | Boolean(node.pcp) === !!parentCompleted && originalCondition2(node);
196 | }
197 |
198 | if (condition) {
199 | nodes = nodes.filter(condition);
200 | }
201 | return nodes;
202 | }
203 | async delete(nodes) {
204 | nodes = ensureArray(nodes);
205 |
206 | const operations = nodes.map((node) => ({
207 | type: 'delete',
208 | data: {
209 | projectid: node.id,
210 | },
211 | undo_data: {
212 | previous_last_modified: node.lm,
213 | parentid: node.parentId,
214 | priority: 5,
215 | },
216 | }));
217 |
218 | await this._update(operations);
219 | await this.refresh();
220 | return Promise.resolve();
221 | }
222 | async complete(nodes, tf) {
223 | if (tf == null) {
224 | tf = true;
225 | }
226 |
227 | nodes = ensureArray(nodes);
228 |
229 | const operations = nodes.map((node) => ({
230 | type: tf ? 'complete' : 'uncomplete',
231 | data: {
232 | projectid: node.id,
233 | },
234 | undo_data: {
235 | previous_last_modified: node.lm,
236 | previous_completed: tf ? false : node.cp,
237 | },
238 | }));
239 |
240 | const { timestamp } = await this._update(operations);
241 |
242 | nodes.forEach((node) => {
243 | if (tf) {
244 | node.cp = timestamp;
245 | } else {
246 | delete node.cp;
247 | }
248 | });
249 | }
250 | async createNested(parentid, text, priority) {
251 | await this.createTrees(parentid, utils.makeChildren(text), priority);
252 | }
253 | async createTrees(parentid, nodeArray, priority) {
254 | if (typeof parentid !== 'string') {
255 | throw new Error("must provide parentid (use 'None' for top-level)");
256 | }
257 | for (let node of nodeArray) {
258 | await this.createTree(parentid, node, priority);
259 | }
260 | await this.refresh();
261 | }
262 | async createTree(parentid, topNode, priority) {
263 | if (typeof parentid !== 'string') {
264 | throw new Error("must provide parentid (use 'None' for top-level)");
265 | }
266 | const { nm, no, ch } = topNode;
267 | const newTopNode = await this.create(parentid, nm, priority, no);
268 |
269 | topNode.id = newTopNode.id;
270 | if (ch && ch.length) {
271 | await this.createTrees(topNode.id, ch, 1000000);
272 | }
273 |
274 | return topNode;
275 | }
276 | async create(parentid = 'None', name, priority = 0, note) {
277 | let projectid = uuidv4();
278 | let operations = [
279 | {
280 | type: 'create',
281 | data: {
282 | projectid,
283 | parentid: parentid,
284 | priority, // 0 adds as first child, 1 as second, etc
285 | },
286 | },
287 | {
288 | type: 'edit',
289 | data: {
290 | projectid,
291 | name,
292 | description: note,
293 | },
294 | },
295 | ];
296 | await this._update(operations);
297 | return { id: projectid };
298 | }
299 | async update(nodes, newNames) {
300 | nodes = ensureArray(nodes);
301 |
302 | const operations = nodes.map((node, idx) => {
303 | return {
304 | type: 'edit',
305 | data: {
306 | projectid: node.id,
307 | name: newNames[idx],
308 | },
309 | undo_data: {
310 | previous_last_modified: node.lm,
311 | previous_name: node.nm,
312 | },
313 | };
314 | });
315 |
316 | const { timestamp } = await this._update(operations);
317 |
318 | nodes.forEach((node, idx) => {
319 | node.nm = newNames[idx];
320 | node.lm = timestamp;
321 | });
322 | }
323 | /* modifies the tree so that shared projects are added in */
324 | static transcludeShares(meta) {
325 | const howManyShares = meta.projectTreeData.auxiliaryProjectTreeInfos.length;
326 | if (!howManyShares) {
327 | return;
328 | }
329 | const auxProjectsByShareId = {};
330 | meta.projectTreeData.auxiliaryProjectTreeInfos.map((x) => {
331 | if (x && x.rootProject && x.rootProject.shared) {
332 | auxProjectsByShareId[x.rootProject.shared.share_id] = x;
333 | }
334 | });
335 | const topLevelNodes =
336 | meta.projectTreeData.mainProjectTreeInfo.rootProjectChildren;
337 | const shareEntryPoints = utils.findAllBreadthFirst(
338 | topLevelNodes,
339 | (node) => node.as,
340 | howManyShares
341 | );
342 | shareEntryPoints.map((node) => {
343 | const auxP = auxProjectsByShareId[node.as];
344 | if (!auxP) {
345 | return;
346 | } // happens with certain templates
347 | node.nm = auxP.rootProject.nm;
348 | node.ch = auxP.rootProjectChildren;
349 | });
350 | }
351 | static getNodesByIdMap(outline) {
352 | const map = {};
353 |
354 | const mapChildren = (arr) => {
355 | arr.forEach((node) => {
356 | map[node.id] = node;
357 | if (node.ch) {
358 | mapChildren(node.ch);
359 | }
360 | });
361 | };
362 | mapChildren(outline);
363 |
364 | return map;
365 | }
366 | static transcludeMirrors(outline) {
367 | const nodesByIdMap = WorkflowyClient.getNodesByIdMap(outline);
368 | const transcludeChildren = (arr) => {
369 | for (let j = 0, len = arr.length; j < len; j++) {
370 | const node = arr[j];
371 | const originalId =
372 | node.metadata &&
373 | (node.metadata.originalId ||
374 | (node.metadata.mirror && node.metadata.mirror.originalId));
375 | if (originalId) {
376 | const originalNode = nodesByIdMap[originalId];
377 | if (originalNode) {
378 | arr[j] = originalNode;
379 | } else {
380 | // shouldn't happen; did when I was doing weird stuff in testing
381 | }
382 | } else {
383 | // only do children when considering in original situation
384 | arr[j].ch && transcludeChildren(arr[j].ch);
385 | }
386 | }
387 | };
388 | transcludeChildren(outline);
389 | }
390 | static pseudoFlattenUsingSet(outline) {
391 | const set = new Set();
392 | const addChildren = (arr, parentId, parentCompleted) => {
393 | let children;
394 | arr.forEach((child) => {
395 | set.add(child);
396 | child.parentId = parentId;
397 |
398 | const { id, cp, pcp, ch } = child;
399 |
400 | if (typeof pcp == 'undefined') {
401 | child.pcp = parentCompleted;
402 | } else {
403 | child.pcp = pcp & parentCompleted; // for mirrors
404 | }
405 | if ((children = ch)) {
406 | addChildren(children, id, cp || pcp);
407 | }
408 | });
409 | };
410 | addChildren(outline, 'None', false);
411 | return [...set];
412 | }
413 | };
414 |
--------------------------------------------------------------------------------
/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "core",
3 | "version": "0.3.1",
4 | "description": "NodeJS wrapper for Workflowy with CLI and Netlify function",
5 | "main": "index.js",
6 | "private": true,
7 | "workspaces": [
8 | "app"
9 | ],
10 | "scripts": {
11 | "start": "cd app && yarn start",
12 | "test": "echo \"Error: no test specified\" && exit 1"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git://github.com/cjlm/send-to-workflowy.git"
17 | },
18 | "keywords": [
19 | "workflowy",
20 | "api"
21 | ],
22 | "author": "Christian Miles (http://cjlm.ca/)",
23 | "contributors": [
24 | "Malcolm Ocean",
25 | "Mike Robertson",
26 | "Carolin Brandt"
27 | ],
28 | "license": "MIT",
29 | "bugs": {
30 | "url": "https://github.com/cjlm/send-to-workflowy/issues"
31 | },
32 | "dependencies": {
33 | "commander": "^8.1.0",
34 | "conf": "^10.0.2",
35 | "dotenv": "^10.0.0",
36 | "form-data-encoder": "^1.4.3",
37 | "formdata-node": "^4.0.0",
38 | "node-fetch": "^2.6.1",
39 | "prompts": "^2.4.1",
40 | "release-it": "^14.11.5",
41 | "uuid": "3.1.0"
42 | },
43 | "bin": {
44 | "wf": "wf-cli.js"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/core/utils.js:
--------------------------------------------------------------------------------
1 | const utils = {
2 | parseCookies: (cookies) =>
3 | cookies
4 | ? Object.fromEntries(
5 | cookies
6 | .split('; ')
7 | .map((x) => x.split(/=(.*)$/, 2).map(decodeURIComponent))
8 | )
9 | : {},
10 | ensureArray: function (val) {
11 | return Array.isArray(val) ? val : [val];
12 | },
13 | getTimestamp: function (meta) {
14 | return Math.floor(
15 | (Date.now() -
16 | meta.projectTreeData.mainProjectTreeInfo.dateJoinedTimestampInSeconds) /
17 | 60
18 | );
19 | },
20 | makePollId: function () {
21 | return (Math.random() + 1).toString(36).substr(2, 8);
22 | },
23 | httpAbove299toError: function ({ response: resp, body }) {
24 | var status = resp.statusCode;
25 | if (
26 | !(
27 | status === 302 &&
28 | ['/', 'https://workflowy.com/'].includes(resp.headers.location)
29 | )
30 | ) {
31 | if (300 <= status && status < 600) {
32 | throw new Error(
33 | `Error with request ${resp.request.uri.href}:${status}`
34 | );
35 | }
36 | if (body.error) {
37 | throw new Error(
38 | `Error with request ${resp.request.uri.href}:${body.error}`
39 | );
40 | }
41 | }
42 | return;
43 | },
44 | handleErr(reason) {
45 | while (reason.reason) {
46 | reason = reason.reason;
47 | }
48 | if (reason.status == 404) {
49 | console.log(
50 | "It seems your sessionid has expired. Let's log you in again."
51 | );
52 | return auth();
53 | } else {
54 | console.log(`Error ${reason.status}: `, reason.message);
55 | process.exit(1);
56 | }
57 | },
58 | findAllBreadthFirst(topLevelNodes, search, maxResults) {
59 | const queue = [].concat(topLevelNodes);
60 | let nodes = [];
61 | while ((node = queue.shift())) {
62 | if (node && search(node)) {
63 | nodes.push(node);
64 | } else if (node && node.ch && node.ch.length) {
65 | queue.push(...node.ch);
66 | }
67 | if (nodes.length == maxResults) {
68 | break;
69 | }
70 | }
71 | return nodes;
72 | },
73 |
74 | makeChildren(str) {
75 | const makeOne = (str = '') => {
76 | const cut = (str = '', char = '') => {
77 | const pos = str.search(char);
78 | return pos === -1
79 | ? [str, '']
80 | : [str.substr(0, pos), str.substr(pos + 1)];
81 | };
82 | const outdent = (str = '') => {
83 | const spaces = Math.max(0, str.search(/\S/));
84 | const re = new RegExp(`(^|\n)\\s{${spaces}}`, 'g');
85 | return str.replace(re, '$1');
86 | };
87 | const [nm, ch] = cut(str, '\n');
88 | return { nm, ch: this.makeChildren(outdent(ch)) };
89 | };
90 | const sanitize = (str = '') => {
91 | return str.trim().replace(/\n\s*\n/g, '\n');
92 | };
93 | return str === ''
94 | ? []
95 | : sanitize(str)
96 | .split(/\n(?!\s)/)
97 | .map(makeOne);
98 | },
99 | };
100 |
101 | module.exports = utils;
102 |
--------------------------------------------------------------------------------
/core/wf-cli.js:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env node
2 |
3 | const prompts = require('prompts');
4 | const Conf = require('conf');
5 |
6 | const WorkflowyClient = require('./index.js');
7 |
8 | const { program } = require('commander');
9 |
10 | let config;
11 |
12 | const initialize = async () => {
13 | config = new Conf();
14 |
15 | if (!config.has('sessionid')) {
16 | console.log(`No config detected... starting authentication process...`);
17 | await auth();
18 | }
19 |
20 | const sessionid = config.get('sessionid');
21 | const includeSharedProjects = config.get('includeSharedProjects');
22 | let wf = new WorkflowyClient({ sessionid, includeSharedProjects });
23 |
24 | await wf.refresh();
25 |
26 | return wf;
27 | };
28 |
29 | program
30 | .option(
31 | '-id, --id ',
32 | '36-digit uuid of parent (required) or defined alias'
33 | )
34 | .option(
35 | '-p, --priority ',
36 | 'priority of the new node, 0 as first child',
37 | 0
38 | )
39 | .option('-i, --print-id', 'also print the node id', false)
40 | .option('-n, --print-note', 'also print the note', false)
41 | .option('-c, --hide-completed', 'hide completed lists', true);
42 |
43 | program
44 | .command('alias [verb] [name]')
45 | .description('work with aliases')
46 | .action(async (verb, name) => {
47 | await initialize();
48 | const aliases = config.get('aliases');
49 | let { id } = program.opts();
50 |
51 | if (verb === 'add' && id) {
52 | config.set({ aliases: { [name]: id } });
53 | console.log(`Added new alias '${name}' for id '${id}'`);
54 | } else if (verb === 'remove' && name) {
55 | delete aliases[name];
56 | config.set('aliases', aliases);
57 | console.log(`Removed alias for name '${name}'`);
58 | } else {
59 | console.log(aliases);
60 | }
61 | return Promise.resolve();
62 | });
63 |
64 | program
65 | .command('capture [note]')
66 | .description('add something to a particular node')
67 | .action(async (text, note) => {
68 | const wf = await initialize();
69 |
70 | console.log('⦿ creating workflowy node');
71 | let { id: parentId } = program.opts();
72 | parentId = applyAlias(parentId);
73 |
74 | if (parentId) {
75 | console.log('parentId', parentId);
76 | }
77 |
78 | const { priority } = program.opts();
79 | try {
80 | const result = await wf.create(parentId, text, priority, note);
81 | } catch (err) {
82 | console.error(err);
83 | }
84 | console.log('created!');
85 | });
86 |
87 | program
88 | .command('tree ')
89 | .description('print your workflowy nodes up to depth n')
90 | .action(async (depth) => {
91 | console.log('⦿ fetching workflowy tree');
92 |
93 | const wf = await initialize();
94 |
95 | const {
96 | printNote,
97 | hideCompleted,
98 | printId,
99 | id: originalId,
100 | } = program.opts();
101 |
102 | const id = applyAlias(originalId);
103 | const options = { printNote, hideCompleted, printId };
104 |
105 | if (id) {
106 | let node = wf.nodes.find((node) => node.id == id);
107 | if (node) {
108 | recursivePrint(node, undefined, '', depth, options);
109 | } else {
110 | console.log(`node ${id} not found`);
111 | }
112 | } else {
113 | let rootnode = {
114 | nm: 'root',
115 | ch: wf.outline,
116 | id: '',
117 | };
118 | recursivePrint(rootnode, undefined, '', depth, options);
119 | }
120 | });
121 |
122 | program
123 | .command('meta')
124 | .description('meta')
125 | .action(async () => {
126 | const wf = await initialize();
127 | console.log('⦿ fetching workflowy data');
128 | try {
129 | const {
130 | projectTreeData: {
131 | auxiliaryProjectTreeInfos,
132 | mainProjectTreeInfo: { rootProjectChildren },
133 | },
134 | settings: { username },
135 | } = await wf.meta();
136 |
137 | console.log(`logged in as ${username}`);
138 | console.log(`${rootProjectChildren.length} top-level nodes`);
139 | } catch (err) {
140 | console.error(err);
141 | }
142 | });
143 |
144 | function recursivePrint(
145 | node,
146 | prefix = '\u21b3 ',
147 | spaces = '',
148 | maxDepth,
149 | options
150 | ) {
151 | const { printId, printNote, hideCompleted } = options;
152 | if (hideCompleted && node.cp) {
153 | return;
154 | }
155 |
156 | let println = [spaces, prefix, node.nm].join('');
157 |
158 | if (printNote && node.no) {
159 | println += `\n${spaces} ${node.no}`;
160 | }
161 |
162 | if (printId) {
163 | println += ` [${node.id}]`;
164 | }
165 |
166 | console.log(println);
167 |
168 | if (maxDepth < 1) {
169 | return;
170 | }
171 |
172 | let children = node.ch;
173 | for (let i in children) {
174 | recursivePrint(children[i], prefix, spaces + ' ', maxDepth - 1, options);
175 | }
176 | }
177 |
178 | function applyAlias(id) {
179 | const aliases = config.get('aliases');
180 | const regex =
181 | '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
182 | return id != undefined && !id.match(regex) ? aliases[id] : id;
183 | }
184 |
185 | const auth = async () => {
186 | console.log(
187 | 'What is your workflowy login info? This will not be saved, merely used once to authenticate.'
188 | );
189 |
190 | let wf = new WorkflowyClient({});
191 |
192 | const { email } = await prompts({
193 | type: 'text',
194 | name: 'email',
195 | message: 'Email:',
196 | });
197 |
198 | const authType = await wf.getAuthType(email);
199 |
200 | const schemas = {
201 | password: { type: 'password', name: 'password', message: 'Password' },
202 | code: {
203 | type: 'text',
204 | name: 'code',
205 | message: `An email has been sent to ${email} with a login code. Enter that code here:`,
206 | },
207 | };
208 |
209 | const value = await prompts(schemas[authType]);
210 |
211 | wf = new WorkflowyClient({
212 | username: email,
213 | ...value,
214 | });
215 |
216 | try {
217 | await wf.login();
218 | await wf.refresh();
219 | } catch (err) {
220 | console.error('err', err);
221 | }
222 |
223 | console.log('Login successful.');
224 |
225 | try {
226 | config.set('sessionid', wf.sessionid);
227 | console.log(`Your session id is: ${wf.sessionid}`);
228 | console.log(`Successfully wrote sessionid to ${config.path}`);
229 | } catch (e) {
230 | return console.log(`Failed to write sessionid to config`);
231 | }
232 | };
233 |
234 | program.parseAsync(process.argv);
235 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "send-to-workflowy",
3 | "version": "0.3.1",
4 | "description": "NodeJS wrapper for Workflowy with CLI and Netlify function",
5 | "main": "index.js",
6 | "private": true,
7 | "workspaces": [
8 | "app",
9 | "core"
10 | ],
11 | "scripts": {},
12 | "repository": {
13 | "type": "git",
14 | "url": "git://github.com/cjlm/send-to-workflowy.git"
15 | },
16 | "keywords": [
17 | "workflowy",
18 | "api"
19 | ],
20 | "author": "Christian Miles (http://cjlm.ca/)",
21 | "contributors": [
22 | "Malcolm Ocean",
23 | "Mike Robertson",
24 | "Carolin Brandt"
25 | ],
26 | "license": "MIT",
27 | "bugs": {
28 | "url": "https://github.com/cjlm/send-to-workflowy/issues"
29 | },
30 | "dependencies": {},
31 | "bin": {
32 | "wf": "wf-cli.js"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------