├── .gitignore
├── LICENSE.txt
├── README.md
├── bin
    └── scratch-client.js
├── lib
    ├── comments.js
    ├── homepage.js
    ├── list.js
    ├── messages.js
    ├── paged-list.js
    ├── profiles.js
    ├── projects.js
    ├── studios.js
    ├── thumbs.js
    └── util.js
├── package-lock.json
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | .scratchSession
2 | node_modules
3 | 
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
  1 |                     GNU GENERAL PUBLIC LICENSE
  2 |                        Version 3, 29 June 2007
  3 | 
  4 |  Copyright (C) 2007 Free Software Foundation, Inc. 
  5 |  Everyone is permitted to copy and distribute verbatim copies
  6 |  of this license document, but changing it is not allowed.
  7 | 
  8 |                             Preamble
  9 | 
 10 |   The GNU General Public License is a free, copyleft license for
 11 | software and other kinds of works.
 12 | 
 13 |   The licenses for most software and other practical works are designed
 14 | to take away your freedom to share and change the works.  By contrast,
 15 | the GNU General Public License is intended to guarantee your freedom to
 16 | share and change all versions of a program--to make sure it remains free
 17 | software for all its users.  We, the Free Software Foundation, use the
 18 | GNU General Public License for most of our software; it applies also to
 19 | any other work released this way by its authors.  You can apply it to
 20 | your programs, too.
 21 | 
 22 |   When we speak of free software, we are referring to freedom, not
 23 | price.  Our General Public Licenses are designed to make sure that you
 24 | have the freedom to distribute copies of free software (and charge for
 25 | them if you wish), that you receive source code or can get it if you
 26 | want it, that you can change the software or use pieces of it in new
 27 | free programs, and that you know you can do these things.
 28 | 
 29 |   To protect your rights, we need to prevent others from denying you
 30 | these rights or asking you to surrender the rights.  Therefore, you have
 31 | certain responsibilities if you distribute copies of the software, or if
 32 | you modify it: responsibilities to respect the freedom of others.
 33 | 
 34 |   For example, if you distribute copies of such a program, whether
 35 | gratis or for a fee, you must pass on to the recipients the same
 36 | freedoms that you received.  You must make sure that they, too, receive
 37 | or can get the source code.  And you must show them these terms so they
 38 | know their rights.
 39 | 
 40 |   Developers that use the GNU GPL protect your rights with two steps:
 41 | (1) assert copyright on the software, and (2) offer you this License
 42 | giving you legal permission to copy, distribute and/or modify it.
 43 | 
 44 |   For the developers' and authors' protection, the GPL clearly explains
 45 | that there is no warranty for this free software.  For both users' and
 46 | authors' sake, the GPL requires that modified versions be marked as
 47 | changed, so that their problems will not be attributed erroneously to
 48 | authors of previous versions.
 49 | 
 50 |   Some devices are designed to deny users access to install or run
 51 | modified versions of the software inside them, although the manufacturer
 52 | can do so.  This is fundamentally incompatible with the aim of
 53 | protecting users' freedom to change the software.  The systematic
 54 | pattern of such abuse occurs in the area of products for individuals to
 55 | use, which is precisely where it is most unacceptable.  Therefore, we
 56 | have designed this version of the GPL to prohibit the practice for those
 57 | products.  If such problems arise substantially in other domains, we
 58 | stand ready to extend this provision to those domains in future versions
 59 | of the GPL, as needed to protect the freedom of users.
 60 | 
 61 |   Finally, every program is threatened constantly by software patents.
 62 | States should not allow patents to restrict development and use of
 63 | software on general-purpose computers, but in those that do, we wish to
 64 | avoid the special danger that patents applied to a free program could
 65 | make it effectively proprietary.  To prevent this, the GPL assures that
 66 | patents cannot be used to render the program non-free.
 67 | 
 68 |   The precise terms and conditions for copying, distribution and
 69 | modification follow.
 70 | 
 71 |                        TERMS AND CONDITIONS
 72 | 
 73 |   0. Definitions.
 74 | 
 75 |   "This License" refers to version 3 of the GNU General Public License.
 76 | 
 77 |   "Copyright" also means copyright-like laws that apply to other kinds of
 78 | works, such as semiconductor masks.
 79 | 
 80 |   "The Program" refers to any copyrightable work licensed under this
 81 | License.  Each licensee is addressed as "you".  "Licensees" and
 82 | "recipients" may be individuals or organizations.
 83 | 
 84 |   To "modify" a work means to copy from or adapt all or part of the work
 85 | in a fashion requiring copyright permission, other than the making of an
 86 | exact copy.  The resulting work is called a "modified version" of the
 87 | earlier work or a work "based on" the earlier work.
 88 | 
 89 |   A "covered work" means either the unmodified Program or a work based
 90 | on the Program.
 91 | 
 92 |   To "propagate" a work means to do anything with it that, without
 93 | permission, would make you directly or secondarily liable for
 94 | infringement under applicable copyright law, except executing it on a
 95 | computer or modifying a private copy.  Propagation includes copying,
 96 | distribution (with or without modification), making available to the
 97 | public, and in some countries other activities as well.
 98 | 
 99 |   To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies.  Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 | 
103 |   An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License.  If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 | 
112 |   1. Source Code.
113 | 
114 |   The "source code" for a work means the preferred form of the work
115 | for making modifications to it.  "Object code" means any non-source
116 | form of a work.
117 | 
118 |   A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 | 
123 |   The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form.  A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 | 
134 |   The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities.  However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work.  For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 | 
147 |   The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 | 
151 |   The Corresponding Source for a work in source code form is that
152 | same work.
153 | 
154 |   2. Basic Permissions.
155 | 
156 |   All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met.  This License explicitly affirms your unlimited
159 | permission to run the unmodified Program.  The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work.  This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 | 
164 |   You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force.  You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright.  Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 | 
175 |   Conveying under any other circumstances is permitted solely under
176 | the conditions stated below.  Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 | 
179 |   3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 | 
181 |   No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 | 
187 |   When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 | 
195 |   4. Conveying Verbatim Copies.
196 | 
197 |   You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 | 
205 |   You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 | 
208 |   5. Conveying Modified Source Versions.
209 | 
210 |   You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 | 
214 |     a) The work must carry prominent notices stating that you modified
215 |     it, and giving a relevant date.
216 | 
217 |     b) The work must carry prominent notices stating that it is
218 |     released under this License and any conditions added under section
219 |     7.  This requirement modifies the requirement in section 4 to
220 |     "keep intact all notices".
221 | 
222 |     c) You must license the entire work, as a whole, under this
223 |     License to anyone who comes into possession of a copy.  This
224 |     License will therefore apply, along with any applicable section 7
225 |     additional terms, to the whole of the work, and all its parts,
226 |     regardless of how they are packaged.  This License gives no
227 |     permission to license the work in any other way, but it does not
228 |     invalidate such permission if you have separately received it.
229 | 
230 |     d) If the work has interactive user interfaces, each must display
231 |     Appropriate Legal Notices; however, if the Program has interactive
232 |     interfaces that do not display Appropriate Legal Notices, your
233 |     work need not make them do so.
234 | 
235 |   A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit.  Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 | 
245 |   6. Conveying Non-Source Forms.
246 | 
247 |   You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 | 
252 |     a) Convey the object code in, or embodied in, a physical product
253 |     (including a physical distribution medium), accompanied by the
254 |     Corresponding Source fixed on a durable physical medium
255 |     customarily used for software interchange.
256 | 
257 |     b) Convey the object code in, or embodied in, a physical product
258 |     (including a physical distribution medium), accompanied by a
259 |     written offer, valid for at least three years and valid for as
260 |     long as you offer spare parts or customer support for that product
261 |     model, to give anyone who possesses the object code either (1) a
262 |     copy of the Corresponding Source for all the software in the
263 |     product that is covered by this License, on a durable physical
264 |     medium customarily used for software interchange, for a price no
265 |     more than your reasonable cost of physically performing this
266 |     conveying of source, or (2) access to copy the
267 |     Corresponding Source from a network server at no charge.
268 | 
269 |     c) Convey individual copies of the object code with a copy of the
270 |     written offer to provide the Corresponding Source.  This
271 |     alternative is allowed only occasionally and noncommercially, and
272 |     only if you received the object code with such an offer, in accord
273 |     with subsection 6b.
274 | 
275 |     d) Convey the object code by offering access from a designated
276 |     place (gratis or for a charge), and offer equivalent access to the
277 |     Corresponding Source in the same way through the same place at no
278 |     further charge.  You need not require recipients to copy the
279 |     Corresponding Source along with the object code.  If the place to
280 |     copy the object code is a network server, the Corresponding Source
281 |     may be on a different server (operated by you or a third party)
282 |     that supports equivalent copying facilities, provided you maintain
283 |     clear directions next to the object code saying where to find the
284 |     Corresponding Source.  Regardless of what server hosts the
285 |     Corresponding Source, you remain obligated to ensure that it is
286 |     available for as long as needed to satisfy these requirements.
287 | 
288 |     e) Convey the object code using peer-to-peer transmission, provided
289 |     you inform other peers where the object code and Corresponding
290 |     Source of the work are being offered to the general public at no
291 |     charge under subsection 6d.
292 | 
293 |   A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 | 
297 |   A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling.  In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage.  For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product.  A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 | 
310 |   "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source.  The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 | 
318 |   If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information.  But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 | 
329 |   The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed.  Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 | 
337 |   Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 | 
343 |   7. Additional Terms.
344 | 
345 |   "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law.  If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 | 
354 |   When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it.  (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.)  You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 | 
361 |   Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 | 
365 |     a) Disclaiming warranty or limiting liability differently from the
366 |     terms of sections 15 and 16 of this License; or
367 | 
368 |     b) Requiring preservation of specified reasonable legal notices or
369 |     author attributions in that material or in the Appropriate Legal
370 |     Notices displayed by works containing it; or
371 | 
372 |     c) Prohibiting misrepresentation of the origin of that material, or
373 |     requiring that modified versions of such material be marked in
374 |     reasonable ways as different from the original version; or
375 | 
376 |     d) Limiting the use for publicity purposes of names of licensors or
377 |     authors of the material; or
378 | 
379 |     e) Declining to grant rights under trademark law for use of some
380 |     trade names, trademarks, or service marks; or
381 | 
382 |     f) Requiring indemnification of licensors and authors of that
383 |     material by anyone who conveys the material (or modified versions of
384 |     it) with contractual assumptions of liability to the recipient, for
385 |     any liability that these contractual assumptions directly impose on
386 |     those licensors and authors.
387 | 
388 |   All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10.  If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term.  If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 | 
398 |   If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 | 
403 |   Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 | 
407 |   8. Termination.
408 | 
409 |   You may not propagate or modify a covered work except as expressly
410 | provided under this License.  Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 | 
415 |   However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 | 
422 |   Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 | 
429 |   Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License.  If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 | 
435 |   9. Acceptance Not Required for Having Copies.
436 | 
437 |   You are not required to accept this License in order to receive or
438 | run a copy of the Program.  Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance.  However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work.  These actions infringe copyright if you do
443 | not accept this License.  Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 | 
446 |   10. Automatic Licensing of Downstream Recipients.
447 | 
448 |   Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License.  You are not responsible
451 | for enforcing compliance by third parties with this License.
452 | 
453 |   An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations.  If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 | 
463 |   You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License.  For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 | 
471 |   11. Patents.
472 | 
473 |   A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based.  The
475 | work thus licensed is called the contributor's "contributor version".
476 | 
477 |   A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version.  For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 | 
487 |   Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 | 
492 |   In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement).  To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 | 
499 |   If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients.  "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 | 
513 |   If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 | 
521 |   A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License.  You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 | 
536 |   Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 | 
540 |   12. No Surrender of Others' Freedom.
541 | 
542 |   If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License.  If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all.  For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 | 
552 |   13. Use with the GNU Affero General Public License.
553 | 
554 |   Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work.  The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 | 
563 |   14. Revised Versions of this License.
564 | 
565 |   The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time.  Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 | 
570 |   Each version is given a distinguishing version number.  If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation.  If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 | 
579 |   If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 | 
584 |   Later license versions may give you additional or different
585 | permissions.  However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 | 
589 |   15. Disclaimer of Warranty.
590 | 
591 |   THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 | 
600 |   16. Limitation of Liability.
601 | 
602 |   IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 | 
612 |   17. Interpretation of Sections 15 and 16.
613 | 
614 |   If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 | 
621 |                      END OF TERMS AND CONDITIONS
622 | 
623 |             How to Apply These Terms to Your New Programs
624 | 
625 |   If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 | 
629 |   To do so, attach the following notices to the program.  It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 | 
634 |     
635 |     Copyright (C)   
636 | 
637 |     This program is free software: you can redistribute it and/or modify
638 |     it under the terms of the GNU General Public License as published by
639 |     the Free Software Foundation, either version 3 of the License, or
640 |     (at your option) any later version.
641 | 
642 |     This program is distributed in the hope that it will be useful,
643 |     but WITHOUT ANY WARRANTY; without even the implied warranty of
644 |     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
645 |     GNU General Public License for more details.
646 | 
647 |     You should have received a copy of the GNU General Public License
648 |     along with this program.  If not, see .
649 | 
650 | Also add information on how to contact you by electronic and paper mail.
651 | 
652 |   If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 | 
655 |       Copyright (C)   
656 |     This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 |     This is free software, and you are welcome to redistribute it
658 |     under certain conditions; type `show c' for details.
659 | 
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License.  Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 | 
664 |   You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 | 
669 |   The GNU General Public License does not permit incorporating your program
670 | into proprietary programs.  If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library.  If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License.  But first, please read
674 | .
675 | 
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # scratch-client-omg
 2 | 
 3 | Scratch was crashing Firefox (nightly) for a while and I needed a way to view and reply to comments. This is the code I used. Also, it's all absolutely free-as-in-freedom, unlike most of the Scratch website (of which the client is now partially, but not completely, open source).
 4 | 
 5 | 
 6 | 
 7 | ### Install
 8 | 
 9 | ```
10 | $ git clone https://github.com/towerofnix/scratch-client-omg.git
11 | $ cd scratch-client-omg
12 | $ npm install
13 | ```
14 | 
15 | ### Usage
16 | 
17 | ```
18 | $ node .
19 | help
20 | 
21 | $ node . griffpatch
22 | griffpatch  Scratcher; Joined Oct 23 2012
23 | ...
24 | 
25 | $ node . 46587498 project
26 | Scratcharia v2.8.3
27 | by griffpatch; id: 46587498
28 | ...
29 | ```
30 | 
31 | ## Supported features
32 | 
33 | This client is definitely not a feature-complete clone of the Scratch web client, but it does support the following:
34 | 
35 | * Projects:
36 |   * Read title, publish date, tags
37 |   * Read instructions, notes and credits
38 |   * Read and jump to original projects of remixes
39 |   * View love-it and favorite counts
40 |   * View thumbnail (directly or copy URL)
41 |   * View project ID, which can be used alongside programs that don't use Flash, like [Scratch 3.0](https://llk.github.io/scratch-gui/) or [Phosphorus](https://phosphorus.github.io/)
42 |   * Browse studio list
43 |   * Leave and view comments; delete any comments on your own project
44 | * User profiles:
45 |   * Read username, location/country, rank, join date, project count
46 |   * Read and update "What I'm working on" and "About me"
47 |   * View avatar (directly or copy URL)
48 |   * View and jump to featured project (including custom label, e.g. "Work in progress")
49 |   * List and browse shared and favorite projects, followed users and followers, followed and curated studios
50 |   * Leave and view comments; delete any comments if on your own profile
51 | * Studios:
52 |   * Read title, project count
53 |   * Read description
54 |   * View thumbnail (directly or copy URL)
55 |   * View activity (jump to actors, projects, comments, etc)
56 |   * List and browse managers, curators, and projects
57 |   * Leave and view comments; delete your own comments if you are a manager of the studio
58 | * Messages:
59 |   * List recent messages
60 |   * Jump to any page of messages (the Scratch site doesn't have a way for you to do this!)
61 |   * Interact with messages: view favorited project, jump to page where a comment was posted, etc.
62 | * Comments, generally:
63 |   * Read top-level comments and browse their replies
64 |   * Send top-level comments and reply to existing comments
65 |   * View the selected comment's ID, and jump to a comment by its ID
66 |   * Delete comments
67 | 
--------------------------------------------------------------------------------
/bin/scratch-client.js:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env node
 2 | 
 3 | 'use strict'
 4 | 
 5 | const fs = require('fs')
 6 | const readline = require('readline')
 7 | const Scratch = require('scratch-api')
 8 | const homepage = require('../lib/homepage')
 9 | const messages = require('../lib/messages')
10 | const profiles = require('../lib/profiles')
11 | const projects = require('../lib/projects')
12 | const studios = require('../lib/studios')
13 | const util = require('../lib/util')
14 | 
15 | const { promisify } = require('util')
16 | const writeFile = promisify(fs.writeFile)
17 | 
18 | function login() {
19 |   return new Promise((resolve, reject) => {
20 |     Scratch.UserSession.load(function(err, user) {
21 |       if (err) {
22 |         reject(err)
23 |       } else {
24 |         resolve(user)
25 |       }
26 |     })
27 |   })
28 | }
29 | 
30 | module.exports.main = async function() {
31 |   let us
32 | 
33 |   try {
34 |     us = await login()
35 |   } catch (err) {
36 |     if (err.message === 'canceled') {
37 |       console.log('')
38 |       return
39 |     } else if (err.message.toLowerCase().startsWith('incorrect')) {
40 |       console.log('Sorry, that\'s not the right username or password. Re-run and try again?')
41 |       return
42 |     } else {
43 |       throw err
44 |     }
45 |   }
46 | 
47 |   console.log(`\x1b[1mYou are logged in as \x1b[34;1m${us.username}\x1b[0;1m.\x1b[0m`)
48 |   console.log('')
49 | 
50 |   const rl = readline.createInterface({
51 |     input: process.stdin, output: process.stdout
52 |   })
53 | 
54 |   if (process.argv[2] === 'messages') {
55 |     await messages.browse({rl, us})
56 |   } else if (process.argv[2] === 'debug' && process.argv[3] === 'really') {
57 |     const file = '/tmp/' + Math.random() + '.txt'
58 |     await writeFile(file, await util.getAuthToken(us))
59 |     console.log('Auth token:', file)
60 |   } else if (process.argv[2]) {
61 |     const pageId = process.argv[2] || us.username
62 |     const pageType = process.argv[3] || 'user'
63 |     if (pageType === 'user') {
64 |       await profiles.browse({rl, us, username: pageId})
65 |     } else if (pageType === 'project') {
66 |       await projects.browse({rl, us, id: pageId})
67 |     } else if (pageType === 'studio') {
68 |       await studios.browse({rl, us, id: pageId})
69 |     }
70 |   } else {
71 |     await homepage.browse({rl, us})
72 |   }
73 | 
74 |   rl.close()
75 | }
76 | 
77 | if (require.main === module) {
78 |   module.exports.main().catch(err => console.error(err))
79 | }
80 | 
--------------------------------------------------------------------------------
/lib/comments.js:
--------------------------------------------------------------------------------
  1 | 'use strict'
  2 | 
  3 | // Number of comments shown in a "page", aka the max number of comments which
  4 | // can be fetched in a single request.
  5 | const PAGE_LENGTH = 40
  6 | 
  7 | const cheerio = require('cheerio')
  8 | const fetch = require('node-fetch')
  9 | const profiles = require('./profiles')
 10 | const util = require('./util')
 11 | 
 12 | module.exports.browse = async function({rl, us, pageType, pageId, pageObj = null, jumpTo = null, commentsEnabled = true}) {
 13 |   let currentPageNumber = 1
 14 | 
 15 |   if (commentsEnabled) {
 16 |     console.log(
 17 |       `\x1b[1mYou will leave comments as \x1b[34;1m${us.username}\x1b[0;1m.\x1b[0m`)
 18 |   } else {
 19 |     console.log(
 20 |       '\x1b[31mSending new comments has been disabled here, but you can browse existing ones.\x1b[0m')
 21 |   }
 22 | 
 23 |   if (jumpTo) {
 24 |     process.stdout.write(`Finding comment ${jumpTo}...`)
 25 |   }
 26 | 
 27 |   let currentComment = null, comments = []
 28 |   let noMoreComments = false, quit = false
 29 | 
 30 |   const jumpToComment = async function(id) {
 31 |     // Try to find the comment from what we already have loaded
 32 |     // (if anything)...
 33 |     const c = findComment(comments, id)
 34 |     if (c) {
 35 |       currentComment = c
 36 |     } else {
 37 |       // If it's not found immediately, search for it.
 38 | 
 39 |       process.stdout.write(`Finding comment ${id}...`)
 40 | 
 41 |       const { comments, jumpedComment, pageNum } = await fetchComments(
 42 |         pageType, pageId, currentPageNumber, id
 43 |       )
 44 | 
 45 |       comments.push(...comments)
 46 |       setupNextPreviousLinks(comments)
 47 |       currentPageNumber = pageNum
 48 | 
 49 |       if (jumpedComment) {
 50 |         console.log('Found!')
 51 |         currentComment = jumpedComment
 52 |       } else {
 53 |         console.log(' Comment not found, sorry.')
 54 |         if (!currentComment) {
 55 |           currentComment = comments[0]
 56 |         }
 57 |       }
 58 |     }
 59 |   }
 60 | 
 61 |   if (jumpTo) {
 62 |     await jumpToComment(jumpTo)
 63 |   } else {
 64 |     comments = (await fetchComments(pageType, pageId, 1)).comments
 65 |     currentComment = comments[0]
 66 |   }
 67 | 
 68 |   // Same logic as in "load more comments" - if there are less than
 69 |   // PAGE_LENGTH comments fetched, then we have definitely fetched them
 70 |   // all.
 71 |   if (comments.length < PAGE_LENGTH) {
 72 |     noMoreComments = true
 73 |   }
 74 | 
 75 |   while (!quit) {
 76 |     if (currentComment) {
 77 |       const { author, content, date, replies, id } = currentComment
 78 |       console.log(`\x1b[2m${date}  (ID: ${id})\x1b[0m`)
 79 |       console.log(`\x1b[34;1m${author}\x1b[0m: ${content}`)
 80 | 
 81 |       if (replies) {
 82 |         const len = replies.length
 83 |         if (len) {
 84 |           console.log(`\x1b[2m${len} repl${len === 1 ? 'y' : 'ies'}\x1b[0m`)
 85 |         }
 86 |       }
 87 |     } else {
 88 |       console.log('There are no comments here, yet.')
 89 |     }
 90 | 
 91 |     const commentsDisabledChoice = {
 92 |       invisible: true,
 93 |       action: async () => {
 94 |         console.log('\x1b[31mSorry, commenting is disabled here.\x1b[0m')
 95 |         await util.delay()
 96 |       }
 97 |     }
 98 | 
 99 |     const cc = currentComment
100 |     await util.choose({rl, us}, {
101 |       q: {
102 |         help: 'Quit browsing comments.',
103 |         longcodes: ['quit', 'back'],
104 |         action: () => {
105 |           quit = true
106 |         }
107 |       },
108 | 
109 |       w: (us && !(cc && cc.parent)) ? commentsEnabled ? {
110 |         help: 'Write a new comment, to be sent to the top of this comment section.',
111 |         longcodes: ['write', 'new'],
112 |         action: async () => {
113 |           const comment = await commentPrompt({
114 |             rl, us, pageType, pageId,
115 |             promptStr: `Comment, as ${us.username}: `
116 |           })
117 | 
118 |           if (comment) {
119 |             console.log('Sent.')
120 |             comments.unshift(comment)
121 |             setupNextPreviousLinks(comments)
122 |             currentComment = comment
123 |           }
124 |         }
125 |       } : commentsDisabledChoice : undefined,
126 | 
127 |       n: (cc && cc.next) ? {
128 |         help: 'View next comment.',
129 |         longcodes: ['next'],
130 |         action: () => {
131 |           currentComment = currentComment.next
132 |         }
133 |       } : undefined,
134 | 
135 |       p: (cc && cc.previous) ? {
136 |         help: 'View previous comment.',
137 |         longcodes: ['prev', 'previous'],
138 |         action: () => {
139 |           currentComment = currentComment.previous
140 |         }
141 |       } : undefined,
142 | 
143 |       i: (cc && cc.replies && cc.replies.length) ? {
144 |         help: 'View replies.',
145 |         longcodes: ['in', 'replies'],
146 |         action: () => {
147 |           currentComment = currentComment.replies[0]
148 |         }
149 |       } : undefined,
150 | 
151 |       I: (cc && cc.replies && cc.replies.length > 1) ? {
152 |         help: 'View the most recent reply.',
153 |         longcodes: ['last', 'lastreply'],
154 |         action: () => {
155 |           currentComment = currentComment.replies[currentComment.replies.length - 1]
156 |         }
157 |       } : undefined,
158 | 
159 |       o: (cc && cc.parent) ? {
160 |         help: 'Go out of this reply thread.',
161 |         longcodes: ['out', 'top'],
162 |         action: () => {
163 |           currentComment = currentComment.parent
164 |         }
165 |       } : undefined,
166 | 
167 |       a: cc ? {
168 |         help: `Browse the profile of this user, \x1b[34;1m${currentComment.author}\x1b[0m.`,
169 |         longcodes: ['author', 'profile'],
170 |         action: async () => {
171 |           await profiles.browse({rl, us, username: currentComment.author})
172 |         }
173 |       } : undefined,
174 | 
175 |       m: !noMoreComments ? {
176 |         help: 'Load more comments.',
177 |         longcodes: ['more'],
178 |         action: async () => {
179 |           const { comments: newComments } = await fetchComments(pageType, pageId, ++currentPageNumber)
180 |           if (newComments.length) {
181 |             comments.push(...newComments)
182 |             setupNextPreviousLinks(comments)
183 |             currentComment = newComments[0]
184 | 
185 |             // If there are less than PAGE_LENGTH comments returned, we have
186 |             // definitely fetched all the comments. This isn't able to detect
187 |             // the case of there being an exact multiple of PAGE_LENGTH
188 |             // comments in total, but that's relatively rare (1/PAGE_LENGTH
189 |             // probability), and is handled by the below "else".
190 |             if (newComments.length < PAGE_LENGTH) {
191 |               noMoreComments = true
192 |             }
193 |           } else {
194 |             console.log('There are no more comments.')
195 |             noMoreComments = true
196 |           }
197 |         }
198 |       } : undefined,
199 | 
200 |       j: {
201 |         help: 'Jump to a comment by its ID.',
202 |         longcodes: ['jump'],
203 |         action: async () => {
204 |           const id = await util.prompt(rl, 'Comment ID: ')
205 |           if (id) {
206 |             await jumpToComment(id)
207 |           }
208 |         }
209 |       },
210 | 
211 |       d: (us && cc && (
212 |         (pageType === 'gallery' && cc.author === us.username && pageObj.areWeAnOwner) ||
213 |         (pageType === 'user' && pageId === us.username) ||
214 |         (pageType === 'project' && pageObj.author === us.username)
215 |       )) ? {
216 |         help: 'Delete this comment.',
217 |         longcodes: ['delete', 'remove'],
218 |         action: async () => {
219 |           if (await util.confirm(rl, `Really delete "${currentComment.content}"? `)) {
220 |             await fetch(`${util.urls.siteAPI}/comments/${pageType}/${pageId}/del/`, {
221 |               method: 'POST',
222 |               body: JSON.stringify({id: currentComment.id}),
223 |               headers: util.makeFetchHeaders(us)
224 |             })
225 | 
226 |             if (currentComment.parent) {
227 |               const index = cc.parent.replies.indexOf(cc)
228 |               cc.parent.replies.splice(index, 1)
229 |               setupNextPreviousLinks(currentComment.parent)
230 |               currentComment = cc.parent.replies[index]
231 |               if (!currentComment) {
232 |                 currentComment = cc.parent
233 |               }
234 |             } else {
235 |               const index = comments.indexOf(currentComment)
236 |               comments.splice(index, 1)
237 |               setupNextPreviousLinks(comments)
238 |               currentComment = comments[index]
239 |             }
240 |             console.log('Deleted the comment!')
241 |           } else {
242 |             console.log('Okay, the comment wasn\'t deleted.')
243 |           }
244 |         }
245 |       } : undefined,
246 | 
247 |       r: (us && cc) ? commentsEnabled ? {
248 |         help: 'Reply to this comment.',
249 |         longcodes: ['reply'],
250 |         action: async () => {
251 |           const reply = await commentPrompt({rl, us, pageType, pageId,
252 |             commenteeId: currentComment.authorId,
253 |             parent: currentComment.threadTopComment,
254 |             promptStr: `Reply with, as ${us.username}: `
255 |           })
256 | 
257 |           if (reply) {
258 |             const replies = currentComment.parent ? currentComment.parent.replies : currentComment.replies
259 |             replies.push(reply)
260 |             setupNextPreviousLinks(replies)
261 | 
262 |             currentComment = reply
263 |           }
264 |         }
265 |       } : commentsDisabledChoice : undefined
266 |     })
267 |   }
268 | }
269 | 
270 | async function fetchComments(type, id, page = 1, jumpTo = null) {
271 |   const comments = await (
272 |     fetch(`${util.urls.siteAPI}/comments/${type}/${id}/?page=${page}&limit=${PAGE_LENGTH}`)
273 |       .then(res => res.text())
274 |       .then(html => parseComments(html))
275 |   )
276 | 
277 |   if (jumpTo) {
278 |     const jumpedComment = findComment(comments, jumpTo)
279 |     if (jumpedComment) {
280 |       return {comments, jumpedComment}
281 |     } else {
282 |       if (comments.length) {
283 |         process.stdout.write('.') // For the progress bar.
284 |         const res = await fetchComments(type, id, page + 1, jumpTo)
285 |         return {
286 |           comments: setupNextPreviousLinks(comments.concat(res.comments)),
287 |           jumpedComment: res.jumpedComment,
288 |           pageNum: res.pageNum
289 |         }
290 |       } else {
291 |         return {comments, jumpedComment: null, pageNum: page}
292 |       }
293 |     }
294 |   } else {
295 |     return {comments, pageNum: page}
296 |   }
297 | }
298 | 
299 | function findComment(comments, id) {
300 |   return comments
301 |     .reduce((acc, c) => acc.concat([c], c.replies), [])
302 |     .find(c => c.id.toString() === id.toString())
303 | }
304 | 
305 | function parseComments(html) {
306 |   const $ = cheerio.load(html)
307 | 
308 |   return setupNextPreviousLinks($('.top-level-reply').map((i, threadEl) => {
309 |     const commentEl = $(threadEl).find('> .comment')
310 |     const comment = parseCommentEl(commentEl, {$})
311 |     Object.assign(comment, {
312 |       threadTopComment: comment,
313 |       replies: setupNextPreviousLinks($(threadEl).find('.reply .comment').map(
314 |         (i, replyEl) => Object.assign(parseCommentEl(replyEl, {$}), {
315 |           parent: comment,
316 |           threadTopComment: comment
317 |         })
318 |       ).get().filter(c => c.content !== '[deleted]'))
319 |     })
320 |     return comment
321 |   }).get().filter(c => c.content !== '[deleted]'))
322 | }
323 | 
324 | function parseCommentEl(commentEl, {$}) {
325 |   return {
326 |     author: $(commentEl).find('.name a').text(),
327 |     authorId: $(commentEl).find('.reply').attr('data-commentee-id'),
328 |     content: util.trimWhitespace($(commentEl).find('.content').text()),
329 |     id: $(commentEl).attr('data-comment-id'),
330 |     date: new Date($(commentEl).find('.time').attr('title'))
331 |   }
332 | }
333 | 
334 | function setupNextPreviousLinks(comments) {
335 |   for (let i = 0; i < comments.length; i++) {
336 |     const comment = comments[i]
337 |     if (i > 0) {
338 |       comment.previous = comments[i - 1]
339 |     }
340 |     if (i < comments.length - 1) {
341 |       comment.next = comments[i + 1]
342 |     }
343 |   }
344 |   return comments
345 | }
346 | 
347 | async function commentPrompt({rl, us, pageType, pageId, commenteeId, parent, promptStr}) {
348 |   const message = await util.prompt(rl, promptStr)
349 | 
350 |   if (message.length > 500) {
351 |     console.log('Message too long (> 500 characters).')
352 |     return
353 |   }
354 | 
355 |   if (message.trim().length === 0) {
356 |     console.log('Not sending reply (empty input).')
357 |     return
358 |   }
359 | 
360 |   const reply = await postComment({pageType, pageId, us,
361 |     content: message, commenteeId, parent
362 |   })
363 | 
364 |   return reply
365 | }
366 | 
367 | function postComment({pageType, pageId, content, us, commenteeId = '', parent = null}) {
368 |   return fetch(`${util.urls.siteAPI}/comments/${pageType}/${pageId}/add/`, {
369 |     method: 'POST',
370 |     body: JSON.stringify(util.clearBlankProperties({
371 |       content,
372 |       commentee_id: commenteeId || '',
373 |       parent_id: parent ? parent.id : ''
374 |     })),
375 |     headers: util.makeFetchHeaders(us)
376 |   }).then(res => {
377 |     if (res.status === 200) {
378 |       return res.text().then(text => {
379 |         const $ = cheerio.load(text)
380 |         const comment = parseCommentEl($('.comment'), {$})
381 |         Object.assign(comment, util.clearBlankProperties({
382 |           parent: parent ? parent : undefined,
383 |           threadTopComment: parent ? parent : comment
384 |         }))
385 |         return comment
386 |       })
387 |     } else {
388 |       return res.text().then(text => {
389 |         console.log(text)
390 |         throw new Error(res.status)
391 |       })
392 |     }
393 |   })
394 | }
395 | 
--------------------------------------------------------------------------------
/lib/homepage.js:
--------------------------------------------------------------------------------
  1 | 'use strict'
  2 | 
  3 | const fetch = require('node-fetch')
  4 | const list = require('./list')
  5 | const projects = require('./projects')
  6 | const studios = require('./studios')
  7 | const util = require('./util')
  8 | 
  9 | module.exports.browse = async function({rl, us}) {
 10 |   const home = await fetchHome(us)
 11 | 
 12 |   const pick = arr => arr[Math.floor(Math.random() * arr.length)]
 13 | 
 14 |   const FP = home.fp[0],
 15 |         FS = home.fs[0],
 16 |         CP = home.cp[0],
 17 |         SDS = pick(home.sds),
 18 |         RP = pick(home.rp),
 19 |         BF = pick(home.bf),
 20 |         LF = pick(home.lf),
 21 |         SF = pick(home.sf),
 22 |         CR = pick(home.cr),
 23 |         CL = pick(home.cl)
 24 | 
 25 |   const proj = p => `\x1b[33;1m${p.title.trim()}\x1b[0m, by \x1b[34;1m${p.creator || p.author.username}\x1b[0m`
 26 |   const studio = s => `\x1b[32;1m${s.title.trim()}\x1b[0m`
 27 | 
 28 |   const showDetails = function() {
 29 |     if (FP) console.log("[all: fp] Latest featured project: [FP ->]", proj(FP))
 30 |     if (FS) console.log("[all: fs] Latest featured studio: [FS ->]", studio(FS))
 31 |     if (CP) console.log("[all: cp] Latest curated project: [CP ->]", proj(CP))
 32 |     if (SDS) console.log("[all: sds] From the Scratch Design Studio: [SDS ->]", proj(SDS))
 33 |     if (CR) console.log("[all: cr] What the community is remixing: [CR ->]", proj(CR))
 34 |     if (CL) console.log("[all: cl] What the community is loving: [CL ->]", proj(CL))
 35 |     if (RP) console.log("[all: rp] A recent project: [RP ->]", proj(RP))
 36 |     if (BF) console.log("[all: bf] Made by a Scratcher you're following: [BF ->]", proj(BF))
 37 |     if (LF) console.log("[all: lf] Loved by a Scratcher you're following: [LF ->]", proj(LF))
 38 |     if (SF) console.log("[all: sf] From a studio you're following: [SF ->]", proj(SF))
 39 |   }
 40 | 
 41 |   const listEntries = function(items, title) {
 42 |     return list.browse({
 43 |       rl, us, items,
 44 |       title: `\x1b[1m${title}\x1b[0m`,
 45 |       formatItem: x => 'instructions' in x ? proj(x) : studio(x),
 46 |       handleItem: x => ('instructions' in x
 47 |         ? projects.browse({rl, us, id: x.id})
 48 |         : studios.browse({rl, us, id: x.id}))
 49 |     })
 50 |   }
 51 | 
 52 |   let quit = false, firstTime = true
 53 |   while (!quit) {
 54 |     console.log('\x1b[1mScratch Homepage\x1b[0m')
 55 |     if (firstTime) {
 56 |       showDetails()
 57 |       firstTime = false
 58 |     }
 59 |     console.log('')
 60 | 
 61 |     await util.choose({rl, us}, {
 62 |       q: {
 63 |         help: 'Quit browsing the homepage.',
 64 |         longcodes: ['quit', 'back'],
 65 |         action: () => {
 66 |           quit = true
 67 |         }
 68 |       },
 69 |       FP: FP ? {
 70 |         help: `View the latest featured project, ${proj(FP)}.`,
 71 |         longcodes: ['featured-project'],
 72 |         action: () => projects.browse({rl, us, id: FP.id})
 73 |       } : undefined,
 74 |       FS: FS ? {
 75 |         help: `View the latest featured studio, ${studio(FS)}.`,
 76 |         longcodes: ['featured-studio'],
 77 |         action: () => studios.browse({rl, us, id: FS.id})
 78 |       } : undefined,
 79 |       CP: CP ? {
 80 |         help: `View the latest curated project, ${proj(CP)}.`,
 81 |         longcodes: ['curated'],
 82 |         action: () => projects.browse({rl, us, id: CP.id})
 83 |       } : undefined,
 84 |       SDS: SDS ? {
 85 |         help: `View a project from the Scratch Design Studio, ${proj(SDS)}.`,
 86 |         longcodes: ['from-sds'],
 87 |         action: () => projects.browse({rl, us, id: SDS.id})
 88 |       } : undefined,
 89 |       CR: CR ? {
 90 |         help: `View a project that the community is remixing, ${proj(CR)}.`,
 91 |         longcodes: ['community-remixed'],
 92 |         action: () => projects.browse({rl, us, id: CR.id})
 93 |       } : undefined,
 94 |       CL: CL ? {
 95 |         help: `View a project that the community is loving, ${proj(CL)}.`,
 96 |         longcodes: ['community-loved'],
 97 |         action: () => projects.browse({rl, us, id: CL.id})
 98 |       } : undefined,
 99 |       RP: RP ? {
100 |         help: `View a recent project, ${proj(RP)}.`,
101 |         longcodes: ['recent'],
102 |         action: () => projects.browse({rl, us, id: RP.id})
103 |       } : undefined,
104 |       BF: BF ? {
105 |         help: `View a project made by a Scratcher you're following, ${proj(BF)}.`,
106 |         longcodes: ['friend-made'],
107 |         action: () => projects.browse({rl, us, id: BF.id})
108 |       } : undefined,
109 |       LF: LF ? {
110 |         help: `View a project loved by a Scratcher you're following, ${proj(LF)}.`,
111 |         longcodes: ['friend-loved'],
112 |         action: () => projects.browse({rl, us, id: LF.id})
113 |       } : undefined,
114 |       SF: SF ? {
115 |         help: `View a project from a studio you're following, ${proj(SF)}.`,
116 |         longcodes: ['from-studio'],
117 |         action: () => projects.browse({rl, us, id: SF.id})
118 |       } : undefined,
119 |       fp: FP ? {
120 |         help: 'Browse a list of featured projects.',
121 |         longcodes: ['list-featured-projects'],
122 |         action: () => listEntries(home.fp, 'Featured projects')
123 |       } : undefined,
124 |       fs: FS ? {
125 |         help: 'Browse a list of featured studios.',
126 |         longcodes: ['list-featured-studios'],
127 |         action: () => listEntries(home.fs, 'Featured studios')
128 |       } : undefined,
129 |       cp: CP ? {
130 |         help: 'Browse a list of curated projects.',
131 |         longcodes: ['list-curated'],
132 |         action: () => listEntries(home.cp, 'Featured projects')
133 |       } : undefined,
134 |       sds: SDS ? {
135 |         help: 'Browse a list of projects in the Scratch Design Studio.',
136 |         longcodes: ['list-sds'],
137 |         action: () => listEntries(home.sds, 'Scratch Design Studio')
138 |       } : undefined,
139 |       cr: CR ? {
140 |         help: 'Browse a list of projects the community is remixing.',
141 |         longcodes: ['list-community-remixed'],
142 |         action: () => listEntries(home.cr, 'What the community is remixing')
143 |       } : undefined,
144 |       cl: CL ? {
145 |         help: 'Browse a list of projects the community is loving.',
146 |         longcodes: ['list-community-loved'],
147 |         action: () => listEntries(home.cl, 'What the community is loving')
148 |       } : undefined,
149 |       rp: RP ? {
150 |         help: 'Browse a list of recent projects.',
151 |         longcodes: ['list-recent'],
152 |         action: () => listEntries(home.rp, 'Recent projects')
153 |       } : undefined,
154 |       bf: BF ? {
155 |         help: 'Browse a list of projects made by Scratchers you\'re following.',
156 |         longcodes: ['list-friend-made'],
157 |         action: () => listEntries(home.bf, 'Projects made by Scratchers you\'re following')
158 |       } : undefined,
159 |       lf: LF ? {
160 |         help: 'Browse a list of projects loved by Scratchers you\'re following.',
161 |         longcodes: ['list-friend-loved'],
162 |         action: () => listEntries(home.lf, 'Projects loved by Scratchers you\'re following')
163 |       } : undefined,
164 |       sf: SF ? {
165 |         help: 'Browse a list of projects from studios you\'re following.',
166 |         longcodes: ['list-from-studio'],
167 |         action: () => listEntries(home.sf, 'Projects from studios you\'re following')
168 |       } : undefined
169 |     })
170 |   }
171 | }
172 | 
173 | async function fetchHome(us) {
174 |   const authToken = await util.getAuthToken(us)
175 |   const following = x => `${util.urls.newAPI}/users/${us.username}/following/${x}?x-token=${authToken}`
176 |   const [
177 |     featuredData,
178 |     byFollowersData,
179 |     lovedByFollowersData,
180 |     inStudiosData
181 |   ] = await Promise.all([
182 |     `${util.urls.newAPI}/proxy/featured`,
183 |     following('users/projects'),
184 |     following('users/loves'),
185 |     following('studios/projects')
186 |   ].map(u => fetch(u).then(res => res.json())))
187 | 
188 |   return {
189 |     fp: featuredData.community_featured_projects,
190 |     fs: featuredData.community_featured_studios,
191 |     cp: featuredData.curator_top_projects,
192 |     sds: featuredData.scratch_design_studio,
193 |     rp: featuredData.community_newest_projects,
194 |     cr: featuredData.community_most_remixed_projects,
195 |     cl: featuredData.community_most_loved_projects,
196 |     bf: byFollowersData,
197 |     lf: lovedByFollowersData,
198 |     sf: inStudiosData
199 |   }
200 | }
201 | 
--------------------------------------------------------------------------------
/lib/list.js:
--------------------------------------------------------------------------------
 1 | // Totally a clone of paged list.
 2 | // The same thing, but without the pages.
 3 | 
 4 | 'use strict'
 5 | 
 6 | const util = require('./util')
 7 | 
 8 | module.exports.browse = async function({rl, us, items, formatItem, title = '', handleItem}) {
 9 |   let quit = false
10 |   while (!quit) {
11 |     if (title) {
12 |       console.log(title)
13 |     }
14 | 
15 |     for (let i = 0; i < items.length; i++) {
16 |       console.log(`[${i + 1}]: ${await formatItem(items[i])}`)
17 |     }
18 |     console.log('')
19 | 
20 |     await util.choose({rl, us}, Object.assign({
21 |       q: {
22 |         help: 'Quit browsing this list.',
23 |         longcodes: ['quit', 'back'],
24 |         action: () => {
25 |           quit = true
26 |         }
27 |       },
28 | 
29 |       ['1-' + items.length]: {
30 |         help: 'Choose an item from the list.',
31 |         action: () => {}
32 |       }
33 |     }, items.reduce((acc, item, i) => {
34 |       acc[i + 1] = {
35 |         invisible: true,
36 |         action: async () => {
37 |           await handleItem(item)
38 |         }
39 |       }
40 |       return acc
41 |     }, {})))
42 |   }
43 | }
44 | 
--------------------------------------------------------------------------------
/lib/messages.js:
--------------------------------------------------------------------------------
  1 | 'use strict'
  2 | 
  3 | const fetch = require('node-fetch')
  4 | const pagedList = require('./paged-list')
  5 | const projects = require('./projects')
  6 | const profiles = require('./profiles')
  7 | const studios = require('./studios')
  8 | const util = require('./util')
  9 | 
 10 | module.exports.browse = async function({rl, us}) {
 11 |   const token = await util.getAuthToken(us)
 12 | 
 13 |   await pagedList.browse({
 14 |     rl, us,
 15 |     getItems: n => fetchMessages(us.username, token, n),
 16 |     title: `\x1b[34;1m${us.username}'s\x1b[0;1m messages\x1b[0m`,
 17 |     formatItem: m => formatMessage({us}, m),
 18 |     pageCount: null,
 19 | 
 20 |     handleItem: async m => {
 21 |       console.log(formatMessage({us}, m))
 22 |       console.log('')
 23 | 
 24 |       await util.choose({rl, us}, {
 25 |         q: {
 26 |           help: 'Quit viewing this message.',
 27 |           longcodes: ['quit', 'back'],
 28 |           action: () => {
 29 |             // No need to set a "quit" flag - we'll automatically quit after
 30 |             // the user makes a choice anyways.
 31 |           }
 32 |         },
 33 | 
 34 |         // "There was activity in.." messages have the actor 'systemuser'.
 35 |         a: m.actor_username === 'systemuser' ? undefined : {
 36 |           help: `View this user's profile, \x1b[34;1m${m.actor_username}\x1b[0m.`,
 37 |           longcodes: ['actor', 'profile'],
 38 |           action: async () => {
 39 |             await profiles.browse({rl, us, username: m.actor_username})
 40 |           }
 41 |         },
 42 | 
 43 |         p: m.project_id ? {
 44 |           help: `View this project, \x1b[33;1m${m.project_title || m.title}\x1b[0m.`,
 45 |           longcodes: ['project'],
 46 |           action: async () => {
 47 |             await projects.browse({rl, us, id: m.project_id})
 48 |           }
 49 |         } : undefined,
 50 | 
 51 |         s: m.gallery_id ? {
 52 |           help: `View this studio, \x1b[32;1m${m.gallery_title || m.title}\x1b[0m.`,
 53 |           longcodes: ['studio'],
 54 |           action: async () => {
 55 |             // TODO: Studio view.
 56 |             console.log('Err, sorry, studios aren\'t implemented yet! ' + util.smile())
 57 |             await new Promise(res => setTimeout(res, 800))
 58 |           }
 59 |         } : undefined,
 60 | 
 61 |         f: m.topic_id ? {
 62 |           help: `View this topic, \x1b[35;1m${m.topic_title}[x1b[0m.`,
 63 |           longcodes: ['forum', 'topic', 'thread'],
 64 |           action: async () => {
 65 |             // TODO: Forum view.
 66 |             console.log('Sorry, forum threads aren\'t implemented yet! ' + util.smile())
 67 |             await new Promise(res => setTimeout(res, 800))
 68 |           }
 69 |         } : undefined,
 70 | 
 71 |         g: m.comment_obj_id ? {
 72 |           help: `Go to where this comment was posted, ${
 73 |             m.comment_type === 0 ? `\x1b[33;1m${m.comment_obj_title}\x1b[0m` :
 74 |             m.comment_type === 1 ? `\x1b[34;1m${m.comment_obj_title}\x1b[0m's profile` :
 75 |             m.comment_type === 2 ? `\x1b[32;1m${m.comment_obj_title}\x1b[0m` :
 76 |             'sooomewhere in the muuuuuultiverse~'
 77 |           }\x1b[0m.`,
 78 |           longcodes: ['go'],
 79 |           action: async () => {
 80 |             if (m.comment_type === 0) {
 81 |               await projects.browse({rl, us, id: m.comment_obj_id})
 82 |             } else if (m.comment_type === 1) {
 83 |               await profiles.browse({rl, us, username: m.comment_obj_title})
 84 |             } else {
 85 |               await studios.browse({rl, us, id: m.comment_obj_id})
 86 |             }
 87 |           }
 88 |         } : undefined,
 89 | 
 90 |         c: m.comment_obj_id ? {
 91 |           help: 'View this comment.',
 92 |           longcodes: ['comment'],
 93 |           action: async () => {
 94 |             if (m.comment_type === 0) {
 95 |               await projects.browse({rl, us, id: m.comment_obj_id, jumpToComment: m.comment_id})
 96 |             } else if (m.comment_type === 1) {
 97 |               await profiles.browse({rl, us, username: m.comment_obj_title, jumpToComment: m.comment_id})
 98 |             } else {
 99 |               await studios.browse({rl, us, id: m.comment_obj_id, jumpToComment: m.comment_id})
100 |             }
101 |           }
102 |         } : undefined
103 |       })
104 |     }
105 |   })
106 | }
107 | 
108 | function fetchMessages(username, token, pageNum = 1) {
109 |   return fetch(`${util.urls.newAPI}/users/${username}/messages` +
110 |     `?x-token=${token}` +
111 |     `&limit=10` +
112 |     `&offset=${10 * (pageNum - 1)}`).then(res => res.json())
113 | }
114 | 
115 | function formatMessage({us}, m) {
116 |   let eventStr = ''
117 | 
118 |   const actor = `\x1b[34;1m${m.actor_username}\x1b[0m`
119 |   const project = `\x1b[33;1m${m.title}\x1b[0m`
120 |   const project2 = `\x1b[33;1m${m.project_title}\x1b[0m`
121 |   const studio = `\x1b[32;1m${m.title}\x1b[0m`
122 |   const studio2 = `\x1b[32;1m${m.gallery_title}\x1b[0m`
123 |   const topic = `\x1b[35;1m${m.topic_title}\x1b[0m`
124 | 
125 |   switch (m.type) {
126 |     case 'loveproject': eventStr += `${actor} \x1b[31mloved your project ${project}\x1b[31m.\x1b[0m`; break
127 |     case 'favoriteproject': eventStr += `${actor} \x1b[33mfavorited your project ${project2}\x1b[33m.\x1b[0m`; break
128 |     case 'remixproject': eventStr += `${actor} \x1b[35mremixed your project ${project}\x1b[34m.\x1b[0m`; break
129 | 
130 |     case 'addcomment': {
131 |       const text = m.comment_fragment
132 |       eventStr += `${actor} \x1b[36mleft a comment`
133 | 
134 |       if (m.comment_type === 0) {
135 |         eventStr += ` on \x1b[33;1m${m.comment_obj_title}\x1b[0m`
136 |       } else if (m.comment_type === 1) {
137 |         if (m.comment_obj_title === m.actor_username) {
138 |           eventStr += ' on their profile'
139 |         } else if (m.comment_obj_title === us.username) {
140 |           eventStr += ' on your profile'
141 |         } else {
142 |           eventStr += ` on \x1b[34;1m${m.comment_obj_title}\x1b[0;36m's profile`
143 |         }
144 |       } else if (m.comment_type === 2) {
145 |         eventStr += ` on \x1b[32;1m${m.comment_obj_title}\x1b[0m`
146 |       }
147 | 
148 |       eventStr += '\x1b[36m:\x1b[0m "'
149 |       if (text.length >= 40) {
150 |         eventStr += `${text.slice(0, 40)}...`
151 |       } else {
152 |         eventStr += text
153 |       }
154 |       eventStr += '"'
155 |       break
156 |     }
157 | 
158 |     case 'followuser': eventStr += `${actor} \x1b[35mfollowed you.\x1b[0m`; break
159 |     case 'curatorinvite': eventStr += `${actor} \x1b[32minvited you to ${studio}\x1b[32m.\x1b[0m`; break
160 |     case 'becomeownerstudio': eventStr += `${actor} \x1b[32mpromoted you to a manager of ${studio2}\x1b[32m.\x1b[0m`; break
161 |     case 'studioactivity': eventStr += `\x1b[32mThere was activity in ${studio}\x1b[32m.\x1b[0m`; break
162 |     case 'forumpost': eventStr += `${actor} \x1b[35mmade a post in ${topic}\x1b[0m\x1b[35m.\x1b[0m`; break
163 |     case 'userjoin': eventStr += `\x1b[39;1mWelcome to Scratch! \x1b[0;39mAfter you make projects and comments, you'll get messages about them here.\x1b[0m`; break
164 | 
165 |     default: eventStr += `Something along the lines of "${m.type}" happened.`
166 |   }
167 | 
168 |   const date = new Date(m.datetime_created)
169 |   eventStr += ` \x1b[2m(${util.timeAgo(date)})\x1b[0m`
170 | 
171 |   return eventStr
172 | }
173 | 
--------------------------------------------------------------------------------
/lib/paged-list.js:
--------------------------------------------------------------------------------
 1 | 'use strict'
 2 | 
 3 | const util = require('./util')
 4 | 
 5 | module.exports.browse = async function({rl, us, getItems, formatItem, title = '', pageCount, handleItem}) {
 6 |   let quit = false, currentPageNumber = 1
 7 |   while (!quit) {
 8 |     const items = await getItems(currentPageNumber)
 9 | 
10 |     let header = ''
11 | 
12 |     if (title) {
13 |       header += title + ' '
14 |     }
15 | 
16 |     header += `(Page ${currentPageNumber}`
17 |     if (pageCount) {
18 |       header += ` / ${pageCount}`
19 |     }
20 |     header += ')'
21 |     console.log(header)
22 | 
23 |     console.log('')
24 |     for (let i = 0; i < items.length; i++) {
25 |       console.log(`[${i + 1}]: ${await formatItem(items[i])}`)
26 |     }
27 |     console.log('')
28 | 
29 |     await util.choose({rl, us}, Object.assign({
30 |       q: {
31 |         help: 'Quit browsing this list.',
32 |         longcodes: ['quit', 'back'],
33 |         action: () => {
34 |           quit = true
35 |         }
36 |       },
37 | 
38 |       n: !pageCount || currentPageNumber < pageCount ? {
39 |         help: 'Go to the next page.',
40 |         longcodes: ['next'],
41 |         action: () => {
42 |           currentPageNumber++
43 |         }
44 |       } : undefined,
45 | 
46 |       p: currentPageNumber > 1 ? {
47 |         help: 'Go to the previous page.',
48 |         longcodes: ['prev', 'previous'],
49 |         action: () => {
50 |           currentPageNumber--
51 |         }
52 |       } : undefined,
53 | 
54 |       j: typeof pageCount !== 'number' || pageCount > 1 ? {
55 |         help: 'Jump to a particular page.',
56 |         longcodes: ['jump'],
57 |         action: async () => {
58 |           const n = parseInt(await util.prompt(rl, 'What page? '))
59 |           if (!isNaN(n)) {
60 |             if (pageCount) {
61 |               currentPageNumber = Math.min(n, pageCount)
62 |             } else {
63 |               currentPageNumber = n
64 |             }
65 |           }
66 |         }
67 |       } : undefined,
68 | 
69 |       ['1-' + items.length]: {
70 |         help: 'Choose an item from the list.',
71 |         action: () => {}
72 |       }
73 |     }, items.reduce((acc, item, i) => {
74 |       acc[i + 1] = {
75 |         invisible: true,
76 |         action: async () => {
77 |           await handleItem(item)
78 |         }
79 |       }
80 |       return acc
81 |     }, {})))
82 |   }
83 | }
84 | 
--------------------------------------------------------------------------------
/lib/profiles.js:
--------------------------------------------------------------------------------
  1 | 'use strict'
  2 | 
  3 | const cheerio = require('cheerio')
  4 | const fetch = require('node-fetch')
  5 | const comments = require('./comments')
  6 | const pagedList = require('./paged-list')
  7 | const projects = require('./projects')
  8 | const studios = require('./studios')
  9 | const thumbs = require('./thumbs')
 10 | const util = require('./util')
 11 | 
 12 | module.exports.browse = async function({rl, us, username, jumpToComment}) {
 13 |   const profile = await fetchProfile(username)
 14 | 
 15 |   if (profile.notFound) {
 16 |     console.log('\x1b[31mThat user profile couldn\'t be found, sorry.\x1b[0m')
 17 |     return util.delay(util.delay.notFound)
 18 |   }
 19 | 
 20 |   const updateDescription = async function({promptStr, property, apiProperty}) {
 21 |     const newValue = await util.prompt(rl, promptStr, profile[property])
 22 |     if (newValue.length > 200) {
 23 |       console.log('Sorry, this is limited to 200 characters.')
 24 |       return
 25 |     }
 26 | 
 27 |     if (newValue !== profile.aboutMe) {
 28 |       if (await util.prettyFetch({}, `${util.urls.siteAPI}/users/all/${profile.username}/`, {
 29 |         method: 'PUT',
 30 |         body: JSON.stringify({
 31 |           [apiProperty]: newValue
 32 |         }),
 33 |         headers: util.makeFetchHeaders(us)
 34 |       })) {
 35 |         profile[property] = newValue
 36 |       }
 37 |     }
 38 |   }
 39 | 
 40 |   const showComments = async function(jumpTo = null) {
 41 |     await comments.browse({
 42 |       rl, us, pageType: 'user', pageId: profile.username, jumpTo,
 43 |       commentsEnabled: profile.commentsEnabled
 44 |     })
 45 |   }
 46 | 
 47 |   if (jumpToComment) {
 48 |     return showComments(jumpToComment)
 49 |   }
 50 | 
 51 |   let quit = false, firstTime = true
 52 |   while (!quit) {
 53 |     let locationStr = ' from ' + profile.location
 54 |     if (profile.location === 'Location not given') {
 55 |       locationStr = '; ' + profile.location
 56 |     }
 57 |     console.log(
 58 |       `\x1b[34;1m${profile.username}\x1b[0m` +
 59 |       `  \x1b[2m${profile.rank + locationStr}` +
 60 |       `; Joined ${profile.joinDate.toDateString()}\x1b[0m`
 61 |     )
 62 | 
 63 |     if (firstTime) {
 64 |       if (profile.aboutMe || profile.wiwo) {
 65 |         console.log('')
 66 |       }
 67 | 
 68 |       process.stdout.write('\x1b[1mAbout me:\x1b[0m')
 69 |       if (profile.aboutMe) {
 70 |         console.log('\n' + util.wrap(profile.aboutMe))
 71 |         console.log('')
 72 |       } else {
 73 |         console.log(' \x1b[2m(N/A)\x1b[0m')
 74 |         if (profile.wiwo) {
 75 |           console.log('')
 76 |         }
 77 |       }
 78 | 
 79 |       process.stdout.write('\x1b[1mWhat I\'m working on:\x1b[0m')
 80 |       if (profile.wiwo) {
 81 |         console.log('\n' + util.wrap(profile.wiwo))
 82 |       } else {
 83 |         console.log(' \x1b[2m(N/A)\x1b[0m')
 84 |       }
 85 |       console.log('')
 86 | 
 87 |       if (profile.featuredProject) {
 88 |         const h = profile.featuredProjectHeading
 89 |         console.log(
 90 |           `\x1b[1m${h + (h.endsWith('!') ? '' : ':')}\x1b[0m` +
 91 |           ` \x1b[33m${profile.featuredProject.name}\x1b[0m`)
 92 |       }
 93 | 
 94 |       if (profile.projectCount) {
 95 |         console.log(`${profile.username} has shared ${profile.projectCount} project${
 96 |           profile.projectCount === 1 ? '' : 's'
 97 |         }.`)
 98 |       } else {
 99 |         console.log(`${profile.username} has not shared any projects.`)
100 |       }
101 | 
102 |       firstTime = false
103 |     }
104 | 
105 |     console.log('')
106 | 
107 |     await util.choose({rl, us}, {
108 |       q: {
109 |         help: 'Quit browsing this profile.',
110 |         longcodes: ['quit', 'back'],
111 |         action: async () => {
112 |           quit = true
113 |         }
114 |       },
115 | 
116 |       I: {
117 |         help: 'View the avatar of this user.',
118 |         longcodes: ['image', 'avatar', 'thumbnail', 'thumb'],
119 |         action: async () => {
120 |           await util.showImage(util.urls.userThumb(profile.id))
121 |         }
122 |       },
123 | 
124 |       f: profile.featuredProject ? {
125 |         help: 'Browse this user\'s featured project.',
126 |         longcodes: ['featured'],
127 |         action: async () => {
128 |           await projects.browse({rl, us, id: profile.featuredProject.id})
129 |         }
130 |       } : undefined,
131 | 
132 |       P: profile.projectCount ? {
133 |         help: 'Browse this user\'s shared projects.',
134 |         longcodes: ['projects', 'shared'],
135 |         action: async () => {
136 |           await thumbs.browsePaged({
137 |             rl, us,
138 |             urlPart: `/users/${username}/projects/`,
139 |             title: `\x1b[34;1m${username}\x1b[0;1m's shared projects\x1b[0m`,
140 |             formatItem: p => `\x1b[33m${p.title}\x1b[0m`,
141 |             handleItem: p => projects.browse({rl, us, id: p.href.match(/([0-9]+)/)[1]})
142 |           })
143 |         }
144 |       } : undefined,
145 | 
146 |       F: profile.hasFavorites ? {
147 |         help: 'Browse this user\'s favorite projects.',
148 |         longcodes: ['favorites'],
149 |         action: async () => {
150 |           await thumbs.browsePaged({
151 |             rl, us,
152 |             urlPart: `/users/${username}/favorites/`,
153 |             title: `\x1b[34;1m${username}\x1b[0;1m's favorite projects\x1b[0m`,
154 |             formatItem: p => `\x1b[33m${p.title}\x1b[0m`,
155 |             handleItem: p => projects.browse({rl, us, id: p.href.match(/[0-9]+/)[0]})
156 |           })
157 |         }
158 |       } : undefined,
159 | 
160 |       u: profile.isFollowing ? {
161 |         help: 'Browse the user profiles that this user is following.',
162 |         longcodes: ['following'],
163 |         action: async () => {
164 |           await thumbs.browsePaged({
165 |             rl, us,
166 |             urlPart: `/users/${username}/following/`,
167 |             title: `\x1b[1mUsers \x1b[34;1m${username}\x1b[0;1m follows\x1b[0m`,
168 |             formatItem: u => `\x1b[34;1m${u.title}\x1b[0m`,
169 |             handleItem: u => module.exports.browse({rl, us, username: u.title})
170 |           })
171 |         }
172 |       } : undefined,
173 | 
174 |       U: profile.hasFollowers ? {
175 |         help: 'Browse the profiles of users who are following this user.',
176 |         longcodes: ['followers'],
177 |         action: async () => {
178 |           await thumbs.browsePaged({
179 |             rl, us,
180 |             urlPart: `/users/${username}/followers/`,
181 |             title: `\x1b[34;1m${username}\x1b[0;1m's followers\x1b[0m`,
182 |             formatItem: u => `\x1b[34;1m${u.title}\x1b[0m`,
183 |             handleItem: u => module.exports.browse({rl, us, username: u.title})
184 |           })
185 |         }
186 |       } : undefined,
187 | 
188 |       s: profile.isCuratingStudios ? {
189 |         help: 'Browse the studios this user is curating.',
190 |         longcodes: ['curating-studios'],
191 |         action: async () => {
192 |           await thumbs.browsePaged({
193 |             rl, us,
194 |             urlPart: `/users/${username}/studios/`,
195 |             title: `\x1b[1mStudios \x1b[34;1m${username}\x1b[0;1m curates\x1b[0m`,
196 |             formatItem: s => `\x1b[32;1m${s.title}\x1b[0m`,
197 |             handleItem: s => studios.browse({rl, us, id: s.href.match(/[0-9]+/)[0]})
198 |           })
199 |         }
200 |       } : undefined,
201 | 
202 |       S: profile.isFollowingStudios ? {
203 |         help: 'Browse the studios this user is following.',
204 |         longcodes: ['following-studios'],
205 |         action: async () => {
206 |           await thumbs.browsePaged({
207 |             rl, us,
208 |             urlPart: `/users/${username}/studios_following/`,
209 |             title: `\x1b[1mStudios \x1b[34;1m${username}\x1b[0;1m follows\x1b[0m`,
210 |             formatItem: s => `\x1b[32;1m${s.title}\x1b[0m`,
211 |             handleItem: s => studios.browse({rl, us, id: s.href.match(/[0-9]+/)[0]})
212 |           })
213 |         }
214 |       } : undefined,
215 | 
216 |       A: profile.username === us.username ? {
217 |         help: 'Change your about me.',
218 |         longcodes: ['change-about'],
219 |         action: async () => {
220 |           await updateDescription({
221 |             promptStr: "New about me: ",
222 |             property: 'aboutMe', apiProperty: 'bio'
223 |           })
224 |         }
225 |       } : undefined,
226 | 
227 |       W: profile.username === us.username ? {
228 |         help: 'Change what you\'re working on.',
229 |         longcodes: ['change-about'],
230 |         action: async () => {
231 |           await updateDescription({
232 |             promptStr: "New what I'm working on: ",
233 |             property: 'wiwo', apiProperty: 'status'
234 |           })
235 |         }
236 |       } : undefined,
237 | 
238 |       c: {
239 |         help: 'Browse comments.',
240 |         longcodes: ['comments'],
241 |         action: async () => {
242 |           await showComments()
243 |         }
244 |       }
245 |     })
246 |   }
247 | }
248 | 
249 | function fetchProfile(username) {
250 |   return fetch(`${util.urls.scratch}/users/${username}`)
251 |     .then(res => res.text())
252 |     .then(html => parseProfile(html))
253 | }
254 | 
255 | function parseProfile(html) {
256 |   const $ = cheerio.load(html)
257 | 
258 |   if ($('#page-404').length) {
259 |     return {notFound: true}
260 |   }
261 | 
262 |   const readMultiline = el => {
263 |     el.find('br').replaceWith('\n')
264 |     return el.text()
265 |   }
266 | 
267 |   const scripts = $('script').map((i, scriptEl) => $(scriptEl).html()).get()
268 | 
269 |   const profile = {
270 |     id: scripts.map(str => str.match(/Scratch\.INIT_DATA\.PROFILE = \{[\s\S]*?userId: ([^,]*),/)).filter(Boolean)[0][1],
271 |     username: $('#profile-data h2').text().match(/[^*]*/)[0],
272 |     rank: $('#profile-data .group').text().trim(),
273 |     location: $('#profile-data .location').text(),
274 |     joinDate: new Date($('span[title]').attr('title')),
275 |     aboutMe: readMultiline($('#bio-readonly .overview')),
276 |     wiwo: readMultiline($('#status-readonly .overview')),
277 |     projectCount: parseInt($('.box:has(#shared) h4').text().match(/([0-9]+)/)[1]),
278 |     isFollowing: !!$('.box-head:contains("Following") a').length,
279 |     hasFollowers: !!$('.box-head:contains("Followers") a').length,
280 |     hasFavorites: !!$('.box-head:contains("Favorite Projects") a').length,
281 |     isFollowingStudios: !!$('.box-head:contains("Studios I\'m Following") a').length,
282 |     isCuratingStudios: !!$('.box-head:contains("Studios I Curate") a').length,
283 |     commentsEnabled: !$('.comments-off').length
284 |   }
285 | 
286 |   if ($('.player a.project-name').length && $('.player a.project-name').text().trim().length) {
287 |     profile.featuredProjectHeading = $('.featured-project-heading').text()
288 |     profile.featuredProject = {
289 |       name: $('.player a.project-name').text(),
290 |       id: parseInt($('.player a.project-name').attr('href').match(/([0-9]+)/)[1])
291 |     }
292 |   } else {
293 |     profile.featuredProjectHeading = profile.featuredProject = null
294 |   }
295 | 
296 |   return profile
297 | }
298 | 
--------------------------------------------------------------------------------
/lib/projects.js:
--------------------------------------------------------------------------------
  1 | 'use strict'
  2 | 
  3 | const cheerio = require('cheerio')
  4 | const fetch = require('node-fetch')
  5 | const comments = require('./comments')
  6 | const profiles = require('./profiles')
  7 | const studios = require('./studios')
  8 | const thumbs = require('./thumbs')
  9 | const util = require('./util')
 10 | 
 11 | module.exports.browse = async function({rl, us, id, jumpToComment = null}) {
 12 |   const project = await fetchProject(id, us)
 13 | 
 14 |   if (project.notFound) {
 15 |     console.log('\x1b[31mThat project couldn\'t be found, sorry. It might have been unshared.\x1b[0m')
 16 |     return util.delay(util.delay.notFound)
 17 |   }
 18 | 
 19 |   const showNotes = function() {
 20 |     if (project.instructions) {
 21 |       console.log('\x1b[1mInstructions:\x1b[0m')
 22 |       console.log(util.wrap(project.instructions))
 23 |       console.log('')
 24 |     }
 25 | 
 26 |     if (project.notesAndCredits) {
 27 |       console.log('\x1b[1mNotes and Credits:\x1b[0m')
 28 |       console.log(util.wrap(project.notesAndCredits))
 29 |       console.log('')
 30 |     }
 31 |   }
 32 | 
 33 |   const showComments = function(jumpTo = null) {
 34 |     return comments.browse({
 35 |       rl, us, pageType: 'project', pageId: id, pageObj: project, jumpTo,
 36 |       commentsEnabled: project.commentsEnabled
 37 |     })
 38 |   }
 39 | 
 40 |   if (jumpToComment) {
 41 |     return showComments(jumpToComment)
 42 |   }
 43 | 
 44 |   let quit = false, firstTime = true
 45 |   while (!quit) {
 46 |     console.log(`\x1b[33;1m${project.title}\x1b[0m`)
 47 | 
 48 |     let header = `\x1b[2m${project.isRemix ? 'Remixed' : 'Created'}`
 49 |     header += ` by \x1b[34;2;1m${project.author}\x1b[0;2m; id: ${id}`
 50 |     if (project.tags.length) {
 51 |       header += `; tagged ${project.tags.map(t => `"${t}"`).join(', ')}`
 52 |     }
 53 |     header += '\x1b[0m'
 54 |     console.log(header)
 55 |     if (project.isRemix) {
 56 |       console.log(`\x1b[2mBased on: \x1b[33;2;1m${project.remixParent.title}\x1b[0m`)
 57 |     }
 58 | 
 59 |     console.log(`\x1b[31mLove-its: ${project.loves}  \x1b[33mFavorites: ${project.favorites}\x1b[0m`)
 60 | 
 61 |     if (firstTime) {
 62 |       console.log('')
 63 |       showNotes()
 64 |       firstTime = false
 65 |     }
 66 | 
 67 |     await util.choose({rl, us}, {
 68 |       q: {
 69 |         help: 'Quit browsing this project.',
 70 |         longcodes: ['quit', 'back'],
 71 |         action: () => {
 72 |           quit = true
 73 |         }
 74 |       },
 75 | 
 76 |       R: project.isRemix ? {
 77 |         help: `View the project this was remixed upon, \x1b[33;1m${project.remixParent.title}\x1b[0m.`,
 78 |         longcodes: ['remix'],
 79 |         action: async () => {
 80 |           await module.exports.browse({rl, us, id: project.remixParent.id})
 81 |         }
 82 |       } : undefined,
 83 | 
 84 |       I: {
 85 |         help: 'View the thumbnail of this project.',
 86 |         longcodes: ['image', 'thumbnail', 'thumb'],
 87 |         action: async () => {
 88 |           await util.showImage(util.urls.projectThumb(id))
 89 |         }
 90 |       },
 91 | 
 92 |       N: (project.instructions || project.notesAndCredits) ? {
 93 |         help: 'View instructions and notes/credits.',
 94 |         longcodes: ['notes', 'credits', 'instructions'],
 95 |         action: showNotes
 96 |       } : undefined,
 97 | 
 98 |       S: project.studioCount ? {
 99 |         help: 'Browse studios this project is in.',
100 |         longcodes: ['studios'],
101 |         action: async () => {
102 |           await thumbs.browseUnpaged({
103 |             rl, us,
104 |             urlPart: `/projects/${id}/studios/`,
105 |             title: `\x1b[33;1m${project.title}'s\x1b[0;1m studios\x1b[0m`,
106 |             formatItem: s => `\x1b[32m${s.title}\x1b[0m`,
107 |             handleItem: s => studios.browse({rl, us, id: s.id})
108 |           })
109 |         }
110 |       } : undefined,
111 | 
112 |       a: {
113 |         help: `Visit the author of this project, \x1b[34;1m${project.author}\x1b[0m.`,
114 |         longcodes: ['author', 'profile'],
115 |         action: async () => {
116 |           await profiles.browse({rl, us, username: project.author})
117 |         }
118 |       },
119 | 
120 |       c: {
121 |         help: 'Browse comments.',
122 |         longcodes: ['comments'],
123 |         action: () => showComments()
124 |       },
125 | 
126 |       // TODO: These are commented out for now; Scratch is responding lots and lots
127 |       // of 403 Forbidden errors. I have no idea why, since equivalent curls work
128 |       // just fine. Nothing in node-fetch seems like it would have an effect either.
129 |       // Anyways, the 403 error also happens with browser fetch.
130 |       /*
131 |       l: project.weLovedIt ? undefined : {
132 |         help: 'Leave a love-it.',
133 |         longcodes: ['love'],
134 |         action: async () => {
135 |           await fetch(`${util.urls.siteAPI}/users/lovers/${id}/add/?usernames=${us.username}`, {
136 |             method: 'PUT',
137 |             headers: util.makeFetchHeaders(us)
138 |           })
139 | 
140 |           project.loves++
141 |           project.weLovedIt = true
142 |         }
143 |       },
144 | 
145 |       L: project.weLovedIt ? {
146 |         help: 'Remove your love-it.',
147 |         longcodes: ['unlove'],
148 |         action: async () => {
149 |           await fetch(`${util.urls.siteAPI}/users/lovers/${id}/remove/?usernames=${us.username}`, {
150 |             method: 'PUT',
151 |             headers: util.makeFetchHeaders(us)
152 |           })
153 | 
154 |           project.loves--
155 |           project.weLovedIt = false
156 |         }
157 |       } : undefined,
158 | 
159 |       f: project.weFavedIt ? undefined : {
160 |         help: 'Leave a favorite.',
161 |         longcodes: ['favorite', 'fave'],
162 |         action: async () => {
163 |           await fetch(`${util.urls.siteAPI}/users/favoriters/${id}/add/?usernames=${us.username}`, {
164 |             method: 'PUT',
165 |             headers: util.makeFetchHeaders(us)
166 |           })
167 | 
168 |           project.favorites++
169 |           project.weFavedIt = true
170 |         }
171 |       },
172 | 
173 |       F: project.weFavedIt ? {
174 |         help: 'Remove your favorite.',
175 |         longcodes: ['unfave', 'unfavorite'],
176 |         action: async () => {
177 |           await fetch(`${util.urls.siteAPI}/users/favoriters/${id}/remove/?usernames=${us.username}`, {
178 |             method: 'PUT',
179 |             headers: util.makeFetchHeaders(us)
180 |           })
181 | 
182 |           project.favorites--
183 |           project.weFavedIt = false
184 |         }
185 |       } : undefined
186 |       */
187 |     })
188 |   }
189 | }
190 | 
191 | function fetchProject(id, us) {
192 |   return fetch(`${util.urls.newAPI}/projects/${id}`)
193 |     .then(res => res.json())
194 |     .then(data => {
195 |       if (data.code === 'NotFound') {
196 |         return {notFound: true}
197 |       }
198 | 
199 |       const project = {
200 |         title: data.title,
201 |         author: data.author.username,
202 |         instructions: data.instructions,
203 |         notesAndCredits: data.description,
204 |         loves: data.stats.loves,
205 |         favorites: data.stats.favorites,
206 |         // TODO: tags
207 |         isRemix: !!data.remix.parent,
208 |         // TODO: commentsEnabled
209 |       }
210 | 
211 |       const loadInteractStatus = (endpoint, projectKey, dataKey) => {
212 |         return util.getAuthToken(us)
213 |           .then(token => fetch(`${util.urls.newAPI}/projects/${id}/${endpoint}/user/${us.username}?x-token=${token}`))
214 |           .then(res => res.json())
215 |           .then(data => {
216 |             project[projectKey] = data[dataKey]
217 |           })
218 |       }
219 | 
220 |       const loadRemixData = () => {
221 |         if (!project.isRemix) {
222 |           return Promise.resolve()
223 |         }
224 | 
225 |         return fetch(`${util.urls.newAPI}/projects/${data.remix.parent}`)
226 |           .then(res => res.json())
227 |           .then(data => {
228 |             project.remixParent = {
229 |               id: data.id,
230 |               title: data.title,
231 |               author: data.author.username
232 |             }
233 |           })
234 |       }
235 | 
236 |       const loadTagData = () => {
237 |         // TODO: Move to new API
238 |         return fetch(`${util.urls.siteAPI}/tags/project/${id}`).then(
239 |           res => res.json().then(data => {
240 |             project.tags = data.map(entry => entry.fields.tag.name)
241 |           }),
242 |           err => null // Probably a 404, no big deal.
243 |         )
244 |       }
245 | 
246 |       let unshared = false
247 | 
248 |       return Promise.all([
249 |         loadInteractStatus('loves', 'weLovedIt', 'userLove'),
250 |         loadInteractStatus('favorites', 'weFavedIt', 'userFavorite'),
251 |         loadRemixData(),
252 |         loadTagData(),
253 |         fetchExtraProjectData(id, us).then(data => {
254 |           if (data.notFound) {
255 |             unshared = true
256 |           } else {
257 |             Object.assign(project, data)
258 |           }
259 |         })
260 |       ]).then(() => unshared ? {notFound: true} : project)
261 |     })
262 | }
263 | 
264 | function fetchExtraProjectData(id, us) {
265 |   return fetch(`${util.urls.scratch}/projects/${id}`, {
266 |     headers: util.makeFetchHeaders(us)
267 |   }).then(res => res.text())
268 |     .then(html => parseProject(html))
269 | }
270 | 
271 | function parseProject(html) {
272 |   const $ = cheerio.load(html)
273 | 
274 |   if ($('#page-404').length) {
275 |     return {notFound: true}
276 |   }
277 | 
278 |   const project = {
279 |     commentsEnabled: !$('.comments-off').length
280 |   }
281 | 
282 |   // TODO: Move to new API
283 |   const studioText = $('#galleries h4').text()
284 |   if (studioText) {
285 |     project.studioCount = parseInt(studioText.match(/[0-9]+/)[0])
286 |   } else {
287 |     project.studioCount = 0
288 |   }
289 | 
290 |   return project
291 | }
292 | 
--------------------------------------------------------------------------------
/lib/studios.js:
--------------------------------------------------------------------------------
  1 | 'use strict'
  2 | 
  3 | const cheerio = require('cheerio')
  4 | const fetch = require('node-fetch')
  5 | const comments = require('./comments')
  6 | const pagedList = require('./paged-list')
  7 | const profiles = require('./profiles')
  8 | const projects = require('./projects')
  9 | const util = require('./util')
 10 | 
 11 | module.exports.browse = async function({rl, us, id, jumpToComment = null}) {
 12 |   const studio = await fetchStudio(id, us)
 13 | 
 14 |   let quit = false, firstTime = true
 15 | 
 16 |   const showDescription = function() {
 17 |     if (studio.description) {
 18 |       console.log('\x1b[1mDescription:\x1b[0m')
 19 |       console.log(util.wrap(studio.description))
 20 |       console.log('')
 21 |     }
 22 |   }
 23 | 
 24 |   const showManagers = function() {
 25 |     return showMembers({rl, us, fetchMembers: fetchManagers, categoryString: 'managers'})
 26 |   }
 27 | 
 28 |   const showCurators = function() {
 29 |     return showMembers({rl, us, fetchMembers: fetchCurators, categoryString: 'curators'})
 30 |   }
 31 | 
 32 |   const showMembers = function({rl, us, fetchMembers, categoryString}) {
 33 |     return pagedList.browse({
 34 |       rl, us,
 35 |       getItems: n => fetchMembers(id, n),
 36 |       title: `\x1b[32;1m${studio.title}\x1b[0;1m's ${categoryString}\x1b[0m`,
 37 |       formatItem: m => `\x1b[34;1m${m.username}\x1b[0m`,
 38 |       handleItem: async m => {
 39 |         await profiles.browse({rl, us, username: m.username})
 40 |       }
 41 |     })
 42 |   }
 43 | 
 44 |   const showProjects = function() {
 45 |     return pagedList.browse({
 46 |       rl, us,
 47 |       getItems: n => fetchProjects(id, n),
 48 |       title: `\x1b[32;1m${studio.title}\x1b[0;1m's projects\x1b[0m`,
 49 |       formatItem: m => `\x1b[33;1m${m.name}\x1b[0m (by \x1b[34;1m${m.author}\x1b[0m)`,
 50 |       handleItem: async p => {
 51 |         await projects.browse({rl, us, id: p.id})
 52 |       }
 53 |     })
 54 |   }
 55 | 
 56 |   const showActivity = function() {
 57 |     return pagedList.browse({
 58 |       rl, us,
 59 |       getItems: n => fetchActivity(id, n),
 60 |       title: `Activity in \x1b[32;1m${studio.title}\x1b[0m`,
 61 |       formatItem: a => formatActivity(a),
 62 |       pageCount: 10, // Activity is only kept for 10 pages.
 63 | 
 64 |       handleItem: async a => {
 65 |         console.log(formatActivity(a))
 66 |         await util.choose({rl, us}, {
 67 |           q: {
 68 |             help: 'Quit browsing this activity.',
 69 |             longcodes: ['quit', 'back'],
 70 |             action: () => {}
 71 |           },
 72 | 
 73 |           a: {
 74 |             help: `View this user's profile, \x1b[34;1m${a.actor}\x1b[0m.`,
 75 |             longcodes: ['actor'],
 76 |             action: async () => {
 77 |               await profiles.browse({rl, us, username: a.actor})
 78 |             }
 79 |           },
 80 | 
 81 |           p: a.what.href.startsWith('/projects/') ? {
 82 |             help: `View this project, \x1b[33;1m${a.what.title}\x1b[0m.`,
 83 |             longcodes: ['project'],
 84 |             action: async () => {
 85 |               await projects.browse({rl, us, id: a.what.href.match(/[0-9]+/)[0]})
 86 |             }
 87 |           } : undefined,
 88 | 
 89 |           o: (a.what.href.startsWith('/users/') && a.what.title !== a.actor) ? {
 90 |             help: `View the other user's profile, \x1b[34;1m${a.what.title}\x1b[0m.`,
 91 |             longcodes: ['other'],
 92 |             action: async () => {
 93 |               await profiles.browse({rl, us, username: a.what.title})
 94 |             }
 95 |           } : undefined,
 96 | 
 97 |           c: a.did === 'left a' ? {
 98 |             help: 'View this comment.',
 99 |             longcodes: ['comment'],
100 |             action: async () => {
101 |               await showComments(a.what.href.match(/[0-9]+$/)[0])
102 |             }
103 |           } : undefined
104 |         })
105 |       }
106 |     })
107 |   }
108 | 
109 |   const showComments = function(jumpTo = null) {
110 |     return comments.browse({
111 |       rl, us, pageType: 'gallery', pageId: id, pageObj: studio, jumpTo,
112 |       commentsEnabled: studio.commentsEnabled
113 |     })
114 |   }
115 | 
116 |   if (jumpToComment) {
117 |     return showComments(jumpToComment)
118 |   }
119 | 
120 |   while (!quit) {
121 |     console.log(`\x1b[32;1m${studio.title}\x1b[0m`)
122 |     console.log(`\x1b[2mContains ${
123 |       studio.projectCount === '0' ? 'no projects' :
124 |       studio.projectCount === '1' ? '1 project' :
125 |       `${studio.projectCount} projects`
126 |     }\x1b[0m`)
127 | 
128 |     if (firstTime) {
129 |       console.log('')
130 |       showDescription()
131 |       firstTime = false
132 |     }
133 | 
134 |     await util.choose({rl, us}, {
135 |       q: {
136 |         help: 'Quit browsing this studio.',
137 |         longcodes: ['quit', 'back'],
138 |         action: () => {
139 |           quit = true
140 |         }
141 |       },
142 | 
143 |       I: {
144 |         help: 'View the thumbnail of this studio.',
145 |         longcodes: ['image', 'thumbnail', 'thumb'],
146 |         action: async () => {
147 |           await util.showImage(util.urls.studioThumb(id))
148 |         }
149 |       },
150 | 
151 |       D: studio.description ? {
152 |         help: 'View the studio description.',
153 |         longcodes: ['description'],
154 |         action: showDescription
155 |       } : undefined,
156 | 
157 |       M: {
158 |         help: 'View managers of this studio.',
159 |         longcodes: ['managers', 'owners'],
160 |         action: showManagers
161 |       },
162 | 
163 |       C: {
164 |         help: 'View curators of this studio.',
165 |         longcodes: ['curators'],
166 |         action: showCurators
167 |       },
168 | 
169 |       P: parseInt(studio.projectCount) ? {
170 |         help: 'View projects in this studio.',
171 |         longcodes: ['projects'],
172 |         action: showProjects
173 |       } : undefined,
174 | 
175 |       A: {
176 |         help: 'View activity in this studio.',
177 |         longcodes: ['activity'],
178 |         action: showActivity
179 |       },
180 | 
181 |       c: {
182 |         help: 'Browse comments.',
183 |         longcodes: ['comments'],
184 |         action: () => showComments()
185 |       }
186 |     })
187 |   }
188 | }
189 | 
190 | function fetchStudio(id, us) {
191 |   return fetch(`${util.urls.newAPI}/studios/${id}`)
192 |     .then(res => res.json())
193 |     .then(data => {
194 |       if (data.code === 'NotFound') {
195 |         return {notFound: true}
196 |       }
197 | 
198 |       const studio = {
199 |         title: data.title,
200 |         description: data.description
201 |       }
202 | 
203 |       return fetchExtraStudioData(id, us).then(data => {
204 |         Object.assign(studio, data)
205 |       }).then(() => studio)
206 |     })
207 | }
208 | 
209 | function fetchExtraStudioData(id, us) {
210 |   return fetch(`${util.urls.scratch}/studios/${id}/comments`, {
211 |     headers: util.makeFetchHeaders(us)
212 |   }).then(res => res.text())
213 |     .then(html => parseStudio(html))
214 | }
215 | 
216 | function parseStudio(html) {
217 |   const $ = cheerio.load(html)
218 | 
219 |   const scripts = $('script').map((i, scriptEl) => $(scriptEl).html()).get()
220 | 
221 |   return {
222 |     projectCount: $('span[data-count=projects]').text(), // This is a string!
223 |     areWeAnOwner: scripts.some(str => str.match(/Scratch\.INIT_DATA\.GALLERY = \{[\s\S]*?is_owner: true/)),
224 |     commentsEnabled: !$('.comments-off').length
225 |   }
226 | }
227 | 
228 | function fetchManagers(id, pageNumber = 1) {
229 |   return fetch(`${util.urls.siteAPI}/users/owners-in/${id}/${pageNumber}/`)
230 |     .then(res => res.text())
231 |     .then(html => parseMembers(html))
232 | }
233 | 
234 | function fetchCurators(id, pageNumber = 1) {
235 |   return fetch(`${util.urls.siteAPI}/users/curators-in/${id}/${pageNumber}/`)
236 |     .then(res => res.text())
237 |     .then(html => parseMembers(html))
238 | }
239 | 
240 | function parseMembers(html) {
241 |   const $ = cheerio.load(html)
242 | 
243 |   if ($('#page-404').length) {
244 |     return []
245 |   }
246 | 
247 |   return $('li').map((i, memberEl) => {
248 |     return {
249 |       username: $(memberEl).find('.title a').text()
250 |     }
251 |   }).get()
252 | }
253 | 
254 | function fetchProjects(id, pageNumber = 1) {
255 |   return fetch(`${util.urls.siteAPI}/projects/in/${id}/${pageNumber}/`)
256 |     .then(res => res.text())
257 |     .then(html => parseProjects(html))
258 | }
259 | 
260 | function parseProjects(html) {
261 |   const $ = cheerio.load(html)
262 | 
263 |   if ($('#page-404').length) {
264 |     return []
265 |   }
266 | 
267 |   return $('li').map((i, projectEl) => {
268 |     return {
269 |       name: $(projectEl).find('.title a').text(),
270 |       author: $(projectEl).find('.owner a').text(),
271 |       id: $(projectEl).find('.title a').attr('href').match(/[0-9]+/)[0]
272 |     }
273 |   }).get()
274 | }
275 | 
276 | function fetchActivity(id, pageNumber = 1) {
277 |   return fetch(`${util.urls.scratch}/studios/${id}/activity/${pageNumber}/`)
278 |     .then(res => res.text())
279 |     .then(html => parseActivity(html))
280 | }
281 | 
282 | function parseActivity(html) {
283 |   const $ = cheerio.load(html)
284 | 
285 |   if ($('#page-404').length) {
286 |     return []
287 |   }
288 | 
289 |   return $('#tabs-content li').map((i, activityEl) => {
290 |     return {
291 |       actor: $(activityEl).find('a').first().text(),
292 |       did: $(activityEl).contents().filter((i, x) => x.nodeType === 3).text().trim(),
293 |       what: {
294 |         title: $(activityEl).find('a').last().text(),
295 |         href: $(activityEl).find('a').last().attr('href')
296 |       }
297 |     }
298 |   }).get()
299 | }
300 | 
301 | function formatActivity(a) {
302 |   let text = ''
303 |   text += `\x1b[34;1m${a.actor}\x1b[0m `
304 | 
305 |   if (['added the project', 'removed the project'].includes(a.did)) {
306 |     text += a.did
307 |     text += ` \x1b[33;1m${a.what.title}\x1b[0m`
308 |   } else if (a.did.startsWith('was promoted to manager')) {
309 |     text += 'was promoted to manager by'
310 |     text += ` \x1b[34;1m${a.what.title}\x1b[0m`
311 |   } else if (a.did.startsWith('accepted')) {
312 |     text += 'accepted an invitation from'
313 |     text += ` \x1b[34;1m${a.what.title}\x1b[0m`
314 |   } else if (a.did.startsWith('made edits to title or')) {
315 |     text += a.did
316 |   } else if (a.did === 'left a') {
317 |     text += 'left a comment'
318 |   }
319 | 
320 |   return text
321 | }
322 | 
--------------------------------------------------------------------------------
/lib/thumbs.js:
--------------------------------------------------------------------------------
 1 | 'use strict'
 2 | 
 3 | const cheerio = require('cheerio')
 4 | const fetch = require('node-fetch')
 5 | const list = require('./list')
 6 | const pagedList = require('./paged-list')
 7 | const projects = require('./projects')
 8 | const util = require('./util')
 9 | 
10 | module.exports.browsePaged = async function({rl, us, urlPart, title, handleItem, formatItem}) {
11 |   const { pageCount, totalCount } = await fetchThumbs(urlPart)
12 |   await pagedList.browse({
13 |     rl, us, handleItem, formatItem, pageCount,
14 |     title: `${title} (${totalCount})`,
15 |     getItems: async n => (await fetchThumbs(urlPart, n)).items
16 |   })
17 | }
18 | 
19 | module.exports.browseUnpaged = async function({rl, us, urlPart, title, handleItem, formatItem}) {
20 |   const { totalCount } = await fetchThumbs(urlPart)
21 |   await list.browse({
22 |     rl, us, handleItem, formatItem,
23 |     title: `${title} (${totalCount})`,
24 |     items: (await fetchThumbs(urlPart)).items,
25 |   })
26 | }
27 | 
28 | function fetchThumbs(urlPart, pageNumber = 1) {
29 |   return fetch(`${util.urls.scratch}${urlPart}?page=${pageNumber}`)
30 |     .then(res => res.text())
31 |     .then(html => parseThumbs(html))
32 | }
33 | 
34 | function parseThumbs(html) {
35 |   const $ = cheerio.load(html)
36 | 
37 |   let totalCount = $('h2').text().trim().match(/([0-9]+)\)$/)
38 |   totalCount = totalCount ? totalCount[1] : $('.thumb').length
39 |   return {
40 |     items: $('.thumb').map((i, thumbEl) => {
41 |       return {
42 |         title: $(thumbEl).find('.title a').text().trim(),
43 |         href: $(thumbEl).find('.title a').attr('href')
44 |       }
45 |     }).get(),
46 |     pageCount: $('.page-links').length ? parseInt($('.page-current').last().text().trim()) : 1,
47 |     totalCount
48 |   }
49 | }
50 | 
--------------------------------------------------------------------------------
/lib/util.js:
--------------------------------------------------------------------------------
  1 | 'use strict'
  2 | 
  3 | const fetch = require('node-fetch')
  4 | const npmCommandExists = require('command-exists')
  5 | const npmWrap = require('word-wrap')
  6 | const { spawn } = require('child_process')
  7 | const homepage = require('./homepage')
  8 | const messages = require('./messages')
  9 | const profiles = require('./profiles')
 10 | const projects = require('./projects')
 11 | const studios = require('./studios')
 12 | 
 13 | module.exports.urls = {}
 14 | module.exports.urls.scratch = 'https://scratch.mit.edu'
 15 | module.exports.urls.siteAPI = 'https://scratch.mit.edu/site-api'
 16 | module.exports.urls.newAPI = 'https://api.scratch.mit.edu'
 17 | module.exports.urls.cdn2 = 'http://cdn2.scratch.mit.edu' // HTTP because ImageMagick doesn't like HTTPS :(
 18 | module.exports.urls.projectThumb = id => `${module.exports.urls.cdn2}/get_image/project/${id}_480x360.png`
 19 | module.exports.urls.studioThumb = id => `${module.exports.urls.cdn2}/get_image/gallery/${id}_510x300.png`
 20 | module.exports.urls.userThumb = id => `${module.exports.urls.cdn2}/get_image/user/${id}_400x400.png`
 21 | 
 22 | module.exports.clearBlankProperties = function(obj) {
 23 |   const newObj = Object.assign({}, obj)
 24 | 
 25 |   for (const [ prop, value ] of Object.entries(newObj)) {
 26 |     if (typeof value === 'undefined') {
 27 |       delete newObj[prop]
 28 |     }
 29 |   }
 30 | 
 31 |   return newObj
 32 | }
 33 | 
 34 | module.exports.trimWhitespace = function(string) {
 35 |   return string.split('\n').map(str => str.trim()).filter(Boolean).join(' ')
 36 | }
 37 | 
 38 | // :)
 39 | let smileSize = 1
 40 | 
 41 | module.exports.smile = function() {
 42 |   return ':' + ')'.repeat(smileSize++)
 43 | }
 44 | 
 45 | module.exports.timeAgo = function(date) {
 46 |   const now = Date.now()
 47 |   const diff = now - date
 48 | 
 49 |   const second = 1000
 50 |   const minute = 60 * second
 51 |   const hour = 60 * minute
 52 |   const day = 24 * hour
 53 | 
 54 |   const days = Math.floor(diff / day)
 55 |   const hours = Math.floor((diff % day) / hour)
 56 |   const minutes = Math.floor((diff % hour) / minute)
 57 |   const seconds = Math.floor((diff % minute) / second)
 58 | 
 59 |   let str
 60 |   if (days) {
 61 |     str = days + ' day'
 62 |     if (days > 1) {
 63 |       str += 's'
 64 |     }
 65 |   } else if (hours) {
 66 |     str = hours + 'h'
 67 |     if (minutes) {
 68 |       str += ', ' + minutes + 'm'
 69 |     }
 70 |   } else if (minutes) {
 71 |     str = minutes + 'm'
 72 |     if (seconds) {
 73 |       str += ', ' + seconds + 's'
 74 |     }
 75 |   } else {
 76 |     return 'now'
 77 |   }
 78 |   str += ' ago'
 79 |   return str
 80 | }
 81 | 
 82 | const commandCache = {}
 83 | module.exports.commandExists = async function commandExists(command) {
 84 |   // When the command-exists module sees that a given command doesn't exist, it
 85 |   // throws an error instead of returning false, which is not what we want.
 86 | 
 87 |   if (!(command in commandCache)) {
 88 |     try {
 89 |       commandCache[command] = await npmCommandExists(command)
 90 |     } catch(err) {
 91 |       commandCache[command] = false
 92 |     }
 93 |   }
 94 | 
 95 |   return commandCache[command]
 96 | }
 97 | 
 98 | module.exports.canShowImages = function() {
 99 |   // Async!
100 |   return module.exports.commandExists('display')
101 | }
102 | 
103 | module.exports.showImage = async function(url) {
104 |   if (await module.exports.canShowImages()) {
105 |     console.log('Opening image:', url)
106 |     spawn('display', [url])
107 |   } else {
108 |     console.log('View the image here:', url)
109 |   }
110 | }
111 | 
112 | module.exports.prompt = function(rl, question = 'prompt: ', defaultValue = '') {
113 |   return new Promise(resolve => {
114 |     rl.question(`\x1b[35;1m${question}\x1b[0m`, answer => {
115 |       resolve(answer)
116 |     })
117 | 
118 |     if (defaultValue) {
119 |       rl.write(defaultValue)
120 |     }
121 |   })
122 | }
123 | 
124 | module.exports.confirm = async function(rl, question = 'confirm: ') {
125 |   const response = await module.exports.prompt(rl, question)
126 |   return ['yes', 'y'].includes(response.toLowerCase())
127 | }
128 | 
129 | module.exports.choose = function({rl, us}, choiceDict) {
130 |   if (!('#' in choiceDict)) {
131 |     choiceDict['#'] = {
132 |       help: 'Menu - go somewhere or do something.',
133 |       longcodes: ['menu'],
134 |       action: () => showGlobalMenu({rl, us})
135 |     }
136 |   }
137 | 
138 |   choiceDict = module.exports.clearBlankProperties(choiceDict)
139 | 
140 |   return new Promise(resolve => {
141 |     const keys = Object.keys(choiceDict)
142 |     const visibleKeys = keys.filter(k => choiceDict[k].invisible !== true)
143 |     const promptString = (
144 |       '[' +
145 |       (visibleKeys.every(k => k.length === 1) ? visibleKeys.join('') : visibleKeys.reduce((acc, key) => {
146 |         return acc + '|' + key
147 |       })) +
148 |       '] '
149 |     )
150 | 
151 |     const recursive = function() {
152 |       rl.question(promptString, answer => {
153 |         if (answer === '?' || answer === 'help') {
154 |           console.log('')
155 |           for (const [ key, { longcodes, help } ] of Object.entries(choiceDict)) {
156 |             if (help) {
157 |               console.log(`- \x1b[34;1m${key + (longcodes ? ` (${longcodes.join(', ')})` : '')}:\x1b[0m ${help}`)
158 |             }
159 |           }
160 |           console.log('')
161 |           recursive()
162 |           return
163 |         }
164 | 
165 |         const match = (
166 |           keys.includes(answer) ? choiceDict[answer] :
167 |           Object.values(choiceDict).find(
168 |             o => o.longcodes && o.longcodes.includes(
169 |               answer.replace(/[- ]/g, '')
170 |             )
171 |           )
172 |         )
173 | 
174 |         if (match) {
175 |           console.log('')
176 |           resolve(match.action(answer))
177 |         } else {
178 |           recursive()
179 |         }
180 |       })
181 |     }
182 | 
183 |     recursive()
184 |   })
185 | }
186 | 
187 | async function showGlobalMenu({rl, us}) {
188 |   await module.exports.choose({rl, us}, {
189 |     '#': undefined,
190 | 
191 |     q: {
192 |       help: 'Go back to what you were doing or browsing before.',
193 |       longcodes: ['quit', 'back'],
194 |       action: () => {}
195 |     },
196 | 
197 |     h: {
198 |       help: 'Go to the homepage.',
199 |       longcodes: ['home', 'homepage'],
200 |       action: () => homepage.browse({rl, us})
201 |     },
202 | 
203 |     u: {
204 |       help: 'Browse a \x1b[34;1muser\x1b[0m by entering their username.',
205 |       longcodes: ['user', 'profile'],
206 |       action: async () => {
207 |         const username = await module.exports.prompt(rl, 'Username? ')
208 |         if (username) {
209 |           await profiles.browse({rl, us, username})
210 |         }
211 |       }
212 |     },
213 | 
214 |     p: {
215 |       help: 'Browse a \x1b[33;1mproject\x1b[0m by entering its ID.',
216 |       longcodes: ['project'],
217 |       action: async () => {
218 |         const id = await module.exports.prompt(rl, 'Project ID? ')
219 |         if (id) {
220 |           await projects.browse({rl, us, id})
221 |         }
222 |       }
223 |     },
224 | 
225 |     s: {
226 |       help: 'Browse a \x1b[32;1mstudio\x1b[0m by entering its ID.',
227 |       longcodes: ['studio'],
228 |       action: async () => {
229 |         const id = await module.exports.prompt(rl, 'Studio ID? ')
230 |         if (id) {
231 |           await studios.browse({rl, us, id})
232 |         }
233 |       }
234 |     },
235 | 
236 |     m: {
237 |       help: 'View your messages.',
238 |       longcodes: ['messages'],
239 |       action: () => messages.browse({rl, us})
240 |     }
241 |   })
242 | }
243 | 
244 | module.exports.getSessionData = function(us) {
245 |   return fetch(module.exports.urls.scratch + '/session', {
246 |     headers: {
247 |       'Cookie': `scratchsessionsid=${us.sessionId}`,
248 |       'X-Requested-With': 'XMLHttpRequest'
249 |     }
250 |   }).then(res => res.json())
251 | }
252 | 
253 | module.exports.getAuthToken = function(us) {
254 |   if (us.authToken) {
255 |     return Promise.resolve(us.authToken)
256 |   }
257 | 
258 |   return module.exports.getSessionData(us).then(obj => {
259 |     return (us.authToken = obj.user.token)
260 |   })
261 | }
262 | 
263 | module.exports.makeFetchHeaders = function(us) {
264 |   return {
265 |     'Cookie': `scratchsessionsid=${us.sessionId}; scratchcsrftoken=a;`,
266 |     'X-CSRFToken': 'a',
267 |     'referer': 'https://scratch.mit.edu'
268 |   }
269 | }
270 | 
271 | module.exports.prettyFetch = async function({
272 |   ing = 'Updating', ed = 'Updated',
273 |   success = 200
274 | }, ...fetchArgs) {
275 |   process.stdout.write('Updating...')
276 |   const res = await fetch(...fetchArgs)
277 |   if (res.status === success) {
278 |     console.log(' \x1b[32mUpdated!\x1b[0m')
279 |     return true
280 |   } else {
281 |     console.log(' \x1b[31mFailed, sorry.\x1b[0m')
282 |     return false
283 |   }
284 | }
285 | 
286 | module.exports.delay = function(ms = 800) {
287 |   return new Promise(res => setTimeout(res, ms))
288 | }
289 | 
290 | module.exports.delay.notFound = 1600
291 | 
292 | module.exports.wrap = function(string) {
293 |   // Pre-wrap processing to trim out excessive whitespace. See issue #34 (e.g. project 261210105).
294 |   string = (string
295 |     // Replace whitespace-only lines with actually blank lines (just \n).
296 |     .replace(/^\s*?$/gm, '')
297 | 
298 |     // Replace 4+ blank lines with a message showing that not all the lines are shown.
299 |     .replace(/\n(\n{3,})\n/g, (match, group) => {
300 |       const count = group.length - 2
301 |       const linesWord = count === 1 ? 'line' : 'lines'
302 |       return `\n\n\x1b[2m(${count} more blank ${linesWord} not displayed)\x1b[0m\n\n`
303 |     })
304 | 
305 |     // Replace 4+ of the same non-\n whitespace character with just three of that character.
306 |     .replace(/([^\S\n])(\1{3,})/g, '$1$1$1'))
307 | 
308 |   // Actually wrap the string!
309 |   return npmWrap(string, {width: 80})
310 | }
311 | 
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "name": "scratch-client-omg",
  3 |   "version": "1.0.0",
  4 |   "lockfileVersion": 1,
  5 |   "requires": true,
  6 |   "dependencies": {
  7 |     "@types/node": {
  8 |       "version": "9.4.0",
  9 |       "resolved": "https://registry.npmjs.org/@types/node/-/node-9.4.0.tgz",
 10 |       "integrity": "sha512-zkYho6/4wZyX6o9UQ8rd0ReEaiEYNNCqYFIAACe2Tf9DrYlgzWW27OigYHnnztnnZQwVRpwWmZKegFmDpinIsA=="
 11 |     },
 12 |     "async": {
 13 |       "version": "0.2.10",
 14 |       "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
 15 |       "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E="
 16 |     },
 17 |     "async-limiter": {
 18 |       "version": "1.0.0",
 19 |       "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz",
 20 |       "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg=="
 21 |     },
 22 |     "balanced-match": {
 23 |       "version": "1.0.0",
 24 |       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
 25 |       "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
 26 |     },
 27 |     "boolbase": {
 28 |       "version": "1.0.0",
 29 |       "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
 30 |       "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24="
 31 |     },
 32 |     "brace-expansion": {
 33 |       "version": "1.1.8",
 34 |       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz",
 35 |       "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=",
 36 |       "requires": {
 37 |         "balanced-match": "^1.0.0",
 38 |         "concat-map": "0.0.1"
 39 |       }
 40 |     },
 41 |     "cheerio": {
 42 |       "version": "1.0.0-rc.2",
 43 |       "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz",
 44 |       "integrity": "sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=",
 45 |       "requires": {
 46 |         "css-select": "~1.2.0",
 47 |         "dom-serializer": "~0.1.0",
 48 |         "entities": "~1.1.1",
 49 |         "htmlparser2": "^3.9.1",
 50 |         "lodash": "^4.15.0",
 51 |         "parse5": "^3.0.1"
 52 |       }
 53 |     },
 54 |     "colors": {
 55 |       "version": "0.6.2",
 56 |       "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz",
 57 |       "integrity": "sha1-JCP+ZnisDF2uiFLl0OW+CMmXq8w="
 58 |     },
 59 |     "command-exists": {
 60 |       "version": "1.2.6",
 61 |       "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.6.tgz",
 62 |       "integrity": "sha512-Qst/zUUNmS/z3WziPxyqjrcz09pm+2Knbs5mAZL4VAE0sSrNY1/w8+/YxeHcoBTsO6iojA6BW7eFf27Eg2MRuw=="
 63 |     },
 64 |     "concat-map": {
 65 |       "version": "0.0.1",
 66 |       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
 67 |       "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
 68 |     },
 69 |     "core-util-is": {
 70 |       "version": "1.0.2",
 71 |       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
 72 |       "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
 73 |     },
 74 |     "css-select": {
 75 |       "version": "1.2.0",
 76 |       "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
 77 |       "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=",
 78 |       "requires": {
 79 |         "boolbase": "~1.0.0",
 80 |         "css-what": "2.1",
 81 |         "domutils": "1.5.1",
 82 |         "nth-check": "~1.0.1"
 83 |       }
 84 |     },
 85 |     "css-what": {
 86 |       "version": "2.1.0",
 87 |       "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz",
 88 |       "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0="
 89 |     },
 90 |     "cycle": {
 91 |       "version": "1.0.3",
 92 |       "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz",
 93 |       "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI="
 94 |     },
 95 |     "deep-equal": {
 96 |       "version": "1.0.1",
 97 |       "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz",
 98 |       "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU="
 99 |     },
100 |     "dom-serializer": {
101 |       "version": "0.1.0",
102 |       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz",
103 |       "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=",
104 |       "requires": {
105 |         "domelementtype": "~1.1.1",
106 |         "entities": "~1.1.1"
107 |       },
108 |       "dependencies": {
109 |         "domelementtype": {
110 |           "version": "1.1.3",
111 |           "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz",
112 |           "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs="
113 |         }
114 |       }
115 |     },
116 |     "domelementtype": {
117 |       "version": "1.3.0",
118 |       "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz",
119 |       "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI="
120 |     },
121 |     "domhandler": {
122 |       "version": "2.4.1",
123 |       "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.1.tgz",
124 |       "integrity": "sha1-iS5HAAqZvlW783dP/qBWHYh5wlk=",
125 |       "requires": {
126 |         "domelementtype": "1"
127 |       }
128 |     },
129 |     "domutils": {
130 |       "version": "1.5.1",
131 |       "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
132 |       "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=",
133 |       "requires": {
134 |         "dom-serializer": "0",
135 |         "domelementtype": "1"
136 |       }
137 |     },
138 |     "encoding": {
139 |       "version": "0.1.12",
140 |       "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
141 |       "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
142 |       "requires": {
143 |         "iconv-lite": "~0.4.13"
144 |       }
145 |     },
146 |     "entities": {
147 |       "version": "1.1.1",
148 |       "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz",
149 |       "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA="
150 |     },
151 |     "eyes": {
152 |       "version": "0.1.8",
153 |       "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
154 |       "integrity": "sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A="
155 |     },
156 |     "fs.realpath": {
157 |       "version": "1.0.0",
158 |       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
159 |       "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
160 |     },
161 |     "glob": {
162 |       "version": "7.1.2",
163 |       "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
164 |       "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
165 |       "requires": {
166 |         "fs.realpath": "^1.0.0",
167 |         "inflight": "^1.0.4",
168 |         "inherits": "2",
169 |         "minimatch": "^3.0.4",
170 |         "once": "^1.3.0",
171 |         "path-is-absolute": "^1.0.0"
172 |       }
173 |     },
174 |     "htmlparser2": {
175 |       "version": "3.9.2",
176 |       "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz",
177 |       "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=",
178 |       "requires": {
179 |         "domelementtype": "^1.3.0",
180 |         "domhandler": "^2.3.0",
181 |         "domutils": "^1.5.1",
182 |         "entities": "^1.1.1",
183 |         "inherits": "^2.0.1",
184 |         "readable-stream": "^2.0.2"
185 |       }
186 |     },
187 |     "i": {
188 |       "version": "0.3.6",
189 |       "resolved": "https://registry.npmjs.org/i/-/i-0.3.6.tgz",
190 |       "integrity": "sha1-2WyScyB28HJxG2sQ/X1PZa2O4j0="
191 |     },
192 |     "iconv-lite": {
193 |       "version": "0.4.19",
194 |       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz",
195 |       "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ=="
196 |     },
197 |     "inflight": {
198 |       "version": "1.0.6",
199 |       "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
200 |       "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
201 |       "requires": {
202 |         "once": "^1.3.0",
203 |         "wrappy": "1"
204 |       }
205 |     },
206 |     "inherits": {
207 |       "version": "2.0.3",
208 |       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
209 |       "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
210 |     },
211 |     "is-stream": {
212 |       "version": "1.1.0",
213 |       "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
214 |       "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
215 |     },
216 |     "isarray": {
217 |       "version": "1.0.0",
218 |       "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
219 |       "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
220 |     },
221 |     "isstream": {
222 |       "version": "0.1.2",
223 |       "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
224 |       "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
225 |     },
226 |     "lodash": {
227 |       "version": "4.17.10",
228 |       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz",
229 |       "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg=="
230 |     },
231 |     "minimatch": {
232 |       "version": "3.0.4",
233 |       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
234 |       "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
235 |       "requires": {
236 |         "brace-expansion": "^1.1.7"
237 |       }
238 |     },
239 |     "minimist": {
240 |       "version": "0.0.8",
241 |       "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
242 |       "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
243 |     },
244 |     "mkdirp": {
245 |       "version": "0.5.1",
246 |       "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
247 |       "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
248 |       "requires": {
249 |         "minimist": "0.0.8"
250 |       }
251 |     },
252 |     "mute-stream": {
253 |       "version": "0.0.7",
254 |       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
255 |       "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
256 |     },
257 |     "ncp": {
258 |       "version": "0.4.2",
259 |       "resolved": "https://registry.npmjs.org/ncp/-/ncp-0.4.2.tgz",
260 |       "integrity": "sha1-q8xsvT7C7Spyn/bnwfqPAXhKhXQ="
261 |     },
262 |     "node-fetch": {
263 |       "version": "1.7.3",
264 |       "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
265 |       "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
266 |       "requires": {
267 |         "encoding": "^0.1.11",
268 |         "is-stream": "^1.0.1"
269 |       }
270 |     },
271 |     "nth-check": {
272 |       "version": "1.0.1",
273 |       "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz",
274 |       "integrity": "sha1-mSms32KPwsQQmN6rgqxYDPFJquQ=",
275 |       "requires": {
276 |         "boolbase": "~1.0.0"
277 |       }
278 |     },
279 |     "once": {
280 |       "version": "1.4.0",
281 |       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
282 |       "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
283 |       "requires": {
284 |         "wrappy": "1"
285 |       }
286 |     },
287 |     "parse5": {
288 |       "version": "3.0.3",
289 |       "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz",
290 |       "integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==",
291 |       "requires": {
292 |         "@types/node": "*"
293 |       }
294 |     },
295 |     "path-is-absolute": {
296 |       "version": "1.0.1",
297 |       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
298 |       "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
299 |     },
300 |     "pkginfo": {
301 |       "version": "0.4.1",
302 |       "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.4.1.tgz",
303 |       "integrity": "sha1-tUGO8EOd5UJfxJlQQtztFPsqhP8="
304 |     },
305 |     "process-nextick-args": {
306 |       "version": "1.0.7",
307 |       "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
308 |       "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
309 |     },
310 |     "prompt": {
311 |       "version": "0.2.14",
312 |       "resolved": "https://registry.npmjs.org/prompt/-/prompt-0.2.14.tgz",
313 |       "integrity": "sha1-V3VPZPVD/XsIRXB8gY7OYY8F/9w=",
314 |       "requires": {
315 |         "pkginfo": "0.x.x",
316 |         "read": "1.0.x",
317 |         "revalidator": "0.1.x",
318 |         "utile": "0.2.x",
319 |         "winston": "0.8.x"
320 |       }
321 |     },
322 |     "read": {
323 |       "version": "1.0.7",
324 |       "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
325 |       "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=",
326 |       "requires": {
327 |         "mute-stream": "~0.0.4"
328 |       }
329 |     },
330 |     "readable-stream": {
331 |       "version": "2.3.3",
332 |       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz",
333 |       "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==",
334 |       "requires": {
335 |         "core-util-is": "~1.0.0",
336 |         "inherits": "~2.0.3",
337 |         "isarray": "~1.0.0",
338 |         "process-nextick-args": "~1.0.6",
339 |         "safe-buffer": "~5.1.1",
340 |         "string_decoder": "~1.0.3",
341 |         "util-deprecate": "~1.0.1"
342 |       }
343 |     },
344 |     "revalidator": {
345 |       "version": "0.1.8",
346 |       "resolved": "https://registry.npmjs.org/revalidator/-/revalidator-0.1.8.tgz",
347 |       "integrity": "sha1-/s5hv6DBtSoga9axgZgYS91SOjs="
348 |     },
349 |     "rimraf": {
350 |       "version": "2.6.2",
351 |       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz",
352 |       "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
353 |       "requires": {
354 |         "glob": "^7.0.5"
355 |       }
356 |     },
357 |     "safe-buffer": {
358 |       "version": "5.1.1",
359 |       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
360 |       "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="
361 |     },
362 |     "scratch-api": {
363 |       "version": "1.1.8",
364 |       "resolved": "https://registry.npmjs.org/scratch-api/-/scratch-api-1.1.8.tgz",
365 |       "integrity": "sha512-pk3yuS9SKgW9khZTRSfcX33Ho41EhJs/qChxplnt/tYgPsNBtD5ZzMgxh1sNYOTUKskzf0AZOR/nk/idk4zY5A==",
366 |       "requires": {
367 |         "prompt": "^0.2.14",
368 |         "ws": "^3.1.0"
369 |       }
370 |     },
371 |     "stack-trace": {
372 |       "version": "0.0.10",
373 |       "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
374 |       "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA="
375 |     },
376 |     "string_decoder": {
377 |       "version": "1.0.3",
378 |       "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz",
379 |       "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==",
380 |       "requires": {
381 |         "safe-buffer": "~5.1.0"
382 |       }
383 |     },
384 |     "ultron": {
385 |       "version": "1.1.1",
386 |       "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz",
387 |       "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og=="
388 |     },
389 |     "util-deprecate": {
390 |       "version": "1.0.2",
391 |       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
392 |       "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
393 |     },
394 |     "utile": {
395 |       "version": "0.2.1",
396 |       "resolved": "https://registry.npmjs.org/utile/-/utile-0.2.1.tgz",
397 |       "integrity": "sha1-kwyI6ZCY1iIINMNWy9mncFItkNc=",
398 |       "requires": {
399 |         "async": "~0.2.9",
400 |         "deep-equal": "*",
401 |         "i": "0.3.x",
402 |         "mkdirp": "0.x.x",
403 |         "ncp": "0.4.x",
404 |         "rimraf": "2.x.x"
405 |       }
406 |     },
407 |     "winston": {
408 |       "version": "0.8.3",
409 |       "resolved": "https://registry.npmjs.org/winston/-/winston-0.8.3.tgz",
410 |       "integrity": "sha1-ZLar9M0Brcrv1QCTk7HY6L7BnbA=",
411 |       "requires": {
412 |         "async": "0.2.x",
413 |         "colors": "0.6.x",
414 |         "cycle": "1.0.x",
415 |         "eyes": "0.1.x",
416 |         "isstream": "0.1.x",
417 |         "pkginfo": "0.3.x",
418 |         "stack-trace": "0.0.x"
419 |       },
420 |       "dependencies": {
421 |         "pkginfo": {
422 |           "version": "0.3.1",
423 |           "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz",
424 |           "integrity": "sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE="
425 |         }
426 |       }
427 |     },
428 |     "word-wrap": {
429 |       "version": "1.2.3",
430 |       "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
431 |       "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ=="
432 |     },
433 |     "wrappy": {
434 |       "version": "1.0.2",
435 |       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
436 |       "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
437 |     },
438 |     "ws": {
439 |       "version": "3.3.3",
440 |       "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz",
441 |       "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==",
442 |       "requires": {
443 |         "async-limiter": "~1.0.0",
444 |         "safe-buffer": "~5.1.0",
445 |         "ultron": "~1.1.0"
446 |       }
447 |     }
448 |   }
449 | }
450 | 
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "scratch-client-omg",
 3 |   "version": "1.0.0",
 4 |   "description": "Not-so-powerful terminal client for Scratch community website",
 5 |   "main": "bin/scratch-client.js",
 6 |   "bin": {
 7 |     "scratch-client": "bin/scratch-client.js"
 8 |   },
 9 |   "dependencies": {
10 |     "cheerio": "^1.0.0-rc.2",
11 |     "command-exists": "^1.2.6",
12 |     "node-fetch": "^1.7.3",
13 |     "scratch-api": "^1.1.8",
14 |     "word-wrap": "^1.2.3"
15 |   },
16 |   "devDependencies": {},
17 |   "repository": {
18 |     "type": "git",
19 |     "url": "git+https://github.com/towerofnix/scratch-client-omg.git"
20 |   },
21 |   "license": "GPL-3.0",
22 |   "bugs": {
23 |     "url": "https://github.com/towerofnix/scratch-client-omg/issues"
24 |   },
25 |   "homepage": "https://github.com/towerofnix/scratch-client-omg#readme"
26 | }
27 | 
--------------------------------------------------------------------------------