├── .npmrc ├── .gitignore ├── docs ├── fonts │ ├── OpenSans-Bold-webfont.eot │ ├── OpenSans-Bold-webfont.woff │ ├── OpenSans-Light-webfont.eot │ ├── OpenSans-Italic-webfont.eot │ ├── OpenSans-Italic-webfont.woff │ ├── OpenSans-Light-webfont.woff │ ├── OpenSans-Regular-webfont.eot │ ├── OpenSans-BoldItalic-webfont.eot │ ├── OpenSans-Regular-webfont.woff │ ├── OpenSans-Semibold-webfont.eot │ ├── OpenSans-Semibold-webfont.ttf │ ├── OpenSans-Semibold-webfont.woff │ ├── OpenSans-BoldItalic-webfont.woff │ ├── OpenSans-LightItalic-webfont.eot │ ├── OpenSans-LightItalic-webfont.woff │ ├── OpenSans-SemiboldItalic-webfont.eot │ ├── OpenSans-SemiboldItalic-webfont.ttf │ └── OpenSans-SemiboldItalic-webfont.woff ├── scripts │ ├── linenumber.js │ └── prettify │ │ ├── lang-css.js │ │ ├── Apache-License-2.0.txt │ │ └── prettify.js ├── styles │ ├── prettify-jsdoc.css │ ├── prettify-tomorrow.css │ └── jsdoc-default.css ├── module.exports.html ├── index.html ├── global.html └── feedly.js.html ├── .codoopts ├── .jsdoc.conf ├── html └── index.html ├── tools ├── waiting.html └── copyif.js ├── LICENSE.md ├── package.json ├── README.md ├── lib ├── utils.js └── feedly.js └── test └── feedly.test.js /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | doc 4 | .DS_Store 5 | man 6 | TAGS 7 | .nyc_output/ 8 | -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Bold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Bold-webfont.eot -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Bold-webfont.woff -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Light-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Light-webfont.eot -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Italic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Italic-webfont.eot -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Italic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Italic-webfont.woff -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Light-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Light-webfont.woff -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Regular-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Regular-webfont.eot -------------------------------------------------------------------------------- /.codoopts: -------------------------------------------------------------------------------- 1 | --name "Feedly API" 2 | --title "Feedly API Documentation" 3 | --readme README.md 4 | ./src/feedly.coffee 5 | LICENSE.md 6 | -------------------------------------------------------------------------------- /docs/fonts/OpenSans-BoldItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-BoldItalic-webfont.eot -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Regular-webfont.woff -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Semibold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Semibold-webfont.eot -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Semibold-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Semibold-webfont.ttf -------------------------------------------------------------------------------- /docs/fonts/OpenSans-Semibold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-Semibold-webfont.woff -------------------------------------------------------------------------------- /docs/fonts/OpenSans-BoldItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-BoldItalic-webfont.woff -------------------------------------------------------------------------------- /docs/fonts/OpenSans-LightItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-LightItalic-webfont.eot -------------------------------------------------------------------------------- /docs/fonts/OpenSans-LightItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-LightItalic-webfont.woff -------------------------------------------------------------------------------- /docs/fonts/OpenSans-SemiboldItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-SemiboldItalic-webfont.eot -------------------------------------------------------------------------------- /docs/fonts/OpenSans-SemiboldItalic-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-SemiboldItalic-webfont.ttf -------------------------------------------------------------------------------- /docs/fonts/OpenSans-SemiboldItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hildjj/node-feedly/HEAD/docs/fonts/OpenSans-SemiboldItalic-webfont.woff -------------------------------------------------------------------------------- /.jsdoc.conf: -------------------------------------------------------------------------------- 1 | { 2 | "source" : { 3 | "include": ["lib/", "README.md"] 4 | }, 5 | "opts": { 6 | "template": "node_modules/minami", 7 | "destination": "./docs/", 8 | "encoding": "utf8" 9 | }, 10 | "templates": { 11 | "cleverLinks": false, 12 | "monospaceLinks": false, 13 | "sort": true, 14 | "default": { 15 | "includeDate": false 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 16 | Local Feedly authentication landing 17 | 18 | 19 |

Feedly authentication result

20 |

Please close this window.

21 | 22 | -------------------------------------------------------------------------------- /tools/waiting.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Code coverage report for All files 5 | 16 | 17 | 18 |
This page will refresh with a coverage report, as long as you have 19 | executed npm run dev. Please be patient.
20 | 21 | -------------------------------------------------------------------------------- /docs/scripts/linenumber.js: -------------------------------------------------------------------------------- 1 | /*global document */ 2 | (function() { 3 | var source = document.getElementsByClassName('prettyprint source linenums'); 4 | var i = 0; 5 | var lineNumber = 0; 6 | var lineId; 7 | var lines; 8 | var totalLines; 9 | var anchorHash; 10 | 11 | if (source && source[0]) { 12 | anchorHash = document.location.hash.substring(1); 13 | lines = source[0].getElementsByTagName('li'); 14 | totalLines = lines.length; 15 | 16 | for (; i < totalLines; i++) { 17 | lineNumber++; 18 | lineId = 'line' + lineNumber; 19 | lines[i].id = lineId; 20 | if (lineId === anchorHash) { 21 | lines[i].className += ' selected'; 22 | } 23 | } 24 | } 25 | })(); 26 | -------------------------------------------------------------------------------- /docs/scripts/prettify/lang-css.js: -------------------------------------------------------------------------------- 1 | PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\f\r ]+/,null," \t\r\n "]],[["str",/^"(?:[^\n\f\r"\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*"/,null],["str",/^'(?:[^\n\f\r'\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*'/,null],["lang-css-str",/^url\(([^"')]*)\)/i],["kwd",/^(?:url|rgb|!important|@import|@page|@media|@charset|inherit)(?=[^\w-]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*)\s*:/i],["com",/^\/\*[^*]*\*+(?:[^*/][^*]*\*+)*\//],["com", 2 | /^(?:<\!--|--\>)/],["lit",/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],["lit",/^#[\da-f]{3,6}/i],["pln",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i],["pun",/^[^\s\w"']+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[["kwd",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[["str",/^[^"')]+/]]),["css-str"]); 3 | -------------------------------------------------------------------------------- /tools/copyif.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | 6 | const src = path.join(__dirname, 'waiting.html') 7 | const dir = path.join(__dirname, '..', 'coverage') 8 | const dst = path.join(dir, 'index.html') 9 | 10 | fs.stat(dst, (er, st) => { 11 | if (er) { 12 | if (er.code !== 'ENOENT') { 13 | console.error(`Error on ${dst}: ${er.message}`) 14 | process.exit(1) 15 | } 16 | } else { 17 | if (!st.isFile()) { 18 | console.error(`Error on ${dst}: not a file`) 19 | process.exit(1) 20 | } 21 | if (st.size > 0) { 22 | process.exit(0) 23 | } 24 | } 25 | // Either the file didn't exist, or it got truncated 26 | fs.mkdir(dir, (er) => { 27 | if (er && (er.code !== 'EEXIST')) { 28 | console.error(`Error creating ${dir}: ${er.message}`) 29 | process.exit(1) 30 | } 31 | fs.copyFile(src, dst, (er) => { 32 | if (er) { 33 | console.error(`Error: ${er.message}`) 34 | process.exit(1) 35 | } 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Joe Hildebrand 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feedly", 3 | "version": "0.1.6", 4 | "description": "feedly.com API", 5 | "main": "lib/feedly.js", 6 | "scripts": { 7 | "clean": "rm -rf coverage doc", 8 | "lint": "standard lib/*.js test/*.js", 9 | "doc": "jsdoc -c .jsdoc.conf", 10 | "coverage": "nyc npm test", 11 | "postcoverage": "nyc report -r html", 12 | "test": "ava test/*.js", 13 | "watch": "watch 'npm run coverage' src/", 14 | "release": "npm version patch && git push --follow-tags && npm publish", 15 | "live": "live-server --port=4001 --ignorePattern='(js|css|png)$' coverage", 16 | "predev": "node tools/copyif.js", 17 | "dev": "npm-run-all -p --silent watch live" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/hildjj/node-feedly.git" 22 | }, 23 | "keywords": [ 24 | "API", 25 | "atom", 26 | "rss", 27 | "feedly", 28 | "news" 29 | ], 30 | "author": { 31 | "name": "Joe Hildebrand", 32 | "email": "joe-github@cursive.net" 33 | }, 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/hildjj/node-feedly/issues" 37 | }, 38 | "homepage": "https://github.com/hildjj/node-feedly", 39 | "devDependencies": { 40 | "ava": "latest", 41 | "jsdoc": "latest", 42 | "live-server": "latest", 43 | "minami": "latest", 44 | "npm-run-all": "latest", 45 | "nyc": "latest", 46 | "standard": "latest", 47 | "taffydb": "2.7.3", 48 | "watch": "latest" 49 | }, 50 | "dependencies": { 51 | "nofilter": "5.0.0", 52 | "opn": "^6.0.0", 53 | "request": "^2.88", 54 | "untildify": "^5.0" 55 | }, 56 | "packageManager": "pnpm@9.15.0", 57 | "engines": { 58 | "node": ">=18.8" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a node API for [Feedly](http://developer.feedly.com) 2 | 3 | Installation 4 | ============ 5 | 6 | Install from NPM: 7 | 8 | npm install --save feedly 9 | 10 | Creating an instance 11 | ==================== 12 | 13 | Create an instance: 14 | 15 | const Feedly = require('feedly') 16 | 17 | const f = new Feedly({ 18 | client_id: 'MY_CLIENT_ID', 19 | client_secret: 'MY_CLIENT_SECRET', 20 | port: 8080 21 | }) 22 | 23 | Use the sandbox: 24 | 25 | const Feedly = require('feedly') 26 | 27 | const f = new Feedly({ 28 | client_id: 'sandbox', 29 | client_secret: 'Get the current secret from http://developer.feedly.com/v3/sandbox/', 30 | base: 'http://sandbox.feedly.com', 31 | port: 8080 32 | }) 33 | 34 | Authentication 35 | ============== 36 | 37 | The first non-trivial method call you make to the object will cause your 38 | default browser to pop up asking you to log in. When that process is complete, 39 | you will see a page served from http://localhost:8080/, which you can close. 40 | After that point, you won't need to log in again until your token expires 41 | (without your having called `refresh` in the meantime). 42 | 43 | **WARNING**: by default, this will store your auth token and refresh token in 44 | `~/.feedly`, unencrypted. Set the `config_file` options to null to prevent this 45 | behavior, but you will have to log in through the web site each time you create 46 | a new `Feedly` instance. 47 | 48 | Callbacks and promises 49 | ====================== 50 | 51 | Each method takes an optional node-style `(error, results)` callback. If you 52 | prefer a promise-style approach, you do without a callback, like this: 53 | 54 | const results = await f.reads() 55 | 56 | 57 | Documentation 58 | ============= 59 | 60 | Full documentation for the API can be found 61 | [here](http://hildjj.github.io/node-feedly/doc/). 62 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const http = require('http') 3 | 4 | const request = require('request') 5 | const NoFilter = require('nofilter') 6 | 7 | module.exports = class Utils { 8 | static qserver (port, text, opener) { 9 | return new Promise((resolve, reject) => { 10 | const server = http.createServer(function (req, res) { 11 | const u = new URL(req.url, true) 12 | if (u.pathname === '/') { 13 | const bs = new NoFilter() 14 | res.writeHead(200, 15 | { 'Content-Type': 'text/html' }) 16 | bs.on('finish', function () { 17 | resolve([u.query, bs.toString('utf8')]) 18 | return server.close() 19 | }) 20 | req.pipe(bs) 21 | res.end(text) 22 | } else { 23 | res.writeHead(404) 24 | res.end() 25 | } 26 | return req.connection.destroy() 27 | }) 28 | 29 | server.on('error', function (er) { 30 | // is this needed? Find an error to test. 31 | return server.close() 32 | }) 33 | 34 | server.listen(port, function () { 35 | const a = server.address() 36 | opener(`http://localhost:${a.port}`) 37 | }) 38 | }) 39 | } 40 | 41 | static qrequest (options) { 42 | if ((options == null)) { 43 | throw new Error('options not optional') 44 | } 45 | 46 | const cb = options.callback 47 | delete options.callback 48 | 49 | let prom = new Promise((resolve, reject) => { 50 | options.json = true 51 | request(options, function (er, res, body) { 52 | if (er != null) { 53 | return reject(er) 54 | } else if (res.statusCode !== 200) { 55 | return reject(new Error(`HTTP error: ${res.statusCode}\nFrom: ${options.uri}\n${JSON.stringify(body)}`)) 56 | } else { 57 | return resolve(body) 58 | } 59 | }) 60 | }) 61 | if (typeof cb === 'function') { 62 | prom = prom.then(r => cb(null, r), cb) 63 | } 64 | 65 | return prom 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /docs/styles/prettify-jsdoc.css: -------------------------------------------------------------------------------- 1 | /* JSDoc prettify.js theme */ 2 | 3 | /* plain text */ 4 | .pln { 5 | color: #000000; 6 | font-weight: normal; 7 | font-style: normal; 8 | } 9 | 10 | /* string content */ 11 | .str { 12 | color: hsl(104, 100%, 24%); 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | 17 | /* a keyword */ 18 | .kwd { 19 | color: #000000; 20 | font-weight: bold; 21 | font-style: normal; 22 | } 23 | 24 | /* a comment */ 25 | .com { 26 | font-weight: normal; 27 | font-style: italic; 28 | } 29 | 30 | /* a type name */ 31 | .typ { 32 | color: #000000; 33 | font-weight: normal; 34 | font-style: normal; 35 | } 36 | 37 | /* a literal value */ 38 | .lit { 39 | color: #006400; 40 | font-weight: normal; 41 | font-style: normal; 42 | } 43 | 44 | /* punctuation */ 45 | .pun { 46 | color: #000000; 47 | font-weight: bold; 48 | font-style: normal; 49 | } 50 | 51 | /* lisp open bracket */ 52 | .opn { 53 | color: #000000; 54 | font-weight: bold; 55 | font-style: normal; 56 | } 57 | 58 | /* lisp close bracket */ 59 | .clo { 60 | color: #000000; 61 | font-weight: bold; 62 | font-style: normal; 63 | } 64 | 65 | /* a markup tag name */ 66 | .tag { 67 | color: #006400; 68 | font-weight: normal; 69 | font-style: normal; 70 | } 71 | 72 | /* a markup attribute name */ 73 | .atn { 74 | color: #006400; 75 | font-weight: normal; 76 | font-style: normal; 77 | } 78 | 79 | /* a markup attribute value */ 80 | .atv { 81 | color: #006400; 82 | font-weight: normal; 83 | font-style: normal; 84 | } 85 | 86 | /* a declaration */ 87 | .dec { 88 | color: #000000; 89 | font-weight: bold; 90 | font-style: normal; 91 | } 92 | 93 | /* a variable name */ 94 | .var { 95 | color: #000000; 96 | font-weight: normal; 97 | font-style: normal; 98 | } 99 | 100 | /* a function name */ 101 | .fun { 102 | color: #000000; 103 | font-weight: bold; 104 | font-style: normal; 105 | } 106 | 107 | /* Specify class=linenums on a pre to get line numbering */ 108 | ol.linenums { 109 | margin-top: 0; 110 | margin-bottom: 0; 111 | } 112 | -------------------------------------------------------------------------------- /docs/styles/prettify-tomorrow.css: -------------------------------------------------------------------------------- 1 | /* Tomorrow Theme */ 2 | /* Original theme - https://github.com/chriskempson/tomorrow-theme */ 3 | /* Pretty printing styles. Used with prettify.js. */ 4 | /* SPAN elements with the classes below are added by prettyprint. */ 5 | /* plain text */ 6 | .pln { 7 | color: #4d4d4c; } 8 | 9 | @media screen { 10 | /* string content */ 11 | .str { 12 | color: hsl(104, 100%, 24%); } 13 | 14 | /* a keyword */ 15 | .kwd { 16 | color: hsl(240, 100%, 50%); } 17 | 18 | /* a comment */ 19 | .com { 20 | color: hsl(0, 0%, 60%); } 21 | 22 | /* a type name */ 23 | .typ { 24 | color: hsl(240, 100%, 32%); } 25 | 26 | /* a literal value */ 27 | .lit { 28 | color: hsl(240, 100%, 40%); } 29 | 30 | /* punctuation */ 31 | .pun { 32 | color: #000000; } 33 | 34 | /* lisp open bracket */ 35 | .opn { 36 | color: #000000; } 37 | 38 | /* lisp close bracket */ 39 | .clo { 40 | color: #000000; } 41 | 42 | /* a markup tag name */ 43 | .tag { 44 | color: #c82829; } 45 | 46 | /* a markup attribute name */ 47 | .atn { 48 | color: #f5871f; } 49 | 50 | /* a markup attribute value */ 51 | .atv { 52 | color: #3e999f; } 53 | 54 | /* a declaration */ 55 | .dec { 56 | color: #f5871f; } 57 | 58 | /* a variable name */ 59 | .var { 60 | color: #c82829; } 61 | 62 | /* a function name */ 63 | .fun { 64 | color: #4271ae; } } 65 | /* Use higher contrast and text-weight for printable form. */ 66 | @media print, projection { 67 | .str { 68 | color: #060; } 69 | 70 | .kwd { 71 | color: #006; 72 | font-weight: bold; } 73 | 74 | .com { 75 | color: #600; 76 | font-style: italic; } 77 | 78 | .typ { 79 | color: #404; 80 | font-weight: bold; } 81 | 82 | .lit { 83 | color: #044; } 84 | 85 | .pun, .opn, .clo { 86 | color: #440; } 87 | 88 | .tag { 89 | color: #006; 90 | font-weight: bold; } 91 | 92 | .atn { 93 | color: #404; } 94 | 95 | .atv { 96 | color: #060; } } 97 | /* Style */ 98 | /* 99 | pre.prettyprint { 100 | background: white; 101 | font-family: Consolas, Monaco, 'Andale Mono', monospace; 102 | font-size: 12px; 103 | line-height: 1.5; 104 | border: 1px solid #ccc; 105 | padding: 10px; } 106 | */ 107 | 108 | /* Specify class=linenums on a pre to get line numbering */ 109 | ol.linenums { 110 | margin-top: 0; 111 | margin-bottom: 0; } 112 | 113 | /* IE indents via margin-left */ 114 | li.L0, 115 | li.L1, 116 | li.L2, 117 | li.L3, 118 | li.L4, 119 | li.L5, 120 | li.L6, 121 | li.L7, 122 | li.L8, 123 | li.L9 { 124 | /* */ } 125 | 126 | /* Alternate shading for lines */ 127 | li.L1, 128 | li.L3, 129 | li.L5, 130 | li.L7, 131 | li.L9 { 132 | /* */ } 133 | -------------------------------------------------------------------------------- /docs/module.exports.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | exports - Documentation 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 29 | 30 |
31 | 32 |

exports

33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 |
43 | 44 |

45 | exports 46 |

47 | 48 |
Talk to the Feedly API. 49 | All methods will ensure a valid authentication dance has occurred, 50 | and perform the dance if necessary. 51 | 52 | All of the methods that take a callback also return 53 | a promise - the callback is therefore optional. 54 | 55 | WARNING: by default, this class stores state information such 56 | as your access token in ~/.feedly by default.
57 | 58 | 59 |
60 | 61 |
62 |
63 | 64 | 65 |
66 | 67 | 68 |

Constructor

69 | 70 | 71 |

new exports()

72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
Source:
109 |
112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |
120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 |
144 | 145 |
146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 |
165 | 166 |
167 | 168 | 169 | 170 | 171 |
172 | 173 |
174 | 175 | 178 | 179 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /test/feedly.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const Feedly = require('../lib/feedly') 5 | const test = require('ava') 6 | const util = require('util') 7 | const unlink = util.promisify(fs.unlink) 8 | const path = require('path') 9 | 10 | const { FEEDLY_SECRET } = process.env 11 | if ((FEEDLY_SECRET == null)) { 12 | throw new Error(`\ 13 | Specify the client secret in the FEEDLY_SECRET environment variable 14 | Find it here: https://groups.google.com/forum/#!forum/feedly-cloud`) 15 | } 16 | 17 | const FEED_URL = 'https://www.tbray.org/ongoing/ongoing.atom' 18 | const FEED = `feed/${FEED_URL}` 19 | const CONFIG = path.join(__dirname, 'test_config.json') 20 | const SANDBOX = 'sandbox7' 21 | const SAFE = ['id', 'name'] 22 | 23 | function pick (o, ...props) { 24 | const ret = {} 25 | if (Array.isArray(props[0])) { 26 | props = props[0] 27 | } 28 | for (const p of props) { 29 | if (Object.prototype.hasOwnProperty.hasOwnProperty.call(o, p)) { 30 | ret[p] = o[p] 31 | } 32 | } 33 | return ret 34 | } 35 | 36 | test('feeds', async t => { 37 | const f = new Feedly({ 38 | client_id: 'sandbox', 39 | client_secret: FEEDLY_SECRET, 40 | base: `http://${SANDBOX}.feedly.com`, 41 | port: 8080, 42 | config_file: CONFIG 43 | }) 44 | t.truthy(f) 45 | await f.ready 46 | t.deepEqual(f.options.base, `http://${SANDBOX}.feedly.com`) 47 | const profile = await f.profile() 48 | t.truthy(profile) 49 | await f.updateProfile({ 50 | gender: 'male' 51 | }) 52 | const prefs = await f.preferences() 53 | t.truthy(prefs) 54 | await f.updatePreferences({ 55 | 'category/reviews/entryOverviewSize': 0 56 | }) 57 | 58 | await f.updatePreferences({ 59 | 'category/reviews/entryOverviewSize': '==DELETE==' 60 | }) 61 | const tok = await f.refresh() 62 | t.is(typeof tok, 'string') 63 | 64 | // await f.unsubscribe(FEED) 65 | let sub = await f.subscribe(FEED) 66 | t.is(sub.length, 1) 67 | const sub1 = pick(sub[0], SAFE) 68 | const subs = await f.subscriptions() 69 | t.truthy(subs) 70 | t.truthy(subs.length > 0) 71 | const sub2 = pick(subs.find(s => s.id === FEED), SAFE) 72 | t.deepEqual(sub1, sub2) 73 | await f.unsubscribe(FEED) 74 | 75 | sub = await f.subscribe(FEED_URL, ['testing_foo', 'testing_bar']) 76 | const fee = await f.feed(FEED) 77 | t.is(fee.id, FEED) 78 | const cats = await f.categories() 79 | 80 | const labels = new Set(cats.map(c => c.label)) 81 | t.truthy(labels.has('testing_foo')) 82 | t.truthy(labels.has('testing_bar')) 83 | const foo = cats.find(c => c.label === 'testing_foo') 84 | t.truthy(foo) 85 | await f.setCategoryLabel(foo.id, 'testing_foo2') 86 | await f.deleteCategory(foo.id) 87 | await t.throwsAsync(f.setCategoryLabel(foo.id, 'testing_foo3')) 88 | 89 | // also test callbacks 90 | await new Promise((resolve, reject) => { 91 | f.counts((er, counts) => { 92 | t.falsy(er) 93 | t.truthy(Array.isArray(counts.unreadcounts)) 94 | t.truthy(counts.unreadcounts.length >= 2) 95 | resolve() 96 | }) 97 | }) 98 | 99 | const page = await f.stream(FEED) 100 | const ent = page.ids[0] 101 | const entries = await f.entry(ent) 102 | t.truthy(Array.isArray(entries)) 103 | t.truthy(entries.length > 0) 104 | const { id } = entries[0] 105 | await f.markEntryRead(id) 106 | 107 | await f.tagEntry(id, 'test_tag_foo') 108 | const tags = await f.tags() 109 | t.truthy(tags) 110 | await f.untagEntries(id, 'test_tag_foo') 111 | await f.setTagLabel('test_tag_foo', 'test_tag_foo2') 112 | await f.untagEntries(id, 'test_tag_foo') 113 | await f.deleteTags('test_tag_foo') 114 | const short = await f.shorten(id) 115 | t.truthy(short) 116 | t.is(typeof short.shortUrl, 'string') 117 | 118 | let contents = await f.contents(FEED) 119 | t.truthy(Array.isArray(contents.items)) 120 | t.truthy(contents.continuation) 121 | contents = await f.contents(FEED, contents.continuation) 122 | t.truthy(contents.items) 123 | await f.markFeedRead(FEED) 124 | await f.markCategoryRead('testing_bar') 125 | const reads = await f.reads() 126 | t.truthy(reads) 127 | t.truthy(Array.isArray(reads.entries)) 128 | const results = await f.searchFeeds('arduino') 129 | t.truthy(results) 130 | t.truthy(Array.isArray(results.results)) 131 | const userid = f.state.id 132 | 133 | const [entry] = await f.createEntry({ 134 | title: 'NBC\'s reviled sci-fi drama \'Heroes\' may get a second lease', 135 | author: 'Nathan Ingraham', 136 | origin: { 137 | title: 'The Verge - All Posts', 138 | htmlUrl: 'http://www.theverge.com/' 139 | }, 140 | 141 | content: { 142 | direction: 'ltr', 143 | content: '...html content the user wants to associate with this entry..' 144 | }, 145 | 146 | alternate: [{ 147 | type: 'text/html', 148 | href: 'http://www.theverge.com/2013/4/17/4236096/nbc-heroes-may-get-a-second-lease-on-life-on-xbox-live' 149 | } 150 | ], 151 | tags: [ 152 | { 153 | id: `user/${userid}/tag/global.saved` 154 | }, 155 | { 156 | id: `user/${userid}/tag/inspiration`, 157 | label: 'inspiration' 158 | } 159 | ], 160 | keywords: [ 161 | 'NBC', 162 | 'sci-fi' 163 | ] 164 | }) 165 | const newEnt = await f.entry(entry) 166 | t.truthy(Array.isArray(newEnt)) 167 | t.truthy(newEnt.length) 168 | // No DELETE in API 169 | 170 | // cleanup 171 | for (const c of cats) { 172 | try { 173 | await f.deleteCategory(c.id) 174 | } catch (e) {} 175 | } 176 | 177 | await f.unsubscribe(FEED) 178 | await f.logout() 179 | }) 180 | 181 | test.after(async t => { 182 | try { 183 | await unlink(CONFIG) 184 | } catch (e) { 185 | console.warn(`Could not unlink '${CONFIG}'`) 186 | } 187 | }) 188 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Home - Documentation 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 29 | 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 |

This is a node API for Feedly

52 |

Installation

53 |

Install from NPM:

54 |
npm install --save feedly
 55 | 
56 |

Creating an instance

57 |

Create an instance:

58 |
const Feedly = require('feedly')
 59 | 
 60 | const f = new Feedly({
 61 |   client_id: 'MY_CLIENT_ID',
 62 |   client_secret: 'MY_CLIENT_SECRET',
 63 |   port: 8080
 64 | })
 65 | 
66 |

Use the sandbox:

67 |
const Feedly = require('feedly')
 68 | 
 69 | const f = new Feedly({
 70 |   client_id: 'sandbox',
 71 |   client_secret: 'Get the current secret from http://developer.feedly.com/v3/sandbox/',
 72 |   base: 'http://sandbox.feedly.com',
 73 |   port: 8080
 74 | })
 75 | 
76 |

Authentication

77 |

The first non-trivial method call you make to the object will cause your 78 | default browser to pop up asking you to log in. When that process is complete, 79 | you will see a page served from http://localhost:8080/, which you can close. 80 | After that point, you won't need to log in again until your token expires 81 | (without your having called refresh in the meantime).

82 |

WARNING: by default, this will store your auth token and refresh token in
83 | ~/.feedly, unencrypted. Set the config_file options to null to prevent this 84 | behavior, but you will have to log in through the web site each time you create 85 | a new Feedly instance.

86 |

Callbacks and promises

87 |

Each method takes an optional node-style (error, results) callback. If you 88 | prefer a promise-style approach, you do without a callback, like this:

89 |
const results = await f.reads()
 90 | 
91 |

Documentation

92 |

Full documentation for the API can be found 93 | here.

94 |
95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 | 103 |
104 | 105 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /docs/global.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Global - Documentation 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 29 | 30 |
31 | 32 |

Global

33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
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 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 |
93 | 94 | 95 | 96 | 97 |
98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 |

Type Definitions

115 | 116 | 117 | 118 |
119 | 120 | 121 | 122 |

errorCallback(error)

123 | 124 | 125 | 126 | 127 | 128 |
129 | Callback with an error. 130 |
131 | 132 | 133 | 134 | 135 | 136 |
137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 |
Source:
164 |
167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 |
175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 |
Parameters:
185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 220 | 221 | 222 | 223 | 224 | 225 | 229 | 230 | 231 | 232 | 233 |
NameTypeDescription
error 213 | 214 | 215 | Error 216 | 217 | 218 | 219 | 226 | null if no error 227 | 228 |
234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 |
251 | 252 | 253 |
254 | 255 | 256 | 257 |

stringCallback(error, str)

258 | 259 | 260 | 261 | 262 | 263 |
264 | Callback with an error or string. 265 |
266 | 267 | 268 | 269 | 270 | 271 |
272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 |
Source:
299 |
302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 |
310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 |
Parameters:
320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 355 | 356 | 357 | 358 | 359 | 360 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 381 | 382 | 383 | 384 | 385 | 386 | 390 | 391 | 392 | 393 | 394 |
NameTypeDescription
error 348 | 349 | 350 | Error 351 | 352 | 353 | 354 | 361 | null if no error 362 | 363 |
str 374 | 375 | 376 | String 377 | 378 | 379 | 380 | 387 | the returned string if no error 388 | 389 |
395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 |
412 | 413 | 414 | 415 | 416 |
417 | 418 |
419 | 420 | 421 | 422 | 423 |
424 | 425 |
426 | 427 | 430 | 431 | 432 | 433 | 434 | -------------------------------------------------------------------------------- /docs/scripts/prettify/Apache-License-2.0.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /docs/scripts/prettify/prettify.js: -------------------------------------------------------------------------------- 1 | var q=null;window.PR_SHOULD_USE_CONTINUATION=!0; 2 | (function(){function L(a){function m(a){var f=a.charCodeAt(0);if(f!==92)return f;var b=a.charAt(1);return(f=r[b])?f:"0"<=b&&b<="7"?parseInt(a.substring(1),8):b==="u"||b==="x"?parseInt(a.substring(2),16):a.charCodeAt(1)}function e(a){if(a<32)return(a<16?"\\x0":"\\x")+a.toString(16);a=String.fromCharCode(a);if(a==="\\"||a==="-"||a==="["||a==="]")a="\\"+a;return a}function h(a){for(var f=a.substring(1,a.length-1).match(/\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\S\s]|[^\\]/g),a= 3 | [],b=[],o=f[0]==="^",c=o?1:0,i=f.length;c122||(d<65||j>90||b.push([Math.max(65,j)|32,Math.min(d,90)|32]),d<97||j>122||b.push([Math.max(97,j)&-33,Math.min(d,122)&-33]))}}b.sort(function(a,f){return a[0]-f[0]||f[1]-a[1]});f=[];j=[NaN,NaN];for(c=0;ci[0]&&(i[1]+1>i[0]&&b.push("-"),b.push(e(i[1])));b.push("]");return b.join("")}function y(a){for(var f=a.source.match(/\[(?:[^\\\]]|\\[\S\s])*]|\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\\d+|\\[^\dux]|\(\?[!:=]|[()^]|[^()[\\^]+/g),b=f.length,d=[],c=0,i=0;c=2&&a==="["?f[c]=h(j):a!=="\\"&&(f[c]=j.replace(/[A-Za-z]/g,function(a){a=a.charCodeAt(0);return"["+String.fromCharCode(a&-33,a|32)+"]"}));return f.join("")}for(var t=0,s=!1,l=!1,p=0,d=a.length;p=5&&"lang-"===b.substring(0,5))&&!(o&&typeof o[1]==="string"))c=!1,b="src";c||(r[f]=b)}i=d;d+=f.length;if(c){c=o[1];var j=f.indexOf(c),k=j+c.length;o[2]&&(k=f.length-o[2].length,j=k-c.length);b=b.substring(5);B(l+i,f.substring(0,j),e,p);B(l+i+j,c,C(b,c),p);B(l+i+k,f.substring(k),e,p)}else p.push(l+i,b)}a.e=p}var h={},y;(function(){for(var e=a.concat(m), 9 | l=[],p={},d=0,g=e.length;d=0;)h[n.charAt(k)]=r;r=r[1];n=""+r;p.hasOwnProperty(n)||(l.push(r),p[n]=q)}l.push(/[\S\s]/);y=L(l)})();var t=m.length;return e}function u(a){var m=[],e=[];a.tripleQuotedStrings?m.push(["str",/^(?:'''(?:[^'\\]|\\[\S\s]|''?(?=[^']))*(?:'''|$)|"""(?:[^"\\]|\\[\S\s]|""?(?=[^"]))*(?:"""|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$))/,q,"'\""]):a.multiLineStrings?m.push(["str",/^(?:'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$)|`(?:[^\\`]|\\[\S\s])*(?:`|$))/, 10 | q,"'\"`"]):m.push(["str",/^(?:'(?:[^\n\r'\\]|\\.)*(?:'|$)|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,q,"\"'"]);a.verbatimStrings&&e.push(["str",/^@"(?:[^"]|"")*(?:"|$)/,q]);var h=a.hashComments;h&&(a.cStyleComments?(h>1?m.push(["com",/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,q,"#"]):m.push(["com",/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\n\r]*)/,q,"#"]),e.push(["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,q])):m.push(["com",/^#[^\n\r]*/, 11 | q,"#"]));a.cStyleComments&&(e.push(["com",/^\/\/[^\n\r]*/,q]),e.push(["com",/^\/\*[\S\s]*?(?:\*\/|$)/,q]));a.regexLiterals&&e.push(["lang-regex",/^(?:^^\.?|[!+-]|!=|!==|#|%|%=|&|&&|&&=|&=|\(|\*|\*=|\+=|,|-=|->|\/|\/=|:|::|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|[?@[^]|\^=|\^\^|\^\^=|{|\||\|=|\|\||\|\|=|~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\s*(\/(?=[^*/])(?:[^/[\\]|\\[\S\s]|\[(?:[^\\\]]|\\[\S\s])*(?:]|$))+\/)/]);(h=a.types)&&e.push(["typ",h]);a=(""+a.keywords).replace(/^ | $/g, 12 | "");a.length&&e.push(["kwd",RegExp("^(?:"+a.replace(/[\s,]+/g,"|")+")\\b"),q]);m.push(["pln",/^\s+/,q," \r\n\t\xa0"]);e.push(["lit",/^@[$_a-z][\w$@]*/i,q],["typ",/^(?:[@_]?[A-Z]+[a-z][\w$@]*|\w+_t\b)/,q],["pln",/^[$_a-z][\w$@]*/i,q],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,q,"0123456789"],["pln",/^\\[\S\s]?/,q],["pun",/^.[^\s\w"-$'./@\\`]*/,q]);return x(m,e)}function D(a,m){function e(a){switch(a.nodeType){case 1:if(k.test(a.className))break;if("BR"===a.nodeName)h(a), 13 | a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)e(a);break;case 3:case 4:if(p){var b=a.nodeValue,d=b.match(t);if(d){var c=b.substring(0,d.index);a.nodeValue=c;(b=b.substring(d.index+d[0].length))&&a.parentNode.insertBefore(s.createTextNode(b),a.nextSibling);h(a);c||a.parentNode.removeChild(a)}}}}function h(a){function b(a,d){var e=d?a.cloneNode(!1):a,f=a.parentNode;if(f){var f=b(f,1),g=a.nextSibling;f.appendChild(e);for(var h=g;h;h=g)g=h.nextSibling,f.appendChild(h)}return e} 14 | for(;!a.nextSibling;)if(a=a.parentNode,!a)return;for(var a=b(a.nextSibling,0),e;(e=a.parentNode)&&e.nodeType===1;)a=e;d.push(a)}var k=/(?:^|\s)nocode(?:\s|$)/,t=/\r\n?|\n/,s=a.ownerDocument,l;a.currentStyle?l=a.currentStyle.whiteSpace:window.getComputedStyle&&(l=s.defaultView.getComputedStyle(a,q).getPropertyValue("white-space"));var p=l&&"pre"===l.substring(0,3);for(l=s.createElement("LI");a.firstChild;)l.appendChild(a.firstChild);for(var d=[l],g=0;g=0;){var h=m[e];A.hasOwnProperty(h)?window.console&&console.warn("cannot override language handler %s",h):A[h]=a}}function C(a,m){if(!a||!A.hasOwnProperty(a))a=/^\s*=o&&(h+=2);e>=c&&(a+=2)}}catch(w){"console"in window&&console.log(w&&w.stack?w.stack:w)}}var v=["break,continue,do,else,for,if,return,while"],w=[[v,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"], 18 | "catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],F=[w,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"],G=[w,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"], 19 | H=[G,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"],w=[w,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"],I=[v,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"], 20 | J=[v,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],v=[v,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"],K=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/,N=/\S/,O=u({keywords:[F,H,w,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END"+ 21 | I,J,v],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),A={};k(O,["default-code"]);k(x([],[["pln",/^[^]*(?:>|$)/],["com",/^<\!--[\S\s]*?(?:--\>|$)/],["lang-",/^<\?([\S\s]+?)(?:\?>|$)/],["lang-",/^<%([\S\s]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\S\s]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\S\s]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\S\s]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]), 22 | ["default-markup","htm","html","mxml","xhtml","xml","xsl"]);k(x([["pln",/^\s+/,q," \t\r\n"],["atv",/^(?:"[^"]*"?|'[^']*'?)/,q,"\"'"]],[["tag",/^^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^\s"'>]*(?:[^\s"'/>]|\/(?=\s)))/],["pun",/^[/<->]+/],["lang-js",/^on\w+\s*=\s*"([^"]+)"/i],["lang-js",/^on\w+\s*=\s*'([^']+)'/i],["lang-js",/^on\w+\s*=\s*([^\s"'>]+)/i],["lang-css",/^style\s*=\s*"([^"]+)"/i],["lang-css",/^style\s*=\s*'([^']+)'/i],["lang-css", 23 | /^style\s*=\s*([^\s"'>]+)/i]]),["in.tag"]);k(x([],[["atv",/^[\S\s]+/]]),["uq.val"]);k(u({keywords:F,hashComments:!0,cStyleComments:!0,types:K}),["c","cc","cpp","cxx","cyc","m"]);k(u({keywords:"null,true,false"}),["json"]);k(u({keywords:H,hashComments:!0,cStyleComments:!0,verbatimStrings:!0,types:K}),["cs"]);k(u({keywords:G,cStyleComments:!0}),["java"]);k(u({keywords:v,hashComments:!0,multiLineStrings:!0}),["bsh","csh","sh"]);k(u({keywords:I,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}), 24 | ["cv","py"]);k(u({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["perl","pl","pm"]);k(u({keywords:J,hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb"]);k(u({keywords:w,cStyleComments:!0,regexLiterals:!0}),["js"]);k(u({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes", 25 | hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0,regexLiterals:!0}),["coffee"]);k(x([],[["str",/^[\S\s]+/]]),["regex"]);window.prettyPrintOne=function(a,m,e){var h=document.createElement("PRE");h.innerHTML=a;e&&D(h,e);E({g:m,i:e,h:h});return h.innerHTML};window.prettyPrint=function(a){function m(){for(var e=window.PR_SHOULD_USE_CONTINUATION?l.now()+250:Infinity;p=0){var k=k.match(g),f,b;if(b= 26 | !k){b=n;for(var o=void 0,c=b.firstChild;c;c=c.nextSibling)var i=c.nodeType,o=i===1?o?b:c:i===3?N.test(c.nodeValue)?b:o:o;b=(f=o===b?void 0:o)&&"CODE"===f.tagName}b&&(k=f.className.match(g));k&&(k=k[1]);b=!1;for(o=n.parentNode;o;o=o.parentNode)if((o.tagName==="pre"||o.tagName==="code"||o.tagName==="xmp")&&o.className&&o.className.indexOf("prettyprint")>=0){b=!0;break}b||((b=(b=n.className.match(/\blinenums\b(?::(\d+))?/))?b[1]&&b[1].length?+b[1]:!0:!1)&&D(n,b),d={g:k,h:n,i:b},E(d))}}p code { 197 | font-size: 0.85em; 198 | } 199 | 200 | .readme table { 201 | margin-bottom: 1em; 202 | border-collapse: collapse; 203 | border-spacing: 0; 204 | } 205 | 206 | .readme table tr { 207 | background-color: #fff; 208 | border-top: 1px solid #ccc; 209 | } 210 | 211 | .readme table th, 212 | .readme table td { 213 | padding: 6px 13px; 214 | border: 1px solid #ddd; 215 | } 216 | 217 | .readme table tr:nth-child(2n) { 218 | background-color: #f8f8f8; 219 | } 220 | 221 | /** Nav **/ 222 | nav { 223 | float: left; 224 | display: block; 225 | width: 250px; 226 | background: #fff; 227 | overflow: auto; 228 | position: fixed; 229 | height: 100%; 230 | padding: 10px; 231 | border-right: 1px solid #eee; 232 | /* box-shadow: 0 0 3px rgba(0,0,0,0.1); */ 233 | } 234 | 235 | nav li { 236 | list-style: none; 237 | padding: 0; 238 | margin: 0; 239 | } 240 | 241 | .nav-heading { 242 | margin-top: 10px; 243 | font-weight: bold; 244 | } 245 | 246 | .nav-heading a { 247 | color: #888; 248 | font-size: 14px; 249 | display: inline-block; 250 | } 251 | 252 | .nav-item-type { 253 | /* margin-left: 5px; */ 254 | width: 18px; 255 | height: 18px; 256 | display: inline-block; 257 | text-align: center; 258 | border-radius: 0.2em; 259 | margin-right: 5px; 260 | font-weight: bold; 261 | line-height: 20px; 262 | font-size: 13px; 263 | } 264 | 265 | .type-function { 266 | background: #B3E5FC; 267 | color: #0288D1; 268 | } 269 | 270 | .type-class { 271 | background: #D1C4E9; 272 | color: #4527A0; 273 | } 274 | 275 | .type-member { 276 | background: #C8E6C9; 277 | color: #388E3C; 278 | } 279 | 280 | .type-module { 281 | background: #E1BEE7; 282 | color: #7B1FA2; 283 | } 284 | 285 | 286 | /** Footer **/ 287 | footer { 288 | color: hsl(0, 0%, 28%); 289 | margin-left: 250px; 290 | display: block; 291 | padding: 30px; 292 | font-style: italic; 293 | font-size: 90%; 294 | border-top: 1px solid #eee; 295 | } 296 | 297 | .ancestors { 298 | color: #999 299 | } 300 | 301 | .ancestors a { 302 | color: #999 !important; 303 | text-decoration: none; 304 | } 305 | 306 | .clear { 307 | clear: both 308 | } 309 | 310 | .important { 311 | font-weight: bold; 312 | color: #950B02; 313 | } 314 | 315 | .yes-def { 316 | text-indent: -1000px 317 | } 318 | 319 | .type-signature { 320 | color: #aaa 321 | } 322 | 323 | .name, .signature { 324 | font-family: Consolas, Monaco, 'Andale Mono', monospace 325 | } 326 | 327 | .details { 328 | margin-top: 14px; 329 | border-left: 2px solid #DDD; 330 | line-height: 30px; 331 | } 332 | 333 | .details dt { 334 | width: 120px; 335 | float: left; 336 | padding-left: 10px; 337 | } 338 | 339 | .details dd { 340 | margin-left: 70px 341 | } 342 | 343 | .details ul { 344 | margin: 0 345 | } 346 | 347 | .details ul { 348 | list-style-type: none 349 | } 350 | 351 | .details li { 352 | margin-left: 30px 353 | } 354 | 355 | .details pre.prettyprint { 356 | margin: 0 357 | } 358 | 359 | .details .object-value { 360 | padding-top: 0 361 | } 362 | 363 | .description { 364 | margin-bottom: 1em; 365 | margin-top: 1em; 366 | } 367 | 368 | .code-caption { 369 | font-style: italic; 370 | font-size: 107%; 371 | margin: 0; 372 | } 373 | 374 | .prettyprint { 375 | font-size: 13px; 376 | border: 1px solid #ddd; 377 | border-radius: 3px; 378 | box-shadow: 0 1px 3px hsla(0, 0%, 0%, 0.05); 379 | overflow: auto; 380 | } 381 | 382 | .prettyprint.source { 383 | width: inherit 384 | } 385 | 386 | .prettyprint code { 387 | font-size: 12px; 388 | line-height: 18px; 389 | display: block; 390 | background-color: #fff; 391 | color: #4D4E53; 392 | } 393 | 394 | .prettyprint code:empty:before { 395 | content: ''; 396 | } 397 | 398 | .prettyprint > code { 399 | padding: 15px 400 | } 401 | 402 | .prettyprint .linenums code { 403 | padding: 0 15px 404 | } 405 | 406 | .prettyprint .linenums li:first-of-type code { 407 | padding-top: 15px 408 | } 409 | 410 | .prettyprint code span.line { 411 | display: inline-block 412 | } 413 | 414 | .prettyprint.linenums { 415 | padding-left: 70px; 416 | -webkit-user-select: none; 417 | -moz-user-select: none; 418 | -ms-user-select: none; 419 | user-select: none; 420 | } 421 | 422 | .prettyprint.linenums ol { 423 | padding-left: 0 424 | } 425 | 426 | .prettyprint.linenums li { 427 | border-left: 3px #ddd solid 428 | } 429 | 430 | .prettyprint.linenums li.selected, .prettyprint.linenums li.selected * { 431 | background-color: lightyellow 432 | } 433 | 434 | .prettyprint.linenums li * { 435 | -webkit-user-select: text; 436 | -moz-user-select: text; 437 | -ms-user-select: text; 438 | user-select: text; 439 | } 440 | 441 | .params, .props { 442 | border-spacing: 0; 443 | border: 1px solid #ddd; 444 | border-collapse: collapse; 445 | border-radius: 3px; 446 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); 447 | width: 100%; 448 | font-size: 14px; 449 | /* margin-left: 15px; */ 450 | } 451 | 452 | .params .name, .props .name, .name code { 453 | color: #4D4E53; 454 | font-family: Consolas, Monaco, 'Andale Mono', monospace; 455 | font-size: 100%; 456 | } 457 | 458 | .params td, .params th, .props td, .props th { 459 | margin: 0px; 460 | text-align: left; 461 | vertical-align: top; 462 | padding: 10px; 463 | display: table-cell; 464 | } 465 | 466 | .params td { 467 | border-top: 1px solid #eee 468 | } 469 | 470 | .params thead tr, .props thead tr { 471 | background-color: #fff; 472 | font-weight: bold; 473 | } 474 | 475 | .params .params thead tr, .props .props thead tr { 476 | background-color: #fff; 477 | font-weight: bold; 478 | } 479 | 480 | .params td.description > p:first-child, .props td.description > p:first-child { 481 | margin-top: 0; 482 | padding-top: 0; 483 | } 484 | 485 | .params td.description > p:last-child, .props td.description > p:last-child { 486 | margin-bottom: 0; 487 | padding-bottom: 0; 488 | } 489 | 490 | dl.param-type { 491 | /* border-bottom: 1px solid hsl(0, 0%, 87%); */ 492 | margin: 0; 493 | padding: 0; 494 | font-size: 16px; 495 | } 496 | 497 | .param-type dt, .param-type dd { 498 | display: inline-block 499 | } 500 | 501 | .param-type dd { 502 | font-family: Consolas, Monaco, 'Andale Mono', monospace; 503 | display: inline-block; 504 | padding: 0; 505 | margin: 0; 506 | font-size: 14px; 507 | } 508 | 509 | .disabled { 510 | color: #454545 511 | } 512 | 513 | /* navicon button */ 514 | .navicon-button { 515 | display: none; 516 | position: relative; 517 | padding: 2.0625rem 1.5rem; 518 | transition: 0.25s; 519 | cursor: pointer; 520 | user-select: none; 521 | opacity: .8; 522 | } 523 | .navicon-button .navicon:before, .navicon-button .navicon:after { 524 | transition: 0.25s; 525 | } 526 | .navicon-button:hover { 527 | transition: 0.5s; 528 | opacity: 1; 529 | } 530 | .navicon-button:hover .navicon:before, .navicon-button:hover .navicon:after { 531 | transition: 0.25s; 532 | } 533 | .navicon-button:hover .navicon:before { 534 | top: .825rem; 535 | } 536 | .navicon-button:hover .navicon:after { 537 | top: -.825rem; 538 | } 539 | 540 | /* navicon */ 541 | .navicon { 542 | position: relative; 543 | width: 2.5em; 544 | height: .3125rem; 545 | background: #000; 546 | transition: 0.3s; 547 | border-radius: 2.5rem; 548 | } 549 | .navicon:before, .navicon:after { 550 | display: block; 551 | content: ""; 552 | height: .3125rem; 553 | width: 2.5rem; 554 | background: #000; 555 | position: absolute; 556 | z-index: -1; 557 | transition: 0.3s 0.25s; 558 | border-radius: 1rem; 559 | } 560 | .navicon:before { 561 | top: .625rem; 562 | } 563 | .navicon:after { 564 | top: -.625rem; 565 | } 566 | 567 | /* open */ 568 | .nav-trigger:checked + label:not(.steps) .navicon:before, 569 | .nav-trigger:checked + label:not(.steps) .navicon:after { 570 | top: 0 !important; 571 | } 572 | 573 | .nav-trigger:checked + label .navicon:before, 574 | .nav-trigger:checked + label .navicon:after { 575 | transition: 0.5s; 576 | } 577 | 578 | /* Minus */ 579 | .nav-trigger:checked + label { 580 | transform: scale(0.75); 581 | } 582 | 583 | /* × and + */ 584 | .nav-trigger:checked + label.plus .navicon, 585 | .nav-trigger:checked + label.x .navicon { 586 | background: transparent; 587 | } 588 | 589 | .nav-trigger:checked + label.plus .navicon:before, 590 | .nav-trigger:checked + label.x .navicon:before { 591 | transform: rotate(-45deg); 592 | background: #FFF; 593 | } 594 | 595 | .nav-trigger:checked + label.plus .navicon:after, 596 | .nav-trigger:checked + label.x .navicon:after { 597 | transform: rotate(45deg); 598 | background: #FFF; 599 | } 600 | 601 | .nav-trigger:checked + label.plus { 602 | transform: scale(0.75) rotate(45deg); 603 | } 604 | 605 | .nav-trigger:checked ~ nav { 606 | left: 0 !important; 607 | } 608 | 609 | .nav-trigger:checked ~ .overlay { 610 | display: block; 611 | } 612 | 613 | .nav-trigger { 614 | position: fixed; 615 | top: 0; 616 | clip: rect(0, 0, 0, 0); 617 | } 618 | 619 | .overlay { 620 | display: none; 621 | position: fixed; 622 | top: 0; 623 | bottom: 0; 624 | left: 0; 625 | right: 0; 626 | width: 100%; 627 | height: 100%; 628 | background: hsla(0, 0%, 0%, 0.5); 629 | z-index: 1; 630 | } 631 | 632 | .section-method { 633 | margin-bottom: 30px; 634 | padding-bottom: 30px; 635 | border-bottom: 1px solid #eee; 636 | } 637 | 638 | @media only screen and (min-width: 320px) and (max-width: 680px) { 639 | body { 640 | overflow-x: hidden; 641 | } 642 | 643 | nav { 644 | background: #FFF; 645 | width: 250px; 646 | height: 100%; 647 | position: fixed; 648 | top: 0; 649 | right: 0; 650 | bottom: 0; 651 | left: -250px; 652 | z-index: 3; 653 | padding: 0 10px; 654 | transition: left 0.2s; 655 | } 656 | 657 | .navicon-button { 658 | display: inline-block; 659 | position: fixed; 660 | top: 1.5em; 661 | right: 0; 662 | z-index: 2; 663 | } 664 | 665 | #main { 666 | width: 100%; 667 | min-width: 360px; 668 | } 669 | 670 | #main h1.page-title { 671 | margin: 1em 0; 672 | } 673 | 674 | #main section { 675 | padding: 0; 676 | } 677 | 678 | footer { 679 | margin-left: 0; 680 | } 681 | } 682 | 683 | @media only print { 684 | nav { 685 | display: none; 686 | } 687 | 688 | #main { 689 | float: none; 690 | width: 100%; 691 | } 692 | } 693 | -------------------------------------------------------------------------------- /lib/feedly.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const url = require('url') 6 | const util = require('util') 7 | const readFile = util.promisify(fs.readFile) 8 | const writeFile = util.promisify(fs.writeFile) 9 | 10 | const open = require('opn') 11 | const untildify = require('untildify') 12 | 13 | const utils = require('./utils') 14 | 15 | /// @nodoc 16 | function _normalizeTag (str, userid) { 17 | if (!str.match(/^user\//)) { 18 | str = `user/${userid}/tag/${str}` 19 | } 20 | return encodeURIComponent(str) 21 | } 22 | 23 | /// @nodoc 24 | function _nodify (cb, f) { 25 | const p = (typeof f === 'function') ? f() : f 26 | return cb ? p.then(r => cb(null, r), cb) : p 27 | } 28 | 29 | /// @nodoc 30 | function _pickCB (...args) { 31 | for (let i = 0; i < args.length; i++) { 32 | if (typeof args[i] === 'function') { 33 | return [args[i], ...args.slice(0, i)] 34 | } 35 | } 36 | return [null, ...args] 37 | } 38 | 39 | function _streamOptions (opts, cb) { 40 | switch (typeof opts) { 41 | case 'function': 42 | return [null, opts] 43 | case 'string': 44 | return [{ continuation: opts }, cb] 45 | case 'object': 46 | case 'undefined': 47 | if (!opts) { // might be null 48 | return [null, cb] 49 | } 50 | break 51 | default: 52 | throw new TypeError('Unknown options type') 53 | } 54 | for (const [k, v] of Object.entries(opts)) { 55 | if (v instanceof Date) { 56 | opts[k] = v.getTime() 57 | } 58 | } 59 | return [opts, cb] 60 | } 61 | 62 | /** 63 | * Talk to the Feedly API. 64 | * All methods will ensure a valid authentication dance has occurred, 65 | * and perform the dance if necessary. 66 | * 67 | * All of the methods that take a callback also return 68 | * a promise - the callback is therefore optional. 69 | * 70 | * WARNING: by default, this class stores state information such 71 | * as your access token in ~/.feedly by default. 72 | */ 73 | class Feedly { 74 | /** 75 | * Creates an instance of Feedly. 76 | * 77 | * @param {Object} options - Options for the API 78 | * @param {int} [options.port] - TCP port to listen on for callbacks. 79 | * (default: 0, which means to pick a random port) 80 | * @param {String} [options.base] - The root URL of the API. 81 | * (default: 'http://cloud.feedly.com') 82 | * @param {String} [options.config_file] - File in which state information such 83 | * as the access token and refresh tokens are stored. Tildes are expanded 84 | * as needed. (default: '~/.feedly') 85 | * @param {String} [options.html_file] - File that contains the HTML to give to 86 | * the web browser after it is redirected to the one-shot web server that 87 | * we'll be running. (default: '../html/index.html') 88 | * @param {String} [options.html_text] - If html_file is null or the file can't 89 | * be read, use this text instead. (default: 'No HTML found') 90 | * @param {int} [options.slop] - If there is less than this amount of time (in 91 | * milliseconds) between now and the expiration of the access token, refresh 92 | * the token. (default: 3600000) 93 | * @param {String} options.client_id - The API client ID. (REQUIRED) 94 | * @param {String} options.client_secret - The API client Secret. (REQUIRED) 95 | */ 96 | constructor (options) { 97 | this.options = Object.assign({}, { 98 | port: 0, 99 | base: 'http://cloud.feedly.com', 100 | config_file: '~/.feedly', 101 | html_file: path.join(__dirname, '../html/index.html'), 102 | html_text: 'No HTML found', 103 | slop: 3600000, 104 | client_id: null, 105 | client_secret: null 106 | }, options) 107 | this.options.config_file = untildify(this.options.config_file) 108 | this.options.html_file = untildify(this.options.html_file) 109 | if ((this.options.client_id == null) || (this.options.client_secret == null)) { 110 | throw new Error('client_id and client_secret required') 111 | } 112 | this.state = {} 113 | 114 | // allSettled ignores errors 115 | this.ready = Promise.all([this._loadConfig(), this._loadHTML()]) 116 | } 117 | 118 | /// @nodoc 119 | async _loadConfig () { 120 | if (this.options.config_file == null) { return null } 121 | try { 122 | const data = await readFile(this.options.config_file) 123 | this.state = JSON.parse(data) 124 | if (this.state.expires != null) { 125 | this.state.expires = new Date(this.state.expires) 126 | } 127 | } catch (er) { 128 | this.state = {} 129 | } 130 | } 131 | 132 | /// @nodoc 133 | async _loadHTML () { 134 | if (this.options.html_file != null) { 135 | try { 136 | this.options.html_text = 137 | await readFile(this.options.html_file, { encoding: 'utf8' }) 138 | } catch (er) { 139 | console.error('WARNING:', er) 140 | } 141 | } 142 | } 143 | 144 | /// @nodoc 145 | async _save () { 146 | if (this.options.config_file != null) { 147 | await writeFile( 148 | this.options.config_file, 149 | JSON.stringify(this.state), 150 | { encoding: 'utf8' }) 151 | } 152 | } 153 | 154 | /// @nodoc 155 | _validToken () { 156 | return (this.state.access_token != null) && 157 | (this.state.refresh_token != null) && 158 | (this.state.expires != null) && 159 | (this.state.expires > new Date()) 160 | } 161 | 162 | /// @nodoc 163 | async _getAuth () { 164 | await this.ready 165 | if (!this._validToken()) { 166 | // do full auth 167 | return this._auth() 168 | } else if ((this.state.expires - new Date()) > this.options.slop) { 169 | return this._refresh() 170 | } 171 | return this.state.access_token 172 | } 173 | 174 | /// @nodoc 175 | async _auth () { 176 | const u = new URL(this.options.base) 177 | let cbURL = null 178 | const [results] = await utils.qserver( 179 | this.options.port, 180 | this.options.html_text, 181 | (cbu) => { 182 | cbURL = cbu 183 | u.pathname = '/v3/auth/auth' 184 | u.query = { 185 | response_type: 'code', 186 | client_id: this.options.client_id, 187 | redirect_uri: cbURL, 188 | scope: 'https://cloud.feedly.com/subscriptions' 189 | } 190 | return open(url.format(u)) 191 | } 192 | ) 193 | if (results.error != null) { 194 | throw results.error 195 | } 196 | return this._getToken(results.code, cbURL) 197 | } 198 | 199 | /// @nodoc 200 | async _getToken (code, redirect) { 201 | const u = new URL(this.options.base) 202 | u.pathname = '/v3/auth/token' 203 | 204 | const body = await utils.qrequest({ 205 | method: 'POST', 206 | uri: url.format(u), 207 | body: { 208 | code, 209 | client_id: this.options.client_id, 210 | client_secret: this.options.client_secret, 211 | grant_type: 'authorization_code', 212 | redirect_uri: redirect 213 | } 214 | }) 215 | this.state = Object.assign({}, this.state, body) 216 | this.state.expires = new Date(new Date().getTime() + (body.expires_in * 1000)) 217 | await this._save() 218 | return this.state.access_token 219 | } 220 | 221 | /// @nodoc 222 | async _refresh () { 223 | const u = new URL(this.options.base) 224 | u.pathname = '/v3/auth/token' 225 | u.query = { 226 | refresh_token: this.state.refresh_token, 227 | client_id: this.options.client_id, 228 | client_secret: this.options.client_secret, 229 | grant_type: 'refresh_token' 230 | } 231 | 232 | const body = await utils.qrequest({ 233 | method: 'POST', 234 | uri: url.format(u) 235 | }) 236 | this.state = Object.assign({}, this.state, body) 237 | this.state.expires = new Date(new Date().getTime() + (body.expires_in * 1000)) 238 | await this._save() 239 | return this.state.access_token 240 | } 241 | 242 | /// @nodoc 243 | async _request (callback, path, method, body = null) { 244 | if (method == null) { method = 'GET' } 245 | const u = new URL(this.options.base) 246 | u.pathname = path 247 | 248 | const auth = await this._getAuth() 249 | return utils.qrequest({ 250 | method, 251 | uri: url.format(u), 252 | headers: { 253 | Authorization: `OAuth ${auth}` 254 | }, 255 | body, 256 | callback 257 | }) 258 | } 259 | 260 | /// @nodoc 261 | async _requestURL (callback, path, method, body = null) { 262 | if (method == null) { method = 'GET' } 263 | const u = new URL(this.options.base) 264 | u.pathname = path 265 | u.query = body 266 | 267 | const auth = await this._getAuth() 268 | return utils.qrequest({ 269 | method, 270 | uri: url.format(u), 271 | headers: { 272 | Authorization: `OAuth ${auth}` 273 | }, 274 | callback 275 | }) 276 | } 277 | 278 | /// @nodoc 279 | _normalizeTags (ary) { 280 | const userid = this.state.id 281 | return ary.map(s => _normalizeTag(s, userid)) 282 | } 283 | 284 | /// @nodoc 285 | _normalizeCategories (ary) { 286 | const userid = this.state.id 287 | return ary.map(cat => { 288 | if (!cat.match(/^user\//)) { 289 | cat = `user/${userid}/category/${cat}` 290 | } 291 | return cat 292 | }) 293 | } 294 | 295 | /** 296 | * Refresh the auth token manually. If the current refresh token is not 297 | * valid, authenticate again. 298 | * 299 | * @param {Function} [cb] - Optional callback function(Error, String) 300 | * @returns {Promise(String)} new auth token 301 | */ 302 | refresh (cb) { 303 | return _nodify(cb, async () => { 304 | await this.ready 305 | return this._validToken() ? this._refresh() : this._auth() 306 | }) 307 | } 308 | 309 | /** 310 | * Discard all tokens 311 | * 312 | * @param {Function} [cb] - Optional callback function(Error) 313 | * @returns {Promise} completed 314 | */ 315 | logout (cb) { 316 | return _nodify(cb, async () => { 317 | await this.ready 318 | 319 | const u = new URL(this.options.base) 320 | u.pathname = '/v3/auth/token' 321 | u.query = { 322 | refresh_token: this.state.refresh_token, 323 | client_id: this.options.client_id, 324 | client_secret: this.options.client_secret, 325 | grant_type: 'revoke_token' 326 | } 327 | 328 | const body = utils.qrequest({ 329 | method: 'POST', 330 | uri: url.format(u) 331 | }) 332 | delete this.state.access_token 333 | delete this.state.expires 334 | delete this.state.plan 335 | delete this.state.provider 336 | delete this.state.refresh_token 337 | delete this.state.token_type 338 | this.state = Object.assign({}, this.state, body) 339 | return this._save() 340 | }) 341 | } 342 | 343 | /** 344 | * Fetch the list of categories 345 | * 346 | * @param {Function} [cb] - Optional callback function(Error, Array(Category)) 347 | * @returns {Promise(Array(Category))} list of categories 348 | * @see {@link https://developer.feedly.com/v3/categories/#get-the-list-of-all-categories} 349 | */ 350 | categories (cb) { 351 | return this._request(cb, '/v3/categories') 352 | } 353 | 354 | /** 355 | * Set the label for a category. 356 | * 357 | * @param {String} id - the category to modify 358 | * @param {String} label - the new label 359 | * @param {Function} [cb] - Optional callback function(Error) 360 | * @returns {Promise} Done 361 | * @see https://developer.feedly.com/v3/categories/#change-the-label-of-an-existing-category 362 | */ 363 | setCategoryLabel (id, label, cb) { 364 | return this._request( 365 | cb, 366 | `/v3/categories/${encodeURIComponent(id)}`, 367 | 'POST', 368 | { label }) 369 | } 370 | 371 | /** 372 | * Delete a category. 373 | * 374 | * @param {String} id - the category to delete 375 | * @param {Function} [cb] - Optional callback function(Error) 376 | * @returns {Promise} Done 377 | * @see https://developer.feedly.com/v3/categories/#delete-a-category 378 | */ 379 | deleteCategory (id, cb) { 380 | return this._request( 381 | cb, 382 | `/v3/categories/${encodeURIComponent(id)}`, 383 | 'DELETE') 384 | } 385 | 386 | /** 387 | * Get one or more entries 388 | * 389 | * @param {String|Array(String)} id - the entry or entries to retrieve 390 | * @param {Function} [cb] - Optional callback function(Error, Entry|Array(Entry)) 391 | * @returns {Promise(Entry)|Promise(Array(Entry))} the entry(s) 392 | * @see https://developer.feedly.com/v3/entries/#get-the-content-of-an-entry 393 | * @see https://developer.feedly.com/v3/entries/#get-the-content-for-a-dynamic-list-of-entries 394 | */ 395 | entry (id, cb) { 396 | if (Array.isArray(id)) { 397 | return this._request(cb, '/v3/entries/.mget', 'POST', id) 398 | } else { 399 | return this._request(cb, `/v3/entries/${encodeURIComponent(id)}`) 400 | } 401 | } 402 | 403 | /** 404 | * Create an entry. This call is useful to inject entries not coming from a 405 | * feed, into a user’s account. The entries created will only be available 406 | * through the tag streams of the respective tags passed. 407 | * 408 | * @param {Entry} entry - See the 409 | * {@link http://developer.feedly.com/v3/entries/#create-and-tag-an-entry Feedly API docs} 410 | * for more information. 411 | * @param {Function} [cb] - Optional callback function(Error) 412 | * @returns {Promise} Done 413 | * @see http://developer.feedly.com/v3/entries/#create-and-tag-an-entry 414 | */ 415 | createEntry (entry, cb) { 416 | return this._request(cb, '/v3/entries/', 'POST', entry) 417 | } 418 | 419 | /** 420 | * Get meta-data about a feed or list of feeds 421 | * 422 | * @param {String|Array(String)} id - the ID or list of IDs of the feed(s) 423 | * @param {Function} [cb] - Optional callback function(Error, Feed|Array(Feed)) 424 | * @returns {Promise(Feed)|Promise(Array(Feed))} 425 | * @see https://developer.feedly.com/v3/feeds/#get-the-metadata-about-a-specific-feed 426 | */ 427 | feed (id, cb) { 428 | if (Array.isArray(id)) { 429 | return this._request(cb, '/v3/feeds/.mget', 'POST', id) 430 | } else { 431 | return this._request(cb, `/v3/feeds/${encodeURIComponent(id)}`) 432 | } 433 | } 434 | 435 | /** 436 | * Get unread counts. In theory, newerThan and streamId can 437 | * be used to reduce the counts that are returned, but I didn't see evidence 438 | * of that in practice. 439 | * 440 | * @param {Boolean} [autorefresh] - Lets the server know if this is a background 441 | * auto-refresh or not. In case of very high load on the service, the server 442 | * can deny access to background requests and give priority to user facing 443 | * operations. 444 | * @param {Date} [newerThan] - timestamp used as a lower time limit, instead of 445 | * the default 30 days 446 | * @param {String} [streamId] - A user or system category can be passed to 447 | * restrict the unread count response to feeds in this category. 448 | * @param {Function} [cb] - Optional callback function(Error, Counts) 449 | * @returns {Promise(Array(Count))} 450 | * @see https://developer.feedly.com/v3/markers/#get-the-list-of-unread-counts 451 | */ 452 | counts (autorefresh, newerThan, streamId, cb) { 453 | [cb, autorefresh, newerThan, streamId] = 454 | _pickCB(autorefresh, newerThan, streamId, cb) 455 | 456 | let input = {} 457 | if (autorefresh != null) { 458 | input.autorefresh = autorefresh 459 | } 460 | if (newerThan != null) { 461 | input.newerThan = newerThan.getTime() 462 | } 463 | if (streamId != null) { 464 | input.streamId = streamId 465 | } 466 | if (Object.keys(input).length === 0) { 467 | input = null 468 | } 469 | return this._request(cb, '/v3/markers/counts', 'GET', input) 470 | } 471 | 472 | /** 473 | * Mark article(s) as read. 474 | * 475 | * @param {Array(String)|String} ids - article ID(s) to mark read 476 | * @param {Function} cb - Optionall callback function(Error) 477 | * @returns {Promise} Done 478 | * @see https://developer.feedly.com/v3/markers/#mark-one-or-multiple-articles-as-read 479 | */ 480 | markEntryRead (ids, cb) { 481 | if (typeof ids === 'string') { 482 | ids = [ids] 483 | } 484 | return this._request(cb, '/v3/markers', 'POST', { 485 | entryIds: ids, 486 | type: 'entries', 487 | action: 'markAsRead' 488 | }) 489 | } 490 | 491 | /** 492 | * Mark article(s) as unread. 493 | * 494 | * @param {Array(String)|String} ids - Article ID(s) to mark unread 495 | * @param {Function} [cb] - Optional callback function(Error) 496 | * @returns {Promise} Done 497 | * @see https://developer.feedly.com/v3/markers/#keep-one-or-multiple-articles-as-unread 498 | */ 499 | markEntryUnread (ids, cb) { 500 | if (typeof ids === 'string') { 501 | ids = [ids] 502 | } 503 | return this._request(cb, '/v3/markers', 'POST', { 504 | entryIds: ids, 505 | type: 'entries', 506 | action: 'keepUnread' 507 | }) 508 | } 509 | 510 | /** 511 | * Mark feed(s) as read. 512 | * 513 | * @param {Array(String)|String} ids - feed ID(s) to mark read 514 | * @param {String|Date} [since] - last entry ID read or timestamp last read 515 | * @param {Function} [cb] - Optional callback function(Error) 516 | * @returns {Promise} Done 517 | * @see https://developer.feedly.com/v3/markers/#mark-a-feed-as-read 518 | */ 519 | markFeedRead (ids, since, cb) { 520 | if (typeof ids === 'string') { 521 | ids = [ids] 522 | } 523 | [cb, since] = _pickCB(since, cb) 524 | 525 | const body = { 526 | feedIds: ids, 527 | type: 'feeds', 528 | action: 'markAsRead' 529 | } 530 | if (since instanceof Date) { 531 | body.asOf = since.getTime() 532 | } else if (typeof since === 'string') { 533 | body.lastReadEntryId = since 534 | } 535 | 536 | return this._request(cb, '/v3/markers', 'POST', body) 537 | } 538 | 539 | /** 540 | * Mark category(s) as read. 541 | * 542 | * @param {Array(String)|String} ids - category ID(s) to mark read 543 | * @param {String|Date} [since] - last entry ID read or timestamp last read 544 | * @param {Function} [cb] - Optional callback function(Error) 545 | * @returns {Promise} Done 546 | * @see https://developer.feedly.com/v3/markers/#mark-a-category-as-read 547 | */ 548 | markCategoryRead (ids, since, cb) { 549 | if (typeof ids === 'string') { 550 | ids = [ids] 551 | } 552 | [cb, since] = _pickCB(since, cb) 553 | 554 | const body = { 555 | categoryIds: this._normalizeCategories(ids), 556 | type: 'categories', 557 | action: 'markAsRead' 558 | } 559 | if (since instanceof Date) { 560 | body.asOf = since.getTime() 561 | } else if (typeof since === 'string') { 562 | body.lastReadEntryId = since 563 | } 564 | 565 | return this._request(cb, '/v3/markers', 'POST', body) 566 | } 567 | 568 | /** 569 | * Mark tag(s) as read. 570 | * 571 | * @param {Array(String)|String} ids - tag ID(s) to mark read 572 | * @param {String|Date} [since] - last entry ID read or timestamp last read 573 | * @param {Function} [cb] - Optional callback function(Error) 574 | * @returns {Promise} Done 575 | * @see https://developer.feedly.com/v3/markers/#mark-a-tag-as-read 576 | */ 577 | markTagRead (ids, since, cb) { 578 | if (typeof ids === 'string') { 579 | ids = [ids] 580 | } 581 | [cb, since] = _pickCB(since, cb) 582 | 583 | const body = { 584 | tagIds: this._normalizeTags(ids), 585 | type: 'tags', 586 | action: 'markAsRead' 587 | } 588 | if (since instanceof Date) { 589 | body.asOf = since.getTime() 590 | } else if (typeof since === 'string') { 591 | body.lastReadEntryId = since 592 | } 593 | 594 | return this._request(cb, '/v3/markers', 'POST', body) 595 | } 596 | 597 | /** 598 | * Get the latest read operations (to sync local cache). 599 | * 600 | * @param {Date} [newerThan] - start date 601 | * @param {any} [cb] - Optional callback function(Error, Array(Read)) 602 | * @returns {Promise(Array(Read))} the read operations 603 | * @see https://developer.feedly.com/v3/markers/#get-the-latest-read-operations-to-sync-local-cache 604 | */ 605 | reads (newerThan, cb) { 606 | [cb, newerThan] = _pickCB(newerThan, cb) 607 | 608 | let input = null 609 | if (newerThan != null) { 610 | input = { 611 | newerThan: newerThan.getTime() 612 | } 613 | } 614 | return this._request(cb, '/v3/markers/reads', 'GET', input) 615 | } 616 | 617 | /** 618 | * Get the latest tagged entry ids 619 | * 620 | * @param {Date} [newerThan] - start date 621 | * @param {any} [cb] - Optional callback function(Error, Tagged) 622 | * @returns {Promise(Tagged)} The tags 623 | * @see https://developer.feedly.com/v3/markers/#get-the-latest-tagged-entry-ids 624 | */ 625 | tags (newerThan, cb) { 626 | [cb, newerThan] = _pickCB(newerThan, cb) 627 | 628 | let input = null 629 | if (newerThan != null) { 630 | input = { 631 | newerThan: newerThan.getTime() 632 | } 633 | } 634 | return this._request(cb, '/v3/markers/tags', 'GET', input) 635 | } 636 | 637 | /** 638 | * Get the current user's preferences 639 | * 640 | * @param {Function} [cb] - Optional function(Error, Prefs) 641 | * @returns {Promise(Prefs)} - the preferences 642 | * @see https://developer.feedly.com/v3/preferences/#get-the-preferences-of-the-user 643 | */ 644 | preferences (cb) { 645 | return this._request(cb, '/v3/preferences') 646 | } 647 | 648 | /** 649 | * Update the preferences of the user 650 | * 651 | * @param {Object} prefs - the preferences to update, use "==DELETE==” 652 | * as the value in order to delete a preference. 653 | * @param {any} [cb] - Optional callback function(Error, Prefs) 654 | * @returns {Promise(Prefs)} updated preferences 655 | * @see https://developer.feedly.com/v3/preferences/#update-the-preferences-of-the-user 656 | */ 657 | updatePreferences (prefs, cb) { 658 | return this._request(cb, '/v3/preferences', 'POST', prefs) 659 | } 660 | 661 | /** 662 | * Get the current user's profile 663 | * 664 | * @param {Function} [cb] - Optional callback function(Error, Profile) 665 | * @returns {Promise(Profile)} Profile information 666 | * @see https://developer.feedly.com/v3/profile/#get-the-profile-of-the-user 667 | */ 668 | profile (cb) { 669 | return this._request(cb, '/v3/profile') 670 | } 671 | 672 | /** 673 | * Update the profile of the user 674 | * 675 | * @param {Object} profile - the profile to update. See 676 | * {@link https://developer.feedly.com/v3/profile/#update-the-profile-of-the-user Feedly API docs} 677 | * for more information 678 | * @param {Function} [cb] - Optional callback function(Error, Profile) 679 | * @returns {Promise(Profile)} The updated profile 680 | * @see https://developer.feedly.com/v3/profile/#update-the-profile-of-the-user 681 | */ 682 | updateProfile (profile, cb) { 683 | return this._request(cb, '/v3/profile', 'POST', profile) 684 | } 685 | 686 | /** 687 | * Find feeds based on title, url or #topic 688 | * 689 | * @param {String} query - the string to search for 690 | * @param {int} [results=20] - the max number of results to return 691 | * @param {String} [locale] - hint the search engine to return feeds in that locale (e.g. “pt”, “fr_FR”) 692 | * @param {Function} [cb] - Optional callback function(Error, Array(Feed)) 693 | * @returns {Promise(Array(Feed))} 694 | * @see https://developer.feedly.com/v3/search/#find-feeds-based-on-title-url-or-topic 695 | */ 696 | searchFeeds (query, results, locale, cb) { 697 | [cb, results, locale] = _pickCB(results, locale, cb) 698 | const req = { 699 | query 700 | } 701 | if (results != null) { 702 | req.n = results 703 | } 704 | if (locale) { 705 | req.locale = locale 706 | } 707 | 708 | return this._requestURL(cb, '/v3/search/feeds', 'GET', req) 709 | } 710 | 711 | /** 712 | * Create a shortened URL for an entry. The short URL is unique for a given 713 | * entry id, user and application. 714 | * 715 | * @param {String} entryId - The entry ID to shorten 716 | * @param {Function} [cb] - Optional callback function(Error, String) 717 | * @returns {Promise(String)} the shortened URL 718 | * @deprecated This is no longer documented in the Feedly API 719 | */ 720 | shorten (entryId, cb) { 721 | return this._requestURL( 722 | cb, 723 | '/v3/shorten/entries', 724 | 'GET', 725 | { entryId }) 726 | } 727 | 728 | /** 729 | * Get a list of entry ids for a specific stream. 730 | * 731 | * @param {String} id - the Stream ID 732 | * @param {String|Object} [options] - A continuation ID as a string is 733 | * used to page, or an object with stream request parameters 734 | * @param {("newest"|"oldest")} [options.ranked="newest"] - order 735 | * @param {Boolean} [options.unreadOnly=false] - only unread? 736 | * @param {Date} [options.newerThan] - since when? 737 | * @param {String} [options.continuation] - continue from where you left off 738 | * @param {Function} [cb] - Optional callback function(Error, Page) 739 | * @returns {Promise(Page)} 740 | * @see https://developer.feedly.com/v3/streams/#get-a-list-of-entry-ids-for-a-specific-stream 741 | */ 742 | stream (id, options, cb) { 743 | [options, cb] = _streamOptions(options, cb) 744 | return this._requestURL( 745 | cb, 746 | `/v3/streams/${encodeURIComponent(id)}/ids`, 747 | 'GET', 748 | options) 749 | } 750 | 751 | /** 752 | * Get the content of a stream 753 | * 754 | * @param {String} id - the Stream ID 755 | * @param {String|Object} [options] - A continuation ID as a string is 756 | * used to page, or an object with stream request parameters 757 | * @param {("newest"|"oldest")} [options.ranked="newest"] - order 758 | * @param {Boolean} [options.unreadOnly=false] - only unread? 759 | * @param {Date} [options.newerThan] - since when? 760 | * @param {String} [options.continuation] - continue from where you left off 761 | * @param {Function} [cb] - Optional callback function(Error, ContentPage) 762 | * @returns {Promise(ContentPage)} 763 | * @see https://developer.feedly.com/v3/streams/#get-the-content-of-a-stream 764 | */ 765 | contents (id, options, cb) { 766 | [options, cb] = _streamOptions(options, cb) 767 | return this._request( 768 | cb, 769 | `/v3/streams/${encodeURIComponent(id)}/contents`, 770 | 'GET', 771 | options) 772 | } 773 | 774 | /** 775 | * Get the user’s subscriptions 776 | * 777 | * @param {Function} [cb] - Optional callback function(Error, Array(Subscription)) 778 | * @returns {Promise(Array(Subscription))} 779 | * @see https://developer.feedly.com/v3/subscriptions/#get-the-users-subscriptions 780 | */ 781 | subscriptions (cb) { 782 | return this._request(cb, '/v3/subscriptions', 'GET') 783 | } 784 | 785 | /** 786 | * Subscribe to a feed 787 | * [{@link https://developer.feedly.com/v3/subscriptions/#subscribe-to-a-feed API doc}] 788 | * 789 | * @param {String} url - the URL of the feed to subscribe to 790 | * @param {String|Array(String)} [categories] - category(s) for the subscription 791 | * @param {String} [title] - Subscription title 792 | * @param {Function} cb - Optional callback function(Error) 793 | * @returns {Promise} Done 794 | * @see https://developer.feedly.com/v3/subscriptions/#subscribe-to-a-feed 795 | */ 796 | subscribe (url, categories, title, cb) { 797 | if (!url.match(/^feed\//)) { 798 | url = `feed/${url}` 799 | } 800 | [cb, categories, title] = _pickCB(categories, title, cb) 801 | 802 | const input = { 803 | id: url 804 | } 805 | 806 | if (categories != null) { 807 | if (!Array.isArray(categories)) { 808 | categories = [categories] 809 | } 810 | const userid = this.state.id 811 | categories = categories.map(c => { 812 | if (typeof c !== 'string') { 813 | return c 814 | } 815 | let id = null 816 | let name = null 817 | const m = c.match(/^user\/[^/]+\/(.*)/) 818 | if (!m) { 819 | id = `user/${userid}/category/${c}` 820 | name = c 821 | } else { 822 | id = c 823 | name = m[1] 824 | } 825 | return { 826 | id, 827 | name 828 | } 829 | }) 830 | input.categories = categories 831 | } 832 | if (title) { 833 | input.title = title 834 | } 835 | return this._request(cb, '/v3/subscriptions', 'POST', input) 836 | } 837 | 838 | /** 839 | * Unsubscribe from a feed 840 | * 841 | * @param {String} id - Feed ID 842 | * @param {Function} [cb] - Optional callback function(Error) 843 | * @returns {Promise} Done 844 | * @see https://developer.feedly.com/v3/subscriptions/#unsubscribe-from-a-feed 845 | */ 846 | unsubscribe (id, cb) { 847 | // TODO: add support for mass unsubscribe 848 | return this._request( 849 | cb, 850 | `/v3/subscriptions/${encodeURIComponent(id)}`, 851 | 'DELETE') 852 | } 853 | 854 | /** 855 | * Tag an existing entry or entries 856 | * 857 | * @param {String|Array(String)} entry - the entry(s) to tag 858 | * @param {String|Array(String)} tags - the tag(s) to apply to the entry 859 | * @param {Function} cb - Optional callback function(Error) 860 | * @returns {Promise} Done 861 | * @see https://developer.feedly.com/v3/tags/#tag-an-existing-entry 862 | * @see https://developer.feedly.com/v3/tags/#tag-multiple-entries-alternate 863 | */ 864 | tagEntry (entry, tags, cb) { 865 | if (!Array.isArray(tags)) { 866 | tags = [tags] 867 | } 868 | tags = this._normalizeTags(tags) 869 | if (Array.isArray(entry)) { 870 | return this._request( 871 | cb, 872 | `/v3/tags/${tags.join(',')}`, 873 | 'PUT', 874 | { entryIds: entry }) 875 | } else { 876 | return this._request( 877 | cb, 878 | `/v3/tags/${tags.join(',')}`, 879 | 'PUT', 880 | { entryId: entry }) 881 | } 882 | } 883 | 884 | /** 885 | * Change a tag label 886 | * 887 | * @param {String} tag - the tag to modify 888 | * @param {String} label - new label for the tag 889 | * @param {Function} cb - Optional callback function(Error) 890 | * @returns {Promise} Done 891 | * @see https://developer.feedly.com/v3/tags/#change-a-tag-label 892 | */ 893 | setTagLabel (tag, label, cb) { 894 | tag = _normalizeTag(tag, this.state.id) 895 | return this._request( 896 | cb, 897 | `/v3/tags/${tag}`, 898 | 'POST', 899 | { label }) 900 | } 901 | 902 | /** 903 | * Untag entries 904 | * 905 | * @param {String|Array(String)} entries - the ID(s) of the entries to modify 906 | * @param {String|Array(String)} tags - the tag(s) to remove 907 | * @param {Function} cb - Optional callback function(Error) 908 | * @returns {Promise} Done 909 | * @see https://developer.feedly.com/v3/tags/#untag-multiple-entries 910 | */ 911 | untagEntries (entries, tags, cb) { 912 | if (!Array.isArray(entries)) { 913 | entries = [entries] 914 | } 915 | entries = entries.map(e => encodeURIComponent(e)) 916 | 917 | if (!Array.isArray(tags)) { 918 | tags = [tags] 919 | } 920 | tags = this._normalizeTags(tags) 921 | 922 | return this._request( 923 | cb, 924 | `/v3/tags/${tags.join(',')}/${entries.join(',')}`, 925 | 'DELETE') 926 | } 927 | 928 | /** 929 | * Delete tags 930 | * 931 | * @param {String|Array(String)} tags - the tag(s) to remove 932 | * @param {any} cb - Optional callback function(Error) 933 | * @returns {Promise} Done 934 | * @see https://developer.feedly.com/v3/tags/#delete-tags 935 | */ 936 | deleteTags (tags, cb) { 937 | if (!Array.isArray(tags)) { 938 | tags = [tags] 939 | } 940 | tags = this._normalizeTags(tags) 941 | return this._request(cb, `/v3/tags/${tags.join(',')}`, 'DELETE') 942 | } 943 | } 944 | 945 | module.exports = Feedly 946 | -------------------------------------------------------------------------------- /docs/feedly.js.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | feedly.js - Documentation 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 29 | 30 |
31 | 32 |

feedly.js

33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 |
'use strict'
  43 | 
  44 | const fs = require('fs')
  45 | const path = require('path')
  46 | const url = require('url')
  47 | const util = require('util')
  48 | const readFile = util.promisify(fs.readFile)
  49 | const writeFile = util.promisify(fs.writeFile)
  50 | 
  51 | const open = require('opn')
  52 | const untildify = require('untildify')
  53 | 
  54 | const utils = require('./utils')
  55 | 
  56 | /// @nodoc
  57 | function _normalizeTag (str, userid) {
  58 |   if (!str.match(/^user\//)) {
  59 |     str = `user/${userid}/tag/${str}`
  60 |   }
  61 |   return encodeURIComponent(str)
  62 | }
  63 | 
  64 | /// @nodoc
  65 | function _nodify (cb, f) {
  66 |   const p = (typeof f === 'function') ? f() : f
  67 |   return cb ? p.then(r => cb(null, r), cb) : p
  68 | }
  69 | 
  70 | /// @nodoc
  71 | function _pickCB (...args) {
  72 |   for (let i = 0; i < args.length; i++) {
  73 |     if (typeof args[i] === 'function') {
  74 |       return [args[i], ...args.slice(0, i)]
  75 |     }
  76 |   }
  77 |   return [null, ...args]
  78 | }
  79 | 
  80 | function _streamOptions (opts, cb) {
  81 |   switch (typeof opts) {
  82 |     case 'function':
  83 |       return [null, opts]
  84 |     case 'string':
  85 |       return [{ continuation: opts }, cb]
  86 |     case 'object':
  87 |     case 'undefined':
  88 |       if (!opts) { // might be null
  89 |         return [null, cb]
  90 |       }
  91 |       break
  92 |     default:
  93 |       throw new TypeError('Unknown options type')
  94 |   }
  95 |   for (const [k, v] of Object.entries(opts)) {
  96 |     if (v instanceof Date) {
  97 |       opts[k] = v.getTime()
  98 |     }
  99 |   }
 100 |   return [opts, cb]
 101 | }
 102 | 
 103 | /**
 104 |  * Talk to the Feedly API.
 105 |  * All methods will ensure a valid authentication dance has occurred,
 106 |  * and perform the dance if necessary.
 107 |  *
 108 |  * All of the methods that take a callback also return
 109 |  * a promise - the callback is therefore optional.
 110 |  *
 111 |  * WARNING: by default, this class stores state information such
 112 |  * as your access token in ~/.feedly by default.
 113 |  */
 114 | class Feedly {
 115 |   /**
 116 |    * Creates an instance of Feedly.
 117 |    *
 118 |    * @param {Object} options - Options for the API
 119 |    * @param {int} [options.port] - TCP port to listen on for callbacks.
 120 |    *   (default: 0, which means to pick a random port)
 121 |    * @param {String} [options.base] - The root URL of the API.
 122 |    *   (default: 'http://cloud.feedly.com')
 123 |    * @param {String} [options.config_file] - File in which state information such
 124 |    *   as the access token and refresh tokens are stored.  Tildes are expanded
 125 |    *   as needed.  (default: '~/.feedly')
 126 |    * @param {String} [options.html_file] - File that contains the HTML to give to
 127 |    *   the web browser after it is redirected to the one-shot web server that
 128 |    *   we'll be running.  (default: '../html/index.html')
 129 |    * @param {String} [options.html_text] - If html_file is null or the file can't
 130 |    *   be read, use this text instead.  (default: 'No HTML found')
 131 |    * @param {int} [options.slop] - If there is less than this amount of time (in
 132 |    *   milliseconds) between now and the expiration of the access token, refresh
 133 |    *   the token.  (default: 3600000)
 134 |    * @param {String} options.client_id - The API client ID.  (REQUIRED)
 135 |    * @param {String} options.client_secret - The API client Secret.  (REQUIRED)
 136 |    */
 137 |   constructor (options) {
 138 |     this.options = Object.assign({}, {
 139 |       port: 0,
 140 |       base: 'http://cloud.feedly.com',
 141 |       config_file: '~/.feedly',
 142 |       html_file: path.join(__dirname, '../html/index.html'),
 143 |       html_text: 'No HTML found',
 144 |       slop: 3600000,
 145 |       client_id: null,
 146 |       client_secret: null
 147 |     }, options)
 148 |     this.options.config_file = untildify(this.options.config_file)
 149 |     this.options.html_file = untildify(this.options.html_file)
 150 |     if ((this.options.client_id == null) || (this.options.client_secret == null)) {
 151 |       throw new Error('client_id and client_secret required')
 152 |     }
 153 |     this.state = {}
 154 | 
 155 |     // allSettled ignores errors
 156 |     this.ready = Promise.all([this._loadConfig(), this._loadHTML()])
 157 |   }
 158 | 
 159 |   /// @nodoc
 160 |   async _loadConfig () {
 161 |     if (this.options.config_file == null) { return null }
 162 |     try {
 163 |       const data = await readFile(this.options.config_file)
 164 |       this.state = JSON.parse(data)
 165 |       if (this.state.expires != null) {
 166 |         this.state.expires = new Date(this.state.expires)
 167 |       }
 168 |     } catch (er) {
 169 |       this.state = {}
 170 |     }
 171 |   }
 172 | 
 173 |   /// @nodoc
 174 |   async _loadHTML () {
 175 |     if (this.options.html_file != null) {
 176 |       try {
 177 |         this.options.html_text =
 178 |           await readFile(this.options.html_file, { encoding: 'utf8' })
 179 |       } catch (er) {
 180 |         console.error('WARNING:', er)
 181 |       }
 182 |     }
 183 |   }
 184 | 
 185 |   /// @nodoc
 186 |   async _save () {
 187 |     if (this.options.config_file != null) {
 188 |       await writeFile(
 189 |         this.options.config_file,
 190 |         JSON.stringify(this.state),
 191 |         { encoding: 'utf8' })
 192 |     }
 193 |   }
 194 | 
 195 |   /// @nodoc
 196 |   _validToken () {
 197 |     return (this.state.access_token != null) &&
 198 |            (this.state.refresh_token != null) &&
 199 |            (this.state.expires != null) &&
 200 |            (this.state.expires > new Date())
 201 |   }
 202 | 
 203 |   /// @nodoc
 204 |   async _getAuth () {
 205 |     await this.ready
 206 |     if (!this._validToken()) {
 207 |       // do full auth
 208 |       return this._auth()
 209 |     } else if ((this.state.expires - new Date()) > this.options.slop) {
 210 |       return this._refresh()
 211 |     }
 212 |     return this.state.access_token
 213 |   }
 214 | 
 215 |   /// @nodoc
 216 |   async _auth () {
 217 |     const u = new URL(this.options.base)
 218 |     let cbURL = null
 219 |     const [results] = await utils.qserver(
 220 |       this.options.port,
 221 |       this.options.html_text,
 222 |       (cbu) => {
 223 |         cbURL = cbu
 224 |         u.pathname = '/v3/auth/auth'
 225 |         u.query = {
 226 |           response_type: 'code',
 227 |           client_id: this.options.client_id,
 228 |           redirect_uri: cbURL,
 229 |           scope: 'https://cloud.feedly.com/subscriptions'
 230 |         }
 231 |         return open(url.format(u))
 232 |       }
 233 |     )
 234 |     if (results.error != null) {
 235 |       throw results.error
 236 |     }
 237 |     return this._getToken(results.code, cbURL)
 238 |   }
 239 | 
 240 |   /// @nodoc
 241 |   async _getToken (code, redirect) {
 242 |     const u = new URL(this.options.base)
 243 |     u.pathname = '/v3/auth/token'
 244 | 
 245 |     const body = await utils.qrequest({
 246 |       method: 'POST',
 247 |       uri: url.format(u),
 248 |       body: {
 249 |         code,
 250 |         client_id: this.options.client_id,
 251 |         client_secret: this.options.client_secret,
 252 |         grant_type: 'authorization_code',
 253 |         redirect_uri: redirect
 254 |       }
 255 |     })
 256 |     this.state = Object.assign({}, this.state, body)
 257 |     this.state.expires = new Date(new Date().getTime() + (body.expires_in * 1000))
 258 |     await this._save()
 259 |     return this.state.access_token
 260 |   }
 261 | 
 262 |   /// @nodoc
 263 |   async _refresh () {
 264 |     const u = new URL(this.options.base)
 265 |     u.pathname = '/v3/auth/token'
 266 |     u.query = {
 267 |       refresh_token: this.state.refresh_token,
 268 |       client_id: this.options.client_id,
 269 |       client_secret: this.options.client_secret,
 270 |       grant_type: 'refresh_token'
 271 |     }
 272 | 
 273 |     const body = await utils.qrequest({
 274 |       method: 'POST',
 275 |       uri: url.format(u)
 276 |     })
 277 |     this.state = Object.assign({}, this.state, body)
 278 |     this.state.expires = new Date(new Date().getTime() + (body.expires_in * 1000))
 279 |     await this._save()
 280 |     return this.state.access_token
 281 |   }
 282 | 
 283 |   /// @nodoc
 284 |   async _request (callback, path, method, body = null) {
 285 |     if (method == null) { method = 'GET' }
 286 |     const u = new URL(this.options.base)
 287 |     u.pathname = path
 288 | 
 289 |     const auth = await this._getAuth()
 290 |     return utils.qrequest({
 291 |       method,
 292 |       uri: url.format(u),
 293 |       headers: {
 294 |         Authorization: `OAuth ${auth}`
 295 |       },
 296 |       body,
 297 |       callback
 298 |     })
 299 |   }
 300 | 
 301 |   /// @nodoc
 302 |   async _requestURL (callback, path, method, body = null) {
 303 |     if (method == null) { method = 'GET' }
 304 |     const u = new URL(this.options.base)
 305 |     u.pathname = path
 306 |     u.query = body
 307 | 
 308 |     const auth = await this._getAuth()
 309 |     return utils.qrequest({
 310 |       method,
 311 |       uri: url.format(u),
 312 |       headers: {
 313 |         Authorization: `OAuth ${auth}`
 314 |       },
 315 |       callback
 316 |     })
 317 |   }
 318 | 
 319 |   /// @nodoc
 320 |   _normalizeTags (ary) {
 321 |     const userid = this.state.id
 322 |     return ary.map(s => _normalizeTag(s, userid))
 323 |   }
 324 | 
 325 |   /// @nodoc
 326 |   _normalizeCategories (ary) {
 327 |     const userid = this.state.id
 328 |     return ary.map(cat => {
 329 |       if (!cat.match(/^user\//)) {
 330 |         cat = `user/${userid}/category/${cat}`
 331 |       }
 332 |       return cat
 333 |     })
 334 |   }
 335 | 
 336 |   /**
 337 |    * Refresh the auth token manually.  If the current refresh token is not
 338 |    * valid, authenticate again.
 339 |    *
 340 |    * @param {Function} [cb] - Optional callback function(Error, String)
 341 |    * @returns {Promise(String)} new auth token
 342 |    */
 343 |   refresh (cb) {
 344 |     return _nodify(cb, async () => {
 345 |       await this.ready
 346 |       return this._validToken() ? this._refresh() : this._auth()
 347 |     })
 348 |   }
 349 | 
 350 |   /**
 351 |    * Discard all tokens
 352 |    *
 353 |    * @param {Function} [cb] - Optional callback function(Error)
 354 |    * @returns {Promise} completed
 355 |    */
 356 |   logout (cb) {
 357 |     return _nodify(cb, async () => {
 358 |       await this.ready
 359 | 
 360 |       const u = new URL(this.options.base)
 361 |       u.pathname = '/v3/auth/token'
 362 |       u.query = {
 363 |         refresh_token: this.state.refresh_token,
 364 |         client_id: this.options.client_id,
 365 |         client_secret: this.options.client_secret,
 366 |         grant_type: 'revoke_token'
 367 |       }
 368 | 
 369 |       const body = utils.qrequest({
 370 |         method: 'POST',
 371 |         uri: url.format(u)
 372 |       })
 373 |       delete this.state.access_token
 374 |       delete this.state.expires
 375 |       delete this.state.plan
 376 |       delete this.state.provider
 377 |       delete this.state.refresh_token
 378 |       delete this.state.token_type
 379 |       this.state = Object.assign({}, this.state, body)
 380 |       return this._save()
 381 |     })
 382 |   }
 383 | 
 384 |   /**
 385 |    * Fetch the list of categories
 386 |    *
 387 |    * @param {Function} [cb] - Optional callback function(Error, Array(Category))
 388 |    * @returns {Promise(Array(Category))} list of categories
 389 |    * @see {@link https://developer.feedly.com/v3/categories/#get-the-list-of-all-categories}
 390 |    */
 391 |   categories (cb) {
 392 |     return this._request(cb, '/v3/categories')
 393 |   }
 394 | 
 395 |   /**
 396 |    * Set the label for a category.
 397 |    *
 398 |    * @param {String} id - the category to modify
 399 |    * @param {String} label - the new label
 400 |    * @param {Function} [cb] - Optional callback function(Error)
 401 |    * @returns {Promise} Done
 402 |    * @see https://developer.feedly.com/v3/categories/#change-the-label-of-an-existing-category
 403 |    */
 404 |   setCategoryLabel (id, label, cb) {
 405 |     return this._request(
 406 |       cb,
 407 |       `/v3/categories/${encodeURIComponent(id)}`,
 408 |       'POST',
 409 |       { label })
 410 |   }
 411 | 
 412 |   /**
 413 |    * Delete a category.
 414 |    *
 415 |    * @param {String} id - the category to delete
 416 |    * @param {Function} [cb] - Optional callback function(Error)
 417 |    * @returns {Promise} Done
 418 |    * @see https://developer.feedly.com/v3/categories/#delete-a-category
 419 |    */
 420 |   deleteCategory (id, cb) {
 421 |     return this._request(
 422 |       cb,
 423 |       `/v3/categories/${encodeURIComponent(id)}`,
 424 |       'DELETE')
 425 |   }
 426 | 
 427 |   /**
 428 |    * Get one or more entries
 429 |    *
 430 |    * @param {String|Array(String)} id - the entry or entries to retrieve
 431 |    * @param {Function} [cb] - Optional callback function(Error, Entry|Array(Entry))
 432 |    * @returns {Promise(Entry)|Promise(Array(Entry))} the entry(s)
 433 |    * @see https://developer.feedly.com/v3/entries/#get-the-content-of-an-entry
 434 |    * @see https://developer.feedly.com/v3/entries/#get-the-content-for-a-dynamic-list-of-entries
 435 |    */
 436 |   entry (id, cb) {
 437 |     if (Array.isArray(id)) {
 438 |       return this._request(cb, '/v3/entries/.mget', 'POST', id)
 439 |     } else {
 440 |       return this._request(cb, `/v3/entries/${encodeURIComponent(id)}`)
 441 |     }
 442 |   }
 443 | 
 444 |   /**
 445 |    * Create an entry.  This call is useful to inject entries not coming from a
 446 |    * feed, into a user’s account. The entries created will only be available
 447 |    * through the tag streams of the respective tags passed.
 448 |    *
 449 |    * @param {Entry} entry - See the
 450 |    *   {@link http://developer.feedly.com/v3/entries/#create-and-tag-an-entry Feedly API docs}
 451 |    *   for more information.
 452 |    * @param {Function} [cb] - Optional callback function(Error)
 453 |    * @returns {Promise} Done
 454 |    * @see http://developer.feedly.com/v3/entries/#create-and-tag-an-entry
 455 |    */
 456 |   createEntry (entry, cb) {
 457 |     return this._request(cb, '/v3/entries/', 'POST', entry)
 458 |   }
 459 | 
 460 |   /**
 461 |    * Get meta-data about a feed or list of feeds
 462 |    *
 463 |    * @param {String|Array(String)} id - the ID or list of IDs of the feed(s)
 464 |    * @param {Function} [cb] - Optional callback function(Error, Feed|Array(Feed))
 465 |    * @returns {Promise(Feed)|Promise(Array(Feed))}
 466 |    * @see https://developer.feedly.com/v3/feeds/#get-the-metadata-about-a-specific-feed
 467 |    */
 468 |   feed (id, cb) {
 469 |     if (Array.isArray(id)) {
 470 |       return this._request(cb, '/v3/feeds/.mget', 'POST', id)
 471 |     } else {
 472 |       return this._request(cb, `/v3/feeds/${encodeURIComponent(id)}`)
 473 |     }
 474 |   }
 475 | 
 476 |   /**
 477 |    * Get unread counts.  In theory, newerThan and streamId can
 478 |    * be used to reduce the counts that are returned, but I didn't see evidence
 479 |    * of that in practice.
 480 |    *
 481 |    * @param {Boolean} [autorefresh] - Lets the server know if this is a background
 482 |    *   auto-refresh or not. In case of very high load on the service, the server
 483 |    *   can deny access to background requests and give priority to user facing
 484 |    *   operations.
 485 |    * @param {Date} [newerThan] - timestamp used as a lower time limit, instead of
 486 |    *   the default 30 days
 487 |    * @param {String} [streamId] - A user or system category can be passed to
 488 |    *   restrict the unread count response to feeds in this category.
 489 |    * @param {Function} [cb] - Optional callback function(Error, Counts)
 490 |    * @returns {Promise(Array(Count))}
 491 |    * @see https://developer.feedly.com/v3/markers/#get-the-list-of-unread-counts
 492 |    */
 493 |   counts (autorefresh, newerThan, streamId, cb) {
 494 |     [cb, autorefresh, newerThan, streamId] =
 495 |       _pickCB(autorefresh, newerThan, streamId, cb)
 496 | 
 497 |     let input = {}
 498 |     if (autorefresh != null) {
 499 |       input.autorefresh = autorefresh
 500 |     }
 501 |     if (newerThan != null) {
 502 |       input.newerThan = newerThan.getTime()
 503 |     }
 504 |     if (streamId != null) {
 505 |       input.streamId = streamId
 506 |     }
 507 |     if (Object.keys(input).length === 0) {
 508 |       input = null
 509 |     }
 510 |     return this._request(cb, '/v3/markers/counts', 'GET', input)
 511 |   }
 512 | 
 513 |   /**
 514 |    * Mark article(s) as read.
 515 |    *
 516 |    * @param {Array(String)|String} ids - article ID(s) to mark read
 517 |    * @param {Function} cb - Optionall callback function(Error)
 518 |    * @returns {Promise} Done
 519 |    * @see https://developer.feedly.com/v3/markers/#mark-one-or-multiple-articles-as-read
 520 |    */
 521 |   markEntryRead (ids, cb) {
 522 |     if (typeof ids === 'string') {
 523 |       ids = [ids]
 524 |     }
 525 |     return this._request(cb, '/v3/markers', 'POST', {
 526 |       entryIds: ids,
 527 |       type: 'entries',
 528 |       action: 'markAsRead'
 529 |     })
 530 |   }
 531 | 
 532 |   /**
 533 |    * Mark article(s) as unread.
 534 |    *
 535 |    * @param {Array(String)|String} ids - Article ID(s) to mark unread
 536 |    * @param {Function} [cb] - Optional callback function(Error)
 537 |    * @returns {Promise} Done
 538 |    * @see https://developer.feedly.com/v3/markers/#keep-one-or-multiple-articles-as-unread
 539 |    */
 540 |   markEntryUnread (ids, cb) {
 541 |     if (typeof ids === 'string') {
 542 |       ids = [ids]
 543 |     }
 544 |     return this._request(cb, '/v3/markers', 'POST', {
 545 |       entryIds: ids,
 546 |       type: 'entries',
 547 |       action: 'keepUnread'
 548 |     })
 549 |   }
 550 | 
 551 |   /**
 552 |    * Mark feed(s) as read.
 553 |    *
 554 |    * @param {Array(String)|String} ids - feed ID(s) to mark read
 555 |    * @param {String|Date} [since] - last entry ID read or timestamp last read
 556 |    * @param {Function} [cb] - Optional callback function(Error)
 557 |    * @returns {Promise} Done
 558 |    * @see https://developer.feedly.com/v3/markers/#mark-a-feed-as-read
 559 |    */
 560 |   markFeedRead (ids, since, cb) {
 561 |     if (typeof ids === 'string') {
 562 |       ids = [ids]
 563 |     }
 564 |     [cb, since] = _pickCB(since, cb)
 565 | 
 566 |     const body = {
 567 |       feedIds: ids,
 568 |       type: 'feeds',
 569 |       action: 'markAsRead'
 570 |     }
 571 |     if (since instanceof Date) {
 572 |       body.asOf = since.getTime()
 573 |     } else if (typeof since === 'string') {
 574 |       body.lastReadEntryId = since
 575 |     }
 576 | 
 577 |     return this._request(cb, '/v3/markers', 'POST', body)
 578 |   }
 579 | 
 580 |   /**
 581 |    * Mark category(s) as read.
 582 |    *
 583 |    * @param {Array(String)|String} ids - category ID(s) to mark read
 584 |    * @param {String|Date} [since] - last entry ID read or timestamp last read
 585 |    * @param {Function} [cb] - Optional callback function(Error)
 586 |    * @returns {Promise} Done
 587 |    * @see https://developer.feedly.com/v3/markers/#mark-a-category-as-read
 588 |    */
 589 |   markCategoryRead (ids, since, cb) {
 590 |     if (typeof ids === 'string') {
 591 |       ids = [ids]
 592 |     }
 593 |     [cb, since] = _pickCB(since, cb)
 594 | 
 595 |     const body = {
 596 |       categoryIds: this._normalizeCategories(ids),
 597 |       type: 'categories',
 598 |       action: 'markAsRead'
 599 |     }
 600 |     if (since instanceof Date) {
 601 |       body.asOf = since.getTime()
 602 |     } else if (typeof since === 'string') {
 603 |       body.lastReadEntryId = since
 604 |     }
 605 | 
 606 |     return this._request(cb, '/v3/markers', 'POST', body)
 607 |   }
 608 | 
 609 |   /**
 610 |    * Mark tag(s) as read.
 611 |    *
 612 |    * @param {Array(String)|String} ids - tag ID(s) to mark read
 613 |    * @param {String|Date} [since] - last entry ID read or timestamp last read
 614 |    * @param {Function} [cb] - Optional callback function(Error)
 615 |    * @returns {Promise} Done
 616 |    * @see https://developer.feedly.com/v3/markers/#mark-a-tag-as-read
 617 |    */
 618 |   markTagRead (ids, since, cb) {
 619 |     if (typeof ids === 'string') {
 620 |       ids = [ids]
 621 |     }
 622 |     [cb, since] = _pickCB(since, cb)
 623 | 
 624 |     const body = {
 625 |       tagIds: this._normalizeTags(ids),
 626 |       type: 'tags',
 627 |       action: 'markAsRead'
 628 |     }
 629 |     if (since instanceof Date) {
 630 |       body.asOf = since.getTime()
 631 |     } else if (typeof since === 'string') {
 632 |       body.lastReadEntryId = since
 633 |     }
 634 | 
 635 |     return this._request(cb, '/v3/markers', 'POST', body)
 636 |   }
 637 | 
 638 |   /**
 639 |    * Get the latest read operations (to sync local cache).
 640 |    *
 641 |    * @param {Date} [newerThan] - start date
 642 |    * @param {any} [cb] - Optional callback function(Error, Array(Read))
 643 |    * @returns {Promise(Array(Read))} the read operations
 644 |    * @see https://developer.feedly.com/v3/markers/#get-the-latest-read-operations-to-sync-local-cache
 645 |    */
 646 |   reads (newerThan, cb) {
 647 |     [cb, newerThan] = _pickCB(newerThan, cb)
 648 | 
 649 |     let input = null
 650 |     if (newerThan != null) {
 651 |       input = {
 652 |         newerThan: newerThan.getTime()
 653 |       }
 654 |     }
 655 |     return this._request(cb, '/v3/markers/reads', 'GET', input)
 656 |   }
 657 | 
 658 |   /**
 659 |    * Get the latest tagged entry ids
 660 |    *
 661 |    * @param {Date} [newerThan] - start date
 662 |    * @param {any} [cb] - Optional callback function(Error, Tagged)
 663 |    * @returns {Promise(Tagged)} The tags
 664 |    * @see https://developer.feedly.com/v3/markers/#get-the-latest-tagged-entry-ids
 665 |    */
 666 |   tags (newerThan, cb) {
 667 |     [cb, newerThan] = _pickCB(newerThan, cb)
 668 | 
 669 |     let input = null
 670 |     if (newerThan != null) {
 671 |       input = {
 672 |         newerThan: newerThan.getTime()
 673 |       }
 674 |     }
 675 |     return this._request(cb, '/v3/markers/tags', 'GET', input)
 676 |   }
 677 | 
 678 |   /**
 679 |    * Get the current user's preferences
 680 |    *
 681 |    * @param {Function} [cb] - Optional function(Error, Prefs)
 682 |    * @returns {Promise(Prefs)} - the preferences
 683 |    * @see https://developer.feedly.com/v3/preferences/#get-the-preferences-of-the-user
 684 |    */
 685 |   preferences (cb) {
 686 |     return this._request(cb, '/v3/preferences')
 687 |   }
 688 | 
 689 |   /**
 690 |    * Update the preferences of the user
 691 |    *
 692 |    * @param {Object} prefs - the preferences to update, use "==DELETE==”
 693 |    *   as the value in order to delete a preference.
 694 |    * @param {any} [cb] - Optional callback function(Error, Prefs)
 695 |    * @returns {Promise(Prefs)} updated preferences
 696 |    * @see https://developer.feedly.com/v3/preferences/#update-the-preferences-of-the-user
 697 |    */
 698 |   updatePreferences (prefs, cb) {
 699 |     return this._request(cb, '/v3/preferences', 'POST', prefs)
 700 |   }
 701 | 
 702 |   /**
 703 |    * Get the current user's profile
 704 |    *
 705 |    * @param {Function} [cb] - Optional callback function(Error, Profile)
 706 |    * @returns {Promise(Profile)} Profile information
 707 |    * @see https://developer.feedly.com/v3/profile/#get-the-profile-of-the-user
 708 |    */
 709 |   profile (cb) {
 710 |     return this._request(cb, '/v3/profile')
 711 |   }
 712 | 
 713 |   /**
 714 |    * Update the profile of the user
 715 |    *
 716 |    * @param {Object} profile - the profile to update.  See
 717 |    *   {@link https://developer.feedly.com/v3/profile/#update-the-profile-of-the-user Feedly API docs}
 718 |    *   for more information
 719 |    * @param {Function} [cb] - Optional callback function(Error, Profile)
 720 |    * @returns {Promise(Profile)} The updated profile
 721 |    * @see https://developer.feedly.com/v3/profile/#update-the-profile-of-the-user
 722 |    */
 723 |   updateProfile (profile, cb) {
 724 |     return this._request(cb, '/v3/profile', 'POST', profile)
 725 |   }
 726 | 
 727 |   /**
 728 |    * Find feeds based on title, url or #topic
 729 |    *
 730 |    * @param {String} query - the string to search for
 731 |    * @param {int} [results=20] - the max number of results to return
 732 |    * @param {String} [locale] - hint the search engine to return feeds in that locale (e.g. “pt”, “fr_FR”)
 733 |    * @param {Function} [cb] - Optional callback function(Error, Array(Feed))
 734 |    * @returns {Promise(Array(Feed))}
 735 |    * @see https://developer.feedly.com/v3/search/#find-feeds-based-on-title-url-or-topic
 736 |    */
 737 |   searchFeeds (query, results, locale, cb) {
 738 |     [cb, results, locale] = _pickCB(results, locale, cb)
 739 |     const req = {
 740 |       query
 741 |     }
 742 |     if (results != null) {
 743 |       req.n = results
 744 |     }
 745 |     if (locale) {
 746 |       req.locale = locale
 747 |     }
 748 | 
 749 |     return this._requestURL(cb, '/v3/search/feeds', 'GET', req)
 750 |   }
 751 | 
 752 |   /**
 753 |    * Create a shortened URL for an entry.  The short URL is unique for a given
 754 |    * entry id, user and application.
 755 |    *
 756 |    * @param {String} entryId - The entry ID to shorten
 757 |    * @param {Function} [cb] - Optional callback function(Error, String)
 758 |    * @returns {Promise(String)} the shortened URL
 759 |    * @deprecated This is no longer documented in the Feedly API
 760 |    */
 761 |   shorten (entryId, cb) {
 762 |     return this._requestURL(
 763 |       cb,
 764 |       '/v3/shorten/entries',
 765 |       'GET',
 766 |       { entryId })
 767 |   }
 768 | 
 769 |   /**
 770 |    * Get a list of entry ids for a specific stream.
 771 |    *
 772 |    * @param {String} id - the Stream ID
 773 |    * @param {String|Object} [options] - A continuation ID as a string is
 774 |    *   used to page, or an object with stream request parameters
 775 |    * @param {("newest"|"oldest")} [options.ranked="newest"] - order
 776 |    * @param {Boolean} [options.unreadOnly=false] - only unread?
 777 |    * @param {Date} [options.newerThan] - since when?
 778 |    * @param {String} [options.continuation] - continue from where you left off
 779 |    * @param {Function} [cb] - Optional callback function(Error, Page)
 780 |    * @returns {Promise(Page)}
 781 |    * @see https://developer.feedly.com/v3/streams/#get-a-list-of-entry-ids-for-a-specific-stream
 782 |    */
 783 |   stream (id, options, cb) {
 784 |     [options, cb] = _streamOptions(options, cb)
 785 |     return this._requestURL(
 786 |       cb,
 787 |       `/v3/streams/${encodeURIComponent(id)}/ids`,
 788 |       'GET',
 789 |       options)
 790 |   }
 791 | 
 792 |   /**
 793 |    * Get the content of a stream
 794 |    *
 795 |    * @param {String} id - the Stream ID
 796 |    * @param {String|Object} [options] - A continuation ID as a string is
 797 |    *   used to page, or an object with stream request parameters
 798 |    * @param {("newest"|"oldest")} [options.ranked="newest"] - order
 799 |    * @param {Boolean} [options.unreadOnly=false] - only unread?
 800 |    * @param {Date} [options.newerThan] - since when?
 801 |    * @param {String} [options.continuation] - continue from where you left off
 802 |    * @param {Function} [cb] - Optional callback function(Error, ContentPage)
 803 |    * @returns {Promise(ContentPage)}
 804 |    * @see https://developer.feedly.com/v3/streams/#get-the-content-of-a-stream
 805 |    */
 806 |   contents (id, options, cb) {
 807 |     [options, cb] = _streamOptions(options, cb)
 808 |     return this._request(
 809 |       cb,
 810 |       `/v3/streams/${encodeURIComponent(id)}/contents`,
 811 |       'GET',
 812 |       options)
 813 |   }
 814 | 
 815 |   /**
 816 |    * Get the user’s subscriptions
 817 |    *
 818 |    * @param {Function} [cb] - Optional callback function(Error, Array(Subscription))
 819 |    * @returns {Promise(Array(Subscription))}
 820 |    * @see https://developer.feedly.com/v3/subscriptions/#get-the-users-subscriptions
 821 |    */
 822 |   subscriptions (cb) {
 823 |     return this._request(cb, '/v3/subscriptions', 'GET')
 824 |   }
 825 | 
 826 |   /**
 827 |    * Subscribe to a feed
 828 |    * [{@link https://developer.feedly.com/v3/subscriptions/#subscribe-to-a-feed API doc}]
 829 |    *
 830 |    * @param {String} url - the URL of the feed to subscribe to
 831 |    * @param {String|Array(String)} [categories] - category(s) for the subscription
 832 |    * @param {String} [title] - Subscription title
 833 |    * @param {Function} cb - Optional callback function(Error)
 834 |    * @returns {Promise} Done
 835 |    * @see https://developer.feedly.com/v3/subscriptions/#subscribe-to-a-feed
 836 |    */
 837 |   subscribe (url, categories, title, cb) {
 838 |     if (!url.match(/^feed\//)) {
 839 |       url = `feed/${url}`
 840 |     }
 841 |     [cb, categories, title] = _pickCB(categories, title, cb)
 842 | 
 843 |     const input = {
 844 |       id: url
 845 |     }
 846 | 
 847 |     if (categories != null) {
 848 |       if (!Array.isArray(categories)) {
 849 |         categories = [categories]
 850 |       }
 851 |       const userid = this.state.id
 852 |       categories = categories.map(c => {
 853 |         if (typeof c !== 'string') {
 854 |           return c
 855 |         }
 856 |         let id = null
 857 |         let name = null
 858 |         const m = c.match(/^user\/[^/]+\/(.*)/)
 859 |         if (!m) {
 860 |           id = `user/${userid}/category/${c}`
 861 |           name = c
 862 |         } else {
 863 |           id = c
 864 |           name = m[1]
 865 |         }
 866 |         return {
 867 |           id,
 868 |           name
 869 |         }
 870 |       })
 871 |       input.categories = categories
 872 |     }
 873 |     if (title) {
 874 |       input.title = title
 875 |     }
 876 |     return this._request(cb, '/v3/subscriptions', 'POST', input)
 877 |   }
 878 | 
 879 |   /**
 880 |    * Unsubscribe from a feed
 881 |    *
 882 |    * @param {String} id - Feed ID
 883 |    * @param {Function} [cb] - Optional callback function(Error)
 884 |    * @returns {Promise} Done
 885 |    * @see https://developer.feedly.com/v3/subscriptions/#unsubscribe-from-a-feed
 886 |    */
 887 |   unsubscribe (id, cb) {
 888 |     // TODO: add support for mass unsubscribe
 889 |     return this._request(
 890 |       cb,
 891 |       `/v3/subscriptions/${encodeURIComponent(id)}`,
 892 |       'DELETE')
 893 |   }
 894 | 
 895 |   /**
 896 |    * Tag an existing entry or entries
 897 |    *
 898 |    * @param {String|Array(String)} entry - the entry(s) to tag
 899 |    * @param {String|Array(String)} tags - the tag(s) to apply to the entry
 900 |    * @param {Function} cb - Optional callback function(Error)
 901 |    * @returns {Promise} Done
 902 |    * @see https://developer.feedly.com/v3/tags/#tag-an-existing-entry
 903 |    * @see https://developer.feedly.com/v3/tags/#tag-multiple-entries-alternate
 904 |    */
 905 |   tagEntry (entry, tags, cb) {
 906 |     if (!Array.isArray(tags)) {
 907 |       tags = [tags]
 908 |     }
 909 |     tags = this._normalizeTags(tags)
 910 |     if (Array.isArray(entry)) {
 911 |       return this._request(
 912 |         cb,
 913 |         `/v3/tags/${tags.join(',')}`,
 914 |         'PUT',
 915 |         { entryIds: entry })
 916 |     } else {
 917 |       return this._request(
 918 |         cb,
 919 |         `/v3/tags/${tags.join(',')}`,
 920 |         'PUT',
 921 |         { entryId: entry })
 922 |     }
 923 |   }
 924 | 
 925 |   /**
 926 |    * Change a tag label
 927 |    *
 928 |    * @param {String} tag - the tag to modify
 929 |    * @param {String} label - new label for the tag
 930 |    * @param {Function} cb - Optional callback function(Error)
 931 |    * @returns {Promise} Done
 932 |    * @see https://developer.feedly.com/v3/tags/#change-a-tag-label
 933 |    */
 934 |   setTagLabel (tag, label, cb) {
 935 |     tag = _normalizeTag(tag, this.state.id)
 936 |     return this._request(
 937 |       cb,
 938 |       `/v3/tags/${tag}`,
 939 |       'POST',
 940 |       { label })
 941 |   }
 942 | 
 943 |   /**
 944 |    * Untag entries
 945 |    *
 946 |    * @param {String|Array(String)} entries - the ID(s) of the entries to modify
 947 |    * @param {String|Array(String)} tags - the tag(s) to remove
 948 |    * @param {Function} cb - Optional callback function(Error)
 949 |    * @returns {Promise} Done
 950 |    * @see https://developer.feedly.com/v3/tags/#untag-multiple-entries
 951 |    */
 952 |   untagEntries (entries, tags, cb) {
 953 |     if (!Array.isArray(entries)) {
 954 |       entries = [entries]
 955 |     }
 956 |     entries = entries.map(e => encodeURIComponent(e))
 957 | 
 958 |     if (!Array.isArray(tags)) {
 959 |       tags = [tags]
 960 |     }
 961 |     tags = this._normalizeTags(tags)
 962 | 
 963 |     return this._request(
 964 |       cb,
 965 |       `/v3/tags/${tags.join(',')}/${entries.join(',')}`,
 966 |       'DELETE')
 967 |   }
 968 | 
 969 |   /**
 970 |    * Delete tags
 971 |    *
 972 |    * @param {String|Array(String)} tags - the tag(s) to remove
 973 |    * @param {any} cb - Optional callback function(Error)
 974 |    * @returns {Promise} Done
 975 |    * @see https://developer.feedly.com/v3/tags/#delete-tags
 976 |    */
 977 |   deleteTags (tags, cb) {
 978 |     if (!Array.isArray(tags)) {
 979 |       tags = [tags]
 980 |     }
 981 |     tags = this._normalizeTags(tags)
 982 |     return this._request(cb, `/v3/tags/${tags.join(',')}`, 'DELETE')
 983 |   }
 984 | }
 985 | 
 986 | module.exports = Feedly
 987 | 
988 |
989 |
990 | 991 | 992 | 993 | 994 |
995 | 996 |
997 | 998 |
999 | Generated by JSDoc 4.0.4 using the Minami theme. 1000 |
1001 | 1002 | 1003 | 1004 | 1005 | 1006 | --------------------------------------------------------------------------------