├── .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 |Feedly authentication result
20 |Please close this window.
21 | 22 | -------------------------------------------------------------------------------- /tools/waiting.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |npm run dev. Please be patient.This is a node API for Feedly
52 |Install from NPM:
54 |npm install --save feedly
55 |
56 | 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 | 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).
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.
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:
const results = await f.reads()
90 |
91 | Full documentation for the API can be found 93 | here.
| Name | 192 | 193 | 194 |Type | 195 | 196 | 197 | 198 | 199 | 200 |Description | 201 |
|---|---|---|
error |
210 |
211 |
212 |
213 |
214 |
215 | Error
216 |
217 |
218 |
219 | |
220 |
221 |
222 |
223 |
224 |
225 | 226 | null if no error 227 | 228 | | 229 |
| Name | 327 | 328 | 329 |Type | 330 | 331 | 332 | 333 | 334 | 335 |Description | 336 |
|---|---|---|
error |
345 |
346 |
347 |
348 |
349 |
350 | Error
351 |
352 |
353 |
354 | |
355 |
356 |
357 |
358 |
359 |
360 | 361 | null if no error 362 | 363 | | 364 |
str |
371 |
372 |
373 |
374 |
375 |
376 | String
377 |
378 |
379 |
380 | |
381 |
382 |
383 |
384 |
385 |
386 | 387 | the returned string if no error 388 | 389 | | 390 |
'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 |